clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix/175-template-quality-pass
lofi / package / runtime / schema-compat_test.ts
schema-compat_test.ts 12.6K
1import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";2import { createDiagnostics } from "./diagnostics.ts";3import type { PwaState } from "./pwa.ts";4import {5 createSchemaCompatGate,6 SchemaCompatibilityError,7 type SchemaCompatState,8 type SchemaCompatUpdateSurface,9} from "./schema-compat.ts";10import { type TableMutationEnvironment, TableMutationRegistry } from "./table-mutations.ts";11import { assert } from "./test-assert.ts";12import { createMemoryJournalStorage } from "./write-journal.ts";13import { WriteLedger } from "./write-ledger.ts";1415class FakeStorage {16 readonly values = new Map<string, string>();17 getItem(key: string): string | null {18 return this.values.get(key) ?? null;19 }20 setItem(key: string, value: string): void {21 this.values.set(key, value);22 }23}2425class FakeUpdateSurface implements SchemaCompatUpdateSurface {26 checkForUpdateCalls = 0;27 #state: PwaState = { worker: "ready", install: "unsupported", update: "idle" };28 readonly #subscribers = new Set<(state: PwaState) => void>();2930 getState(): PwaState {31 return this.#state;32 }33 subscribe(subscriber: (state: PwaState) => void): () => void {34 this.#subscribers.add(subscriber);35 subscriber(this.#state);36 return () => this.#subscribers.delete(subscriber);37 }38 checkForUpdate(): Promise<boolean> {39 this.checkForUpdateCalls += 1;40 return Promise.resolve(true);41 }42 setUpdate(update: PwaState["update"]): void {43 this.#state = { ...this.#state, update };44 for (const subscriber of this.#subscribers) subscriber(this.#state);45 }46}4748const olderBundle = { v: 1, revision: "old", head: "v1:a", lineage: ["v1:a"] };49const newerBundle = { v: 1, revision: "new", head: "v1:b", lineage: ["v1:a", "v1:b"] };50const newerRecord = JSON.stringify({ v: 1, head: "v1:b", lineage: ["v1:a", "v1:b"] });51const storageKey = "lofi:schema-version:test";5253function gateUnderTest(options: {54 manifest?: unknown;55 loadManifest?: () => Promise<unknown>;56 storage?: FakeStorage;57 surface?: FakeUpdateSurface;58 storageEvents?: EventTarget;59}) {60 const storage = options.storage ?? new FakeStorage();61 const surface = options.surface ?? new FakeUpdateSurface();62 const gate = createSchemaCompatGate({63 production: () => true,64 loadManifest: options.loadManifest ?? (() => Promise.resolve(options.manifest ?? null)),65 storage: () => storage,66 storageKey: () => storageKey,67 storageEvents: () => options.storageEvents,68 controller: () => surface,69 });70 return { gate, storage, surface };71}7273function waitForState(74 gate: ReturnType<typeof createSchemaCompatGate>,75 predicate: (state: SchemaCompatState) => boolean,76): Promise<SchemaCompatState> {77 return new Promise((resolve) => {78 const unsubscribe = gate.subscribe((state) => {79 if (!predicate(state)) return;80 unsubscribe();81 resolve(state);82 });83 });84}8586Deno.test("first boot is compatible and the runtime stamps the local version", async () => {87 const { gate, storage } = gateUnderTest({ manifest: olderBundle });88 const compatible = waitForState(gate, (state) => state.state === "compatible");89 gate.start();90 const state = await compatible;91 assert(92 state.state === "compatible" && state.classification === "first-boot",93 "an unstamped store was not classified first-boot",94 );95 await gate.assertWritable();96 gate.markRuntimeWritable();97 const record = JSON.parse(storage.getItem(storageKey) ?? "null");98 assert(record?.head === "v1:a", "a writable first boot did not stamp the local version");99});100101Deno.test("code ahead of data migrates forward and advances the stamp", async () => {102 const storage = new FakeStorage();103 storage.setItem(storageKey, JSON.stringify({ v: 1, head: "v1:a", lineage: ["v1:a"] }));104 const { gate } = gateUnderTest({ manifest: newerBundle, storage });105 const compatible = waitForState(gate, (state) => state.state === "compatible");106 gate.start();107 const state = await compatible;108 assert(109 state.state === "compatible" && state.classification === "code-ahead",110 "a newer bundle over older data was not code-ahead",111 );112 await gate.assertWritable();113 gate.markRuntimeWritable();114 const record = JSON.parse(storage.getItem(storageKey) ?? "null");115 assert(record?.head === "v1:b", "the stamp did not advance to the new head");116 assert(record?.lineage?.includes("v1:a"), "the advanced stamp lost its lineage");117});118119Deno.test("data ahead of code refuses writes, keeps the stamp, and prompts an update check", async () => {120 const storage = new FakeStorage();121 storage.setItem(storageKey, newerRecord);122 const { gate, surface } = gateUnderTest({ manifest: olderBundle, storage });123 const dataAhead = waitForState(gate, (state) => state.state === "data-ahead");124 gate.start();125 const state = await dataAhead;126 assert(state.state === "data-ahead" && state.reason === "schema", "wrong data-ahead reason");127 assert(state.message.includes("Update the app"), "the diagnostic does not name the remediation");128 let refused: unknown;129 try {130 await gate.assertWritable();131 } catch (error) {132 refused = error;133 }134 assert(refused instanceof SchemaCompatibilityError, "writes were not refused");135 gate.markRuntimeWritable();136 assert(storage.getItem(storageKey) === newerRecord, "a read-only boot rewrote the stamp");137 assert(surface.checkForUpdateCalls === 1, "the gate did not prompt an update check");138});139140Deno.test("an unrelated schema history is treated as data-ahead, never writable", async () => {141 const storage = new FakeStorage();142 storage.setItem(storageKey, JSON.stringify({ v: 1, head: "v1:z", lineage: ["v1:z"] }));143 const { gate } = gateUnderTest({ manifest: olderBundle, storage });144 const dataAhead = waitForState(gate, (state) => state.state === "data-ahead");145 gate.start();146 await dataAhead;147 await gate.assertWritable().then(148 () => {149 throw new Error("an unrelated history allowed writes");150 },151 () => undefined,152 );153});154155Deno.test("a data-ahead gate surfaces an in-flight update as updating", async () => {156 const storage = new FakeStorage();157 storage.setItem(storageKey, newerRecord);158 const { gate, surface } = gateUnderTest({ manifest: olderBundle, storage });159 gate.start();160 await waitForState(gate, (state) => state.state === "data-ahead");161 surface.setUpdate("installing");162 assert(gate.getState().state === "updating", "an installing update was not surfaced");163 surface.setUpdate("ready");164 assert(gate.getState().state === "data-ahead", "a ready update ended the refusal too early");165 surface.setUpdate("applying");166 assert(gate.getState().state === "updating", "an applying update was not surfaced");167 await gate.assertWritable().then(168 () => {169 throw new Error("writes resumed during the update window");170 },171 () => undefined,172 );173});174175Deno.test("development mode and a missing manifest leave the gate inert", async () => {176 const inert = createSchemaCompatGate({ production: () => false });177 inert.start();178 assert(179 inert.getState().state === "unchecked" &&180 (inert.getState() as { reason?: string }).reason === "development",181 "development did not stay unchecked",182 );183 await inert.assertWritable();184185 const { gate } = gateUnderTest({ manifest: null });186 const settled = waitForState(187 gate,188 (state) => state.state === "unchecked" && state.reason === "no-manifest",189 );190 gate.start();191 await settled;192 await gate.assertWritable();193194 const failing = gateUnderTest({ loadManifest: () => Promise.reject(new Error("offline")) });195 const failedSettled = waitForState(196 failing.gate,197 (state) => state.state === "unchecked" && state.reason === "no-manifest",198 );199 failing.gate.start();200 await failedSettled;201 await failing.gate.assertWritable();202});203204Deno.test("a write that races the verdict waits for it", async () => {205 const storage = new FakeStorage();206 storage.setItem(storageKey, newerRecord);207 let resolveManifest: (value: unknown) => void = () => undefined;208 const { gate } = gateUnderTest({209 storage,210 loadManifest: () => new Promise((resolve) => resolveManifest = resolve),211 });212 gate.start();213 const outcome: { value: "pending" | "allowed" | "refused" } = { value: "pending" };214 const write = gate.assertWritable().then(215 () => outcome.value = "allowed",216 () => outcome.value = "refused",217 );218 await Promise.resolve();219 const beforeVerdict = outcome.value;220 assert(beforeVerdict === "pending", "the write did not wait for the pending verdict");221 resolveManifest(olderBundle);222 await write;223 const afterVerdict = outcome.value;224 assert(afterVerdict === "refused", "the settled data-ahead verdict did not refuse the write");225});226227Deno.test("another tab advancing the stamp flips this tab read-only in place", async () => {228 const storage = new FakeStorage();229 const storageEvents = new EventTarget();230 const { gate } = gateUnderTest({ manifest: olderBundle, storage, storageEvents });231 const compatible = waitForState(gate, (state) => state.state === "compatible");232 gate.start();233 await compatible;234 storage.setItem(storageKey, newerRecord);235 const dataAhead = waitForState(gate, (state) => state.state === "data-ahead");236 storageEvents.dispatchEvent(237 Object.assign(new Event("storage"), { key: storageKey }),238 );239 await dataAhead;240});241242Deno.test("a stale tab after a worker swap is read-only with a reload prompt", async () => {243 const { gate } = gateUnderTest({ manifest: olderBundle });244 const compatible = waitForState(gate, (state) => state.state === "compatible");245 gate.start();246 await compatible;247 gate.markStaleTab();248 const state = gate.getState();249 assert(250 state.state === "data-ahead" && state.reason === "stale-tab" &&251 state.message.includes("Reload"),252 "the stale tab was not flipped to a reload prompt",253 );254 await gate.assertWritable().then(255 () => {256 throw new Error("a stale tab allowed writes");257 },258 () => undefined,259 );260});261262// ---------------------------------------------------------------------------263// Integration: an old shell next to newer data. The local record was stamped264// under a migrated schema; a bundle pinned to the older manifest boots, and265// the mutation surface — wired exactly like the production registry — refuses266// the write with the gate's diagnostic.267// ---------------------------------------------------------------------------268269type Row = { id: string; title: string };270type Init = Omit<Row, "id">;271const table = { _table: "records", _schema: {} } as TableProxy<Row, Init>;272273class RefusingDb {274 operations = 0;275 value(): Db {276 return this as unknown as Db;277 }278 onMutationError(_listener: (event: MutationErrorEvent) => void): () => void {279 return () => undefined;280 }281 insert(_table: TableProxy<Row, Init>, values: Init) {282 this.operations += 1;283 return { wait: () => Promise.resolve({ id: "row-1", ...values }) };284 }285}286287Deno.test("an old shell over newer data refuses table writes with the gate diagnostic", async () => {288 const storage = new FakeStorage();289 storage.setItem(storageKey, newerRecord);290 const { gate } = gateUnderTest({ manifest: olderBundle, storage });291 gate.start();292 await waitForState(gate, (state) => state.state === "data-ahead");293294 const db = new RefusingDb();295 const diagnostics = createDiagnostics();296 // Table mutations route through the write ledger, so the production guard297 // seam — the gate check plus the upgrade write lock — lives on the ledger298 // environment; this wires the same shape minus the lock.299 const ledger = new WriteLedger({300 getDb: () => Promise.resolve(db.value()),301 syncConfigured: () => false,302 storage: createMemoryJournalStorage(),303 resolveTable: () => null,304 resolveEffectUnit: () => null,305 subscribeRuntimeRecreation: () => () => undefined,306 updateDiagnostics: (update) => update(diagnostics),307 now: () => 0,308 async guardWrite() {309 await gate.assertWritable();310 },311 });312 const environment: TableMutationEnvironment = {313 getDb: () => Promise.resolve(db.value()),314 syncConfigured: () => false,315 getLedger: () => ledger,316 subscribeRuntimeRecreation: () => () => undefined,317 updateDiagnostics: (update) => update(diagnostics),318 };319 const store = new TableMutationRegistry(environment).acquire(table).store;320 let refused: unknown;321 try {322 await store.insert({ title: "blocked" });323 } catch (error) {324 refused = error;325 }326 assert(refused instanceof SchemaCompatibilityError, "the mutation store allowed the write");327 assert(db.operations === 0, "the refused write still reached the database");328 assert(diagnostics.mutationErrors === 1, "the refusal was not counted in diagnostics");329 assert(330 store.getSnapshot().error?.includes("newer version") === true,331 "the store snapshot does not carry the user-facing diagnostic",332 );333 ledger.dispose();334});