clone

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

fix(runtime): gate sync recovery on election, isolate SW caches per scope

Dami committed 0ff9705
Resolves the sync/store and service-worker findings from the full review:
SYNC-1..6 and SW-1..5.

Sync and stores:
- SYNC-1: foreground recovery gains a live shouldReconnect gate wired to
  syncing() and a new shared transport-gate flag the inspector sets before
  pausing. Recovery no longer silently resumes replication after
  stopSyncBackup() or behind an inspector pause; a configured server alone
  is not consent.
- SYNC-2: TableStore reads sync configuration through a live callback
  instead of capturing it at construction, so stores follow later elections
  and stops without being recreated - no accumulated global waits after
  stopping, and global durability reporting resumes after re-election.
- SYNC-3: an unsubscribe arriving after close() no longer double-decrements
  activeConsumers.
- SYNC-4: database-wide mutation-error events are attributed by batch id;
  a store fails and counts only its own batches. When batch ids are
  unavailable, every event still surfaces rather than silencing errors.
- SYNC-5: a mutation no longer flips a still-loading store to ready with
  empty rows before the first query result.
- SYNC-6: late global rejections are write-generation guarded like
  successes; a superseded write's rejection stays diagnosable but cannot
  overwrite the newer write's durability.

Service worker and PWA:
- SW-1: cache names carry the registration scope (lofi-scope-<key>-...),
  chosen to escape the legacy prefixes so not-yet-upgraded sibling workers
  cannot prune them; activation deletes only the current scope's stale
  revisions and leaves ambiguous legacy names alone.
- SW-2: shell precache requests use cache:"reload", so a new revision can
  never be populated from stale HTTP-cache responses.
- SW-3: every controlled tab reloads once when a new worker takes over,
  not only the applyUpdate() initiator; first claims never reload and a
  document reloads at most once, preventing loops.
- SW-4: Range requests bypass the worker entirely.
- SW-5: lookups consult only the worker's own caches, so product or
  sibling-app caches can no longer shadow lofi resources.

