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-mutations_test.ts
table-mutations_test.ts 7.6K
1import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";2import { schema as s } from "jazz-tools";3import { createDiagnostics } from "./diagnostics.ts";4import { type TableMutationEnvironment, TableMutationRegistry } from "./table-mutations.ts";5import { assert, assertCount } from "./test-assert.ts";67type Row = { id: string; title: string; archived: boolean };8type Init = Omit<Row, "id">;9const table = { _table: "records", _schema: {} } as TableProxy<Row, Init>;1011class FakeDb {12 mutationListeners = new Set<(event: MutationErrorEvent) => void>();13 localWaits = 0;14 globalWaits = 0;15 failLocal: Error | null = null;16 nextId = 0;17 batchIds = false;18 nextBatch = 0;19 operations: string[] = [];2021 value(): Db {22 return this as unknown as Db;23 }2425 onMutationError(listener: (event: MutationErrorEvent) => void): () => void {26 this.mutationListeners.add(listener);27 return () => this.mutationListeners.delete(listener);28 }2930 insert(_table: TableProxy<Row, Init>, values: Init) {31 this.operations.push("insert");32 return this.handle({ id: `row-${++this.nextId}`, ...values });33 }3435 update(_table: TableProxy<Row, Init>, _id: string, _patch: Partial<Init>) {36 this.operations.push("update");37 return this.handle(undefined);38 }3940 delete(_table: TableProxy<Row, Init>, _id: string) {41 this.operations.push("delete");42 return this.handle(undefined);43 }4445 reject(event: MutationErrorEvent): void {46 for (const listener of this.mutationListeners) listener(event);47 }4849 private handle<T>(value: T) {50 return {51 ...(this.batchIds ? { batchId: `batch-${++this.nextBatch}` } : {}),52 wait: ({ tier }: { tier: "local" | "global" }) => {53 if (tier === "local") {54 this.localWaits += 1;55 if (this.failLocal) {56 const error = this.failLocal;57 this.failLocal = null;58 return Promise.reject(error);59 }60 } else {61 this.globalWaits += 1;62 }63 return Promise.resolve(value);64 },65 };66 }67}6869function environment(db: FakeDb, syncConfigured = false) {70 const diagnostics = createDiagnostics();71 const recreation = new Set<() => void>();72 const value: TableMutationEnvironment = {73 getDb: () => Promise.resolve(db.value()),74 syncConfigured: () => syncConfigured,75 subscribeRuntimeRecreation(listener) {76 recreation.add(listener);77 return () => recreation.delete(listener);78 },79 updateDiagnostics(update) {80 update(diagnostics);81 },82 };83 return { value, diagnostics, recreation };84}8586async function flush(): Promise<void> {87 await Promise.resolve();88 await Promise.resolve();89}9091Deno.test("table mutation consumers share one listener and release it after final teardown", async () => {92 const db = new FakeDb();93 const runtime = environment(db);94 const registry = new TableMutationRegistry(runtime.value);95 const first = registry.acquire(table);96 const second = registry.acquire(table);97 assert(first.store === second.store, "one table created duplicate mutation stores");98 const stopFirst = first.store.subscribe(() => undefined);99 const stopSecond = second.store.subscribe(() => undefined);100 await flush();101 assertCount(db.mutationListeners.size, 1, "one table opened duplicate mutation listeners");102 assertCount(runtime.diagnostics.activeMutationListeners, 1, "listener diagnostics drifted");103 stopFirst();104 first.release();105 assertCount(db.mutationListeners.size, 1, "one remaining consumer lost mutation errors");106 stopSecond();107 second.release();108 assertCount(db.mutationListeners.size, 0, "final teardown retained the mutation listener");109 assertCount(runtime.diagnostics.activeMutationListeners, 0, "listener diagnostics leaked");110});111112Deno.test("typed mutations return inserted rows and wait for local durability", async () => {113 const db = new FakeDb();114 const runtime = environment(db);115 const store = new TableMutationRegistry(runtime.value).acquire(table).store;116 const created = await store.insert({ title: "created", archived: false });117 await store.update(created.id, { archived: true });118 await store.remove(created.id);119 assert(created.id === "row-1" && created.title === "created", "insert omitted the created row");120 assert(db.operations.join(",") === "insert,update,delete", "typed operations missed the DB");121 assertCount(db.localWaits, 3, "mutations resolved before local durability");122 assertCount(runtime.diagnostics.localWaitCalls, 3, "local waits were not diagnosable");123 assert(store.getSnapshot().durability === "local", "local durability was not observable");124});125126Deno.test("managed mutations continue global durability tracking in the background", async () => {127 const db = new FakeDb();128 const runtime = environment(db, true);129 const store = new TableMutationRegistry(runtime.value).acquire(table).store;130 await store.insert({ title: "managed", archived: false });131 await flush();132 assertCount(db.globalWaits, 1, "managed mutation omitted its global wait");133 assert(store.getSnapshot().durability === "global", "global confirmation was not observable");134 assertCount(runtime.diagnostics.pendingGlobalWrites, 0, "global wait remained pending");135});136137Deno.test("local and asynchronous permission rejections reach public mutation state", async () => {138 const db = new FakeDb();139 const runtime = environment(db);140 const lease = new TableMutationRegistry(runtime.value).acquire(table);141 const stop = lease.store.subscribe(() => undefined);142 await flush();143 db.failLocal = new Error("local permission denied");144 await lease.store.insert({ title: "blocked", archived: false }).catch(() => undefined);145 assert(lease.store.getSnapshot().error === "local permission denied", "local rejection hidden");146 db.reject({ code: "WriteRejected", reason: "revoked" } as MutationErrorEvent);147 assert(lease.store.getSnapshot().error?.includes("revoked"), "async rejection hidden");148 assertCount(runtime.diagnostics.mutationErrors, 2, "rejections were not diagnosed");149 stop();150 lease.release();151});152153Deno.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});185186Deno.test("mutation types follow the exact Jazz table insert and row shapes", () => {187 const app = s.defineApp({ records: s.table({ title: s.string(), archived: s.boolean() }) });188 const registry = new TableMutationRegistry(environment(new FakeDb()).value);189 const lease = registry.acquire(app.records);190 const result: Promise<s.RowOf<typeof app.records>> = lease.store.insert({191 title: "typed",192 archived: false,193 });194 assert(result instanceof Promise, "typed insert did not return a promise");195 lease.release();196});