clone

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

Merge pull request #60 from FelineStateMachine/feat/preact-live-data

FelineStateMachine committed 846650f
feat(preact): add live data hooks
commit
846650f814b735cf1164520866951bb0e4ae3762
parent
954c97b0d681b46a6b8adc69a032462651e3738a
parent
c5b445fe989d800e3cdb7615972504b2be452e37
author
FelineStateMachine <dami.etoile@gmail.com>
11 files changed, +624 -64
Mapps/reference/src/islands/TaskList.tsx2+-
Mapps/reference/src/islands/use-tasks.ts78++------
Mpackage/astro/mod.ts2+
Mpackage/astro/mod_test.ts2+
Apackage/preact/live-data.ts94+++++++++
Apackage/preact/live-data_test.tsx23++
Mpackage/preact/mod.ts7+
Mpackage/runtime/mod.ts6+
Apackage/runtime/table-mutations.ts310++++++++++++++++++++++++++++++
Apackage/runtime/table-mutations_test.ts160+++++++++++++++
Mpackage/testdata/starter.snapshot.json4+-
M apps/reference/src/islands/TaskList.tsx +1 -1
M apps/reference/src/islands/use-tasks.ts +17 -61
@@ -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 };
M package/astro/mod.ts +2 -0
M package/astro/mod_test.ts +2 -0
A package/preact/live-data.ts +94 -0
@@ -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 };
A package/preact/live-data_test.tsx +23 -0
M package/preact/mod.ts +7 -0
M package/runtime/mod.ts +6 -0
A package/runtime/table-mutations.ts +310 -0
@@ -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());
A package/runtime/table-mutations_test.ts +160 -0
@@ -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+});
M package/testdata/starter.snapshot.json +2 -2