- commit
- c5b445fe989d800e3cdb7615972504b2be452e37
- parent
- 954c97b0d681b46a6b8adc69a032462651e3738a
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 2 | +- |
| M | apps/ | 78 | ++------ |
| M | package/ | 2 | + |
| M | package/ | 2 | + |
| A | package/ | 94 | +++++++++ |
| A | package/ | 23 | ++ |
| M | package/ | 7 | + |
| M | package/ | 6 | + |
| A | package/ | 310 | ++++++++++++++++++++++++++++++ |
| A | package/ | 160 | +++++++++++++++ |
| M | package/ | 4 | +- |
@@ -40,7 +40,7 @@ export default function TaskList() {
4040 <p class="state" role="status">4141 {status === "loading" && "Opening persistent storage…"}4242 {status === "error" &&43- `${failureKind === "startup" ? "Startup failed" : "Write failed"}: ${error}`}43+ `${failureKind === "read" ? "Read failed" : "Write failed"}: ${error}`}4444 {status === "ready" && `${tasks.length} item(s) · ${4545 durability === "global"4646 ? "synced to your account"@@ -1,6 +1,6 @@
1-import { useCallback, useEffect, useState } from "preact/hooks";1+import { useCallback } from "preact/hooks";22 import { schema as s } from "jazz-tools";3-import { getRuntime, runtimeRecreatedEvent, type TableSnapshot } from "@nzip/lofi";3+import { useLiveQuery, useTableMutations } from "@nzip/lofi/preact";44 import { app } from "../app.ts";55 66 /**@@ -14,71 +14,27 @@ const tasksTable = app.schema.tasks;
1414 /** Row and insert types come straight from the declared schema. */1515 export type Task = s.RowOf<typeof tasksTable>;1616 17-const initial: TableSnapshot<Task> = {18- status: "loading",19- rows: [],20- durability: "none",21- error: null,22-};23-2417 export function useTasks() {25- const [snapshot, setSnapshot] = useState(initial);26- const [startupFailed, setStartupFailed] = useState(false);27-28- useEffect(() => {29- let cancelled = false;30- let connectionGeneration = 0;31- let unsubscribe: (() => void) | undefined;32- // The local-first account opens immediately, so this only reconnects when the33- // runtime is recreated — e.g. after electing to sync or restoring an account,34- // both of which dispatch `runtimeRecreatedEvent`.35- const connect = () => {36- const generation = ++connectionGeneration;37- unsubscribe?.();38- unsubscribe = undefined;39- setSnapshot(initial);40- setStartupFailed(false);41- void getRuntime().then((runtime) => {42- if (cancelled || generation !== connectionGeneration) return;43- const store = runtime.store(tasksTable);44- const update = () => setSnapshot(store.getSnapshot());45- unsubscribe = store.subscribe(update);46- update();47- }, (error) => {48- if (cancelled || generation !== connectionGeneration) return;49- setStartupFailed(true);50- setSnapshot({51- ...initial,52- status: "error",53- durability: "failed",54- error: error instanceof Error ? error.message : String(error),55- });56- });57- };58- connect();59- globalThis.addEventListener(runtimeRecreatedEvent, connect);60- return () => {61- cancelled = true;62- connectionGeneration += 1;63- globalThis.removeEventListener(runtimeRecreatedEvent, connect);64- unsubscribe?.();65- };66- }, []);18+ const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);19+ const mutations = useTableMutations(tasksTable);6720 68- // The generic store also exposes `update` (for edits) and `delete`; this69- // starter only needs insert and a boolean toggle. Add more verbs as you grow.70- const store = useCallback(async () => (await getRuntime()).store(tasksTable), []);21+ // The generic mutation surface also exposes `remove`; this starter only needs22+ // insert and a boolean toggle. Add more domain verbs as you grow.7123 const create = useCallback(async (text: string) => {72- await (await store()).insert({ text, completed: false, createdAt: new Date() });73- }, [store]);24+ await mutations.insert({ text, completed: false, createdAt: new Date() });25+ }, [mutations.insert]);7426 const setCompleted = useCallback(async (id: string, completed: boolean) => {75- await (await store()).update(id, { completed });76- }, [store]);27+ await mutations.update(id, { completed });28+ }, [mutations.update]);29+30+ const mutationFailed = mutations.error !== null;7731 7832 return {79- ...snapshot,80- tasks: snapshot.rows,81- failureKind: startupFailed ? "startup" as const : "write" as const,33+ status: query.status === "error" || mutationFailed ? "error" as const : query.status,34+ error: query.error ?? mutations.error,35+ durability: mutations.durability,36+ tasks: query.rows,37+ failureKind: query.status === "error" ? "read" as const : "write" as const,8238 create,8339 setCompleted,8440 };@@ -38,6 +38,7 @@ const runtimeFiles = [
3838 "runtime.ts",3939 "session.ts",4040 "startup-recovery.ts",41+ "table-mutations.ts",4142 "table-store.ts",4243 "ui-mutation.ts",4344 ] as const;@@ -53,6 +54,7 @@ const accessFiles = [
5354 5455 const preactFiles = [5556 "DeviceStatus.tsx",57+ "live-data.ts",5658 "mod.ts",5759 "PwaActions.tsx",5860 "RuntimeRecovery.tsx",@@ -26,9 +26,11 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
2626 }2727 await Deno.stat(`${root}/.lofi/package/preact/DeviceStatus.tsx`);2828 await Deno.stat(`${root}/.lofi/package/preact/RuntimeRecovery.tsx`);29+ await Deno.stat(`${root}/.lofi/package/preact/live-data.ts`);2930 await Deno.stat(`${root}/.lofi/package/runtime/passkey-recovery.ts`);3031 await Deno.stat(`${root}/.lofi/package/runtime/live-query-store.ts`);3132 await Deno.stat(`${root}/.lofi/package/runtime/startup-recovery.ts`);33+ await Deno.stat(`${root}/.lofi/package/runtime/table-mutations.ts`);3234 await Deno.stat(`${root}/.lofi/package/access/mod.ts`);3335 } finally {3436 await Deno.remove(root, { recursive: true });@@ -0,0 +1,94 @@
1+import type { QueryBuilder, TableProxy } from "jazz-tools";2+import { useCallback, useEffect, useRef, useState } from "preact/hooks";3+import {4+ acquireLiveQuery,5+ type LiveQuerySnapshot,6+ type LiveQueryStore,7+} from "../runtime/live-query-store.ts";8+import {9+ acquireTableMutations,10+ type TableMutationSnapshot,11+ type TableMutationStore,12+} from "../runtime/table-mutations.ts";13+import type { TableRow } from "../runtime/table-store.ts";14+15+const loading = <T extends TableRow>(): LiveQuerySnapshot<T> => ({16+ status: "loading",17+ rows: [],18+ error: null,19+});20+21+/** Subscribes a Preact component to any typed Jazz query. */22+export function useLiveQuery<T extends TableRow>(23+ createQuery: () => QueryBuilder<T>,24+ dependencies: readonly unknown[],25+): LiveQuerySnapshot<T> {26+ const [snapshot, setSnapshot] = useState<LiveQuerySnapshot<T>>(loading);27+ useEffect(() => {28+ setSnapshot(loading());29+ try {30+ const lease = acquireLiveQuery(createQuery());31+ const update = () => setSnapshot(lease.store.getSnapshot());32+ const unsubscribe = lease.store.subscribe(update);33+ update();34+ return () => {35+ unsubscribe();36+ lease.release();37+ };38+ } catch (error) {39+ setSnapshot({40+ status: "error",41+ rows: [],42+ error: error instanceof Error ? error.message : String(error),43+ });44+ }45+ }, dependencies);46+ return snapshot;47+}48+49+/** Typed mutation methods plus their shared table-scoped observable state. */50+export type TableMutations<T extends TableRow, Init> = TableMutationSnapshot & {51+ insert(values: Init): Promise<T>;52+ update(id: string, patch: Partial<Init>): Promise<void>;53+ remove(id: string): Promise<void>;54+};55+56+/** Binds one typed Jazz table to stable insert, update, and remove methods. */57+export function useTableMutations<T extends TableRow, Init>(58+ table: TableProxy<T, Init>,59+): TableMutations<T, Init> {60+ const [snapshot, setSnapshot] = useState<TableMutationSnapshot>({61+ pending: 0,62+ durability: "none",63+ error: null,64+ });65+ const store = useRef<TableMutationStore<T, Init> | null>(null);66+ useEffect(() => {67+ const lease = acquireTableMutations(table);68+ store.current = lease.store;69+ const update = () => setSnapshot(lease.store.getSnapshot());70+ const unsubscribe = lease.store.subscribe(update);71+ update();72+ return () => {73+ if (store.current === lease.store) store.current = null;74+ unsubscribe();75+ lease.release();76+ };77+ }, [table]);78+79+ const requireStore = useCallback((): TableMutationStore<T, Init> => {80+ if (!store.current) throw new Error("table mutations are not mounted yet");81+ return store.current;82+ }, []);83+ const insert = useCallback((values: Init) => requireStore().insert(values), [requireStore]);84+ const update = useCallback(85+ (id: string, patch: Partial<Init>) => requireStore().update(id, patch),86+ [requireStore],87+ );88+ const remove = useCallback((id: string) => requireStore().remove(id), [requireStore]);89+ return { ...snapshot, insert, update, remove };90+}91+92+// Keep the exact store type reachable in generated declaration output without93+// asking application authors to import implementation modules.94+export type { LiveQuerySnapshot, LiveQueryStore, TableMutationSnapshot, TableMutationStore };@@ -0,0 +1,23 @@
1+import { schema as s } from "jazz-tools";2+import { useLiveQuery, useTableMutations } from "./live-data.ts";3+4+const app = s.defineApp({5+ records: s.table({ title: s.string(), archived: s.boolean() }),6+});7+8+function TypedLiveDataContract() {9+ const query = useLiveQuery(() => app.records.select("title"), []);10+ const mutations = useTableMutations(app.records);11+ const title: string | undefined = query.rows[0]?.title;12+ const inserted: Promise<s.RowOf<typeof app.records>> = mutations.insert({13+ title: "typed",14+ archived: false,15+ });16+ // @ts-expect-error exact select projections must omit unselected columns.17+ query.rows[0]?.archived;18+ return { inserted, title };19+}20+21+Deno.test("Preact live-data hooks retain exact query and table types", () => {22+ if (typeof TypedLiveDataContract !== "function") throw new Error("hook contract was not defined");23+});@@ -8,6 +8,13 @@
88 */99 export { default as DeviceStatus } from "./DeviceStatus.tsx";1010 export { RuntimeRecovery, type RuntimeRecoveryProps } from "./RuntimeRecovery.tsx";11+export {12+ type LiveQuerySnapshot,13+ type TableMutations,14+ type TableMutationSnapshot,15+ useLiveQuery,16+ useTableMutations,17+} from "./live-data.ts";1118 export {1219 PwaActions,1320 type PwaActionsProps,@@ -81,6 +81,12 @@ export {
8181 type RuntimeStartupFailure,8282 type RuntimeStartupFailureCode,8383 } from "./startup-recovery.ts";84+export {85+ acquireTableMutations,86+ type TableMutationLease,87+ type TableMutationSnapshot,88+ type TableMutationStore,89+} from "./table-mutations.ts";8490 export {8591 AccountReplacementError,8692 type AccountReplacementOptions,@@ -0,0 +1,310 @@
1+import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";2+import { syncing } from "./config.ts";3+import type { RuntimeDiagnostics } from "./diagnostics.ts";4+import { getRuntime, runtimeRecreatedEvent, updateRuntimeDiagnostics } from "./runtime.ts";5+import type { TableRow } from "./table-store.ts";6+7+/** Observable state shared by every mutation consumer for one table. */8+export type TableMutationSnapshot = {9+ pending: number;10+ durability: "none" | "local" | "global" | "failed";11+ error: string | null;12+};13+14+type Listener = () => void;15+type MutationHandle<T> = { wait(options: { tier: "local" | "global" }): Promise<T> };16+17+/** Runtime seams used by table-mutation tests and the package-wide registry. */18+export type TableMutationEnvironment = {19+ getDb(): Promise<Db>;20+ syncConfigured(): boolean;21+ subscribeRuntimeRecreation(listener: () => void): () => void;22+ updateDiagnostics(update: (diagnostics: RuntimeDiagnostics) => void): void;23+};24+25+/** Framework-neutral typed mutations and observable durability for one table. */26+export class TableMutationStore<T extends TableRow, Init> {27+ readonly #table: TableProxy<T, Init>;28+ readonly #environment: TableMutationEnvironment;29+ readonly #onIdle: () => void;30+ readonly #listeners = new Set<Listener>();31+ #snapshot: TableMutationSnapshot = { pending: 0, durability: "none", error: null };32+ #stopMutationErrors: (() => void) | null = null;33+ #stopRuntimeRecreation: (() => void) | null = null;34+ #generation = 0;35+ #writeGeneration = 0;36+ #disposed = false;37+38+ /** Creates one registry-owned mutation store. */39+ constructor(40+ table: TableProxy<T, Init>,41+ environment: TableMutationEnvironment,42+ onIdle: () => void,43+ ) {44+ this.#table = table;45+ this.#environment = environment;46+ this.#onIdle = onIdle;47+ }48+49+ /** Returns current pending, durability, and rejection state. */50+ getSnapshot = (): TableMutationSnapshot => this.#snapshot;51+52+ /** Retains the table-scoped error listener for one consumer. */53+ subscribe = (listener: Listener): () => void => {54+ if (this.#disposed) throw new Error("table mutation store has been released");55+ const subscriber = () => listener();56+ this.#listeners.add(subscriber);57+ if (this.#listeners.size === 1) this.#start();58+ listener();59+ let active = true;60+ return () => {61+ if (!active) return;62+ active = false;63+ this.#listeners.delete(subscriber);64+ if (this.#listeners.size === 0) {65+ this.#stop();66+ this.#onIdle();67+ }68+ };69+ };70+71+ /** Number of mounted consumers sharing this table-scoped mutation state. */72+ get consumerCount(): number {73+ return this.#listeners.size;74+ }75+76+ /** Inserts and returns the typed created row after local durability. */77+ async insert(values: Init): Promise<T> {78+ return await this.#perform((db) => db.insert(this.#table, values));79+ }80+81+ /** Updates a row and resolves after local durability. */82+ async update(id: string, patch: Partial<Init>): Promise<void> {83+ await this.#perform((db) => db.update(this.#table, id, patch));84+ }85+86+ /** Deletes a row and resolves after local durability. */87+ async remove(id: string): Promise<void> {88+ await this.#perform((db) => db.delete(this.#table, id));89+ }90+91+ /** Releases listeners owned by an evicted or hot-reloaded store. */92+ dispose(): void {93+ if (this.#disposed) return;94+ this.#disposed = true;95+ this.#listeners.clear();96+ this.#stop();97+ }98+99+ async #perform<Result>(create: (db: Db) => MutationHandle<Result>): Promise<Result> {100+ if (this.#disposed) throw new Error("table mutation store has been released");101+ const db = await this.#environment.getDb();102+ let mutation: MutationHandle<Result>;103+ try {104+ mutation = create(db);105+ } catch (error) {106+ this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);107+ this.#fail(error);108+ throw error;109+ }110+ return await this.#settle(mutation);111+ }112+113+ async #settle<Result>(mutation: MutationHandle<Result>): Promise<Result> {114+ const generation = ++this.#writeGeneration;115+ this.#updateDiagnostics((diagnostics) => diagnostics.pendingLocalWrites += 1);116+ this.#set({ pending: this.#snapshot.pending + 1, durability: "none", error: null });117+ try {118+ const result = await mutation.wait({ tier: "local" });119+ this.#updateDiagnostics((diagnostics) => {120+ diagnostics.pendingLocalWrites -= 1;121+ diagnostics.localWaitCalls += 1;122+ });123+ this.#set({124+ pending: Math.max(0, this.#snapshot.pending - 1),125+ ...(generation === this.#writeGeneration ? { durability: "local" as const } : {}),126+ });127+ if (this.#environment.syncConfigured()) this.#trackGlobal(mutation, generation);128+ return result;129+ } catch (error) {130+ this.#updateDiagnostics((diagnostics) => {131+ diagnostics.pendingLocalWrites -= 1;132+ diagnostics.mutationErrors += 1;133+ });134+ this.#set({ pending: Math.max(0, this.#snapshot.pending - 1) });135+ this.#fail(error);136+ throw error;137+ }138+ }139+140+ #trackGlobal<Result>(mutation: MutationHandle<Result>, generation: number): void {141+ this.#updateDiagnostics((diagnostics) => diagnostics.pendingGlobalWrites += 1);142+ void mutation.wait({ tier: "global" }).then(143+ () => {144+ if (generation === this.#writeGeneration) this.#set({ durability: "global", error: null });145+ },146+ (error) => {147+ this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);148+ this.#fail(error);149+ },150+ ).finally(() => this.#updateDiagnostics((diagnostics) => diagnostics.pendingGlobalWrites -= 1));151+ }152+153+ #start(): void {154+ this.#stopRuntimeRecreation = this.#environment.subscribeRuntimeRecreation(() =>155+ this.#attach()156+ );157+ this.#attach();158+ }159+160+ #attach(): void {161+ const generation = ++this.#generation;162+ this.#detachMutationErrors();163+ void this.#environment.getDb().then((db) => {164+ if (this.#disposed || generation !== this.#generation || this.#listeners.size === 0) return;165+ let stop: () => void;166+ try {167+ stop = db.onMutationError((event) => this.#reportMutationError(event, generation));168+ } catch (error) {169+ this.#fail(error, generation);170+ return;171+ }172+ if (this.#disposed || generation !== this.#generation || this.#listeners.size === 0) {173+ stop();174+ return;175+ }176+ this.#stopMutationErrors = stop;177+ this.#updateDiagnostics((diagnostics) => {178+ diagnostics.activeMutationListeners += 1;179+ diagnostics.totalMutationListeners += 1;180+ });181+ }, (error) => this.#fail(error, generation));182+ }183+184+ #reportMutationError(event: MutationErrorEvent, generation: number): void {185+ if (generation !== this.#generation) return;186+ this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);187+ this.#fail(new Error(`${event.code}: ${event.reason}`));188+ }189+190+ #stop(): void {191+ this.#generation += 1;192+ this.#detachMutationErrors();193+ this.#stopRuntimeRecreation?.();194+ this.#stopRuntimeRecreation = null;195+ }196+197+ #detachMutationErrors(): void {198+ if (!this.#stopMutationErrors) return;199+ this.#stopMutationErrors();200+ this.#stopMutationErrors = null;201+ this.#updateDiagnostics((diagnostics) => diagnostics.activeMutationListeners -= 1);202+ }203+204+ #fail(error: unknown, generation = this.#generation): void {205+ if (this.#disposed || generation !== this.#generation) return;206+ this.#set({207+ durability: "failed",208+ error: error instanceof Error ? error.message : String(error),209+ });210+ }211+212+ #set(change: Partial<TableMutationSnapshot>): void {213+ this.#snapshot = { ...this.#snapshot, ...change };214+ if (change.durability) {215+ this.#updateDiagnostics((diagnostics) =>216+ diagnostics.lastWriteDurability = change.durability!217+ );218+ }219+ for (const listener of this.#listeners) listener();220+ }221+222+ #updateDiagnostics(update: (diagnostics: RuntimeDiagnostics) => void): void {223+ this.#environment.updateDiagnostics(update);224+ }225+}226+227+/** Retained ownership of one shared table-mutation store. */228+export type TableMutationLease<T extends TableRow, Init> = {229+ store: TableMutationStore<T, Init>;230+ release(): void;231+};232+233+type Entry = { store: TableMutationStore<TableRow, unknown>; leases: number };234+235+/** Shares mutation state and one vendor error listener per schema table. */236+export class TableMutationRegistry {237+ readonly #environment: TableMutationEnvironment;238+ readonly #schemas = new WeakMap<object, Map<string, Entry>>();239+ readonly #stores = new Set<TableMutationStore<TableRow, unknown>>();240+241+ /** Creates an isolated registry over one runtime environment. */242+ constructor(environment: TableMutationEnvironment) {243+ this.#environment = environment;244+ }245+246+ /** Acquires the shared mutation surface for a typed table. */247+ acquire<T extends TableRow, Init>(table: TableProxy<T, Init>): TableMutationLease<T, Init> {248+ const schema = table._schema as object;249+ let entries = this.#schemas.get(schema);250+ if (!entries) {251+ entries = new Map();252+ this.#schemas.set(schema, entries);253+ }254+ let entry = entries.get(table._table);255+ if (!entry) {256+ const store = new TableMutationStore(257+ table,258+ this.#environment,259+ () => this.#evictIfUnused(entries!, table._table),260+ );261+ entry = { store: store as TableMutationStore<TableRow, unknown>, leases: 0 };262+ entries.set(table._table, entry);263+ this.#stores.add(entry.store);264+ }265+ entry.leases += 1;266+ let retained = true;267+ return {268+ store: entry.store as TableMutationStore<T, Init>,269+ release: () => {270+ if (!retained) return;271+ retained = false;272+ entry!.leases -= 1;273+ this.#evictIfUnused(entries!, table._table);274+ },275+ };276+ }277+278+ /** Disposes all active stores during hot replacement. */279+ dispose(): void {280+ for (const store of this.#stores) store.dispose();281+ this.#stores.clear();282+ }283+284+ #evictIfUnused(entries: Map<string, Entry>, key: string): void {285+ const entry = entries.get(key);286+ if (!entry || entry.leases > 0 || entry.store.consumerCount > 0) return;287+ entries.delete(key);288+ this.#stores.delete(entry.store);289+ entry.store.dispose();290+ }291+}292+293+const defaultRegistry = new TableMutationRegistry({294+ getDb: async () => (await getRuntime()).db,295+ syncConfigured: syncing,296+ subscribeRuntimeRecreation(listener) {297+ globalThis.addEventListener(runtimeRecreatedEvent, listener);298+ return () => globalThis.removeEventListener(runtimeRecreatedEvent, listener);299+ },300+ updateDiagnostics: updateRuntimeDiagnostics,301+});302+303+/** Acquires the package-wide typed mutation surface for one table. */304+export function acquireTableMutations<T extends TableRow, Init>(305+ table: TableProxy<T, Init>,306+): TableMutationLease<T, Init> {307+ return defaultRegistry.acquire(table);308+}309+310+import.meta.hot?.dispose(() => defaultRegistry.dispose());@@ -0,0 +1,160 @@
1+import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";2+import { schema as s } from "jazz-tools";3+import { createDiagnostics } from "./diagnostics.ts";4+import { type TableMutationEnvironment, TableMutationRegistry } from "./table-mutations.ts";5+import { assert, assertCount } from "./test-assert.ts";6+7+type Row = { id: string; title: string; archived: boolean };8+type Init = Omit<Row, "id">;9+const table = { _table: "records", _schema: {} } as TableProxy<Row, Init>;10+11+class FakeDb {12+ mutationListeners = new Set<(event: MutationErrorEvent) => void>();13+ localWaits = 0;14+ globalWaits = 0;15+ failLocal: Error | null = null;16+ nextId = 0;17+ operations: string[] = [];18+19+ value(): Db {20+ return this as unknown as Db;21+ }22+23+ onMutationError(listener: (event: MutationErrorEvent) => void): () => void {24+ this.mutationListeners.add(listener);25+ return () => this.mutationListeners.delete(listener);26+ }27+28+ insert(_table: TableProxy<Row, Init>, values: Init) {29+ this.operations.push("insert");30+ return this.handle({ id: `row-${++this.nextId}`, ...values });31+ }32+33+ update(_table: TableProxy<Row, Init>, _id: string, _patch: Partial<Init>) {34+ this.operations.push("update");35+ return this.handle(undefined);36+ }37+38+ delete(_table: TableProxy<Row, Init>, _id: string) {39+ this.operations.push("delete");40+ return this.handle(undefined);41+ }42+43+ reject(event: MutationErrorEvent): void {44+ for (const listener of this.mutationListeners) listener(event);45+ }46+47+ private handle<T>(value: T) {48+ return {49+ wait: ({ tier }: { tier: "local" | "global" }) => {50+ if (tier === "local") {51+ this.localWaits += 1;52+ if (this.failLocal) {53+ const error = this.failLocal;54+ this.failLocal = null;55+ return Promise.reject(error);56+ }57+ } else {58+ this.globalWaits += 1;59+ }60+ return Promise.resolve(value);61+ },62+ };63+ }64+}65+66+function environment(db: FakeDb, syncConfigured = false) {67+ const diagnostics = createDiagnostics();68+ const recreation = new Set<() => void>();69+ const value: TableMutationEnvironment = {70+ getDb: () => Promise.resolve(db.value()),71+ syncConfigured: () => syncConfigured,72+ subscribeRuntimeRecreation(listener) {73+ recreation.add(listener);74+ return () => recreation.delete(listener);75+ },76+ updateDiagnostics(update) {77+ update(diagnostics);78+ },79+ };80+ return { value, diagnostics, recreation };81+}82+83+async function flush(): Promise<void> {84+ await Promise.resolve();85+ await Promise.resolve();86+}87+88+Deno.test("table mutation consumers share one listener and release it after final teardown", async () => {89+ const db = new FakeDb();90+ const runtime = environment(db);91+ const registry = new TableMutationRegistry(runtime.value);92+ const first = registry.acquire(table);93+ const second = registry.acquire(table);94+ assert(first.store === second.store, "one table created duplicate mutation stores");95+ const stopFirst = first.store.subscribe(() => undefined);96+ const stopSecond = second.store.subscribe(() => undefined);97+ await flush();98+ assertCount(db.mutationListeners.size, 1, "one table opened duplicate mutation listeners");99+ assertCount(runtime.diagnostics.activeMutationListeners, 1, "listener diagnostics drifted");100+ stopFirst();101+ first.release();102+ assertCount(db.mutationListeners.size, 1, "one remaining consumer lost mutation errors");103+ stopSecond();104+ second.release();105+ assertCount(db.mutationListeners.size, 0, "final teardown retained the mutation listener");106+ assertCount(runtime.diagnostics.activeMutationListeners, 0, "listener diagnostics leaked");107+});108+109+Deno.test("typed mutations return inserted rows and wait for local durability", async () => {110+ const db = new FakeDb();111+ const runtime = environment(db);112+ const store = new TableMutationRegistry(runtime.value).acquire(table).store;113+ const created = await store.insert({ title: "created", archived: false });114+ await store.update(created.id, { archived: true });115+ await store.remove(created.id);116+ assert(created.id === "row-1" && created.title === "created", "insert omitted the created row");117+ assert(db.operations.join(",") === "insert,update,delete", "typed operations missed the DB");118+ assertCount(db.localWaits, 3, "mutations resolved before local durability");119+ assertCount(runtime.diagnostics.localWaitCalls, 3, "local waits were not diagnosable");120+ assert(store.getSnapshot().durability === "local", "local durability was not observable");121+});122+123+Deno.test("managed mutations continue global durability tracking in the background", async () => {124+ const db = new FakeDb();125+ const runtime = environment(db, true);126+ const store = new TableMutationRegistry(runtime.value).acquire(table).store;127+ await store.insert({ title: "managed", archived: false });128+ await flush();129+ assertCount(db.globalWaits, 1, "managed mutation omitted its global wait");130+ assert(store.getSnapshot().durability === "global", "global confirmation was not observable");131+ assertCount(runtime.diagnostics.pendingGlobalWrites, 0, "global wait remained pending");132+});133+134+Deno.test("local and asynchronous permission rejections reach public mutation state", async () => {135+ const db = new FakeDb();136+ const runtime = environment(db);137+ const lease = new TableMutationRegistry(runtime.value).acquire(table);138+ const stop = lease.store.subscribe(() => undefined);139+ await flush();140+ db.failLocal = new Error("local permission denied");141+ await lease.store.insert({ title: "blocked", archived: false }).catch(() => undefined);142+ assert(lease.store.getSnapshot().error === "local permission denied", "local rejection hidden");143+ db.reject({ code: "WriteRejected", reason: "revoked" } as MutationErrorEvent);144+ assert(lease.store.getSnapshot().error?.includes("revoked"), "async rejection hidden");145+ assertCount(runtime.diagnostics.mutationErrors, 2, "rejections were not diagnosed");146+ stop();147+ lease.release();148+});149+150+Deno.test("mutation types follow the exact Jazz table insert and row shapes", () => {151+ const app = s.defineApp({ records: s.table({ title: s.string(), archived: s.boolean() }) });152+ const registry = new TableMutationRegistry(environment(new FakeDb()).value);153+ const lease = registry.acquire(app.records);154+ const result: Promise<s.RowOf<typeof app.records>> = lease.store.insert({155+ title: "typed",156+ archived: false,157+ });158+ assert(result instanceof Promise, "typed insert did not return a promise");159+ lease.release();160+});@@ -13,8 +13,8 @@
1313 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1414 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1515 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",16- "src/islands/TaskList.tsx": "64275d107c2ff725c514d951bd577990e3aeab4e2dcb8f182ded62ebd75f38e7",17- "src/islands/use-tasks.ts": "6ee49d90580ab9b6810d20617504e0f0147d568d42ab2b381caafb3ab23f8717",16+ "src/islands/TaskList.tsx": "cf559f8529e3836730abcdafab8184032c8ac662a1e794e20f0879161bee37be",17+ "src/islands/use-tasks.ts": "385763aee603f29e3bc6efa2a7d68190653e2957481afbb491b5d7b5e30b13f1",1818 "src/layouts/Shell.astro": "12144355def66c7bf6ed5b0d611ba3a5454cf7499b667936aa6c04494807653a",1919 "src/pages/index.astro": "de9ad86f9b25f7aff1e293931a785dbe393e4d0a35464ffbb649a0ebb4518281",2020 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",