clone

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

feat/prf-provision-custody
lofi / package / runtime / table-store_test.ts
table-store_test.ts 15.9K
1import type { Db, MutationErrorEvent } from "jazz-tools";2// Package contract tests.3import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";4import { createTableStore, type TableHandle } from "./table-store.ts";5import { assert, assertCount } from "./test-assert.ts";67const test = (globalThis as unknown as {8 Deno: { test(name: string, body: () => void | Promise<void>): void };9}).Deno.test;1011type Row = { id: string; text: string; completed: boolean; createdAt: Date };12type RowInit = Omit<Row, "id">;1314const table = {} as unknown as TableHandle<Row, RowInit>;1516function diagnostics(): RuntimeDiagnostics {17 return {18 ...createDiagnostics(),19 storageState: "persistent-driver-open",20 clientsCreated: 1,21 activeClients: 1,22 };23}2425function fakeDb(options: { allowGlobal?: boolean; batchIds?: boolean } = {}) {26 let subscriber: ((delta: { all: unknown[] }) => void) | null = null;27 let unsubscribeCalls = 0;28 let rejectNextWrite: Error | null = null;29 let deferredNextWrite: {30 promise: Promise<void>;31 resolve(): void;32 reject(error: Error): void;33 } | null = null;34 let deferredNextGlobal: {35 promise: Promise<void>;36 resolve(): void;37 reject(error: Error): void;38 } | null = null;39 let globalWaitCalls = 0;40 let batchCounter = 0;41 const mutationListeners = new Set<(event: MutationErrorEvent) => void>();42 const writes: Array<{ operation: "insert" | "update" | "delete"; value: unknown }> = [];4344 function handle() {45 const deferredWrite = deferredNextWrite;46 deferredNextWrite = null;47 const deferredGlobal = deferredNextGlobal;48 deferredNextGlobal = null;49 return {50 ...(options.batchIds ? { batchId: `batch-${++batchCounter}` } : {}),51 wait: ({ tier }: { tier: "local" | "global" }) => {52 if (tier === "global") {53 globalWaitCalls += 1;54 if (!options.allowGlobal) {55 return Promise.reject(new Error("unconfigured stores must not request global waits"));56 }57 if (deferredGlobal) return deferredGlobal.promise;58 return Promise.resolve();59 }60 if (tier === "local" && rejectNextWrite) {61 const error = rejectNextWrite;62 rejectNextWrite = null;63 return Promise.reject(error);64 }65 if (tier === "local" && deferredWrite) return deferredWrite.promise;66 return Promise.resolve();67 },68 };69 }7071 const value = {72 subscribeAll(_collection: unknown, callback: (delta: { all: unknown[] }) => void) {73 subscriber = callback;74 return () => {75 unsubscribeCalls += 1;76 subscriber = null;77 };78 },79 onMutationError(callback: (event: MutationErrorEvent) => void) {80 mutationListeners.add(callback);81 return () => mutationListeners.delete(callback);82 },83 insert(_collection: unknown, next: unknown) {84 writes.push({ operation: "insert", value: next });85 return handle();86 },87 update(_collection: unknown, id: string, next: unknown) {88 writes.push({ operation: "update", value: { id, next } });89 return handle();90 },91 delete(_collection: unknown, id: string) {92 writes.push({ operation: "delete", value: id });93 return handle();94 },95 } as unknown as Db;9697 return {98 value,99 publish(all: unknown[]) {100 assert(subscriber, "expected an active vendor subscription");101 subscriber({ all });102 },103 failNextWrite(message: string) {104 rejectNextWrite = new Error(message);105 },106 deferNextWrite() {107 let resolve!: () => void;108 let reject!: (error: Error) => void;109 const promise = new Promise<void>((accept, decline) => {110 resolve = accept;111 reject = decline;112 });113 deferredNextWrite = { promise, resolve, reject };114 return deferredNextWrite;115 },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 },130 writes,131 globalWaitCalls: () => globalWaitCalls,132 unsubscribeCalls: () => unsubscribeCalls,133 };134}135136test("two consumers share one table subscription and clean up idempotently", () => {137 const db = fakeDb();138 const counts = diagnostics();139 const store = createTableStore<Row, RowInit>(db.value, table, counts);140 let firstUpdates = 0;141 let secondUpdates = 0;142143 const stopFirst = store.subscribe(() => firstUpdates += 1);144 const stopSecond = store.subscribe(() => secondUpdates += 1);145 assertCount(counts.activeConsumers, 2, "both component consumers must be counted");146 assertCount(counts.activeVendorSubscriptions, 1, "one query should have one subscription");147 assertCount(counts.totalVendorSubscriptions, 1, "the underlying query should open once");148149 db.publish([{150 id: "row-1",151 text: "shared",152 completed: false,153 createdAt: new Date(0),154 }]);155 assert(store.getSnapshot().status === "ready", "the first query result must end loading");156 assert(store.getSnapshot().rows.length === 1, "the query result must be published");157 assert(firstUpdates > 0 && secondUpdates > 0, "both roots must receive reactive updates");158159 stopFirst();160 assertCount(counts.activeConsumers, 1, "unmounting one root must leave the other active");161 assertCount(counts.activeVendorSubscriptions, 1, "the remaining consumer retains the query");162 stopFirst();163 assertCount(counts.activeConsumers, 1, "unsubscribe must be idempotent");164 stopSecond();165 assertCount(counts.activeConsumers, 0, "the final unmount must remove all consumers");166 assertCount(counts.activeVendorSubscriptions, 0, "the final unmount must close the query");167 assertCount(db.unsubscribeCalls(), 1, "the vendor unsubscribe must run exactly once");168 assertCount(counts.unsubscribeCalls, 1, "cleanup must remain externally diagnosable");169});170171test("consumer diagnostics remain additive across distinct table stores", () => {172 const counts = diagnostics();173 const first = createTableStore<Row, RowInit>(fakeDb().value, table, counts);174 const second = createTableStore<Row, RowInit>(fakeDb().value, table, counts);175 const stopFirst = first.subscribe(() => undefined);176 const stopSecond = second.subscribe(() => undefined);177 assertCount(counts.activeConsumers, 2, "distinct stores overwrote the shared consumer count");178 stopFirst();179 assertCount(counts.activeConsumers, 1, "one store teardown erased another store's consumer");180 stopSecond();181 first.close();182 second.close();183 assertCount(counts.activeConsumers, 0, "distinct store cleanup leaked consumers");184});185186test("every mutation exposes pending work and waits for local durability", async () => {187 const db = fakeDb();188 const counts = diagnostics();189 const store = createTableStore<Row, RowInit>(db.value, table, counts);190191 const deferred = db.deferNextWrite();192 const first = store.insert({ text: "first", completed: false, createdAt: new Date(0) });193 await Promise.resolve();194 assertCount(counts.pendingLocalWrites, 1, "an unsettled local write must be observable");195 assert(String(counts.lastWriteDurability) === "none", "pending work must not claim durability");196 deferred.resolve();197 await first;198 assertCount(counts.pendingLocalWrites, 0, "a retained local write must leave no pending work");199 await store.update("row-1", { text: "edited" });200 await store.update("row-1", { completed: true });201 await store.delete("row-1");202203 assertCount(counts.localWaitCalls, 4, "each accepted mutation must await local durability");204 assert(205 db.writes.map((write) => write.operation).join(",") === "insert,update,update,delete",206 "insert, edit, complete, and delete must reach the Jazz boundary",207 );208 assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");209 assert(210 String(counts.lastWriteDurability) === "local",211 "diagnostics must expose local durability",212 );213 assertCount(214 db.globalWaitCalls(),215 0,216 "an unconfigured store must never request global durability",217 );218});219220test("configured sync advances the latest write from local to global durability", async () => {221 const db = fakeDb({ allowGlobal: true });222 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics(), {223 syncConfigured: () => true,224 });225 await store.insert({ text: "sync after reconnect", completed: false, createdAt: new Date(0) });226 await Promise.resolve();227 assert(228 store.getSnapshot().durability === "global",229 "the public mutation handle must make completed global durability visible",230 );231 assertCount(db.globalWaitCalls(), 1, "configured sync must request global durability");232});233234test("local write failures remain visible and reject the mutation", async () => {235 const db = fakeDb();236 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics());237 db.failNextWrite("disk full");238 let rejected = false;239 try {240 await store.insert({ text: "cannot persist", completed: false, createdAt: new Date(0) });241 } catch {242 rejected = true;243 }244 assert(rejected, "a failed local wait must reject the public mutation");245 assert(store.getSnapshot().status === "error", "a failed local wait must reach UI state");246 assert(store.getSnapshot().error === "disk full", "the durable-write cause must remain visible");247});248249test("a superseded local-write failure remains visible", async () => {250 const db = fakeDb();251 const counts = diagnostics();252 const store = createTableStore<Row, RowInit>(db.value, table, counts);253 const firstWrite = db.deferNextWrite();254255 const firstMutation = store.insert({ text: "first", completed: false, createdAt: new Date(0) });256 await Promise.resolve();257 await store.insert({ text: "newer", completed: false, createdAt: new Date(0) });258 firstWrite.reject(new Error("first write lost"));259260 let rejected = false;261 try {262 await firstMutation;263 } catch {264 rejected = true;265 }266 assert(rejected, "the superseded mutation promise must still reject");267 assert(store.getSnapshot().status === "error", "the stale rejection must reach UI state");268 assert(store.getSnapshot().durability === "failed", "the stale rejection must be durable state");269 assert(store.getSnapshot().error === "first write lost", "the stale cause must remain visible");270 assertCount(counts.mutationErrors, 1, "the stale failure must remain diagnosable");271});272273test("retrying after failure clears the error status immediately", async () => {274 const db = fakeDb();275 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics());276 db.failNextWrite("disk full");277 await store.insert({ text: "fails", completed: false, createdAt: new Date(0) }).catch(() =>278 undefined279 );280 const retryWrite = db.deferNextWrite();281282 const retry = store.insert({ text: "retry", completed: false, createdAt: new Date(0) });283 await Promise.resolve();284 assert(store.getSnapshot().status === "ready", "retry must leave the failed list status");285 assert(store.getSnapshot().error === null, "retry must not render a null error message");286 assert(store.getSnapshot().durability === "none", "retry is pending until local durability");287 retryWrite.resolve();288 await retry;289});290291test("public mutation errors update observable diagnostics", () => {292 const counts = diagnostics();293 const store = createTableStore<Row, RowInit>(fakeDb().value, table, counts);294 store.reportMutationError({295 code: "WriteRejected",296 reason: "permission denied",297 } as MutationErrorEvent);298 assertCount(counts.mutationErrors, 1, "mutation errors must be counted");299 assert(store.getSnapshot().error?.includes("permission denied"), "the reason must reach the UI");300});301302test("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});319320test("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});341342test("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});363364test("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});373374test("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});