clone

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

feat/141-effect-library
lofi / package / runtime / table-store_test.ts
table-store_test.ts 16.0K
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.remove("row-1");202 // The deprecated alias must keep routing through the same removal path.203 await store.delete("row-1");204205 assertCount(counts.localWaitCalls, 5, "each accepted mutation must await local durability");206 assert(207 db.writes.map((write) => write.operation).join(",") === "insert,update,update,delete,delete",208 "insert, edit, complete, and both removal spellings must reach the Jazz boundary",209 );210 assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");211 assert(212 String(counts.lastWriteDurability) === "local",213 "diagnostics must expose local durability",214 );215 assertCount(216 db.globalWaitCalls(),217 0,218 "an unconfigured store must never request global durability",219 );220});221222test("configured sync advances the latest write from local to global durability", async () => {223 const db = fakeDb({ allowGlobal: true });224 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics(), {225 syncConfigured: () => true,226 });227 await store.insert({ text: "sync after reconnect", completed: false, createdAt: new Date(0) });228 await Promise.resolve();229 assert(230 store.getSnapshot().durability === "global",231 "the public mutation handle must make completed global durability visible",232 );233 assertCount(db.globalWaitCalls(), 1, "configured sync must request global durability");234});235236test("local write failures remain visible and reject the mutation", async () => {237 const db = fakeDb();238 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics());239 db.failNextWrite("disk full");240 let rejected = false;241 try {242 await store.insert({ text: "cannot persist", completed: false, createdAt: new Date(0) });243 } catch {244 rejected = true;245 }246 assert(rejected, "a failed local wait must reject the public mutation");247 assert(store.getSnapshot().status === "error", "a failed local wait must reach UI state");248 assert(store.getSnapshot().error === "disk full", "the durable-write cause must remain visible");249});250251test("a superseded local-write failure remains visible", async () => {252 const db = fakeDb();253 const counts = diagnostics();254 const store = createTableStore<Row, RowInit>(db.value, table, counts);255 const firstWrite = db.deferNextWrite();256257 const firstMutation = store.insert({ text: "first", completed: false, createdAt: new Date(0) });258 await Promise.resolve();259 await store.insert({ text: "newer", completed: false, createdAt: new Date(0) });260 firstWrite.reject(new Error("first write lost"));261262 let rejected = false;263 try {264 await firstMutation;265 } catch {266 rejected = true;267 }268 assert(rejected, "the superseded mutation promise must still reject");269 assert(store.getSnapshot().status === "error", "the stale rejection must reach UI state");270 assert(store.getSnapshot().durability === "failed", "the stale rejection must be durable state");271 assert(store.getSnapshot().error === "first write lost", "the stale cause must remain visible");272 assertCount(counts.mutationErrors, 1, "the stale failure must remain diagnosable");273});274275test("retrying after failure clears the error status immediately", async () => {276 const db = fakeDb();277 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics());278 db.failNextWrite("disk full");279 await store.insert({ text: "fails", completed: false, createdAt: new Date(0) }).catch(() =>280 undefined281 );282 const retryWrite = db.deferNextWrite();283284 const retry = store.insert({ text: "retry", completed: false, createdAt: new Date(0) });285 await Promise.resolve();286 assert(store.getSnapshot().status === "ready", "retry must leave the failed list status");287 assert(store.getSnapshot().error === null, "retry must not render a null error message");288 assert(store.getSnapshot().durability === "none", "retry is pending until local durability");289 retryWrite.resolve();290 await retry;291});292293test("public mutation errors update observable diagnostics", () => {294 const counts = diagnostics();295 const store = createTableStore<Row, RowInit>(fakeDb().value, table, counts);296 store.reportMutationError({297 code: "WriteRejected",298 reason: "permission denied",299 } as MutationErrorEvent);300 assertCount(counts.mutationErrors, 1, "mutation errors must be counted");301 assert(store.getSnapshot().error?.includes("permission denied"), "the reason must reach the UI");302});303304test("stores follow sync elections and stops without being recreated", async () => {305 const db = fakeDb({ allowGlobal: true });306 let elected = false;307 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics(), {308 syncConfigured: () => elected,309 });310 await store.insert({ text: "before election", completed: false, createdAt: new Date(0) });311 assertCount(db.globalWaitCalls(), 0, "an unelected write must not request global durability");312 elected = true;313 await store.insert({ text: "after election", completed: false, createdAt: new Date(0) });314 await Promise.resolve();315 assertCount(db.globalWaitCalls(), 1, "a write after election must request global durability");316 assert(store.getSnapshot().durability === "global", "the elected write must report global");317 elected = false;318 await store.insert({ text: "after stop", completed: false, createdAt: new Date(0) });319 assertCount(db.globalWaitCalls(), 1, "a write after stopping sync must not accumulate waits");320});321322test("a superseded write's late global rejection cannot mask newer global durability", async () => {323 const db = fakeDb({ allowGlobal: true });324 const counts = diagnostics();325 const store = createTableStore<Row, RowInit>(db.value, table, counts, {326 syncConfigured: () => true,327 });328 const staleGlobal = db.deferNextGlobalWait();329 await store.insert({ text: "older", completed: false, createdAt: new Date(0) });330 await store.insert({ text: "newer", completed: false, createdAt: new Date(0) });331 await Promise.resolve();332 assert(store.getSnapshot().durability === "global", "the newer write must reach global");333 staleGlobal.reject(new Error("older write rejected late"));334 await Promise.resolve();335 await Promise.resolve();336 assert(337 store.getSnapshot().durability === "global",338 "a stale rejection must not overwrite the newer write's durability",339 );340 assert(store.getSnapshot().error === null, "a stale rejection must not surface as the error");341 assertCount(counts.mutationErrors, 1, "the stale rejection must remain diagnosable");342});343344test("asynchronous rejections mark only the store that owns the batch", async () => {345 const db = fakeDb({ batchIds: true });346 const counts = diagnostics();347 const store = createTableStore<Row, RowInit>(db.value, table, counts);348 await store.insert({ text: "mine", completed: false, createdAt: new Date(0) });349 db.emitMutationError({350 code: "WriteRejected",351 reason: "another table's write",352 batch: { batchId: "batch-999" },353 } as unknown as MutationErrorEvent);354 assert(store.getSnapshot().status !== "error", "a foreign batch must not fail this store");355 assertCount(counts.mutationErrors, 0, "a foreign batch must not count against this store");356 db.emitMutationError({357 code: "WriteRejected",358 reason: "revoked",359 batch: { batchId: "batch-1" },360 } as unknown as MutationErrorEvent);361 assert(store.getSnapshot().status === "error", "the owning store must surface its rejection");362 assert(store.getSnapshot().error?.includes("revoked"), "the cause must reach the UI");363 assertCount(counts.mutationErrors, 1, "the owned rejection must be counted once");364});365366test("an unsubscribe arriving after close cannot drive consumers negative", () => {367 const counts = diagnostics();368 const store = createTableStore<Row, RowInit>(fakeDb().value, table, counts);369 const stop = store.subscribe(() => undefined);370 store.close();371 assertCount(counts.activeConsumers, 0, "close must release every consumer");372 stop();373 assertCount(counts.activeConsumers, 0, "a late unsubscribe must not double-decrement");374});375376test("a mutation does not present an empty loading table as ready", async () => {377 const db = fakeDb();378 const store = createTableStore<Row, RowInit>(db.value, table, diagnostics());379 const stop = store.subscribe(() => undefined);380 assert(store.getSnapshot().status === "loading", "a fresh store must report loading");381 await store.insert({ text: "first", completed: false, createdAt: new Date(0) });382 assert(383 store.getSnapshot().status === "loading",384 "a write before the first query result must not claim the table is ready",385 );386 db.publish([{ id: "row-1", text: "first", completed: false, createdAt: new Date(0) }]);387 assert(store.getSnapshot().status === "ready", "the first query result must end loading");388 stop();389});