clone

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

feat(pwa): couple bundle revision to schema version — compatibility gate and cross-tab upgrade coordination (#142)

FelineStateMachine committed f039a53
* feat(schema): lofi-owned schema fingerprints and compatibility classification

A stable v1 fingerprint over the compiled (wasm-form) schema — canonical
JSON, column order insensitive, snapshot round-trip safe — plus version
ranges (head + migration lineage) and the containment classification
(first-boot / equal / code-ahead / data-ahead / unrelated) the boot gate
runs. Independent of the sync server's schema hash: both sides of every
comparison are produced here, so only this normalization must stay stable.

* feat(pwa): stamp the revision↔schema manifest into the build and precache

deno task build now writes dist/lofi-schema.json — the build revision plus
the schema range the bundle understands, with the head fingerprinted from
src/schema.ts and the lineage from the committed migration snapshots. The
file joins the precache so an offline shell still knows its own range, and
production validation rejects a manifest that is missing or belongs to a
different build.

* feat(runtime): boot compatibility gate and cross-tab upgrade coordination

On boot the gate compares the bundle's stamped schema range against the
version recorded beside the local data. Code ahead of data stays the normal
path and advances the stamp; data ahead of code refuses table writes with a
clear diagnostic (RuntimeDiagnostics, DeviceStatus, a minimal default
banner) and drives checkForUpdate()/applyUpdate() as the remediation —
reads continue, boot is never hard-failed by the gate.

applyUpdate() now coordinates the worker swap across tabs on the same
scope: upgrade intent broadcasts on a scope-keyed BroadcastChannel, tabs
pause new local writes for a bounded window, and in-flight writes settle
under a scope-keyed shared Web Lock before the waiting worker activates.
Bystander tabs reload by default; pwa.staleTabs: "prompt" keeps them open,
read-only, with a reload prompt. useSchemaCompat exposes the state to apps
that render their own surface; every browser dependency is feature-detected
so zero configuration stays safe.

* docs(deployment): the schema compatibility model, read-only mode, and tab coordination

What the manifest guarantees, what read-only mode means for users, and how
to replace the default banner or keep stale tabs open. The reference app
documents the update-lifecycle knob in place while running the safe
defaults, so the dogfood app exercises the default banner path.

* chore(reference): refresh the lockfile's linked package version
commit
f039a53706ad3c096e3c5dc1c1c6d70405e677d2
parent
67572fbb844a9773a7afb120df35e6e152471246
author
FelineStateMachine <dami.etoile@gmail.com>
30 files changed, +2205 -26
Mapps/reference/deno.lock2+-
Mapps/reference/src/app.ts6+
Mdeno.json2+-
Mdocs/deployment.md57++++-
Mpackage/astro/mod.ts4+
Mpackage/commands/build.ts15+
Mpackage/preact/DeviceStatus.tsx9+
Mpackage/preact/mod.ts5+
Apackage/preact/use-schema-compat.ts31++
Mpackage/runtime/app.ts20+
Mpackage/runtime/boot.ts39+++-
Mpackage/runtime/diagnostics.ts3+
Mpackage/runtime/mod.ts8+
Mpackage/runtime/pwa.ts76+++++-
Mpackage/runtime/pwa_test.ts82++++++
Mpackage/runtime/runtime.ts17+
Apackage/runtime/schema-compat.ts407++++++++++++++++++++++++++++++
Apackage/runtime/schema-compat_test.ts317+++++++++++++++++++++++
Mpackage/runtime/table-mutations.ts38+++-
Mpackage/runtime/table-store.ts33++-
Apackage/runtime/upgrade-coordination.ts233+++++++++++++++++
Apackage/runtime/upgrade-coordination_test.ts255+++++++++++++++++++
Apackage/schema/compat.ts190++++++++++++++
Apackage/schema/compat_test.ts130++++++++++
Mpackage/testdata/starter.snapshot.json2+-
Mpackage/tooling/project_test.ts4+
Mpackage/tooling/pwa-validation.ts23++
Mpackage/tooling/pwa-validation_test.ts14+-
Apackage/tooling/schema-manifest.ts100+++++++
Apackage/tooling/schema-manifest_test.ts109++++++++
M apps/reference/deno.lock +1 -1
M apps/reference/src/app.ts +6 -0
M deno.json +1 -1
@@ -63,7 +63,7 @@
6363 "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:conformance",6464 "doc": "deno run --allow-run tools/doc_lint.ts",6565 "docs:links": "deno run --allow-read tools/docs_links.ts",66- "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/mod_test.ts package/schema/nested_test.ts package/testing package/tooling apps/reference/tests",66+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/testing package/tooling apps/reference/tests",6767 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",6868 "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/store_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",6969 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",
M docs/deployment.md +53 -4
@@ -24,10 +24,11 @@ deno task build
2424 deno task preview2525 ```2626 27-`build` writes `dist/`, records a source fingerprint in `dist/lofi-build.json`, generates the28-precache list, and scans for server-secret values. Before Astro starts, the shared doctor preflight29-validates the author-owned manifest and referenced assets. After Astro finishes, build verifies the30-emitted HTML links, manifest, icons, nested routes, worker revision/scope, build identity, and exact27+`build` writes `dist/`, records a source fingerprint in `dist/lofi-build.json`, stamps the schema28+compatibility manifest in `dist/lofi-schema.json`, generates the precache list, and scans for29+server-secret values. Before Astro starts, the shared doctor preflight validates the author-owned30+manifest and referenced assets. After Astro finishes, build verifies the emitted HTML links,31+manifest, icons, nested routes, worker revision/scope, build identity, schema manifest, and exact3132 precache set together. `preview` refuses to start when the build identity is missing or invalid.3233 3334 To use another preview port:
@@ -212,6 +213,54 @@ A runtime-cache write error is best-effort and leaves the active worker ready. R
212213 required precache, and activation failures remain worker failures; update-check failures leave the213214 current worker running and retry on a later foreground signal.214215 216+### Schema compatibility and read-only mode217+218+The app shell and the local data version independently: a browser can hold an old cached shell next219+to data another tab or device already migrated to a newer schema. Booting old code read-write onto220+newer data is a defect, so lofi couples the two at build time and gates boot on the comparison.221+222+Every build stamps `dist/lofi-schema.json` with the schema range the bundle understands: a223+fingerprint of the compiled schema (the head) plus fingerprints of every committed migration224+snapshot under `src/migrations/snapshots/` (the lineage). The file ships in the precache, so an225+offline shell always knows its own range. On each read-write boot the runtime records that range in226+`localStorage` beside the store — readable before the runtime opens.227+228+On boot the gate compares the two:229+230+- **Code ahead of data** (the local record is in the bundle's lineage): the normal path. Migrations231+ run as they always have, and the record advances to the new head.232+- **Data ahead of code** (the bundle's head is in the local record's lineage): the shell is older233+ than the data. The gate refuses table writes, surfaces the state through runtime diagnostics and234+ the `DeviceStatus` panel, and prompts an update check. Reads continue — the app stays usable for235+ looking things up, and nothing is lost; boot is never hard-failed by the gate.236+- **Unrelated histories** (neither contains the other, e.g. a schema change shipped without its237+ migration snapshot): treated like data-ahead. Writes stay refused until a bundle that knows the238+ data's schema arrives.239+240+For users, read-only mode means edits are declined with the message "data was saved by a newer241+version of the app"; updating the app (or reloading once the update installs) restores editing. The242+refusal reaches application code as a `SchemaCompatibilityError` on the mutation promise, the same243+surface as any other write failure.244+245+By default the framework renders a minimal fixed banner (`.lofi-schema-compat-banner`) with the246+message and an update/reload action. Apps can restyle it via that class, or replace it entirely:247+suppress it with `pwa: { updateBanner: "none" }` in `defineLofiApp` and render a custom surface from248+the `useSchemaCompat` hook in `@nzip/lofi/preact` (states: `unchecked`, `compatible`, `data-ahead`,249+`updating`).250+251+Applying an update coordinates across tabs on the same registration scope. The initiating tab252+announces the swap on a scope-keyed BroadcastChannel; every tab pauses new local writes for a253+bounded window while in-flight writes settle under a scope-keyed Web Lock, and only then is the254+waiting worker activated. Afterwards, tabs that did not initiate the update reload by default;255+`pwa: { staleTabs: "prompt" }` keeps their documents open instead, read-only, with a reload prompt.256+Channel and lock names carry the scope key exactly like the cache names, so sibling lofi apps on one257+origin never coordinate with each other by accident. Browsers without BroadcastChannel or Web Locks258+fall back to the bounded pause alone.259+260+In development the gate is inert, and a deployment without `lofi-schema.json` (built before the261+manifest existed) leaves writes enabled — the protection begins with the first build that ships the262+manifest.263+215264 Do not run a server-side Jazz credential in the static host or expose `JAZZ_ADMIN_SECRET` or216265 `BACKEND_SECRET` as public environment variables.217266
M package/astro/mod.ts +4 -0
M package/commands/build.ts +15 -0
M package/preact/DeviceStatus.tsx +9 -0
M package/preact/mod.ts +5 -0
A package/preact/use-schema-compat.ts +31 -0
M package/runtime/app.ts +20 -0
M package/runtime/boot.ts +38 -1
@@ -1,9 +1,45 @@
1+/// <reference path="./env.d.ts" />12 // Package-owned browser boot orchestration.3+import { getLofiApp } from "./app.ts";24 import { restoreDeclaredSink } from "./data-sink.ts";3-import { registerProductionServiceWorker } from "./pwa.ts";5+import {6+ attachPwaUpdateCoordination,7+ registerProductionServiceWorker,8+ resolvePwaResources,9+} from "./pwa.ts";10+import { mountDefaultCompatBanner, schemaCompatGate } from "./schema-compat.ts";11+import { configureUpgradeCoordinator } from "./upgrade-coordination.ts";412 513 let booted = false;614 15+// The compatibility gate and the cross-tab upgrade coordination are wired16+// before the runtime opens: the gate's verdict must be able to refuse the17+// first write, and sibling tabs must hear an upgrade announcement no matter18+// which module triggers it.19+function startCompatibilityGate(): void {20+ schemaCompatGate.start();21+ mountDefaultCompatBanner();22+ if (!import.meta.env.PROD) return;23+ const deploymentBaseUrl = new URL(import.meta.env.BASE_URL, document.location.origin).href;24+ const coordinator = configureUpgradeCoordinator(resolvePwaResources(deploymentBaseUrl).scope);25+ attachPwaUpdateCoordination({26+ prepareUpdateSwap: () => coordinator.announceUpgrade(),27+ staleTabBehavior: () => {28+ try {29+ return getLofiApp().pwa?.staleTabs ?? "reload";30+ } catch {31+ return "reload";32+ }33+ },34+ onStaleTab: () => {35+ // The swap this tab sat out is over; resume writes long enough for the36+ // gate to flip the tab read-only with its reload prompt.37+ coordinator.notifyActivation();38+ schemaCompatGate.markStaleTab();39+ },40+ });41+}42+743 /**844 * Starts lofi's browser lifecycle once for the current document.945 *
@@ -33,6 +69,7 @@ export async function bootLofi(): Promise<void> {
3369 } catch {3470 // Continue local-only; enrollment can re-declare the sink.3571 }72+ startCompatibilityGate();3673 await import("./lifecycle.ts");3774 registerProductionServiceWorker();3875 if (import.meta.env.DEV) void import("./probe.ts");
M package/runtime/diagnostics.ts +3 -0
M package/runtime/mod.ts +8 -0
M package/runtime/pwa.ts +66 -10
@@ -106,6 +106,21 @@ export type PwaControllerDependencies = {
106106 /** @deprecated Supply deploymentBaseUrl when injecting browser location in tests. */107107 readonly reload?: () => void;108108 readonly exposeState?: (state: PwaState) => void;109+ /**110+ * Runs before the waiting worker is told to activate, so sibling tabs can111+ * quiesce in-flight writes. When supplied, `applyUpdate()` posts the112+ * skip-waiting message only after this settles; a rejection does not block113+ * the swap. When omitted, activation is requested synchronously.114+ */115+ readonly prepareUpdateSwap?: () => Promise<void>;116+ /**117+ * How a tab that did not initiate an update behaves when a new worker takes118+ * control: `reload` (the default) reloads it onto the new revision, `prompt`119+ * keeps the document and reports the stale revision via `onStaleTab`.120+ */121+ readonly staleTabBehavior?: () => "reload" | "prompt";122+ /** Notified when this tab stays on a stale revision under `prompt` behavior. */123+ readonly onStaleTab?: () => void;109124 };110125 111126 /** Stateful controller for browser installation and service-worker updates. */
@@ -385,10 +400,12 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
385400 else update({ failure: failure("runtime-cache") });386401 }387402 });388- // A controller change means a new worker took over this tab. Every389- // controlled tab must reload — the new worker prunes the old revision's403+ // A controller change means a new worker took over this tab. By default404+ // every controlled tab reloads — the new worker prunes the old revision's390405 // caches, so a document kept on the old HTML/module graph goes stale —391- // not just the tab whose applyUpdate() initiated the change. The first406+ // not just the tab whose applyUpdate() initiated the change. Under the407+ // `prompt` stale-tab preference a bystander tab keeps its document and is408+ // reported stale instead; the initiating tab always reloads. The first392409 // claim of a previously uncontrolled page does not reload, and a document393410 // reloads at most once, so a misbehaving worker cannot cause a loop.394411 let hadController = Boolean(container.controller);
@@ -400,13 +417,17 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
400417 reloadForUpdate = false;401418 if (!initiated && !wasControlled) return;402419 if (reloadedForController) return;403- reloadedForController = true;404420 waitingWorker = undefined;405421 update({406422 worker: "ready",407423 update: "idle",408424 failure: clearFailure("installation", "update-check"),409425 });426+ if (!initiated && (dependencies.staleTabBehavior?.() ?? "reload") === "prompt") {427+ dependencies.onStaleTab?.();428+ return;429+ }430+ reloadedForController = true;410431 if (dependencies.reload) dependencies.reload();411432 else location.reload();412433 });
@@ -462,26 +483,61 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
462483 checkForUpdate,463484 applyUpdate() {464485 if (!waitingWorker || state.update !== "ready") return false;486+ const worker = waitingWorker;487+ const prepare = dependencies.prepareUpdateSwap;465488 reloadForUpdate = true;466- try {467- waitingWorker.postMessage({ type: "LOFI_SKIP_WAITING" });489+ const requestActivation = () => {490+ try {491+ worker.postMessage({ type: "LOFI_SKIP_WAITING" });492+ return true;493+ } catch {494+ reloadForUpdate = false;495+ failUpdate("installation");496+ return false;497+ }498+ };499+ if (!prepare) {500+ if (!requestActivation()) return false;468501 update({ update: "applying", failure: clearFailure("installation", "update-check") });469502 return true;470- } catch {471- reloadForUpdate = false;472- failUpdate("installation");473- return false;474503 }504+ // With coordination, sibling tabs quiesce writes first; the swap itself505+ // must not depend on their cooperation, so a rejection still proceeds.506+ update({ update: "applying", failure: clearFailure("installation", "update-check") });507+ void Promise.resolve().then(prepare).catch(() => undefined).then(requestActivation);508+ return true;475509 },476510 initialize,477511 };478512 }479513 514+/** Cross-tab coordination hooks the boot wiring attaches to the shared controller. */515+export type PwaUpdateCoordination = {516+ /** Quiesces sibling-tab writes before the worker swap. */517+ prepareUpdateSwap: () => Promise<void>;518+ /** The author's stale-tab preference; `reload` is the safe default. */519+ staleTabBehavior: () => "reload" | "prompt";520+ /** Marks this tab stale when `prompt` keeps it on the old revision. */521+ onStaleTab: () => void;522+};523+524+// Late-bound so this module stays free of upgrade-coordination imports; the525+// hooks act only once boot wiring attaches them.526+let updateCoordination: PwaUpdateCoordination | undefined;527+528+/** Attaches (or replaces) the shared controller's cross-tab coordination hooks. */529+export function attachPwaUpdateCoordination(hooks: PwaUpdateCoordination): void {530+ updateCoordination = hooks;531+}532+480533 /** Shared controller used by the root runtime and optional Preact bindings. */481534 export const pwaController: PwaController = createPwaController({482535 exposeState(next) {483536 if (typeof document !== "undefined") (globalThis as LofiPwaGlobal).__LOFI_PWA_STATE__ = next;484537 },538+ prepareUpdateSwap: () => updateCoordination?.prepareUpdateSwap() ?? Promise.resolve(),539+ staleTabBehavior: () => updateCoordination?.staleTabBehavior() ?? "reload",540+ onStaleTab: () => updateCoordination?.onStaleTab(),485541 });486542 487543 /** Returns the shared controller's current state snapshot. */
M package/runtime/pwa_test.ts +82 -0
@@ -79,6 +79,9 @@ function testController(options: {
7979 clearTimeout?: (handle: unknown) => void;8080 updateCheckIntervalMs?: number;8181 updateCheckTimeoutMs?: number;82+ prepareUpdateSwap?: () => Promise<void>;83+ staleTabBehavior?: () => "reload" | "prompt";84+ onStaleTab?: () => void;8285 } = {}): PwaController {8386 const environment = options.install === "manual-ios"8487 ? { platform: "iPhone", maxTouchPoints: 5 }
@@ -103,6 +106,9 @@ function testController(options: {
103106 production: () => options.production ?? false,104107 deploymentBaseUrl: () => options.deploymentBaseUrl ?? "http://127.0.0.1:4321/",105108 reload: options.reload,109+ prepareUpdateSwap: options.prepareUpdateSwap,110+ staleTabBehavior: options.staleTabBehavior,111+ onStaleTab: options.onStaleTab,106112 });107113 }108114
@@ -293,6 +299,82 @@ test("waiting-worker update activation posts skip-waiting and reloads on control
293299 if (reloaded) throw new Error("one update action caused a reload loop");294300 });295301 302+test("a coordinated update quiesces sibling writes before requesting activation", async () => {303+ const container = new FakeContainer();304+ const waiting = new FakeServiceWorker("installed");305+ container.controller = asServiceWorker(new FakeServiceWorker("activated"));306+ container.registration.waiting = asServiceWorker(waiting);307+ let releaseQuiescence: () => void = () => undefined;308+ const quiescence = new Promise<void>((resolve) => releaseQuiescence = resolve);309+ const controller = testController({310+ container,311+ production: true,312+ reload: () => undefined,313+ prepareUpdateSwap: () => quiescence,314+ });315+ const available = waitForState(controller, (state) => state.update === "ready");316+ controller.initialize();317+ await available;318+ if (!controller.applyUpdate()) throw new Error("update action was unavailable");319+ if (controller.getState().update !== "applying") {320+ throw new Error("coordinated update application state was not observable");321+ }322+ await Promise.resolve();323+ if (waiting.messages.length !== 0) {324+ throw new Error("activation was requested before sibling writes quiesced");325+ }326+ releaseQuiescence();327+ await quiescence;328+ for (let i = 0; i < 6; i += 1) await Promise.resolve();329+ if (JSON.stringify(waiting.messages) !== JSON.stringify([{ type: "LOFI_SKIP_WAITING" }])) {330+ throw new Error("quiescence did not hand off to the worker swap");331+ }332+});333+334+test("a rejected quiescence still proceeds to the worker swap", async () => {335+ const container = new FakeContainer();336+ const waiting = new FakeServiceWorker("installed");337+ container.controller = asServiceWorker(new FakeServiceWorker("activated"));338+ container.registration.waiting = asServiceWorker(waiting);339+ const controller = testController({340+ container,341+ production: true,342+ reload: () => undefined,343+ prepareUpdateSwap: () => Promise.reject(new Error("locks unavailable")),344+ });345+ const available = waitForState(controller, (state) => state.update === "ready");346+ controller.initialize();347+ await available;348+ if (!controller.applyUpdate()) throw new Error("update action was unavailable");349+ for (let i = 0; i < 6; i += 1) await Promise.resolve();350+ if (JSON.stringify(waiting.messages) !== JSON.stringify([{ type: "LOFI_SKIP_WAITING" }])) {351+ throw new Error("a failed quiescence blocked the swap");352+ }353+});354+355+test("the prompt preference keeps a bystander tab and reports it stale", async () => {356+ const container = new FakeContainer();357+ container.controller = asServiceWorker(new FakeServiceWorker("activated"));358+ container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));359+ let reloaded = 0;360+ let staleReports = 0;361+ const controller = testController({362+ container,363+ production: true,364+ reload: () => reloaded += 1,365+ staleTabBehavior: () => "prompt",366+ onStaleTab: () => staleReports += 1,367+ });368+ controller.initialize();369+ await waitForState(controller, (state) => state.worker === "ready");370+ container.dispatchEvent(new Event("controllerchange"));371+ if (reloaded !== 0) throw new Error("the prompt preference still reloaded the bystander tab");372+ if (staleReports !== 1) throw new Error("the stale tab was not reported");373+ if (controller.getState().update !== "idle") {374+ throw new Error("the stale tab kept a stale update state");375+ }376+});377+296378 test("a controlled tab reloads on an update another tab initiated", async () => {297379 // The new worker prunes the old revision's caches, so every claimed tab298380 // must leave the old HTML/module graph — not only the tab that clicked.
M package/runtime/runtime.ts +17 -0
@@ -4,7 +4,9 @@ import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";
44 import { getLofiApp } from "./app.ts";55 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";66 import { activeSink, appId, databaseConfig, syncing } from "./config.ts";7+import { assertSchemaWritable, schemaCompatGate, subscribeSchemaCompat } from "./schema-compat.ts";78 import { resolveStoreStatus } from "./store-status.ts";9+import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";810 import { setEncryptedColumnKey } from "../schema/encrypted.ts";911 import { assertDurableBrowser } from "./device-capabilities.ts";1012 import {
@@ -244,6 +246,9 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
244246 state.diagnostics.startupFailure = null;245247 state.diagnostics.clientsCreated += 1;246248 state.diagnostics.activeClients += 1;249+ // The runtime is open read-write; the compatibility gate may stamp the250+ // local schema version forward (it never stamps while data is ahead).251+ schemaCompatGate.markRuntimeWritable();247252 notifyDiagnostics(state);248253 return db;249254 }, record);
@@ -275,6 +280,10 @@ function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {
275280 store = createTableStore(db, table, state.diagnostics, {276281 syncConfigured: syncing,277282 onDiagnosticsChange: () => notifyDiagnostics(state),283+ guardWrite: async () => {284+ await assertSchemaWritable();285+ return await acquireUpgradeWriteLock();286+ },278287 }) as TableStore<TableRow, unknown>;279288 stores.set(table, store);280289 }
@@ -292,6 +301,14 @@ function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {
292301 return runtime;293302 }294303 304+// The compatibility verdict is part of runtime diagnostics; mirror the shared305+// gate into the slot so DeviceStatus and the inspector observe it live.306+subscribeSchemaCompat((compat) => {307+ const state = slot();308+ state.diagnostics.schemaCompat = compat;309+ notifyDiagnostics(state);310+});311+295312 /** Opens or reuses the one package runtime for the current browser document. */296313 export function getRuntime(): Promise<LofiRuntime> {297314 if (recreationPromise) return recreationPromise;
A package/runtime/schema-compat.ts +407 -0
@@ -0,0 +1,407 @@
1+/// <reference path="./env.d.ts" />2+/**3+ * The boot compatibility gate between the running bundle's supported schema4+ * range and the schema version the local data was last written under.5+ *6+ * A long-lived offline shell can be older than the data another tab or7+ * device migrated. Booting that old code read-write onto newer data is a8+ * defect, not an author choice, so the gate refuses writes (reads continue)9+ * and names the one remediation: update the app. Code ahead of data is the10+ * normal path — migrations run as they always have — and the gate then11+ * stamps the local record forward. The gate never hard-fails boot: reads go12+ * through the engine's own machinery, and a truly unreadable store surfaces13+ * as a startup failure there.14+ *15+ * The bundle's range comes from the build-stamped `lofi-schema.json`, which16+ * ships in the precache so an offline shell still knows its own range; the17+ * local record lives in `localStorage` beside the store, readable before the18+ * runtime fully boots. Zero configuration is safe: in development the gate19+ * is inert, a missing manifest leaves writes enabled, and the framework20+ * renders a minimal default banner that apps may replace via the optional21+ * `pwa` block of `defineLofiApp` and the `useSchemaCompat` Preact hook.22+ *23+ * @module24+ */25+26+import {27+ classifySchemaCompat,28+ parseLocalSchemaVersion,29+ parseSchemaCompatManifest,30+ type SchemaVersionRange,31+ serializeLocalSchemaVersion,32+} from "../schema/compat.ts";33+import { getLofiApp } from "./app.ts";34+import { anchorAppId } from "./data-sink.ts";35+import { type PwaController, pwaController } from "./pwa.ts";36+37+/** Why writes are refused: newer-schema data, or a stale tab after a swap. */38+export type SchemaCompatReason = "schema" | "stale-tab";39+40+/** The compatibility state exposed through diagnostics and the Preact hook. */41+export type SchemaCompatState =42+ /** No verdict: development mode, no manifest, or the check is in flight. */43+ | { state: "unchecked"; reason: "inactive" | "development" | "no-manifest" | "pending" }44+ /** Writes are allowed; `classification` says why. */45+ | { state: "compatible"; classification: "first-boot" | "equal" | "code-ahead" }46+ /** The data is newer than this code: read-only until the app updates. */47+ | { state: "data-ahead"; reason: SchemaCompatReason; message: string }48+ /** An update that resolves the mismatch is installing or applying. */49+ | { state: "updating"; message: string };50+51+const dataAheadMessage =52+ "This device's data was saved by a newer version of the app, so editing is paused to protect " +53+ "it. Reading still works. Update the app to continue editing.";54+const staleTabMessage =55+ "A newer version of the app is now active in another tab. Reload this tab to continue editing.";56+const updatingMessage = "Updating the app… editing resumes when the update finishes.";57+58+/** Raised when a mutation is refused because the local data is ahead of the code. */59+export class SchemaCompatibilityError extends Error {60+ /** Stable error class name for diagnostics and error boundaries. */61+ override readonly name = "SchemaCompatibilityError";62+ /** Stable category for write-refusal classification. */63+ readonly code = "schema-data-ahead";64+}65+66+/** The update surface the gate drives as its remediation. */67+export type SchemaCompatUpdateSurface = Pick<68+ PwaController,69+ "getState" | "subscribe" | "checkForUpdate"70+>;71+72+/** Injectable browser surfaces used to test the gate deterministically. */73+export type SchemaCompatGateDependencies = {74+ readonly production?: () => boolean;75+ /** Loads the raw bundle manifest value; `null` means the file is absent. */76+ readonly loadManifest?: () => Promise<unknown>;77+ readonly storage?: () => Pick<Storage, "getItem" | "setItem"> | undefined;78+ readonly storageKey?: () => string;79+ /** Target for cross-tab `storage` events; defaults to the window. */80+ readonly storageEvents?: () => EventTarget | undefined;81+ readonly controller?: () => SchemaCompatUpdateSurface | undefined;82+ /** Upper bound a write waits for the pending verdict; defaults to 8 seconds. */83+ readonly settleTimeoutMs?: number;84+};85+86+/** The boot compatibility gate between bundle schema range and local data. */87+export type SchemaCompatGate = {88+ /** Starts the check once; later calls are no-ops. */89+ start(): void;90+ /** The current compatibility state snapshot. */91+ getState(): SchemaCompatState;92+ /** Subscribes to state changes; the listener runs immediately. */93+ subscribe(listener: (state: SchemaCompatState) => void): () => void;94+ /** Resolves when writes are allowed; rejects with {@link SchemaCompatibilityError}. */95+ assertWritable(): Promise<void>;96+ /** Records that the runtime opened read-write; stamps the local version. */97+ markRuntimeWritable(): void;98+ /** Marks this tab stale after another tab's worker swap; refuses writes. */99+ markStaleTab(): void;100+};101+102+function defaultLoadManifest(): Promise<unknown> {103+ const base = new URL(import.meta.env.BASE_URL, document.location.origin);104+ if (!base.pathname.endsWith("/")) base.pathname += "/";105+ return fetch(new URL("lofi-schema.json", base)).then(106+ (response) => {107+ if (response.status === 404) {108+ void response.body?.cancel();109+ return null;110+ }111+ if (!response.ok) {112+ void response.body?.cancel();113+ throw new Error(`schema manifest failed: HTTP ${response.status}`);114+ }115+ return response.json();116+ },117+ );118+}119+120+/** Creates an isolated gate, primarily for tests; boot uses the shared one. */121+export function createSchemaCompatGate(122+ dependencies: SchemaCompatGateDependencies = {},123+): SchemaCompatGate {124+ const subscribers = new Set<(state: SchemaCompatState) => void>();125+ let state: SchemaCompatState = { state: "unchecked", reason: "inactive" };126+ let bundle: SchemaVersionRange | null = null;127+ let staleTab = false;128+ let started = false;129+ let stamped = false;130+ let runtimeWritable = false;131+ let settled = false;132+ const settleWaiters = new Set<() => void>();133+134+ const storage = () => {135+ if (dependencies.storage) return dependencies.storage();136+ try {137+ return typeof localStorage === "undefined" ? undefined : localStorage;138+ } catch {139+ return undefined;140+ }141+ };142+ const storageKey = () => dependencies.storageKey?.() ?? `lofi:schema-version:${anchorAppId}`;143+ const controller = () => (dependencies.controller ? dependencies.controller() : pwaController);144+ const settleTimeoutMs = dependencies.settleTimeoutMs ?? 8_000;145+146+ const publish = (next: SchemaCompatState) => {147+ state = next;148+ for (const subscriber of subscribers) subscriber(state);149+ };150+151+ const settle = () => {152+ if (settled) return;153+ settled = true;154+ for (const waiter of settleWaiters) waiter();155+ settleWaiters.clear();156+ };157+158+ const readLocal = (): SchemaVersionRange | null => {159+ try {160+ return parseLocalSchemaVersion(storage()?.getItem(storageKey()) ?? null);161+ } catch {162+ return null;163+ }164+ };165+166+ const stamp = () => {167+ if (!bundle || stamped) return;168+ stamped = true;169+ try {170+ storage()?.setItem(storageKey(), serializeLocalSchemaVersion(bundle));171+ } catch {172+ // A private-mode storage failure must not affect a working runtime.173+ }174+ };175+176+ // The overlay: while the mismatch stands and an update is actually moving,177+ // the surfaced state is "updating" rather than a bare refusal.178+ const effectiveState = (base: SchemaCompatState): SchemaCompatState => {179+ if (base.state !== "data-ahead") return base;180+ const update = controller()?.getState().update;181+ return update === "installing" || update === "applying"182+ ? { state: "updating", message: updatingMessage }183+ : base;184+ };185+186+ let base: SchemaCompatState = state;187+ const setBase = (next: SchemaCompatState) => {188+ base = next;189+ publish(effectiveState(base));190+ };191+192+ const evaluate = () => {193+ if (staleTab) {194+ setBase({ state: "data-ahead", reason: "stale-tab", message: staleTabMessage });195+ return;196+ }197+ if (!bundle) return;198+ const classification = classifySchemaCompat(bundle, readLocal());199+ if (classification === "data-ahead" || classification === "unrelated") {200+ setBase({ state: "data-ahead", reason: "schema", message: dataAheadMessage });201+ // The remediation is a newer bundle; kick a bounded update check. Later202+ // foreground signals re-check through the controller's own machinery.203+ void controller()?.checkForUpdate();204+ return;205+ }206+ setBase({207+ state: "compatible",208+ classification: classification === "first-boot" ? "first-boot" : classification,209+ });210+ if (runtimeWritable) stamp();211+ };212+213+ const attachPwaOverlay = () => {214+ controller()?.subscribe(() => {215+ publish(effectiveState(base));216+ });217+ };218+219+ const attachStorageEvents = () => {220+ const target = dependencies.storageEvents221+ ? dependencies.storageEvents()222+ : (typeof window === "undefined" ? undefined : window);223+ // Another tab advancing the stamp while this one runs is the live form of224+ // the same hazard; re-evaluate so this tab flips to read-only in place.225+ target?.addEventListener("storage", (event) => {226+ const key = (event as StorageEvent).key;227+ if (key === null || key === storageKey()) evaluate();228+ });229+ };230+231+ return {232+ start() {233+ if (started) return;234+ started = true;235+ if (!(dependencies.production?.() ?? import.meta.env.PROD)) {236+ setBase({ state: "unchecked", reason: "development" });237+ settle();238+ return;239+ }240+ setBase({ state: "unchecked", reason: "pending" });241+ attachPwaOverlay();242+ attachStorageEvents();243+ const load = dependencies.loadManifest ?? defaultLoadManifest;244+ void Promise.resolve().then(load).then(245+ (value) => {246+ const manifest = value === null ? null : parseSchemaCompatManifest(value);247+ if (!manifest) {248+ setBase({ state: "unchecked", reason: "no-manifest" });249+ } else {250+ bundle = { head: manifest.head, lineage: manifest.lineage };251+ evaluate();252+ }253+ settle();254+ },255+ () => {256+ // An unreachable manifest must not brick a working app; the shell257+ // that matters offline serves it from its own precache.258+ setBase({ state: "unchecked", reason: "no-manifest" });259+ settle();260+ },261+ );262+ },263+ getState: () => state,264+ subscribe(listener) {265+ subscribers.add(listener);266+ listener(state);267+ return () => subscribers.delete(listener);268+ },269+ async assertWritable() {270+ if (started && !settled) {271+ await new Promise<void>((resolve) => {272+ settleWaiters.add(resolve);273+ const timer = setTimeout(resolve, settleTimeoutMs);274+ settleWaiters.add(() => clearTimeout(timer));275+ });276+ }277+ if (state.state === "data-ahead" || state.state === "updating") {278+ throw new SchemaCompatibilityError(279+ state.state === "updating" ? updatingMessage : state.message,280+ );281+ }282+ },283+ markRuntimeWritable() {284+ runtimeWritable = true;285+ if (base.state === "compatible") stamp();286+ },287+ markStaleTab() {288+ staleTab = true;289+ evaluate();290+ },291+ };292+}293+294+/** Shared gate used by boot, the runtime, and the optional Preact bindings. */295+export const schemaCompatGate: SchemaCompatGate = createSchemaCompatGate();296+297+/** Returns the shared gate's current compatibility state snapshot. */298+export function getSchemaCompatState(): SchemaCompatState {299+ return schemaCompatGate.getState();300+}301+302+/** Subscribes to shared compatibility state; returns an unsubscribe function. */303+export function subscribeSchemaCompat(304+ listener: (state: SchemaCompatState) => void,305+): () => void {306+ return schemaCompatGate.subscribe(listener);307+}308+309+/** Resolves when writes are allowed; rejects with {@link SchemaCompatibilityError}. */310+export function assertSchemaWritable(): Promise<void> {311+ return schemaCompatGate.assertWritable();312+}313+314+/** One-line label for diagnostic surfaces (development inspector, DeviceStatus). */315+export function describeSchemaCompat(state: SchemaCompatState): string {316+ switch (state.state) {317+ case "unchecked":318+ switch (state.reason) {319+ case "development":320+ return "not checked (development)";321+ case "no-manifest":322+ return "not checked (no compatibility manifest)";323+ case "pending":324+ return "checking…";325+ default:326+ return "not checked";327+ }328+ case "compatible":329+ return state.classification === "code-ahead"330+ ? "compatible (migrations pending)"331+ : "compatible";332+ case "data-ahead":333+ return state.reason === "stale-tab"334+ ? "read-only — reload this tab"335+ : "read-only — data is newer than this app version";336+ case "updating":337+ return "updating…";338+ }339+}340+341+// ---------------------------------------------------------------------------342+// Default banner: the zero-configuration surface for read-only mode. A plain343+// DOM element (no UI framework) so it works in any app; authors opt out with344+// `pwa: { updateBanner: "none" }` and render their own from useSchemaCompat.345+// ---------------------------------------------------------------------------346+347+const bannerClass = "lofi-schema-compat-banner";348+349+function bannerAction(state: SchemaCompatState): { label: string; run: () => void } | null {350+ if (state.state !== "data-ahead") return null;351+ if (state.reason === "stale-tab") {352+ return { label: "Reload", run: () => location.reload() };353+ }354+ if (pwaController.getState().update === "ready") {355+ return { label: "Update app", run: () => void pwaController.applyUpdate() };356+ }357+ return { label: "Check for updates", run: () => void pwaController.checkForUpdate() };358+}359+360+function renderDefaultBanner(state: SchemaCompatState): void {361+ const existing = document.querySelector(`.${bannerClass}`);362+ if (state.state !== "data-ahead" && state.state !== "updating") {363+ existing?.remove();364+ return;365+ }366+ const banner = existing ?? document.createElement("div");367+ banner.textContent = "";368+ banner.className = bannerClass;369+ banner.setAttribute("role", "alert");370+ (banner as HTMLElement).style.cssText =371+ "position:fixed;inset:auto 0 0 0;z-index:2147483000;display:flex;gap:0.75rem;" +372+ "align-items:center;justify-content:center;flex-wrap:wrap;padding:0.75rem 1rem;" +373+ "background:#1f2430;color:#fff;font:14px/1.4 system-ui,sans-serif;text-align:center;";374+ const message = document.createElement("span");375+ message.textContent = state.message;376+ banner.append(message);377+ const action = bannerAction(state);378+ if (action) {379+ const button = document.createElement("button");380+ button.type = "button";381+ button.textContent = action.label;382+ button.style.cssText =383+ "padding:0.35rem 0.9rem;border:1px solid #fff;border-radius:0.4rem;background:none;" +384+ "color:inherit;font:inherit;cursor:pointer;";385+ button.addEventListener("click", action.run);386+ banner.append(button);387+ }388+ if (!existing) document.body.append(banner);389+}390+391+/**392+ * Mounts the framework's minimal read-only banner unless the app opted out.393+ * Called once by boot; safe without a defined app (defaults apply).394+ */395+export function mountDefaultCompatBanner(): void {396+ if (typeof document === "undefined") return;397+ let preference: "default" | "none" = "default";398+ try {399+ preference = getLofiApp().pwa?.updateBanner ?? "default";400+ } catch {401+ // No app definition yet; the safe default applies.402+ }403+ if (preference === "none") return;404+ schemaCompatGate.subscribe(renderDefaultBanner);405+ // Re-render on update-state changes so the action label tracks readiness.406+ pwaController.subscribe(() => renderDefaultBanner(schemaCompatGate.getState()));407+}
A package/runtime/schema-compat_test.ts +317 -0
@@ -0,0 +1,317 @@
1+import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";2+import { createDiagnostics } from "./diagnostics.ts";3+import type { PwaState } from "./pwa.ts";4+import {5+ createSchemaCompatGate,6+ SchemaCompatibilityError,7+ type SchemaCompatState,8+ type SchemaCompatUpdateSurface,9+} from "./schema-compat.ts";10+import { type TableMutationEnvironment, TableMutationRegistry } from "./table-mutations.ts";11+import { assert } from "./test-assert.ts";12+13+class FakeStorage {14+ readonly values = new Map<string, string>();15+ getItem(key: string): string | null {16+ return this.values.get(key) ?? null;17+ }18+ setItem(key: string, value: string): void {19+ this.values.set(key, value);20+ }21+}22+23+class FakeUpdateSurface implements SchemaCompatUpdateSurface {24+ checkForUpdateCalls = 0;25+ #state: PwaState = { worker: "ready", install: "unsupported", update: "idle" };26+ readonly #subscribers = new Set<(state: PwaState) => void>();27+28+ getState(): PwaState {29+ return this.#state;30+ }31+ subscribe(subscriber: (state: PwaState) => void): () => void {32+ this.#subscribers.add(subscriber);33+ subscriber(this.#state);34+ return () => this.#subscribers.delete(subscriber);35+ }36+ checkForUpdate(): Promise<boolean> {37+ this.checkForUpdateCalls += 1;38+ return Promise.resolve(true);39+ }40+ setUpdate(update: PwaState["update"]): void {41+ this.#state = { ...this.#state, update };42+ for (const subscriber of this.#subscribers) subscriber(this.#state);43+ }44+}45+46+const olderBundle = { v: 1, revision: "old", head: "v1:a", lineage: ["v1:a"] };47+const newerBundle = { v: 1, revision: "new", head: "v1:b", lineage: ["v1:a", "v1:b"] };48+const newerRecord = JSON.stringify({ v: 1, head: "v1:b", lineage: ["v1:a", "v1:b"] });49+const storageKey = "lofi:schema-version:test";50+51+function gateUnderTest(options: {52+ manifest?: unknown;53+ loadManifest?: () => Promise<unknown>;54+ storage?: FakeStorage;55+ surface?: FakeUpdateSurface;56+ storageEvents?: EventTarget;57+}) {58+ const storage = options.storage ?? new FakeStorage();59+ const surface = options.surface ?? new FakeUpdateSurface();60+ const gate = createSchemaCompatGate({61+ production: () => true,62+ loadManifest: options.loadManifest ?? (() => Promise.resolve(options.manifest ?? null)),63+ storage: () => storage,64+ storageKey: () => storageKey,65+ storageEvents: () => options.storageEvents,66+ controller: () => surface,67+ });68+ return { gate, storage, surface };69+}70+71+function waitForState(72+ gate: ReturnType<typeof createSchemaCompatGate>,73+ predicate: (state: SchemaCompatState) => boolean,74+): Promise<SchemaCompatState> {75+ return new Promise((resolve) => {76+ const unsubscribe = gate.subscribe((state) => {77+ if (!predicate(state)) return;78+ unsubscribe();79+ resolve(state);80+ });81+ });82+}83+84+Deno.test("first boot is compatible and the runtime stamps the local version", async () => {85+ const { gate, storage } = gateUnderTest({ manifest: olderBundle });86+ const compatible = waitForState(gate, (state) => state.state === "compatible");87+ gate.start();88+ const state = await compatible;89+ assert(90+ state.state === "compatible" && state.classification === "first-boot",91+ "an unstamped store was not classified first-boot",92+ );93+ await gate.assertWritable();94+ gate.markRuntimeWritable();95+ const record = JSON.parse(storage.getItem(storageKey) ?? "null");96+ assert(record?.head === "v1:a", "a writable first boot did not stamp the local version");97+});98+99+Deno.test("code ahead of data migrates forward and advances the stamp", async () => {100+ const storage = new FakeStorage();101+ storage.setItem(storageKey, JSON.stringify({ v: 1, head: "v1:a", lineage: ["v1:a"] }));102+ const { gate } = gateUnderTest({ manifest: newerBundle, storage });103+ const compatible = waitForState(gate, (state) => state.state === "compatible");104+ gate.start();105+ const state = await compatible;106+ assert(107+ state.state === "compatible" && state.classification === "code-ahead",108+ "a newer bundle over older data was not code-ahead",109+ );110+ await gate.assertWritable();111+ gate.markRuntimeWritable();112+ const record = JSON.parse(storage.getItem(storageKey) ?? "null");113+ assert(record?.head === "v1:b", "the stamp did not advance to the new head");114+ assert(record?.lineage?.includes("v1:a"), "the advanced stamp lost its lineage");115+});116+117+Deno.test("data ahead of code refuses writes, keeps the stamp, and prompts an update check", async () => {118+ const storage = new FakeStorage();119+ storage.setItem(storageKey, newerRecord);120+ const { gate, surface } = gateUnderTest({ manifest: olderBundle, storage });121+ const dataAhead = waitForState(gate, (state) => state.state === "data-ahead");122+ gate.start();123+ const state = await dataAhead;124+ assert(state.state === "data-ahead" && state.reason === "schema", "wrong data-ahead reason");125+ assert(state.message.includes("Update the app"), "the diagnostic does not name the remediation");126+ let refused: unknown;127+ try {128+ await gate.assertWritable();129+ } catch (error) {130+ refused = error;131+ }132+ assert(refused instanceof SchemaCompatibilityError, "writes were not refused");133+ gate.markRuntimeWritable();134+ assert(storage.getItem(storageKey) === newerRecord, "a read-only boot rewrote the stamp");135+ assert(surface.checkForUpdateCalls === 1, "the gate did not prompt an update check");136+});137+138+Deno.test("an unrelated schema history is treated as data-ahead, never writable", async () => {139+ const storage = new FakeStorage();140+ storage.setItem(storageKey, JSON.stringify({ v: 1, head: "v1:z", lineage: ["v1:z"] }));141+ const { gate } = gateUnderTest({ manifest: olderBundle, storage });142+ const dataAhead = waitForState(gate, (state) => state.state === "data-ahead");143+ gate.start();144+ await dataAhead;145+ await gate.assertWritable().then(146+ () => {147+ throw new Error("an unrelated history allowed writes");148+ },149+ () => undefined,150+ );151+});152+153+Deno.test("a data-ahead gate surfaces an in-flight update as updating", async () => {154+ const storage = new FakeStorage();155+ storage.setItem(storageKey, newerRecord);156+ const { gate, surface } = gateUnderTest({ manifest: olderBundle, storage });157+ gate.start();158+ await waitForState(gate, (state) => state.state === "data-ahead");159+ surface.setUpdate("installing");160+ assert(gate.getState().state === "updating", "an installing update was not surfaced");161+ surface.setUpdate("ready");162+ assert(gate.getState().state === "data-ahead", "a ready update ended the refusal too early");163+ surface.setUpdate("applying");164+ assert(gate.getState().state === "updating", "an applying update was not surfaced");165+ await gate.assertWritable().then(166+ () => {167+ throw new Error("writes resumed during the update window");168+ },169+ () => undefined,170+ );171+});172+173+Deno.test("development mode and a missing manifest leave the gate inert", async () => {174+ const inert = createSchemaCompatGate({ production: () => false });175+ inert.start();176+ assert(177+ inert.getState().state === "unchecked" &&178+ (inert.getState() as { reason?: string }).reason === "development",179+ "development did not stay unchecked",180+ );181+ await inert.assertWritable();182+183+ const { gate } = gateUnderTest({ manifest: null });184+ const settled = waitForState(185+ gate,186+ (state) => state.state === "unchecked" && state.reason === "no-manifest",187+ );188+ gate.start();189+ await settled;190+ await gate.assertWritable();191+192+ const failing = gateUnderTest({ loadManifest: () => Promise.reject(new Error("offline")) });193+ const failedSettled = waitForState(194+ failing.gate,195+ (state) => state.state === "unchecked" && state.reason === "no-manifest",196+ );197+ failing.gate.start();198+ await failedSettled;199+ await failing.gate.assertWritable();200+});201+202+Deno.test("a write that races the verdict waits for it", async () => {203+ const storage = new FakeStorage();204+ storage.setItem(storageKey, newerRecord);205+ let resolveManifest: (value: unknown) => void = () => undefined;206+ const { gate } = gateUnderTest({207+ storage,208+ loadManifest: () => new Promise((resolve) => resolveManifest = resolve),209+ });210+ gate.start();211+ const outcome: { value: "pending" | "allowed" | "refused" } = { value: "pending" };212+ const write = gate.assertWritable().then(213+ () => outcome.value = "allowed",214+ () => outcome.value = "refused",215+ );216+ await Promise.resolve();217+ const beforeVerdict = outcome.value;218+ assert(beforeVerdict === "pending", "the write did not wait for the pending verdict");219+ resolveManifest(olderBundle);220+ await write;221+ const afterVerdict = outcome.value;222+ assert(afterVerdict === "refused", "the settled data-ahead verdict did not refuse the write");223+});224+225+Deno.test("another tab advancing the stamp flips this tab read-only in place", async () => {226+ const storage = new FakeStorage();227+ const storageEvents = new EventTarget();228+ const { gate } = gateUnderTest({ manifest: olderBundle, storage, storageEvents });229+ const compatible = waitForState(gate, (state) => state.state === "compatible");230+ gate.start();231+ await compatible;232+ storage.setItem(storageKey, newerRecord);233+ const dataAhead = waitForState(gate, (state) => state.state === "data-ahead");234+ storageEvents.dispatchEvent(235+ Object.assign(new Event("storage"), { key: storageKey }),236+ );237+ await dataAhead;238+});239+240+Deno.test("a stale tab after a worker swap is read-only with a reload prompt", async () => {241+ const { gate } = gateUnderTest({ manifest: olderBundle });242+ const compatible = waitForState(gate, (state) => state.state === "compatible");243+ gate.start();244+ await compatible;245+ gate.markStaleTab();246+ const state = gate.getState();247+ assert(248+ state.state === "data-ahead" && state.reason === "stale-tab" &&249+ state.message.includes("Reload"),250+ "the stale tab was not flipped to a reload prompt",251+ );252+ await gate.assertWritable().then(253+ () => {254+ throw new Error("a stale tab allowed writes");255+ },256+ () => undefined,257+ );258+});259+260+// ---------------------------------------------------------------------------261+// Integration: an old shell next to newer data. The local record was stamped262+// under a migrated schema; a bundle pinned to the older manifest boots, and263+// the mutation surface — wired exactly like the production registry — refuses264+// the write with the gate's diagnostic.265+// ---------------------------------------------------------------------------266+267+type Row = { id: string; title: string };268+type Init = Omit<Row, "id">;269+const table = { _table: "records", _schema: {} } as TableProxy<Row, Init>;270+271+class RefusingDb {272+ operations = 0;273+ value(): Db {274+ return this as unknown as Db;275+ }276+ onMutationError(_listener: (event: MutationErrorEvent) => void): () => void {277+ return () => undefined;278+ }279+ insert(_table: TableProxy<Row, Init>, values: Init) {280+ this.operations += 1;281+ return { wait: () => Promise.resolve({ id: "row-1", ...values }) };282+ }283+}284+285+Deno.test("an old shell over newer data refuses table writes with the gate diagnostic", async () => {286+ const storage = new FakeStorage();287+ storage.setItem(storageKey, newerRecord);288+ const { gate } = gateUnderTest({ manifest: olderBundle, storage });289+ gate.start();290+ await waitForState(gate, (state) => state.state === "data-ahead");291+292+ const db = new RefusingDb();293+ const diagnostics = createDiagnostics();294+ const environment: TableMutationEnvironment = {295+ getDb: () => Promise.resolve(db.value()),296+ syncConfigured: () => false,297+ subscribeRuntimeRecreation: () => () => undefined,298+ updateDiagnostics: (update) => update(diagnostics),299+ async guardWrite() {300+ await gate.assertWritable();301+ },302+ };303+ const store = new TableMutationRegistry(environment).acquire(table).store;304+ let refused: unknown;305+ try {306+ await store.insert({ title: "blocked" });307+ } catch (error) {308+ refused = error;309+ }310+ assert(refused instanceof SchemaCompatibilityError, "the mutation store allowed the write");311+ assert(db.operations === 0, "the refused write still reached the database");312+ assert(diagnostics.mutationErrors === 1, "the refusal was not counted in diagnostics");313+ assert(314+ store.getSnapshot().error?.includes("newer version") === true,315+ "the store snapshot does not carry the user-facing diagnostic",316+ );317+});
M package/runtime/table-mutations.ts +34 -4
@@ -3,7 +3,9 @@ import { syncing } from "./config.ts";
33 import type { RuntimeDiagnostics } from "./diagnostics.ts";44 import { settleDurableWrite } from "./durability.ts";55 import { getRuntime, runtimeRecreatedEvent, updateRuntimeDiagnostics } from "./runtime.ts";6+import { assertSchemaWritable } from "./schema-compat.ts";67 import type { TableRow } from "./table-store.ts";8+import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";79 810 /** Observable state shared by every mutation consumer for one table. */911 export type TableMutationSnapshot = {
@@ -29,6 +31,13 @@ export type TableMutationEnvironment = {
2931 syncConfigured(): boolean;3032 subscribeRuntimeRecreation(listener: () => void): () => void;3133 updateDiagnostics(update: (diagnostics: RuntimeDiagnostics) => void): void;34+ /**35+ * Runs before each mutation reaches the database: rejects to refuse the36+ * write (the schema-compatibility gate), and may resolve with a release37+ * function that is invoked once the write settles locally (the cross-tab38+ * write lock). Absent means writes are never guarded.39+ */40+ guardWrite?(): Promise<(() => void) | void>;3241 };3342 3443 /** Framework-neutral typed mutations and observable durability for one table. */
@@ -109,16 +118,33 @@ export class TableMutationStore<T extends TableRow, Init> {
109118 110119 async #perform<Result>(create: (db: Db) => MutationHandle<Result>): Promise<Result> {111120 if (this.#disposed) throw new Error("table mutation store has been released");112- const db = await this.#environment.getDb();113- let mutation: MutationHandle<Result>;121+ // The guard runs before the mutation reaches the vendor: a refusal (the122+ // schema-compatibility gate) surfaces like any mutation failure, and the123+ // returned release ends this write's hold on the cross-tab write lock124+ // once the write settles to the local tier — the quiescence bar an125+ // upgrade swap waits on.126+ let releaseGuard: (() => void) | undefined;114127 try {115- mutation = create(db);128+ releaseGuard = (await this.#environment.guardWrite?.()) ?? undefined;116129 } catch (error) {117130 this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);118131 this.#fail(error);119132 throw error;120133 }121- return await this.#settle(mutation);134+ try {135+ const db = await this.#environment.getDb();136+ let mutation: MutationHandle<Result>;137+ try {138+ mutation = create(db);139+ } catch (error) {140+ this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);141+ this.#fail(error);142+ throw error;143+ }144+ return await this.#settle(mutation);145+ } finally {146+ releaseGuard?.();147+ }122148 }123149 124150 #ownsBatch(event: MutationErrorEvent): boolean {
@@ -323,6 +349,10 @@ const defaultRegistry = new TableMutationRegistry({
323349 return () => globalThis.removeEventListener(runtimeRecreatedEvent, listener);324350 },325351 updateDiagnostics: updateRuntimeDiagnostics,352+ async guardWrite() {353+ await assertSchemaWritable();354+ return await acquireUpgradeWriteLock();355+ },326356 });327357 328358 /** Acquires the package-wide typed mutation surface for one table. */
M package/runtime/table-store.ts +30 -3
@@ -49,6 +49,12 @@ export type TableStoreOptions = {
4949 syncConfigured?: () => boolean;5050 /** Called whenever a diagnostics counter changes. */5151 onDiagnosticsChange?: () => void;52+ /**53+ * Runs before each mutation reaches the database: rejects to refuse the54+ * write (the schema-compatibility gate), and may resolve with a release55+ * function invoked once the write settles. Absent means unguarded writes.56+ */57+ guardWrite?: () => Promise<(() => void) | void>;5258 };5359 5460 /**
@@ -62,6 +68,7 @@ export class TableStore<T extends TableRow, Init> {
6268 readonly #diagnostics: RuntimeDiagnostics;6369 readonly #syncConfigured: () => boolean;6470 readonly #diagnosticsChanged: () => void;71+ readonly #guardWrite?: () => Promise<(() => void) | void>;6572 readonly #listeners = new Set<Listener>();6673 readonly #stopMutationErrors: () => void;6774 readonly #ownBatches = new Set<unknown>();
@@ -87,6 +94,7 @@ export class TableStore<T extends TableRow, Init> {
8794 this.#diagnostics = diagnostics;8895 this.#syncConfigured = options.syncConfigured ?? (() => false);8996 this.#diagnosticsChanged = options.onDiagnosticsChange ?? (() => undefined);97+ this.#guardWrite = options.guardWrite;9098 // Asynchronous write rejections that are not tied to an awaited mutation9199 // still have to reach diagnostics and the UI, so the store owns this92100 // listener. The vendor event is database-wide, so it is filtered to this
@@ -127,17 +135,36 @@ export class TableStore<T extends TableRow, Init> {
127135 128136 /** Inserts a row and waits for local durability before resolving. */129137 async insert(values: Init): Promise<void> {130- await this.#settle(this.#db.insert(this.#table, values));138+ await this.#perform(() => this.#db.insert(this.#table, values));131139 }132140 133141 /** Updates a row and waits for local durability before resolving. */134142 async update(id: string, patch: Partial<Init>): Promise<void> {135- await this.#settle(this.#db.update(this.#table, id, patch));143+ await this.#perform(() => this.#db.update(this.#table, id, patch));136144 }137145 138146 /** Deletes a row and waits for local durability before resolving. */139147 async delete(id: string): Promise<void> {140- await this.#settle(this.#db.delete(this.#table, id));148+ await this.#perform(() => this.#db.delete(this.#table, id));149+ }150+151+ // The guard runs before the mutation reaches the vendor: a refusal (the152+ // schema-compatibility gate) surfaces like any write failure, and the153+ // release ends this write's hold on the cross-tab write lock once the154+ // write settles — the quiescence bar an upgrade swap waits on.155+ async #perform(create: () => MutationHandle): Promise<void> {156+ let releaseGuard: (() => void) | undefined;157+ try {158+ releaseGuard = (await this.#guardWrite?.()) ?? undefined;159+ } catch (error) {160+ this.#fail(error);161+ throw error;162+ }163+ try {164+ await this.#settle(create());165+ } finally {166+ releaseGuard?.();167+ }141168 }142169 143170 /** Projects an asynchronous Jazz mutation rejection into diagnostics and store state. */
A package/runtime/upgrade-coordination.ts +233 -0
@@ -0,0 +1,233 @@
1+/**2+ * Cross-tab coordination for the service-worker swap.3+ *4+ * Two tabs on the same scope share one store, so an `applyUpdate()` in one5+ * tab must not race writes in another. The coordinator announces upgrade6+ * intent on a scope-keyed BroadcastChannel, every tab pauses new local writes7+ * for the swap window, and a scope-keyed Web Lock — held shared for the8+ * duration of each local write — lets the announcing tab wait for in-flight9+ * writes to settle (an exclusive acquisition) before the worker swap. Channel10+ * and lock names carry the registration scope exactly like the service11+ * worker's cache names, so sibling lofi apps on one origin never cross-talk.12+ *13+ * Every browser surface is feature-detected: without BroadcastChannel or Web14+ * Locks the coordinator degrades to local-only pausing, and the swap still15+ * proceeds after a bounded quiescence timeout.16+ *17+ * @module18+ */19+20+/** Minimal BroadcastChannel surface, injectable for tests. */21+export type UpgradeChannel = {22+ postMessage(message: unknown): void;23+ addEventListener(type: "message", listener: (event: MessageEvent) => void): void;24+ close(): void;25+};26+27+/** Minimal Web Locks surface, injectable for tests. */28+export type UpgradeLockManager = {29+ request<T>(30+ name: string,31+ options: { mode: "shared" | "exclusive" },32+ callback: (lock: unknown) => Promise<T> | T,33+ ): Promise<T>;34+};35+36+/** Derives the scope key used in channel and lock names from a scope path. */37+export function upgradeScopeKey(scopePath: string): string {38+ return scopePath.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "root";39+}40+41+/** The BroadcastChannel name for one scope's upgrade announcements. */42+export function upgradeChannelName(scopePath: string): string {43+ return `lofi-scope-${upgradeScopeKey(scopePath)}-upgrade`;44+}45+46+/** The Web Lock name held shared by local writes on one scope. */47+export function upgradeLockName(scopePath: string): string {48+ return `lofi-scope-${upgradeScopeKey(scopePath)}-writes`;49+}50+51+/** Injectable surfaces and bounds for one upgrade coordinator. */52+export type UpgradeCoordinatorOptions = {53+ /** The service-worker registration scope path (e.g. `/app/`). */54+ scopePath: string;55+ /** Channel factory; defaults to the browser BroadcastChannel when present. */56+ channel?: (name: string) => UpgradeChannel | undefined;57+ /** Lock manager; defaults to `navigator.locks` when present. */58+ locks?: () => UpgradeLockManager | undefined;59+ /** How long tabs pause writes waiting for the swap; defaults to 15 seconds. */60+ pauseWindowMs?: number;61+ /** How long the announcer waits for in-flight writes; defaults to 5 seconds. */62+ quiescenceTimeoutMs?: number;63+ readonly setTimeout?: (callback: () => void, milliseconds: number) => unknown;64+ readonly clearTimeout?: (handle: unknown) => void;65+};66+67+/** Coordinates one tab's participation in a scope-wide worker swap. */68+export type UpgradeCoordinator = {69+ /**70+ * Announces upgrade intent to every tab on the scope (this one included)71+ * and resolves once in-flight local writes have settled or the bounded72+ * quiescence window expired — the safe moment to swap the worker.73+ */74+ announceUpgrade(): Promise<void>;75+ /**76+ * Acquires the shared write lock for one local write, waiting out any77+ * announced swap window first. The returned function releases the hold and78+ * is safe to call more than once.79+ */80+ acquireWriteLock(): Promise<() => void>;81+ /** Ends this tab's pause window after the swap completed or was abandoned. */82+ notifyActivation(): void;83+ /** Whether writes are currently paused for an announced swap. */84+ writesPaused(): boolean;85+ /** Closes the channel and releases timers. */86+ dispose(): void;87+};88+89+const upgradeIntentMessage = { type: "lofi-upgrade-intent" } as const;90+91+/** Creates an isolated coordinator; production wiring uses the shared one. */92+export function createUpgradeCoordinator(options: UpgradeCoordinatorOptions): UpgradeCoordinator {93+ const pauseWindowMs = options.pauseWindowMs ?? 15_000;94+ const quiescenceTimeoutMs = options.quiescenceTimeoutMs ?? 5_000;95+ const scheduleTimeout = (callback: () => void, milliseconds: number) =>96+ options.setTimeout ? options.setTimeout(callback, milliseconds) : (97+ globalThis.setTimeout(callback, milliseconds)98+ );99+ const cancelTimeout = (handle: unknown) => {100+ if (options.clearTimeout) options.clearTimeout(handle);101+ else globalThis.clearTimeout(handle as number);102+ };103+ const locks = () =>104+ options.locks105+ ? options.locks()106+ : (typeof navigator !== "undefined" && "locks" in navigator107+ ? (navigator as { locks: UpgradeLockManager }).locks108+ : undefined);109+ const openChannel = (name: string): UpgradeChannel | undefined => {110+ if (options.channel) return options.channel(name);111+ return typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(name);112+ };113+114+ const lockName = upgradeLockName(options.scopePath);115+ let paused = false;116+ let pauseTimer: unknown;117+ const resumeWaiters = new Set<() => void>();118+ let disposed = false;119+120+ const resume = () => {121+ if (!paused) return;122+ paused = false;123+ if (pauseTimer !== undefined) cancelTimeout(pauseTimer);124+ pauseTimer = undefined;125+ for (const waiter of resumeWaiters) waiter();126+ resumeWaiters.clear();127+ };128+129+ // The pause window is bounded: a swap that never completes (the waiting130+ // worker failed, the announcing tab closed) must not leave every sibling131+ // tab refusing writes forever.132+ const pause = () => {133+ if (paused || disposed) return;134+ paused = true;135+ if (pauseTimer !== undefined) cancelTimeout(pauseTimer);136+ pauseTimer = scheduleTimeout(resume, pauseWindowMs);137+ };138+139+ const channel = openChannel(upgradeChannelName(options.scopePath));140+ channel?.addEventListener("message", (event) => {141+ const message = event.data as { type?: string } | undefined;142+ if (message?.type === upgradeIntentMessage.type) pause();143+ });144+145+ const waitWhilePaused = (): Promise<void> => {146+ if (!paused) return Promise.resolve();147+ return new Promise((resolve) => resumeWaiters.add(resolve));148+ };149+150+ const acquireQuiescence = (): Promise<void> => {151+ const manager = locks();152+ if (!manager) return Promise.resolve();153+ return new Promise((resolve) => {154+ let settled = false;155+ const finish = () => {156+ if (settled) return false;157+ settled = true;158+ return true;159+ };160+ const timer = scheduleTimeout(() => {161+ if (finish()) resolve();162+ }, quiescenceTimeoutMs);163+ // Acquiring the exclusive lock resolves only once every shared holder164+ // (an in-flight local write) released; the lock is dropped immediately165+ // — quiescence is a moment, not a held state.166+ void Promise.resolve(manager.request(lockName, { mode: "exclusive" }, () => undefined))167+ .catch(() => undefined)168+ .then(() => {169+ cancelTimeout(timer);170+ if (finish()) resolve();171+ });172+ });173+ };174+175+ return {176+ async announceUpgrade() {177+ channel?.postMessage(upgradeIntentMessage);178+ pause();179+ await acquireQuiescence();180+ },181+ async acquireWriteLock() {182+ await waitWhilePaused();183+ const manager = locks();184+ if (!manager) return () => undefined;185+ let release: () => void = () => undefined;186+ const held = new Promise<void>((resolve) => {187+ release = resolve;188+ });189+ // Wait for the acquisition itself: a write must not proceed while an190+ // announced swap holds (or is queued for) the exclusive lock.191+ await new Promise<void>((resolve) => {192+ void Promise.resolve(manager.request(lockName, { mode: "shared" }, () => {193+ resolve();194+ return held;195+ })).catch(() => resolve());196+ });197+ return release;198+ },199+ notifyActivation: resume,200+ writesPaused: () => paused,201+ dispose() {202+ disposed = true;203+ resume();204+ channel?.close();205+ },206+ };207+}208+209+let shared: UpgradeCoordinator | null = null;210+211+/**212+ * Configures the shared coordinator for the registration scope in effect.213+ * Called by the boot wiring once the deployment scope is known; repeated214+ * calls replace the previous coordinator.215+ */216+export function configureUpgradeCoordinator(scopePath: string): UpgradeCoordinator {217+ shared?.dispose();218+ shared = createUpgradeCoordinator({ scopePath });219+ return shared;220+}221+222+/** The shared coordinator, or `null` before boot wiring configured one. */223+export function sharedUpgradeCoordinator(): UpgradeCoordinator | null {224+ return shared;225+}226+227+/**228+ * Acquires the shared write lock for one local write through the shared229+ * coordinator; a no-op release when no coordinator is configured.230+ */231+export function acquireUpgradeWriteLock(): Promise<() => void> {232+ return shared ? shared.acquireWriteLock() : Promise.resolve(() => undefined);233+}
A package/runtime/upgrade-coordination_test.ts +255 -0
@@ -0,0 +1,255 @@
1+import {2+ createUpgradeCoordinator,3+ type UpgradeChannel,4+ upgradeChannelName,5+ type UpgradeCoordinatorOptions,6+ type UpgradeLockManager,7+ upgradeLockName,8+} from "./upgrade-coordination.ts";9+import { assert } from "./test-assert.ts";10+11+// A shared in-memory BroadcastChannel bus: every coordinator built over one12+// bus behaves like a tab on the same origin and scope.13+class FakeChannelBus {14+ readonly channels = new Map<string, Set<FakeChannel>>();15+16+ open(name: string): FakeChannel {17+ const channel = new FakeChannel(this, name);18+ let peers = this.channels.get(name);19+ if (!peers) {20+ peers = new Set();21+ this.channels.set(name, peers);22+ }23+ peers.add(channel);24+ return channel;25+ }26+27+ broadcast(from: FakeChannel, name: string, message: unknown): void {28+ for (const peer of this.channels.get(name) ?? []) {29+ // BroadcastChannel never delivers to the posting port.30+ if (peer !== from) peer.deliver(message);31+ }32+ }33+}34+35+class FakeChannel implements UpgradeChannel {36+ readonly #listeners = new Set<(event: MessageEvent) => void>();37+ constructor(private readonly bus: FakeChannelBus, private readonly name: string) {}38+ postMessage(message: unknown): void {39+ this.bus.broadcast(this, this.name, message);40+ }41+ addEventListener(_type: "message", listener: (event: MessageEvent) => void): void {42+ this.#listeners.add(listener);43+ }44+ deliver(message: unknown): void {45+ for (const listener of this.#listeners) {46+ listener({ data: message } as MessageEvent);47+ }48+ }49+ close(): void {50+ this.#listeners.clear();51+ this.bus.channels.get(this.name)?.delete(this);52+ }53+}54+55+// A minimal Web Locks implementation: shared requests coexist, an exclusive56+// request waits for every earlier holder, and later requests queue behind a57+// pending exclusive one — the semantics the quiescence handshake relies on.58+type LockRequest = {59+ mode: "shared" | "exclusive";60+ grant: () => void;61+};62+63+class FakeLockManager implements UpgradeLockManager {64+ readonly #queues = new Map<string, { holders: LockRequest[]; queue: LockRequest[] }>();65+66+ request<T>(67+ name: string,68+ options: { mode: "shared" | "exclusive" },69+ callback: (lock: unknown) => Promise<T> | T,70+ ): Promise<T> {71+ let state = this.#queues.get(name);72+ if (!state) {73+ state = { holders: [], queue: [] };74+ this.#queues.set(name, state);75+ }76+ const lockState = state;77+ return new Promise<T>((resolve, reject) => {78+ const request: LockRequest = {79+ mode: options.mode,80+ grant: () => {81+ lockState.holders.push(request);82+ void Promise.resolve()83+ .then(() => callback({}))84+ .then(resolve, reject)85+ .finally(() => {86+ lockState.holders.splice(lockState.holders.indexOf(request), 1);87+ this.#drain(lockState);88+ });89+ },90+ };91+ lockState.queue.push(request);92+ this.#drain(lockState);93+ });94+ }95+96+ #drain(state: { holders: LockRequest[]; queue: LockRequest[] }): void {97+ while (state.queue.length > 0) {98+ const next = state.queue[0];99+ if (next.mode === "exclusive") {100+ if (state.holders.length > 0) return;101+ state.queue.shift();102+ next.grant();103+ return;104+ }105+ if (state.holders.some((holder) => holder.mode === "exclusive")) return;106+ state.queue.shift();107+ next.grant();108+ }109+ }110+}111+112+function tab(113+ bus: FakeChannelBus,114+ locks: FakeLockManager,115+ overrides: Partial<UpgradeCoordinatorOptions> = {},116+) {117+ return createUpgradeCoordinator({118+ scopePath: "/app/",119+ channel: (name) => bus.open(name),120+ locks: () => locks,121+ ...overrides,122+ });123+}124+125+async function flush(times = 3): Promise<void> {126+ for (let i = 0; i < times; i += 1) await Promise.resolve();127+}128+129+Deno.test("channel and lock names are scope-keyed like the worker's cache names", () => {130+ assert(131+ upgradeChannelName("/field-notes/") === "lofi-scope-field-notes-upgrade",132+ "channel name is not scope-keyed",133+ );134+ assert(135+ upgradeLockName("/field-notes/") === "lofi-scope-field-notes-writes",136+ "lock name is not scope-keyed",137+ );138+ assert(upgradeChannelName("/") === "lofi-scope-root-upgrade", "root scope key drifted");139+});140+141+Deno.test("two tabs: the swap waits for the sibling's in-flight write to settle", async () => {142+ const bus = new FakeChannelBus();143+ const locks = new FakeLockManager();144+ const initiating = tab(bus, locks);145+ const sibling = tab(bus, locks);146+ try {147+ // The sibling has a write in flight (its shared lock is held).148+ const releaseWrite = await sibling.acquireWriteLock();149+ let swapped = false;150+ const announce = initiating.announceUpgrade().then(() => swapped = true);151+ await flush();152+ assert(sibling.writesPaused(), "the sibling did not pause writes on the announcement");153+ assert(initiating.writesPaused(), "the initiating tab did not pause its own writes");154+ assert(!swapped, "the swap proceeded while a write was in flight");155+ releaseWrite();156+ await announce;157+ assert(swapped, "the swap did not proceed once writes settled");158+ } finally {159+ initiating.dispose();160+ sibling.dispose();161+ }162+});163+164+Deno.test("two tabs: writes queued during the swap window resume after activation", async () => {165+ const bus = new FakeChannelBus();166+ const locks = new FakeLockManager();167+ const initiating = tab(bus, locks);168+ const sibling = tab(bus, locks);169+ try {170+ await initiating.announceUpgrade();171+ let acquired = false;172+ const write = sibling.acquireWriteLock().then((release) => {173+ acquired = true;174+ return release;175+ });176+ await flush();177+ assert(!acquired, "a paused sibling accepted a new write during the swap window");178+ // Activation reached the sibling (controllerchange); its window ends.179+ sibling.notifyActivation();180+ (await write)();181+ assert(acquired, "the sibling's queued write did not resume after activation");182+ } finally {183+ initiating.dispose();184+ sibling.dispose();185+ }186+});187+188+Deno.test("an abandoned swap window resumes writes at the bounded timeout", async () => {189+ const bus = new FakeChannelBus();190+ const locks = new FakeLockManager();191+ const timers: Array<() => void> = [];192+ const sibling = tab(bus, locks, {193+ setTimeout: (callback) => {194+ timers.push(callback);195+ return timers.length;196+ },197+ clearTimeout: () => undefined,198+ });199+ const initiating = tab(bus, locks);200+ try {201+ await initiating.announceUpgrade();202+ assert(sibling.writesPaused(), "the sibling did not pause writes");203+ // The worker swap never happens; the pause-window timer expires.204+ for (const expire of timers.splice(0)) expire();205+ assert(!sibling.writesPaused(), "the pause window did not expire");206+ (await sibling.acquireWriteLock())();207+ } finally {208+ initiating.dispose();209+ sibling.dispose();210+ }211+});212+213+Deno.test("quiescence is bounded when a shared holder never settles", async () => {214+ const bus = new FakeChannelBus();215+ const locks = new FakeLockManager();216+ const timers: Array<() => void> = [];217+ const initiating = tab(bus, locks, {218+ setTimeout: (callback) => {219+ timers.push(callback);220+ return timers.length;221+ },222+ clearTimeout: () => undefined,223+ });224+ const sibling = tab(bus, locks);225+ try {226+ await sibling.acquireWriteLock(); // never released227+ let swapped = false;228+ const announce = initiating.announceUpgrade().then(() => swapped = true);229+ await flush();230+ assert(!swapped, "the swap did not wait at all");231+ for (const expire of timers.splice(0)) expire();232+ await announce;233+ assert(swapped, "a hung write blocked the swap past its bound");234+ } finally {235+ initiating.dispose();236+ sibling.dispose();237+ }238+});239+240+Deno.test("without Web Locks and BroadcastChannel the coordinator degrades safely", async () => {241+ const coordinator = createUpgradeCoordinator({242+ scopePath: "/app/",243+ channel: () => undefined,244+ locks: () => undefined,245+ });246+ try {247+ (await coordinator.acquireWriteLock())();248+ await coordinator.announceUpgrade();249+ assert(coordinator.writesPaused(), "the local pause window is still expected");250+ coordinator.notifyActivation();251+ assert(!coordinator.writesPaused(), "activation did not end the local pause");252+ } finally {253+ coordinator.dispose();254+ }255+});
A package/schema/compat.ts +190 -0
@@ -0,0 +1,190 @@
1+/**2+ * Revision↔schema compatibility: the manifest a build stamps next to its3+ * service worker, and the classification the boot gate runs against the4+ * schema version recorded beside the local data.5+ *6+ * The bundle and the data version independently, so an offline shell can be7+ * older than the data another tab or device migrated. The fingerprint here is8+ * lofi-owned and deliberately independent of the sync server's schema hash:9+ * both sides of every comparison — the built manifest and the local stamp —10+ * are produced by this module, so the only requirement is that the11+ * normalization stays stable across releases. The `v1:` prefix names that12+ * normalization; a future change ships as `v2:` and classifies against `v1:`13+ * values as unrelated rather than silently equal.14+ *15+ * @module16+ */17+18+import { nestedAppDeployTarget, nestedAppTables } from "./nested.ts";19+20+/** The schema range one build understands: its head and every known ancestor. */21+export type SchemaVersionRange = {22+ /** The fingerprint of the schema this build compiles against. */23+ head: string;24+ /** Fingerprints of the head and every ancestor schema (migration lineage). */25+ lineage: readonly string[];26+};27+28+/** The build-stamped compatibility manifest (`dist/lofi-schema.json`). */29+export type SchemaCompatManifest = SchemaVersionRange & {30+ /** Manifest format version. */31+ v: 1;32+ /** The build revision the schema range belongs to (the source hash). */33+ revision: string;34+};35+36+/**37+ * How one build's supported schema range relates to the version the local38+ * data was last written under.39+ */40+export type SchemaCompatClassification =41+ /** No recorded local version — nothing constrains this build. */42+ | "first-boot"43+ /** The local data and this build share one schema head. */44+ | "equal"45+ /** This build's lineage contains the local version: migrations run forward. */46+ | "code-ahead"47+ /** The local data's lineage contains this build's head: the data is newer. */48+ | "data-ahead"49+ /** Neither range contains the other; treat like newer data, never write. */50+ | "unrelated";51+52+// Canonical JSON: object keys sorted recursively, `undefined` entries dropped53+// exactly as JSON serialization drops them (so an in-memory schema and its54+// JSON snapshot fingerprint identically), and any `columns` array sorted by55+// column name — the engine treats column order as insignificant, so the56+// fingerprint must too. Everything else keeps its value verbatim.57+function canonicalize(value: unknown, key?: string): unknown {58+ if (Array.isArray(value)) {59+ const entries = value.map((entry) => canonicalize(entry) ?? null);60+ if (key === "columns") {61+ entries.sort((left, right) => {62+ const leftName = (left as { name?: unknown })?.name;63+ const rightName = (right as { name?: unknown })?.name;64+ return String(leftName).localeCompare(String(rightName));65+ });66+ }67+ return entries;68+ }69+ if (value && typeof value === "object") {70+ const sorted: Record<string, unknown> = {};71+ for (const name of Object.keys(value as Record<string, unknown>).sort()) {72+ const entry = canonicalize((value as Record<string, unknown>)[name], name);73+ if (entry !== undefined) sorted[name] = entry;74+ }75+ return sorted;76+ }77+ return value;78+}79+80+/**81+ * Computes the stable lofi fingerprint of one compiled (wasm-form) schema:82+ * `v1:` plus 16 hex characters of the SHA-256 of the canonicalized schema.83+ */84+export async function computeSchemaFingerprint(85+ wasmSchema: Record<string, unknown>,86+): Promise<string> {87+ const input = new TextEncoder().encode(JSON.stringify(canonicalize(wasmSchema)));88+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));89+ const hex = Array.from(digest.slice(0, 8))90+ .map((byte) => byte.toString(16).padStart(2, "0"))91+ .join("");92+ return `v1:${hex}`;93+}94+95+/**96+ * The compiled wasm-form schema of an app value: a flat `defineApp` result97+ * carries it directly, a nested root exposes it through its deploy target.98+ * Throws when the value is neither.99+ */100+export function resolveAppWasmSchema(app: unknown): Record<string, unknown> {101+ if (app && typeof app === "object") {102+ if (nestedAppTables(app) !== null) {103+ const target = nestedAppDeployTarget(app) as { wasmSchema?: Record<string, unknown> };104+ if (target.wasmSchema) return target.wasmSchema;105+ }106+ const wasmSchema = (app as { wasmSchema?: unknown }).wasmSchema;107+ if (wasmSchema && typeof wasmSchema === "object") {108+ return wasmSchema as Record<string, unknown>;109+ }110+ }111+ throw new Error(112+ "schema compatibility requires an app value from s.defineApp or s.defineNestedApp",113+ );114+}115+116+/**117+ * Builds the schema version range for one app: the head fingerprint plus the118+ * fingerprints of every ancestor schema (the committed migration snapshots).119+ * The head is always part of the lineage; ancestor order is not significant —120+ * classification is containment, not ordering.121+ */122+export async function buildSchemaVersionRange(123+ app: unknown,124+ ancestorSchemas: readonly Record<string, unknown>[] = [],125+): Promise<SchemaVersionRange> {126+ const head = await computeSchemaFingerprint(resolveAppWasmSchema(app));127+ const lineage = new Set<string>();128+ for (const ancestor of ancestorSchemas) {129+ lineage.add(await computeSchemaFingerprint(ancestor));130+ }131+ lineage.add(head);132+ return { head, lineage: [...lineage].sort() };133+}134+135+/**136+ * Classifies one build's supported schema range against the version range137+ * recorded beside the local data. Containment decides direction: a build138+ * whose lineage includes the local head is ahead of the data (migrations run139+ * as usual), a local record whose lineage includes the build's head was140+ * stamped by a newer build (the data is ahead), and two ranges that contain141+ * neither head are unrelated.142+ */143+export function classifySchemaCompat(144+ bundle: SchemaVersionRange,145+ local: SchemaVersionRange | null,146+): SchemaCompatClassification {147+ if (!local) return "first-boot";148+ if (local.head === bundle.head) return "equal";149+ if (bundle.lineage.includes(local.head)) return "code-ahead";150+ if (local.lineage.includes(bundle.head)) return "data-ahead";151+ return "unrelated";152+}153+154+function isVersionRange(value: unknown): value is SchemaVersionRange {155+ if (!value || typeof value !== "object") return false;156+ const range = value as { head?: unknown; lineage?: unknown };157+ return typeof range.head === "string" && range.head.length > 0 &&158+ Array.isArray(range.lineage) &&159+ range.lineage.every((entry) => typeof entry === "string") &&160+ range.lineage.includes(range.head);161+}162+163+/** Parses an untrusted manifest value; `null` when the shape is not usable. */164+export function parseSchemaCompatManifest(value: unknown): SchemaCompatManifest | null {165+ if (!value || typeof value !== "object") return null;166+ const manifest = value as { v?: unknown; revision?: unknown };167+ if (manifest.v !== 1 || typeof manifest.revision !== "string") return null;168+ if (!isVersionRange(value)) return null;169+ const range = value as SchemaVersionRange;170+ return { v: 1, revision: manifest.revision, head: range.head, lineage: [...range.lineage] };171+}172+173+/** Parses a persisted local schema-version record; `null` when unusable. */174+export function parseLocalSchemaVersion(value: unknown): SchemaVersionRange | null {175+ if (typeof value !== "string" || !value) return null;176+ try {177+ const parsed = JSON.parse(value) as { v?: unknown };178+ if (!parsed || typeof parsed !== "object" || parsed.v !== 1) return null;179+ if (!isVersionRange(parsed)) return null;180+ const range = parsed as unknown as SchemaVersionRange;181+ return { head: range.head, lineage: [...range.lineage] };182+ } catch {183+ return null;184+ }185+}186+187+/** Serializes a local schema-version record for persistence beside the store. */188+export function serializeLocalSchemaVersion(range: SchemaVersionRange): string {189+ return JSON.stringify({ v: 1, head: range.head, lineage: [...range.lineage] });190+}
A package/schema/compat_test.ts +130 -0
@@ -0,0 +1,130 @@
1+import {2+ buildSchemaVersionRange,3+ classifySchemaCompat,4+ computeSchemaFingerprint,5+ parseLocalSchemaVersion,6+ parseSchemaCompatManifest,7+ resolveAppWasmSchema,8+ serializeLocalSchemaVersion,9+} from "./compat.ts";10+import { s } from "./mod.ts";11+12+function assert(condition: unknown, message: string): asserts condition {13+ if (!condition) throw new Error(message);14+}15+16+const appV1 = s.defineApp({17+ todos: s.table({ title: s.string(), done: s.boolean() }),18+});19+const appV2 = s.defineApp({20+ todos: s.table({ title: s.string(), done: s.boolean(), note: s.string() }),21+});22+23+Deno.test("schema fingerprints are stable, versioned, and column-order insensitive", async () => {24+ const first = await computeSchemaFingerprint(resolveAppWasmSchema(appV1));25+ const again = await computeSchemaFingerprint(resolveAppWasmSchema(appV1));26+ assert(first === again, "the same schema produced two fingerprints");27+ assert(/^v1:[0-9a-f]{16}$/.test(first), `fingerprint is not versioned hex: ${first}`);28+29+ const reordered = {30+ todos: {31+ columns: [32+ { name: "title", column_type: "Text" },33+ { name: "done", column_type: "Boolean" },34+ ],35+ },36+ };37+ const sorted = {38+ todos: {39+ columns: [40+ { name: "done", column_type: "Boolean" },41+ { name: "title", column_type: "Text" },42+ ],43+ },44+ };45+ assert(46+ await computeSchemaFingerprint(reordered) === await computeSchemaFingerprint(sorted),47+ "column order changed the fingerprint",48+ );49+ assert(50+ first !== await computeSchemaFingerprint(resolveAppWasmSchema(appV2)),51+ "two different schemas collided",52+ );53+ const roundTripped = JSON.parse(54+ JSON.stringify(resolveAppWasmSchema(appV1)),55+ ) as Record<string, unknown>;56+ assert(57+ first === await computeSchemaFingerprint(roundTripped),58+ "a schema and its JSON snapshot fingerprint differently",59+ );60+});61+62+Deno.test("nested apps fingerprint through their deploy target", async () => {63+ const nested = s.defineNestedApp({64+ taskapp: { tasks: s.table({ text: s.string() }) },65+ });66+ const fingerprint = await computeSchemaFingerprint(resolveAppWasmSchema(nested));67+ assert(/^v1:[0-9a-f]{16}$/.test(fingerprint), "nested app produced no fingerprint");68+});69+70+Deno.test("a schema version range carries its head and every ancestor", async () => {71+ const ancestor = resolveAppWasmSchema(appV1);72+ const range = await buildSchemaVersionRange(appV2, [ancestor]);73+ const ancestorFingerprint = await computeSchemaFingerprint(ancestor);74+ const head = await computeSchemaFingerprint(resolveAppWasmSchema(appV2));75+ assert(range.head === head, "range head is not the app's fingerprint");76+ assert(range.lineage.includes(head), "range lineage omitted the head");77+ assert(range.lineage.includes(ancestorFingerprint), "range lineage omitted the ancestor");78+ const noAncestors = await buildSchemaVersionRange(appV1);79+ assert(80+ noAncestors.lineage.length === 1 && noAncestors.lineage[0] === noAncestors.head,81+ "an unmigrated app's lineage is not exactly its head",82+ );83+});84+85+Deno.test("gate classification covers first-boot, equal, code-ahead, data-ahead, unrelated", () => {86+ const older = { head: "v1:a", lineage: ["v1:a"] };87+ const newer = { head: "v1:b", lineage: ["v1:a", "v1:b"] };88+ const foreign = { head: "v1:z", lineage: ["v1:z"] };89+ assert(classifySchemaCompat(newer, null) === "first-boot", "no record must be first-boot");90+ assert(classifySchemaCompat(newer, newer) === "equal", "same head must be equal");91+ assert(92+ classifySchemaCompat(newer, older) === "code-ahead",93+ "a bundle whose lineage contains the data's head must be code-ahead",94+ );95+ assert(96+ classifySchemaCompat(older, newer) === "data-ahead",97+ "data stamped by a newer bundle must be data-ahead",98+ );99+ assert(100+ classifySchemaCompat(older, foreign) === "unrelated",101+ "disconnected histories must be unrelated",102+ );103+});104+105+Deno.test("manifest and local-record parsing reject unusable shapes", () => {106+ const manifest = parseSchemaCompatManifest({107+ v: 1,108+ revision: "abc",109+ head: "v1:a",110+ lineage: ["v1:a"],111+ });112+ assert(manifest?.head === "v1:a", "a valid manifest was rejected");113+ assert(parseSchemaCompatManifest(null) === null, "null passed manifest parsing");114+ assert(115+ parseSchemaCompatManifest({ v: 2, revision: "abc", head: "v1:a", lineage: ["v1:a"] }) === null,116+ "an unknown manifest version was accepted",117+ );118+ assert(119+ parseSchemaCompatManifest({ v: 1, revision: "abc", head: "v1:a", lineage: ["v1:b"] }) === null,120+ "a manifest whose lineage omits its head was accepted",121+ );122+123+ const record = serializeLocalSchemaVersion({ head: "v1:a", lineage: ["v1:a"] });124+ assert(125+ parseLocalSchemaVersion(record)?.head === "v1:a",126+ "a round-tripped local record was rejected",127+ );128+ assert(parseLocalSchemaVersion("not json") === null, "malformed JSON passed record parsing");129+ assert(parseLocalSchemaVersion(null) === null, "a missing record did not parse to null");130+});
M package/testdata/starter.snapshot.json +1 -1
M package/tooling/project_test.ts +4 -0
M package/tooling/pwa-validation.ts +23 -0
M package/tooling/pwa-validation_test.ts +13 -1
@@ -414,7 +414,11 @@ async function makeProductionFixture(): Promise<string> {
414414 join(dist, "sw.js"),415415 `const revision = "fixture-hash"; new URL("./lofi-precache.json", self.registration.scope);\n`,416416 );417- files.push("index.html", "settings/index.html", "lofi-build.json", "sw.js");417+ await Deno.writeTextFile(418+ join(dist, "lofi-schema.json"),419+ `${JSON.stringify({ v: 1, revision: "fixture-hash", head: "v1:aa", lineage: ["v1:aa"] })}\n`,420+ );421+ files.push("index.html", "settings/index.html", "lofi-build.json", "lofi-schema.json", "sw.js");418422 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));419423 await Deno.writeTextFile(420424 join(dist, "lofi-precache.json"),
@@ -448,6 +452,10 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
448452 join(dist, "sw.js"),449453 `new URL("./lofi-precache.json", self.registration.scope);\n`,450454 );455+ await Deno.writeTextFile(456+ join(dist, "lofi-schema.json"),457+ `${JSON.stringify({ v: 1, revision: "other-hash", head: "v1:aa", lineage: ["v1:aa"] })}\n`,458+ );451459 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));452460 manifest.shortcuts[0].url = "./missing/";453461 manifest.share_target = {
@@ -469,6 +477,10 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
469477 assert(hasIssue(issues, "settings/index.html: must link"), "nested manifest link drift passed");470478 assert(hasIssue(issues, "entries do not match"), "precache drift passed");471479 assert(hasIssue(issues, "build revision does not match"), "worker revision drift passed");480+ assert(481+ hasIssue(issues, "lofi-schema.json: schema compatibility manifest"),482+ "schema manifest revision drift passed",483+ );472484 assert(hasIssue(issues, "has no emitted offline route"), "missing shortcut route passed");473485 assert(474486 hasIssue(issues, "share_target.action has no emitted offline route"),
A package/tooling/schema-manifest.ts +100 -0
@@ -0,0 +1,100 @@
1+/**2+ * Build-time generation of the revision↔schema compatibility manifest3+ * (`dist/lofi-schema.json`).4+ *5+ * The head fingerprint comes from the project's own schema module, and the6+ * lineage from the committed migration snapshots (each snapshot is a prior7+ * schema in wasm form). Both are computable offline at build time; the boot8+ * gate later compares the shipped range against the version recorded beside9+ * the local data.10+ *11+ * @module12+ */13+14+import { join, resolve } from "node:path";15+import { pathToFileURL } from "node:url";16+import {17+ buildSchemaVersionRange,18+ resolveAppWasmSchema,19+ type SchemaCompatManifest,20+} from "../schema/compat.ts";21+import { nestedAppTables } from "../schema/nested.ts";22+23+function isAppValue(value: unknown): boolean {24+ if (!value || typeof value !== "object") return false;25+ if (nestedAppTables(value) !== null) return true;26+ const wasmSchema = (value as { wasmSchema?: unknown }).wasmSchema;27+ return Boolean(wasmSchema && typeof wasmSchema === "object");28+}29+30+/**31+ * Resolves the app value from a schema module's exports: the `app` export32+ * when it is one, otherwise the single exported app-shaped value. Throws an33+ * actionable error when none (or more than one candidate) is found.34+ */35+export function findSchemaAppExport(moduleExports: Record<string, unknown>): unknown {36+ if (isAppValue(moduleExports.app)) return moduleExports.app;37+ const candidates = Object.entries(moduleExports).filter(([, value]) => isAppValue(value));38+ if (candidates.length === 1) return candidates[0][1];39+ throw new Error(40+ candidates.length === 041+ ? "src/schema.ts does not export an app value (s.defineApp or s.defineNestedApp); " +42+ "the schema compatibility manifest needs one to fingerprint the schema"43+ : `src/schema.ts exports several app values (${44+ candidates.map(([name]) => name).join(", ")45+ }); export the deployed one as \`app\``,46+ );47+}48+49+async function readSnapshotSchemas(50+ snapshotsDir: string,51+): Promise<Record<string, unknown>[]> {52+ let entries: Deno.DirEntry[];53+ try {54+ entries = [...Deno.readDirSync(snapshotsDir)];55+ } catch (error) {56+ if (error instanceof Deno.errors.NotFound) return [];57+ throw error;58+ }59+ const schemas: Record<string, unknown>[] = [];60+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {61+ if (!entry.isFile || !entry.name.endsWith(".json")) continue;62+ const parsed = JSON.parse(await Deno.readTextFile(join(snapshotsDir, entry.name)));63+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {64+ schemas.push(parsed as Record<string, unknown>);65+ }66+ }67+ return schemas;68+}69+70+/**71+ * Generates the compatibility manifest for one project: imports the schema72+ * module, fingerprints its compiled schema as the head, and fingerprints73+ * every committed migration snapshot (`src/migrations/snapshots/*.json`) as74+ * the lineage.75+ */76+export async function generateSchemaCompatManifest(options: {77+ /** The build revision (source hash) this manifest belongs to. */78+ revision: string;79+ /** Project root; defaults to the current working directory. */80+ root?: string;81+}): Promise<SchemaCompatManifest> {82+ const root = resolve(options.root ?? Deno.cwd());83+ const schemaPath = join(root, "src", "schema.ts");84+ let moduleExports: Record<string, unknown>;85+ try {86+ moduleExports = await import(pathToFileURL(schemaPath).href) as Record<string, unknown>;87+ } catch (error) {88+ throw new Error(89+ `failed to load ${schemaPath} for the schema compatibility manifest: ${90+ error instanceof Error ? error.message : String(error)91+ }`,92+ );93+ }94+ const app = findSchemaAppExport(moduleExports);95+ // Resolve eagerly so a malformed app fails here with the module named.96+ resolveAppWasmSchema(app);97+ const ancestors = await readSnapshotSchemas(join(root, "src", "migrations", "snapshots"));98+ const range = await buildSchemaVersionRange(app, ancestors);99+ return { v: 1, revision: options.revision, head: range.head, lineage: range.lineage };100+}
A package/tooling/schema-manifest_test.ts +109 -0
@@ -0,0 +1,109 @@
1+import { join } from "node:path";2+import { pathToFileURL } from "node:url";3+import { computeSchemaFingerprint, parseSchemaCompatManifest } from "../schema/compat.ts";4+import { findSchemaAppExport, generateSchemaCompatManifest } from "./schema-manifest.ts";5+6+function assert(condition: unknown, message: string): asserts condition {7+ if (!condition) throw new Error(message);8+}9+10+const schemaModuleUrl = pathToFileURL(11+ join(import.meta.dirname!, "..", "schema", "mod.ts"),12+).href;13+14+async function makeProject(schemaSource: string): Promise<string> {15+ const root = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-schema-manifest-test-" });16+ await Deno.mkdir(join(root, "src"), { recursive: true });17+ await Deno.writeTextFile(join(root, "src", "schema.ts"), schemaSource);18+ return root;19+}20+21+const v1Source = `import { s } from ${JSON.stringify(schemaModuleUrl)};22+export const app = s.defineApp({23+ todos: s.table({ title: s.string(), done: s.boolean() }),24+});25+`;26+27+const v2Source = `import { s } from ${JSON.stringify(schemaModuleUrl)};28+export const app = s.defineApp({29+ todos: s.table({ title: s.string(), done: s.boolean(), note: s.string() }),30+});31+`;32+33+Deno.test("the build manifest stamps the schema head for the given revision", async () => {34+ const root = await makeProject(v1Source);35+ try {36+ const manifest = await generateSchemaCompatManifest({ revision: "rev-1", root });37+ assert(parseSchemaCompatManifest(manifest) !== null, "generated manifest failed to parse");38+ assert(manifest.revision === "rev-1", "manifest lost the build revision");39+ assert(/^v1:[0-9a-f]{16}$/.test(manifest.head), `manifest head is malformed: ${manifest.head}`);40+ assert(41+ manifest.lineage.length === 1 && manifest.lineage[0] === manifest.head,42+ "an unmigrated project's lineage is not exactly its head",43+ );44+ } finally {45+ await Deno.remove(root, { recursive: true });46+ }47+});48+49+Deno.test("committed migration snapshots become the manifest lineage", async () => {50+ const root = await makeProject(v2Source);51+ try {52+ // The committed snapshot of the previous schema, as `migrations create`53+ // writes it: the prior schema in wasm form.54+ const previous = await makeProject(v1Source);55+ let previousWasm: Record<string, unknown>;56+ try {57+ const moduleExports = await import(58+ pathToFileURL(join(previous, "src", "schema.ts")).href59+ ) as { app: { wasmSchema: Record<string, unknown> } };60+ previousWasm = moduleExports.app.wasmSchema;61+ } finally {62+ await Deno.remove(previous, { recursive: true });63+ }64+ const snapshots = join(root, "src", "migrations", "snapshots");65+ await Deno.mkdir(snapshots, { recursive: true });66+ await Deno.writeTextFile(67+ join(snapshots, "20260101T000000-abcdef123456.json"),68+ `${JSON.stringify(previousWasm)}\n`,69+ );70+ const manifest = await generateSchemaCompatManifest({ revision: "rev-2", root });71+ const ancestor = await computeSchemaFingerprint(previousWasm);72+ assert(manifest.lineage.includes(manifest.head), "lineage omitted the head");73+ assert(manifest.lineage.includes(ancestor), "lineage omitted the snapshot ancestor");74+ assert(manifest.head !== ancestor, "the migrated schema collided with its ancestor");75+ } finally {76+ await Deno.remove(root, { recursive: true });77+ }78+});79+80+Deno.test("a schema module without an app value fails with an actionable error", async () => {81+ const root = await makeProject(`export const notAnApp = { tables: [] };\n`);82+ try {83+ let message = "";84+ try {85+ await generateSchemaCompatManifest({ revision: "rev-3", root });86+ } catch (error) {87+ message = error instanceof Error ? error.message : String(error);88+ }89+ assert(message.includes("does not export an app value"), `unhelpful error: ${message}`);90+ } finally {91+ await Deno.remove(root, { recursive: true });92+ }93+});94+95+Deno.test("the app export is found by name or as the single app-shaped export", () => {96+ const app = { wasmSchema: { todos: { columns: [] } } };97+ assert(findSchemaAppExport({ app }) === app, "the app export was not preferred");98+ assert(99+ findSchemaAppExport({ v2: app, migration: { forward: [] } }) === app,100+ "a single renamed app export was not found",101+ );102+ let message = "";103+ try {104+ findSchemaAppExport({ v1: app, v2: { wasmSchema: { notes: { columns: [] } } } });105+ } catch (error) {106+ message = error instanceof Error ? error.message : String(error);107+ }108+ assert(message.includes("several app values"), `ambiguity was not reported: ${message}`);109+});