clone

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

feat: effect system — verbs with sync-boundary effects (WriteHandle, durable journal, s.mutation) (#143)

FelineStateMachine committed c4aefb6
* feat(runtime): per-write ledger — durable journal, WriteHandle, verdict taxonomy

Every write now gets a monotonic-stage WriteHandle (await resolves at saved),
a device-local journal entry persisted beside runtime storage and readable at
boot before sync connects, and settlement against the store: confirmation
settles synced, an adjudicated permanent verdict settles rejected, and
transient or unknown codes keep the write pending. Boot reconciliation
rebuilds pending state from the journal, applies replayed verdicts, confirms
writes by global-tier probes, and re-runs outstanding effect obligations
at-least-once. Table mutations route through the ledger and return handles;
their table-scoped snapshot semantics are unchanged.

* feat(schema): s.mutation, s.effect, s.log — verbs carry effect units

Tables are the nouns; mutations are the verbs. s.effect declares a named,
reusable, typed pairing of action and compensation; s.mutation declares a
callable verb over s.insert/s.update/s.remove with its units attached once,
at the declaration, so call sites carry nothing. Inline onSynced/onRejected
are sugar for an implicit unit named after the verb. Names are the journal's
durable identities: unique per app, failing fast on collision, with dev hot
replacement re-registering in place. s.log(label) is the first built-in unit,
recording a structured diagnostics entry on either fate. The module stays
authoring-only and delegates writes to the installed runtime dispatcher.

* feat(preact): useWrite, usePendingWrites, useSyncStatus

Level-triggered bindings over the write ledger: one write's live stage and
rejection reason, the reload-safe pending set for a waiting-to-sync
indicator, and per-row sync badges. Table-mutation methods now surface their
WriteHandle types through the hook layer. Ledger resolution happens inside
effects so server-side prerendering of an island never opens browser
storage.

* test(effects): sync-node integration — stages, reload re-arm, stale-policy rejection

Three suites against a local Jazz server: two-client stage progression with
onSynced firing exactly once on the originating device; boot reconciliation
from a journal frozen at the saved stage, with the effect firing after the
store confirms; and a genuine stale-policy rejection — permissions tightened
while offline — asserting the rejected stage, one compensation run, and the
engine rolling the optimistic row back out of local reads. Runs under its
own task (test:effects) in the check chain because the server needs FFI.

* feat(app): reference showcase — a verb with effects over the tasks table

addTask is now a declared verb carrying s.log and inline consequence and
compensation handlers; setTaskCompleted is a plain verb. The island shows
optimistic UI at saved, a per-row waiting-to-sync badge, the pending-writes
indicator, and effect notices for both fates. The domain hook derives its
durability line from the last write's stage, and the starter snapshot
follows.

* docs(site): nouns and verbs — the effect-system teaching model

Tables are what exists and take policies; mutations are what happens and
take consequences. The guide covers the write lifecycle vocabulary, verb and
effect declaration, the at-least-once delivery contract with journal-id
idempotency, the two meanings of no (local refusal versus adjudicated
rejection, with the engine's own rollback), and the pending-state hooks.
Data-and-UI points at it from the mutation section.

* docs(site): pull the node write-verdict and health contract pages

Pin the /node docs checkout at the lofi-node commit whose site pages state
the write-verdict contract and the /health liveness surface, and list the
new write-verdicts reference page in the manifest.

* test(runtime): pin the ledger's write-guard semantics

A guard refusal surfaces through the verb call with no journal entry, no
rejected verdict, and no effect run; the guard's release covers exactly the
local-durability window, once per write.

* feat(runtime): retention — hashed journal, delivery windows, quarantine, overdue intents

The journal now persists per-column keyed hashes (HMAC-SHA-256 under a
random per-journal key) instead of column values: enough for the boot
equality probe, never a second plaintext copy of row data. Handlers keep the
in-memory row for same-session delivery; after a reload a synced obligation
receives the live store row, and compensation for a rejected write receives
write identity plus the structured cause only — the engine already rolled
the row back.

Durable state also knows how to die. s.effect gains a delivery window
(expiresAfter): past it, a synced write's undelivered obligation retires as
expired — no handler, diagnosable, prunable. Repeatedly failing handlers
quarantine after a bounded attempt count (maxAttempts, default 5) instead of
re-arming forever. s.mutation gains an intent lifespan (expires): an overdue
pending write surfaces through the pending set and diagnostics but is never
retired — the engine offers no withdrawal for a committed batch (verified:
rollbackBatch refuses committed batches and the write syncs on reconnect),
so local retirement could fire compensation for a write that still lands.
Rejections carry a structured cause, denied today with expired reserved for
store-enforced lifespans.

* docs(site): retention — durable things know how to die

Obligations, intents, and residue each have a lifespan and a defined
afterlife: delivery windows retire undelivered obligations of synced writes,
overdue intents surface without compensation until store-enforced expiry can
make retirement sound, and quarantine bounds failing handlers. The guide
also states what the journal persists — keyed column hashes, never values,
with the honest limits of that posture — and the handler-data asymmetry
between same-session and re-armed delivery.
commit
c4aefb6c75bf35b0d90a7a9bec1987f6886aeec0
parent
f039a53706ad3c096e3c5dc1c1c6d70405e677d2
author
FelineStateMachine <dami.etoile@gmail.com>
32 files changed, +3884 -152
M.github/workflows/site.yml2+-
Mapps/reference/src/islands/TaskList.tsx67++-
Mapps/reference/src/islands/use-tasks.ts108+++-
Mdeno.json6+-
Mdocs/data-and-ui.md9+-
Adocs/nouns-and-verbs.md189++++++
Mpackage/astro/mod.ts6+
Apackage/effects_sync_test.ts277+++++++++
Mpackage/preact/live-data.ts15+-
Mpackage/preact/live-data_test.tsx3+-
Mpackage/preact/mod.ts7+
Apackage/preact/write-hooks.ts98+++
Mpackage/runtime/boot.ts6+
Mpackage/runtime/diagnostics.ts45+
Mpackage/runtime/mod.ts29+
Apackage/runtime/mutation-taxonomy.ts39+
Mpackage/runtime/runtime.ts10+
Mpackage/runtime/schema-compat_test.ts19+-
Mpackage/runtime/table-mutations.ts170+++---
Mpackage/runtime/table-mutations_test.ts51+-
Mpackage/runtime/ui-mutation.ts2+-
Apackage/runtime/write-handle.ts220+++++++
Apackage/runtime/write-handle_test.ts62++
Apackage/runtime/write-journal.ts317++++++++++
Apackage/runtime/write-ledger.ts908++++++++++++++++++++++++++++++
Apackage/runtime/write-ledger_test.ts786++++++++++++++++++++++++++
Apackage/schema/effects.ts378++++++++++++
Apackage/schema/effects_test.ts157+++++
Mpackage/schema/mod.ts37+
Mpackage/testdata/starter.snapshot.json4+-
Mwebsite/docs-manifest.json4+
Mwebsite/node-docs-manifest.json5+
M .github/workflows/site.yml +1 -1
M apps/reference/src/islands/TaskList.tsx +49 -18
@@ -1,14 +1,18 @@
11 import { useState } from "preact/hooks";22 import { settleUiMutation } from "@nzip/lofi";3-import { useTasks } from "./use-tasks.ts";3+import { usePendingWrites, useSyncStatus } from "@nzip/lofi/preact";4+import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";45 56 /**6- * Author-owned starter UI. One island over one table: add a row, list rows, and7- * toggle a boolean. Replace it with your own schema and components — the7+ * Author-owned starter UI. One island over one table: add a row through a8+ * verb with effects, list rows with per-row sync badges, and toggle a9+ * boolean. Replace it with your own schema and components — the810 * package-owned runtime stays the same.911 */1012 export default function TaskList() {1113 const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();14+ const notice = useTaskNotice();15+ const pending = usePendingWrites();1216 const [text, setText] = useState("");1317 1418 return (
@@ -23,6 +27,8 @@ export default function TaskList() {
2327 const next = text.trim();2428 if (!next) return;2529 setText("");30+ // The verb's handle is thenable: settled here at saved, while the31+ // sync fate and its effects continue in the background.2632 void settleUiMutation(create(next));2733 }}2834 >
@@ -47,26 +53,51 @@ export default function TaskList() {
4753 : durability === "local"4854 ? "saved on this device"4955 : durability === "failed"50- ? "write failed"56+ ? "write declined"5157 : "ready"5258 }`}5359 </p>60+ {pending.count > 0 && (61+ <p class="state" data-pending-writes={pending.count}>62+ {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync63+ </p>64+ )}65+ {notice && (66+ <p class="state" role="status" data-notice={notice.kind}>67+ {notice.text}68+ </p>69+ )}5470 <ul aria-label="Tasks">55- {tasks.map((task) => (56- <li key={task.id} class={task.completed ? "task task-complete" : "task"}>57- <label class="task-toggle">58- <input59- type="checkbox"60- checked={task.completed}61- aria-label={`Complete ${task.text}`}62- onChange={(event) =>63- void settleUiMutation(setCompleted(task.id, event.currentTarget.checked))}64- />65- <span>{task.text}</span>66- </label>67- </li>68- ))}71+ {tasks.map((task) => <TaskItem key={task.id} task={task} setCompleted={setCompleted} />)}6972 </ul>7073 </section>7174 );7275 }76+77+function TaskItem(78+ { task, setCompleted }: {79+ task: Task;80+ setCompleted: (id: string, completed: boolean) => PromiseLike<unknown>;81+ },82+) {83+ const syncStatus = useSyncStatus(task);84+ return (85+ <li class={task.completed ? "task task-complete" : "task"}>86+ <label class="task-toggle">87+ <input88+ type="checkbox"89+ checked={task.completed}90+ aria-label={`Complete ${task.text}`}91+ onChange={(event) =>92+ void settleUiMutation(setCompleted(task.id, event.currentTarget.checked))}93+ />94+ <span>{task.text}</span>95+ </label>96+ {syncStatus !== "synced" && (97+ <span class="state" data-sync-status={syncStatus}>98+ {syncStatus === "waiting" ? "waiting to sync" : "declined"}99+ </span>100+ )}101+ </li>102+ );103+}
M apps/reference/src/islands/use-tasks.ts +88 -20
@@ -1,38 +1,106 @@
1-import { useCallback } from "preact/hooks";2-import type { RowOf } from "@nzip/lofi";3-import { useLiveQuery, useTableMutations } from "@nzip/lofi/preact";1+import { useCallback, useEffect, useState } from "preact/hooks";2+import type { RowOf, WriteHandle } from "@nzip/lofi";3+import { useLiveQuery, useWrite } from "@nzip/lofi/preact";4+import { s } from "@nzip/lofi/schema";45 import { app } from "../app.ts";56 67 /**7- * Author-owned example. This is the whole binding between a declared table and8- * the UI: pick a table from your schema, derive its row type, and wrap the9- * generic store's writes in domain verbs. Replace `tasks` and the methods below10- * with your own model — framework runtime code remains in `@nzip/lofi`.8+ * Author-owned example. Tables are the nouns of your app; verbs are what9+ * happens to them. This module declares two verbs over the `tasks` table —10+ * `addTask` carries effects that run when the write's sync fate settles, and11+ * `setTaskCompleted` is a plain verb — then wraps them in a domain hook.12+ * Replace `tasks` and the verbs below with your own model; framework runtime13+ * code remains in `@nzip/lofi`.1114 */1215 const tasksTable = app.schema.tasks;1316 1417 /** The row type comes straight from the declared schema. */1518 export type Task = RowOf<typeof tasksTable>;1619 20+/** A one-line consequence or compensation surfaced to the UI. */21+export type TaskNotice = { kind: "synced" | "rejected"; text: string };22+23+// A tiny author-owned notice channel: effect handlers run outside any24+// component, so they publish through module state and hooks subscribe.25+let notice: TaskNotice | null = null;26+const noticeListeners = new Set<() => void>();27+28+function publishNotice(next: TaskNotice | null): void {29+ notice = next;30+ for (const listener of [...noticeListeners]) listener();31+}32+33+/**34+ * The verb call sites use. Its effect units are declared once, here: the35+ * consequence runs when the store confirms the task, the compensation runs if36+ * a stale-policy write is denied — even if the app restarted in between.37+ */38+export const addTask = s.mutation("addTask", s.insert(tasksTable), {39+ effects: [s.log("task-added")],40+ onSynced: (task) => {41+ publishNotice({ kind: "synced", text: `"${task.text ?? "Task"}" synced to your account` });42+ },43+ onRejected: (task) => {44+ // The engine already rolled the denied row back out of local reads; this45+ // compensates what the user was told.46+ publishNotice({47+ kind: "rejected",48+ text: `"${task.text ?? "Task"}" was declined by the store and has been removed`,49+ });50+ },51+});52+53+/** Toggling completion is a plain verb: no consequences, same lifecycle. */54+export const setTaskCompleted = s.mutation("setTaskCompleted", s.update(tasksTable));55+56+/** Subscribes to the latest effect notice; `null` until one is published. */57+export function useTaskNotice(): TaskNotice | null {58+ const [current, setCurrent] = useState<TaskNotice | null>(notice);59+ useEffect(() => {60+ const listener = () => setCurrent(notice);61+ noticeListeners.add(listener);62+ listener();63+ return () => void noticeListeners.delete(listener);64+ }, []);65+ return current;66+}67+1768 export function useTasks() {1869 const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);19- const mutations = useTableMutations(tasksTable);70+ const [lastWrite, setLastWrite] = useState<WriteHandle<Task> | null>(null);71+ const [writeError, setWriteError] = useState<string | null>(null);72+ const write = useWrite(lastWrite);2073 21- // The generic mutation surface also exposes `remove`; this starter only needs22- // insert and a boolean toggle. Add more domain verbs as you grow.23- const create = useCallback(async (text: string) => {24- await mutations.insert({ text, completed: false, createdAt: new Date() });25- }, [mutations.insert]);26- const setCompleted = useCallback(async (id: string, completed: boolean) => {27- await mutations.update(id, { completed });28- }, [mutations.update]);74+ const create = useCallback((text: string) => {75+ // The verb returns a WriteHandle: awaiting it means saved on this device;76+ // `.synced` and the declared effects follow the store's confirmation.77+ const handle = addTask({ text, completed: false, createdAt: new Date() });78+ setLastWrite(handle);79+ setWriteError(null);80+ handle.saved.then(undefined, (error) => {81+ setWriteError(error instanceof Error ? error.message : String(error));82+ });83+ return handle;84+ }, []);85+ const setCompleted = useCallback(86+ (id: string, completed: boolean) => setTaskCompleted(id, { completed }),87+ [],88+ );2989 30- const mutationFailed = mutations.error !== null;90+ const durability = write.stage === "synced"91+ ? "global" as const92+ : write.stage === "saved" || write.stage === "syncing"93+ ? "local" as const94+ : write.stage === "rejected"95+ ? "failed" as const96+ : "none" as const;3197 3298 return {33- status: query.status === "error" || mutationFailed ? "error" as const : query.status,34- error: query.error ?? mutations.error,35- durability: mutations.durability,99+ status: query.status === "error" || writeError !== null ? "error" as const : query.status,100+ error: query.error ?? writeError,101+ durability,102+ lastWriteStage: write.stage,103+ lastWriteReason: write.reason,36104 tasks: query.rows,37105 failureKind: query.status === "error" ? "read" as const : "write" as const,38106 create,
M deno.json +4 -2
@@ -60,11 +60,12 @@
6060 },6161 "tasks": {6262 "build": "deno task --cwd apps/reference build",63- "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:conformance",63+ "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:effects && deno task test:conformance",6464 "doc": "deno run --allow-run tools/doc_lint.ts",6565 "docs:links": "deno run --allow-read tools/docs_links.ts",66- "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/testing package/tooling apps/reference/tests",66+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/effects_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/testing package/tooling apps/reference/tests",6767 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",68+ "test:effects": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/effects_sync_test.ts",6869 "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/store_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",6970 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",7071 "test": "deno task test:unit",
@@ -115,6 +116,7 @@
115116 "docs/data-and-ui.md",116117 "docs/deployment.md",117118 "docs/getting-started.md",119+ "docs/nouns-and-verbs.md",118120 "docs/examples",119121 "docs/permissions.md",120122 "docs/reference",
M docs/data-and-ui.md +6 -3
A docs/nouns-and-verbs.md +189 -0
@@ -0,0 +1,189 @@
1+# Nouns and verbs2+3+A lofi application has two kinds of declarations. Tables are the nouns: they say what exists.4+Policies attach to tables because a policy must be safe to evaluate on every replica that holds the5+data. Mutations are the verbs: they say what happens. Consequences attach to verbs because a6+consequence needs an owner — the one device that performed the intent. The API keeps the name7+`s.table`; this grammar is how to think about where things belong, not a set of identifiers.8+9+The one-sentence contract: **an effect runs after the write syncs, even if the app restarts in10+between; if the write is rejected, `onRejected` runs instead.**11+12+```mermaid13+flowchart LR14+ Table["Noun: s.table"] --> Policy["Policies (every replica)"]15+ Verb["Verb: s.mutation"] --> Effects["Effect units (originating device)"]16+ Verb --> Handle["WriteHandle"]17+ Handle --> UI["useWrite / usePendingWrites / useSyncStatus"]18+```19+20+## The write lifecycle21+22+Every write moves through a closed, framework-owned lifecycle. Stages are monotonic — a write never23+moves backwards:24+25+| stage | meaning |26+| ---------- | --------------------------------------------------------------------------------- |27+| `saving` | accepted by the runtime, not yet durable |28+| `saved` | durable on this device; `await write` resolves here |29+| `syncing` | in transit to the store (appears only when the runtime can observe the transport) |30+| `synced` | the store confirmed the write |31+| `rejected` | the store adjudicated the write and denied it |32+33+`await write` resolves at `saved` — the same local-durability contract every mutation in lofi keeps.34+The UI clears the form and moves on. Sync is a property you reach for, not what `await` gives you:35+36+```ts37+const write = placeOrder({ item, qty }); // WriteHandle<Order>38+const order = await write; // saved: durable on this device39+await write.synced; // confirmed by the store40+write.stage; // current stage, always readable41+write.reason; // { code, reason } when rejected42+```43+44+The current runtime moves writes from `saved` directly to `synced`; the `syncing` stage and an45+offline reason appear once the runtime can observe its connection. On a device with no sync location46+at all there is no store to adjudicate, so local durability is settlement: the write reads `synced`47+and effects run once it is durable on the device.48+49+## Declaring verbs50+51+A verb is a named mutation over one table operation, declared once with `s.mutation`. Call sites52+invoke it like a function and carry nothing:53+54+```ts55+import { s } from "@nzip/lofi/schema";56+import { app } from "../app.ts";57+58+const chargeCard = s.effect("chargeCard", app.schema.orders, {59+ onSynced: async (order, { journalId }) => {60+ // The world accepted the order. Pass journalId to external APIs61+ // as the idempotency key.62+ },63+ onRejected: async (order) => {64+ // The order never happened: compensate what the user saw.65+ },66+});67+68+export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {69+ effects: [chargeCard, s.log("order-placed")],70+});71+```72+73+`s.insert(table)`, `s.update(table)`, and `s.remove(table)` declare which operation the verb74+performs; the verb's call signature follows from it. Inline `onSynced`/`onRejected` options are75+sugar for an implicit single unit named after the verb.76+77+Naming: tables are plural nouns (`orders`, `tasks`), verbs are imperative verb phrases78+(`placeOrder`, not `orderInsert`). Verb and effect names are durable identities — the journal uses79+them to re-arm handlers after a reload — and must be unique per app; duplicate declarations fail80+fast.81+82+## Effect units83+84+An effect unit is a named, reusable pairing of action and compensation. Effects live on verbs: never85+per call (forgettable, inconsistent across call sites) and never per table (tables carry no intent,86+and data-attached effects would have no owning replica).87+88+- Handlers run on the originating device only. The journal is local to the device that performed the89+ intent; other replicas sync the data and run nothing.90+- Units are independent. Each is journaled and retried on its own; one unit's failure never blocks91+ another, and there are no ordering guarantees between units of one write. Anything order-dependent92+ composes by a unit issuing a further write.93+- There is no `onSaved`. Ordinary code after `await write` covers that moment.94+- Delivery is at-least-once. A crash between handler start and journal completion re-runs the95+ handler at the next boot. Handlers that call external services must be idempotent: pass the96+ `journalId` the handler receives — the write's `(write id, effect name)` key — as the idempotency97+ key to payment-style APIs.98+- A failed handler is surfaced in runtime diagnostics and re-armed at the next boot, never silently99+ swallowed.100+101+`s.log(label)` is a built-in unit that records a structured entry in runtime diagnostics on either102+fate. Repeated calls with one label share one unit.103+104+## Retention: durable things know how to die105+106+Obligations, intents, and residue each have a lifespan and a defined afterlife.107+108+- **Obligations** — `s.effect(name, table, handlers, { expiresAfter })` declares a delivery window109+ in milliseconds, measured from the write. When the write synced but the obligation could not be110+ delivered inside the window (device offline, handler failing), the obligation retires as expired:111+ no handler fires — the write happened, so compensation would be wrong — diagnostics count it, and112+ the entry becomes prunable. External-tier handlers whose receivers keep finite idempotency windows113+ should always set this.114+- **Intents** — `s.mutation(name, op, { expires })` declares the intent's lifespan. A write still115+ pending past it is surfaced: the pending-writes set marks it `expired` and diagnostics count it.116+ The runtime cannot withdraw a locally accepted write — the engine re-proposes pending batches117+ until the store answers, and a rolled-back batch primitive exists only for uncommitted batches —118+ so an overdue intent is reported, never retired: retiring it as rejected could fire compensation119+ for a write that later syncs anyway. When store-side expiry enforcement lands, an overdue intent120+ will settle as `rejected` with cause `expired`; the cause type already carries that value so the121+ surface is forward-compatible.122+- **Residue** — a handler that keeps failing is quarantined after a bounded number of attempts123+ (`maxAttempts`, default 5): the obligation retires as failed-permanent, diagnostics count it, and124+ the entry becomes prunable instead of re-arming forever.125+126+The two expirations are distinct on purpose: an expired _intent_ is a write that never happened127+(compensation would apply, once it can be sound); an expired _obligation_ is a write that happened128+whose side effect missed its window (compensation would be wrong).129+130+### What the journal stores131+132+The journal persists write identity, fate, and per-column keyed hashes (HMAC-SHA-256 under a random133+per-journal key) — enough for the boot equality probe, never the column values themselves. This134+prevents a second greppable plaintext copy of row data beside the store. It does not protect135+low-entropy values against brute force by an attacker holding the whole storage; that attacker's136+access already equals the local store's own at-rest posture.137+138+## Rejection is a verdict, not an error139+140+Two very different things can say no:141+142+- **Local refusal.** The runtime refuses a write that violates the policy this device currently143+ holds. The refusal throws synchronously from the verb call — ordinary error handling — and never144+ creates a stage, a journal entry, or an effect. Nothing was shown to the user, so there is nothing145+ to compensate.146+- **Adjudicated rejection.** A write accepted locally under a policy that had already tightened at147+ the store is denied when it arrives. The write settles as `rejected`, `write.synced` rejects, and148+ `onRejected` runs — once — on the originating device. Compensation gates on the store's permanent149+ verdicts only; an unknown or transient code keeps the write pending.150+151+The engine rolls a rejected write back out of local query results itself: the optimistic row152+disappears, a rejected patch reverts. `onRejected` therefore compensates what the user was told —153+notices, external calls, follow-up writes — not the row data.154+155+## Pending state in the UI156+157+The journal persists every unsettled write beside the runtime's storage, readable at boot before158+sync connects, so pending state survives a reload:159+160+```tsx161+import { usePendingWrites, useSyncStatus, useWrite } from "@nzip/lofi/preact";162+163+const pending = usePendingWrites(); // { count, writes } — "N changes waiting to sync"164+const status = useSyncStatus(task); // "synced" | "waiting" | "rejected" per row165+const { stage, reason } = useWrite(write); // one write's live stage166+```167+168+All three surfaces are level-triggered: they report current truth rather than events, so a component169+that mounts late sees the same state as one that watched every transition.170+171+## API reference172+173+- `WriteHandle<T>` — thenable per-write handle: `saved`, `synced`, `stage`, `reason`, `subscribe`.174+ Returned by verbs and by `useTableMutations` methods.175+- `s.mutation(name, op, options?)` — declares a typed callable verb; `options.effects` attaches176+ units, inline `onSynced`/`onRejected` declare an implicit unit.177+- `s.effect(name, table, handlers)` — declares a named, reusable, typed effect unit.178+- `s.log(label)` — built-in unit recording a diagnostics entry.179+- `useWrite(write)` — `{ stage, reason }` with re-render on change.180+- `usePendingWrites()` — the reload-safe pending set.181+- `useSyncStatus(row)` — per-row badge state.182+183+Generated API pages for these exports live in the [API reference](/api).184+185+## See also186+187+- [Data and UI](data-and-ui.md) — tables, queries, and the domain-hook pattern.188+- [Permissions](permissions.md) — the policies that attach to nouns.189+- [Sync and recovery](sync-and-recovery.md) — how a device elects sync and recovers an account.
M package/astro/mod.ts +6 -0
A package/effects_sync_test.ts +277 -0
@@ -0,0 +1,277 @@
1+// Effect-system integration against a real local Jazz server: two-client2+// stage progression with single-device effect delivery, boot reconciliation3+// over a copied journal, and the adjudicated stale-policy rejection path.4+// Runs in its own task (test:effects) because the server needs FFI.5+import { createDb, type Db, schema as s } from "jazz-tools";6+import { deploy, startLocalJazzServer } from "jazz-tools/testing";7+import type { EffectContext, EffectUnit } from "./schema/effects.ts";8+import { createDiagnostics } from "./runtime/diagnostics.ts";9+import { assert, assertCount } from "./runtime/test-assert.ts";10+import { createMemoryJournalStorage } from "./runtime/write-journal.ts";11+import { WriteLedger, type WriteLedgerEnvironment } from "./runtime/write-ledger.ts";12+13+const app = s.defineApp({14+ notes: s.table({ text: s.string() }),15+});16+const openPermissions = s.definePermissions(app, ({ policy }) => {17+ policy.notes.allowInsert.always();18+ policy.notes.allowRead.always();19+ policy.notes.allowUpdate.always();20+ policy.notes.allowDelete.always();21+});22+// No write rule: a store deployed with this denies every insert.23+const closedPermissions = s.definePermissions(app, ({ policy }) => {24+ policy.notes.allowRead.always();25+});26+27+function secret(fill: number): string {28+ let binary = "";29+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);30+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");31+}32+33+async function within<T>(operation: Promise<T>, label: string, milliseconds = 20_000): Promise<T> {34+ let timer: ReturnType<typeof setTimeout> | undefined;35+ try {36+ return await Promise.race([37+ operation,38+ new Promise<never>((_resolve, reject) => {39+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);40+ }),41+ ]);42+ } finally {43+ if (timer !== undefined) clearTimeout(timer);44+ }45+}46+47+type EffectCall = { handler: "onSynced" | "onRejected"; context: EffectContext };48+49+function recordingUnit(name: string, calls: EffectCall[]): EffectUnit<{ id: string }> {50+ return {51+ effectName: name,52+ handlers: {53+ onSynced: (_row, context) => void calls.push({ handler: "onSynced", context }),54+ onRejected: (_row, context) => void calls.push({ handler: "onRejected", context }),55+ },56+ };57+}58+59+function ledgerOver(db: Db, options: {60+ storage?: ReturnType<typeof createMemoryJournalStorage>;61+ units?: Map<string, EffectUnit<{ id: string }>>;62+} = {}) {63+ const diagnostics = createDiagnostics();64+ const storage = options.storage ?? createMemoryJournalStorage();65+ const units = options.units ?? new Map<string, EffectUnit<{ id: string }>>();66+ const environment: WriteLedgerEnvironment = {67+ getDb: () => Promise.resolve(db),68+ syncConfigured: () => true,69+ storage,70+ resolveTable: (name) =>71+ name === "notes"72+ ? app.notes as unknown as { where(input: Record<string, unknown>): unknown }73+ : null,74+ resolveEffectUnit: (name) => units.get(name) ?? null,75+ subscribeRuntimeRecreation: () => () => undefined,76+ updateDiagnostics: (update) => update(diagnostics),77+ now: Date.now,78+ retryDelayMs: () => 250,79+ probeTimeoutMs: 8000,80+ };81+ return { ledger: new WriteLedger(environment), storage, diagnostics };82+}83+84+type Harness = {85+ server: Awaited<ReturnType<typeof startLocalJazzServer>>;86+ client(fill: number): Promise<Db>;87+ stop(): Promise<void>;88+};89+90+async function bootHarness(): Promise<Harness> {91+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });92+ await deploy({93+ appId: server.appId,94+ serverUrl: server.url,95+ adminSecret: server.adminSecret,96+ schema: app,97+ permissions: openPermissions,98+ });99+ const clients: Db[] = [];100+ return {101+ server,102+ async client(fill: number) {103+ const db = await createDb({104+ appId: server.appId,105+ serverUrl: server.url,106+ secret: secret(fill),107+ userBranch: "main",108+ driver: { type: "memory" },109+ });110+ clients.push(db);111+ return db;112+ },113+ async stop() {114+ await Promise.allSettled(115+ clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),116+ );117+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);118+ },119+ };120+}121+122+function waitFor(predicate: () => boolean, label: string, milliseconds = 20_000): Promise<void> {123+ return within(124+ (async () => {125+ while (!predicate()) await new Promise((resolve) => setTimeout(resolve, 100));126+ })(),127+ label,128+ milliseconds,129+ );130+}131+132+Deno.test("two clients: stages progress and onSynced fires only on the originating device", async () => {133+ const harness = await bootHarness();134+ try {135+ const aliceDb = await harness.client(1);136+ const bobDb = await harness.client(2);137+ const aliceCalls: EffectCall[] = [];138+ const bobCalls: EffectCall[] = [];139+ const aliceUnit = recordingUnit("notifyTeam", aliceCalls);140+ const alice = ledgerOver(aliceDb, { units: new Map([["notifyTeam", aliceUnit]]) });141+ const bob = ledgerOver(bobDb, {142+ units: new Map([["notifyTeam", recordingUnit("notifyTeam", bobCalls)]]),143+ });144+ await alice.ledger.arm();145+ await bob.ledger.arm();146+147+ const handle = alice.ledger.perform<{ id: string; text: string }>(148+ { kind: "insert", table: app.notes as never, values: { text: "from alice" } },149+ { verb: "addNote", units: [aliceUnit] },150+ );151+ const stages: string[] = [handle.stage];152+ handle.subscribe(() => {153+ if (stages.at(-1) !== handle.stage) stages.push(handle.stage);154+ });155+ const row = await within(Promise.resolve(handle), "saved settlement");156+ assert(row.text === "from alice", "await must resolve the created row at saved");157+ await within(handle.synced, "synced settlement");158+ assert(159+ stages.join(",").endsWith("saved,synced") || stages.join(",") === "saved,synced" ||160+ stages.join(",") === "saving,saved,synced",161+ `stages must progress monotonically (saw ${stages.join(",")})`,162+ );163+164+ const bobView = await within(165+ bobDb.all(app.notes.where({ id: row.id }), { tier: "global" }),166+ "bob read",167+ );168+ assertCount(bobView.length, 1, "the second client must sync the row");169+ await waitFor(() => aliceCalls.length > 0, "originating-device effect delivery");170+ assertCount(aliceCalls.length, 1, "onSynced must fire exactly once on the writer");171+ assert(aliceCalls[0].handler === "onSynced", "confirmation runs the action handler");172+ // Give any wrongly-armed effect a moment to surface before asserting.173+ await new Promise((resolve) => setTimeout(resolve, 300));174+ assertCount(bobCalls.length, 0, "effects run on the originating device only");175+ assertCount(alice.ledger.getPendingSnapshot().count, 0, "nothing may stay pending");176+ alice.ledger.dispose();177+ bob.ledger.dispose();178+ } finally {179+ await harness.stop();180+ }181+});182+183+Deno.test("boot reconciliation: a journaled write settles and its effect fires after sync resolves", async () => {184+ const harness = await bootHarness();185+ try {186+ const writerDb = await harness.client(1);187+ const writer = ledgerOver(writerDb);188+ await writer.ledger.arm();189+ const handle = writer.ledger.perform<{ id: string; text: string }>(190+ { kind: "insert", table: app.notes as never, values: { text: "journaled" } },191+ { verb: "addNote", units: [recordingUnit("celebrate", [])] },192+ );193+ await within(Promise.resolve(handle), "saved settlement");194+ await writer.ledger.flush();195+ // Freeze the journal at the saved stage: this is the on-disk state a196+ // crashed device would boot from.197+ const frozen = writer.storage.text();198+ assert(frozen !== null && frozen.includes(handle.writeId), "the write must be journaled");199+ await within(handle.synced, "writer synced settlement");200+ writer.ledger.dispose();201+202+ // A fresh runtime for the same account boots from the frozen journal.203+ const rebootDb = await harness.client(1);204+ const calls: EffectCall[] = [];205+ const reboot = ledgerOver(rebootDb, {206+ storage: createMemoryJournalStorage(frozen),207+ units: new Map([["celebrate", recordingUnit("celebrate", calls)]]),208+ });209+ await reboot.ledger.arm();210+ await waitFor(() => calls.length > 0, "re-armed effect delivery");211+ assertCount(calls.length, 1, "the re-armed effect must fire exactly once");212+ assert(calls[0].handler === "onSynced", "the confirmed write runs the action handler");213+ assert(214+ calls[0].context.journalId === `${handle.writeId}:celebrate`,215+ "the idempotency key must survive the reboot",216+ );217+ assertCount(reboot.ledger.getPendingSnapshot().count, 0, "the entry must settle");218+ reboot.ledger.dispose();219+ } finally {220+ await harness.stop();221+ }222+});223+224+Deno.test("stale-policy rejection: rejected stage, one compensation, engine rolls the row back", async () => {225+ const harness = await bootHarness();226+ try {227+ const aliceDb = await harness.client(1);228+ // Seed while the open policy is in force so the client holds it locally.229+ const seeded = await within(230+ aliceDb.insert(app.notes, { text: "seed" }).wait({ tier: "global" }),231+ "seed insert",232+ );233+ const calls: EffectCall[] = [];234+ const chargeCard = recordingUnit("chargeCard", calls);235+ const alice = ledgerOver(aliceDb, { units: new Map([["chargeCard", chargeCard]]) });236+ await alice.ledger.arm();237+238+ await within(aliceDb.disconnect(), "disconnect", 5_000);239+ // The store's permissions tighten while the device is offline.240+ await deploy({241+ appId: harness.server.appId,242+ serverUrl: harness.server.url,243+ adminSecret: harness.server.adminSecret,244+ schema: app,245+ permissions: closedPermissions,246+ });247+248+ // Accepted locally under the stale policy: no throw, a live handle.249+ const handle = alice.ledger.perform<{ id: string; text: string }>(250+ { kind: "insert", table: app.notes as never, values: { text: "stale write" } },251+ { verb: "addNote", units: [chargeCard] },252+ );253+ await within(aliceDb.reconnect(), "reconnect", 10_000);254+ await waitFor(() => handle.stage === "rejected", "adjudicated verdict");255+ assert(handle.reason?.code === "permission_denied", "the verdict code must surface");256+257+ await waitFor(() => calls.length > 0, "compensation delivery");258+ assertCount(calls.length, 1, "onRejected must fire exactly once");259+ assert(calls[0].handler === "onRejected", "the denied write runs compensation");260+261+ // The engine itself rolls the rejected write back out of local reads: the262+ // optimistic row must not linger as apparent success.263+ const rows = await within(264+ aliceDb.all(app.notes.where({}), { tier: "local" }),265+ "post-rejection read",266+ );267+ assert(268+ rows.every((row) => row.text !== "stale write"),269+ "the rejected optimistic row must leave local query results",270+ );271+ assert(rows.some((row) => row.id === seeded.id), "unrelated rows must survive");272+ assertCount(alice.ledger.getPendingSnapshot().count, 0, "the rejected write is not pending");273+ alice.ledger.dispose();274+ } finally {275+ await harness.stop();276+ }277+});
M package/preact/live-data.ts +9 -6
@@ -11,6 +11,7 @@ import {
1111 type TableMutationStore,1212 } from "../runtime/table-mutations.ts";1313 import type { TableRow } from "../runtime/table-store.ts";14+import type { WriteHandle } from "../runtime/write-handle.ts";1415 1516 const loading = <T extends TableRow>(): LiveQuerySnapshot<T> => ({1617 status: "loading",
@@ -71,17 +72,19 @@ export function useLiveQuery<T extends TableRow>(
7172 7273 /** Typed mutation methods plus their shared table-scoped observable state. */7374 export type TableMutations<T extends TableRow, Init> = TableMutationSnapshot & {74- insert(values: Init): Promise<T>;75- update(id: string, patch: Partial<Init>): Promise<void>;76- remove(id: string): Promise<void>;75+ insert(values: Init): WriteHandle<T>;76+ update(id: string, patch: Partial<Init>): WriteHandle<void>;77+ remove(id: string): WriteHandle<void>;7778 };7879 7980 /**8081 * Binds one typed Jazz table to stable insert, update, and remove methods.8182 *82- * Mutation promises resolve after local durability; when managed sync is active,83- * global confirmation continues in the background and updates the shared84- * table-scoped `pending`, `durability`, and `error` state.83+ * Each method returns a thenable `WriteHandle`: awaiting it resolves after84+ * local durability, and the handle's `saved` and `synced` promises track the85+ * write's sync fate. When managed sync is active, global confirmation86+ * continues in the background and updates the shared table-scoped `pending`,87+ * `durability`, and `error` state.8588 *8689 * @example8790 * ```ts
M package/preact/live-data_test.tsx +2 -1
M package/preact/mod.ts +7 -0
A package/preact/write-hooks.ts +98 -0
@@ -0,0 +1,98 @@
1+/**2+ * Preact bindings for the per-write sync lifecycle: one write's stage, the3+ * reload-safe pending set, and per-row sync badges. All three are4+ * level-triggered — they render current truth on mount, so a component that5+ * mounts after a transition sees the same state as one that watched it happen.6+ *7+ * @module8+ */9+10+import { useEffect, useState } from "preact/hooks";11+import type { WriteHandle, WriteRejection, WriteStage } from "../runtime/write-handle.ts";12+import {13+ getWriteLedger,14+ type PendingWritesSnapshot,15+ type RowSyncStatus,16+} from "../runtime/write-ledger.ts";17+18+/** The observable state of one write, re-rendered on every stage change. */19+export type WriteState = {20+ /** The write's current stage, or `null` while no write is being observed. */21+ stage: WriteStage | null;22+ /** The rejection carried by a `rejected` write. */23+ reason: WriteRejection | null;24+};25+26+/**27+ * Observes one write's lifecycle. Pass the handle returned by a verb or a28+ * table mutation; the component re-renders on each stage change and a29+ * late-mounted component sees the current stage immediately.30+ *31+ * @example32+ * ```tsx33+ * const [write, setWrite] = useState<WriteHandle<Order> | null>(null);34+ * const { stage, reason } = useWrite(write);35+ * // stage: "saving" | "saved" | "syncing" | "synced" | "rejected" | null36+ * ```37+ */38+export function useWrite<T>(write: WriteHandle<T> | null | undefined): WriteState {39+ const [state, setState] = useState<WriteState>(() => ({40+ stage: write?.stage ?? null,41+ reason: write?.reason ?? null,42+ }));43+ useEffect(() => {44+ if (!write) {45+ setState({ stage: null, reason: null });46+ return;47+ }48+ return write.subscribe(() => setState({ stage: write.stage, reason: write.reason }));49+ }, [write]);50+ return state;51+}52+53+/**54+ * The reload-safe set of writes still waiting to sync, for "N changes waiting55+ * to sync" indicators. The set is rebuilt from the durable journal at boot,56+ * so it survives a reload with writes still pending.57+ *58+ * @example59+ * ```tsx60+ * const pending = usePendingWrites();61+ * return pending.count > 0 ? <p>{pending.count} change(s) waiting to sync</p> : null;62+ * ```63+ */64+export function usePendingWrites(): PendingWritesSnapshot {65+ const [snapshot, setSnapshot] = useState<PendingWritesSnapshot>({ count: 0, writes: [] });66+ // The ledger is resolved inside the effect so server-side prerendering of an67+ // island never opens browser storage.68+ useEffect(() => {69+ const ledger = getWriteLedger();70+ return ledger.subscribe(() => setSnapshot(ledger.getPendingSnapshot()));71+ }, []);72+ return snapshot;73+}74+75+/**76+ * The sync status of one row for per-row badges: `waiting` while a journaled77+ * write touching the row has not settled, `rejected` when the row's latest78+ * settled write was denied, and `synced` otherwise.79+ *80+ * @example81+ * ```tsx82+ * const status = useSyncStatus(task);83+ * {status === "waiting" && <span class="badge">waiting to sync</span>}84+ * ```85+ */86+export function useSyncStatus(row: { id: string } | null | undefined): RowSyncStatus {87+ const rowId = row?.id ?? null;88+ const [status, setStatus] = useState<RowSyncStatus>("synced");89+ useEffect(() => {90+ if (rowId === null) {91+ setStatus("synced");92+ return;93+ }94+ const ledger = getWriteLedger();95+ return ledger.subscribe(() => setStatus(ledger.rowStatus(rowId)));96+ }, [rowId]);97+ return status;98+}
M package/runtime/boot.ts +6 -0
M package/runtime/diagnostics.ts +45 -0
@@ -2,6 +2,33 @@ import type { SchemaCompatState } from "./schema-compat.ts";
22 import type { RuntimeStartupFailure } from "./startup-recovery.ts";33 import type { RuntimeStoreStatus } from "./store-status.ts";44 5+/** One structured entry recorded by the built-in `s.log` effect unit. */6+export type EffectLogEntry = {7+ /** The author-chosen log label. */8+ label: string;9+ /** The declaring verb's name, or `null` for writes without a verb. */10+ verb: string | null;11+ /** The written table's name. */12+ table: string;13+ /** The written row's id. */14+ rowId: string;15+ /** Which fate settled the write. */16+ fate: "synced" | "rejected";17+ /** Epoch milliseconds when the entry was recorded. */18+ at: number;19+};20+21+// Recent-entry window for the s.log diagnostic feed.22+const maxEffectLogEntries = 20;23+24+/** Appends one `s.log` entry, keeping the bounded recent window. */25+export function recordEffectLogEntry(26+ diagnostics: RuntimeDiagnostics,27+ entry: EffectLogEntry,28+): void {29+ diagnostics.effectLog = [...diagnostics.effectLog.slice(-(maxEffectLogEntries - 1)), entry];30+}31+532 /**633 // Package-owned runtime diagnostics.734 * Runtime-owned observability counters. These describe the framework's storage,
@@ -25,6 +52,18 @@ export type RuntimeDiagnostics = {
2552 pendingGlobalWrites: number;2653 lastWriteDurability: "none" | "local" | "global" | "failed";2754 mutationErrors: number;55+ /** Journaled writes that have not yet settled as synced or rejected. */56+ journaledPendingWrites: number;57+ /** Pending writes whose declared intent lifespan has passed (surfacing only). */58+ expiredPendingWrites: number;59+ /** Effect handler invocations that threw; failed obligations re-arm at boot. */60+ effectHandlerFailures: number;61+ /** Obligations retired because their delivery window closed undelivered. */62+ expiredObligations: number;63+ /** Obligations quarantined after repeated handler failures. */64+ quarantinedObligations: number;65+ /** Recent structured entries recorded by the built-in `s.log` effect unit. */66+ effectLog: readonly EffectLogEntry[];2867 };2968 3069 export function createDiagnostics(): RuntimeDiagnostics {
@@ -46,5 +85,11 @@ export function createDiagnostics(): RuntimeDiagnostics {
4685 pendingGlobalWrites: 0,4786 lastWriteDurability: "none",4887 mutationErrors: 0,88+ journaledPendingWrites: 0,89+ expiredPendingWrites: 0,90+ effectHandlerFailures: 0,91+ expiredObligations: 0,92+ quarantinedObligations: 0,93+ effectLog: [],4994 };5095 }
M package/runtime/mod.ts +29 -0
A package/runtime/mutation-taxonomy.ts +39 -0
@@ -0,0 +1,39 @@
1+/**2+ * Package-owned mutation-error taxonomy.3+ *4+ * The sync node adjudicates a write that was accepted locally and emits a5+ * rejection code. This module maps those codes onto the one distinction the6+ * effect system acts on: permanent verdicts compensate, everything else stays7+ * pending. The classification mirrors the taxonomy the lofi sync node8+ * publishes; codes this package does not recognize are `unknown` and are9+ * never treated as permanent, so an uninterpretable code can never trigger10+ * compensation.11+ *12+ * @module13+ */14+15+/**16+ * How a rejection code is acted on: `permanent` verdicts settle the write as17+ * `rejected` and run compensation, `transient` and `unknown` codes leave the18+ * write pending until a later settlement attempt resolves it.19+ */20+export type MutationErrorClass = "permanent" | "transient" | "unknown";21+22+// The one permanent code the node emits today. Growing this set is a23+// package-version decision made together with the node's taxonomy.24+const permanentCodes = new Set(["permission_denied"]);25+26+// Codes describing delivery conditions rather than verdicts.27+const transientCodes = new Set(["network", "timeout", "disconnected"]);28+29+/**30+ * Classifies a sync-node rejection code. `permission_denied` is `permanent`;31+ * known delivery-condition codes are `transient`; anything else — including a32+ * missing code — is `unknown`. Compensation gates on `permanent` only.33+ */34+export function classifyMutationError(code: string | null | undefined): MutationErrorClass {35+ if (!code) return "unknown";36+ if (permanentCodes.has(code)) return "permanent";37+ if (transientCodes.has(code)) return "transient";38+ return "unknown";39+}
M package/runtime/runtime.ts +10 -0
M package/runtime/schema-compat_test.ts +18 -1
@@ -9,6 +9,8 @@ import {
99 } from "./schema-compat.ts";1010 import { type TableMutationEnvironment, TableMutationRegistry } from "./table-mutations.ts";1111 import { assert } from "./test-assert.ts";12+import { createMemoryJournalStorage } from "./write-journal.ts";13+import { WriteLedger } from "./write-ledger.ts";1214 1315 class FakeStorage {1416 readonly values = new Map<string, string>();
@@ -291,14 +293,28 @@ Deno.test("an old shell over newer data refuses table writes with the gate diagn
291293 292294 const db = new RefusingDb();293295 const diagnostics = createDiagnostics();294- const environment: TableMutationEnvironment = {296+ // Table mutations route through the write ledger, so the production guard297+ // seam — the gate check plus the upgrade write lock — lives on the ledger298+ // environment; this wires the same shape minus the lock.299+ const ledger = new WriteLedger({295300 getDb: () => Promise.resolve(db.value()),296301 syncConfigured: () => false,302+ storage: createMemoryJournalStorage(),303+ resolveTable: () => null,304+ resolveEffectUnit: () => null,297305 subscribeRuntimeRecreation: () => () => undefined,298306 updateDiagnostics: (update) => update(diagnostics),307+ now: () => 0,299308 async guardWrite() {300309 await gate.assertWritable();301310 },311+ });312+ const environment: TableMutationEnvironment = {313+ getDb: () => Promise.resolve(db.value()),314+ syncConfigured: () => false,315+ getLedger: () => ledger,316+ subscribeRuntimeRecreation: () => () => undefined,317+ updateDiagnostics: (update) => update(diagnostics),302318 };303319 const store = new TableMutationRegistry(environment).acquire(table).store;304320 let refused: unknown;
@@ -314,4 +330,5 @@ Deno.test("an old shell over newer data refuses table writes with the gate diagn
314330 store.getSnapshot().error?.includes("newer version") === true,315331 "the store snapshot does not carry the user-facing diagnostic",316332 );333+ ledger.dispose();317334 });
M package/runtime/table-mutations.ts +79 -91
@@ -1,11 +1,10 @@
11 import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";22 import { syncing } from "./config.ts";33 import type { RuntimeDiagnostics } from "./diagnostics.ts";4-import { settleDurableWrite } from "./durability.ts";54 import { getRuntime, runtimeRecreatedEvent, updateRuntimeDiagnostics } from "./runtime.ts";6-import { assertSchemaWritable } from "./schema-compat.ts";75 import type { TableRow } from "./table-store.ts";8-import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";6+import type { WriteHandle } from "./write-handle.ts";7+import { getWriteLedger, type WriteLedger } from "./write-ledger.ts";98 109 /** Observable state shared by every mutation consumer for one table. */1110 export type TableMutationSnapshot = {
@@ -15,11 +14,6 @@ export type TableMutationSnapshot = {
1514 };1615 1716 type Listener = () => void;18-type MutationHandle<T> = {19- /** The vendor batch id, used to attribute asynchronous rejections. */20- batchId?: unknown;21- wait(options: { tier: "local" | "global" }): Promise<T>;22-};2317 2418 // Own-batch attribution keeps a bounded window of recent writes; late2519 // rejections older than this are diagnostics-only.
@@ -29,15 +23,10 @@ const MAX_TRACKED_BATCHES = 128;
2923 export type TableMutationEnvironment = {3024 getDb(): Promise<Db>;3125 syncConfigured(): boolean;26+ /** The per-write ledger table mutations route through. */27+ getLedger(): WriteLedger;3228 subscribeRuntimeRecreation(listener: () => void): () => void;3329 updateDiagnostics(update: (diagnostics: RuntimeDiagnostics) => void): void;34- /**35- * Runs before each mutation reaches the database: rejects to refuse the36- * write (the schema-compatibility gate), and may resolve with a release37- * function that is invoked once the write settles locally (the cross-tab38- * write lock). Absent means writes are never guarded.39- */40- guardWrite?(): Promise<(() => void) | void>;4130 };4231 4332 /** Framework-neutral typed mutations and observable durability for one table. */
@@ -93,19 +82,41 @@ export class TableMutationStore<T extends TableRow, Init> {
9382 return this.#listeners.size;9483 }9584 96- /** Inserts and returns the typed created row after local durability. */97- async insert(values: Init): Promise<T> {98- return await this.#perform((db) => db.insert(this.#table, values));85+ /**86+ * Inserts a row. Awaiting the returned handle resolves with the typed87+ * created row at `saved`; the handle's stage promises track the sync fate.88+ */89+ insert(values: Init): WriteHandle<T> {90+ return this.#track(() =>91+ this.#environment.getLedger().perform<T>({92+ kind: "insert",93+ table: this.#table as TableProxy<unknown, unknown>,94+ values,95+ })96+ );9997 }10098 101- /** Updates a row and resolves after local durability. */102- async update(id: string, patch: Partial<Init>): Promise<void> {103- await this.#perform((db) => db.update(this.#table, id, patch));99+ /** Updates a row. Awaiting the returned handle resolves at `saved`. */100+ update(id: string, patch: Partial<Init>): WriteHandle<void> {101+ return this.#track(() =>102+ this.#environment.getLedger().perform<void>({103+ kind: "update",104+ table: this.#table as TableProxy<unknown, unknown>,105+ id,106+ patch: patch as Record<string, unknown>,107+ })108+ );104109 }105110 106- /** Deletes a row and resolves after local durability. */107- async remove(id: string): Promise<void> {108- await this.#perform((db) => db.delete(this.#table, id));111+ /** Deletes a row. Awaiting the returned handle resolves at `saved`. */112+ remove(id: string): WriteHandle<void> {113+ return this.#track(() =>114+ this.#environment.getLedger().perform<void>({115+ kind: "remove",116+ table: this.#table as TableProxy<unknown, unknown>,117+ id,118+ })119+ );109120 }110121 111122 /** Releases listeners owned by an evicted or hot-reloaded store. */
@@ -116,35 +127,49 @@ export class TableMutationStore<T extends TableRow, Init> {
116127 this.#stop();117128 }118129 119- async #perform<Result>(create: (db: Db) => MutationHandle<Result>): Promise<Result> {130+ #track<Result>(start: () => WriteHandle<Result>): WriteHandle<Result> {120131 if (this.#disposed) throw new Error("table mutation store has been released");121- // The guard runs before the mutation reaches the vendor: a refusal (the122- // schema-compatibility gate) surfaces like any mutation failure, and the123- // returned release ends this write's hold on the cross-tab write lock124- // once the write settles to the local tier — the quiescence bar an125- // upgrade swap waits on.126- let releaseGuard: (() => void) | undefined;132+ const generation = ++this.#writeGeneration;133+ let handle: WriteHandle<Result>;127134 try {128- releaseGuard = (await this.#environment.guardWrite?.()) ?? undefined;135+ // A refusal — the ledger's write guard (schema-compatibility gate) or a136+ // current-policy violation — leaves through this call with no journal137+ // entry; the ledger has already counted it.138+ handle = start();129139 } catch (error) {130- this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);131140 this.#fail(error);132141 throw error;133142 }134- try {135- const db = await this.#environment.getDb();136- let mutation: MutationHandle<Result>;137- try {138- mutation = create(db);139- } catch (error) {140- this.#updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);141- this.#fail(error);142- throw error;143- }144- return await this.#settle(mutation);145- } finally {146- releaseGuard?.();147- }143+ this.#trackBatch(handle.batchId);144+ this.#set({ pending: this.#snapshot.pending + 1, durability: "none", error: null });145+ // Generation guards keep a superseded write's late outcome — success or146+ // rejection — diagnosable without overwriting the newer write's state.147+ handle.saved.then(148+ () => {149+ this.#trackBatch(handle.batchId);150+ this.#set({151+ pending: Math.max(0, this.#snapshot.pending - 1),152+ ...(generation === this.#writeGeneration ? { durability: "local" as const } : {}),153+ });154+ },155+ (error) => {156+ this.#set({ pending: Math.max(0, this.#snapshot.pending - 1) });157+ if (generation === this.#writeGeneration) this.#fail(error);158+ },159+ );160+ handle.synced.then(161+ () => {162+ // Without configured sync the ledger settles at local durability; the163+ // table-scoped durability label stays `local` in that mode.164+ if (generation === this.#writeGeneration && this.#environment.syncConfigured()) {165+ this.#set({ durability: "global", error: null });166+ }167+ },168+ (error) => {169+ if (generation === this.#writeGeneration) this.#fail(error);170+ },171+ );172+ return handle;148173 }149174 150175 #ownsBatch(event: MutationErrorEvent): boolean {
@@ -152,52 +177,18 @@ export class TableMutationStore<T extends TableRow, Init> {
152177 // Attribution is only possible when the vendor exposes batch ids on both153178 // sides; without them, surface every event rather than silence real errors.154179 if (batchId === undefined || !this.#batchTracking) return true;155- return this.#ownBatches.has(batchId);180+ return this.#ownBatches.has(String(batchId));156181 }157182 158- #trackBatch<Result>(mutation: MutationHandle<Result>): void {159- if (mutation.batchId === undefined) return;183+ #trackBatch(batchId: string | null): void {184+ if (batchId === null) return;160185 this.#batchTracking = true;161- this.#ownBatches.add(mutation.batchId);186+ this.#ownBatches.add(batchId);162187 if (this.#ownBatches.size > MAX_TRACKED_BATCHES) {163188 this.#ownBatches.delete(this.#ownBatches.values().next().value);164189 }165190 }166191 167- async #settle<Result>(mutation: MutationHandle<Result>): Promise<Result> {168- const generation = ++this.#writeGeneration;169- this.#trackBatch(mutation);170- this.#set({ pending: this.#snapshot.pending + 1, durability: "none", error: null });171- try {172- // Generation guards keep a superseded write's late outcome — success or173- // rejection — diagnosable without overwriting the newer write's state.174- return await settleDurableWrite(175- mutation,176- (update) => this.#updateDiagnostics(update),177- this.#environment.syncConfigured() ? "background" : "none",178- {179- onLocal: () =>180- this.#set({181- pending: Math.max(0, this.#snapshot.pending - 1),182- ...(generation === this.#writeGeneration ? { durability: "local" as const } : {}),183- }),184- onGlobal: () => {185- if (generation === this.#writeGeneration) {186- this.#set({ durability: "global", error: null });187- }188- },189- onGlobalError: (error) => {190- if (generation === this.#writeGeneration) this.#fail(error);191- },192- },193- );194- } catch (error) {195- this.#set({ pending: Math.max(0, this.#snapshot.pending - 1) });196- this.#fail(error);197- throw error;198- }199- }200-201192 #start(): void {202193 this.#stopRuntimeRecreation = this.#environment.subscribeRuntimeRecreation(() =>203194 this.#attach()
@@ -309,14 +300,14 @@ export class TableMutationRegistry {
309300 this.#environment,310301 () => this.#evictIfUnused(entries!, table._table),311302 );312- entry = { store: store as TableMutationStore<TableRow, unknown>, leases: 0 };303+ entry = { store: store as unknown as TableMutationStore<TableRow, unknown>, leases: 0 };313304 entries.set(table._table, entry);314305 this.#stores.add(entry.store);315306 }316307 entry.leases += 1;317308 let retained = true;318309 return {319- store: entry.store as TableMutationStore<T, Init>,310+ store: entry.store as unknown as TableMutationStore<T, Init>,320311 release: () => {321312 if (!retained) return;322313 retained = false;
@@ -344,15 +335,12 @@ export class TableMutationRegistry {
344335 const defaultRegistry = new TableMutationRegistry({345336 getDb: async () => (await getRuntime()).db,346337 syncConfigured: syncing,338+ getLedger: getWriteLedger,347339 subscribeRuntimeRecreation(listener) {348340 globalThis.addEventListener(runtimeRecreatedEvent, listener);349341 return () => globalThis.removeEventListener(runtimeRecreatedEvent, listener);350342 },351343 updateDiagnostics: updateRuntimeDiagnostics,352- async guardWrite() {353- await assertSchemaWritable();354- return await acquireUpgradeWriteLock();355- },356344 });357345 358346 /** Acquires the package-wide typed mutation surface for one table. */
M package/runtime/table-mutations_test.ts +45 -6
@@ -3,6 +3,9 @@ import { schema as s } from "jazz-tools";
33 import { createDiagnostics } from "./diagnostics.ts";44 import { type TableMutationEnvironment, TableMutationRegistry } from "./table-mutations.ts";55 import { assert, assertCount } from "./test-assert.ts";6+import { WriteHandle } from "./write-handle.ts";7+import { WriteLedger } from "./write-ledger.ts";8+import { createMemoryJournalStorage } from "./write-journal.ts";69 710 type Row = { id: string; title: string; archived: boolean };811 type Init = Omit<Row, "id">;
@@ -29,7 +32,8 @@ class FakeDb {
2932 3033 insert(_table: TableProxy<Row, Init>, values: Init) {3134 this.operations.push("insert");32- return this.handle({ id: `row-${++this.nextId}`, ...values });35+ const row = { id: `row-${++this.nextId}`, ...values };36+ return { value: row, ...this.handle(row) };3337 }3438 3539 update(_table: TableProxy<Row, Init>, _id: string, _patch: Partial<Init>) {
@@ -42,6 +46,10 @@ class FakeDb {
4246 return this.handle(undefined);4347 }4448 49+ all(): Promise<unknown[]> {50+ return Promise.resolve([]);51+ }52+4553 reject(event: MutationErrorEvent): void {4654 for (const listener of this.mutationListeners) listener(event);4755 }
@@ -69,9 +77,25 @@ class FakeDb {
6977 function environment(db: FakeDb, syncConfigured = false) {7078 const diagnostics = createDiagnostics();7179 const recreation = new Set<() => void>();80+ const ledger = new WriteLedger({81+ getDb: () => Promise.resolve(db.value()),82+ syncConfigured: () => syncConfigured,83+ storage: createMemoryJournalStorage(),84+ resolveTable: () => null,85+ resolveEffectUnit: () => null,86+ subscribeRuntimeRecreation(listener) {87+ recreation.add(listener);88+ return () => recreation.delete(listener);89+ },90+ updateDiagnostics(update) {91+ update(diagnostics);92+ },93+ now: () => 0,94+ });7295 const value: TableMutationEnvironment = {7396 getDb: () => Promise.resolve(db.value()),7497 syncConfigured: () => syncConfigured,98+ getLedger: () => ledger,7599 subscribeRuntimeRecreation(listener) {76100 recreation.add(listener);77101 return () => recreation.delete(listener);
@@ -80,12 +104,13 @@ function environment(db: FakeDb, syncConfigured = false) {
80104 update(diagnostics);81105 },82106 };83- return { value, diagnostics, recreation };107+ return { value, diagnostics, recreation, ledger };84108 }85109 86110 async function flush(): Promise<void> {87111 await Promise.resolve();88112 await Promise.resolve();113+ await Promise.resolve();89114 }90115 91116 Deno.test("table mutation consumers share one listener and release it after final teardown", async () => {
@@ -107,6 +132,7 @@ Deno.test("table mutation consumers share one listener and release it after fina
107132 second.release();108133 assertCount(db.mutationListeners.size, 0, "final teardown retained the mutation listener");109134 assertCount(runtime.diagnostics.activeMutationListeners, 0, "listener diagnostics leaked");135+ runtime.ledger.dispose();110136 });111137 112138 Deno.test("typed mutations return inserted rows and wait for local durability", async () => {
@@ -116,11 +142,13 @@ Deno.test("typed mutations return inserted rows and wait for local durability",
116142 const created = await store.insert({ title: "created", archived: false });117143 await store.update(created.id, { archived: true });118144 await store.remove(created.id);145+ await flush();119146 assert(created.id === "row-1" && created.title === "created", "insert omitted the created row");120147 assert(db.operations.join(",") === "insert,update,delete", "typed operations missed the DB");121148 assertCount(db.localWaits, 3, "mutations resolved before local durability");122149 assertCount(runtime.diagnostics.localWaitCalls, 3, "local waits were not diagnosable");123150 assert(store.getSnapshot().durability === "local", "local durability was not observable");151+ runtime.ledger.dispose();124152 });125153 126154 Deno.test("managed mutations continue global durability tracking in the background", async () => {
@@ -132,6 +160,7 @@ Deno.test("managed mutations continue global durability tracking in the backgrou
132160 assertCount(db.globalWaits, 1, "managed mutation omitted its global wait");133161 assert(store.getSnapshot().durability === "global", "global confirmation was not observable");134162 assertCount(runtime.diagnostics.pendingGlobalWrites, 0, "global wait remained pending");163+ runtime.ledger.dispose();135164 });136165 137166 Deno.test("local and asynchronous permission rejections reach public mutation state", async () => {
@@ -141,13 +170,18 @@ Deno.test("local and asynchronous permission rejections reach public mutation st
141170 const stop = lease.store.subscribe(() => undefined);142171 await flush();143172 db.failLocal = new Error("local permission denied");144- await lease.store.insert({ title: "blocked", archived: false }).catch(() => undefined);173+ await lease.store.insert({ title: "blocked", archived: false }).then(174+ () => undefined,175+ () => undefined,176+ );177+ await flush();145178 assert(lease.store.getSnapshot().error === "local permission denied", "local rejection hidden");146179 db.reject({ code: "WriteRejected", reason: "revoked" } as MutationErrorEvent);147180 assert(lease.store.getSnapshot().error?.includes("revoked"), "async rejection hidden");148181 assertCount(runtime.diagnostics.mutationErrors, 2, "rejections were not diagnosed");149182 stop();150183 lease.release();184+ runtime.ledger.dispose();151185 });152186 153187 Deno.test("asynchronous rejections reach only the store owning the batch", async () => {
@@ -158,6 +192,7 @@ Deno.test("asynchronous rejections reach only the store owning the batch", async
158192 const stop = lease.store.subscribe(() => undefined);159193 await flush();160194 await lease.store.insert({ title: "mine", archived: false });195+ await flush();161196 db.reject(162197 {163198 code: "WriteRejected",
@@ -181,16 +216,20 @@ Deno.test("asynchronous rejections reach only the store owning the batch", async
181216 assertCount(runtime.diagnostics.mutationErrors, 1, "the owned rejection must count once");182217 stop();183218 lease.release();219+ runtime.ledger.dispose();184220 });185221 186222 Deno.test("mutation types follow the exact Jazz table insert and row shapes", () => {187223 const app = s.defineApp({ records: s.table({ title: s.string(), archived: s.boolean() }) });188- const registry = new TableMutationRegistry(environment(new FakeDb()).value);224+ const runtime = environment(new FakeDb());225+ const registry = new TableMutationRegistry(runtime.value);189226 const lease = registry.acquire(app.records);190- const result: Promise<s.RowOf<typeof app.records>> = lease.store.insert({227+ const result: WriteHandle<s.RowOf<typeof app.records>> = lease.store.insert({191228 title: "typed",192229 archived: false,193230 });194- assert(result instanceof Promise, "typed insert did not return a promise");231+ assert(result instanceof WriteHandle, "typed insert did not return a write handle");232+ assert(typeof result.then === "function", "the write handle must stay thenable");195233 lease.release();234+ runtime.ledger.dispose();196235 });
M package/runtime/ui-mutation.ts +1 -1
A package/runtime/write-handle.ts +220 -0
@@ -0,0 +1,220 @@
1+/**2+ * Package-owned per-write handle.3+ *4+ * One write, observed as a monotonic stage machine. The handle is thenable —5+ * `await write` resolves at `saved`, the local-durability contract every6+ * existing mutation keeps — and exposes the later sync fate as level-triggered7+ * state: stage and reason are properties, `saved` and `synced` are promises,8+ * and subscribers always receive the current truth immediately, so no9+ * consumer can miss a transition by attaching late.10+ *11+ * @module12+ */13+14+/**15+ * The closed, framework-owned write lifecycle. Stages are monotonic:16+ * `saving → saved → syncing → synced | rejected`. `syncing` appears only when17+ * the runtime can observe the transport; a runtime without that signal moves18+ * writes from `saved` directly to `synced`.19+ */20+export type WriteStage = "saving" | "saved" | "syncing" | "synced" | "rejected";21+22+/** Why a write settled as `rejected`: the structured cause, code, and reason. */23+export type WriteRejection = {24+ /**25+ * The structured cause: `denied` for a store verdict, `expired` for an26+ * intent retired past its lifespan.27+ */28+ cause: "denied" | "expired";29+ /** The sync node's rejection code, or `null` when none was carried. */30+ code: string | null;31+ /** Human-readable reason text from the adjudicating node. */32+ reason: string;33+};34+35+/**36+ * Raised through {@link WriteHandle.synced} when a write settles as37+ * `rejected`: the store adjudicated the write and denied it permanently.38+ */39+export class WriteRejectedError extends Error {40+ /** Stable error class name for diagnostics and error boundaries. */41+ override readonly name = "WriteRejectedError";42+ /** The structured cause: `denied` or `expired`. */43+ readonly rejectionCause: "denied" | "expired";44+ /** The adjudicated rejection code, or `null` when none was carried. */45+ readonly code: string | null;46+ /** The journal id of the rejected write. */47+ readonly writeId: string;48+49+ /** Creates the settled rejection carried by a write's `synced` promise. */50+ constructor(writeId: string, rejection: WriteRejection) {51+ super(rejection.reason);52+ this.writeId = writeId;53+ this.code = rejection.code;54+ this.rejectionCause = rejection.cause;55+ }56+}57+58+const stageOrder: Record<WriteStage, number> = {59+ saving: 0,60+ saved: 1,61+ syncing: 2,62+ synced: 3,63+ rejected: 3,64+};65+66+type Resolver<T> = { resolve: (value: T) => void; reject: (error: unknown) => void };67+68+function exposedPromise<T>(): { promise: Promise<T>; resolver: Resolver<T> } {69+ let resolver: Resolver<T>;70+ const promise = new Promise<T>((resolve, reject) => {71+ resolver = { resolve, reject };72+ });73+ // An author is free to never read a stage promise; the handle itself keeps74+ // each one observed so an unconsumed rejection cannot escape as a global75+ // unhandled-rejection event.76+ promise.catch(() => undefined);77+ return { promise, resolver: resolver! };78+}79+80+/**81+ * A single write observed through the author-facing lifecycle.82+ *83+ * `await write` (the thenable) resolves at `saved` with the write's value —84+ * for inserts, the created row. `write.synced` resolves when the store85+ * confirms the write and rejects with {@link WriteRejectedError} when the86+ * store denies it. `stage` and `reason` are current-state properties;87+ * `subscribe` notifies immediately and on every later change.88+ */89+export class WriteHandle<T> implements PromiseLike<T> {90+ readonly #writeId: string;91+ readonly #saved: Promise<T>;92+ readonly #synced: Promise<T>;93+ readonly #savedResolver: Resolver<T>;94+ readonly #syncedResolver: Resolver<T>;95+ readonly #listeners = new Set<() => void>();96+ #stage: WriteStage = "saving";97+ #reason: WriteRejection | null = null;98+ #value: T | undefined;99+ #valueSet = false;100+ #batchId: string | null = null;101+102+ /** Creates a handle in `saving`; the runtime advances it via its controller. */103+ constructor(writeId: string) {104+ this.#writeId = writeId;105+ const saved = exposedPromise<T>();106+ const synced = exposedPromise<T>();107+ this.#saved = saved.promise;108+ this.#savedResolver = saved.resolver;109+ this.#synced = synced.promise;110+ this.#syncedResolver = synced.resolver;111+ }112+113+ /** The stable journal id of this write. */114+ get writeId(): string {115+ return this.#writeId;116+ }117+118+ /** The vendor batch id attributing adjudicated verdicts, once known. */119+ get batchId(): string | null {120+ return this.#batchId;121+ }122+123+ /** @internal Records the vendor batch id once the write is accepted. */124+ setBatchId(batchId: string | null): void {125+ this.#batchId = batchId;126+ }127+128+ /** The current lifecycle stage. Monotonic; never moves backwards. */129+ get stage(): WriteStage {130+ return this.#stage;131+ }132+133+ /** The rejection carried by a `rejected` write; `null` in every other stage. */134+ get reason(): WriteRejection | null {135+ return this.#reason;136+ }137+138+ /** Resolves with the write's value once the write is durable on this device. */139+ get saved(): Promise<T> {140+ return this.#saved;141+ }142+143+ /**144+ * Resolves with the write's value once the store confirms the write, and145+ * rejects with {@link WriteRejectedError} when the store denies it.146+ */147+ get synced(): Promise<T> {148+ return this.#synced;149+ }150+151+ /**152+ * Awaiting the handle resolves at `saved` — the same local-durability153+ * contract as every other mutation promise in the package.154+ */155+ then<Fulfilled = T, Rejected = never>(156+ onfulfilled?: ((value: T) => Fulfilled | PromiseLike<Fulfilled>) | null,157+ onrejected?: ((reason: unknown) => Rejected | PromiseLike<Rejected>) | null,158+ ): Promise<Fulfilled | Rejected> {159+ return this.#saved.then(onfulfilled, onrejected);160+ }161+162+ /**163+ * Observes stage changes. The listener runs immediately with the current164+ * state and again after every stage advance, so late subscribers see the165+ * same truth as early ones. Returns an idempotent unsubscribe function.166+ */167+ subscribe(listener: () => void): () => void {168+ this.#listeners.add(listener);169+ listener();170+ let active = true;171+ return () => {172+ if (!active) return;173+ active = false;174+ this.#listeners.delete(listener);175+ };176+ }177+178+ /**179+ * @internal Advances the monotonic stage machine; later stages never180+ * regress. The value supplied at (or before) `saved` is the one both stage181+ * promises resolve with.182+ */183+ advance(stage: Exclude<WriteStage, "rejected">, value?: T): void {184+ if (this.#stage === "synced" || this.#stage === "rejected") return;185+ if (!this.#valueSet && value !== undefined) {186+ this.#value = value;187+ this.#valueSet = true;188+ }189+ if (stageOrder[stage] <= stageOrder[this.#stage]) return;190+ this.#stage = stage;191+ if (stageOrder[stage] >= stageOrder.saved) this.#savedResolver.resolve(this.#value as T);192+ if (stage === "synced") this.#syncedResolver.resolve(this.#value as T);193+ this.#emit();194+ }195+196+ /** @internal Settles the write as `rejected` with the adjudicated reason. */197+ reject(rejection: WriteRejection): void {198+ if (this.#stage === "synced" || this.#stage === "rejected") return;199+ this.#stage = "rejected";200+ this.#reason = rejection;201+ // A verdict arrives after local acceptance in the normal flow, but a202+ // handle refused before `saved` must still settle both promises.203+ const error = new WriteRejectedError(this.#writeId, rejection);204+ this.#savedResolver.reject(error);205+ this.#syncedResolver.reject(error);206+ this.#emit();207+ }208+209+ /** @internal Fails every stage promise without entering the `rejected` stage. */210+ fail(error: unknown): void {211+ if (this.#stage === "synced" || this.#stage === "rejected") return;212+ this.#savedResolver.reject(error);213+ this.#syncedResolver.reject(error);214+ this.#emit();215+ }216+217+ #emit(): void {218+ for (const listener of this.#listeners) listener();219+ }220+}
A package/runtime/write-handle_test.ts +62 -0
@@ -0,0 +1,62 @@
1+import { assert, assertCount } from "./test-assert.ts";2+import { WriteHandle, WriteRejectedError } from "./write-handle.ts";3+4+Deno.test("await resolves at saved with the write's value", async () => {5+ const handle = new WriteHandle<{ id: string }>("w1");6+ handle.advance("saved", { id: "row-1" });7+ const row = await handle;8+ assert(row.id === "row-1", "the thenable must resolve with the saved value");9+ assert(handle.stage === "saved", "stage must be saved after local durability");10+});11+12+Deno.test("stages are monotonic and never regress", () => {13+ const handle = new WriteHandle<void>("w1");14+ handle.advance("synced");15+ handle.advance("saved");16+ assert(handle.stage === "synced", "a later stage must not regress to an earlier one");17+ handle.reject({ cause: "denied", code: "permission_denied", reason: "late" });18+ assert(handle.stage === "synced", "a settled write must ignore late verdicts");19+ assert(handle.reason === null, "a settled write must not carry a rejection");20+});21+22+Deno.test("synced resolves with the value staged before confirmation", async () => {23+ const handle = new WriteHandle<{ id: string }>("w1");24+ handle.advance("saving", { id: "row-9" });25+ handle.advance("synced");26+ const row = await handle.synced;27+ assert(row.id === "row-9", "synced must resolve with the staged value");28+});29+30+Deno.test("rejection settles both promises with WriteRejectedError and sets reason", async () => {31+ const handle = new WriteHandle<void>("w1");32+ handle.advance("saved");33+ handle.reject({ cause: "denied", code: "permission_denied", reason: "denied by store" });34+ assert(handle.stage === "rejected", "the verdict must reach the stage");35+ assert(handle.reason?.code === "permission_denied", "the rejection code must be readable");36+ const error = await handle.synced.then(() => null, (thrown) => thrown as WriteRejectedError);37+ assert(error instanceof WriteRejectedError, "synced must reject with WriteRejectedError");38+ assert(error.writeId === "w1", "the error must carry the journal id");39+});40+41+Deno.test("an unobserved rejected handle leaks no unhandled rejection", async () => {42+ const handle = new WriteHandle<void>("w1");43+ handle.reject({ cause: "denied", code: null, reason: "refused" });44+ // Nothing awaits the handle; settling the microtask queue must not surface45+ // an unhandled rejection (the test runner fails the test if one escapes).46+ await new Promise((resolve) => setTimeout(resolve, 0));47+ assert(handle.stage === "rejected", "the refusal must still settle the stage");48+});49+50+Deno.test("subscribers are level-triggered: late subscribers see current truth", () => {51+ const handle = new WriteHandle<void>("w1");52+ handle.advance("saved");53+ const seen: string[] = [];54+ const stop = handle.subscribe(() => seen.push(handle.stage));55+ assert(seen[0] === "saved", "a late subscriber must immediately observe the current stage");56+ handle.advance("synced");57+ assert(seen[1] === "synced", "subscribers must observe later transitions");58+ stop();59+ stop();60+ handle.reject({ cause: "denied", code: null, reason: "ignored" });61+ assertCount(seen.length, 2, "an unsubscribed listener must not be notified");62+});
A package/runtime/write-journal.ts +317 -0
@@ -0,0 +1,317 @@
1+/**2+ * Package-owned durable write journal.3+ *4+ * The substrate of the effect system: one device-local record per write,5+ * persisting enough to rebuild pending sync state and outstanding effect6+ * obligations after a reload. The journal lives beside the runtime's7+ * persistent storage — OPFS when the browser provides it, `localStorage`8+ * otherwise — and is readable at boot before sync connects. Entries are keyed9+ * by write id; effect obligations are keyed by `(write id, effect name)`, and10+ * the composed journal id is the idempotency key handlers receive.11+ *12+ * The journal never stores column values. Boot-reconciliation equality probes13+ * compare per-column keyed hashes (HMAC-SHA-256 under a random per-journal14+ * key), so the journal is not a second greppable plaintext copy of row data.15+ * This does not protect low-entropy values against brute force by an attacker16+ * holding the whole storage — that attacker's access already equals the local17+ * store's own at-rest posture.18+ *19+ * @module20+ */21+22+import { sha256 } from "@noble/hashes/sha2";23+24+/**25+ * One obligation's durable status. `pending` until the handler completes;26+ * `failed` handlers re-arm at boot until quarantine retires them as27+ * `failed-permanent`; `expired` marks a delivery window that closed before28+ * the obligation could be delivered. Retired statuses never run again and29+ * make the entry prunable.30+ */31+export type JournalEffectStatus =32+ | "pending"33+ | "done"34+ | "failed"35+ | "expired"36+ | "failed-permanent";37+38+/** One effect obligation's durable state within a journaled write. */39+export type JournalEffectState = {40+ /** The obligation's durable status. */41+ status: JournalEffectStatus;42+ /** How many times the handler has been started, across reloads. */43+ attempts: number;44+ /** The last handler failure message, for diagnostics. */45+ lastError: string | null;46+ /** Epoch milliseconds when the delivery window closes, or `null` for none. */47+ expiresAt: number | null;48+};49+50+/** The durable fate of a journaled write. */51+export type JournalWriteStage = "saved" | "synced" | "rejected";52+53+/** One journaled write: identity, snapshot, fate, and effect obligations. */54+export type JournalWriteRecord = {55+ /** The stable package-owned write id. */56+ writeId: string;57+ /** The declaring verb's name, or `null` for writes without a verb. */58+ verb: string | null;59+ /** The written table's name. */60+ table: string;61+ /** Which operation the write performed. */62+ op: "insert" | "update" | "remove";63+ /** The written row's id. */64+ rowId: string;65+ /** The vendor batch id used to attribute adjudicated verdicts. */66+ batchId: string | null;67+ /**68+ * Keyed per-column hashes of the written values (all columns for inserts,69+ * the changed columns for updates, none for removes), sufficient for the70+ * boot-reconciliation equality probe. Values themselves are never stored.71+ */72+ rowHashes: Record<string, string>;73+ /** The write's durable fate so far. */74+ stage: JournalWriteStage;75+ /** The structured rejection cause, when the stage is `rejected`. */76+ cause: "denied" | "expired" | null;77+ /** The adjudicated rejection code, when the stage is `rejected`. */78+ code: string | null;79+ /** The adjudicated rejection reason, when the stage is `rejected`. */80+ reason: string | null;81+ /** Epoch milliseconds when the write was journaled. */82+ createdAt: number;83+ /** Epoch milliseconds when the intent's lifespan ends, or `null` for none. */84+ expiresAt: number | null;85+ /** Effect obligations keyed by effect name. */86+ effects: Record<string, JournalEffectState>;87+};88+89+/** The persisted journal document. */90+export type JournalDocument = {91+ /** Storage format version. */92+ version: 1;93+ /** The random per-journal key (hex) the column-value hashes are keyed with. */94+ hashKey: string;95+ /** Journaled writes keyed by write id. */96+ writes: Record<string, JournalWriteRecord>;97+};98+99+/**100+ * Where the journal document persists. The default resolves OPFS, then101+ * `localStorage`, then memory; tests inject a deterministic implementation.102+ */103+export type JournalStorage = {104+ /** Reads the persisted document text, or `null` when none exists. */105+ load(): Promise<string | null>;106+ /** Replaces the persisted document text. */107+ save(text: string): Promise<void>;108+};109+110+/** The composed idempotency key for one `(write id, effect name)` obligation. */111+export function journalIdFor(writeId: string, effectName: string): string {112+ return `${writeId}:${effectName}`;113+}114+115+function randomHashKey(): string {116+ const bytes = crypto.getRandomValues(new Uint8Array(32));117+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");118+}119+120+/** An empty journal document with a freshly generated hash key. */121+export function emptyJournal(): JournalDocument {122+ return { version: 1, hashKey: randomHashKey(), writes: {} };123+}124+125+// One HMAC-SHA-256 block; noble's sha256 keeps this synchronous, so hashes126+// exist before the journal entry is first persisted.127+function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {128+ const blockSize = 64;129+ const normalizedKey = key.length > blockSize ? sha256(key) : key;130+ const inner = new Uint8Array(blockSize).fill(0x36);131+ const outer = new Uint8Array(blockSize).fill(0x5c);132+ for (let index = 0; index < normalizedKey.length; index += 1) {133+ inner[index] ^= normalizedKey[index];134+ outer[index] ^= normalizedKey[index];135+ }136+ const message1 = new Uint8Array(blockSize + message.length);137+ message1.set(inner);138+ message1.set(message, blockSize);139+ const innerHash = sha256(message1);140+ const message2 = new Uint8Array(blockSize + innerHash.length);141+ message2.set(outer);142+ message2.set(innerHash, blockSize);143+ return sha256(message2);144+}145+146+function hexKeyBytes(hex: string): Uint8Array {147+ const bytes = new Uint8Array(hex.length >> 1);148+ for (let index = 0; index < bytes.length; index += 1) {149+ bytes[index] = parseInt(hex.slice(index * 2, index * 2 + 2), 16);150+ }151+ return bytes;152+}153+154+/**155+ * The keyed hash of one JSON-normalized column value under a journal's hash156+ * key: what the journal persists instead of the value, and what the157+ * boot-reconciliation probe computes over live rows for the equality check.158+ */159+export function hashJournalValue(hashKey: string, value: unknown): string {160+ const canonical = JSON.stringify(value === undefined ? null : value) ?? "null";161+ const digest = hmacSha256(hexKeyBytes(hashKey), new TextEncoder().encode(canonical));162+ return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");163+}164+165+/**166+ * Parses persisted journal text; unreadable or foreign text is an empty167+ * journal. Entries from earlier formats without column hashes are dropped, so168+ * no plaintext-era record survives a load.169+ */170+export function parseJournal(text: string | null): JournalDocument {171+ if (!text) return emptyJournal();172+ try {173+ const value = JSON.parse(text) as Partial<JournalDocument> | null;174+ if (175+ value && value.version === 1 && typeof value.hashKey === "string" && value.hashKey &&176+ value.writes && typeof value.writes === "object"177+ ) {178+ const writes: Record<string, JournalWriteRecord> = {};179+ for (const [writeId, entry] of Object.entries(value.writes)) {180+ const record = entry as Partial<JournalWriteRecord>;181+ if (!record.rowHashes || typeof record.rowHashes !== "object") continue;182+ writes[writeId] = entry as JournalWriteRecord;183+ }184+ return { version: 1, hashKey: value.hashKey, writes };185+ }186+ } catch {187+ // Fall through: a corrupt journal must not brick boot.188+ }189+ return emptyJournal();190+}191+192+/** An in-memory {@link JournalStorage} for tests and storage-less runtimes. */193+export function createMemoryJournalStorage(initial: string | null = null): JournalStorage & {194+ /** The currently persisted text, for assertions. */195+ text(): string | null;196+} {197+ let stored = initial;198+ return {199+ load: () => Promise.resolve(stored),200+ save(text: string) {201+ stored = text;202+ return Promise.resolve();203+ },204+ text: () => stored,205+ };206+}207+208+type OpfsDirectory = {209+ getFileHandle(210+ name: string,211+ options?: { create?: boolean },212+ ): Promise<{213+ getFile(): Promise<{ text(): Promise<string> }>;214+ createWritable(): Promise<{ write(text: string): Promise<void>; close(): Promise<void> }>;215+ }>;216+};217+218+function opfsStorage(fileName: string): JournalStorage | null {219+ const storage = (globalThis.navigator as Navigator | undefined)?.storage as220+ | { getDirectory?: () => Promise<OpfsDirectory> }221+ | undefined;222+ if (typeof storage?.getDirectory !== "function") return null;223+ const directory = () => storage.getDirectory!();224+ return {225+ async load() {226+ try {227+ const handle = await (await directory()).getFileHandle(fileName);228+ return await (await handle.getFile()).text();229+ } catch {230+ return null;231+ }232+ },233+ async save(text: string) {234+ const handle = await (await directory()).getFileHandle(fileName, { create: true });235+ const writable = await handle.createWritable();236+ await writable.write(text);237+ await writable.close();238+ },239+ };240+}241+242+function localStorageStorage(key: string): JournalStorage | null {243+ if (typeof localStorage === "undefined") return null;244+ return {245+ load() {246+ try {247+ return Promise.resolve(localStorage.getItem(key));248+ } catch {249+ return Promise.resolve(null);250+ }251+ },252+ save(text: string) {253+ try {254+ localStorage.setItem(key, text);255+ } catch {256+ // A private-mode quota failure degrades to session-only pending state.257+ }258+ return Promise.resolve();259+ },260+ };261+}262+263+/**264+ * Resolves the default journal storage for one app id: an OPFS file when the265+ * browser provides it, `localStorage` otherwise, and memory as a last resort,266+ * so the journal is always readable at boot before sync connects.267+ */268+export function createDefaultJournalStorage(appId: string): JournalStorage {269+ return opfsStorage(`lofi-write-journal-${appId}.json`) ??270+ localStorageStorage(`lofi:write-journal:${appId}`) ??271+ createMemoryJournalStorage();272+}273+274+/**275+ * The journal's single reader and writer: an in-memory document with276+ * serialized, coalesced persistence. Mutations apply synchronously in memory;277+ * `flush()` awaits the trailing persisted state.278+ */279+export class WriteJournal {280+ readonly #storage: JournalStorage;281+ #document: JournalDocument = emptyJournal();282+ #chain: Promise<void> = Promise.resolve();283+ #loaded = false;284+285+ /** Creates a journal over one storage location. */286+ constructor(storage: JournalStorage) {287+ this.#storage = storage;288+ }289+290+ /** Loads the persisted document once; later calls return the memory state. */291+ async load(): Promise<JournalDocument> {292+ if (!this.#loaded) {293+ this.#document = parseJournal(await this.#storage.load());294+ this.#loaded = true;295+ }296+ return this.#document;297+ }298+299+ /** The current in-memory document. */300+ get document(): JournalDocument {301+ return this.#document;302+ }303+304+ /** Applies one mutation to the document and schedules persistence. */305+ update(mutate: (document: JournalDocument) => void): void {306+ mutate(this.#document);307+ const snapshot = JSON.stringify(this.#document);308+ this.#chain = this.#chain309+ .then(() => this.#storage.save(snapshot))310+ .catch(() => undefined);311+ }312+313+ /** Resolves once every scheduled persistence has settled. */314+ flush(): Promise<void> {315+ return this.#chain;316+ }317+}
A package/runtime/write-ledger.ts +908 -0
@@ -0,0 +1,908 @@
1+/**2+ * Package-owned write ledger: the runtime half of the effect system.3+ *4+ * Every write performed here gets a {@link WriteHandle}, a journal entry, and5+ * — when its verb declares them — effect obligations. The ledger settles each6+ * write against the store, fires effect handlers on the originating device7+ * when the write's fate resolves, and reconstructs pending state from the8+ * journal at boot: entries whose rows the store already confirms settle as9+ * synced, adjudicated verdicts settle as rejected, and everything else stays10+ * pending under periodic probes until the store answers.11+ *12+ * Verdict handling follows the sync-node contract: a synchronous local13+ * refusal throws from the call site and never journals; an adjudicated14+ * rejection settles the write only when15+ * {@link classifyMutationError} calls its code permanent. The engine rolls a16+ * rejected write back out of local query results itself, so compensation17+ * handlers repair what the user was told, not the row.18+ *19+ * @module20+ */21+22+import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";23+import {24+ type EffectContext,25+ type EffectRow,26+ type EffectUnit,27+ type MutationDescriptor,28+ resolveEffectUnit,29+ setMutationRuntime,30+} from "../schema/effects.ts";31+import { appId, syncing } from "./config.ts";32+import { recordEffectLogEntry, type RuntimeDiagnostics } from "./diagnostics.ts";33+import { settleDurableWrite } from "./durability.ts";34+import { classifyMutationError } from "./mutation-taxonomy.ts";35+import {36+ findRuntimeTable,37+ getRuntime,38+ runtimeRecreatedEvent,39+ updateRuntimeDiagnostics,40+} from "./runtime.ts";41+import { assertSchemaWritable } from "./schema-compat.ts";42+import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";43+import { WriteHandle } from "./write-handle.ts";44+import {45+ createDefaultJournalStorage,46+ hashJournalValue,47+ type JournalEffectState,48+ journalIdFor,49+ type JournalStorage,50+ type JournalWriteRecord,51+ WriteJournal,52+} from "./write-journal.ts";53+54+/** One write not yet settled, as shown by pending-writes surfaces. */55+export type PendingWriteSummary = {56+ /** The stable journal id of the write. */57+ writeId: string;58+ /** The declaring verb's name, or `null` for writes without a verb. */59+ verb: string | null;60+ /** The written table's name. */61+ table: string;62+ /** The written row's id. */63+ rowId: string;64+ /** Which operation the write performed. */65+ op: "insert" | "update" | "remove";66+ /** Epoch milliseconds when the write was journaled. */67+ createdAt: number;68+ /**69+ * True once the verb's declared lifespan has passed while the write is70+ * still pending. Surfacing only: the runtime cannot withdraw a locally71+ * accepted write, so an overdue intent is reported, never retired.72+ */73+ expired: boolean;74+};75+76+/** The reload-safe pending set powering "N changes waiting to sync". */77+export type PendingWritesSnapshot = {78+ /** How many journaled writes have not settled. */79+ count: number;80+ /** The pending writes, oldest first. */81+ writes: readonly PendingWriteSummary[];82+};83+84+/** Per-row sync state for badges: settled, still waiting, or denied. */85+export type RowSyncStatus = "synced" | "waiting" | "rejected";86+87+/** A write the ledger can perform. */88+export type LedgerWriteRequest =89+ | { kind: "insert"; table: TableProxy<unknown, unknown>; values: unknown }90+ | {91+ kind: "update";92+ table: TableProxy<unknown, unknown>;93+ id: string;94+ patch: Record<string, unknown>;95+ }96+ | { kind: "remove"; table: TableProxy<unknown, unknown>; id: string };97+98+/** Verb metadata carried by a ledger write. */99+export type LedgerWriteOptions = {100+ /** The declaring verb's name, or `null` for bare table writes. */101+ verb?: string | null;102+ /** The effect units journaled with this write. */103+ units?: readonly EffectUnit<{ id: string }>[];104+ /** The intent's lifespan in milliseconds, or `null` for none. */105+ expiresMs?: number | null;106+};107+108+type VendorWrite = {109+ batchId?: unknown;110+ value?: unknown;111+ wait(options: { tier: "local" | "global" }): Promise<unknown>;112+};113+114+type LedgerDb = Pick<Db, "onMutationError"> & {115+ insert(table: never, values: never): VendorWrite;116+ update(table: never, id: string, patch: never): VendorWrite;117+ delete(table: never, id: string): VendorWrite;118+ all(query: never, options?: { tier: "local" | "global" }): Promise<unknown[]>;119+};120+121+type ProbeTable = { where(input: Record<string, unknown>): unknown };122+123+/** Runtime seams used by the ledger; tests inject deterministic values. */124+export type WriteLedgerEnvironment = {125+ /** Opens (or returns) the vendor database. */126+ getDb(): Promise<Db>;127+ /** Whether managed sync is configured; read live on every write. */128+ syncConfigured(): boolean;129+ /** Where the journal persists. */130+ storage: JournalStorage;131+ /** Resolves a declared table by name for boot-reconciliation probes. */132+ resolveTable(name: string): ProbeTable | null;133+ /** Resolves a registered effect unit by its durable name. */134+ resolveEffectUnit(name: string): EffectUnit<{ id: string }> | null;135+ /** Subscribes to runtime replacement, so settlement re-arms on a new client. */136+ subscribeRuntimeRecreation(listener: () => void): () => void;137+ /** Applies one diagnostics update. */138+ updateDiagnostics(update: (diagnostics: RuntimeDiagnostics) => void): void;139+ /** Current epoch milliseconds. */140+ now(): number;141+ /** Delay before settlement retry `attempt`; injectable for deterministic tests. */142+ retryDelayMs?: (attempt: number) => number;143+ /** How long a global-tier probe may run before it is retried later. */144+ probeTimeoutMs?: number;145+ /** How often retention is swept while the ledger runs. Default 30 seconds. */146+ sweepIntervalMs?: number;147+ /**148+ * Runs before each write reaches the database: rejects to refuse the write149+ * (the schema-compatibility gate — the refusal surfaces through the verb150+ * call with no journal entry, stage, or effect), and may resolve with a151+ * release function invoked once the write settles locally (the cross-tab152+ * write lock). Absent means writes are never guarded.153+ */154+ guardWrite?(): Promise<(() => void) | void>;155+};156+157+const defaultRetryDelay = (attempt: number): number => Math.min(4000 * (attempt + 1), 30_000);158+159+function rejectionShape(error: unknown): { code: string; reason: string } | null {160+ if (!error || typeof error !== "object") return null;161+ const value = error as { name?: unknown; code?: unknown; reason?: unknown };162+ if (value.name !== "PersistedWriteRejectedError" || typeof value.code !== "string") return null;163+ return { code: value.code, reason: typeof value.reason === "string" ? value.reason : "" };164+}165+166+function normalized(value: unknown): unknown {167+ if (value === undefined) return undefined;168+ return JSON.parse(JSON.stringify(value));169+}170+171+function hashesMatch(172+ hashKey: string,173+ journaled: Record<string, string>,174+ row: Record<string, unknown>,175+): boolean {176+ for (const [key, digest] of Object.entries(journaled)) {177+ if (key === "id") continue;178+ if (hashJournalValue(hashKey, normalized(row[key]) ?? null) !== digest) return false;179+ }180+ return true;181+}182+183+// The default quarantine bound: failing attempts before an obligation184+// retires as failed-permanent instead of re-arming forever.185+const defaultMaxAttempts = 5;186+187+type RetiredEffectStatus = "done" | "expired" | "failed-permanent";188+189+function isRetired(status: JournalEffectState["status"]): status is RetiredEffectStatus {190+ return status === "done" || status === "expired" || status === "failed-permanent";191+}192+193+/**194+ * The per-app write ledger. One instance owns the journal, the pending-writes195+ * observable, boot reconciliation, and effect delivery. Application code196+ * reaches it through verbs and hooks; tests construct isolated instances over197+ * an injected {@link WriteLedgerEnvironment}.198+ */199+export class WriteLedger {200+ readonly #environment: WriteLedgerEnvironment;201+ readonly #journal: WriteJournal;202+ readonly #listeners = new Set<() => void>();203+ readonly #running = new Set<string>();204+ readonly #probing = new Set<string>();205+ readonly #timers = new Set<ReturnType<typeof setTimeout>>();206+ readonly #sessionFates = new Map<string, { stage: "synced" | "rejected"; at: number }>();207+ #handles = new Map<string, WriteHandle<unknown>>();208+ readonly #liveRows = new Map<string, Record<string, unknown>>();209+ #sweepTimer: ReturnType<typeof setInterval> | null = null;210+ #db: LedgerDb | null = null;211+ #stopMutationErrors: (() => void) | null = null;212+ #stopRecreation: (() => void) | null = null;213+ #armed: Promise<void> | null = null;214+ #pendingSnapshot: PendingWritesSnapshot = { count: 0, writes: [] };215+ #disposed = false;216+217+ /** Creates a ledger over one environment; call {@link arm} before relying on boot state. */218+ constructor(environment: WriteLedgerEnvironment) {219+ this.#environment = environment;220+ this.#journal = new WriteJournal(environment.storage);221+ }222+223+ /**224+ * Loads the journal and reconciles it against the store: confirmed writes225+ * settle as synced, replayed verdicts settle as rejected, unsettled writes226+ * stay pending under probes, and settled writes with outstanding effect227+ * obligations re-run their handlers. Idempotent.228+ */229+ arm(): Promise<void> {230+ this.#armed ??= this.#reconcile();231+ return this.#armed;232+ }233+234+ /** Level-triggered pending-writes state; identity changes only on change. */235+ getPendingSnapshot = (): PendingWritesSnapshot => this.#pendingSnapshot;236+237+ /** Subscribes to ledger changes; the listener runs immediately and on every change. */238+ subscribe = (listener: () => void): () => void => {239+ this.#listeners.add(listener);240+ listener();241+ let active = true;242+ return () => {243+ if (!active) return;244+ active = false;245+ this.#listeners.delete(listener);246+ };247+ };248+249+ /** The sync status of one row, derived from journal entries and session fates. */250+ rowStatus(rowId: string): RowSyncStatus {251+ for (const entry of Object.values(this.#journal.document.writes)) {252+ if (entry.rowId === rowId && entry.stage === "saved") return "waiting";253+ }254+ for (const entry of Object.values(this.#journal.document.writes)) {255+ if (entry.rowId === rowId && entry.stage === "rejected") return "rejected";256+ }257+ const fate = this.#sessionFates.get(rowId);258+ if (fate?.stage === "rejected") return "rejected";259+ return "synced";260+ }261+262+ /**263+ * Performs one write. A refusal — the write guard's or the runtime's own264+ * current-policy check — leaves through the verb call as ordinary author265+ * error handling and never creates a journal entry, a stage, or an effect:266+ * it throws synchronously when the unguarded database is already open, and267+ * fails the returned handle otherwise. The handle resolves at `saved` and268+ * settles through `synced` or `rejected`.269+ */270+ perform<T>(request: LedgerWriteRequest, options: LedgerWriteOptions = {}): WriteHandle<T> {271+ if (this.#disposed) throw new Error("write ledger has been disposed");272+ const handle = new WriteHandle<T>(crypto.randomUUID());273+ const db = this.#db;274+ const guard = this.#environment.guardWrite;275+ if (db && !guard) {276+ this.#execute(db, request, options, handle as WriteHandle<unknown>);277+ return handle;278+ }279+ void (async () => {280+ let release: (() => void) | undefined;281+ try {282+ release = (await guard?.()) ?? undefined;283+ } catch (error) {284+ // A guard refusal is not a verdict: nothing is journaled and no285+ // effect fires; the failure surfaces like any refused mutation.286+ this.#environment.updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);287+ handle.fail(error);288+ return;289+ }290+ try {291+ const openDb = await this.#withDb();292+ this.#execute(openDb, request, options, handle as WriteHandle<unknown>);293+ } catch (error) {294+ handle.fail(error);295+ } finally {296+ if (release) {297+ // The lock covers the local-durability window: it releases when298+ // the write settles locally or fails, matching the quiescence bar299+ // an upgrade swap waits on.300+ const done = release;301+ handle.saved.then(() => done(), () => done());302+ }303+ }304+ })();305+ return handle;306+ }307+308+ /** Performs one declared verb call; the verb dispatcher's entry point. */309+ performVerb(descriptor: MutationDescriptor, args: readonly unknown[]): WriteHandle<unknown> {310+ const table = descriptor.op.table as TableProxy<unknown, unknown>;311+ const options: LedgerWriteOptions = {312+ verb: descriptor.verbName,313+ units: descriptor.units,314+ expiresMs: descriptor.expiresMs,315+ };316+ if (descriptor.op.kind === "insert") {317+ return this.perform({ kind: "insert", table, values: args[0] }, options);318+ }319+ if (descriptor.op.kind === "update") {320+ return this.perform({321+ kind: "update",322+ table,323+ id: args[0] as string,324+ patch: args[1] as Record<string, unknown>,325+ }, options);326+ }327+ return this.perform({ kind: "remove", table, id: args[0] as string }, options);328+ }329+330+ /** Re-attempts outstanding obligations of one effect name after late registration. */331+ retryObligationsFor(effectName: string): void {332+ for (const entry of Object.values(this.#journal.document.writes)) {333+ if (entry.stage === "saved") continue;334+ const state = entry.effects[effectName];335+ if (state && !isRetired(state.status)) void this.#runObligation(entry, effectName);336+ }337+ }338+339+ /** Resolves once scheduled journal persistence has settled; test seam. */340+ flush(): Promise<void> {341+ return this.#journal.flush();342+ }343+344+ /** Releases listeners and cancels every scheduled probe and retry. */345+ dispose(): void {346+ if (this.#disposed) return;347+ this.#disposed = true;348+ if (this.#sweepTimer !== null) clearInterval(this.#sweepTimer);349+ this.#sweepTimer = null;350+ for (const timer of this.#timers) clearTimeout(timer);351+ this.#timers.clear();352+ this.#listeners.clear();353+ this.#stopMutationErrors?.();354+ this.#stopMutationErrors = null;355+ this.#stopRecreation?.();356+ this.#stopRecreation = null;357+ }358+359+ async #withDb(): Promise<LedgerDb> {360+ if (this.#db) return this.#db;361+ const db = await this.#environment.getDb() as unknown as LedgerDb;362+ if (this.#db !== db) {363+ this.#db = db;364+ this.#attachMutationErrors(db);365+ }366+ this.#stopRecreation ??= this.#environment.subscribeRuntimeRecreation(() => {367+ this.#db = null;368+ this.#stopMutationErrors?.();369+ this.#stopMutationErrors = null;370+ void this.#withDb().then(() => this.#startProbes(), () => undefined);371+ });372+ return db;373+ }374+375+ #attachMutationErrors(db: LedgerDb): void {376+ // The fallback event fires once per runtime lifetime; the journal records377+ // the verdict idempotently on arrival and never waits for a re-fire.378+ this.#stopMutationErrors = db.onMutationError((event: MutationErrorEvent) => {379+ const batchId = event.batch?.batchId;380+ if (batchId === undefined) return;381+ const entry = Object.values(this.#journal.document.writes)382+ .find((candidate) => candidate.batchId === String(batchId));383+ if (entry) this.#applyVerdict(entry, event.code ?? null, event.reason ?? "");384+ });385+ }386+387+ async #reconcile(): Promise<void> {388+ await this.#journal.load();389+ // Retention first: obligations whose delivery window closed while the390+ // device was away retire before anything re-arms.391+ this.#sweepRetention();392+ await this.#withDb();393+ if (this.#disposed) return;394+ for (const entry of Object.values(this.#journal.document.writes)) {395+ if (entry.stage === "saved") continue;396+ // A settled write with outstanding obligations means a crash landed397+ // between handler start and journal completion: at-least-once delivery398+ // re-runs the handlers now.399+ this.#fireUnits(entry);400+ }401+ this.#startProbes();402+ if (this.#sweepTimer === null && !this.#disposed) {403+ this.#sweepTimer = setInterval(404+ () => this.#sweepRetention(),405+ this.#environment.sweepIntervalMs ?? 30_000,406+ );407+ // A periodic bookkeeping timer must never keep a process alive.408+ (this.#sweepTimer as unknown as { unref?: () => void }).unref?.();409+ }410+ }411+412+ // Retention: delivery windows close, overdue intents surface. Runs at boot413+ // and periodically while the ledger lives.414+ #sweepRetention(): void {415+ if (this.#disposed) return;416+ const now = this.#environment.now();417+ for (const entry of Object.values(this.#journal.document.writes)) {418+ if (entry.stage === "saved") continue;419+ for (const [name, state] of Object.entries(entry.effects)) {420+ if (isRetired(state.status)) continue;421+ if (this.#running.has(journalIdFor(entry.writeId, name))) continue;422+ if (state.expiresAt !== null && now > state.expiresAt) {423+ this.#retireObligation(entry, name, "expired");424+ }425+ }426+ }427+ this.#afterChange();428+ }429+430+ #retireObligation(431+ entry: JournalWriteRecord,432+ effectName: string,433+ status: "expired" | "failed-permanent",434+ ): void {435+ const state = entry.effects[effectName];436+ if (!state || isRetired(state.status)) return;437+ this.#journal.update(() => {438+ state.status = status;439+ });440+ this.#environment.updateDiagnostics((diagnostics) => {441+ if (status === "expired") diagnostics.expiredObligations += 1;442+ else diagnostics.quarantinedObligations += 1;443+ });444+ }445+446+ #startProbes(): void {447+ for (const entry of Object.values(this.#journal.document.writes)) {448+ if (entry.stage === "saved") this.#probe(entry.writeId, 0);449+ }450+ }451+452+ #probe(writeId: string, attempt: number): void {453+ if (this.#disposed || this.#probing.has(writeId)) return;454+ const entry = this.#journal.document.writes[writeId];455+ if (!entry || entry.stage !== "saved") return;456+ const db = this.#db;457+ const table = this.#environment.resolveTable(entry.table);458+ if (!db || !table) {459+ this.#scheduleProbe(writeId, attempt + 1);460+ return;461+ }462+ this.#probing.add(writeId);463+ const timeoutMs = this.#environment.probeTimeoutMs ?? 8000;464+ let timer: ReturnType<typeof setTimeout> | undefined;465+ const expired = new Promise<null>((resolve) => {466+ timer = setTimeout(() => resolve(null), timeoutMs);467+ this.#timers.add(timer);468+ });469+ const read = db.all(table.where({ id: entry.rowId }) as never, { tier: "global" })470+ .then((rows) => rows as Array<Record<string, unknown>>, () => null);471+ void Promise.race([read, expired]).then((rows) => {472+ if (timer !== undefined) {473+ clearTimeout(timer);474+ this.#timers.delete(timer);475+ }476+ this.#probing.delete(writeId);477+ if (this.#disposed) return;478+ const current = this.#journal.document.writes[writeId];479+ if (!current || current.stage !== "saved") return;480+ if (rows === null) {481+ this.#scheduleProbe(writeId, attempt + 1);482+ return;483+ }484+ const row = rows.find((candidate) => candidate.id === current.rowId);485+ const confirmed = current.op === "remove" ? row === undefined : row !== undefined &&486+ hashesMatch(this.#journal.document.hashKey, current.rowHashes, row);487+ if (confirmed) this.#settleSynced(current);488+ else this.#scheduleProbe(writeId, attempt + 1);489+ });490+ }491+492+ #scheduleProbe(writeId: string, attempt: number): void {493+ if (this.#disposed) return;494+ const delay = (this.#environment.retryDelayMs ?? defaultRetryDelay)(attempt);495+ const timer = setTimeout(() => {496+ this.#timers.delete(timer);497+ this.#probe(writeId, attempt);498+ }, delay);499+ this.#timers.add(timer);500+ }501+502+ #execute(503+ db: LedgerDb,504+ request: LedgerWriteRequest,505+ options: LedgerWriteOptions,506+ handle: WriteHandle<unknown>,507+ ): void {508+ let vendor: VendorWrite;509+ let rowId: string;510+ let rowSnapshot: Record<string, unknown>;511+ try {512+ if (request.kind === "insert") {513+ vendor = db.insert(request.table as never, request.values as never);514+ const value = vendor.value as { id: string } | undefined;515+ rowId = value?.id ?? "";516+ rowSnapshot = { ...(normalized(value) as Record<string, unknown> ?? {}) };517+ } else if (request.kind === "update") {518+ vendor = db.update(request.table as never, request.id, request.patch as never);519+ rowId = request.id;520+ rowSnapshot = { id: request.id, ...(normalized(request.patch) as Record<string, unknown>) };521+ } else {522+ vendor = db.delete(request.table as never, request.id);523+ rowId = request.id;524+ rowSnapshot = { id: request.id };525+ }526+ } catch (error) {527+ // A current-policy refusal: no batch, no journal entry, no stage. The528+ // error leaves through the verb call as ordinary author error handling.529+ this.#environment.updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);530+ throw error;531+ }532+ // The journal persists keyed hashes, never values: enough for the boot533+ // equality probe without a second plaintext copy of row data. The values534+ // themselves live only in this session's memory, for handler delivery.535+ const hashKey = this.#journal.document.hashKey;536+ const rowHashes: Record<string, string> = {};537+ for (const [column, value] of Object.entries(rowSnapshot)) {538+ if (column === "id") continue;539+ rowHashes[column] = hashJournalValue(hashKey, normalized(value) ?? null);540+ }541+ const createdAt = this.#environment.now();542+ const entry: JournalWriteRecord = {543+ writeId: handle.writeId,544+ verb: options.verb ?? null,545+ table: (request.table as { _table?: string })._table ?? "",546+ op: request.kind,547+ rowId,548+ batchId: vendor.batchId === undefined ? null : String(vendor.batchId),549+ rowHashes,550+ stage: "saved",551+ cause: null,552+ code: null,553+ reason: null,554+ createdAt,555+ expiresAt: options.expiresMs != null ? createdAt + options.expiresMs : null,556+ effects: Object.fromEntries(557+ (options.units ?? []).map((unit) => [558+ unit.effectName,559+ {560+ status: "pending" as const,561+ attempts: 0,562+ lastError: null,563+ expiresAt: unit.expiresAfterMs != null ? createdAt + unit.expiresAfterMs : null,564+ },565+ ]),566+ ),567+ };568+ this.#journal.update((document) => {569+ document.writes[entry.writeId] = entry;570+ });571+ this.#liveRows.set(entry.writeId, rowSnapshot);572+ this.#handles.set(entry.writeId, handle);573+ handle.setBatchId(entry.batchId);574+ // Pre-stage the insert's row value so a fast global confirmation can575+ // never resolve the stage promises before the value is known.576+ if (request.kind === "insert") handle.advance("saving", vendor.value);577+ this.#afterChange();578+ void this.#settle(vendor, entry, handle);579+ }580+581+ async #settle(582+ vendor: VendorWrite,583+ entry: JournalWriteRecord,584+ handle: WriteHandle<unknown>,585+ ): Promise<void> {586+ const syncConfigured = this.#environment.syncConfigured();587+ let value: unknown;588+ try {589+ value = await settleDurableWrite(590+ vendor as { wait(options: { tier: "local" | "global" }): Promise<unknown> },591+ (update) => this.#environment.updateDiagnostics(update),592+ syncConfigured ? "background" : "none",593+ {594+ onGlobal: () => this.#settleSynced(entry),595+ onGlobalError: (error) => this.#handleSettlementError(vendor, entry, error, 0),596+ },597+ );598+ } catch (error) {599+ const rejection = rejectionShape(error);600+ if (rejection) {601+ // Some drivers surface the adjudicated verdict through the local-tier602+ // wait; it is still a verdict, not a local durability failure.603+ this.#applyVerdict(entry, rejection.code, rejection.reason);604+ return;605+ }606+ // Local durability failed: the write never became durable, so the607+ // journal must not present it as pending sync.608+ this.#journal.update((document) => {609+ delete document.writes[entry.writeId];610+ });611+ this.#handles.delete(entry.writeId);612+ handle.fail(error);613+ this.#afterChange();614+ return;615+ }616+ handle.advance("saved", value);617+ // Without a sync location there is no store to adjudicate: local618+ // durability is settlement, and effects run now.619+ if (!syncConfigured) this.#settleSynced(entry);620+ }621+622+ #handleSettlementError(623+ vendor: VendorWrite,624+ entry: JournalWriteRecord,625+ error: unknown,626+ attempt: number,627+ ): void {628+ const rejection = rejectionShape(error);629+ if (rejection) {630+ this.#applyVerdict(entry, rejection.code, rejection.reason);631+ if (this.#journal.document.writes[entry.writeId]?.stage === "saved") {632+ // A non-permanent verdict keeps the write pending; re-derive later.633+ this.#scheduleWaitRetry(vendor, entry, attempt + 1);634+ }635+ return;636+ }637+ // A transport failure is not a verdict; the write stays pending.638+ this.#scheduleWaitRetry(vendor, entry, attempt + 1);639+ }640+641+ #scheduleWaitRetry(vendor: VendorWrite, entry: JournalWriteRecord, attempt: number): void {642+ if (this.#disposed) return;643+ const delay = (this.#environment.retryDelayMs ?? defaultRetryDelay)(attempt);644+ const timer = setTimeout(() => {645+ this.#timers.delete(timer);646+ const current = this.#journal.document.writes[entry.writeId];647+ if (!current || current.stage !== "saved") return;648+ vendor.wait({ tier: "global" }).then(649+ () => this.#settleSynced(current),650+ (error) => this.#handleSettlementError(vendor, current, error, attempt),651+ );652+ }, delay);653+ this.#timers.add(timer);654+ }655+656+ #settleSynced(entry: JournalWriteRecord): void {657+ const current = this.#journal.document.writes[entry.writeId];658+ if (!current || current.stage !== "saved") return;659+ this.#journal.update(() => {660+ current.stage = "synced";661+ });662+ const handle = this.#handles.get(current.writeId);663+ handle?.advance("synced");664+ this.#fireUnits(current);665+ this.#afterChange();666+ }667+668+ #applyVerdict(entry: JournalWriteRecord, code: string | null, reason: string): void {669+ const current = this.#journal.document.writes[entry.writeId];670+ if (!current || current.stage !== "saved") return;671+ if (classifyMutationError(code) !== "permanent") return;672+ this.#journal.update(() => {673+ current.stage = "rejected";674+ current.cause = "denied";675+ current.code = code;676+ current.reason = reason;677+ });678+ const handle = this.#handles.get(current.writeId);679+ handle?.reject({ cause: "denied", code, reason });680+ this.#fireUnits(current);681+ this.#afterChange();682+ }683+684+ #fireUnits(entry: JournalWriteRecord): void {685+ // Units are independent: each runs in its own task with its own journal686+ // state, so one handler's failure never blocks another unit, and there687+ // are no ordering guarantees between units of one write.688+ for (const name of Object.keys(entry.effects)) {689+ void this.#runObligation(entry, name);690+ }691+ }692+693+ async #runObligation(entry: JournalWriteRecord, effectName: string): Promise<void> {694+ const journalId = journalIdFor(entry.writeId, effectName);695+ const state = entry.effects[effectName];696+ if (!state || isRetired(state.status) || this.#running.has(journalId)) return;697+ // A closed delivery window retires the obligation before any handler698+ // consideration: the write happened, so compensation would be wrong, and699+ // the receiver's idempotency window is assumed gone.700+ if (state.expiresAt !== null && this.#environment.now() > state.expiresAt) {701+ this.#retireObligation(entry, effectName, "expired");702+ this.#afterChange();703+ return;704+ }705+ const unit = this.#environment.resolveEffectUnit(effectName);706+ // An unregistered unit stays pending: its declaring module may not have707+ // loaded yet, and registration re-attempts outstanding obligations.708+ if (!unit) return;709+ const fate = entry.stage === "synced" ? "synced" as const : "rejected" as const;710+ const handler = fate === "synced" ? unit.handlers.onSynced : unit.handlers.onRejected;711+ if (!handler) {712+ this.#journal.update(() => {713+ state.status = "done";714+ });715+ this.#afterChange();716+ return;717+ }718+ this.#running.add(journalId);719+ try {720+ const row = await this.#resolveHandlerRow(entry, fate);721+ // Attempts are journaled before the handler starts: a crash mid-handler722+ // re-runs it at the next boot — the documented at-least-once contract.723+ this.#journal.update(() => {724+ state.attempts += 1;725+ state.status = "pending";726+ });727+ const context: EffectContext = {728+ journalId,729+ writeId: entry.writeId,730+ verb: entry.verb,731+ table: entry.table,732+ rowId: entry.rowId,733+ fate,734+ cause: fate === "rejected" ? entry.cause ?? "denied" : null,735+ code: entry.code,736+ reason: entry.reason,737+ };738+ try {739+ await handler(row as EffectRow<{ id: string }>, context);740+ this.#journal.update(() => {741+ state.status = "done";742+ state.lastError = null;743+ });744+ } catch (error) {745+ const maxAttempts = unit.maxAttempts ?? defaultMaxAttempts;746+ this.#journal.update(() => {747+ state.status = "failed";748+ state.lastError = error instanceof Error ? error.message : String(error);749+ });750+ this.#environment.updateDiagnostics((diagnostics) =>751+ diagnostics.effectHandlerFailures += 1752+ );753+ // Quarantine: a handler that keeps failing retires instead of754+ // re-arming forever; the retirement is diagnosable and prunable.755+ if (state.attempts >= maxAttempts) {756+ this.#retireObligation(entry, effectName, "failed-permanent");757+ }758+ }759+ } finally {760+ this.#running.delete(journalId);761+ this.#afterChange();762+ }763+ }764+765+ /**766+ * The row a handler receives. Same-session deliveries pass the in-memory767+ * write snapshot. After a reload the journal holds no values: a synced768+ * write's row is fetched live from the store (sync confirmed it exists); a769+ * rejected write was rolled back by the engine, so compensation receives770+ * write identity only — the row id and the structured cause.771+ */772+ async #resolveHandlerRow(773+ entry: JournalWriteRecord,774+ fate: "synced" | "rejected",775+ ): Promise<Record<string, unknown>> {776+ const live = this.#liveRows.get(entry.writeId);777+ if (live) return live;778+ if (fate === "synced" && entry.op !== "remove") {779+ const db = this.#db;780+ const table = this.#environment.resolveTable(entry.table);781+ if (db && table) {782+ try {783+ const rows = await db.all(784+ table.where({ id: entry.rowId }) as never,785+ { tier: "local" },786+ ) as Array<Record<string, unknown>>;787+ const row = rows.find((candidate) => candidate.id === entry.rowId);788+ if (row) return row;789+ } catch {790+ // Fall through to identity-only delivery below.791+ }792+ }793+ }794+ return { id: entry.rowId };795+ }796+797+ #afterChange(): void {798+ const document = this.#journal.document;799+ for (const entry of Object.values(document.writes)) {800+ if (entry.stage === "saved") continue;801+ const fate = this.#sessionFates.get(entry.rowId);802+ if (!fate || fate.at <= entry.createdAt) {803+ this.#sessionFates.set(entry.rowId, { stage: entry.stage, at: entry.createdAt });804+ }805+ const settled = Object.values(entry.effects)806+ .every((state) => isRetired(state.status));807+ if (settled) {808+ // Fully settled entries leave the journal; failed-but-not-quarantined809+ // obligations keep theirs so the next boot can re-arm the handler.810+ this.#journal.update(() => {811+ delete document.writes[entry.writeId];812+ });813+ this.#handles.delete(entry.writeId);814+ this.#liveRows.delete(entry.writeId);815+ }816+ }817+ const now = this.#environment.now();818+ const pending = Object.values(document.writes)819+ .filter((entry) => entry.stage === "saved")820+ .sort((left, right) => left.createdAt - right.createdAt)821+ .map((entry): PendingWriteSummary => ({822+ writeId: entry.writeId,823+ verb: entry.verb,824+ table: entry.table,825+ rowId: entry.rowId,826+ op: entry.op,827+ createdAt: entry.createdAt,828+ expired: entry.expiresAt !== null && now > entry.expiresAt,829+ }));830+ const changed = pending.length !== this.#pendingSnapshot.count ||831+ pending.some((entry, index) =>832+ this.#pendingSnapshot.writes[index]?.writeId !== entry.writeId ||833+ this.#pendingSnapshot.writes[index]?.expired !== entry.expired834+ );835+ if (changed) this.#pendingSnapshot = { count: pending.length, writes: pending };836+ this.#environment.updateDiagnostics((diagnostics) => {837+ diagnostics.journaledPendingWrites = pending.length;838+ diagnostics.expiredPendingWrites = pending.filter((entry) => entry.expired).length;839+ });840+ for (const listener of this.#listeners) listener();841+ }842+}843+844+let defaultLedger: WriteLedger | null = null;845+846+function createDefaultLedger(): WriteLedger {847+ const ledger = new WriteLedger({848+ getDb: async () => (await getRuntime()).db,849+ syncConfigured: syncing,850+ storage: createDefaultJournalStorage(appId),851+ resolveTable: (name) => findRuntimeTable(name),852+ resolveEffectUnit,853+ subscribeRuntimeRecreation(listener) {854+ globalThis.addEventListener(runtimeRecreatedEvent, listener);855+ return () => globalThis.removeEventListener(runtimeRecreatedEvent, listener);856+ },857+ updateDiagnostics: updateRuntimeDiagnostics,858+ now: Date.now,859+ // Every write — verbs and table mutations alike — passes the860+ // schema-compatibility gate and holds the cross-tab write lock for its861+ // local-durability window, exactly like the direct table-store surface.862+ async guardWrite() {863+ await assertSchemaWritable();864+ return await acquireUpgradeWriteLock();865+ },866+ });867+ // Arming is best-effort here: a runtime that cannot open (an unsupported868+ // browser, a prerender pass) surfaces through its own boot diagnostics.869+ void ledger.arm().catch(() => undefined);870+ return ledger;871+}872+873+/** The package-wide ledger for the current document, created and armed lazily. */874+export function getWriteLedger(): WriteLedger {875+ defaultLedger ??= createDefaultLedger();876+ return defaultLedger;877+}878+879+/**880+ * Arms the package-wide ledger at boot so journaled writes reconcile and881+ * outstanding effect obligations re-run before any island mounts.882+ */883+export function armWriteLedger(): Promise<void> {884+ return getWriteLedger().arm();885+}886+887+// The runtime half of the schema-declared verb surface: importing the runtime888+// package installs dispatch, so verbs work wherever islands import hooks.889+setMutationRuntime({890+ dispatch: (descriptor, args) => getWriteLedger().performVerb(descriptor, args),891+ recordLog: (label, context) =>892+ updateRuntimeDiagnostics((diagnostics) =>893+ recordEffectLogEntry(diagnostics, {894+ label,895+ verb: context.verb,896+ table: context.table,897+ rowId: context.rowId,898+ fate: context.fate,899+ at: Date.now(),900+ })901+ ),902+ unitRegistered: (name) => defaultLedger?.retryObligationsFor(name),903+});904+905+import.meta.hot?.dispose(() => {906+ defaultLedger?.dispose();907+ defaultLedger = null;908+});
A package/runtime/write-ledger_test.ts +786 -0
@@ -0,0 +1,786 @@
1+import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";2+import { createDiagnostics } from "./diagnostics.ts";3+import type { EffectContext, EffectUnit } from "../schema/effects.ts";4+import { assert, assertCount } from "./test-assert.ts";5+import {6+ createMemoryJournalStorage,7+ emptyJournal,8+ hashJournalValue,9+ type JournalDocument,10+ journalIdFor,11+} from "./write-journal.ts";12+import { WriteLedger, type WriteLedgerEnvironment } from "./write-ledger.ts";13+14+type Row = { id: string; title: string };15+type Init = Omit<Row, "id">;16+const table = { _table: "records", _schema: {} } as TableProxy<Row, Init>;17+18+class FakeRejection extends Error {19+ override readonly name = "PersistedWriteRejectedError";20+ constructor(readonly code: string, readonly reason: string) {21+ super(`${code}: ${reason}`);22+ }23+}24+25+type Deferred = { resolve(): void; reject(error: unknown): void };26+27+class FakeDb {28+ mutationListeners = new Set<(event: MutationErrorEvent) => void>();29+ globalWaits: Deferred[] = [];30+ rows: Array<Record<string, unknown>> = [];31+ refuseNext: Error | null = null;32+ nextId = 0;33+ nextBatch = 0;34+ reads = 0;35+36+ value(): Db {37+ return this as unknown as Db;38+ }39+40+ onMutationError(listener: (event: MutationErrorEvent) => void): () => void {41+ this.mutationListeners.add(listener);42+ return () => this.mutationListeners.delete(listener);43+ }44+45+ emit(event: MutationErrorEvent): void {46+ for (const listener of this.mutationListeners) listener(event);47+ }48+49+ insert(_table: TableProxy<Row, Init>, values: Init) {50+ if (this.refuseNext) {51+ const error = this.refuseNext;52+ this.refuseNext = null;53+ throw error;54+ }55+ const row = { id: `row-${++this.nextId}`, ...values };56+ return { value: row, ...this.handle() };57+ }58+59+ update(_table: TableProxy<Row, Init>, _id: string, _patch: Partial<Init>) {60+ return this.handle();61+ }62+63+ delete(_table: TableProxy<Row, Init>, _id: string) {64+ return this.handle();65+ }66+67+ all(): Promise<unknown[]> {68+ this.reads += 1;69+ return Promise.resolve(this.rows);70+ }71+72+ settleGlobal(index = 0): void {73+ this.globalWaits[index]?.resolve();74+ }75+76+ rejectGlobal(error: unknown, index = 0): void {77+ this.globalWaits[index]?.reject(error);78+ }79+80+ private handle() {81+ return {82+ batchId: `batch-${++this.nextBatch}`,83+ wait: ({ tier }: { tier: "local" | "global" }) => {84+ if (tier === "local") return Promise.resolve(undefined);85+ return new Promise<unknown>((resolve, reject) => {86+ this.globalWaits.push({ resolve: () => resolve(undefined), reject });87+ });88+ },89+ };90+ }91+}92+93+type Harness = {94+ db: FakeDb;95+ ledger: WriteLedger;96+ diagnostics: ReturnType<typeof createDiagnostics>;97+ storage: ReturnType<typeof createMemoryJournalStorage>;98+ units: Map<string, EffectUnit<{ id: string }>>;99+ clock: { value: number };100+};101+102+function harness(options: {103+ syncConfigured?: boolean;104+ storage?: ReturnType<typeof createMemoryJournalStorage>;105+ units?: Map<string, EffectUnit<{ id: string }>>;106+ db?: FakeDb;107+ guardWrite?: () => Promise<(() => void) | void>;108+ sweepIntervalMs?: number;109+} = {}): Harness {110+ const db = options.db ?? new FakeDb();111+ const diagnostics = createDiagnostics();112+ const storage = options.storage ?? createMemoryJournalStorage();113+ const units = options.units ?? new Map<string, EffectUnit<{ id: string }>>();114+ const clock = { value: 1000 };115+ const environment: WriteLedgerEnvironment = {116+ getDb: () => Promise.resolve(db.value()),117+ syncConfigured: () => options.syncConfigured ?? true,118+ storage,119+ resolveTable: (name) => (name === "records" ? { where: (input) => input } : null),120+ resolveEffectUnit: (name) => units.get(name) ?? null,121+ subscribeRuntimeRecreation: () => () => undefined,122+ updateDiagnostics: (update) => update(diagnostics),123+ now: () => clock.value,124+ retryDelayMs: () => 1,125+ probeTimeoutMs: 50,126+ sweepIntervalMs: options.sweepIntervalMs ?? 60_000,127+ ...(options.guardWrite ? { guardWrite: options.guardWrite } : {}),128+ };129+ return { db, ledger: new WriteLedger(environment), diagnostics, storage, units, clock };130+}131+132+function unit(133+ name: string,134+ calls: Array<{ handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }>,135+ options: {136+ failFirst?: { onSynced?: boolean };137+ failAlways?: boolean;138+ expiresAfter?: number;139+ maxAttempts?: number;140+ } = {},141+): EffectUnit<{ id: string }> {142+ let failed = false;143+ return {144+ effectName: name,145+ handlers: {146+ onSynced: (row, context) => {147+ if (options.failAlways) throw new Error("handler always explodes");148+ if (options.failFirst?.onSynced && !failed) {149+ failed = true;150+ throw new Error("handler exploded");151+ }152+ calls.push({ handler: "onSynced", row, context });153+ },154+ onRejected: (row, context) => {155+ calls.push({ handler: "onRejected", row, context });156+ },157+ },158+ expiresAfterMs: options.expiresAfter ?? null,159+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),160+ };161+}162+163+async function flush(times = 6): Promise<void> {164+ for (let i = 0; i < times; i += 1) await Promise.resolve();165+}166+167+async function settleTimers(ms = 10): Promise<void> {168+ await new Promise((resolve) => setTimeout(resolve, ms));169+}170+171+Deno.test("a verb write progresses saved to synced and fires onSynced exactly once", async () => {172+ const calls: Array<173+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }174+ > = [];175+ const units = new Map([["chargeCard", unit("chargeCard", calls)]]);176+ const fixture = harness({ units });177+ await fixture.ledger.arm();178+ const handle = fixture.ledger.perform<Row>(179+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "one" } },180+ { verb: "placeOrder", units: [units.get("chargeCard")!] },181+ );182+ const row = await handle;183+ assert(row.title === "one", "await must resolve the created row at saved");184+ assert(handle.stage === "saved", "the write must rest at saved until the store confirms");185+ assertCount(fixture.ledger.getPendingSnapshot().count, 1, "a saved write must count as pending");186+ assert(fixture.ledger.rowStatus(row.id) === "waiting", "an unsettled row must read waiting");187+ fixture.db.settleGlobal();188+ await flush();189+ assert(String(handle.stage) === "synced", "store confirmation must reach the handle");190+ assertCount(calls.length, 1, "onSynced must fire exactly once");191+ assert(calls[0].handler === "onSynced", "confirmation must run the action handler");192+ assert(193+ calls[0].context.journalId === journalIdFor(handle.writeId, "chargeCard"),194+ "the handler must receive the (write id, effect name) idempotency key",195+ );196+ assertCount(fixture.ledger.getPendingSnapshot().count, 0, "a settled write must leave pending");197+ assert(fixture.ledger.rowStatus(row.id) === "synced", "a confirmed row must read synced");198+ await fixture.ledger.flush();199+ assert(fixture.storage.text()?.includes(handle.writeId) === false, "settled entries must prune");200+ fixture.ledger.dispose();201+});202+203+Deno.test("a synchronous local refusal throws from the call and never journals", async () => {204+ const fixture = harness();205+ await fixture.ledger.arm();206+ fixture.db.refuseNext = new Error("current policy refuses this write");207+ let thrown: Error | null = null;208+ try {209+ fixture.ledger.perform<Row>(210+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "no" } },211+ );212+ } catch (error) {213+ thrown = error as Error;214+ }215+ assert(thrown?.message === "current policy refuses this write", "the refusal must throw");216+ assertCount(fixture.ledger.getPendingSnapshot().count, 0, "a refusal must not journal");217+ await fixture.ledger.flush();218+ assert(219+ (fixture.storage.text() ?? "").includes("records") === false,220+ "a refusal must leave no journal entry",221+ );222+ assertCount(fixture.diagnostics.mutationErrors, 1, "the refusal must still be diagnosable");223+ fixture.ledger.dispose();224+});225+226+Deno.test("a permanent verdict settles rejected and fires onRejected exactly once", async () => {227+ const calls: Array<228+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }229+ > = [];230+ const units = new Map([["chargeCard", unit("chargeCard", calls)]]);231+ const fixture = harness({ units });232+ await fixture.ledger.arm();233+ const handle = fixture.ledger.perform<Row>(234+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "denied" } },235+ { verb: "placeOrder", units: [units.get("chargeCard")!] },236+ );237+ const row = await handle;238+ fixture.db.rejectGlobal(new FakeRejection("permission_denied", "policy tightened"));239+ await flush();240+ assert(handle.stage === "rejected", "a permanent verdict must settle the stage");241+ assert(handle.reason?.code === "permission_denied", "the verdict code must be readable");242+ assertCount(calls.length, 1, "onRejected must fire exactly once");243+ assert(calls[0].handler === "onRejected", "compensation must run, not the action");244+ assert(calls[0].context.fate === "rejected", "the handler must see the rejected fate");245+ assert(fixture.ledger.rowStatus(row.id) === "rejected", "the row must read rejected");246+ assertCount(fixture.ledger.getPendingSnapshot().count, 0, "a rejected write is not pending");247+ fixture.ledger.dispose();248+});249+250+Deno.test("a transient failure keeps the write pending and never compensates", async () => {251+ const calls: Array<252+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }253+ > = [];254+ const units = new Map([["chargeCard", unit("chargeCard", calls)]]);255+ const fixture = harness({ units });256+ await fixture.ledger.arm();257+ const handle = fixture.ledger.perform<Row>(258+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "slow" } },259+ { units: [units.get("chargeCard")!] },260+ );261+ await handle;262+ fixture.db.rejectGlobal(new FakeRejection("mystery_code", "unknown"));263+ await flush();264+ assert(handle.stage === "saved", "an uninterpretable code must keep the write pending");265+ assertCount(calls.length, 0, "no compensation on a non-permanent code");266+ assertCount(fixture.ledger.getPendingSnapshot().count, 1, "the write must stay pending");267+ // The scheduled re-derivation asks the store again; confirm it settles.268+ await settleTimers();269+ fixture.db.settleGlobal(1);270+ await flush();271+ assert(String(handle.stage) === "synced", "a later confirmation must settle the pending write");272+ assertCount(calls.filter((call) => call.handler === "onSynced").length, 1, "then onSynced runs");273+ fixture.ledger.dispose();274+});275+276+Deno.test("reload re-arm: journaled obligations fire after the journal is reloaded", async () => {277+ const storage = createMemoryJournalStorage();278+ // First run: the write settles as synced, but the unit is not registered279+ // yet (its declaring module never loaded), so the obligation stays pending.280+ const first = harness({ storage });281+ await first.ledger.arm();282+ const handle = first.ledger.perform<Row>(283+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "kept" } },284+ {285+ verb: "placeOrder",286+ units: [{ effectName: "notify", handlers: {} } as EffectUnit<{ id: string }>],287+ },288+ );289+ await handle;290+ first.db.settleGlobal();291+ await flush();292+ await first.ledger.flush();293+ first.ledger.dispose();294+ assert(295+ (storage.text() ?? "").includes(handle.writeId),296+ "the unsatisfied obligation must survive in the persisted journal",297+ );298+299+ const calls: Array<300+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }301+ > = [];302+ const units = new Map([["notify", unit("notify", calls)]]);303+ const second = harness({ storage, units });304+ await second.ledger.arm();305+ await flush();306+ assertCount(calls.length, 1, "the reloaded runtime must re-run the journaled obligation");307+ assert(calls[0].handler === "onSynced", "the synced fate must run the action handler");308+ assert(309+ calls[0].context.journalId === journalIdFor(handle.writeId, "notify"),310+ "the idempotency key must survive the reload",311+ );312+ await second.ledger.flush();313+ assert((second.storage.text() ?? "").includes(handle.writeId) === false, "then the entry prunes");314+ second.ledger.dispose();315+});316+317+Deno.test("boot reconciliation confirms journaled writes against store state", async () => {318+ const calls: Array<319+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }320+ > = [];321+ const units = new Map([["celebrate", unit("celebrate", calls)]]);322+ const document: JournalDocument = emptyJournal();323+ document.writes["w-confirmed"] = {324+ writeId: "w-confirmed",325+ verb: "addTask",326+ table: "records",327+ op: "insert",328+ rowId: "row-a",329+ batchId: "batch-a",330+ rowHashes: { title: hashJournalValue(document.hashKey, "kept") },331+ stage: "saved",332+ cause: null,333+ code: null,334+ reason: null,335+ createdAt: 1,336+ expiresAt: null,337+ effects: {338+ celebrate: { status: "pending", attempts: 0, lastError: null, expiresAt: null },339+ },340+ };341+ document.writes["w-waiting"] = {342+ writeId: "w-waiting",343+ verb: null,344+ table: "records",345+ op: "insert",346+ rowId: "row-b",347+ batchId: "batch-b",348+ rowHashes: { title: hashJournalValue(document.hashKey, "not there yet") },349+ stage: "saved",350+ cause: null,351+ code: null,352+ reason: null,353+ createdAt: 2,354+ expiresAt: null,355+ effects: {},356+ };357+ const storage = createMemoryJournalStorage(JSON.stringify(document));358+ const fixture = harness({ storage, units });359+ fixture.db.rows = [{ id: "row-a", title: "kept" }];360+ await fixture.ledger.arm();361+ await flush();362+ await settleTimers();363+ assert(364+ fixture.ledger.rowStatus("row-a") === "synced",365+ "a journaled write the store confirms must settle as synced at boot",366+ );367+ assertCount(calls.length, 1, "the re-armed effect must fire after the store confirms");368+ assert(369+ fixture.ledger.rowStatus("row-b") === "waiting",370+ "a journaled write the store does not confirm must stay pending",371+ );372+ assertCount(fixture.ledger.getPendingSnapshot().count, 1, "only the unconfirmed write pends");373+ fixture.ledger.dispose();374+});375+376+Deno.test("boot reconciliation applies replayed verdicts to journaled writes", async () => {377+ const calls: Array<378+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }379+ > = [];380+ const units = new Map([["compensate", unit("compensate", calls)]]);381+ const document: JournalDocument = emptyJournal();382+ document.writes["w-denied"] = {383+ writeId: "w-denied",384+ verb: "addTask",385+ table: "records",386+ op: "insert",387+ rowId: "row-x",388+ batchId: "batch-x",389+ rowHashes: { title: hashJournalValue(document.hashKey, "denied") },390+ stage: "saved",391+ cause: null,392+ code: null,393+ reason: null,394+ createdAt: 1,395+ expiresAt: null,396+ effects: {397+ compensate: { status: "pending", attempts: 0, lastError: null, expiresAt: null },398+ },399+ };400+ const storage = createMemoryJournalStorage(JSON.stringify(document));401+ const fixture = harness({ storage, units });402+ await fixture.ledger.arm();403+ fixture.db.emit(404+ {405+ code: "permission_denied",406+ reason: "policy tightened while offline",407+ batch: { batchId: "batch-x" },408+ } as unknown as MutationErrorEvent,409+ );410+ await flush();411+ assertCount(calls.length, 1, "the replayed verdict must run compensation once");412+ assert(calls[0].handler === "onRejected", "a permanent verdict runs onRejected");413+ assert(fixture.ledger.rowStatus("row-x") === "rejected", "the row must read rejected");414+ fixture.db.emit(415+ {416+ code: "permission_denied",417+ reason: "duplicate replay",418+ batch: { batchId: "batch-x" },419+ } as unknown as MutationErrorEvent,420+ );421+ await flush();422+ assertCount(calls.length, 1, "a duplicate verdict must be recorded idempotently");423+ fixture.ledger.dispose();424+});425+426+Deno.test("an unknown replayed code keeps the journaled write pending", async () => {427+ const document: JournalDocument = emptyJournal();428+ document.writes["w-odd"] = {429+ writeId: "w-odd",430+ verb: null,431+ table: "records",432+ op: "update",433+ rowId: "row-y",434+ batchId: "batch-y",435+ rowHashes: { title: hashJournalValue(document.hashKey, "edited") },436+ stage: "saved",437+ cause: null,438+ code: null,439+ reason: null,440+ createdAt: 1,441+ expiresAt: null,442+ effects: {},443+ };444+ const storage = createMemoryJournalStorage(JSON.stringify(document));445+ const fixture = harness({ storage });446+ await fixture.ledger.arm();447+ fixture.db.emit(448+ {449+ code: "mystery",450+ reason: "unclassifiable",451+ batch: { batchId: "batch-y" },452+ } as unknown as MutationErrorEvent,453+ );454+ await flush();455+ assert(456+ fixture.ledger.rowStatus("row-y") === "waiting",457+ "an uninterpretable code must never settle or compensate a write",458+ );459+ fixture.ledger.dispose();460+});461+462+Deno.test("a remove is confirmed at boot by the row's absence", async () => {463+ const document: JournalDocument = emptyJournal();464+ document.writes["w-gone"] = {465+ writeId: "w-gone",466+ verb: null,467+ table: "records",468+ op: "remove",469+ rowId: "row-z",470+ batchId: "batch-z",471+ rowHashes: {},472+ stage: "saved",473+ cause: null,474+ code: null,475+ reason: null,476+ createdAt: 1,477+ expiresAt: null,478+ effects: {},479+ };480+ const storage = createMemoryJournalStorage(JSON.stringify(document));481+ const fixture = harness({ storage });482+ fixture.db.rows = [];483+ await fixture.ledger.arm();484+ await flush();485+ await settleTimers();486+ assertCount(fixture.ledger.getPendingSnapshot().count, 0, "the absent row confirms the remove");487+ fixture.ledger.dispose();488+});489+490+Deno.test("effect units are independent: one failing handler never blocks another", async () => {491+ const calls: Array<492+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }493+ > = [];494+ const units = new Map<string, EffectUnit<{ id: string }>>([495+ ["fragile", unit("fragile", calls, { failFirst: { onSynced: true } })],496+ ["sturdy", unit("sturdy", calls)],497+ ]);498+ const fixture = harness({ units });499+ await fixture.ledger.arm();500+ const handle = fixture.ledger.perform<Row>(501+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "both" } },502+ { units: [units.get("fragile")!, units.get("sturdy")!] },503+ );504+ await handle;505+ fixture.db.settleGlobal();506+ await flush();507+ assertCount(508+ calls.filter((call) => call.handler === "onSynced").length,509+ 1,510+ "the sturdy unit must run even though the fragile one failed",511+ );512+ assertCount(fixture.diagnostics.effectHandlerFailures, 1, "the failure must be diagnosable");513+ await fixture.ledger.flush();514+ assert(515+ (fixture.storage.text() ?? "").includes("fragile"),516+ "the failed obligation must stay journaled so the next boot re-arms it",517+ );518+ fixture.ledger.dispose();519+});520+521+Deno.test("without configured sync, local durability is settlement and effects run", async () => {522+ const calls: Array<523+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }524+ > = [];525+ const units = new Map([["local-note", unit("local-note", calls)]]);526+ const fixture = harness({ syncConfigured: false, units });527+ await fixture.ledger.arm();528+ const handle = fixture.ledger.perform<Row>(529+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "solo" } },530+ { units: [units.get("local-note")!] },531+ );532+ await handle;533+ await flush();534+ assert(handle.stage === "synced", "a device without a sync location settles at saved");535+ assertCount(calls.length, 1, "effects must run when the write settles");536+ assertCount(fixture.ledger.getPendingSnapshot().count, 0, "nothing waits without a store");537+ fixture.ledger.dispose();538+});539+540+Deno.test("a write-guard refusal fails the handle and never journals or compensates", async () => {541+ const calls: Array<542+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }543+ > = [];544+ const units = new Map([["chargeCard", unit("chargeCard", calls)]]);545+ const fixture = harness({546+ units,547+ guardWrite: () => Promise.reject(new Error("schema is ahead of this bundle")),548+ });549+ await fixture.ledger.arm();550+ const handle = fixture.ledger.perform<Row>(551+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "held" } },552+ { verb: "placeOrder", units: [units.get("chargeCard")!] },553+ );554+ const error = await handle.saved.then(() => null, (thrown) => thrown as Error);555+ assert(556+ error?.message === "schema is ahead of this bundle",557+ "the guard refusal must surface through the verb call",558+ );559+ assert(handle.stage !== "rejected", "a refusal is not an adjudicated verdict");560+ assertCount(fixture.ledger.getPendingSnapshot().count, 0, "a refused write must not journal");561+ await fixture.ledger.flush();562+ assert(563+ (fixture.storage.text() ?? "").includes("held") === false,564+ "a refused write must leave no journal entry",565+ );566+ assertCount(calls.length, 0, "a refused write must fire no effect");567+ assertCount(fixture.diagnostics.mutationErrors, 1, "the refusal must be diagnosable");568+ assertCount(fixture.db.nextId, 0, "the write must never reach the database");569+ fixture.ledger.dispose();570+});571+572+Deno.test("the write-guard release covers exactly the local-durability window", async () => {573+ let released = 0;574+ const fixture = harness({575+ guardWrite: () => Promise.resolve(() => void (released += 1)),576+ });577+ await fixture.ledger.arm();578+ const handle = fixture.ledger.perform<Row>(579+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "locked" } },580+ );581+ await handle;582+ await flush();583+ assertCount(released, 1, "the lock must release once the write settles locally");584+ fixture.db.settleGlobal();585+ await flush();586+ assertCount(released, 1, "global settlement must not release the lock again");587+ fixture.ledger.dispose();588+});589+590+Deno.test("the serialized journal holds hashes, never plaintext column values", async () => {591+ const fixture = harness();592+ await fixture.ledger.arm();593+ const handle = fixture.ledger.perform<Row>(594+ {595+ kind: "insert",596+ table: table as TableProxy<unknown, unknown>,597+ values: { title: "SECRET-VALUE-XYZ" },598+ },599+ );600+ await handle;601+ await fixture.ledger.flush();602+ const persisted = fixture.storage.text() ?? "";603+ assert(persisted.includes(handle.writeId), "the pending write must be journaled");604+ assert(605+ persisted.includes("SECRET-VALUE-XYZ") === false,606+ "no plaintext column value may reach the persisted journal",607+ );608+ assert(persisted.includes("rowHashes"), "the equality-probe hashes must be journaled");609+ fixture.ledger.dispose();610+});611+612+Deno.test("boot compensation receives write identity only; live handlers keep the row", async () => {613+ const calls: Array<614+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }615+ > = [];616+ const units = new Map([["compensate", unit("compensate", calls)]]);617+ const document: JournalDocument = emptyJournal();618+ document.writes["w-back"] = {619+ writeId: "w-back",620+ verb: "placeOrder",621+ table: "records",622+ op: "insert",623+ rowId: "row-q",624+ batchId: "batch-q",625+ rowHashes: { title: hashJournalValue(document.hashKey, "gone") },626+ stage: "saved",627+ cause: null,628+ code: null,629+ reason: null,630+ createdAt: 1,631+ expiresAt: null,632+ effects: {633+ compensate: { status: "pending", attempts: 0, lastError: null, expiresAt: null },634+ },635+ };636+ const storage = createMemoryJournalStorage(JSON.stringify(document));637+ const fixture = harness({ storage, units });638+ await fixture.ledger.arm();639+ fixture.db.emit(640+ {641+ code: "permission_denied",642+ reason: "tightened while away",643+ batch: { batchId: "batch-q" },644+ } as unknown as MutationErrorEvent,645+ );646+ await flush();647+ assertCount(calls.length, 1, "the replayed verdict must run compensation");648+ assert(649+ Object.keys(calls[0].row).join(",") === "id" && calls[0].row.id === "row-q",650+ "after a reload the journal holds no values: compensation receives identity only",651+ );652+ assert(calls[0].context.cause === "denied", "the structured cause must reach the handler");653+ fixture.ledger.dispose();654+});655+656+Deno.test("a synced obligation re-armed at boot receives the live store row", async () => {657+ const calls: Array<658+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }659+ > = [];660+ const units = new Map([["celebrate", unit("celebrate", calls)]]);661+ const document: JournalDocument = emptyJournal();662+ document.writes["w-live"] = {663+ writeId: "w-live",664+ verb: "addTask",665+ table: "records",666+ op: "insert",667+ rowId: "row-l",668+ batchId: "batch-l",669+ rowHashes: { title: hashJournalValue(document.hashKey, "kept") },670+ stage: "synced",671+ cause: null,672+ code: null,673+ reason: null,674+ createdAt: 1,675+ expiresAt: null,676+ effects: {677+ celebrate: { status: "pending", attempts: 0, lastError: null, expiresAt: null },678+ },679+ };680+ const storage = createMemoryJournalStorage(JSON.stringify(document));681+ const fixture = harness({ storage, units });682+ fixture.db.rows = [{ id: "row-l", title: "kept" }];683+ await fixture.ledger.arm();684+ await flush();685+ assertCount(calls.length, 1, "the re-armed obligation must run");686+ assert(687+ (calls[0].row as { title?: string }).title === "kept",688+ "a synced write's handler must receive the live row the store confirmed",689+ );690+ fixture.ledger.dispose();691+});692+693+Deno.test("an overdue intent surfaces in pending state and diagnostics without compensation", async () => {694+ const calls: Array<695+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }696+ > = [];697+ const units = new Map([["chargeCard", unit("chargeCard", calls)]]);698+ const fixture = harness({ units, sweepIntervalMs: 5 });699+ await fixture.ledger.arm();700+ const handle = fixture.ledger.perform<Row>(701+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "slow" } },702+ { verb: "placeOrder", units: [units.get("chargeCard")!], expiresMs: 50 },703+ );704+ await handle;705+ assert(706+ fixture.ledger.getPendingSnapshot().writes[0]?.expired === false,707+ "an intent inside its lifespan is not overdue",708+ );709+ fixture.clock.value = 2000;710+ await settleTimers(20);711+ const snapshot = fixture.ledger.getPendingSnapshot();712+ assert(snapshot.writes[0]?.expired === true, "the overdue intent must surface");713+ assertCount(fixture.diagnostics.expiredPendingWrites, 1, "diagnostics must count it");714+ assert(handle.stage === "saved", "no withdrawal exists, so the stage must not change");715+ assertCount(calls.length, 0, "no compensation may fire for a write that can still sync");716+ // The store later confirms the overdue write: it settles normally.717+ fixture.db.settleGlobal();718+ await flush();719+ assert(String(handle.stage) === "synced", "a late confirmation still settles the write");720+ fixture.ledger.dispose();721+});722+723+Deno.test("a closed delivery window retires the obligation: no handler, diagnosable, prunable", async () => {724+ const calls: Array<725+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }726+ > = [];727+ const units = new Map([["notifyOnce", unit("notifyOnce", calls, { expiresAfter: 30 })]]);728+ const fixture = harness({ units });729+ await fixture.ledger.arm();730+ const handle = fixture.ledger.perform<Row>(731+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "late" } },732+ { units: [units.get("notifyOnce")!] },733+ );734+ await handle;735+ // The device stays away past the delivery window; the write then syncs.736+ fixture.clock.value = 5000;737+ fixture.db.settleGlobal();738+ await flush();739+ assert(String(handle.stage) === "synced", "the write itself settled");740+ assertCount(calls.length, 0, "the write happened, so no handler may fire past the window");741+ assertCount(fixture.diagnostics.expiredObligations, 1, "the retirement must be diagnosable");742+ await fixture.ledger.flush();743+ assert(744+ (fixture.storage.text() ?? "").includes(handle.writeId) === false,745+ "a retired obligation makes the entry prunable",746+ );747+ fixture.ledger.dispose();748+});749+750+Deno.test("quarantine retires a permanently failing handler after its attempt bound", async () => {751+ const storage = createMemoryJournalStorage();752+ const calls: Array<753+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }754+ > = [];755+ const failing = unit("fragile", calls, { failAlways: true, maxAttempts: 2 });756+ const first = harness({ storage, units: new Map([["fragile", failing]]) });757+ await first.ledger.arm();758+ const handle = first.ledger.perform<Row>(759+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "boom" } },760+ { units: [failing] },761+ );762+ await handle;763+ first.db.settleGlobal();764+ await flush();765+ await first.ledger.flush();766+ assertCount(first.diagnostics.effectHandlerFailures, 1, "the first failure is counted");767+ assert((storage.text() ?? "").includes("fragile"), "the failed obligation re-arms at boot");768+ first.ledger.dispose();769+770+ const second = harness({ storage, units: new Map([["fragile", failing]]) });771+ second.db.rows = [];772+ await second.ledger.arm();773+ await flush();774+ assertCount(775+ second.diagnostics.quarantinedObligations,776+ 1,777+ "the attempt bound must quarantine the obligation",778+ );779+ await second.ledger.flush();780+ assert(781+ (second.storage.text() ?? "").includes(handle.writeId) === false,782+ "a quarantined obligation makes the entry prunable",783+ );784+ assertCount(calls.length, 0, "a quarantined handler never reports success");785+ second.ledger.dispose();786+});
A package/schema/effects.ts +378 -0
@@ -0,0 +1,378 @@
1+/// <reference path="../runtime/env.d.ts" />2+/**3+ * Author-declared verbs and effect units.4+ *5+ * Tables are the nouns of an application: they declare what exists, and6+ * policies attach to them because a policy must be safe to evaluate on every7+ * replica. Mutations are the verbs: they declare what happens, and8+ * consequences attach to them because a consequence needs an owner — the9+ * device that performed the intent. This module is the declarative half of10+ * that grammar: {@link effect} pairs an action with its compensation under a11+ * durable name, {@link mutation} binds effect units to a named verb once, at12+ * declaration, so call sites carry nothing. The runtime half journals each13+ * unit per write and fires handlers when the write's fate settles.14+ *15+ * This module stays authoring-only: it declares names, tables, and handlers,16+ * and delegates every write to the runtime installed through17+ * {@link setMutationRuntime}.18+ *19+ * @module20+ */21+22+import type { TableProxy } from "jazz-tools";23+import type { WriteHandle } from "../runtime/write-handle.ts";24+25+/**26+ * The row snapshot an effect handler receives: always the row id, plus the27+ * journaled columns — the full row for inserts, the changed columns for28+ * updates, and only the id for removes.29+ */30+export type EffectRow<Row> = Partial<Row> & { id: string };31+32+/**33+ * Delivery metadata passed to every effect handler. Delivery is34+ * at-least-once: a crash between handler start and journal completion re-runs35+ * the handler at the next boot, so handlers calling external services should36+ * pass {@link EffectContext.journalId} as an idempotency key.37+ */38+export type EffectContext = {39+ /** The `(write id, effect name)` idempotency key for this obligation. */40+ journalId: string;41+ /** The journaled write's stable id. */42+ writeId: string;43+ /** The declaring verb's name, or `null` for writes without a verb. */44+ verb: string | null;45+ /** The written table's name. */46+ table: string;47+ /** The written row's id. */48+ rowId: string;49+ /** Which fate settled the write. */50+ fate: "synced" | "rejected";51+ /**52+ * Why a `rejected` write settled: `denied` for a store verdict, `expired`53+ * for an intent retired past its lifespan; `null` on the `synced` fate.54+ */55+ cause: "denied" | "expired" | null;56+ /** The adjudicated rejection code, on the `rejected` fate. */57+ code: string | null;58+ /** The adjudicated rejection reason, on the `rejected` fate. */59+ reason: string | null;60+};61+62+/** The action and compensation handlers one effect unit pairs. */63+export type EffectHandlers<Row> = {64+ /** Runs on the originating device once the store confirms the write. */65+ onSynced?: (row: EffectRow<Row>, context: EffectContext) => void | Promise<void>;66+ /**67+ * Runs on the originating device when the store adjudicates the write and68+ * denies it permanently. Compensates optimistic state that was shown to the69+ * user; a synchronous local refusal never fires it.70+ */71+ onRejected?: (row: EffectRow<Row>, context: EffectContext) => void | Promise<void>;72+};73+74+/**75+ * Retention options for one effect unit: how long delivery may lag the write,76+ * and how many failing attempts are made before quarantine.77+ */78+export type EffectUnitOptions = {79+ /**80+ * The delivery window in milliseconds, measured from the write. When the81+ * write synced but this obligation could not be delivered inside the82+ * window, the obligation retires as expired and no handler fires — the83+ * write happened, so compensation would be wrong. External-tier handlers84+ * whose receivers keep finite idempotency windows should set this.85+ */86+ expiresAfter?: number;87+ /** Failing attempts before quarantine retires the obligation. Default 5. */88+ maxAttempts?: number;89+};90+91+/**92+ * A named, reusable pairing of action and compensation. The name is the93+ * durable identity the journal uses to re-arm handlers after a reload; a94+ * mutation declares its units once, at the verb declaration.95+ */96+export type EffectUnit<Row = { id: string }> = {97+ /** The app-unique durable name of this unit. */98+ readonly effectName: string;99+ /** The unit's handlers. */100+ readonly handlers: EffectHandlers<Row>;101+ /** The delivery window in milliseconds, or `null` for none. */102+ readonly expiresAfterMs?: number | null;103+ /** Failing attempts before quarantine retires the obligation. */104+ readonly maxAttempts?: number;105+};106+107+/** Which operation a {@link MutationOp} performs. */108+export type MutationOpKind = "insert" | "update" | "remove";109+110+/** A table operation a verb is declared over; see {@link insert}, {@link update}, {@link remove}. */111+export type MutationOp<T, Init, Kind extends MutationOpKind = MutationOpKind> = {112+ /** The operation kind. */113+ readonly kind: Kind;114+ /** The declared table the verb writes. */115+ readonly table: TableProxy<T, Init>;116+};117+118+/** Declares that a verb inserts rows into `table`. */119+export function insert<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "insert"> {120+ return { kind: "insert", table };121+}122+123+/** Declares that a verb updates rows of `table`. */124+export function update<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "update"> {125+ return { kind: "update", table };126+}127+128+/** Declares that a verb removes rows from `table`. */129+export function remove<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "remove"> {130+ return { kind: "remove", table };131+}132+133+/** A callable verb declared over an insert operation. */134+export type InsertVerb<T, Init> = (values: Init) => WriteHandle<T>;135+/** A callable verb declared over an update operation. */136+export type UpdateVerb<Init> = (id: string, patch: Partial<Init>) => WriteHandle<void>;137+/** A callable verb declared over a remove operation. */138+export type RemoveVerb = (id: string) => WriteHandle<void>;139+140+/** The callable shape of a declared verb, derived from its operation. */141+export type MutationVerb<Op> = Op extends MutationOp<infer T, infer Init, "insert">142+ ? InsertVerb<T, Init>143+ : Op extends MutationOp<infer _T, infer Init, "update"> ? UpdateVerb<Init>144+ : Op extends MutationOp<infer _T, infer _Init, "remove"> ? RemoveVerb145+ : never;146+147+/** Options accepted by {@link mutation}: declared units plus inline sugar. */148+export type MutationOptions<Row> = {149+ /** The effect units this verb carries; declared once, reused by every call site. */150+ effects?: readonly EffectUnit<Row>[];151+ /** Inline sugar for an implicit single unit named after the verb. */152+ onSynced?: EffectHandlers<Row>["onSynced"];153+ /** Inline sugar for an implicit single unit named after the verb. */154+ onRejected?: EffectHandlers<Row>["onRejected"];155+ /**156+ * The intent's lifespan in milliseconds. A write of this verb still pending157+ * past its lifespan is surfaced through pending-writes state and158+ * diagnostics. The engine offers no withdrawal for a locally accepted write159+ * at the pinned runtime, so an overdue intent is reported, never retired:160+ * retiring it as rejected could fire compensation for a write that later161+ * syncs anyway.162+ */163+ expires?: number;164+};165+166+/** The registered declaration the runtime dispatches for one verb. */167+export type MutationDescriptor = {168+ /** The verb's app-unique name. */169+ readonly verbName: string;170+ /** The operation the verb performs. */171+ readonly op: MutationOp<unknown, unknown>;172+ /** The verb's effect units, implicit unit included. */173+ readonly units: readonly EffectUnit<{ id: string }>[];174+ /** The intent's lifespan in milliseconds, or `null` for none. */175+ readonly expiresMs: number | null;176+};177+178+/** The runtime half installed by the package runtime before verbs are called. */179+export type MutationRuntime = {180+ /** Performs one declared verb call and returns its write handle. */181+ dispatch(descriptor: MutationDescriptor, args: readonly unknown[]): WriteHandle<unknown>;182+ /** Records one structured {@link log} entry. */183+ recordLog(label: string, context: EffectContext): void;184+ /** Re-attempts outstanding journal obligations after a late unit registration. */185+ unitRegistered?(name: string): void;186+};187+188+type EffectsSlot = {189+ runtime: MutationRuntime | null;190+ effects: Map<string, EffectUnit<{ id: string }>>;191+ verbs: Map<string, MutationDescriptor>;192+ logs: Map<string, EffectUnit<{ id: string }>>;193+};194+195+const slotName = "__LOFI_EFFECT_DECLARATIONS__";196+const effectsGlobal = globalThis as typeof globalThis & { [slotName]?: EffectsSlot };197+198+function slot(): EffectsSlot {199+ effectsGlobal[slotName] ??= {200+ runtime: null,201+ effects: new Map(),202+ verbs: new Map(),203+ logs: new Map(),204+ };205+ return effectsGlobal[slotName];206+}207+208+/** Installed by the package runtime; author code never calls this. */209+export function setMutationRuntime(runtime: MutationRuntime): void {210+ slot().runtime = runtime;211+}212+213+/** Resolves a registered effect unit by its durable name, for the runtime. */214+export function resolveEffectUnit(name: string): EffectUnit<{ id: string }> | null {215+ return slot().effects.get(name) ?? null;216+}217+218+/** Clears every declaration; deterministic-test seam. */219+export function clearEffectDeclarations(): void {220+ const state = slot();221+ state.effects.clear();222+ state.verbs.clear();223+ state.logs.clear();224+}225+226+// During dev hot replacement author modules re-evaluate routinely; the newest227+// declaration replaces its predecessor under the same durable name. Outside228+// dev, a duplicate name is a real collision and fails fast.229+const hotReplacement = Boolean(import.meta.hot);230+231+function registerUnit(unit: EffectUnit<{ id: string }>): void {232+ const state = slot();233+ const existing = state.effects.get(unit.effectName);234+ if (existing && existing !== unit && !hotReplacement) {235+ throw new Error(236+ `effect name "${unit.effectName}" is already declared; effect names are the journal's ` +237+ `durable identities and must be unique per app`,238+ );239+ }240+ state.effects.set(unit.effectName, unit);241+ // A unit declared after boot may satisfy obligations journaled before the242+ // reload; the runtime re-attempts them on registration.243+ state.runtime?.unitRegistered?.(unit.effectName);244+}245+246+/**247+ * Declares a named, reusable, typed effect unit over one table.248+ *249+ * The name is durable: the journal re-arms this unit's handlers by name after250+ * a reload, so renaming a unit orphans obligations journaled under the old251+ * name. Names must be unique per app; a duplicate declaration throws.252+ *253+ * @example254+ * ```ts255+ * const chargeCard = s.effect("chargeCard", app.schema.orders, {256+ * onSynced: async (order, { journalId }) => {257+ * // The world accepted the order. journalId is the idempotency key.258+ * },259+ * onRejected: async (order) => {260+ * // The order never happened: compensate what the user saw.261+ * },262+ * });263+ * ```264+ */265+export function effect<T extends { id: string }, Init>(266+ name: string,267+ table: TableProxy<T, Init>,268+ handlers: EffectHandlers<T>,269+ options: EffectUnitOptions = {},270+): EffectUnit<T> {271+ if (!name.trim()) throw new Error("effect name must not be empty");272+ void table;273+ const unit: EffectUnit<T> = {274+ effectName: name,275+ handlers,276+ expiresAfterMs: options.expiresAfter ?? null,277+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),278+ };279+ registerUnit(unit as EffectUnit<{ id: string }>);280+ return unit;281+}282+283+/**284+ * A built-in effect unit that records a structured entry in runtime285+ * diagnostics on either fate. Repeated calls with one label return the same286+ * unit, so a label can be shared by several verbs.287+ */288+export function log(label: string): EffectUnit<{ id: string }> {289+ if (!label.trim()) throw new Error("log label must not be empty");290+ const state = slot();291+ const name = `log:${label}`;292+ let unit = state.logs.get(name);293+ if (!unit) {294+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {295+ requireRuntime().recordLog(label, context);296+ };297+ unit = { effectName: name, handlers: { onSynced: record, onRejected: record } };298+ state.logs.set(name, unit);299+ registerUnit(unit);300+ }301+ return unit;302+}303+304+function requireRuntime(): MutationRuntime {305+ const runtime = slot().runtime;306+ if (!runtime) {307+ throw new Error(308+ "the lofi runtime is not installed; import @nzip/lofi (or @nzip/lofi/preact) in the " +309+ "module that calls this verb",310+ );311+ }312+ return runtime;313+}314+315+/**316+ * Declares a typed, callable verb: a named mutation over one table operation,317+ * carrying its effect units. Call sites invoke the verb like a function and318+ * receive a `WriteHandle`; `await` resolves at `saved`.319+ *320+ * Verb names are app-unique and durable — the journal attributes re-armed321+ * obligations through them — and read as imperative verb phrases322+ * (`placeOrder`, not `orderInsert`). Inline `onSynced`/`onRejected` are sugar323+ * for an implicit single unit named after the verb.324+ *325+ * @example326+ * ```ts327+ * export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {328+ * effects: [chargeCard, s.log("order-placed")],329+ * });330+ *331+ * const write = placeOrder({ item, qty }); // WriteHandle<Order>332+ * await write; // saved: durable on this device333+ * await write.synced; // confirmed by the store334+ * ```335+ */336+export function mutation<T extends { id: string }, Init, Kind extends MutationOpKind>(337+ name: string,338+ op: MutationOp<T, Init, Kind>,339+ options: MutationOptions<T> = {},340+): MutationVerb<MutationOp<T, Init, Kind>> {341+ if (!name.trim()) throw new Error("mutation name must not be empty");342+ const state = slot();343+ const existing = state.verbs.get(name);344+ if (existing && !hotReplacement) {345+ throw new Error(`mutation name "${name}" is already declared; verb names are unique per app`);346+ }347+ const units: EffectUnit<{ id: string }>[] = [];348+ const seen = new Set<string>();349+ for (const unit of options.effects ?? []) {350+ if (seen.has(unit.effectName)) {351+ throw new Error(`mutation "${name}" declares effect "${unit.effectName}" twice`);352+ }353+ seen.add(unit.effectName);354+ units.push(unit as EffectUnit<{ id: string }>);355+ }356+ if (options.onSynced || options.onRejected) {357+ if (seen.has(name)) {358+ throw new Error(359+ `mutation "${name}" cannot combine inline handlers with an effect unit named "${name}"`,360+ );361+ }362+ const implicit: EffectUnit<T> = {363+ effectName: name,364+ handlers: { onSynced: options.onSynced, onRejected: options.onRejected },365+ };366+ registerUnit(implicit as EffectUnit<{ id: string }>);367+ units.push(implicit as EffectUnit<{ id: string }>);368+ }369+ const descriptor: MutationDescriptor = {370+ verbName: name,371+ op: op as MutationOp<unknown, unknown>,372+ units,373+ expiresMs: options.expires ?? null,374+ };375+ state.verbs.set(name, descriptor);376+ const verb = (...args: readonly unknown[]) => requireRuntime().dispatch(descriptor, args);377+ return verb as MutationVerb<MutationOp<T, Init, Kind>>;378+}
A package/schema/effects_test.ts +157 -0
@@ -0,0 +1,157 @@
1+import { schema } from "jazz-tools";2+import { assert, assertCount } from "../runtime/test-assert.ts";3+import type { WriteHandle } from "../runtime/write-handle.ts";4+import {5+ clearEffectDeclarations,6+ type EffectContext,7+ type MutationDescriptor,8+ s,9+ setMutationRuntime,10+} from "./mod.ts";11+12+const app = schema.defineApp({13+ orders: schema.table({ item: schema.string(), qty: schema.int() }),14+});15+type Order = schema.RowOf<typeof app.orders>;16+17+type Dispatched = { descriptor: MutationDescriptor; args: readonly unknown[] };18+19+function installRecorder(): { dispatched: Dispatched[]; logs: string[] } {20+ const dispatched: Dispatched[] = [];21+ const logs: string[] = [];22+ setMutationRuntime({23+ dispatch(descriptor, args) {24+ dispatched.push({ descriptor, args });25+ return { stage: "saving" } as unknown as WriteHandle<unknown>;26+ },27+ recordLog(label) {28+ logs.push(label);29+ },30+ });31+ return { dispatched, logs };32+}33+34+Deno.test("verbs carry their declared units and dispatch call-site arguments", () => {35+ clearEffectDeclarations();36+ const recorder = installRecorder();37+ const chargeCard = s.effect("chargeCard", app.orders, {38+ onSynced: (order: Partial<Order> & { id: string }) => void order.id,39+ });40+ const placeOrder = s.mutation("placeOrder", s.insert(app.orders), {41+ effects: [chargeCard, s.log("order-placed")],42+ expires: 60_000,43+ });44+ placeOrder({ item: "tea", qty: 2 });45+ assertCount(recorder.dispatched.length, 1, "the verb call must reach the dispatcher");46+ const { descriptor, args } = recorder.dispatched[0];47+ assert(descriptor.verbName === "placeOrder", "the verb name must reach the runtime");48+ assert(descriptor.op.kind === "insert", "the operation kind must reach the runtime");49+ assert(50+ descriptor.units.map((unit) => unit.effectName).join(",") === "chargeCard,log:order-placed",51+ "declared units must ride the descriptor in declaration order",52+ );53+ assert(54+ (args[0] as { item: string }).item === "tea",55+ "call-site values must be forwarded untouched",56+ );57+ assert(descriptor.expiresMs === 60_000, "the intent lifespan must ride the descriptor");58+ clearEffectDeclarations();59+});60+61+Deno.test("inline handlers are sugar for an implicit unit named after the verb", () => {62+ clearEffectDeclarations();63+ const recorder = installRecorder();64+ const completeTask = s.mutation("completeTask", s.update(app.orders), {65+ onSynced: () => undefined,66+ });67+ completeTask("row-1", { qty: 3 });68+ const descriptor = recorder.dispatched[0].descriptor;69+ assertCount(descriptor.units.length, 1, "inline handlers must produce one implicit unit");70+ assert(71+ descriptor.units[0].effectName === "completeTask",72+ "the implicit unit must be named after the verb",73+ );74+ assert(75+ s.log("noise") !== descriptor.units[0],76+ "the implicit unit is registered like any other named unit",77+ );78+ clearEffectDeclarations();79+});80+81+Deno.test("effect names are unique per app and collide fast", () => {82+ clearEffectDeclarations();83+ s.effect("chargeCard", app.orders, {});84+ let thrown = false;85+ try {86+ s.effect("chargeCard", app.orders, {});87+ } catch {88+ thrown = true;89+ }90+ assert(thrown, "a duplicate effect name must fail at declaration");91+ clearEffectDeclarations();92+});93+94+Deno.test("verb names are unique per app and collide fast", () => {95+ clearEffectDeclarations();96+ s.mutation("placeOrder", s.insert(app.orders));97+ let thrown = false;98+ try {99+ s.mutation("placeOrder", s.insert(app.orders));100+ } catch {101+ thrown = true;102+ }103+ assert(thrown, "a duplicate verb name must fail at declaration");104+ clearEffectDeclarations();105+});106+107+Deno.test("s.log reuses one unit per label and records through the runtime", () => {108+ clearEffectDeclarations();109+ const recorder = installRecorder();110+ const first = s.log("order-placed");111+ const second = s.log("order-placed");112+ assert(first === second, "one label must resolve to one shared unit");113+ const context: EffectContext = {114+ journalId: "w1:log:order-placed",115+ writeId: "w1",116+ verb: "placeOrder",117+ table: "orders",118+ rowId: "row-1",119+ fate: "synced",120+ cause: null,121+ code: null,122+ reason: null,123+ };124+ first.handlers.onSynced?.({ id: "row-1" }, context);125+ first.handlers.onRejected?.({ id: "row-1" }, context);126+ assertCount(recorder.logs.length, 2, "both fates must record a structured entry");127+ clearEffectDeclarations();128+});129+130+Deno.test("verb declarations without an installed runtime fail only when called", () => {131+ clearEffectDeclarations();132+ setMutationRuntime(null as unknown as Parameters<typeof setMutationRuntime>[0]);133+ const orphan = s.mutation("orphanVerb", s.remove(app.orders));134+ let thrown = false;135+ try {136+ orphan("row-1");137+ } catch (error) {138+ thrown = (error as Error).message.includes("runtime is not installed");139+ }140+ assert(thrown, "calling a verb before the runtime installs must explain itself");141+ clearEffectDeclarations();142+});143+144+Deno.test("verb call shapes follow the declared operation", () => {145+ clearEffectDeclarations();146+ const recorder = installRecorder();147+ const add = s.mutation("addOrder", s.insert(app.orders));148+ const edit = s.mutation("editOrder", s.update(app.orders));149+ const drop = s.mutation("dropOrder", s.remove(app.orders));150+ add({ item: "tea", qty: 1 });151+ edit("row-1", { qty: 2 });152+ drop("row-1");153+ assertCount(recorder.dispatched.length, 3, "each verb call must dispatch once");154+ assert(recorder.dispatched[1].args[0] === "row-1", "update must forward the row id first");155+ assert(recorder.dispatched[2].args[0] === "row-1", "remove must forward the row id");156+ clearEffectDeclarations();157+});
M package/schema/mod.ts +37 -0
@@ -23,6 +23,7 @@
2323 * @module2424 */2525 import { schema } from "jazz-tools";26+import { effect, insert, log, mutation, remove, update } from "./effects.ts";2627 import { encryptedJson, encryptedText } from "./encrypted.ts";2728 import {2829 defineNestedApp,
@@ -76,6 +77,12 @@ export type SchemaDsl =
7677 flattenNestedSchema: typeof flattenNestedSchema;7778 encryptedText: typeof encryptedText;7879 encryptedJson: typeof encryptedJson;80+ mutation: typeof mutation;81+ effect: typeof effect;82+ log: typeof log;83+ insert: typeof insert;84+ update: typeof update;85+ remove: typeof remove;7986 };8087 8188 /**
@@ -92,6 +99,12 @@ export const s: SchemaDsl = {
9299 flattenNestedSchema,93100 encryptedText,94101 encryptedJson,102+ mutation,103+ effect,104+ log,105+ insert,106+ update,107+ remove,95108 };96109 97110 export {
@@ -112,6 +125,30 @@ export {
112125 nestedAppTables,113126 type NestedSchemaDefinition,114127 } from "./nested.ts";128+export {129+ clearEffectDeclarations,130+ effect,131+ type EffectContext,132+ type EffectHandlers,133+ type EffectRow,134+ type EffectUnit,135+ insert,136+ type InsertVerb,137+ log,138+ mutation,139+ type MutationDescriptor,140+ type MutationOp,141+ type MutationOpKind,142+ type MutationOptions,143+ type MutationRuntime,144+ type MutationVerb,145+ remove,146+ type RemoveVerb,147+ resolveEffectUnit,148+ setMutationRuntime,149+ update,150+ type UpdateVerb,151+} from "./effects.ts";115152 116153 // Store provisioning (./store.ts) is deliberately NOT re-exported here: the117154 // Jazz schema loader bundles the author's schema module graph — this facade
M package/testdata/starter.snapshot.json +2 -2
M website/docs-manifest.json +4 -0
M website/node-docs-manifest.json +5 -0