clone

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

fix: surface every durability failure

Dami committed f6ce64d
commit
f6ce64d72ca8a6cbf579c02a7419480b3ec06f11
parent
f7e59b6c3c2a167b518f88d0d6eb89d342ae9175
author
Dami <dami.etoile@gmail.com>
2 files changed, +81 -5
Mapps/reference/src/_lofi/checklist-store.ts8++-
Mapps/reference/src/_lofi/checklist-store_test.ts78+++++++++++++++++++++++++++++-
M apps/reference/src/_lofi/checklist-store.ts +5 -3
M apps/reference/src/_lofi/checklist-store_test.ts +76 -2
@@ -29,20 +29,36 @@ function diagnostics(): RuntimeDiagnostics {
2929 };3030 }3131 32-function fakeDb() {32+function fakeDb(options: { allowGlobal?: boolean } = {}) {3333 let subscriber: ((delta: { all: unknown[] }) => void) | null = null;3434 let unsubscribeCalls = 0;3535 let rejectNextWrite: Error | null = null;36+ let deferredNextWrite: {37+ promise: Promise<void>;38+ resolve(): void;39+ reject(error: Error): void;40+ } | null = null;41+ let globalWaitCalls = 0;3642 const writes: Array<{ operation: "insert" | "update" | "delete"; value: unknown }> = [];3743 3844 function handle() {45+ const deferredWrite = deferredNextWrite;46+ deferredNextWrite = null;3947 return {4048 wait: ({ tier }: { tier: "local" | "global" }) => {49+ if (tier === "global") {50+ globalWaitCalls += 1;51+ if (!options.allowGlobal) {52+ return Promise.reject(new Error("unconfigured stores must not request global waits"));53+ }54+ return Promise.resolve();55+ }4156 if (tier === "local" && rejectNextWrite) {4257 const error = rejectNextWrite;4358 rejectNextWrite = null;4459 return Promise.reject(error);4560 }61+ if (tier === "local" && deferredWrite) return deferredWrite.promise;4662 return Promise.resolve();4763 },4864 };
@@ -79,7 +95,18 @@ function fakeDb() {
7995 failNextWrite(message: string) {8096 rejectNextWrite = new Error(message);8197 },98+ deferNextWrite() {99+ let resolve!: () => void;100+ let reject!: (error: Error) => void;101+ const promise = new Promise<void>((accept, decline) => {102+ resolve = accept;103+ reject = decline;104+ });105+ deferredNextWrite = { promise, resolve, reject };106+ return deferredNextWrite;107+ },82108 writes,109+ globalWaitCalls: () => globalWaitCalls,83110 unsubscribeCalls: () => unsubscribeCalls,84111 };85112 }
@@ -135,16 +162,23 @@ test("every checklist mutation waits for local durability", async () => {
135162 "create, edit, complete, and delete must reach the Jazz boundary",136163 );137164 assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");165+ assertCount(166+ db.globalWaitCalls(),167+ 0,168+ "an unconfigured store must never request global durability",169+ );138170 });139171 140172 test("configured sync advances the latest write from local to global durability", async () => {141- const store = new ChecklistStore(fakeDb().value, diagnostics(), true);173+ const db = fakeDb({ allowGlobal: true });174+ const store = new ChecklistStore(db.value, diagnostics(), true);142175 await store.create("sync after reconnect");143176 await Promise.resolve();144177 assert(145178 store.getSnapshot().durability === "global",146179 "the public mutation handle must make completed global durability visible",147180 );181+ assertCount(db.globalWaitCalls(), 1, "configured sync must request global durability");148182 });149183 150184 test("local write failures remain visible and reject the mutation", async () => {
@@ -162,6 +196,46 @@ test("local write failures remain visible and reject the mutation", async () =>
162196 assert(store.getSnapshot().error === "disk full", "the durable-write cause must remain visible");163197 });164198 199+test("a superseded local-write failure remains visible", async () => {200+ const db = fakeDb();201+ const counts = diagnostics();202+ const store = new ChecklistStore(db.value, counts);203+ const firstWrite = db.deferNextWrite();204+205+ const firstMutation = store.create("first");206+ await Promise.resolve();207+ await store.create("newer");208+ firstWrite.reject(new Error("first write lost"));209+210+ let rejected = false;211+ try {212+ await firstMutation;213+ } catch {214+ rejected = true;215+ }216+ assert(rejected, "the superseded mutation promise must still reject");217+ assert(store.getSnapshot().status === "error", "the stale rejection must reach UI state");218+ assert(store.getSnapshot().durability === "failed", "the stale rejection must be durable state");219+ assert(store.getSnapshot().error === "first write lost", "the stale cause must remain visible");220+ assertCount(counts.mutationErrors, 1, "the stale failure must remain diagnosable");221+});222+223+test("retrying after failure clears the error status immediately", async () => {224+ const db = fakeDb();225+ const store = new ChecklistStore(db.value, diagnostics());226+ db.failNextWrite("disk full");227+ await store.create("fails").catch(() => undefined);228+ const retryWrite = db.deferNextWrite();229+230+ const retry = store.create("retry");231+ await Promise.resolve();232+ assert(store.getSnapshot().status === "ready", "retry must leave the failed list status");233+ assert(store.getSnapshot().error === null, "retry must not render a null error message");234+ assert(store.getSnapshot().durability === "none", "retry is pending until local durability");235+ retryWrite.resolve();236+ await retry;237+});238+165239 test("public mutation errors update observable diagnostics", () => {166240 const counts = diagnostics();167241 const store = new ChecklistStore(fakeDb().value, counts);