* fix(preact): sync-state quality pass — first-class blocked dispositions, typed enrollment errors, account-gate freshness The template half of #175, following the runtime half in #180. DeviceStatus names why nothing is syncing, not merely that it is not: the one-line Data sync verdict distinguishes owner mismatch, store refusals (no schema, rejected ticket), an unopenable sink record, and the absence of any sync location, with owner-mismatch copy naming the owning account when known. The no-sync hint now leads with ticket enrollment; the compiled-in env-var path is the stated alternative. TicketEnrollForm relays typed refusals verbatim — SyncEnrollmentError, SyncOwnerError, and DataSinkError carry their own remediation, and the generic paste-again line cannot fix an unprovisioned store or a foreign sync owner. AccountGate re-reads the session when the runtime is recreated, gains the owner-mismatch branch offering exactly the two remediations (stop sync releases the election, restore adopts the owner), and surfaces a definite store problem in the backed-up state. Starter mirror and snapshot regenerated. * feat(effects): built-in effect library and the custom-unit authoring contract Closes #141. Grows the #139 effect machinery into a tiered standard library and formalizes the contract for authoring custom units. Contract (effects.ts): - EffectContext gains `op` and `writeCreatedAt` so units can see the operation and measure saved→fate latency. - PermanentEffectError / isPermanentEffectError: a handler throws it to retire an obligation immediately (a hopeless failure) instead of re-arming until maxAttempts. The ledger honors the severity. - requireMutationRuntime + anonymousEffectName expose the seams the library is built on, keeping every built-in on the public authoring surface. Built-in units (schema/effect-library.ts), tiered by risk: - Observation — s.trace (saved→fate span in diagnostics, OTLP-shaped, no vendor coupling), s.debug (dev-only timeline, stripped in PROD). s.log stays in the core beside the runtime recorder. - Data-internal — s.notice (durable, UI-agnostic message queue, idempotent by journal id; closes "a rejected write flashed success"), s.mark (patch the row on fate, convergent, skips a rolled-back insert), s.chain (issue a follow-up verb on synced, no saga API). - External — s.webhook (POST row+fate with journalId as Idempotency-Key; 4xx → permanent, 5xx/429/network → retryable; default 24h delivery window). Runtime: - notice-queue.ts: durable queue (OPFS→localStorage→memory), idempotent enqueue, TTL + dismissal, subscribe; activeNotices diagnostic. - write-ledger.ts installs recordTrace/recordDebug/enqueueNotice/applyMark and owns the queue singleton; public listNotices/subscribeNotices/dismissNotice. - diagnostics.ts: effectTraces, effectDebugTimeline, activeNotices. Preact: useNotices hook + Notices component render the durable queue; a toast stack is a userland wrapper over the same hook. Docs: docs/effects.md (library reference + authoring contract + worked custom unit), teaching-path link from nouns-and-verbs, README reference entries. Tests: per-unit happy/rejection/idempotency, notice-queue idempotence/TTL/ reload, ledger permanent-failure retirement, and a custom unit built purely on the public contract. Reference app adds s.trace to addTask (dogfood); starter mirror + snapshot regenerated. Manifest updated for the three new modules. Deferred, noted for a follow-up: migrating the reference app's hand-rolled task-notice to s.notice (a focused island refactor). * fix(effects): review remediations — durable anonymous-unit identity, notice-queue boot merge, a11y Addresses findings from the multi-agent + Codex review of #184. Anonymous-unit identity (HIGH — silent mis-binding across reloads): - notice/mark/chain no longer take their durable name from a global declaration counter (module-load-order dependent, so a re-armed obligation could bind to the WRONG unit — for chain, the wrong follow-up verb). They are now named `<verb>#<position>` at mutation() time, from the author-chosen verb and their slot in its effects — an identity independent of which module loads first. - trace/debug/webhook are content-named and now share one unit per identity (cachedBuiltin), so reusing one across verbs aggregates instead of throwing a duplicate-name error. webhook keys on url+config. Notice queue: - Boot-window clobber (HIGH): load() now MERGES persisted entries with any enqueued during the async load window instead of overwriting, so a notice an effect enqueues at a boot re-arm is not silently dropped. - TTL drift (MEDIUM): list() is now a pure read; retirement happens in sweep(), wired to a periodic timer in the queue owner; the activeNotices count is computed from the live view so it never overcounts expired entries. mark best-effort (MEDIUM): applyMark swallows a denied/vanished-row patch instead of quarantine-spamming; documented that mark is a convenience over the status column, not a delivery guarantee (use notice/webhook for that). Notices a11y (MEDIUM): the aria-live region stays mounted (hidden) when empty so the first notice is announced; custom children are keyed. Tests: anonymous-unit verb-scoped naming, content-name sharing across verbs, notice boot-merge. All green; build refreshed. * fix(effects): make built-ins replay-safe * refactor(reference): adopt durable task notices (#185) * fix(sync): authenticate PoP preflights and align demo notices (#186) Use proof-of-possession connect URLs for store-status checks during enrollment and boot. Migrate the demo overlay to durable notices and improve recovery/passkey diagnostics.
- commit
- f6391267d81efbf4c36dda648a1eb91e3f5d5b03
- parent
- 0b6a3ec370e662cbe2eb5188c01fb10391b3cf18
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -2,6 +2,7 @@ import { useState } from "preact/hooks";
22 import { settleUiMutation } from "@nzip/lofi";33 import {44 type BootProgress,5+ Notices,56 useBootProgress,67 usePendingWrites,78 useSyncStatus,@@ -10,7 +11,6 @@ import {
1011 type Incident,1112 type IncidentStatus,1213 type Severity,13- useIncidentNotice,1414 useIncidents,1515 } from "./use-incidents.ts";1616 @@ -51,7 +51,6 @@ function openedLabel(value: Incident["openedAt"]): string {
5151 */5252 export default function IncidentBoard() {5353 const { status, error, durability, incidents, failureKind, report, setStatus } = useIncidents();54- const notice = useIncidentNotice();5554 const pending = usePendingWrites();5655 const boot = useBootProgress();5756 const [title, setTitle] = useState("");@@ -107,11 +106,7 @@ export default function IncidentBoard() {
107106 {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync108107 </p>109108 )}110- {notice && (111- <p class="state" role="status" data-notice={notice.kind}>112- {notice.text}113- </p>114- )}109+ <Notices label="Incident notifications" />115110 <div class="columns">116111 {COLUMNS.map((column) => {117112 const rows = incidents.filter((incident) => incident.status === column.status);@@ -1,4 +1,4 @@
1-import { useCallback, useEffect, useState } from "preact/hooks";1+import { useCallback, useState } from "preact/hooks";22 import type { RowOf, WriteHandle } from "@nzip/lofi";33 import { useLiveQuery, useWrite } from "@nzip/lofi/preact";44 import { s } from "@nzip/lofi/schema";@@ -18,57 +18,27 @@ export type Incident = RowOf<typeof incidentsTable>;
1818 export type Severity = Incident["severity"];1919 export type IncidentStatus = Incident["status"];2020 21-/** A one-line consequence or compensation surfaced to the UI. */22-export type IncidentNotice = { kind: "synced" | "rejected"; text: string };23-24-// A tiny author-owned notice channel: effect handlers run outside any25-// component, so they publish through module state and hooks subscribe.26-let notice: IncidentNotice | null = null;27-const noticeListeners = new Set<() => void>();28-29-function publishNotice(next: IncidentNotice | null): void {30- notice = next;31- for (const listener of [...noticeListeners]) listener();32-}33-3421 /**3522 * The reporting verb. Its effect units are declared once, here: the3623 * consequence runs when the store confirms the row, the compensation runs if3724 * a stale-policy write is denied, even if the app restarted in between.3825 */3926 export const reportIncident = s.mutation("reportIncident", s.insert(incidentsTable), {40- effects: [s.log("incident-reported")],41- onSynced: (incident) => {42- publishNotice({43- kind: "synced",44- text: `"${incident.title ?? "Incident"}" confirmed by the store`,45- });46- },47- onRejected: (incident) => {48- // The engine already rolled the denied row back out of local reads; this49- // compensates what the user was told.50- publishNotice({51- kind: "rejected",52- text: `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,53- });54- },27+ effects: [28+ s.log("incident-reported"),29+ s.notice<Incident>({30+ synced: (incident) => `"${incident.title ?? "Incident"}" confirmed by the store`,31+ // The engine already rolled a denied insert out of local reads; this32+ // durable notice compensates what the user was told, even after reload.33+ rejected: (incident) =>34+ `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,35+ }),36+ ],5537 });5638 5739 /** Moving an incident between states is a plain verb: same lifecycle. */5840 export const setIncidentStatus = s.mutation("setIncidentStatus", s.update(incidentsTable));5941 60-/** Subscribes to the latest effect notice; `null` until one is published. */61-export function useIncidentNotice(): IncidentNotice | null {62- const [current, setCurrent] = useState<IncidentNotice | null>(notice);63- useEffect(() => {64- const listener = () => setCurrent(notice);65- noticeListeners.add(listener);66- listener();67- return () => void noticeListeners.delete(listener);68- }, []);69- return current;70-}71-7242 export function useIncidents() {7343 const query = useLiveQuery(() => incidentsTable.orderBy("openedAt", "desc"), []);7444 const [lastWrite, setLastWrite] = useState<WriteHandle<Incident> | null>(null);@@ -50,7 +50,7 @@ function describe(error: unknown): string {
5050 if (isAuthError(error)) {5151 switch (error.code) {5252 case "cancelled":53- return "Passkey prompt dismissed — your recovery phrase was not shown.";53+ return "Passkey verification did not complete — your recovery phrase was not shown.";5454 case "unsupported":5555 return "This browser does not support passkeys.";5656 default:@@ -2,11 +2,12 @@ import { useState } from "preact/hooks";
22 import { settleUiMutation } from "@nzip/lofi";33 import {44 type BootProgress,5+ Notices,56 useBootProgress,67 usePendingWrites,78 useSyncStatus,89 } from "@nzip/lofi/preact";9-import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";10+import { type Task, useTasks } from "./use-tasks.ts";1011 1112 // A cold first visit waits on the engine download, not on storage; name the1213 // wait it is actually in, with byte progress while the download runs.@@ -26,7 +27,6 @@ function loadingLabel(boot: BootProgress): string {
2627 */2728 export default function TaskList() {2829 const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();29- const notice = useTaskNotice();3030 const pending = usePendingWrites();3131 const boot = useBootProgress();3232 const [text, setText] = useState("");@@ -78,11 +78,7 @@ export default function TaskList() {
7878 {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync7979 </p>8080 )}81- {notice && (82- <p class="state" role="status" data-notice={notice.kind}>83- {notice.text}84- </p>85- )}81+ <Notices />8682 <ul aria-label="Tasks">8783 {tasks.map((task) => <TaskItem key={task.id} task={task} setCompleted={setCompleted} />)}8884 </ul>@@ -1,4 +1,4 @@
1-import { useCallback, useEffect, useState } from "preact/hooks";1+import { useCallback, useState } from "preact/hooks";22 import type { RowOf, WriteHandle } from "@nzip/lofi";33 import { useLiveQuery, useWrite } from "@nzip/lofi/preact";44 import { s } from "@nzip/lofi/schema";@@ -17,54 +17,27 @@ const tasksTable = app.schema.tasks;
1717 /** The row type comes straight from the declared schema. */1818 export type Task = RowOf<typeof tasksTable>;1919 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-3320 /**3421 * The verb call sites use. Its effect units are declared once, here: the3522 * consequence runs when the store confirms the task, the compensation runs if3623 * a stale-policy write is denied — even if the app restarted in between.3724 */3825 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- },26+ effects: [27+ s.log("task-added"),28+ s.trace("task-added"),29+ s.notice<Task>({30+ synced: (task) => `"${task.text ?? "Task"}" synced to your account`,31+ // The engine already rolled a denied insert out of local reads; this32+ // durable notice compensates what the user was told, even after reload.33+ rejected: (task) => `"${task.text ?? "Task"}" was declined by the store and has been removed`,34+ }),35+ ],5136 });5237 5338 /** Toggling completion is a plain verb: no consequences, same lifecycle. */5439 export const setTaskCompleted = s.mutation("setTaskCompleted", s.update(tasksTable));5540 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-6841 export function useTasks() {6942 const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);7043 const [lastWrite, setLastWrite] = useState<WriteHandle<Task> | null>(null);@@ -37,6 +37,8 @@ shortest product overview and command summary. AI agents can ingest these docs a
3737 - [Direct sharing](examples/shared.md)3838 - [Fixed-role group](examples/group.md)3939 - [Policy conditions on typed columns](examples/policy-conditions.md)40+- [Nouns and verbs](nouns-and-verbs.md) — declaring verbs, effect units, and the write lifecycle41+- [The effect library](effects.md) — built-in effect units and the custom-unit authoring contract4042 - Data modeling examples — how to shape tables and columns4143 - [Collaborative list data model](examples/collaborative-list.md)4244 - [Collaborative sets](examples/collaborative-sets.md)@@ -0,0 +1,163 @@
1+# The effect library2+3+Effect units give a verb consequences: a handler that runs on the originating device when the4+write's fate settles. [Nouns and verbs](nouns-and-verbs.md) covers the machinery — declaration, the5+journal, at-least-once delivery, retention. This page is the built-in library and the contract for6+authoring your own.7+8+The teaching path runs one new idea at a time: declare a verb, **observe** it (`s.log`, `s.trace`),9+make its fate **data** (`s.notice`, `s.mark`), **compose** further writes (`s.chain`), then reach10+the **outside world** (`s.webhook`). Every built-in is idempotent by construction, so each is also a11+worked example of the discipline your own units must follow.12+13+## The authoring contract14+15+A custom unit is `s.effect(name, table, handlers, options)`. Four rules make it safe:16+17+- **The name is a durable identity.** The journal re-arms a unit's handlers by name after a reload,18+ so names are app-unique (a duplicate throws at declaration) and renaming a unit orphans its19+ in-flight obligations. The content-named built-ins (`s.log`, `s.trace`, `s.debug`, `s.webhook`)20+ share one unit per identity, so reusing one across verbs aggregates rather than collides. The21+ anonymous built-ins (`s.notice`, `s.mark`, `s.chain`) are named `<verb>#<position>` from the verb22+ they are attached to and their slot in that verb's `effects` — a durable identity independent of23+ which module loads first, so the only way to orphan one is to reorder the effects within its own24+ verb.25+- **Delivery is at-least-once, so handlers must be idempotent.** A crash between handler start and26+ journal completion re-runs the handler at the next boot. The `context.journalId` a handler27+ receives — the write's `(write id, effect name)` key — is the idempotency key: pass it to any28+ external call, and dedupe your own side effects on it.29+- **Failure has two severities.** An ordinary thrown error is _retryable_: the obligation re-arms at30+ the next boot until it succeeds or `maxAttempts` (default 5) quarantines it. Throwing31+ `PermanentEffectError` is _permanent_: the obligation retires immediately, without burning the32+ retry budget on a call that will keep failing. Both are counted in runtime diagnostics; a failed33+ handler is never silently swallowed.34+- **`onRejected` compensates ancillary state, never row data.** The engine rolls a rejected write35+ out of local query results itself. After a reload a rejected handler receives the row id alone, so36+ compensate what the user was told or what you called externally — not the row.37+38+A unit written to these rules behaves like a built-in. This is the smallest one:39+40+```ts41+import { PermanentEffectError, s } from "@nzip/lofi/schema";42+43+const sent = new Set<string>();44+export const sendReceipt = s.effect("sendReceipt", app.schema.orders, {45+ onSynced: async (order, { journalId }) => {46+ if (sent.has(journalId)) return; // at-least-once: dedupe on the key47+ const res = await fetch(receiptUrl, { headers: { "Idempotency-Key": journalId } });48+ if (res.status >= 400 && res.status < 500) {49+ throw new PermanentEffectError(`receipt refused: ${res.status}`); // do not retry50+ }51+ if (!res.ok) throw new Error(`receipt transient failure: ${res.status}`); // retry52+ sent.add(journalId);53+ },54+});55+```56+57+## Observation tier58+59+Cannot change anything — they only record.60+61+### `s.log(label)`62+63+Records a structured diagnostics entry (write id, fate, timing) on either fate. Repeated calls with64+one label share a unit. See [nouns and verbs](nouns-and-verbs.md#effect-units).65+66+### `s.trace(label?)`67+68+A span from the write's journaling to its settled fate, recorded in runtime diagnostics as an69+OpenTelemetry-shaped event with the saved→synced/rejected latency. Pure instrumentation and the70+observability hook; an OTLP exporter is an adapter over this feed, never a concept you configure per71+verb. Without a label the verb name labels the span.72+73+```ts74+export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {75+ effects: [s.trace("checkout")],76+});77+```78+79+### `s.debug()`80+81+A development-only timeline of each fate an obligation settles, for eyeballing delivery in the82+inspector. Stripped from production builds — in a `PROD` bundle the handlers record nothing.83+84+## Data-internal tier85+86+Write back into the app. Fate stops being a callback and becomes replicated data.87+88+### `s.notice(config)`89+90+Enqueues a durable, user-visible message when the write settles — the fix for the "a rejected write91+still flashed success" failure mode. The queue is durable and UI-agnostic: an entry created at a92+boot re-arm survives with nothing mounted, and a component renders it later. Render with the93+built-in `<Notices />` or the `useNotices()` hook from `@nzip/lofi/preact`; a toast stack is a94+userland wrapper over the same queue, never an imperative call. Idempotent by the obligation's95+journal id, so a re-delivery enqueues one entry. `ttlMs` bounds an entry's life; pass `null` to keep96+it until dismissed.97+98+```ts99+export const publish = s.mutation("publish", s.update(app.schema.posts), {100+ effects: [s.notice<Post>({101+ synced: "Published.",102+ rejected: (post) => `Could not publish "${post.title}".`,103+ })],104+});105+```106+107+### `s.mark(table, config)`108+109+Patches the written row when its fate resolves, so write fate becomes replicated data every device110+and query sees — the hand-rolled `status` column made declarative. The patch is absolute (a static111+set-column-to-value object), so it is convergent under re-delivery. A rejected _insert_ has no row112+to mark — the engine rolled it out — so the rejected patch is skipped for inserts; on updates and113+removes the row survives the rollback.114+115+```ts116+export const submit = s.mutation("submit", s.update(app.schema.claims), {117+ effects: [s.mark(app.schema.claims, {118+ synced: { status: "confirmed" },119+ rejected: { status: "failed" },120+ })],121+});122+```123+124+### `s.chain(verb, toInput)`125+126+Issues a follow-up verb once the write syncs, mapping the settled row to the next verb's input.127+Reifies a chain of writes (reserve → charge → fulfill) declaratively without a saga API — each link128+is an ordinary verb with its own effects and rejection handling. Fires only on `synced`.129+130+```ts131+export const reserve = s.mutation("reserve", s.insert(app.schema.holds), {132+ effects: [s.chain(charge, (hold) => ({ holdId: hold.id, amount: hold.total }))],133+});134+```135+136+## External tier137+138+Where the guardrails earn their keep.139+140+### `s.webhook(name, url, options?)`141+142+POSTs the settled row and its fate to `url`, with the obligation's journal id auto-injected as143+`Idempotency-Key` so a re-delivery the receiver already saw is dropped receiver-side. The generic144+integration workhorse and the reference for the at-least-once contract.145+146+- **`name` is the durable identity.** Keep it stable across releases. URLs and header values are147+ checked only in memory and never become journal keys, so credentials do not leak into persisted148+ effect identifiers. Reusing a name with different configuration fails fast.149+- **Failure severity follows the response.** A network error, `5xx`, or `429` throws an ordinary150+ error, so the ledger's bounded backoff retries it; any other `4xx` throws `PermanentEffectError`,151+ retiring the obligation rather than retrying a request the receiver will keep refusing.152+- **Delivery defaults to a finite window.** Receiver idempotency windows are finite (Stripe forgets153+ keys after ~24h), so external delivery defaults to a 24-hour `expiresAfterMs` rather than154+ infinity; pass `null` to opt into no expiry.155+156+```ts157+export const order = s.mutation("order", s.insert(app.schema.orders), {158+ effects: [s.webhook("orders", "https://hooks.example.com/orders")],159+});160+```161+162+Provider-specific units (email, SMS, push) are userland wrappers over `s.webhook`; the library ships163+the workhorse, not the integrations.@@ -99,7 +99,10 @@ and data-attached effects would have no owning replica).
9999 swallowed.100100 101101 `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.102+fate. Repeated calls with one label share one unit. It is the first of a tiered built-in library —103+observe (`s.log`, `s.trace`), make fate data (`s.notice`, `s.mark`), compose (`s.chain`), reach the104+outside world (`s.webhook`) — with the contract for authoring your own. See105+[the effect library](effects.md).103106 104107 ## Retention: durable things know how to die105108 @@ -28,6 +28,7 @@ export const runtimeFiles = [
2828 "mod.ts",2929 "mutation-taxonomy.ts",3030 "namespace-state.ts",31+ "notice-queue.ts",3132 "passkey-recovery.ts",3233 "pop.ts",3334 "probe.ts",@@ -69,6 +70,7 @@ export const accessFiles = [
6970 /** Schema facade modules vendored into `.lofi/`; kept in lockstep by the manifest test. */7071 export const schemaFiles = [7172 "compat.ts",73+ "effect-library.ts",7274 "effects.ts",7375 "encrypted.ts",7476 "mod.ts",@@ -87,11 +89,13 @@ export const preactFiles = [
8789 "DeviceStatus.tsx",8890 "live-data.ts",8991 "mod.ts",92+ "Notices.tsx",9093 "PwaActions.tsx",9194 "RuntimeRecovery.tsx",9295 "TicketEnrollForm.tsx",9396 "use-boot-progress.ts",9497 "use-device-capabilities.ts",98+ "use-notices.ts",9599 "use-schema-compat.ts",96100 "use-storage-fork.ts",97101 "write-hooks.ts",@@ -2,6 +2,7 @@ import type { VNode } from "preact";
22 import { useEffect, useState } from "preact/hooks";33 // Package-owned optional diagnostics UI.44 import { useDeviceCapabilities } from "./use-device-capabilities.ts";5+import { type CredentialOriginReport, getAuthCapability } from "../runtime/auth.ts";56 import { settleUiMutation } from "../runtime/ui-mutation.ts";67 import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";78 import { PwaActions } from "./PwaActions.tsx";@@ -30,6 +31,20 @@ function Row({ label, value }: { label: string; value: string }): VNode {
3031 3132 const available = (present: boolean) => (present ? "available" : "missing");3233 34+/** One-line credential-origin verdict that distinguishes API support from deployability. */35+export function describeCredentialOrigin(origin: CredentialOriginReport): string {36+ switch (origin.status) {37+ case "stable":38+ return `stable — ${origin.rpId}`;39+ case "local-only":40+ return `local development only — ${origin.rpId}`;41+ case "unverified":42+ return `unverified — ${origin.rpId}`;43+ case "blocked":44+ return origin.rpId ? `blocked — ${origin.rpId}` : "blocked";45+ }46+}47+3348 /**3449 * The one-line Data sync verdict. The blocked dispositions are first-class —3550 * the report must say *why* nothing is syncing, not merely that it is not:@@ -79,6 +94,7 @@ export function DeviceStatus(): VNode {
7994 const [pwa, setPwa] = useState<PwaState>(getPwaState());8095 const [session, setSession] = useState<Session | null>(null);8196 const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());97+ const [credentialOrigin, setCredentialOrigin] = useState<CredentialOriginReport | null>(null);8298 8399 useEffect(() => subscribePwaState(setPwa), []);84100 useEffect(@@ -92,6 +108,15 @@ export function DeviceStatus(): VNode {
92108 globalThis.addEventListener(runtimeRecreatedEvent, refresh);93109 return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);94110 }, []);111+ useEffect(() => {112+ let active = true;113+ void getAuthCapability().then((capability) => {114+ if (active) setCredentialOrigin(capability.origin);115+ });116+ return () => {117+ active = false;118+ };119+ }, []);95120 96121 if (!report) return <p class="device-status">Checking device capabilities…</p>;97122 @@ -218,9 +243,16 @@ export function DeviceStatus(): VNode {
218243 <dl>219244 <Row label="Identity" value="local-first account" />220245 <Row label="Backup" value={backupState} />221- <Row label="WebAuthn" value={available(report.webAuthn)} />222- <Row label="PRF (at-rest key)" value={report.prf} />246+ <Row label="WebAuthn API" value={available(report.webAuthn)} />247+ <Row label="PRF API (at-rest key)" value={report.prf} />248+ <Row249+ label="Credential origin"250+ value={credentialOrigin ? describeCredentialOrigin(credentialOrigin) : "checking"}251+ />223252 </dl>253+ {credentialOrigin && credentialOrigin.status !== "stable" && (254+ <p>{credentialOrigin.action}</p>255+ )}224256 </div>225257 226258 <div class="device-category">@@ -1,4 +1,4 @@
1-import { describeSyncState } from "./DeviceStatus.tsx";1+import { describeCredentialOrigin, describeSyncState } from "./DeviceStatus.tsx";22 33 // The report's contract after the sync-state integrity pass: a reader must be44 // able to tell *why* nothing is syncing. Each blocked disposition names its@@ -45,3 +45,22 @@ Deno.test("describeSyncState puts the owner mismatch above store answers", () =>
4545 throw new Error(`owner mismatch was outranked: ${verdict}`);4646 }4747 });48+49+Deno.test("credential-origin status does not overstate API support", () => {50+ const local = describeCredentialOrigin({51+ status: "local-only",52+ rpId: "localhost",53+ action: "use the stable HTTPS origin",54+ });55+ if (!local.includes("local development only") || !local.includes("localhost")) {56+ throw new Error(`local credential origin was overstated: ${local}`);57+ }58+ const stable = describeCredentialOrigin({59+ status: "stable",60+ rpId: "demo.lofi.host",61+ action: "keep this hostname",62+ });63+ if (!stable.includes("stable") || !stable.includes("demo.lofi.host")) {64+ throw new Error(`stable credential origin lost its hostname: ${stable}`);65+ }66+});@@ -0,0 +1,64 @@
1+import { Fragment, type VNode } from "preact";2+// Package-owned optional notices surface.3+import { type NoticeEntry, useNotices } from "./use-notices.ts";4+5+/** Props for the built-in {@link Notices} surface. */6+export type NoticesProps = {7+ /** Accessible label for the region; defaults to "Notifications". */8+ label?: string;9+ /** Renders one notice; defaults to the built-in row with a dismiss button. */10+ children?: (notice: NoticeEntry, dismiss: () => void) => VNode;11+};12+13+/**14+ * The built-in durable-notice surface: renders the live `s.notice` queue as an15+ * ARIA live region so a message enqueued when a write settled — success or the16+ * "a rejected write still flashed success" case — is announced and dismissable.17+ * Optional and entirely public: it is `useNotices` plus default markup, so an18+ * app can drop it and render its own (a toast stack, a banner) over the same19+ * hook.20+ *21+ * @example22+ * ```tsx23+ * import { Notices } from "@nzip/lofi/preact";24+ *25+ * export function AppChrome() {26+ * return <Notices />;27+ * }28+ * ```29+ *30+ * @param props Optional region label and a custom per-notice renderer.31+ * @returns The always-mounted live notices region.32+ */33+export function Notices({ label = "Notifications", children }: NoticesProps): VNode {34+ const { notices, dismiss } = useNotices();35+ // The live region stays mounted even when empty: a screen reader only36+ // announces mutations to a region already in the DOM, so inserting the37+ // region together with its first notice would drop that first announcement —38+ // exactly the message this surface exists to deliver.39+ return (40+ <section41+ class="lofi-notices"42+ aria-label={label}43+ aria-live="polite"44+ >45+ {notices.map((notice) =>46+ children47+ ? <Fragment key={notice.id}>{children(notice, () => dismiss(notice.id))}</Fragment>48+ : (49+ <div key={notice.id} class="lofi-notice" data-tone={notice.tone} role="status">50+ <p class="lofi-notice-message">{notice.message}</p>51+ <button52+ type="button"53+ class="lofi-notice-dismiss"54+ aria-label="Dismiss notification"55+ onClick={() => dismiss(notice.id)}56+ >57+ Dismiss58+ </button>59+ </div>60+ )61+ )}62+ </section>63+ );64+}@@ -0,0 +1,33 @@
1+import { render } from "npm:preact-render-to-string@6.7.0";2+import { getNoticeQueue } from "../runtime/mod.ts";3+import { Notices } from "./Notices.tsx";4+5+Deno.test("Notices keeps its empty live region exposed for the first announcement", () => {6+ const html = render(<Notices />);7+ if (!html.includes('aria-live="polite"')) {8+ throw new Error("the durable notice surface omitted its live region");9+ }10+ if (html.includes(" hidden")) {11+ throw new Error("an empty live region must remain in the accessibility tree");12+ }13+});14+15+Deno.test("Notices preserves custom renderer markup without a wrapper", async () => {16+ const queue = getNoticeQueue();17+ const id = "notices-custom-renderer-test";18+ await queue.enqueue({ id, message: "Custom", tone: "info", ttlMs: null });19+ try {20+ const html = render(21+ <Notices>{(notice) => <article data-notice={notice.id}>{notice.message}</article>}</Notices>,22+ );23+ if (!html.includes(`<article data-notice="${id}">Custom</article>`)) {24+ throw new Error("the custom notice renderer did not retain its own root markup");25+ }26+ if (html.includes("<div")) {27+ throw new Error("the notices surface inserted a wrapper around custom markup");28+ }29+ } finally {30+ queue.dismiss(id);31+ await queue.flush();32+ }33+});@@ -14,6 +14,13 @@
1414 * 1515 */1616 export { DeviceStatus } from "./DeviceStatus.tsx";17+export { Notices, type NoticesProps } from "./Notices.tsx";18+export {19+ type NoticeEntry,20+ type NoticesSurface,21+ type NoticeTone,22+ useNotices,23+} from "./use-notices.ts";1724 export { RuntimeRecovery, type RuntimeRecoveryProps } from "./RuntimeRecovery.tsx";1825 export {1926 type LiveQuerySnapshot,@@ -0,0 +1,60 @@
1+/**2+ * Preact binding for the durable notice queue behind `s.notice`: the live list3+ * of user-visible messages an effect enqueued when a write settled, plus the4+ * dismissal action. Apps render their own surface over this, or use the5+ * built-in {@link Notices} component.6+ *7+ * @module8+ */9+10+import { useEffect, useState } from "preact/hooks";11+import {12+ dismissAllNotices,13+ dismissNotice,14+ listNotices,15+ type NoticeEntry,16+ subscribeNotices,17+} from "../runtime/mod.ts";18+19+export type { NoticeEntry, NoticeTone } from "../runtime/mod.ts";20+21+/** The live notices and the actions to retire them. */22+export type NoticesSurface = {23+ /** The live notices: enqueued, not dismissed, not past their TTL. */24+ notices: readonly NoticeEntry[];25+ /** Dismisses one notice by id. */26+ dismiss: (id: string) => void;27+ /** Dismisses every notice. */28+ dismissAll: () => void;29+};30+31+/**32+ * Subscribes a Preact component to the durable notice queue. The list stays33+ * live across enqueues (including effects that fire at a boot re-arm),34+ * dismissals, and TTL retirement.35+ *36+ * @example37+ * ```tsx38+ * import { useNotices } from "@nzip/lofi/preact";39+ *40+ * const { notices, dismiss } = useNotices();41+ * return notices.map((n) => (42+ * <div key={n.id} data-tone={n.tone}>43+ * {n.message}44+ * <button type="button" onClick={() => dismiss(n.id)}>Dismiss</button>45+ * </div>46+ * ));47+ * ```48+ *49+ * @returns The live notices and the dismissal actions.50+ */51+export function useNotices(): NoticesSurface {52+ const [notices, setNotices] = useState<readonly NoticeEntry[]>(() => listNotices());53+ useEffect(() => {54+ // Publish the current list once on mount — an effect may have enqueued55+ // before this component subscribed — then follow every change.56+ setNotices(listNotices());57+ return subscribeNotices(() => setNotices(listNotices()));58+ }, []);59+ return { notices, dismiss: dismissNotice, dismissAll: dismissAllNotices };60+}@@ -335,16 +335,19 @@ export type SyncTicket = {
335335 336336 const TICKET_PREFIX = "lofisync1.";337337 const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;338+const TICKET_CONNECT_PATH = /^\/t\/[A-Za-z0-9_-]{43}\/c\/[A-Za-z0-9_-]{43}$/;338339 339340 /**340- * Whether a server URL carries an app-connect ticket path (`/t/<secret>`) and341+ * Whether a server URL carries an app-connect ticket path (`/t/<secret>`) or342+ * its PoP-authenticated connect-token form (`/t/<secret>/c/<token>`) and341343 * therefore fronts a lofi-node gate, which is what exposes the metadata-only342344 * store-status endpoint. First-party Jazz servers and open-mode node URLs do343345 * not match.344346 */345347 export function isTicketServerUrl(serverUrl: string): boolean {346348 try {347- return TICKET_PATH.test(new URL(serverUrl).pathname);349+ const path = new URL(serverUrl).pathname;350+ return TICKET_PATH.test(path) || TICKET_CONNECT_PATH.test(path);348351 } catch {349352 return false;350353 }@@ -30,6 +30,65 @@ export function recordEffectLogEntry(
3030 diagnostics.effectLog = [...diagnostics.effectLog.slice(-(maxEffectLogEntries - 1)), entry];3131 }3232 33+/**34+ * One OpenTelemetry-shaped span recorded by the built-in `s.trace` effect35+ * unit: the write's journaling to its settled fate, with the elapsed latency.36+ * The framework emits these into diagnostics with no vendor coupling; an37+ * OTLP exporter is an adapter over this feed, never a concept the author sees.38+ */39+export type EffectTraceEntry = {40+ /** The author's span label, or `null` when the verb name labels it. */41+ label: string | null;42+ /** The declaring verb's name, or `null` for writes without a verb. */43+ verb: string | null;44+ /** The written row's id. */45+ rowId: string;46+ /** Which fate ended the span. */47+ fate: "synced" | "rejected";48+ /** Saved-to-fate latency in milliseconds. */49+ durationMs: number;50+ /** Epoch milliseconds when the span closed. */51+ at: number;52+};53+54+/** One development-only timeline event recorded by the built-in `s.debug` unit. */55+export type EffectDebugEvent = {56+ /** The declaring verb's name, or `null` for writes without a verb. */57+ verb: string | null;58+ /** The obligation's journal id. */59+ journalId: string;60+ /** The stage/fate this event marks. */61+ event: string;62+ /** Epoch milliseconds when the event was recorded. */63+ at: number;64+};65+66+// Recent-entry windows for the trace and dev-debug feeds.67+const maxEffectTraceEntries = 20;68+const maxEffectDebugEvents = 50;69+70+/** Appends one `s.trace` span, keeping the bounded recent window. */71+export function recordEffectTrace(72+ diagnostics: RuntimeDiagnostics,73+ entry: EffectTraceEntry,74+): void {75+ diagnostics.effectTraces = [76+ ...diagnostics.effectTraces.slice(-(maxEffectTraceEntries - 1)),77+ entry,78+ ];79+}80+81+/** Appends one `s.debug` timeline event, keeping the bounded recent window. */82+export function recordEffectDebugEvent(83+ diagnostics: RuntimeDiagnostics,84+ entry: EffectDebugEvent,85+): void {86+ diagnostics.effectDebugTimeline = [87+ ...diagnostics.effectDebugTimeline.slice(-(maxEffectDebugEvents - 1)),88+ entry,89+ ];90+}91+3392 /**3493 * Runtime-owned observability counters. These describe the framework's storage,3594 * subscription, and write machinery and never reference any application schema.@@ -92,6 +151,15 @@ export type RuntimeDiagnostics = {
92151 quarantinedObligations: number;93152 /** Recent structured entries recorded by the built-in `s.log` effect unit. */94153 effectLog: readonly EffectLogEntry[];154+ /** Recent saved→fate spans recorded by the built-in `s.trace` effect unit. */155+ effectTraces: readonly EffectTraceEntry[];156+ /**157+ * Recent development-only timeline events from the built-in `s.debug` unit;158+ * always empty in production builds, where `s.debug` records nothing.159+ */160+ effectDebugTimeline: readonly EffectDebugEvent[];161+ /** How many durable `s.notice` entries are unread (undismissed, unexpired). */162+ activeNotices: number;95163 /** Recent shared-field key alerts: substitutions, forged wraps, self-key96164 * conflicts. A non-empty list is the detection surface the threat model97165 * promises — render it, never swallow it. */@@ -155,6 +223,9 @@ export function createDiagnostics(): RuntimeDiagnostics {
155223 expiredObligations: 0,156224 quarantinedObligations: 0,157225 effectLog: [],226+ effectTraces: [],227+ effectDebugTimeline: [],228+ activeNotices: 0,158229 sharedFieldAlerts: [],159230 };160231 }@@ -208,7 +208,7 @@ export {
208208 type SinkRestoreOutcome,209209 type SyncTicket,210210 } from "./data-sink.ts";211-export { RecoveryError } from "./recovery.ts";211+export { RecoveryError, type RecoveryErrorCode } from "./recovery.ts";212212 export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";213213 export {214214 type RowOf,@@ -230,16 +230,27 @@ export {
230230 } from "./write-handle.ts";231231 export {232232 armWriteLedger,233+ dismissAllNotices,234+ dismissNotice,235+ getNoticeQueue,233236 getWriteLedger,234237 type LedgerWriteOptions,235238 type LedgerWriteRequest,239+ listNotices,236240 type PendingWritesSnapshot,237241 type PendingWriteSummary,238242 type ProbeTable,239243 type RowSyncStatus,244+ subscribeNotices,240245 WriteLedger,241246 type WriteLedgerEnvironment,242247 } from "./write-ledger.ts";248+export {249+ type NoticeEnqueueInput,250+ type NoticeEntry,251+ NoticeQueue,252+ type NoticeTone,253+} from "./notice-queue.ts";243254 export {244255 createDefaultJournalStorage,245256 createMemoryJournalStorage,@@ -251,7 +262,7 @@ export {
251262 type JournalWriteRecord,252263 type JournalWriteStage,253264 } from "./write-journal.ts";254-export type { EffectLogEntry } from "./diagnostics.ts";265+export type { EffectDebugEvent, EffectLogEntry, EffectTraceEntry } from "./diagnostics.ts";255266 export { pinnedFingerprint, trustPeerKey, verifyAndPinFingerprint } from "./shared-field-keys.ts";256267 export type {257268 EffectContext,@@ -0,0 +1,249 @@
1+/**2+ * Package-owned durable notice queue: the store behind the built-in3+ * `s.notice` effect unit.4+ *5+ * A notice is a user-visible message an effect enqueues when a write settles —6+ * the durable answer to "a rejected write still flashed success". The queue is7+ * deliberately not an imperative toast: an effect can fire at a boot re-arm8+ * with no UI mounted, so the entry must outlive the render and be picked up9+ * later. Entries persist beside the write journal (OPFS when the browser10+ * provides it, `localStorage` otherwise), are keyed by the enqueuing11+ * obligation's journal id so an at-least-once re-delivery adds one entry, and12+ * retire by dismissal or TTL. A component (the built-in notices surface, or an13+ * author's) subscribes and renders; toasts are a userland wrapper over this.14+ *15+ * @module16+ */17+18+import {19+ createDefaultJournalStorage,20+ createMemoryJournalStorage,21+ type JournalStorage,22+} from "./write-journal.ts";23+24+/** How a notice is classified for rendering. */25+export type NoticeTone = "info" | "success" | "warning" | "error";26+27+/** One durable notice entry. */28+export type NoticeEntry = {29+ /** The enqueuing obligation's journal id — the entry's idempotency key. */30+ id: string;31+ /** The user-facing message. */32+ message: string;33+ /** The message classification. */34+ tone: NoticeTone;35+ /** Epoch milliseconds when the entry was enqueued. */36+ createdAt: number;37+ /** Epoch milliseconds when the entry retires by TTL, or `null` for none. */38+ expiresAt: number | null;39+};40+41+/** The persisted queue document. */42+type NoticeDocument = {43+ version: 1;44+ entries: NoticeEntry[];45+};46+47+function parse(text: string | null): NoticeDocument {48+ if (!text) return { version: 1, entries: [] };49+ try {50+ const value = JSON.parse(text) as Partial<NoticeDocument> | null;51+ if (value && value.version === 1 && Array.isArray(value.entries)) {52+ const entries = value.entries.filter((entry): entry is NoticeEntry =>53+ typeof entry?.id === "string" && typeof entry.message === "string"54+ );55+ return { version: 1, entries };56+ }57+ } catch {58+ // A corrupt queue must not brick boot; start empty.59+ }60+ return { version: 1, entries: [] };61+}62+63+/** What one enqueue call carries, before the queue stamps identity and time. */64+export type NoticeEnqueueInput = {65+ /** The idempotency key — the enqueuing obligation's journal id. */66+ id: string;67+ /** The user-facing message. */68+ message: string;69+ /** The message classification. */70+ tone: NoticeTone;71+ /** Lifespan before TTL retirement, or `null` to keep until dismissed. */72+ ttlMs: number | null;73+};74+75+/**76+ * The single reader and writer of the durable notice queue: an in-memory77+ * document with coalesced persistence, a live-notice snapshot, and change78+ * notification. Retirement (dismissal, TTL) is applied lazily on every read79+ * and on a periodic sweep, so a queue loaded at boot never surfaces a message80+ * whose window already closed.81+ */82+export class NoticeQueue {83+ readonly #storage: JournalStorage;84+ readonly #now: () => number;85+ #document: NoticeDocument = { version: 1, entries: [] };86+ #chain: Promise<void> = Promise.resolve();87+ #loaded = false;88+ #loading: Promise<void> | null = null;89+ #listeners = new Set<() => void>();90+ #onCountChange?: (count: number) => void;91+92+ /** Creates a queue over one storage location and clock. */93+ constructor(94+ storage: JournalStorage,95+ now: () => number = Date.now,96+ onCountChange?: (count: number) => void,97+ ) {98+ this.#storage = storage;99+ this.#now = now;100+ this.#onCountChange = onCountChange;101+ }102+103+ /**104+ * Loads the persisted document once, dropping already-expired entries.105+ * Entries enqueued in the async load window — an effect firing at a boot106+ * re-arm before load resolves — are merged in rather than clobbered: the107+ * persisted set is the base, and any in-memory entry (fresher) wins on id.108+ */109+ load(): Promise<void> {110+ if (this.#loaded) return Promise.resolve();111+ this.#loading ??= this.#load().catch((error) => {112+ this.#loading = null;113+ throw error;114+ });115+ return this.#loading;116+ }117+118+ async #load(): Promise<void> {119+ const persisted = parse(await this.#storage.load());120+ const pending = this.#document.entries;121+ const byId = new Map<string, NoticeEntry>();122+ for (const entry of persisted.entries) byId.set(entry.id, entry);123+ // In-memory entries were enqueued this session and are authoritative for124+ // their id; they overwrite any stale persisted copy.125+ for (const entry of pending) byId.set(entry.id, entry);126+ this.#document = { version: 1, entries: [...byId.values()] };127+ this.#loaded = true;128+ const swept = this.#sweepExpired();129+ // A load that absorbed in-flight entries must re-persist the merged set130+ // and publish it, so the boot-enqueued notice is not silently deferred.131+ if (pending.length > 0 || swept) await this.#persist();132+ this.#emit();133+ }134+135+ /**136+ * Enqueues one notice, idempotent by id: an entry whose id is already137+ * present is left untouched, so an at-least-once re-delivery adds nothing.138+ */139+ async enqueue(input: NoticeEnqueueInput): Promise<void> {140+ await this.load();141+ const exists = this.#document.entries.some((entry) => entry.id === input.id);142+ if (!exists) {143+ const createdAt = this.#now();144+ this.#document.entries.push({145+ id: input.id,146+ message: input.message,147+ tone: input.tone,148+ createdAt,149+ expiresAt: input.ttlMs === null ? null : createdAt + input.ttlMs,150+ });151+ this.#emit();152+ }153+ // Persist duplicate deliveries too: if the first attempt mutated memory154+ // but its save failed, replay is the retry that makes the entry durable.155+ await this.#persist();156+ }157+158+ /** Dismisses one entry by id; unknown ids are a no-op. */159+ dismiss(id: string): void {160+ const next = this.#document.entries.filter((entry) => entry.id !== id);161+ if (next.length === this.#document.entries.length) return;162+ this.#document.entries = next;163+ void this.#persist().catch(() => undefined);164+ this.#emit();165+ }166+167+ /** Dismisses every entry. */168+ dismissAll(): void {169+ if (this.#document.entries.length === 0) return;170+ this.#document.entries = [];171+ void this.#persist().catch(() => undefined);172+ this.#emit();173+ }174+175+ /**176+ * The live notices: enqueued, not dismissed, not past their TTL. A pure177+ * read — it computes the live view without mutating stored state, so a178+ * render never silently prunes the queue (which would drift the persisted179+ * set and the `activeNotices` count from what a later `sweep()` sees).180+ * Actual retirement of expired entries happens in {@link sweep}, which181+ * persists and notifies. A fresh array each call, so callers may hold it.182+ */183+ list(): readonly NoticeEntry[] {184+ return this.#liveEntries();185+ }186+187+ #liveEntries(): NoticeEntry[] {188+ const now = this.#now();189+ return this.#document.entries.filter((entry) =>190+ entry.expiresAt === null || entry.expiresAt > now191+ );192+ }193+194+ /** Subscribes to queue changes; returns an unsubscribe function. */195+ subscribe(listener: () => void): () => void {196+ this.#listeners.add(listener);197+ return () => this.#listeners.delete(listener);198+ }199+200+ /** Resolves once every scheduled persistence has settled. */201+ flush(): Promise<void> {202+ return this.#chain;203+ }204+205+ /** Applies TTL retirement, persisting and notifying if anything changed. */206+ sweep(): void {207+ if (this.#sweepExpired()) {208+ void this.#persist().catch(() => undefined);209+ this.#emit();210+ }211+ }212+213+ #sweepExpired(): boolean {214+ const now = this.#now();215+ const before = this.#document.entries.length;216+ this.#document.entries = this.#document.entries.filter((entry) =>217+ entry.expiresAt === null || entry.expiresAt > now218+ );219+ return this.#document.entries.length !== before;220+ }221+222+ #persist(): Promise<void> {223+ const snapshot = JSON.stringify(this.#document);224+ const attempt = this.#chain.then(() => this.#storage.save(snapshot));225+ // Keep the serialized queue usable after a failed attempt while returning226+ // that failure to the effect handler that requires durable delivery.227+ this.#chain = attempt.catch(() => undefined);228+ return attempt;229+ }230+231+ #emit(): void {232+ // The count and every subscriber see the live view, so `activeNotices`233+ // never overcounts entries that have expired but not yet been swept.234+ this.#onCountChange?.(this.#liveEntries().length);235+ for (const listener of this.#listeners) listener();236+ }237+}238+239+/** Resolves the default notice-queue storage for one app id. */240+export function createDefaultNoticeStorage(appId: string): JournalStorage {241+ // The journal's storage resolver already picks OPFS, then localStorage, then242+ // memory; a distinct file name keeps the two documents apart.243+ return createDefaultJournalStorage(`notices-${appId}`);244+}245+246+/** An in-memory notice queue for tests and storage-less runtimes. */247+export function createMemoryNoticeQueue(now?: () => number): NoticeQueue {248+ return new NoticeQueue(createMemoryJournalStorage(), now);249+}@@ -0,0 +1,99 @@
1+import { assert, assertCount } from "./test-assert.ts";2+import { createMemoryJournalStorage } from "./write-journal.ts";3+import { NoticeQueue } from "./notice-queue.ts";4+5+function queue(now: () => number = () => 1000): {6+ q: NoticeQueue;7+ storage: ReturnType<typeof createMemoryJournalStorage>;8+ counts: number[];9+} {10+ const storage = createMemoryJournalStorage();11+ const counts: number[] = [];12+ return { q: new NoticeQueue(storage, now, (count) => counts.push(count)), storage, counts };13+}14+15+Deno.test("enqueue is idempotent by id, so an at-least-once re-delivery adds one entry", async () => {16+ const { q, counts } = queue();17+ await q.enqueue({ id: "w1:notice#1", message: "Saved.", tone: "success", ttlMs: null });18+ await q.enqueue({ id: "w1:notice#1", message: "Saved.", tone: "success", ttlMs: null });19+ assertCount(q.list().length, 1, "the second enqueue with one id must be a no-op");20+ assertCount(counts.at(-1) ?? -1, 1, "the active-notice count must reflect one entry");21+});22+23+Deno.test("dismiss removes one entry and notifies subscribers", async () => {24+ const { q } = queue();25+ let notified = 0;26+ q.subscribe(() => notified += 1);27+ await q.enqueue({ id: "a", message: "one", tone: "info", ttlMs: null });28+ await q.enqueue({ id: "b", message: "two", tone: "info", ttlMs: null });29+ q.dismiss("a");30+ assertCount(q.list().length, 1, "dismiss must drop exactly the named entry");31+ assert(q.list()[0].id === "b", "the surviving entry must be the one not dismissed");32+ assert(notified >= 3, "each mutation must notify subscribers");33+});34+35+Deno.test("a TTL entry retires once its window closes", async () => {36+ let clock = 1000;37+ const { q } = queue(() => clock);38+ await q.enqueue({ id: "ttl", message: "temporary", tone: "warning", ttlMs: 5000 });39+ assertCount(q.list().length, 1, "the entry is live inside its window");40+ clock = 6001;41+ assertCount(q.list().length, 0, "list must not surface an entry past its TTL");42+});43+44+Deno.test("an entry enqueued during the load window is merged, not clobbered", async () => {45+ const storage = createMemoryJournalStorage(46+ JSON.stringify({47+ version: 1,48+ entries: [{ id: "persisted", message: "old", tone: "info", createdAt: 1, expiresAt: null }],49+ }),50+ );51+ const q = new NoticeQueue(storage, () => 1000);52+ // An effect fires at a boot re-arm and enqueues before load() resolves.53+ const enqueued = q.enqueue({ id: "boot", message: "fresh", tone: "success", ttlMs: null });54+ await Promise.all([q.load(), enqueued]);55+ const ids = q.list().map((entry) => entry.id).sort();56+ assertCount(ids.length, 2, "the boot entry and the persisted entry both survive");57+ assert(ids.includes("boot") && ids.includes("persisted"), "neither entry is clobbered");58+});59+60+Deno.test("a persisted queue reloads its entries and drops expired ones", async () => {61+ let clock = 1000;62+ const storage = createMemoryJournalStorage();63+ const first = new NoticeQueue(storage, () => clock);64+ await first.enqueue({ id: "keep", message: "durable", tone: "info", ttlMs: null });65+ await first.enqueue({ id: "drop", message: "fleeting", tone: "info", ttlMs: 100 });66+ await first.flush();67+68+ clock = 5000;69+ const second = new NoticeQueue(storage, () => clock);70+ await second.load();71+ const ids = second.list().map((entry) => entry.id);72+ assert(ids.includes("keep"), "a persisted entry must survive a reload");73+ assert(!ids.includes("drop"), "an entry whose TTL passed while away must not reappear");74+});75+76+Deno.test("enqueue rejects a failed save and replay retries persistence", async () => {77+ const saves: string[] = [];78+ let attempts = 0;79+ const q = new NoticeQueue({80+ load: () => Promise.resolve(null),81+ save(text) {82+ attempts += 1;83+ if (attempts === 1) return Promise.reject(new Error("quota"));84+ saves.push(text);85+ return Promise.resolve();86+ },87+ });88+ const input = { id: "retry", message: "durable", tone: "error" as const, ttlMs: null };89+ let rejected = false;90+ try {91+ await q.enqueue(input);92+ } catch {93+ rejected = true;94+ }95+ assert(rejected, "the effect handler must observe a failed durable save");96+ await q.enqueue(input);97+ assert(attempts === 2, "re-delivery must retry persistence for an in-memory duplicate");98+ assert(saves[0]?.includes('"id":"retry"') === true, "the replayed entry must become durable");99+});@@ -21,7 +21,8 @@ export type RecoverablePasskeyErrorCode =
2121 | "restore-failed";2222 2323 const messages: Record<RecoverablePasskeyErrorCode, string> = {24- cancelled: "The passkey prompt was cancelled. Nothing on this device was replaced.",24+ cancelled:25+ "A passkey was not created or opened. Nothing on this device was replaced; try again or use the recovery phrase.",2526 unsupported:2627 "This browser cannot create or restore a recoverable passkey. Use the recovery phrase instead.",2728 "credential-missing":@@ -25,6 +25,12 @@ Deno.test("passkey backup errors become actionable non-secret recovery errors",
2525 !mapped.message.includes("vendor detail"),2626 "vendor detail leaked into public error text",2727 );28+ if (code === "cancelled") {29+ assert(30+ mapped.message.includes("not created or opened") && !mapped.message.includes("cancelled"),31+ "NotAllowedError guidance must not assume the person dismissed the prompt",32+ );33+ }2834 }2935 });3036 @@ -21,32 +21,39 @@ import { RecoveryPhrase, RecoveryPhraseError } from "jazz-tools/passphrase";
2121 /** The number of words in a lofi recovery phrase. */2222 export const RECOVERY_PHRASE_WORDS = 24;2323 24+/** Stable recovery-phrase failure categories for actionable UI guidance. */25+export type RecoveryErrorCode =26+ | "invalid-length"27+ | "invalid-word"28+ | "invalid-checksum"29+ | "invalid-secret";30+31+const MESSAGES: Record<RecoveryErrorCode, string> = {32+ "invalid-length":33+ `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,34+ "invalid-word": "One of the words is not in the recovery word list — check your spelling.",35+ "invalid-checksum":36+ "That phrase is not a valid recovery phrase — re-check the words and their order.",37+ "invalid-secret": "The account secret could not be encoded as a recovery phrase.",38+};39+2440 /** A precise, non-leaking failure reason for a recovery-phrase operation. */2541 export class RecoveryError extends Error {2642 /** Stable error class name for diagnostics and error boundaries. */2743 override readonly name = "RecoveryError";2844 /** Actionable category that callers can map to recovery guidance. */29- readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret";45+ readonly code: RecoveryErrorCode;3046 /** Creates a phrase error without retaining the submitted phrase. */31- constructor(code: RecoveryError["code"], message?: string) {32- super(message ?? `Recovery phrase operation failed: ${code}.`);47+ constructor(code: RecoveryErrorCode, message?: string) {48+ super(message ?? MESSAGES[code]);3349 this.code = code;3450 }3551 }3652 37-const MESSAGES: Record<RecoveryError["code"], string> = {38- "invalid-length":39- `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,40- "invalid-word": "One of the words is not in the recovery word list — check your spelling.",41- "invalid-checksum":42- "That phrase is not a valid recovery phrase — re-check the words and their order.",43- "invalid-secret": "The account secret could not be encoded as a recovery phrase.",44-};45-4653 function mapError(error: unknown): RecoveryError {4754 if (error instanceof RecoveryError) return error;4855 if (error instanceof RecoveryPhraseError) {49- const code = error.code as RecoveryError["code"];56+ const code = error.code as RecoveryErrorCode;5057 return new RecoveryError(code, MESSAGES[code] ?? undefined);5158 }5259 return new RecoveryError("invalid-checksum", error instanceof Error ? error.message : undefined);@@ -49,12 +49,20 @@ test("fromRecoveryPhrase tolerates messy whitespace and casing", () => {
4949 5050 test("fromRecoveryPhrase rejects a wrong word count with invalid-length", () => {5151 let code: string | undefined;52+ let message = "";5253 try {5354 fromRecoveryPhrase("one two three");5455 } catch (error) {55- if (error instanceof RecoveryError) code = error.code;56+ if (error instanceof RecoveryError) {57+ code = error.code;58+ message = error.message;59+ }5660 }5761 assert(code === "invalid-length", "too few words must raise invalid-length");62+ assert(63+ message.includes(`${RECOVERY_PHRASE_WORDS} words`) && !message.includes("invalid-length"),64+ "the invalid-length error must give person-readable word-count guidance",65+ );5866 });5967 6068 test("fromRecoveryPhrase rejects an empty phrase with invalid-length", () => {@@ -294,15 +294,6 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
294294 }295295 notifyDiagnostics(state);296296 }297- // The store preflight rides alongside database creation, never in front298- // of it: local-first boot must not wait on the network. resolveStoreStatus299- // maps every failure and timeout to a diagnostic value, so this only300- // records state — a schema-less or drifted store surfaces here at boot301- // instead of as a hanging first write, and is never repaired from here.302- void resolveStoreStatus({ connect: effectiveConnect, sink: activeSink() }).then((status) => {303- state.diagnostics.storeStatus = status;304- notifyDiagnostics(state);305- });306297 // A possession-bound sink proves the device key before connecting: the307298 // exchange mints a connect token the sync client carries as a path308299 // segment. A failed exchange (node restart, revocation, key loss) boots@@ -325,6 +316,17 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
325316 keyPair,326317 }) ?? undefined;327318 }319+ // The store preflight rides alongside database creation, never in front320+ // of it: local-first boot must not wait on the network. A possession-bound321+ // sink must use the authenticated connect URL minted above; its bare322+ // ticket URL correctly rejects even metadata reads. resolveStoreStatus323+ // maps every remaining failure and timeout to a diagnostic value, so this324+ // only records state and never repairs the store.325+ const statusSink = popSink ? { serverUrl: serverUrlOverride ?? popSink.serverUrl } : null;326+ void resolveStoreStatus({ connect: effectiveConnect, sink: statusSink }).then((status) => {327+ state.diagnostics.storeStatus = status;328+ notifyDiagnostics(state);329+ });328330 const db = await createDb(329331 databaseConfig(330332 secret,@@ -48,7 +48,12 @@ import {
4848 secretFingerprint,4949 SyncOwnerError,5050 } from "./sync-owner.ts";51-import { type DevicePublicKey, exportDevicePublicKey, getOrCreatePopKeyPair } from "./pop.ts";51+import {52+ completePopExchange,53+ type DevicePublicKey,54+ exportDevicePublicKey,55+ getOrCreatePopKeyPair,56+} from "./pop.ts";5257 import { holdProvisionCapability } from "./provision.ts";5358 import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";5459 import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";@@ -437,11 +442,13 @@ export async function performTicketEnrollment(
437442 ): Promise<Session> {438443 // Only a provision-scoped ticket reaches the scope-down exchange, so only439444 // then is there a binding to offer.445+ let deviceKeyPair: CryptoKeyPair | undefined;440446 let devicePublicKey: DevicePublicKey | undefined;441447 const parsed = parseSyncTicket(ticket);442448 if (parsed?.scope === "provision") {443449 try {444- devicePublicKey = await exportDevicePublicKey(await getOrCreatePopKeyPair(parsed.appId));450+ deviceKeyPair = await getOrCreatePopKeyPair(parsed.appId);451+ devicePublicKey = await exportDevicePublicKey(deviceKeyPair);445452 } catch {446453 // No usable key custody in this context; the exchange still derives a447454 // ticket, held as a bearer credential exactly as before.@@ -450,6 +457,21 @@ export async function performTicketEnrollment(
450457 const split = await splitTicketForEnrollment(ticket, deps.fetcher, devicePublicKey);451458 const previous = readDeclaredSink();452459 const declared = await declareSinkFromTicket(split.sinkTicket, deps.keyStore, split.pop);460+ // A PoP-bound derived ticket rejects every bare request, including the461+ // metadata preflight. Prove possession first and ask store-status through462+ // the short-lived connect URL, just as the managed runtime does for sync.463+ // Without this exchange a healthy store looks unreachable, while a real464+ // no-schema refusal can be lost and enrollment incorrectly kept.465+ let preflightServerUrl = declared.serverUrl;466+ if (split.pop && deviceKeyPair) {467+ preflightServerUrl = await completePopExchange({468+ serverUrl: declared.serverUrl,469+ appId: declared.appId,470+ ticketId: split.pop.ticketId,471+ keyPair: deviceKeyPair,472+ ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),473+ }) ?? declared.serverUrl;474+ }453475 // The preflight decides whether enrollment is kept: a store that answers454476 // with a definite refusal rolls the declaration back before anything else455477 // (election, provision custody) observes it. An unreachable store is not a@@ -457,7 +479,7 @@ export async function performTicketEnrollment(
457479 // the warning recorded where status surfaces read it.458480 const status = await resolveStoreStatus({459481 connect: true,460- sink: { serverUrl: declared.serverUrl },482+ sink: { serverUrl: preflightServerUrl },461483 ...(deps.preflight ? { preflight: deps.preflight } : {}),462484 ...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),463485 });@@ -288,6 +288,112 @@ test(
288288 }),289289 );290290 291+test(292+ "a PoP-bound provision enrollment preflights through its authenticated connect URL",293+ withCleanSyncState(async () => {294+ const derivedSecret = "d".repeat(43);295+ const connectSecret = "c".repeat(43);296+ const requestedUrls: string[] = [];297+ const fetcher: typeof fetch = (input, init) => {298+ const url = String(input);299+ requestedUrls.push(url);300+ if (url.endsWith("/derive-sync-ticket")) {301+ return Promise.resolve(302+ new Response(303+ JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),304+ { status: 200 },305+ ),306+ );307+ }308+ if (url.endsWith("/pop/challenge")) {309+ return Promise.resolve(310+ new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {311+ status: 200,312+ }),313+ );314+ }315+ if (url.endsWith("/pop/answer") && init?.method === "POST") {316+ return Promise.resolve(317+ new Response(JSON.stringify({ v: 1, connect: connectSecret }), { status: 200 }),318+ );319+ }320+ throw new Error(`unexpected fetch: ${url}`);321+ };322+ let preflightUrl = "";323+ await performTicketEnrollment(provisionTicket, {324+ fetcher,325+ keyStore: memoryDeviceKeyStore(),326+ preflight: (url) => {327+ preflightUrl = url;328+ return answering("deployed")();329+ },330+ elect: () => Promise.resolve(undefined as never),331+ });332+ assert(333+ preflightUrl ===334+ `http://192.168.1.10:4802/t/${derivedSecret}/c/${connectSecret}`,335+ `store-status must use the PoP-authenticated connect URL (received ${preflightUrl})`,336+ );337+ assert(338+ requestedUrls.some((url) => url.endsWith("/pop/challenge")) &&339+ requestedUrls.some((url) => url.endsWith("/pop/answer")),340+ "enrollment must complete the PoP exchange before preflight",341+ );342+ }),343+);344+345+test(346+ "a no_schema answer through a PoP connect URL still rolls enrollment back",347+ withCleanSyncState(async () => {348+ const fetcher: typeof fetch = (input) => {349+ const url = String(input);350+ if (url.endsWith("/derive-sync-ticket")) {351+ return Promise.resolve(352+ new Response(353+ JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),354+ { status: 200 },355+ ),356+ );357+ }358+ if (url.endsWith("/pop/challenge")) {359+ return Promise.resolve(360+ new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {361+ status: 200,362+ }),363+ );364+ }365+ if (url.endsWith("/pop/answer")) {366+ return Promise.resolve(367+ new Response(JSON.stringify({ v: 1, connect: "c".repeat(43) }), { status: 200 }),368+ );369+ }370+ throw new Error(`unexpected fetch: ${url}`);371+ };372+ let thrown: unknown;373+ try {374+ await performTicketEnrollment(provisionTicket, {375+ fetcher,376+ keyStore: memoryDeviceKeyStore(),377+ preflight: answering("no_schema"),378+ elect: () => Promise.reject(new Error("elect must not run")),379+ });380+ } catch (error) {381+ thrown = error;382+ }383+ assert(384+ isSyncEnrollmentError(thrown) && thrown.code === "no_schema",385+ `the authenticated no-schema answer must retain the enrollment safety gate (received ${386+ thrown instanceof Error ? `${thrown.name}: ${thrown.message}` : String(thrown)387+ })`,388+ );389+ assert(readDeclaredSink() === null, "the PoP-bound sink must be rolled back");390+ assert(391+ !provisionCapabilityStatus().held,392+ "the provision capability must not be held after the refused enrollment",393+ );394+ }),395+);396+291397 test(292398 "electing under a foreign owner is refused; an unclaimed election records the owner",293399 withCleanSyncState(async () => {@@ -97,8 +97,12 @@ Deno.test("a hung or failing preflight degrades to store_unavailable", async ()
9797 );9898 });9999 100-Deno.test("only a /t/<secret> path counts as a ticket-gated server URL", () => {100+Deno.test("only ticket and PoP connect paths count as ticket-gated server URLs", () => {101101 assert(isTicketServerUrl(ticketUrl), "a valid ticket URL must be recognized");102+ assert(103+ isTicketServerUrl(`${ticketUrl}/c/${"c".repeat(43)}`),104+ "a valid PoP connect URL must be recognized",105+ );102106 assert(103107 !isTicketServerUrl("https://sync.example.com"),104108 "a first-party server URL must not be probed",@@ -107,6 +111,10 @@ Deno.test("only a /t/<secret> path counts as a ticket-gated server URL", () => {
107111 !isTicketServerUrl("https://node.example/t/short"),108112 "a short secret segment must not be treated as a ticket path",109113 );114+ assert(115+ !isTicketServerUrl(`${ticketUrl}/c/short`),116+ "a short connect token must not be treated as a ticket path",117+ );110118 assert(!isTicketServerUrl("not a url"), "a malformed URL must not be treated as a ticket path");111119 });112120 @@ -54,6 +54,8 @@ export type JournalWriteStage = "saved" | "synced" | "rejected";
5454 export type JournalWriteRecord = {5555 /** The stable package-owned write id. */5656 writeId: string;57+ /** Parent obligation retaining this child until it commits, or `null`. */58+ retainedBy?: string | null;5759 /** The declaring verb's name, or `null` for writes without a verb. */5860 verb: string | null;5961 /** The written table's name. */@@ -250,11 +252,7 @@ function localStorageStorage(key: string): JournalStorage | null {
250252 }251253 },252254 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- }255+ localStorage.setItem(key, text);258256 return Promise.resolve();259257 },260258 };@@ -280,6 +278,7 @@ export class WriteJournal {
280278 readonly #storage: JournalStorage;281279 #document: JournalDocument = emptyJournal();282280 #chain: Promise<void> = Promise.resolve();281+ #lastAttempt: Promise<void> = Promise.resolve();283282 #loaded = false;284283 285284 /** Creates a journal over one storage location. */@@ -305,13 +304,13 @@ export class WriteJournal {
305304 update(mutate: (document: JournalDocument) => void): void {306305 mutate(this.#document);307306 const snapshot = JSON.stringify(this.#document);308- this.#chain = this.#chain309- .then(() => this.#storage.save(snapshot))310- .catch(() => undefined);307+ const attempt = this.#chain.then(() => this.#storage.save(snapshot));308+ this.#lastAttempt = attempt;309+ this.#chain = attempt.catch(() => undefined);311310 }312311 313312 /** Resolves once every scheduled persistence has settled. */314313 flush(): Promise<void> {315- return this.#chain;314+ return this.#lastAttempt;316315 }317316 }@@ -24,12 +24,19 @@ import {
2424 type EffectContext,2525 type EffectRow,2626 type EffectUnit,27+ isPermanentEffectError,2728 type MutationDescriptor,2829 resolveEffectUnit,2930 setMutationRuntime,3031 } from "../schema/effects.ts";3132 import { appId, syncing } from "./config.ts";32-import { recordEffectLogEntry, type RuntimeDiagnostics } from "./diagnostics.ts";33+import {34+ recordEffectDebugEvent,35+ recordEffectLogEntry,36+ recordEffectTrace,37+ type RuntimeDiagnostics,38+} from "./diagnostics.ts";39+import { createDefaultNoticeStorage, type NoticeEntry, NoticeQueue } from "./notice-queue.ts";3340 import { settleDurableWrite } from "./durability.ts";3441 import { classifyMutationError } from "./mutation-taxonomy.ts";3542 import {@@ -44,6 +51,7 @@ import { createWriteHandle, type WriteHandle, type WriteHandleController } from
4451 import {4552 createDefaultJournalStorage,4653 hashJournalValue,54+ type JournalDocument,4755 type JournalEffectState,4856 journalIdFor,4957 type JournalStorage,@@ -103,6 +111,10 @@ export type LedgerWriteOptions = {
103111 units?: readonly EffectUnit<{ id: string }>[];104112 /** The intent's lifespan in milliseconds, or `null` for none. */105113 expiresAfterMs?: number | null;114+ /** Internal deterministic identity for a chain child. */115+ writeId?: string;116+ /** Parent obligation retaining this chain child until parent completion. */117+ retainedBy?: string | null;106118 };107119 108120 type VendorWrite = {@@ -272,7 +284,7 @@ export class WriteLedger {
272284 */273285 perform<T>(request: LedgerWriteRequest, options: LedgerWriteOptions = {}): WriteHandle<T> {274286 if (this.#disposed) throw new Error("write ledger has been disposed");275- const { handle, controller } = createWriteHandle<T>(crypto.randomUUID());287+ const { handle, controller } = createWriteHandle<T>(options.writeId ?? crypto.randomUUID());276288 const db = this.#db;277289 const guard = this.#environment.guardWrite;278290 if (db && !guard) {@@ -321,12 +333,17 @@ export class WriteLedger {
321333 }322334 323335 /** Performs one declared verb call; the verb dispatcher's entry point. */324- performVerb(descriptor: MutationDescriptor, args: readonly unknown[]): WriteHandle<unknown> {336+ performVerb(337+ descriptor: MutationDescriptor,338+ args: readonly unknown[],339+ internal: Pick<LedgerWriteOptions, "writeId" | "retainedBy"> = {},340+ ): WriteHandle<unknown> {325341 const table = descriptor.op.table as TableProxy<unknown, unknown>;326342 const options: LedgerWriteOptions = {327343 verb: descriptor.verbName,328344 units: descriptor.units,329345 expiresAfterMs: descriptor.expiresAfterMs,346+ ...internal,330347 };331348 if (descriptor.op.kind === "insert") {332349 return this.perform({ kind: "insert", table, values: args[0] }, options);@@ -342,6 +359,22 @@ export class WriteLedger {
342359 return this.perform({ kind: "remove", table, id: args[0] as string }, options);343360 }344361 362+ /** Performs a chain child once and retains its record through parent commit. */363+ async performChainedVerb(364+ descriptor: MutationDescriptor,365+ args: readonly unknown[],366+ parentJournalId: string,367+ ): Promise<void> {368+ const writeId = `chain:${parentJournalId}`;369+ if (this.#journal.document.writes[writeId]) {370+ await this.#journal.flush();371+ return;372+ }373+ const handle = this.performVerb(descriptor, args, { writeId, retainedBy: parentJournalId });374+ await handle.saved;375+ await this.#journal.flush();376+ }377+345378 /** Re-attempts outstanding obligations of one effect name after late registration. */346379 retryObligationsFor(effectName: string): void {347380 for (const entry of Object.values(this.#journal.document.writes)) {@@ -557,6 +590,7 @@ export class WriteLedger {
557590 const createdAt = this.#environment.now();558591 const entry: JournalWriteRecord = {559592 writeId: handle.writeId,593+ retainedBy: options.retainedBy ?? null,560594 verb: options.verb ?? null,561595 table: (request.table as { _table?: string })._table ?? "",562596 op: request.kind,@@ -723,8 +757,9 @@ export class WriteLedger {
723757 const fate = entry.stage === "synced" ? "synced" as const : "rejected" as const;724758 const handler = fate === "synced" ? unit.handlers.onSynced : unit.handlers.onRejected;725759 if (!handler) {726- this.#journal.update(() => {760+ this.#journal.update((document) => {727761 state.status = "done";762+ this.#releaseRetainedChildren(document, journalId);728763 });729764 this.#afterChange();730765 return;@@ -743,7 +778,9 @@ export class WriteLedger {
743778 writeId: entry.writeId,744779 verb: entry.verb,745780 table: entry.table,781+ op: entry.op,746782 rowId: entry.rowId,783+ writeCreatedAt: entry.createdAt,747784 fate,748785 cause: fate === "rejected" ? entry.cause ?? "denied" : null,749786 code: entry.code,@@ -751,9 +788,10 @@ export class WriteLedger {
751788 };752789 try {753790 await handler(row as EffectRow<{ id: string }>, context);754- this.#journal.update(() => {791+ this.#journal.update((document) => {755792 state.status = "done";756793 state.lastError = null;794+ this.#releaseRetainedChildren(document, journalId);757795 });758796 } catch (error) {759797 const maxAttempts = unit.maxAttempts ?? defaultMaxAttempts;@@ -764,9 +802,11 @@ export class WriteLedger {
764802 this.#environment.updateDiagnostics((diagnostics) =>765803 diagnostics.effectHandlerFailures += 1766804 );767- // Quarantine: a handler that keeps failing retires instead of768- // re-arming forever; the retirement is diagnosable and prunable.769- if (state.attempts >= maxAttempts) {805+ // A handler that declared its failure permanent retires now: retrying806+ // a request the receiver will keep refusing only burns the budget and807+ // delays the quarantine diagnostic. Otherwise a failing handler808+ // re-arms until repeated attempts quarantine it.809+ if (isPermanentEffectError(error) || state.attempts >= maxAttempts) {770810 this.#retireObligation(entry, effectName, "failed-permanent");771811 }772812 }@@ -808,6 +848,12 @@ export class WriteLedger {
808848 return { id: entry.rowId };809849 }810850 851+ #releaseRetainedChildren(document: JournalDocument, parentJournalId: string): void {852+ for (const child of Object.values(document.writes)) {853+ if (child.retainedBy === parentJournalId) child.retainedBy = null;854+ }855+ }856+811857 #afterChange(): void {812858 const document = this.#journal.document;813859 for (const entry of Object.values(document.writes)) {@@ -818,7 +864,7 @@ export class WriteLedger {
818864 }819865 const settled = Object.values(entry.effects)820866 .every((state) => isRetired(state.status));821- if (settled) {867+ if (settled && !entry.retainedBy) {822868 // Fully settled entries leave the journal; failed-but-not-quarantined823869 // obligations keep theirs so the next boot can re-arm the handler.824870 this.#journal.update(() => {@@ -898,10 +944,66 @@ export function armWriteLedger(): Promise<void> {
898944 return getWriteLedger().arm();899945 }900946 947+let defaultNotices: NoticeQueue | null = null;948+let noticeSweepTimer: ReturnType<typeof setInterval> | null = null;949+950+/**951+ * The package-wide durable notice queue behind `s.notice`, created lazily and952+ * kept in sync with the `activeNotices` diagnostic. Public read/subscribe/953+ * dismiss surfaces ({@link listNotices}, {@link subscribeNotices},954+ * {@link dismissNotice}) are re-exported from the runtime entry.955+ */956+export function getNoticeQueue(): NoticeQueue {957+ if (!defaultNotices) {958+ const queue = new NoticeQueue(959+ createDefaultNoticeStorage(appId),960+ Date.now,961+ (count) =>962+ updateRuntimeDiagnostics((diagnostics) => {963+ diagnostics.activeNotices = count;964+ }),965+ );966+ defaultNotices = queue;967+ // Load persisted entries; the merged initial count publishes through the968+ // queue's own onCountChange. A storage-less or prerender context simply969+ // starts empty.970+ void queue.load().catch(() => undefined);971+ // A TTL-only expiry has no queue mutation to repaint it, so a modest972+ // periodic sweep retires expired entries and republishes the count.973+ if (typeof setInterval === "function") {974+ noticeSweepTimer = setInterval(() => queue.sweep(), 30_000);975+ (noticeSweepTimer as { unref?: () => void })?.unref?.();976+ }977+ }978+ return defaultNotices;979+}980+981+/** The live durable notices for the current document. */982+export function listNotices(): readonly NoticeEntry[] {983+ return getNoticeQueue().list();984+}985+986+/** Subscribes to notice-queue changes; returns an unsubscribe function. */987+export function subscribeNotices(listener: () => void): () => void {988+ return getNoticeQueue().subscribe(listener);989+}990+991+/** Dismisses one durable notice by id. */992+export function dismissNotice(id: string): void {993+ getNoticeQueue().dismiss(id);994+}995+996+/** Dismisses every durable notice. */997+export function dismissAllNotices(): void {998+ getNoticeQueue().dismissAll();999+}1000+9011001 // The runtime half of the schema-declared verb surface: importing the runtime9021002 // package installs dispatch, so verbs work wherever islands import hooks.9031003 setMutationRuntime({9041004 dispatch: (descriptor, args) => getWriteLedger().performVerb(descriptor, args),1005+ dispatchChained: (descriptor, args, parentJournalId) =>1006+ getWriteLedger().performChainedVerb(descriptor, args, parentJournalId),9051007 recordLog: (label, context) =>9061008 updateRuntimeDiagnostics((diagnostics) =>9071009 recordEffectLogEntry(diagnostics, {@@ -913,10 +1015,52 @@ setMutationRuntime({
9131015 at: Date.now(),9141016 })9151017 ),1018+ recordTrace: (label, context) =>1019+ updateRuntimeDiagnostics((diagnostics) =>1020+ recordEffectTrace(diagnostics, {1021+ label,1022+ verb: context.verb,1023+ rowId: context.rowId,1024+ fate: context.fate,1025+ durationMs: Math.max(0, Date.now() - context.writeCreatedAt),1026+ at: Date.now(),1027+ })1028+ ),1029+ recordDebug: (event, context) =>1030+ updateRuntimeDiagnostics((diagnostics) =>1031+ recordEffectDebugEvent(diagnostics, {1032+ verb: context.verb,1033+ journalId: context.journalId,1034+ event,1035+ at: Date.now(),1036+ })1037+ ),1038+ enqueueNotice: (input, context) =>1039+ getNoticeQueue().enqueue({1040+ id: context.journalId,1041+ message: input.message,1042+ tone: input.tone,1043+ ttlMs: input.ttlMs,1044+ }),1045+ applyMark: async (table, rowId, patch) => {1046+ try {1047+ await getWriteLedger().perform({ kind: "update", table, id: rowId, patch }).saved;1048+ } catch {1049+ // A mark is best-effort: the status patch may find no row (a row removed1050+ // elsewhere) or be denied by policy. Swallow so the mark obligation does1051+ // not re-arm and quarantine over a patch that will never land; the1052+ // fate-as-data guarantee for must-survive signals belongs to s.notice.1053+ }1054+ },9161055 unitRegistered: (name) => defaultLedger?.retryObligationsFor(name),9171056 });9181057 9191058 import.meta.hot?.dispose(() => {9201059 defaultLedger?.dispose();9211060 defaultLedger = null;1061+ defaultNotices = null;1062+ if (noticeSweepTimer !== null) {1063+ clearInterval(noticeSweepTimer);1064+ noticeSweepTimer = null;1065+ }9221066 });@@ -1,6 +1,11 @@
11 import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";22 import { createDiagnostics } from "./diagnostics.ts";3-import type { EffectContext, EffectUnit } from "../schema/effects.ts";3+import {4+ type EffectContext,5+ type EffectUnit,6+ type MutationDescriptor,7+ PermanentEffectError,8+} from "../schema/effects.ts";49 import { assert, assertCount } from "./test-assert.ts";510 import {611 createMemoryJournalStorage,@@ -135,6 +140,7 @@ function unit(
135140 options: {136141 failFirst?: { onSynced?: boolean };137142 failAlways?: boolean;143+ failPermanent?: boolean;138144 expiresAfterMs?: number;139145 maxAttempts?: number;140146 } = {},@@ -144,6 +150,7 @@ function unit(
144150 effectName: name,145151 handlers: {146152 onSynced: (row, context) => {153+ if (options.failPermanent) throw new PermanentEffectError("receiver refused for good");147154 if (options.failAlways) throw new Error("handler always explodes");148155 if (options.failFirst?.onSynced && !failed) {149156 failed = true;@@ -747,6 +754,38 @@ Deno.test("a closed delivery window retires the obligation: no handler, diagnosa
747754 fixture.ledger.dispose();748755 });749756 757+Deno.test("a PermanentEffectError retires the obligation on the first failure", async () => {758+ const storage = createMemoryJournalStorage();759+ const calls: Array<760+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }761+ > = [];762+ // A high attempt bound proves the retirement is the permanent signal, not763+ // the attempt count: an ordinary throw would re-arm four more times.764+ const refusing = unit("permanent", calls, { failPermanent: true, maxAttempts: 5 });765+ const fixture = harness({ storage, units: new Map([["permanent", refusing]]) });766+ await fixture.ledger.arm();767+ const handle = fixture.ledger.perform<Row>(768+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "gone" } },769+ { units: [refusing] },770+ );771+ await handle;772+ fixture.db.settleGlobal();773+ await flush();774+ await fixture.ledger.flush();775+ assertCount(fixture.diagnostics.effectHandlerFailures, 1, "the failure is counted once");776+ assertCount(777+ fixture.diagnostics.quarantinedObligations,778+ 1,779+ "a permanent failure quarantines without exhausting attempts",780+ );781+ assert(782+ (storage.text() ?? "").includes(handle.writeId) === false,783+ "the retired obligation makes the entry prunable, so it never re-arms",784+ );785+ assertCount(calls.length, 0, "a permanently refused handler never reports success");786+ fixture.ledger.dispose();787+});788+750789 Deno.test("quarantine retires a permanently failing handler after its attempt bound", async () => {751790 const storage = createMemoryJournalStorage();752791 const calls: Array<@@ -784,3 +823,27 @@ Deno.test("quarantine retires a permanently failing handler after its attempt bo
784823 assertCount(calls.length, 0, "a quarantined handler never reports success");785824 second.ledger.dispose();786825 });826+827+Deno.test("a chained child uses one retained deterministic write across replay", async () => {828+ const fixture = harness();829+ await fixture.ledger.arm();830+ const descriptor: MutationDescriptor = {831+ verbName: "createChild",832+ op: { kind: "insert", table: table as TableProxy<unknown, unknown> },833+ units: [],834+ expiresAfterMs: null,835+ };836+ const parentJournalId = "parent-write:chain#0";837+ await fixture.ledger.performChainedVerb(descriptor, [{ title: "child" }], parentJournalId);838+ assertCount(fixture.db.nextBatch, 1, "the first delivery must issue one child mutation");839+ const writeId = `chain:${parentJournalId}`;840+ const first = JSON.parse(fixture.storage.text() ?? "{}") as JournalDocument;841+ assert(842+ first.writes[writeId]?.retainedBy === parentJournalId,843+ "the child record must remain retained until its parent obligation commits",844+ );845+846+ await fixture.ledger.performChainedVerb(descriptor, [{ title: "child" }], parentJournalId);847+ assertCount(fixture.db.nextBatch, 1, "replay must observe the retained child, not issue another");848+ fixture.ledger.dispose();849+});@@ -0,0 +1,361 @@
1+/// <reference path="../runtime/env.d.ts" />2+/**3+ * The built-in effect library: reusable {@link EffectUnit}s tiered by risk,4+ * each idempotent by construction so it doubles as a reference implementation5+ * of the at-least-once discipline a custom unit author must follow.6+ *7+ * The tiers are a learning ramp, one new concept each:8+ *9+ * - **Observation** — {@link trace}, {@link debug}. Cannot change anything;10+ * they only record. (The first observation unit, `s.log`, lives in11+ * `effects.ts` beside the core because the runtime records it directly.)12+ * - **Data-internal** — {@link notice}, {@link mark}, {@link chain}. Write13+ * back into the app: a durable message, a status column, a follow-up verb.14+ * Fate stops being a callback and becomes replicated data.15+ * - **External** — {@link webhook}. Calls the outside world, where the16+ * idempotency key and a bounded, backed-off retry earn their keep.17+ *18+ * Every unit here is built on the public authoring surface from `effects.ts`19+ * ({@link effect}, {@link EffectContext}, {@link PermanentEffectError}, and the20+ * optional runtime capabilities the package runtime installs) — nothing21+ * private. The authoring guide points custom-unit authors at these as models.22+ *23+ * @module24+ */25+26+import type { TableProxy } from "jazz-tools";27+import type { WriteHandle } from "../runtime/write-handle.ts";28+import {29+ anonymousUnit,30+ cachedBuiltin,31+ dispatchChainedMutation,32+ type EffectContext,33+ type EffectHandlers,34+ type EffectRow,35+ type EffectUnit,36+ type NoticeInput,37+ PermanentEffectError,38+ requireMutationRuntime,39+} from "./effects.ts";40+41+/**42+ * Observation unit: a span from the write's journaling to its settled fate,43+ * recorded in runtime diagnostics as an OpenTelemetry-shaped event with the44+ * saved→synced/rejected latency. Pure instrumentation — it changes no state45+ * and cannot fail a write. The optional label groups related spans; without46+ * one the verb name labels the span. Repeated calls with one label share a47+ * unit, so a label can be reused across verbs.48+ *49+ * @example50+ * ```ts51+ * export const placeOrder = s.mutation("placeOrder", s.insert(app.orders), {52+ * effects: [s.trace("checkout")],53+ * });54+ * ```55+ */56+export function trace(label?: string): EffectUnit<{ id: string }> {57+ if (label !== undefined && !label.trim()) throw new Error("trace label must not be empty");58+ const name = label ? `trace:${label}` : "trace";59+ return cachedBuiltin(name, () => {60+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {61+ requireMutationRuntime().recordTrace?.(label ?? null, context);62+ };63+ return { onSynced: record, onRejected: record };64+ });65+}66+67+/**68+ * Observation unit: a development-only timeline entry for each fate an69+ * obligation settles, for eyeballing effect delivery in the inspector.70+ * Stripped from production builds — in a `PROD` bundle the handlers record71+ * nothing, so it costs a closure and no more.72+ *73+ * @example74+ * ```ts75+ * export const editNote = s.mutation("editNote", s.update(app.notes), {76+ * effects: [s.debug()],77+ * });78+ * ```79+ */80+export function debug(): EffectUnit<{ id: string }> {81+ return cachedBuiltin("debug", () => {82+ const production = !import.meta.env.DEV;83+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {84+ if (production) return;85+ requireMutationRuntime().recordDebug?.(context.fate, context);86+ };87+ return { onSynced: record, onRejected: record };88+ });89+}90+91+/** A notice message resolved from the settled row, or a static string. */92+export type NoticeResolver<Row> = string | ((row: EffectRow<Row>) => string);93+94+/** Per-fate notice configuration for {@link notice}. */95+export type NoticeConfig<Row> = {96+ /** The message (or resolver) enqueued when the write syncs. */97+ synced?: NoticeResolver<Row>;98+ /** The message (or resolver) enqueued when the write is rejected. */99+ rejected?: NoticeResolver<Row>;100+ /**101+ * Lifespan of an enqueued entry before the durable queue retires it. Omit102+ * for the queue default; pass `null` to keep the entry until dismissed.103+ */104+ ttlMs?: number | null;105+};106+107+function resolveNotice<Row>(resolver: NoticeResolver<Row>, row: EffectRow<Row>): string {108+ return typeof resolver === "function" ? resolver(row) : resolver;109+}110+111+/**112+ * Data-internal unit: enqueues a durable, user-visible message when the write113+ * settles — the fix for the "a rejected write still flashed success" failure114+ * mode. The queue is durable and UI-agnostic: an entry created at a boot115+ * re-arm survives with nothing mounted, and a component (the built-in notices116+ * surface, or an author's) renders it. Toasts are a userland wrapper over the117+ * queue, never an imperative call from here.118+ *119+ * Idempotent by the obligation's journal id: a re-delivered handler enqueues120+ * the same keyed entry once, so a crash-and-replay shows one message.121+ *122+ * @example123+ * ```ts124+ * export const publish = s.mutation("publish", s.update(app.posts), {125+ * effects: [s.notice<Post>({126+ * synced: "Published.",127+ * rejected: (post) => `Could not publish "${post.title}".`,128+ * })],129+ * });130+ * ```131+ */132+export function notice<Row extends { id: string } = { id: string }>(133+ config: NoticeConfig<Row>,134+): EffectUnit<Row> {135+ if (config.synced === undefined && config.rejected === undefined) {136+ throw new Error("notice must configure at least one of synced or rejected");137+ }138+ const ttlMs = config.ttlMs;139+ const enqueue = (140+ resolver: NoticeResolver<Row> | undefined,141+ tone: NoticeInput["tone"],142+ ) =>143+ async (row: EffectRow<{ id: string }>, context: EffectContext) => {144+ if (resolver === undefined) return;145+ const runtime = requireMutationRuntime();146+ await runtime.enqueueNotice?.(147+ { message: resolveNotice(resolver, row as EffectRow<Row>), tone, ttlMs: ttlMs ?? null },148+ context,149+ );150+ };151+ return anonymousUnit<{ id: string }>("notice", {152+ onSynced: enqueue(config.synced, "success"),153+ onRejected: enqueue(config.rejected, "error"),154+ }) as unknown as EffectUnit<Row>;155+}156+157+/** Per-fate row patches for {@link mark}. */158+export type MarkConfig<Init> = {159+ /** The patch applied to the row when the write syncs. */160+ synced?: Partial<Init>;161+ /** The patch applied to the row when the write is rejected. */162+ rejected?: Partial<Init>;163+};164+165+/**166+ * Data-internal unit: patches the written row when its fate resolves, so write167+ * fate becomes replicated data every device and query sees — the hand-rolled168+ * `status` column made declarative. The patch is absolute (a static169+ * set-column-to-value object), so it is convergent under re-delivery: applying170+ * it twice lands the same row.171+ *172+ * A rejected *insert* has no row to mark — the engine rolled it out — so the173+ * rejected patch is skipped for inserts; on updates and removes the row174+ * survives the rollback and the patch records the failure.175+ *176+ * The mark is best-effort: its patch is an ordinary update that the store may177+ * itself deny (a policy that governs the status column), or that finds no row178+ * (an update whose row was concurrently removed elsewhere). In those cases the179+ * mark simply does not land — it is a convenience over the manual status180+ * column, not a delivery guarantee. For a fate signal that must survive, pair181+ * it with {@link notice} (durable queue) or a `webhook`.182+ *183+ * @example184+ * ```ts185+ * export const submit = s.mutation("submit", s.update(app.claims), {186+ * effects: [s.mark(app.claims, {187+ * synced: { status: "confirmed" },188+ * rejected: { status: "failed" },189+ * })],190+ * });191+ * ```192+ */193+export function mark<T extends { id: string }, Init>(194+ table: TableProxy<T, Init>,195+ config: MarkConfig<Init>,196+): EffectUnit<T> {197+ if (config.synced === undefined && config.rejected === undefined) {198+ throw new Error("mark must configure at least one of synced or rejected");199+ }200+ const apply = async (201+ patch: Partial<Init> | undefined,202+ row: EffectRow<T>,203+ skipForInsertOp: boolean,204+ context: EffectContext,205+ ) => {206+ if (patch === undefined) return;207+ // A rejected insert left no row behind; patching a vanished id would only208+ // fail the obligation into a pointless retry.209+ if (skipForInsertOp && context.op === "insert") return;210+ await requireMutationRuntime().applyMark?.(211+ table as TableProxy<unknown, unknown>,212+ row.id,213+ patch as Record<string, unknown>,214+ );215+ };216+ return anonymousUnit<T>("mark", {217+ onSynced: (row, context) => apply(config.synced, row, false, context),218+ onRejected: (row, context) => apply(config.rejected, row, true, context),219+ });220+}221+222+/**223+ * Data-internal unit: issues a follow-up verb once the write syncs, mapping224+ * the settled row to the next verb's input. Reifies a chain of writes225+ * (reserve → charge → fulfill) declaratively, without a saga API — each link226+ * is an ordinary verb, so its own effects and rejection handling apply. Fires227+ * only on `synced`; a rejected write starts no chain.228+ *229+ * The follow-up is journaled under a deterministic child write id derived230+ * from this obligation. Its record remains retained until the parent commits231+ * delivery, so a crash between those steps reuses the child instead of232+ * issuing the mutation again.233+ *234+ * @example235+ * ```ts236+ * export const reserve = s.mutation("reserve", s.insert(app.holds), {237+ * effects: [s.chain(charge, (hold) => ({ holdId: hold.id, amount: hold.total }))],238+ * });239+ * ```240+ */241+export function chain<Row extends { id: string }, NextInput, NextResult>(242+ next: (input: NextInput) => WriteHandle<NextResult>,243+ toInput: (row: EffectRow<Row>) => NextInput,244+): EffectUnit<Row> {245+ return anonymousUnit<{ id: string }>("chain", {246+ onSynced: async (row, context) => {247+ // The ledger derives the child write id from this obligation and keeps248+ // its record until the parent is committed done. A replay therefore249+ // observes the existing child instead of issuing the mutation twice.250+ const input = toInput(row as EffectRow<Row>);251+ await dispatchChainedMutation(next, [input], context.journalId);252+ },253+ }) as unknown as EffectUnit<Row>;254+}255+256+/** Options for the {@link webhook} unit. */257+export type WebhookOptions = {258+ /** Which fates POST; defaults to both. */259+ on?: ReadonlyArray<"synced" | "rejected">;260+ /**261+ * Delivery window in milliseconds. Receiver idempotency windows are finite262+ * (Stripe forgets keys after ~24h), so external delivery defaults to a263+ * conservative 24 hours rather than infinity; pass `null` to opt into no264+ * expiry.265+ *266+ * @default 86_400_000267+ */268+ expiresAfterMs?: number | null;269+ /** Failing attempts before the obligation is quarantined. */270+ maxAttempts?: number;271+ /** Extra headers merged onto the POST (the idempotency key is always set). */272+ headers?: Record<string, string>;273+};274+275+const defaultWebhookExpiry = 24 * 60 * 60 * 1000;276+277+/**278+ * External unit: POSTs the settled row and its fate to `url`, using `name` as279+ * its safe durable identity and with the280+ * obligation's journal id auto-injected as `Idempotency-Key` so a re-delivery281+ * the receiver already saw is dropped receiver-side. The generic282+ * outside-world workhorse and the reference for the at-least-once contract.283+ *284+ * Failure severity follows the response: a transient failure (network error,285+ * `5xx`, `429`) throws an ordinary error, so the ledger's bounded backoff286+ * retries it; a `4xx` (other than `429`) throws {@link PermanentEffectError},287+ * retiring the obligation without burning the retry budget on a request the288+ * receiver will keep refusing. Because receiver keys expire, delivery defaults289+ * to a 24-hour window ({@link WebhookOptions.expiresAfterMs}).290+ *291+ * @example292+ * ```ts293+ * export const order = s.mutation("order", s.insert(app.orders), {294+ * effects: [s.webhook("orders", "https://hooks.example.com/orders")],295+ * });296+ * ```297+ */298+export function webhook(299+ name: string,300+ url: string,301+ options: WebhookOptions = {},302+): EffectUnit<{ id: string }> {303+ if (!name.trim()) throw new Error("webhook name must not be empty");304+ if (!url.trim()) throw new Error("webhook url must not be empty");305+ const fates = new Set(options.on ?? ["synced", "rejected"]);306+ const configuredHeaders = new Headers(options.headers);307+ configuredHeaders.delete("content-type");308+ configuredHeaders.delete("idempotency-key");309+ const signature = JSON.stringify({310+ url,311+ on: [...fates].sort(),312+ expiresAfterMs: options.expiresAfterMs === undefined313+ ? defaultWebhookExpiry314+ : options.expiresAfterMs,315+ maxAttempts: options.maxAttempts ?? null,316+ headers: [...configuredHeaders.entries()].sort(([left], [right]) => left.localeCompare(right)),317+ });318+ const build = (): EffectHandlers<{ id: string }> => {319+ const post = async (row: EffectRow<{ id: string }>, context: EffectContext) => {320+ if (!fates.has(context.fate)) return;321+ let response: Response;322+ try {323+ const headers = new Headers(configuredHeaders);324+ headers.set("content-type", "application/json");325+ headers.set("idempotency-key", context.journalId);326+ response = await fetch(url, {327+ method: "POST",328+ headers,329+ body: JSON.stringify({330+ journalId: context.journalId,331+ verb: context.verb,332+ table: context.table,333+ op: context.op,334+ fate: context.fate,335+ code: context.code,336+ reason: context.reason,337+ row,338+ }),339+ });340+ } catch {341+ // A network-level failure is transient by nature: rethrow so the342+ // ledger retries with backoff inside the delivery window.343+ throw new Error(`webhook "${name}" unreachable`);344+ }345+ if (response.ok) return;346+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {347+ throw new PermanentEffectError(`webhook "${name}" refused with ${response.status}`);348+ }349+ throw new Error(`webhook "${name}" transient failure ${response.status}`);350+ };351+ return { onSynced: post, onRejected: post };352+ };353+ // Only the author-chosen name is durable. Configuration (including headers)354+ // is compared in memory so secrets never enter journal identifiers.355+ return cachedBuiltin(`webhook:${name}`, build, {356+ expiresAfterMs: options.expiresAfterMs === undefined357+ ? defaultWebhookExpiry358+ : options.expiresAfterMs,359+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),360+ }, signature);361+}@@ -0,0 +1,331 @@
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 { chain, mark, notice, trace, webhook } from "./effect-library.ts";5+import {6+ clearEffectDeclarations,7+ effect,8+ type EffectContext,9+ insert,10+ mutation,11+ type MutationRuntime,12+ type NoticeInput,13+ resolveEffectUnit,14+ setMutationRuntime,15+} from "./effects.ts";16+17+const app = schema.defineApp({18+ claims: schema.table({ title: schema.string(), status: schema.string() }),19+});20+type Claim = schema.RowOf<typeof app.claims>;21+22+type Recorder = {23+ traces: Array<{ label: string | null; context: EffectContext }>;24+ notices: Array<{ input: NoticeInput; context: EffectContext }>;25+ marks: Array<{ rowId: string; patch: Record<string, unknown> }>;26+ dispatched: Array<{27+ descriptor: unknown;28+ args: readonly unknown[];29+ parentJournalId?: string;30+ }>;31+};32+33+function install(): Recorder {34+ const recorder: Recorder = { traces: [], notices: [], marks: [], dispatched: [] };35+ const runtime: MutationRuntime = {36+ dispatch(descriptor, args) {37+ recorder.dispatched.push({ descriptor, args });38+ return { saved: Promise.resolve() } as unknown as WriteHandle<unknown>;39+ },40+ dispatchChained(descriptor, args, parentJournalId) {41+ recorder.dispatched.push({ descriptor, args, parentJournalId });42+ return Promise.resolve();43+ },44+ recordLog() {},45+ recordTrace(label, context) {46+ recorder.traces.push({ label, context });47+ },48+ enqueueNotice(input, context) {49+ recorder.notices.push({ input, context });50+ return Promise.resolve();51+ },52+ applyMark(_table, rowId, patch) {53+ recorder.marks.push({ rowId, patch });54+ return Promise.resolve();55+ },56+ };57+ setMutationRuntime(runtime);58+ return recorder;59+}60+61+function context(overrides: Partial<EffectContext> = {}): EffectContext {62+ return {63+ journalId: "w1:unit#1",64+ writeId: "w1",65+ verb: "submit",66+ table: "claims",67+ op: "update",68+ rowId: "row-1",69+ writeCreatedAt: 500,70+ fate: "synced",71+ cause: null,72+ code: null,73+ reason: null,74+ ...overrides,75+ };76+}77+78+// The identity guarantee: an anonymous built-in takes its durable name from79+// the verb it is attached to and its position in that verb's effects, NOT from80+// a global declaration counter. This is what makes a journaled notice/mark/81+// chain obligation re-arm against the same logical unit regardless of which82+// module evaluated first on the re-arming boot.83+Deno.test("anonymous units are named <verb>#<index>, stable and verb-scoped", () => {84+ clearEffectDeclarations();85+ install();86+ const orders = schema.defineApp({ orders: schema.table({ item: schema.string() }) }).orders;87+ mutation("placeOrder", insert(orders), {88+ effects: [notice({ synced: "Placed." }), mark(orders, { synced: { item: "x" } })],89+ });90+ mutation("reorder", insert(orders), {91+ effects: [notice({ synced: "Reordered." })],92+ });93+ // Position within the verb, not a global counter: reorder's notice is #0,94+ // not #2, so adding placeOrder above it cannot shift reorder's identity.95+ assert(resolveEffectUnit("placeOrder#0") !== null, "the first unit is <verb>#0");96+ assert(resolveEffectUnit("placeOrder#1") !== null, "the second unit is <verb>#1");97+ assert(resolveEffectUnit("reorder#0") !== null, "a second verb restarts the index at 0");98+ assert(resolveEffectUnit("reorder#1") === null, "reorder declares only one anonymous unit");99+ clearEffectDeclarations();100+});101+102+Deno.test("trace records a span with the saved-to-fate latency on either fate", () => {103+ clearEffectDeclarations();104+ const recorder = install();105+ const unit = trace("checkout");106+ unit.handlers.onSynced?.({ id: "row-1" }, context({ writeCreatedAt: 500 }));107+ unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));108+ assertCount(recorder.traces.length, 2, "both fates must record a span");109+ assert(recorder.traces[0].label === "checkout", "the span must carry the author label");110+ clearEffectDeclarations();111+});112+113+// The content-named built-ins share one unit per identity instead of throwing114+// a duplicate-name error when reused across verbs.115+Deno.test("trace and webhook share one unit per durable identity", () => {116+ clearEffectDeclarations();117+ install();118+ assert(trace("checkout") === trace("checkout"), "one label shares one trace unit");119+ assert(trace() === trace(), "the unlabeled trace shares one unit");120+ const url = "https://hooks.example.com/x";121+ assert(webhook("orders", url) === webhook("orders", url), "one name+config shares one unit");122+ let mismatched = false;123+ try {124+ webhook("orders", url, { maxAttempts: 9 });125+ } catch (error) {126+ mismatched = !(error as Error).message.includes(url);127+ }128+ assert(mismatched, "reusing a name with different config must fail without leaking config");129+ assert(130+ webhook("orders-v2", url, { maxAttempts: 9 }) !== webhook("orders", url),131+ "a distinct author name creates a distinct durable unit",132+ );133+ clearEffectDeclarations();134+});135+136+Deno.test("notice enqueues a message keyed by the obligation journal id", async () => {137+ clearEffectDeclarations();138+ const recorder = install();139+ const unit = notice<Claim>({140+ synced: "Submitted.",141+ rejected: (claim) => `Could not submit "${claim.title ?? "claim"}".`,142+ });143+ await unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:notice#1" }));144+ await unit.handlers.onRejected?.(145+ { id: "row-1", title: "roof" } as Claim & { id: string },146+ context({ fate: "rejected", journalId: "w2:notice#1" }),147+ );148+ assertCount(recorder.notices.length, 2, "each fate with a message enqueues one notice");149+ assert(150+ recorder.notices[0].input.message === "Submitted.",151+ "the static message must pass through",152+ );153+ assert(recorder.notices[0].input.tone === "success", "a synced notice is a success tone");154+ assert(155+ recorder.notices[1].input.message === 'Could not submit "roof".',156+ "the resolver must see the settled row",157+ );158+ assert(recorder.notices[1].input.tone === "error", "a rejected notice is an error tone");159+ clearEffectDeclarations();160+});161+162+Deno.test("notice fires only the configured fate", async () => {163+ clearEffectDeclarations();164+ const recorder = install();165+ const unit = notice({ rejected: "Failed." });166+ await unit.handlers.onSynced?.({ id: "row-1" }, context());167+ assertCount(recorder.notices.length, 0, "an unconfigured synced fate enqueues nothing");168+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));169+ assertCount(recorder.notices.length, 1, "the configured rejected fate enqueues");170+ clearEffectDeclarations();171+});172+173+Deno.test("mark patches the row on synced and on a rejected update", async () => {174+ clearEffectDeclarations();175+ const recorder = install();176+ const unit = mark(app.claims, {177+ synced: { status: "confirmed" },178+ rejected: { status: "failed" },179+ });180+ await unit.handlers.onSynced?.({ id: "row-1" }, context({ op: "update" }));181+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ op: "update", fate: "rejected" }));182+ assertCount(recorder.marks.length, 2, "both fates patch the surviving row");183+ assert(recorder.marks[0].patch.status === "confirmed", "synced must apply its patch");184+ assert(recorder.marks[1].patch.status === "failed", "a rejected update must apply its patch");185+ clearEffectDeclarations();186+});187+188+Deno.test("mark skips a rejected insert, which left no row to patch", async () => {189+ clearEffectDeclarations();190+ const recorder = install();191+ const unit = mark(app.claims, { rejected: { status: "failed" } });192+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ op: "insert", fate: "rejected" }));193+ assertCount(recorder.marks.length, 0, "a rolled-back insert has no row, so mark must skip it");194+ clearEffectDeclarations();195+});196+197+Deno.test("chain issues the follow-up verb only on synced", async () => {198+ clearEffectDeclarations();199+ const recorder = install();200+ const charge = mutation("chargeClaim", insert(app.claims));201+ const unit = chain(charge, (row: { id: string }) => ({202+ title: row.id,203+ status: "charging",204+ }));205+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));206+ assertCount(recorder.dispatched.length, 0, "a rejected write starts no chain");207+ await unit.handlers.onSynced?.({ id: "row-1" }, context());208+ assertCount(recorder.dispatched.length, 1, "a synced write issues the follow-up verb once");209+ assert(210+ (recorder.dispatched[0].args[0] as { title: string }).title === "row-1",211+ "the mapper must receive the settled row",212+ );213+ assert(214+ recorder.dispatched[0].parentJournalId === "w1:unit#1",215+ "the child must be tied to its parent obligation identity",216+ );217+ clearEffectDeclarations();218+});219+220+Deno.test("webhook injects the journal id as the idempotency key and posts the fate", async () => {221+ clearEffectDeclarations();222+ install();223+ const requests: Array<{ url: string; init: RequestInit }> = [];224+ const originalFetch = globalThis.fetch;225+ globalThis.fetch = ((url: string | URL, init?: RequestInit) => {226+ requests.push({ url: String(url), init: init ?? {} });227+ return Promise.resolve(new Response(null, { status: 200 }));228+ }) as typeof fetch;229+ try {230+ const unit = webhook("claims", "https://hooks.example.com/claims", {231+ headers: { authorization: "secret", "idempotency-key": "override" },232+ });233+ assert(unit.effectName === "webhook:claims", "durable identity must contain only the name");234+ assert(!unit.effectName.includes("secret"), "header secrets must not enter durable identity");235+ await unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:webhook#1" }));236+ assertCount(requests.length, 1, "a synced write posts once");237+ const key = new Headers(requests[0].init.headers as HeadersInit).get("idempotency-key") ?? "";238+ assert(key === "w1:webhook#1", "the journal id must ride as the idempotency key");239+ const body = JSON.parse(String(requests[0].init.body));240+ assert(body.fate === "synced", "the payload must carry the settled fate");241+ } finally {242+ globalThis.fetch = originalFetch;243+ }244+ clearEffectDeclarations();245+});246+247+Deno.test("webhook treats a 4xx as permanent and a 5xx as retryable", async () => {248+ clearEffectDeclarations();249+ install();250+ const originalFetch = globalThis.fetch;251+ const respond = (status: number) => {252+ globalThis.fetch = (() => Promise.resolve(new Response(null, { status }))) as typeof fetch;253+ };254+ try {255+ const unit = webhook("claims", "https://hooks.example.com/claims");256+ respond(400);257+ let permanent = false;258+ try {259+ await unit.handlers.onSynced?.({ id: "row-1" }, context());260+ } catch (error) {261+ permanent = (error as Error).name === "PermanentEffectError";262+ }263+ assert(permanent, "a 4xx must throw a PermanentEffectError so the ledger retires it");264+265+ respond(503);266+ let retryable = false;267+ try {268+ await unit.handlers.onSynced?.({ id: "row-1" }, context());269+ } catch (error) {270+ retryable = (error as Error).name !== "PermanentEffectError";271+ }272+ assert(retryable, "a 5xx must throw an ordinary error so the ledger retries it");273+ } finally {274+ globalThis.fetch = originalFetch;275+ }276+ clearEffectDeclarations();277+});278+279+// The contract's proof: a custom unit written with nothing but the public280+// authoring surface — s.effect, the context's journal id as the idempotency281+// key, and PermanentEffectError for a hopeless failure — behaves like a282+// built-in. This is also the authoring guide's worked example.283+Deno.test("a custom unit on the public contract is idempotent by journal id", async () => {284+ clearEffectDeclarations();285+ install();286+ const seen = new Set<string>();287+ let sends = 0;288+ const sendOnce = effect("sendReceipt", app.claims, {289+ onSynced: (_row, context) => {290+ // The at-least-once duty: dedupe on the journal id so a re-delivered291+ // handler produces one external effect.292+ if (seen.has(context.journalId)) return;293+ seen.add(context.journalId);294+ sends += 1;295+ },296+ });297+ await sendOnce.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:sendReceipt" }));298+ await sendOnce.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:sendReceipt" }));299+ assertCount(sends, 1, "a re-delivery under one journal id must produce one effect");300+ clearEffectDeclarations();301+});302+303+Deno.test("webhook defaults to a finite 24-hour delivery window", () => {304+ clearEffectDeclarations();305+ install();306+ const unit = webhook("claims", "https://hooks.example.com/claims");307+ assert(unit.expiresAfterMs === 24 * 60 * 60 * 1000, "external delivery must default to 24h");308+ const forever = webhook("claims-forever", "https://hooks.example.com/claims2", {309+ expiresAfterMs: null,310+ });311+ assert(312+ forever.expiresAfterMs === null || forever.expiresAfterMs === undefined,313+ "null must opt into no expiry",314+ );315+ clearEffectDeclarations();316+});317+318+Deno.test("webhook default expiry and explicit no-expiry do not alias", () => {319+ clearEffectDeclarations();320+ install();321+ const url = "https://hooks.example.com/expiry";322+ webhook("expiry", url);323+ let thrown = false;324+ try {325+ webhook("expiry", url, { expiresAfterMs: null });326+ } catch {327+ thrown = true;328+ }329+ assert(thrown, "explicit null must not reuse the default 24-hour configuration");330+ clearEffectDeclarations();331+});@@ -49,8 +49,12 @@ export type EffectContext = {
4949 verb: string | null;5050 /** The written table's name. */5151 table: string;52+ /** Which operation the write performed. */53+ op: "insert" | "update" | "remove";5254 /** The written row's id. */5355 rowId: string;56+ /** Epoch milliseconds when the write was journaled, for latency spans. */57+ writeCreatedAt: number;5458 /** Which fate settled the write. */5559 fate: "synced" | "rejected";5660 /**@@ -68,6 +72,43 @@ export type EffectContext = {
6872 reason: string | null;6973 };7074 75+/**76+ * Thrown from an effect handler to retire the obligation immediately instead77+ * of re-arming it. Delivery is at-least-once by default: an ordinary thrown78+ * error is *retryable* — the handler re-runs at the next boot until it79+ * succeeds or {@link EffectUnitOptions.maxAttempts} quarantines it. Some80+ * failures are known to be *permanent* — a webhook receiver answered `400`, a81+ * row a handler needed is gone for good — and retrying only burns attempts and82+ * delays the quarantine diagnostic. Throwing this retires the obligation now,83+ * counted as a permanent handler failure. The message reaches diagnostics.84+ *85+ * @example86+ * ```ts87+ * s.effect("charge", app.orders, {88+ * onSynced: async (order, { journalId }) => {89+ * const res = await fetch(url, { headers: { "Idempotency-Key": journalId } });90+ * if (res.status >= 400 && res.status < 500) {91+ * throw new PermanentEffectError(`charge refused: ${res.status}`);92+ * }93+ * if (!res.ok) throw new Error(`charge transient failure: ${res.status}`);94+ * },95+ * });96+ * ```97+ */98+export class PermanentEffectError extends Error {99+ /** Stable class name for diagnostics and the ledger's severity check. */100+ override readonly name = "PermanentEffectError";101+ /** Creates the permanent-failure signal with a diagnostics message. */102+ constructor(message: string) {103+ super(message);104+ }105+}106+107+/** True when a handler failure asked to retire rather than retry. */108+export function isPermanentEffectError(error: unknown): error is PermanentEffectError {109+ return error instanceof PermanentEffectError;110+}111+71112 /** The action and compensation handlers one effect unit pairs. */72113 export type EffectHandlers<Row> = {73114 /** Runs on the originating device once the store confirms the write. */@@ -121,6 +162,15 @@ export type EffectUnit<Row = { id: string }> = {
121162 readonly expiresAfterMs?: number | null;122163 /** Failing attempts before quarantine retires the obligation. */123164 readonly maxAttempts?: number;165+ /**166+ * Set on a built-in unit the author did not name (`s.notice`, `s.mark`,167+ * `s.chain`). Such a unit is registered lazily, when a {@link mutation}168+ * includes it: its durable name becomes `<verb>#<position>` — stable across169+ * reloads because it derives from the author-chosen verb name and the unit's170+ * fixed position in that verb's `effects`, not from module-evaluation order.171+ * Absent on named units, which register at declaration.172+ */173+ readonly anonymousPrefix?: string;124174 };125175 126176 /** Which operation a {@link MutationOp} performs. */@@ -206,12 +256,50 @@ export type MutationDescriptor = {
206256 readonly expiresAfterMs: number | null;207257 };208258 259+/**260+ * One durable notice a {@link notice} unit enqueues. The queue is persistent261+ * and UI-agnostic: entries may be created at a boot re-arm with nothing262+ * mounted, and a component renders them later. `tone` classifies the message263+ * for the render; `ttlMs` bounds its life when the author sets no explicit264+ * dismissal.265+ */266+export type NoticeInput = {267+ /** The user-facing message. */268+ message: string;269+ /** How to classify the message for rendering. */270+ tone: "info" | "success" | "warning" | "error";271+ /** Lifespan in milliseconds before the queue retires the entry, or `null`. */272+ ttlMs: number | null;273+};274+209275 /** The runtime half installed by the package runtime before verbs are called. */210276 export type MutationRuntime = {211277 /** Performs one declared verb call and returns its write handle. */212278 dispatch(descriptor: MutationDescriptor, args: readonly unknown[]): WriteHandle<unknown>;279+ /** Performs a chain child under a deterministic id retained by its parent. */280+ dispatchChained(281+ descriptor: MutationDescriptor,282+ args: readonly unknown[],283+ parentJournalId: string,284+ ): Promise<void>;213285 /** Records one structured {@link log} entry. */214286 recordLog(label: string, context: EffectContext): void;287+ /** Records one {@link trace} span from the write's journaling to its fate. */288+ recordTrace?(label: string | null, context: EffectContext): void;289+ /** Records one {@link debug} timeline event; a no-op in production builds. */290+ recordDebug?(event: string, context: EffectContext): void;291+ /** Enqueues one durable {@link notice} entry. */292+ enqueueNotice?(input: NoticeInput, context: EffectContext): Promise<void>;293+ /**294+ * Patches a row on behalf of a {@link mark} unit: a bare update carrying no295+ * further units, so write fate becomes replicated row data without296+ * recursion. Resolves at local durability; rejects if the row is gone.297+ */298+ applyMark?(299+ table: TableProxy<unknown, unknown>,300+ rowId: string,301+ patch: Record<string, unknown>,302+ ): Promise<void>;215303 /** Re-attempts outstanding journal obligations after a late unit registration. */216304 unitRegistered?(name: string): void;217305 };@@ -221,6 +309,13 @@ type EffectsSlot = {
221309 effects: Map<string, EffectUnit<{ id: string }>>;222310 verbs: Map<string, MutationDescriptor>;223311 logs: Map<string, EffectUnit<{ id: string }>>;312+ /** Cache for shared built-ins (trace, debug, webhook) keyed by durable name. */313+ cache: Map<string, CachedBuiltinEntry>;314+};315+316+type CachedBuiltinEntry = {317+ unit: EffectUnit<{ id: string }>;318+ signature: string | null;224319 };225320 226321 const slotName = "__LOFI_EFFECT_DECLARATIONS__";@@ -232,6 +327,7 @@ function slot(): EffectsSlot {
232327 effects: new Map(),233328 verbs: new Map(),234329 logs: new Map(),330+ cache: new Map(),235331 };236332 return effectsGlobal[slotName];237333 }@@ -256,6 +352,7 @@ export function clearEffectDeclarations(): void {
256352 state.effects.clear();257353 state.verbs.clear();258354 state.logs.clear();355+ state.cache.clear();259356 }260357 261358 // During dev hot replacement author modules re-evaluate routinely; the newest@@ -365,6 +462,79 @@ function requireRuntime(): MutationRuntime {
365462 return runtime;366463 }367464 465+/**466+ * The installed runtime, or a thrown explanation when none is. The built-in467+ * effect library ({@link EffectUnit} factories in `effect-library.ts`) reaches468+ * the runtime through this so it stays on the public authoring surface — the469+ * same requirement custom units must meet.470+ */471+export function requireMutationRuntime(): MutationRuntime {472+ return requireRuntime();473+}474+475+/**476+ * Builds an anonymous built-in unit (a notice, mark, or chain the author did477+ * not name) without registering it. Registration is deferred to478+ * {@link mutation}, which names it `<verb>#<position>` from the verb it is479+ * attached to and its position in that verb's `effects`. That identity is480+ * stable across reloads regardless of which module evaluates first, so a481+ * journaled obligation always re-arms against the same logical unit; the only482+ * way to orphan one is to reorder the effects within its own verb.483+ */484+export function anonymousUnit<Row extends { id: string }>(485+ prefix: string,486+ handlers: EffectHandlers<Row>,487+ options: EffectUnitOptions = {},488+): EffectUnit<Row> {489+ return {490+ effectName: "",491+ handlers,492+ anonymousPrefix: prefix,493+ expiresAfterMs: options.expiresAfterMs ?? null,494+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),495+ };496+}497+498+/**499+ * Returns the shared built-in unit registered under `name`, building and500+ * registering it on first use. For observation and external units (`s.trace`,501+ * `s.debug`, `s.webhook`) whose whole identity is their durable name, so502+ * reusing one across verbs shares a single unit instead of colliding — the503+ * same discipline {@link log} follows. A supplied in-memory signature makes504+ * reuse under different configuration fail without putting that configuration505+ * into the durable name.506+ */507+export function cachedBuiltin<Row extends { id: string }>(508+ name: string,509+ build: () => EffectHandlers<Row>,510+ options: Omit<EffectUnitOptions, "expiresAfterMs"> & { expiresAfterMs?: number | null } = {},511+ signature?: string,512+): EffectUnit<Row> {513+ const state = slot();514+ const existing = state.cache.get(name);515+ if (existing && "unit" in existing) {516+ if (existing.signature !== (signature ?? null)) {517+ throw new Error(`built-in effect "${name}" is already declared with different options`);518+ }519+ return existing.unit as unknown as EffectUnit<Row>;520+ }521+ // A dev hot-reload may retain the pre-signature cache shape. Rebuild that522+ // entry so future declarations receive the same configuration checks.523+ if (existing) state.cache.delete(name);524+ const unit: EffectUnit<Row> = {525+ effectName: name,526+ handlers: build(),527+ expiresAfterMs: options.expiresAfterMs ?? null,528+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),529+ };530+ state.cache.set(name, {531+ unit: unit as unknown as EffectUnit<{ id: string }>,532+ signature: signature ?? null,533+ });534+ registerUnit(unit as unknown as EffectUnit<{ id: string }>);535+ return unit;536+}537+368538 /**369539 * Declares a typed, callable verb: a named mutation over one table operation,370540 * carrying its effect units. Call sites invoke the verb like a function and@@ -409,12 +579,28 @@ export function mutation<T extends { id: string }, Init, Kind extends MutationOp
409579 }410580 const units: EffectUnit<{ id: string }>[] = [];411581 const seen = new Set<string>();412- for (const unit of options.effects ?? []) {582+ const seenDeclarations = new Set<EffectUnit<T>>();583+ const declared = options.effects ?? [];584+ for (let index = 0; index < declared.length; index += 1) {585+ const declaration = declared[index];586+ if (seenDeclarations.has(declaration)) {587+ throw new Error(`mutation "${name}" declares the same effect unit twice`);588+ }589+ seenDeclarations.add(declaration);590+ let unit = declaration as EffectUnit<{ id: string }>;591+ if (unit.anonymousPrefix !== undefined) {592+ // An anonymous built-in (s.notice/s.mark/s.chain) is named and593+ // registered here, from the verb it is attached to and its position in594+ // this verb's effects — a durable identity independent of module load595+ // order. Register a finalized copy so the ledger resolves it at re-arm.596+ unit = { ...unit, effectName: `${name}#${index}`, anonymousPrefix: undefined };597+ registerUnit(unit);598+ }413599 if (seen.has(unit.effectName)) {414600 throw new Error(`mutation "${name}" declares effect "${unit.effectName}" twice`);415601 }416602 seen.add(unit.effectName);417- units.push(unit as EffectUnit<{ id: string }>);603+ units.push(unit);418604 }419605 if (options.onSynced || options.onRejected) {420606 if (seen.has(name)) {@@ -437,5 +623,23 @@ export function mutation<T extends { id: string }, Init, Kind extends MutationOp
437623 };438624 state.verbs.set(name, descriptor);439625 const verb = (...args: readonly unknown[]) => requireRuntime().dispatch(descriptor, args);626+ Object.defineProperty(verb, mutationDescriptor, { value: descriptor });440627 return verb as MutationVerb<MutationOp<T, Init, Kind>>;441628 }629+630+const mutationDescriptor = Symbol.for("@nzip/lofi/mutationDescriptor");631+632+/** Dispatches a declared mutation as a deterministic child of an effect. */633+export function dispatchChainedMutation<Input, Result>(634+ verb: (input: Input) => WriteHandle<Result>,635+ args: readonly [Input],636+ parentJournalId: string,637+): Promise<void> {638+ const descriptor = (verb as unknown as { [mutationDescriptor]?: MutationDescriptor })[639+ mutationDescriptor640+ ];641+ if (!descriptor) {642+ throw new Error("chain target must be a mutation declared with s.mutation");643+ }644+ return requireRuntime().dispatchChained(descriptor, args, parentJournalId);645+}@@ -19,6 +19,9 @@ function installRecorder(): { dispatched: Dispatched[]; logs: string[] } {
1919 dispatched.push({ descriptor, args });2020 return { stage: "saving" } as unknown as WriteHandle<unknown>;2121 },22+ dispatchChained() {23+ return Promise.resolve();24+ },2225 recordLog(label) {2326 logs.push(label);2427 },@@ -99,6 +102,19 @@ Deno.test("verb names are unique per app and collide fast", () => {
99102 clearEffectDeclarations();100103 });101104 105+Deno.test("one anonymous unit object cannot occupy two mutation positions", () => {106+ clearEffectDeclarations();107+ const unit = s.notice({ synced: "Saved." });108+ let thrown = false;109+ try {110+ s.mutation("duplicateNotice", s.insert(app.orders), { effects: [unit, unit] });111+ } catch {112+ thrown = true;113+ }114+ assert(thrown, "reusing one anonymous declaration must fail before it gains two identities");115+ clearEffectDeclarations();116+});117+102118 Deno.test("s.log reuses one unit per label and records through the runtime", () => {103119 clearEffectDeclarations();104120 const recorder = installRecorder();@@ -110,7 +126,9 @@ Deno.test("s.log reuses one unit per label and records through the runtime", ()
110126 writeId: "w1",111127 verb: "placeOrder",112128 table: "orders",129+ op: "insert",113130 rowId: "row-1",131+ writeCreatedAt: 0,114132 fate: "synced",115133 cause: null,116134 code: null,@@ -30,6 +30,7 @@
3030 */3131 import { schema } from "jazz-tools";3232 import { effect, insert, log, mutation, remove, update } from "./effects.ts";33+import { chain, debug, mark, notice, trace, webhook } from "./effect-library.ts";3334 import {3435 encryptedDate,3536 encryptedJson,@@ -100,6 +101,12 @@ export type SchemaDsl =
100101 mutation: typeof mutation;101102 effect: typeof effect;102103 log: typeof log;104+ trace: typeof trace;105+ debug: typeof debug;106+ notice: typeof notice;107+ mark: typeof mark;108+ chain: typeof chain;109+ webhook: typeof webhook;103110 insert: typeof insert;104111 update: typeof update;105112 remove: typeof remove;@@ -150,6 +157,12 @@ export const s: SchemaDsl = {
150157 mutation,151158 effect,152159 log,160+ trace,161+ debug,162+ notice,163+ mark,164+ chain,165+ webhook,153166 insert,154167 update,155168 remove,@@ -211,17 +224,32 @@ export {
211224 type EffectUnitOptions,212225 insert,213226 type InsertVerb,227+ isPermanentEffectError,214228 log,215229 mutation,216230 type MutationOp,217231 type MutationOpKind,218232 type MutationOptions,219233 type MutationVerb,234+ type NoticeInput,235+ PermanentEffectError,220236 remove,221237 type RemoveVerb,222238 update,223239 type UpdateVerb,224240 } from "./effects.ts";241+export {242+ chain,243+ debug,244+ mark,245+ type MarkConfig,246+ notice,247+ type NoticeConfig,248+ type NoticeResolver,249+ trace,250+ webhook,251+ type WebhookOptions,252+} from "./effect-library.ts";225253 // Type-only, so the authoring-only module graph stays free of runtime code:226254 // verb calls settle through the runtime write handle, and these names let227255 // schema-graph modules annotate that contract without importing the runtime.@@ -50,7 +50,7 @@ function describe(error: unknown): string {
5050 if (isAuthError(error)) {5151 switch (error.code) {5252 case "cancelled":53- return "Passkey prompt dismissed — your recovery phrase was not shown.";53+ return "Passkey verification did not complete — your recovery phrase was not shown.";5454 case "unsupported":5555 return "This browser does not support passkeys.";5656 default:@@ -2,11 +2,12 @@ import { useState } from "preact/hooks";
22 import { settleUiMutation } from "@nzip/lofi";33 import {44 type BootProgress,5+ Notices,56 useBootProgress,67 usePendingWrites,78 useSyncStatus,89 } from "@nzip/lofi/preact";9-import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";10+import { type Task, useTasks } from "./use-tasks.ts";1011 1112 // A cold first visit waits on the engine download, not on storage; name the1213 // wait it is actually in, with byte progress while the download runs.@@ -26,7 +27,6 @@ function loadingLabel(boot: BootProgress): string {
2627 */2728 export default function TaskList() {2829 const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();29- const notice = useTaskNotice();3030 const pending = usePendingWrites();3131 const boot = useBootProgress();3232 const [text, setText] = useState("");@@ -78,11 +78,7 @@ export default function TaskList() {
7878 {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync7979 </p>8080 )}81- {notice && (82- <p class="state" role="status" data-notice={notice.kind}>83- {notice.text}84- </p>85- )}81+ <Notices />8682 <ul aria-label="Tasks">8783 {tasks.map((task) => <TaskItem key={task.id} task={task} setCompleted={setCompleted} />)}8884 </ul>@@ -1,4 +1,4 @@
1-import { useCallback, useEffect, useState } from "preact/hooks";1+import { useCallback, useState } from "preact/hooks";22 import type { RowOf, WriteHandle } from "@nzip/lofi";33 import { useLiveQuery, useWrite } from "@nzip/lofi/preact";44 import { s } from "@nzip/lofi/schema";@@ -17,54 +17,27 @@ const tasksTable = app.schema.tasks;
1717 /** The row type comes straight from the declared schema. */1818 export type Task = RowOf<typeof tasksTable>;1919 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-3320 /**3421 * The verb call sites use. Its effect units are declared once, here: the3522 * consequence runs when the store confirms the task, the compensation runs if3623 * a stale-policy write is denied — even if the app restarted in between.3724 */3825 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- },26+ effects: [27+ s.log("task-added"),28+ s.trace("task-added"),29+ s.notice<Task>({30+ synced: (task) => `"${task.text ?? "Task"}" synced to your account`,31+ // The engine already rolled a denied insert out of local reads; this32+ // durable notice compensates what the user was told, even after reload.33+ rejected: (task) => `"${task.text ?? "Task"}" was declined by the store and has been removed`,34+ }),35+ ],5136 });5237 5338 /** Toggling completion is a plain verb: no consequences, same lifecycle. */5439 export const setTaskCompleted = s.mutation("setTaskCompleted", s.update(tasksTable));5540 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-6841 export function useTasks() {6942 const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);7043 const [lastWrite, setLastWrite] = useState<WriteHandle<Task> | null>(null);@@ -14,9 +14,9 @@
1414 "README.md": "125e906d85029b14d788b8e2dfe6e3037700770b9fd50e76ce69f98c3a82bb05",1515 "src/app.ts": "2021889ec895b7c758e9c541eb968a480f63610073fe2c2d316faa7b66e85113",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",17- "src/islands/AccountGate.tsx": "0faa82edb05ea9c6da1f575ebb75cc2f60d8e95e88524c0720e3c885a1eee659",18- "src/islands/TaskList.tsx": "7b0da8830e5fab9c59948eff1cbaa357805ecea9c5d070249dd46157e5e36bdf",19- "src/islands/use-tasks.ts": "0859b1bbd18a7e9fee54cbe919b4e1c9c4b23665227bffa54bd7f0d910192589",17+ "src/islands/AccountGate.tsx": "bce8ea5f64d4c9bb399b8665b0e14b3c20e6e3a9ab1663e011e811057b1f8a5e",18+ "src/islands/TaskList.tsx": "5b76707bf953d12a42d2be26c0a4f3eab77e972e228685a6cd945a65891e18f0",19+ "src/islands/use-tasks.ts": "277a54f6f77afebc303d869f0aac9db8423a2b5c707333046ce4d95993577a17",2020 "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",2121 "src/pages/index.astro": "8a696a8154b77c34deba3919f6383dc2ed0a6af15612fd9b16f04416e3331e93",2222 "src/permissions.ts": "7a2dad8ce48816c1611c85049209f4fddef08ab332dcbd94bac4fa162fc6c1b0",@@ -34,3 +34,18 @@ Deno.test("the demo landing page stamps the released version", async () => {
3434 `which release produced the demo`,3535 );3636 });37+38+Deno.test("the demo uses the starter's durable notice surface", async () => {39+ const incidents = await Deno.readTextFile(40+ join(OVERLAY_ROOT, "src/islands/use-incidents.ts"),41+ );42+ const board = await Deno.readTextFile(43+ join(OVERLAY_ROOT, "src/islands/IncidentBoard.tsx"),44+ );45+ assert(incidents.includes("s.notice<Incident>"), "incident effects must enqueue durable notices");46+ assert(47+ !incidents.includes("publishNotice") && !incidents.includes("useIncidentNotice"),48+ "the overlay must not restore the hand-rolled in-memory notice channel",49+ );50+ assert(board.includes("<Notices"), "the incident board must render the durable notice queue");51+});