deployment.md updated for the new cache-isolation and multi-tab reload
contracts. New tests cover every finding; existing global-deletion tests
were rewritten to codify scope isolation instead.
commit
0ff9705dd4dfc86e1912d1e756b9459dac0d9d20
parent
c140e223a9e838225855feb5fa38922b471fabed
author
Dami <dami.etoile@gmail.com>
15 files changed, +525 -48
Mdocs/deployment.md30+++++--
Mpackage/runtime/foreground-recovery.ts10++
Mpackage/runtime/foreground-recovery_test.ts33++++++++
Mpackage/runtime/lifecycle.ts6+-
Mpackage/runtime/probe.ts17+++-
Mpackage/runtime/pwa.ts15+++-
Mpackage/runtime/pwa_test.ts35+++++++++
Mpackage/runtime/runtime.ts4+-
Mpackage/runtime/sw.js51++++++++++--
Mpackage/runtime/sw_test.ts91+++++++++++++++++++++--
Mpackage/runtime/table-mutations.ts37+++++++++-
Mpackage/runtime/table-mutations_test.ts36+++++++++
Mpackage/runtime/table-store.ts65++++++++++++++--
Mpackage/runtime/table-store_test.ts121+++++++++++++++++++++++++++++-
Apackage/runtime/transport-gate.ts22+++++
M docs/deployment.md +22 -8
@@ -161,16 +161,24 @@ emitted, the worker falls back to the cached application root.
161161 162162 ### Offline cache policy163163 164-The build's precache manifest contains required shell resources only. If any listed response cannot165-be fetched, service-worker installation fails and reports a precache error rather than exposing a166-worker that cannot cold-start the application. Product-specific optional resources do not belong in167-that manifest.164+The build's precache manifest contains required shell resources only. Shell assets are fetched165+bypassing the HTTP cache (matching the manifest's own `no-store` fetch), so a new revision can never166+be populated from stale HTTP-cached responses. If any listed response cannot be fetched,167+service-worker installation fails and reports a precache error rather than exposing a worker that168+cannot cold-start the application. Product-specific optional resources do not belong in that169+manifest.170+171+Cache names carry the registration scope, and lookups consult only the worker's own caches: apps172+served from different base paths on one origin never read, shadow, or delete each other's caches,173+and activation prunes only the current scope's previous revisions. Requests carrying a `Range`174+header are left to the network — a cached complete response cannot answer a partial request.168175 169176 Runtime caching is a separate, best-effort policy. It accepts only successful, public, same-origin170177 responses inside the worker scope for fonts, images, scripts, styles, and workers. Navigation,171178 cross-origin requests, partial responses, `private` or `no-store` responses, and `Vary: *` responses172179 are not added. The cache retains at most 64 entries, moves refreshed URLs to the end, evicts the173-oldest inserted overflow, and removes previous build revisions during activation.180+oldest inserted overflow, and removes the current scope's previous build revisions during181+activation.174182 175183 Navigation preload remains disabled: generated routes and their assets are precached, so starting a176184 parallel network request before the normal cache-first lookup would spend bandwidth on the expected
@@ -186,9 +194,15 @@ browser offers it; an insecure or unsupported context is reported separately.
186194 When the app returns to the foreground, restores from the back-forward cache, or reconnects, Lofi187195 asks the active service-worker registration to check for an update. Checks share one in-flight188196 request, time out, and are rate-limited. Update state moves through `checking`, `installing`,189-`ready`, and `applying`; a waiting worker activates only after the user chooses **Update app**. Only190-that explicit action permits the following controller change to reload the page, so ordinary worker191-changes cannot create a reload loop.197+`ready`, and `applying`; a waiting worker activates only after the user chooses **Update app**. Once198+the new worker takes control, every claimed tab reloads — the new worker prunes the old revision's199+caches, so a document left on the old HTML and module graph would go stale — not only the tab that200+chose the update. The first claim of a previously uncontrolled page does not reload, and a document201+reloads at most once, so ordinary worker changes cannot create a reload loop.202+203+Foreground recovery reconnects managed sync only while the current account has elected sync and the204+development inspector has not paused the transport; a configured server alone is not consent to205+resume replication.192206 193207 A runtime-cache write error is best-effort and leaves the active worker ready. Registration,194208 required precache, and activation failures remain worker failures; update-check failures leave the
M package/runtime/foreground-recovery.ts +10 -0
M package/runtime/foreground-recovery_test.ts +33 -0
@@ -149,3 +149,36 @@ test("disposed lifecycle removes every browser event listener", async () => {
149149 await Promise.resolve();150150 assertCount(reconnects, 0, "disposed lifecycle retained a browser listener");151151 });152+153+test("recovery is idle while the live gate withholds consent", async () => {154+ const page = new EventTarget();155+ const document = new EventTarget();156+ let elected = false;157+ let reconnects = 0;158+ const manager = createForegroundRecovery({159+ enabled: true,160+ pageTarget: page,161+ visibilityTarget: document,162+ isVisible: () => true,163+ isOnline: () => true,164+ shouldReconnect: () => elected,165+ reconnect: () => {166+ reconnects += 1;167+ return Promise.resolve();168+ },169+ });170+ // A configured server is not consent: with sync stopped or the transport171+ // paused, foreground signals must not silently resume replication.172+ document.dispatchEvent(event("visibilitychange"));173+ page.dispatchEvent(event("online"));174+ await Promise.resolve();175+ assertCount(reconnects, 0, "recovery reconnected without an election");176+ assert(manager.getState().status === "idle", "a withheld gate must leave recovery idle");177+ assertCount(manager.getState().attempts, 0, "a withheld gate must not count attempts");178+ elected = true;179+ page.dispatchEvent(event("online"));180+ await Promise.resolve();181+ await Promise.resolve();182+ assertCount(reconnects, 1, "an elected account must recover on the next signal");183+ manager.dispose();184+});
M package/runtime/lifecycle.ts +5 -1
M package/runtime/probe.ts +12 -5
@@ -4,6 +4,7 @@ import { readDeviceCapabilityReport } from "./device-capabilities.ts";
44 import { type InspectorAdapter, type InspectorSnapshot, mountInspector } from "./inspector.ts";55 import { getForegroundRecoveryState, subscribeForegroundRecovery } from "./lifecycle.ts";66 import { getRuntime, getRuntimeDiagnostics, subscribeRuntimeDiagnostics } from "./runtime.ts";7+import { isTransportPausedByInspector, setTransportPausedByInspector } from "./transport-gate.ts";78 89 export type LofiDevelopmentBridge = InspectorAdapter;910
@@ -12,7 +13,6 @@ type LofiDevelopmentGlobal = typeof globalThis & {
1213 };1314 1415 const stateListeners = new Set<() => void>();15-let transportPaused = false;1616 1717 function reloadBrowserClient(): Promise<void> {1818 globalThis.location.reload();
@@ -59,7 +59,7 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
5959 sync: {6060 mode: serverUrl ? "managed configured" : "local-only",6161 transport: serverUrl62- ? transportPaused ? "paused by inspector" : "live detail unavailable"62+ ? isTransportPausedByInspector() ? "paused by inspector" : "live detail unavailable"6363 : "not configured",6464 pendingLocalWrites: diagnostics.pendingLocalWrites,6565 pendingGlobalWrites: diagnostics.pendingGlobalWrites,
@@ -103,9 +103,16 @@ const bridge: LofiDevelopmentBridge = {
103103 throw new Error("Cloud transport pause is unavailable in local-only mode");104104 }105105 const runtime = await getRuntime();106- if (paused) await runtime.db.disconnect();107- else await runtime.db.reconnect();108- transportPaused = paused;106+ // Record the pause before touching the transport so foreground recovery107+ // cannot race a reconnect in between and silently override the inspector.108+ setTransportPausedByInspector(paused);109+ try {110+ if (paused) await runtime.db.disconnect();111+ else await runtime.db.reconnect();112+ } catch (error) {113+ setTransportPausedByInspector(!paused);114+ throw error;115+ }109116 notifyState();110117 },111118 restartClient() {
M package/runtime/pwa.ts +14 -1
M package/runtime/pwa_test.ts +35 -0
@@ -293,6 +293,41 @@ test("waiting-worker update activation posts skip-waiting and reloads on control
293293 if (reloaded) throw new Error("one update action caused a reload loop");294294 });295295 296+test("a controlled tab reloads on an update another tab initiated", async () => {297+ // The new worker prunes the old revision's caches, so every claimed tab298+ // must leave the old HTML/module graph — not only the tab that clicked.299+ const container = new FakeContainer();300+ container.controller = asServiceWorker(new FakeServiceWorker("activated"));301+ container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));302+ let reloaded = 0;303+ const controller = testController({304+ container,305+ production: true,306+ reload: () => reloaded += 1,307+ });308+ controller.initialize();309+ await waitForState(controller, (state) => state.worker === "ready");310+ container.dispatchEvent(new Event("controllerchange"));311+ if (reloaded !== 1) throw new Error("a bystander tab kept the stale document after an update");312+ container.dispatchEvent(new Event("controllerchange"));313+ if (reloaded !== 1) throw new Error("a document must reload at most once per lifetime");314+});315+316+test("the first claim of an uncontrolled page never reloads", async () => {317+ const container = new FakeContainer();318+ container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));319+ let reloaded = false;320+ const controller = testController({321+ container,322+ production: true,323+ reload: () => reloaded = true,324+ });325+ controller.initialize();326+ await waitForState(controller, (state) => state.worker === "ready");327+ container.dispatchEvent(new Event("controllerchange"));328+ if (reloaded) throw new Error("clients.claim() on first visit must not reload the page");329+});330+296331 test("foreground update checks are single-flight and rate-limited", async () => {297332 const target = new EventTarget();298333 const visibility = new EventTarget();
M package/runtime/runtime.ts +3 -1
M package/runtime/sw.js +42 -9
@@ -1,7 +1,14 @@
11 const BUILD_REVISION = "__LOFI_BUILD_REVISION__";22 // Package-owned service worker source.3-const SHELL_CACHE_PREFIX = "lofi-shell-";4-const RUNTIME_CACHE_PREFIX = "lofi-runtime-";3+// Cache names carry the registration scope so sibling lofi apps on the same4+// origin never see or delete each other's caches. The `lofi-scope-` family5+// also deliberately escapes the legacy `lofi-shell-`/`lofi-runtime-` prefixes,6+// so a sibling still running a pre-scoped worker cannot prune these caches.7+const SCOPE_KEY = new URL(self.registration.scope).pathname8+ .replace(/[^a-zA-Z0-9]+/g, "-")9+ .replace(/^-+|-+$/g, "") || "root";10+const SHELL_CACHE_PREFIX = `lofi-scope-${SCOPE_KEY}-shell-`;11+const RUNTIME_CACHE_PREFIX = `lofi-scope-${SCOPE_KEY}-runtime-`;512 const SHELL_CACHE_NAME = `${SHELL_CACHE_PREFIX}${BUILD_REVISION}`;613 const RUNTIME_CACHE_NAME = `${RUNTIME_CACHE_PREFIX}${BUILD_REVISION}`;714 const MAX_RUNTIME_ENTRIES = 64;
@@ -22,9 +29,17 @@ self.addEventListener("install", (event) => {
2229 if (!response.ok) throw new Error(`precache manifest failed: ${response.status}`);2330 const paths = await response.json();2431 // Every emitted path is part of the generated offline shell. Installation25- // fails as one transaction rather than claiming readiness with a partial shell.32+ // fails as one transaction rather than claiming readiness with a partial33+ // shell. `cache: "reload"` bypasses the HTTP cache for every shell asset,34+ // matching the manifest's no-store fetch — otherwise a new revision's35+ // cache could be populated from stale HTTP-cached responses and serve36+ // them cache-first indefinitely.2637 const cache = await caches.open(SHELL_CACHE_NAME);27- await cache.addAll(paths.map((path) => new URL(path, self.registration.scope)));38+ await cache.addAll(39+ paths.map((path) =>40+ new Request(new URL(path, self.registration.scope), { cache: "reload" })41+ ),42+ );2843 } catch (error) {2944 await reportFailure(3045 "precache",
@@ -41,6 +56,10 @@ self.addEventListener("message", (event) => {
4156 4257 self.addEventListener("activate", (event) => {4358 event.waitUntil((async () => {59+ // Prune only this scope's stale revisions. Legacy unscoped60+ // `lofi-shell-*`/`lofi-runtime-*` names are left alone: they are61+ // indistinguishable from a sibling app's caches, and deleting what we62+ // cannot prove is ours is the failure mode this scoping exists to end.4463 for (const name of await caches.keys()) {4564 const staleShell = name.startsWith(SHELL_CACHE_PREFIX) && name !== SHELL_CACHE_NAME;4665 const staleRuntime = name.startsWith(RUNTIME_CACHE_PREFIX) && name !== RUNTIME_CACHE_NAME;
@@ -53,15 +72,26 @@ self.addEventListener("activate", (event) => {
5372 })());5473 });5574 56-function cachedPrerenderedNavigation(request) {75+// Look up responses only in this worker's own caches. An unscoped76+// caches.match() searches every cache on the origin, letting product or77+// sibling-app caches shadow lofi resources before ours are even consulted.78+async function matchOwnCaches(request, options) {79+ const shell = await caches.open(SHELL_CACHE_NAME);80+ const cached = await shell.match(request, options);81+ if (cached) return cached;82+ const runtime = await caches.open(RUNTIME_CACHE_NAME);83+ return await runtime.match(request, options);84+}85+86+async function cachedPrerenderedNavigation(request) {5787 const requestUrl = new URL(request.url);5888 const scope = new URL(self.registration.scope);5989 if (requestUrl.origin !== scope.origin || !requestUrl.pathname.startsWith(scope.pathname)) {6090 return undefined;6191 }6292 const route = requestUrl.pathname.slice(scope.pathname.length).replace(/^\/+|\/+$/g, "");63- if (!route) return caches.match(scope);64- return caches.match(new URL(`${route}/index.html`, scope));93+ if (!route) return await matchOwnCaches(scope);94+ return await matchOwnCaches(new URL(`${route}/index.html`, scope));6595 }6696 6797 function isRuntimeCacheEligible(request) {
@@ -95,7 +125,7 @@ async function writeRuntimeCache(request, response) {
95125 }96126 97127 async function routeRequest(request) {98- const cached = await caches.match(request, {128+ const cached = await matchOwnCaches(request, {99129 ignoreSearch: request.mode === "navigate",100130 });101131 if (cached) return { response: cached };
@@ -111,7 +141,7 @@ async function routeRequest(request) {
111141 return { response, runtimeWrite };112142 } catch (error) {113143 if (request.mode === "navigate") {114- const shell = await caches.match(new URL("./", self.registration.scope));144+ const shell = await matchOwnCaches(new URL("./", self.registration.scope));115145 if (shell) return { response: shell };116146 }117147 throw error;
@@ -120,6 +150,9 @@ async function routeRequest(request) {
120150 121151 self.addEventListener("fetch", (event) => {122152 if (event.request.method !== "GET") return;153+ // Cached responses are complete 200s; answering a Range request with one154+ // breaks seeking and Safari media playback. Let the browser go to network.155+ if (event.request.headers?.get?.("range")) return;123156 const requestUrl = new URL(event.request.url);124157 if (requestUrl.origin !== self.location.origin) return;125158 const result = routeRequest(event.request);
M package/runtime/sw_test.ts +84 -7
@@ -1,7 +1,9 @@
11 const serviceWorkerUrl = new URL("./sw.js", import.meta.url);22 const revision = "__LOFI_BUILD_REVISION__";3-const shellCacheName = `lofi-shell-${revision}`;4-const runtimeCacheName = `lofi-runtime-${revision}`;3+// The harness registers at scope https://example.com/app/, so cache names4+// carry the "app" scope key and never collide with sibling base paths.5+const shellCacheName = `lofi-scope-app-shell-${revision}`;6+const runtimeCacheName = `lofi-scope-app-runtime-${revision}`;57 68 function assert(condition: unknown, message: string): asserts condition {79 if (!condition) throw new Error(message);
@@ -320,22 +322,38 @@ Deno.test("runtime cache evicts its oldest entry at the fixed size bound", async
320322 }321323 });322324 323-Deno.test("activation removes old shell and runtime revisions but preserves unrelated caches", async () => {325+Deno.test("activation prunes only this scope's stale revisions", async () => {324326 const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("unused")));325327 try {326- await harness.caches.open("lofi-shell-old");327- await harness.caches.open("lofi-runtime-old");328+ await harness.caches.open(`lofi-scope-app-shell-old`);329+ await harness.caches.open(`lofi-scope-app-runtime-old`);328330 await harness.caches.open(shellCacheName);329331 await harness.caches.open(runtimeCacheName);332+ // A sibling lofi app at another base path and a legacy unscoped cache —333+ // neither provably ours, neither may be deleted.334+ await harness.caches.open("lofi-scope-other-shell-old");335+ await harness.caches.open("lofi-shell-legacy");336+ await harness.caches.open("lofi-runtime-legacy");330337 await harness.caches.open("product-cache");331338 await harness.dispatchLifecycle("activate");332339 const names = await harness.caches.keys();333- assert(!names.includes("lofi-shell-old"), "stale shell cache survived activation");334- assert(!names.includes("lofi-runtime-old"), "stale runtime cache survived activation");340+ assert(!names.includes("lofi-scope-app-shell-old"), "stale shell revision survived activation");341+ assert(342+ !names.includes("lofi-scope-app-runtime-old"),343+ "stale runtime revision survived activation",344+ );335345 assert(336346 names.includes(shellCacheName) && names.includes(runtimeCacheName),337347 "current caches removed",338348 );349+ assert(350+ names.includes("lofi-scope-other-shell-old"),351+ "a sibling app's scoped cache was deleted",352+ );353+ assert(354+ names.includes("lofi-shell-legacy") && names.includes("lofi-runtime-legacy"),355+ "ambiguous legacy caches were deleted",356+ );339357 assert(names.includes("product-cache"), "unrelated cache was removed");340358 assert(harness.claimed() === 1, "activation did not claim clients");341359 } finally {
@@ -343,6 +361,65 @@ Deno.test("activation removes old shell and runtime revisions but preserves unre
343361 }344362 });345363 364+Deno.test("lookups never read another cache on the origin", async () => {365+ const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("network")));366+ try {367+ const product = await harness.caches.open("product-cache");368+ await product.put("https://example.com/app/photo.png", new Response("product shadow"));369+ const event = harness.dispatchFetch(370+ fakeRequest("https://example.com/app/photo.png", { destination: "image" }),371+ );372+ assert(event.response, "request was not handled");373+ assert(374+ await (await event.response).text() === "network",375+ "a foreign cache shadowed the worker's own caches",376+ );377+ await Promise.all(event.lifetimes);378+ } finally {379+ harness.close();380+ }381+});382+383+Deno.test("shell precache bypasses the HTTP cache for every asset", async () => {384+ const precacheInputs: Array<RequestInfo | URL> = [];385+ const harness = await serviceWorkerHarness((input) => {386+ const url = requestUrl(input);387+ if (url.endsWith("lofi-precache.json")) return Promise.resolve(Response.json(["./"]));388+ precacheInputs.push(input);389+ return Promise.resolve(new Response("shell"));390+ });391+ try {392+ await harness.dispatchLifecycle("install");393+ assert(precacheInputs.length === 1, "expected one precached shell asset");394+ const request = precacheInputs[0];395+ assert(request instanceof Request, "precache must construct explicit requests");396+ assert(397+ request.cache === "reload",398+ "shell assets must bypass the HTTP cache, matching the manifest's no-store fetch",399+ );400+ } finally {401+ harness.close();402+ }403+});404+405+Deno.test("range requests are left to the network", async () => {406+ const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("full body")));407+ try {408+ const shell = await harness.caches.open(shellCacheName);409+ await shell.put("https://example.com/app/audio.mp3", new Response("cached full"));410+ const event = harness.dispatchFetch({411+ ...fakeRequest("https://example.com/app/audio.mp3", { destination: "" }),412+ headers: { get: (name: string) => name === "range" ? "bytes=0-1023" : null },413+ });414+ assert(415+ event.response === undefined,416+ "a Range request must not be answered with a cached 200",417+ );418+ } finally {419+ harness.close();420+ }421+});422+346423 Deno.test("runtime cache failure is reported without failing the network response", async () => {347424 const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("network image")));348425 try {
M package/runtime/table-mutations.ts +35 -2
@@ -12,7 +12,15 @@ export type TableMutationSnapshot = {
1212 };1313 1414 type Listener = () => void;15-type MutationHandle<T> = { wait(options: { tier: "local" | "global" }): Promise<T> };15+type MutationHandle<T> = {16+ /** The vendor batch id, used to attribute asynchronous rejections. */17+ batchId?: unknown;18+ wait(options: { tier: "local" | "global" }): Promise<T>;19+};20+21+// Own-batch attribution keeps a bounded window of recent writes; late22+// rejections older than this are diagnostics-only.23+const MAX_TRACKED_BATCHES = 128;1624 1725 /** Runtime seams used by table-mutation tests and the package-wide registry. */1826 export type TableMutationEnvironment = {
@@ -28,6 +36,8 @@ export class TableMutationStore<T extends TableRow, Init> {
2836 readonly #environment: TableMutationEnvironment;2937 readonly #onIdle: () => void;3038 readonly #listeners = new Set<Listener>();39+ readonly #ownBatches = new Set<unknown>();40+ #batchTracking = false;3141 #snapshot: TableMutationSnapshot = { pending: 0, durability: "none", error: null };3242 #stopMutationErrors: (() => void) | null = null;3343 #stopRuntimeRecreation: (() => void) | null = null;
@@ -110,8 +120,26 @@ export class TableMutationStore<T extends TableRow, Init> {
110120 return await this.#settle(mutation);111121 }112122 123+ #ownsBatch(event: MutationErrorEvent): boolean {124+ const batchId = event.batch?.batchId;125+ // Attribution is only possible when the vendor exposes batch ids on both126+ // sides; without them, surface every event rather than silence real errors.127+ if (batchId === undefined || !this.#batchTracking) return true;128+ return this.#ownBatches.has(batchId);129+ }130+131+ #trackBatch<Result>(mutation: MutationHandle<Result>): void {132+ if (mutation.batchId === undefined) return;133+ this.#batchTracking = true;134+ this.#ownBatches.add(mutation.batchId);135+ if (this.#ownBatches.size > MAX_TRACKED_BATCHES) {136+ this.#ownBatches.delete(this.#ownBatches.values().next().value);137+ }138+ }139+113140 async #settle<Result>(mutation: MutationHandle<Result>): Promise<Result> {114141 const generation = ++this.#writeGeneration;142+ this.#trackBatch(mutation);115143 this.#updateDiagnostics((diagnostics) => diagnostics.pendingLocalWrites += 1);116144 this.#set({ pending: this.#snapshot.pending + 1, durability: "none", error: null });117145 try {
@@ -145,7 +173,9 @@ export class TableMutationStore<T extends TableRow, Init> {
145173 },146174 (error) => {147175 this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);148- this.#fail(error);176+ // Match the success guard: a superseded write's late rejection is177+ // diagnosable but must not overwrite the newer write's durability.178+ if (generation === this.#writeGeneration) this.#fail(error);149179 },150180 ).finally(() => this.#updateDiagnostics((diagnostics) => diagnostics.pendingGlobalWrites -= 1));151181 }
@@ -183,6 +213,9 @@ export class TableMutationStore<T extends TableRow, Init> {
183213 184214 #reportMutationError(event: MutationErrorEvent, generation: number): void {185215 if (generation !== this.#generation) return;216+ // The vendor event is database-wide; only this store's own batches may217+ // mark it failed or count against its diagnostics.218+ if (!this.#ownsBatch(event)) return;186219 this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);187220 this.#fail(new Error(`${event.code}: ${event.reason}`));188221 }
M package/runtime/table-mutations_test.ts +36 -0
@@ -14,6 +14,8 @@ class FakeDb {
1414 globalWaits = 0;1515 failLocal: Error | null = null;1616 nextId = 0;17+ batchIds = false;18+ nextBatch = 0;1719 operations: string[] = [];1820 1921 value(): Db {
@@ -46,6 +48,7 @@ class FakeDb {
4648 4749 private handle<T>(value: T) {4850 return {51+ ...(this.batchIds ? { batchId: `batch-${++this.nextBatch}` } : {}),4952 wait: ({ tier }: { tier: "local" | "global" }) => {5053 if (tier === "local") {5154 this.localWaits += 1;
@@ -147,6 +150,39 @@ Deno.test("local and asynchronous permission rejections reach public mutation st
147150 lease.release();148151 });149152 153+Deno.test("asynchronous rejections reach only the store owning the batch", async () => {154+ const db = new FakeDb();155+ db.batchIds = true;156+ const runtime = environment(db);157+ const lease = new TableMutationRegistry(runtime.value).acquire(table);158+ const stop = lease.store.subscribe(() => undefined);159+ await flush();160+ await lease.store.insert({ title: "mine", archived: false });161+ db.reject(162+ {163+ code: "WriteRejected",164+ reason: "another table's write",165+ batch: { batchId: "batch-999" },166+ } as unknown as MutationErrorEvent,167+ );168+ assert(169+ lease.store.getSnapshot().durability !== "failed",170+ "a foreign batch rejection must not fail this store",171+ );172+ assertCount(runtime.diagnostics.mutationErrors, 0, "a foreign batch must not be counted here");173+ db.reject(174+ {175+ code: "WriteRejected",176+ reason: "revoked",177+ batch: { batchId: "batch-1" },178+ } as unknown as MutationErrorEvent,179+ );180+ assert(lease.store.getSnapshot().error?.includes("revoked"), "own rejection was hidden");181+ assertCount(runtime.diagnostics.mutationErrors, 1, "the owned rejection must count once");182+ stop();183+ lease.release();184+});185+150186 Deno.test("mutation types follow the exact Jazz table insert and row shapes", () => {151187 const app = s.defineApp({ records: s.table({ title: s.string(), archived: s.boolean() }) });152188 const registry = new TableMutationRegistry(environment(new FakeDb()).value);
M package/runtime/table-store.ts +55 -10
@@ -22,13 +22,23 @@ export type TableSnapshot<T extends TableRow> = {
2222 2323 type Listener = () => void;2424 type MutationHandle = {25+ /** The vendor batch id, used to attribute asynchronous rejections. */26+ batchId?: unknown;2527 wait(options: { tier: "local" | "global" }): Promise<unknown>;2628 };2729 30+// Own-batch attribution keeps a bounded window of recent writes; late31+// rejections older than this are diagnostics-only.32+const MAX_TRACKED_BATCHES = 128;33+2834 /** Runtime options controlling durability waits and diagnostics notifications. */2935 export type TableStoreOptions = {30- /** Whether managed sync is configured; gates global-durability waits. */31- syncConfigured?: boolean;36+ /**37+ * Whether managed sync is configured; gates global-durability waits. Read38+ * live on every write so stores follow sync elections and stops without39+ * being recreated.40+ */41+ syncConfigured?: () => boolean;3242 /** Called whenever a diagnostics counter changes. */3343 onDiagnosticsChange?: () => void;3444 };
@@ -42,10 +52,12 @@ export class TableStore<T extends TableRow, Init> {
4252 readonly #db: Db;4353 readonly #table: TableHandle<T, Init>;4454 readonly #diagnostics: RuntimeDiagnostics;45- readonly #syncConfigured: boolean;55+ readonly #syncConfigured: () => boolean;4656 readonly #diagnosticsChanged: () => void;4757 readonly #listeners = new Set<Listener>();4858 readonly #stopMutationErrors: () => void;59+ readonly #ownBatches = new Set<unknown>();60+ #batchTracking = false;4961 #vendorUnsubscribe: (() => void) | null = null;5062 #writeGeneration = 0;5163 #snapshot: TableSnapshot<T> = {
@@ -65,11 +77,16 @@ export class TableStore<T extends TableRow, Init> {
6577 this.#db = db;6678 this.#table = table;6779 this.#diagnostics = diagnostics;68- this.#syncConfigured = options.syncConfigured ?? false;80+ this.#syncConfigured = options.syncConfigured ?? (() => false);6981 this.#diagnosticsChanged = options.onDiagnosticsChange ?? (() => undefined);7082 // Asynchronous write rejections that are not tied to an awaited mutation71- // still have to reach diagnostics and the UI, so the store owns this listener.72- this.#stopMutationErrors = db.onMutationError((event) => this.reportMutationError(event));83+ // still have to reach diagnostics and the UI, so the store owns this84+ // listener. The vendor event is database-wide, so it is filtered to this85+ // store's own batches — one table's rejection must not mark every store86+ // failed or multiply diagnostics counts.87+ this.#stopMutationErrors = db.onMutationError((event) => {88+ if (this.#ownsBatch(event)) this.reportMutationError(event);89+ });7390 this.#diagnostics.activeMutationListeners += 1;7491 this.#diagnostics.totalMutationListeners += 1;7592 this.#diagnosticsChanged();
@@ -90,7 +107,9 @@ export class TableStore<T extends TableRow, Init> {
90107 return () => {91108 if (!active) return;92109 active = false;93- this.#listeners.delete(subscriber);110+ // After close() the listener set is already empty; a late unsubscribe111+ // must not decrement consumers a second time.112+ if (!this.#listeners.delete(subscriber)) return;94113 this.#diagnostics.activeConsumers -= 1;95114 this.#diagnosticsChanged();96115 if (this.#listeners.size === 0) this.#closeVendorSubscription();
@@ -130,12 +149,36 @@ export class TableStore<T extends TableRow, Init> {
130149 this.#diagnosticsChanged();131150 }132151 152+ #ownsBatch(event: MutationErrorEvent): boolean {153+ const batchId = event.batch?.batchId;154+ // Attribution is only possible when the vendor exposes batch ids on both155+ // sides; without them, surface every event rather than silence real errors.156+ if (batchId === undefined || !this.#batchTracking) return true;157+ return this.#ownBatches.has(batchId);158+ }159+160+ #trackBatch(mutation: MutationHandle): void {161+ if (mutation.batchId === undefined) return;162+ this.#batchTracking = true;163+ this.#ownBatches.add(mutation.batchId);164+ if (this.#ownBatches.size > MAX_TRACKED_BATCHES) {165+ this.#ownBatches.delete(this.#ownBatches.values().next().value);166+ }167+ }168+133169 async #settle(mutation: MutationHandle): Promise<void> {134170 const generation = ++this.#writeGeneration;135171 let localPending = true;172+ this.#trackBatch(mutation);136173 this.#diagnostics.pendingLocalWrites += 1;137174 this.#diagnosticsChanged();138- this.#set({ status: "ready", durability: "none", error: null });175+ // A store that has not received its first query result stays loading —176+ // a mutation must not present an empty table as ready.177+ this.#set({178+ status: this.#snapshot.status === "loading" ? "loading" : "ready",179+ durability: "none",180+ error: null,181+ });139182 try {140183 await mutation.wait({ tier: "local" });141184 localPending = false;
@@ -145,7 +188,7 @@ export class TableStore<T extends TableRow, Init> {
145188 if (generation === this.#writeGeneration) {146189 this.#set({ durability: "local", error: null });147190 }148- if (this.#syncConfigured) {191+ if (this.#syncConfigured()) {149192 this.#diagnostics.pendingGlobalWrites += 1;150193 this.#diagnosticsChanged();151194 void mutation.wait({ tier: "global" }).then(
@@ -157,7 +200,9 @@ export class TableStore<T extends TableRow, Init> {
157200 (error) => {158201 this.#diagnostics.mutationErrors += 1;159202 this.#diagnosticsChanged();160- this.#fail(error);203+ // Match the success guard: a superseded write's late rejection is204+ // diagnosable but must not overwrite the newer write's durability.205+ if (generation === this.#writeGeneration) this.#fail(error);161206 },162207 ).finally(() => {163208 this.#diagnostics.pendingGlobalWrites -= 1;
M package/runtime/table-store_test.ts +117 -4
@@ -22,7 +22,7 @@ function diagnostics(): RuntimeDiagnostics {
2222 };2323 }2424 25-function fakeDb(options: { allowGlobal?: boolean } = {}) {25+function fakeDb(options: { allowGlobal?: boolean; batchIds?: boolean } = {}) {2626 let subscriber: ((delta: { all: unknown[] }) => void) | null = null;2727 let unsubscribeCalls = 0;2828 let rejectNextWrite: Error | null = null;
@@ -31,19 +31,30 @@ function fakeDb(options: { allowGlobal?: boolean } = {}) {
3131 resolve(): void;3232 reject(error: Error): void;3333 } | null = null;34+ let deferredNextGlobal: {35+ promise: Promise<void>;36+ resolve(): void;37+ reject(error: Error): void;38+ } | null = null;3439 let globalWaitCalls = 0;40+ let batchCounter = 0;41+ const mutationListeners = new Set<(event: MutationErrorEvent) => void>();3542 const writes: Array<{ operation: "insert" | "update" | "delete"; value: unknown }> = [];3643 3744 function handle() {3845 const deferredWrite = deferredNextWrite;3946 deferredNextWrite = null;47+ const deferredGlobal = deferredNextGlobal;48+ deferredNextGlobal = null;4049 return {50+ ...(options.batchIds ? { batchId: `batch-${++batchCounter}` } : {}),4151 wait: ({ tier }: { tier: "local" | "global" }) => {4252 if (tier === "global") {4353 globalWaitCalls += 1;4454 if (!options.allowGlobal) {4555 return Promise.reject(new Error("unconfigured stores must not request global waits"));4656 }57+ if (deferredGlobal) return deferredGlobal.promise;4758 return Promise.resolve();4859 }4960 if (tier === "local" && rejectNextWrite) {
@@ -65,8 +76,9 @@ function fakeDb(options: { allowGlobal?: boolean } = {}) {
6576 subscriber = null;6677 };6778 },68- onMutationError(_callback: (event: MutationErrorEvent) => void) {69- return () => undefined;79+ onMutationError(callback: (event: MutationErrorEvent) => void) {80+ mutationListeners.add(callback);81+ return () => mutationListeners.delete(callback);7082 },7183 insert(_collection: unknown, next: unknown) {7284 writes.push({ operation: "insert", value: next });
@@ -101,6 +113,20 @@ function fakeDb(options: { allowGlobal?: boolean } = {}) {
101113 deferredNextWrite = { promise, resolve, reject };102114 return deferredNextWrite;103115 },116+ deferNextGlobalWait() {117+ let resolve!: () => void;118+ let reject!: (error: Error) => void;119+ const promise = new Promise<void>((accept, decline) => {120+ resolve = accept;121+ reject = decline;122+ });123+ promise.catch(() => undefined);124+ deferredNextGlobal = { promise, resolve, reject };125+ return deferredNextGlobal;126+ },127+ emitMutationError(event: MutationErrorEvent) {128+ for (const listener of mutationListeners) listener(event);129+ },104130 writes,105131 globalWaitCalls: () => globalWaitCalls,106132 unsubscribeCalls: () => unsubscribeCalls,
@@ -194,7 +220,7 @@ test("every mutation exposes pending work and waits for local durability", async
194220 test("configured sync advances the latest write from local to global durability", async () => {195221 const db = fakeDb({ allowGlobal: true });196222 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics(), {197- syncConfigured: true,223+ syncConfigured: () => true,198224 });199225 await store.insert({ text: "sync after reconnect", completed: false, createdAt: new Date(0) });200226 await Promise.resolve();
@@ -272,3 +298,90 @@ test("public mutation errors update observable diagnostics", () => {
272298 assertCount(counts.mutationErrors, 1, "mutation errors must be counted");273299 assert(store.getSnapshot().error?.includes("permission denied"), "the reason must reach the UI");274300 });301+302+test("stores follow sync elections and stops without being recreated", async () => {303+ const db = fakeDb({ allowGlobal: true });304+ let elected = false;305+ const store = createTableStore<Row, RowInit>(db.value, table, diagnostics(), {306+ syncConfigured: () => elected,307+ });308+ await store.insert({ text: "before election", completed: false, createdAt: new Date(0) });309+ assertCount(db.globalWaitCalls(), 0, "an unelected write must not request global durability");310+ elected = true;311+ await store.insert({ text: "after election", completed: false, createdAt: new Date(0) });312+ await Promise.resolve();313+ assertCount(db.globalWaitCalls(), 1, "a write after election must request global durability");314+ assert(store.getSnapshot().durability === "global", "the elected write must report global");315+ elected = false;316+ await store.insert({ text: "after stop", completed: false, createdAt: new Date(0) });317+ assertCount(db.globalWaitCalls(), 1, "a write after stopping sync must not accumulate waits");318+});319+320+test("a superseded write's late global rejection cannot mask newer global durability", async () => {321+ const db = fakeDb({ allowGlobal: true });322+ const counts = diagnostics();323+ const store = createTableStore<Row, RowInit>(db.value, table, counts, {324+ syncConfigured: () => true,325+ });326+ const staleGlobal = db.deferNextGlobalWait();327+ await store.insert({ text: "older", completed: false, createdAt: new Date(0) });328+ await store.insert({ text: "newer", completed: false, createdAt: new Date(0) });329+ await Promise.resolve();330+ assert(store.getSnapshot().durability === "global", "the newer write must reach global");331+ staleGlobal.reject(new Error("older write rejected late"));332+ await Promise.resolve();333+ await Promise.resolve();334+ assert(335+ store.getSnapshot().durability === "global",336+ "a stale rejection must not overwrite the newer write's durability",337+ );338+ assert(store.getSnapshot().error === null, "a stale rejection must not surface as the error");339+ assertCount(counts.mutationErrors, 1, "the stale rejection must remain diagnosable");340+});341+342+test("asynchronous rejections mark only the store that owns the batch", async () => {343+ const db = fakeDb({ batchIds: true });344+ const counts = diagnostics();345+ const store = createTableStore<Row, RowInit>(db.value, table, counts);346+ await store.insert({ text: "mine", completed: false, createdAt: new Date(0) });347+ db.emitMutationError({348+ code: "WriteRejected",349+ reason: "another table's write",350+ batch: { batchId: "batch-999" },351+ } as unknown as MutationErrorEvent);352+ assert(store.getSnapshot().status !== "error", "a foreign batch must not fail this store");353+ assertCount(counts.mutationErrors, 0, "a foreign batch must not count against this store");354+ db.emitMutationError({355+ code: "WriteRejected",356+ reason: "revoked",357+ batch: { batchId: "batch-1" },358+ } as unknown as MutationErrorEvent);359+ assert(store.getSnapshot().status === "error", "the owning store must surface its rejection");360+ assert(store.getSnapshot().error?.includes("revoked"), "the cause must reach the UI");361+ assertCount(counts.mutationErrors, 1, "the owned rejection must be counted once");362+});363+364+test("an unsubscribe arriving after close cannot drive consumers negative", () => {365+ const counts = diagnostics();366+ const store = createTableStore<Row, RowInit>(fakeDb().value, table, counts);367+ const stop = store.subscribe(() => undefined);368+ store.close();369+ assertCount(counts.activeConsumers, 0, "close must release every consumer");370+ stop();371+ assertCount(counts.activeConsumers, 0, "a late unsubscribe must not double-decrement");372+});373+374+test("a mutation does not present an empty loading table as ready", async () => {375+ const db = fakeDb();376+ const store = createTableStore<Row, RowInit>(db.value, table, diagnostics());377+ const stop = store.subscribe(() => undefined);378+ assert(store.getSnapshot().status === "loading", "a fresh store must report loading");379+ await store.insert({ text: "first", completed: false, createdAt: new Date(0) });380+ assert(381+ store.getSnapshot().status === "loading",382+ "a write before the first query result must not claim the table is ready",383+ );384+ db.publish([{ id: "row-1", text: "first", completed: false, createdAt: new Date(0) }]);385+ assert(store.getSnapshot().status === "ready", "the first query result must end loading");386+ stop();387+});
A package/runtime/transport-gate.ts +22 -0