diff --git a/apps/demo/overlay/src/islands/IncidentBoard.tsx b/apps/demo/overlay/src/islands/IncidentBoard.tsx
index 392612f..50ca64c 100644
--- a/apps/demo/overlay/src/islands/IncidentBoard.tsx
+++ b/apps/demo/overlay/src/islands/IncidentBoard.tsx
@@ -2,6 +2,7 @@ import { useState } from "preact/hooks";
import { settleUiMutation } from "@nzip/lofi";
import {
type BootProgress,
+ Notices,
useBootProgress,
usePendingWrites,
useSyncStatus,
@@ -10,7 +11,6 @@ import {
type Incident,
type IncidentStatus,
type Severity,
- useIncidentNotice,
useIncidents,
} from "./use-incidents.ts";
@@ -51,7 +51,6 @@ function openedLabel(value: Incident["openedAt"]): string {
*/
export default function IncidentBoard() {
const { status, error, durability, incidents, failureKind, report, setStatus } = useIncidents();
- const notice = useIncidentNotice();
const pending = usePendingWrites();
const boot = useBootProgress();
const [title, setTitle] = useState("");
@@ -107,11 +106,7 @@ export default function IncidentBoard() {
{pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync
)}
- {notice && (
-
- {notice.text}
-
- )}
+
{COLUMNS.map((column) => {
const rows = incidents.filter((incident) => incident.status === column.status);
diff --git a/apps/demo/overlay/src/islands/use-incidents.ts b/apps/demo/overlay/src/islands/use-incidents.ts
index 0b9e80c..c759f1a 100644
--- a/apps/demo/overlay/src/islands/use-incidents.ts
+++ b/apps/demo/overlay/src/islands/use-incidents.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "preact/hooks";
+import { useCallback, useState } from "preact/hooks";
import type { RowOf, WriteHandle } from "@nzip/lofi";
import { useLiveQuery, useWrite } from "@nzip/lofi/preact";
import { s } from "@nzip/lofi/schema";
@@ -18,57 +18,27 @@ export type Incident = RowOf;
export type Severity = Incident["severity"];
export type IncidentStatus = Incident["status"];
-/** A one-line consequence or compensation surfaced to the UI. */
-export type IncidentNotice = { kind: "synced" | "rejected"; text: string };
-
-// A tiny author-owned notice channel: effect handlers run outside any
-// component, so they publish through module state and hooks subscribe.
-let notice: IncidentNotice | null = null;
-const noticeListeners = new Set<() => void>();
-
-function publishNotice(next: IncidentNotice | null): void {
- notice = next;
- for (const listener of [...noticeListeners]) listener();
-}
-
/**
* The reporting verb. Its effect units are declared once, here: the
* consequence runs when the store confirms the row, the compensation runs if
* a stale-policy write is denied, even if the app restarted in between.
*/
export const reportIncident = s.mutation("reportIncident", s.insert(incidentsTable), {
- effects: [s.log("incident-reported")],
- onSynced: (incident) => {
- publishNotice({
- kind: "synced",
- text: `"${incident.title ?? "Incident"}" confirmed by the store`,
- });
- },
- onRejected: (incident) => {
- // The engine already rolled the denied row back out of local reads; this
- // compensates what the user was told.
- publishNotice({
- kind: "rejected",
- text: `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,
- });
- },
+ effects: [
+ s.log("incident-reported"),
+ s.notice({
+ synced: (incident) => `"${incident.title ?? "Incident"}" confirmed by the store`,
+ // The engine already rolled a denied insert out of local reads; this
+ // durable notice compensates what the user was told, even after reload.
+ rejected: (incident) =>
+ `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,
+ }),
+ ],
});
/** Moving an incident between states is a plain verb: same lifecycle. */
export const setIncidentStatus = s.mutation("setIncidentStatus", s.update(incidentsTable));
-/** Subscribes to the latest effect notice; `null` until one is published. */
-export function useIncidentNotice(): IncidentNotice | null {
- const [current, setCurrent] = useState(notice);
- useEffect(() => {
- const listener = () => setCurrent(notice);
- noticeListeners.add(listener);
- listener();
- return () => void noticeListeners.delete(listener);
- }, []);
- return current;
-}
-
export function useIncidents() {
const query = useLiveQuery(() => incidentsTable.orderBy("openedAt", "desc"), []);
const [lastWrite, setLastWrite] = useState | null>(null);
diff --git a/apps/reference/src/islands/AccountGate.tsx b/apps/reference/src/islands/AccountGate.tsx
index 6e8b66b..cf82c82 100644
--- a/apps/reference/src/islands/AccountGate.tsx
+++ b/apps/reference/src/islands/AccountGate.tsx
@@ -50,7 +50,7 @@ function describe(error: unknown): string {
if (isAuthError(error)) {
switch (error.code) {
case "cancelled":
- return "Passkey prompt dismissed — your recovery phrase was not shown.";
+ return "Passkey verification did not complete — your recovery phrase was not shown.";
case "unsupported":
return "This browser does not support passkeys.";
default:
diff --git a/apps/reference/src/islands/TaskList.tsx b/apps/reference/src/islands/TaskList.tsx
index 57fe382..bcc1caf 100644
--- a/apps/reference/src/islands/TaskList.tsx
+++ b/apps/reference/src/islands/TaskList.tsx
@@ -2,11 +2,12 @@ import { useState } from "preact/hooks";
import { settleUiMutation } from "@nzip/lofi";
import {
type BootProgress,
+ Notices,
useBootProgress,
usePendingWrites,
useSyncStatus,
} from "@nzip/lofi/preact";
-import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";
+import { type Task, useTasks } from "./use-tasks.ts";
// A cold first visit waits on the engine download, not on storage; name the
// wait it is actually in, with byte progress while the download runs.
@@ -26,7 +27,6 @@ function loadingLabel(boot: BootProgress): string {
*/
export default function TaskList() {
const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();
- const notice = useTaskNotice();
const pending = usePendingWrites();
const boot = useBootProgress();
const [text, setText] = useState("");
@@ -78,11 +78,7 @@ export default function TaskList() {
{pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync
)}
- {notice && (
-
- {notice.text}
-
- )}
+
{tasks.map((task) => )}
diff --git a/apps/reference/src/islands/use-tasks.ts b/apps/reference/src/islands/use-tasks.ts
index 4c85582..6b923c7 100644
--- a/apps/reference/src/islands/use-tasks.ts
+++ b/apps/reference/src/islands/use-tasks.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "preact/hooks";
+import { useCallback, useState } from "preact/hooks";
import type { RowOf, WriteHandle } from "@nzip/lofi";
import { useLiveQuery, useWrite } from "@nzip/lofi/preact";
import { s } from "@nzip/lofi/schema";
@@ -17,54 +17,27 @@ const tasksTable = app.schema.tasks;
/** The row type comes straight from the declared schema. */
export type Task = RowOf;
-/** A one-line consequence or compensation surfaced to the UI. */
-export type TaskNotice = { kind: "synced" | "rejected"; text: string };
-
-// A tiny author-owned notice channel: effect handlers run outside any
-// component, so they publish through module state and hooks subscribe.
-let notice: TaskNotice | null = null;
-const noticeListeners = new Set<() => void>();
-
-function publishNotice(next: TaskNotice | null): void {
- notice = next;
- for (const listener of [...noticeListeners]) listener();
-}
-
/**
* The verb call sites use. Its effect units are declared once, here: the
* consequence runs when the store confirms the task, the compensation runs if
* a stale-policy write is denied — even if the app restarted in between.
*/
export const addTask = s.mutation("addTask", s.insert(tasksTable), {
- effects: [s.log("task-added")],
- onSynced: (task) => {
- publishNotice({ kind: "synced", text: `"${task.text ?? "Task"}" synced to your account` });
- },
- onRejected: (task) => {
- // The engine already rolled the denied row back out of local reads; this
- // compensates what the user was told.
- publishNotice({
- kind: "rejected",
- text: `"${task.text ?? "Task"}" was declined by the store and has been removed`,
- });
- },
+ effects: [
+ s.log("task-added"),
+ s.trace("task-added"),
+ s.notice({
+ synced: (task) => `"${task.text ?? "Task"}" synced to your account`,
+ // The engine already rolled a denied insert out of local reads; this
+ // durable notice compensates what the user was told, even after reload.
+ rejected: (task) => `"${task.text ?? "Task"}" was declined by the store and has been removed`,
+ }),
+ ],
});
/** Toggling completion is a plain verb: no consequences, same lifecycle. */
export const setTaskCompleted = s.mutation("setTaskCompleted", s.update(tasksTable));
-/** Subscribes to the latest effect notice; `null` until one is published. */
-export function useTaskNotice(): TaskNotice | null {
- const [current, setCurrent] = useState(notice);
- useEffect(() => {
- const listener = () => setCurrent(notice);
- noticeListeners.add(listener);
- listener();
- return () => void noticeListeners.delete(listener);
- }, []);
- return current;
-}
-
export function useTasks() {
const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);
const [lastWrite, setLastWrite] = useState | null>(null);
diff --git a/docs/README.md b/docs/README.md
index faaf26d..a0444a3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -37,6 +37,8 @@ shortest product overview and command summary. AI agents can ingest these docs a
- [Direct sharing](examples/shared.md)
- [Fixed-role group](examples/group.md)
- [Policy conditions on typed columns](examples/policy-conditions.md)
+- [Nouns and verbs](nouns-and-verbs.md) — declaring verbs, effect units, and the write lifecycle
+- [The effect library](effects.md) — built-in effect units and the custom-unit authoring contract
- Data modeling examples — how to shape tables and columns
- [Collaborative list data model](examples/collaborative-list.md)
- [Collaborative sets](examples/collaborative-sets.md)
diff --git a/docs/effects.md b/docs/effects.md
new file mode 100644
index 0000000..b32953a
--- /dev/null
+++ b/docs/effects.md
@@ -0,0 +1,163 @@
+# The effect library
+
+Effect units give a verb consequences: a handler that runs on the originating device when the
+write's fate settles. [Nouns and verbs](nouns-and-verbs.md) covers the machinery — declaration, the
+journal, at-least-once delivery, retention. This page is the built-in library and the contract for
+authoring your own.
+
+The teaching path runs one new idea at a time: declare a verb, **observe** it (`s.log`, `s.trace`),
+make its fate **data** (`s.notice`, `s.mark`), **compose** further writes (`s.chain`), then reach
+the **outside world** (`s.webhook`). Every built-in is idempotent by construction, so each is also a
+worked example of the discipline your own units must follow.
+
+## The authoring contract
+
+A custom unit is `s.effect(name, table, handlers, options)`. Four rules make it safe:
+
+- **The name is a durable identity.** The journal re-arms a unit's handlers by name after a reload,
+ so names are app-unique (a duplicate throws at declaration) and renaming a unit orphans its
+ in-flight obligations. The content-named built-ins (`s.log`, `s.trace`, `s.debug`, `s.webhook`)
+ share one unit per identity, so reusing one across verbs aggregates rather than collides. The
+ anonymous built-ins (`s.notice`, `s.mark`, `s.chain`) are named `#` from the verb
+ they are attached to and their slot in that verb's `effects` — a durable identity independent of
+ which module loads first, so the only way to orphan one is to reorder the effects within its own
+ verb.
+- **Delivery is at-least-once, so handlers must be idempotent.** A crash between handler start and
+ journal completion re-runs the handler at the next boot. The `context.journalId` a handler
+ receives — the write's `(write id, effect name)` key — is the idempotency key: pass it to any
+ external call, and dedupe your own side effects on it.
+- **Failure has two severities.** An ordinary thrown error is _retryable_: the obligation re-arms at
+ the next boot until it succeeds or `maxAttempts` (default 5) quarantines it. Throwing
+ `PermanentEffectError` is _permanent_: the obligation retires immediately, without burning the
+ retry budget on a call that will keep failing. Both are counted in runtime diagnostics; a failed
+ handler is never silently swallowed.
+- **`onRejected` compensates ancillary state, never row data.** The engine rolls a rejected write
+ out of local query results itself. After a reload a rejected handler receives the row id alone, so
+ compensate what the user was told or what you called externally — not the row.
+
+A unit written to these rules behaves like a built-in. This is the smallest one:
+
+```ts
+import { PermanentEffectError, s } from "@nzip/lofi/schema";
+
+const sent = new Set();
+export const sendReceipt = s.effect("sendReceipt", app.schema.orders, {
+ onSynced: async (order, { journalId }) => {
+ if (sent.has(journalId)) return; // at-least-once: dedupe on the key
+ const res = await fetch(receiptUrl, { headers: { "Idempotency-Key": journalId } });
+ if (res.status >= 400 && res.status < 500) {
+ throw new PermanentEffectError(`receipt refused: ${res.status}`); // do not retry
+ }
+ if (!res.ok) throw new Error(`receipt transient failure: ${res.status}`); // retry
+ sent.add(journalId);
+ },
+});
+```
+
+## Observation tier
+
+Cannot change anything — they only record.
+
+### `s.log(label)`
+
+Records a structured diagnostics entry (write id, fate, timing) on either fate. Repeated calls with
+one label share a unit. See [nouns and verbs](nouns-and-verbs.md#effect-units).
+
+### `s.trace(label?)`
+
+A span from the write's journaling to its settled fate, recorded in runtime diagnostics as an
+OpenTelemetry-shaped event with the saved→synced/rejected latency. Pure instrumentation and the
+observability hook; an OTLP exporter is an adapter over this feed, never a concept you configure per
+verb. Without a label the verb name labels the span.
+
+```ts
+export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {
+ effects: [s.trace("checkout")],
+});
+```
+
+### `s.debug()`
+
+A development-only timeline of each fate an obligation settles, for eyeballing delivery in the
+inspector. Stripped from production builds — in a `PROD` bundle the handlers record nothing.
+
+## Data-internal tier
+
+Write back into the app. Fate stops being a callback and becomes replicated data.
+
+### `s.notice(config)`
+
+Enqueues a durable, user-visible message when the write settles — the fix for the "a rejected write
+still flashed success" failure mode. The queue is durable and UI-agnostic: an entry created at a
+boot re-arm survives with nothing mounted, and a component renders it later. Render with the
+built-in `` or the `useNotices()` hook from `@nzip/lofi/preact`; a toast stack is a
+userland wrapper over the same queue, never an imperative call. Idempotent by the obligation's
+journal id, so a re-delivery enqueues one entry. `ttlMs` bounds an entry's life; pass `null` to keep
+it until dismissed.
+
+```ts
+export const publish = s.mutation("publish", s.update(app.schema.posts), {
+ effects: [s.notice({
+ synced: "Published.",
+ rejected: (post) => `Could not publish "${post.title}".`,
+ })],
+});
+```
+
+### `s.mark(table, config)`
+
+Patches the written row when its fate resolves, so write fate becomes replicated data every device
+and query sees — the hand-rolled `status` column made declarative. The patch is absolute (a static
+set-column-to-value object), so it is convergent under re-delivery. A rejected _insert_ has no row
+to mark — the engine rolled it out — so the rejected patch is skipped for inserts; on updates and
+removes the row survives the rollback.
+
+```ts
+export const submit = s.mutation("submit", s.update(app.schema.claims), {
+ effects: [s.mark(app.schema.claims, {
+ synced: { status: "confirmed" },
+ rejected: { status: "failed" },
+ })],
+});
+```
+
+### `s.chain(verb, toInput)`
+
+Issues a follow-up verb once the write syncs, mapping the settled row to the next verb's input.
+Reifies a chain of writes (reserve → charge → fulfill) declaratively without a saga API — each link
+is an ordinary verb with its own effects and rejection handling. Fires only on `synced`.
+
+```ts
+export const reserve = s.mutation("reserve", s.insert(app.schema.holds), {
+ effects: [s.chain(charge, (hold) => ({ holdId: hold.id, amount: hold.total }))],
+});
+```
+
+## External tier
+
+Where the guardrails earn their keep.
+
+### `s.webhook(name, url, options?)`
+
+POSTs the settled row and its fate to `url`, with the obligation's journal id auto-injected as
+`Idempotency-Key` so a re-delivery the receiver already saw is dropped receiver-side. The generic
+integration workhorse and the reference for the at-least-once contract.
+
+- **`name` is the durable identity.** Keep it stable across releases. URLs and header values are
+ checked only in memory and never become journal keys, so credentials do not leak into persisted
+ effect identifiers. Reusing a name with different configuration fails fast.
+- **Failure severity follows the response.** A network error, `5xx`, or `429` throws an ordinary
+ error, so the ledger's bounded backoff retries it; any other `4xx` throws `PermanentEffectError`,
+ retiring the obligation rather than retrying a request the receiver will keep refusing.
+- **Delivery defaults to a finite window.** Receiver idempotency windows are finite (Stripe forgets
+ keys after ~24h), so external delivery defaults to a 24-hour `expiresAfterMs` rather than
+ infinity; pass `null` to opt into no expiry.
+
+```ts
+export const order = s.mutation("order", s.insert(app.schema.orders), {
+ effects: [s.webhook("orders", "https://hooks.example.com/orders")],
+});
+```
+
+Provider-specific units (email, SMS, push) are userland wrappers over `s.webhook`; the library ships
+the workhorse, not the integrations.
diff --git a/docs/nouns-and-verbs.md b/docs/nouns-and-verbs.md
index e513843..4ca325b 100644
--- a/docs/nouns-and-verbs.md
+++ b/docs/nouns-and-verbs.md
@@ -99,7 +99,10 @@ and data-attached effects would have no owning replica).
swallowed.
`s.log(label)` is a built-in unit that records a structured entry in runtime diagnostics on either
-fate. Repeated calls with one label share one unit.
+fate. Repeated calls with one label share one unit. It is the first of a tiered built-in library —
+observe (`s.log`, `s.trace`), make fate data (`s.notice`, `s.mark`), compose (`s.chain`), reach the
+outside world (`s.webhook`) — with the contract for authoring your own. See
+[the effect library](effects.md).
## Retention: durable things know how to die
diff --git a/package/astro/manifest.ts b/package/astro/manifest.ts
index c7f5638..f72c00f 100644
--- a/package/astro/manifest.ts
+++ b/package/astro/manifest.ts
@@ -28,6 +28,7 @@ export const runtimeFiles = [
"mod.ts",
"mutation-taxonomy.ts",
"namespace-state.ts",
+ "notice-queue.ts",
"passkey-recovery.ts",
"pop.ts",
"probe.ts",
@@ -69,6 +70,7 @@ export const accessFiles = [
/** Schema facade modules vendored into `.lofi/`; kept in lockstep by the manifest test. */
export const schemaFiles = [
"compat.ts",
+ "effect-library.ts",
"effects.ts",
"encrypted.ts",
"mod.ts",
@@ -87,11 +89,13 @@ export const preactFiles = [
"DeviceStatus.tsx",
"live-data.ts",
"mod.ts",
+ "Notices.tsx",
"PwaActions.tsx",
"RuntimeRecovery.tsx",
"TicketEnrollForm.tsx",
"use-boot-progress.ts",
"use-device-capabilities.ts",
+ "use-notices.ts",
"use-schema-compat.ts",
"use-storage-fork.ts",
"write-hooks.ts",
diff --git a/package/preact/DeviceStatus.tsx b/package/preact/DeviceStatus.tsx
index 1ad20d1..c716303 100644
--- a/package/preact/DeviceStatus.tsx
+++ b/package/preact/DeviceStatus.tsx
@@ -2,6 +2,7 @@ import type { VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
// Package-owned optional diagnostics UI.
import { useDeviceCapabilities } from "./use-device-capabilities.ts";
+import { type CredentialOriginReport, getAuthCapability } from "../runtime/auth.ts";
import { settleUiMutation } from "../runtime/ui-mutation.ts";
import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";
import { PwaActions } from "./PwaActions.tsx";
@@ -30,6 +31,20 @@ function Row({ label, value }: { label: string; value: string }): VNode {
const available = (present: boolean) => (present ? "available" : "missing");
+/** One-line credential-origin verdict that distinguishes API support from deployability. */
+export function describeCredentialOrigin(origin: CredentialOriginReport): string {
+ switch (origin.status) {
+ case "stable":
+ return `stable — ${origin.rpId}`;
+ case "local-only":
+ return `local development only — ${origin.rpId}`;
+ case "unverified":
+ return `unverified — ${origin.rpId}`;
+ case "blocked":
+ return origin.rpId ? `blocked — ${origin.rpId}` : "blocked";
+ }
+}
+
/**
* The one-line Data sync verdict. The blocked dispositions are first-class —
* the report must say *why* nothing is syncing, not merely that it is not:
@@ -79,6 +94,7 @@ export function DeviceStatus(): VNode {
const [pwa, setPwa] = useState(getPwaState());
const [session, setSession] = useState(null);
const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());
+ const [credentialOrigin, setCredentialOrigin] = useState(null);
useEffect(() => subscribePwaState(setPwa), []);
useEffect(
@@ -92,6 +108,15 @@ export function DeviceStatus(): VNode {
globalThis.addEventListener(runtimeRecreatedEvent, refresh);
return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);
}, []);
+ useEffect(() => {
+ let active = true;
+ void getAuthCapability().then((capability) => {
+ if (active) setCredentialOrigin(capability.origin);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
if (!report) return
Checking device capabilities…
;
@@ -218,9 +243,16 @@ export function DeviceStatus(): VNode {
diff --git a/package/preact/DeviceStatus_test.tsx b/package/preact/DeviceStatus_test.tsx
index 94824bc..056989a 100644
--- a/package/preact/DeviceStatus_test.tsx
+++ b/package/preact/DeviceStatus_test.tsx
@@ -1,4 +1,4 @@
-import { describeSyncState } from "./DeviceStatus.tsx";
+import { describeCredentialOrigin, describeSyncState } from "./DeviceStatus.tsx";
// The report's contract after the sync-state integrity pass: a reader must be
// 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", () =>
throw new Error(`owner mismatch was outranked: ${verdict}`);
}
});
+
+Deno.test("credential-origin status does not overstate API support", () => {
+ const local = describeCredentialOrigin({
+ status: "local-only",
+ rpId: "localhost",
+ action: "use the stable HTTPS origin",
+ });
+ if (!local.includes("local development only") || !local.includes("localhost")) {
+ throw new Error(`local credential origin was overstated: ${local}`);
+ }
+ const stable = describeCredentialOrigin({
+ status: "stable",
+ rpId: "demo.lofi.host",
+ action: "keep this hostname",
+ });
+ if (!stable.includes("stable") || !stable.includes("demo.lofi.host")) {
+ throw new Error(`stable credential origin lost its hostname: ${stable}`);
+ }
+});
diff --git a/package/preact/Notices.tsx b/package/preact/Notices.tsx
new file mode 100644
index 0000000..f733d87
--- /dev/null
+++ b/package/preact/Notices.tsx
@@ -0,0 +1,64 @@
+import { Fragment, type VNode } from "preact";
+// Package-owned optional notices surface.
+import { type NoticeEntry, useNotices } from "./use-notices.ts";
+
+/** Props for the built-in {@link Notices} surface. */
+export type NoticesProps = {
+ /** Accessible label for the region; defaults to "Notifications". */
+ label?: string;
+ /** Renders one notice; defaults to the built-in row with a dismiss button. */
+ children?: (notice: NoticeEntry, dismiss: () => void) => VNode;
+};
+
+/**
+ * The built-in durable-notice surface: renders the live `s.notice` queue as an
+ * ARIA live region so a message enqueued when a write settled — success or the
+ * "a rejected write still flashed success" case — is announced and dismissable.
+ * Optional and entirely public: it is `useNotices` plus default markup, so an
+ * app can drop it and render its own (a toast stack, a banner) over the same
+ * hook.
+ *
+ * @example
+ * ```tsx
+ * import { Notices } from "@nzip/lofi/preact";
+ *
+ * export function AppChrome() {
+ * return ;
+ * }
+ * ```
+ *
+ * @param props Optional region label and a custom per-notice renderer.
+ * @returns The always-mounted live notices region.
+ */
+export function Notices({ label = "Notifications", children }: NoticesProps): VNode {
+ const { notices, dismiss } = useNotices();
+ // The live region stays mounted even when empty: a screen reader only
+ // announces mutations to a region already in the DOM, so inserting the
+ // region together with its first notice would drop that first announcement —
+ // exactly the message this surface exists to deliver.
+ return (
+
+ {notices.map((notice) =>
+ children
+ ? {children(notice, () => dismiss(notice.id))}
+ : (
+
+
{notice.message}
+
+
+ )
+ )}
+
+ );
+}
diff --git a/package/preact/Notices_test.tsx b/package/preact/Notices_test.tsx
new file mode 100644
index 0000000..a54ce5d
--- /dev/null
+++ b/package/preact/Notices_test.tsx
@@ -0,0 +1,33 @@
+import { render } from "npm:preact-render-to-string@6.7.0";
+import { getNoticeQueue } from "../runtime/mod.ts";
+import { Notices } from "./Notices.tsx";
+
+Deno.test("Notices keeps its empty live region exposed for the first announcement", () => {
+ const html = render();
+ if (!html.includes('aria-live="polite"')) {
+ throw new Error("the durable notice surface omitted its live region");
+ }
+ if (html.includes(" hidden")) {
+ throw new Error("an empty live region must remain in the accessibility tree");
+ }
+});
+
+Deno.test("Notices preserves custom renderer markup without a wrapper", async () => {
+ const queue = getNoticeQueue();
+ const id = "notices-custom-renderer-test";
+ await queue.enqueue({ id, message: "Custom", tone: "info", ttlMs: null });
+ try {
+ const html = render(
+ {(notice) => {notice.message}},
+ );
+ if (!html.includes(`Custom`)) {
+ throw new Error("the custom notice renderer did not retain its own root markup");
+ }
+ if (html.includes("
void;
+ /** Dismisses every notice. */
+ dismissAll: () => void;
+};
+
+/**
+ * Subscribes a Preact component to the durable notice queue. The list stays
+ * live across enqueues (including effects that fire at a boot re-arm),
+ * dismissals, and TTL retirement.
+ *
+ * @example
+ * ```tsx
+ * import { useNotices } from "@nzip/lofi/preact";
+ *
+ * const { notices, dismiss } = useNotices();
+ * return notices.map((n) => (
+ *
+ * {n.message}
+ *
+ *
+ * ));
+ * ```
+ *
+ * @returns The live notices and the dismissal actions.
+ */
+export function useNotices(): NoticesSurface {
+ const [notices, setNotices] = useState(() => listNotices());
+ useEffect(() => {
+ // Publish the current list once on mount — an effect may have enqueued
+ // before this component subscribed — then follow every change.
+ setNotices(listNotices());
+ return subscribeNotices(() => setNotices(listNotices()));
+ }, []);
+ return { notices, dismiss: dismissNotice, dismissAll: dismissAllNotices };
+}
diff --git a/package/runtime/data-sink.ts b/package/runtime/data-sink.ts
index 259df53..0cb63c8 100644
--- a/package/runtime/data-sink.ts
+++ b/package/runtime/data-sink.ts
@@ -335,16 +335,19 @@ export type SyncTicket = {
const TICKET_PREFIX = "lofisync1.";
const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;
+const TICKET_CONNECT_PATH = /^\/t\/[A-Za-z0-9_-]{43}\/c\/[A-Za-z0-9_-]{43}$/;
/**
- * Whether a server URL carries an app-connect ticket path (`/t/`) and
+ * Whether a server URL carries an app-connect ticket path (`/t/`) or
+ * its PoP-authenticated connect-token form (`/t//c/`) and
* therefore fronts a lofi-node gate, which is what exposes the metadata-only
* store-status endpoint. First-party Jazz servers and open-mode node URLs do
* not match.
*/
export function isTicketServerUrl(serverUrl: string): boolean {
try {
- return TICKET_PATH.test(new URL(serverUrl).pathname);
+ const path = new URL(serverUrl).pathname;
+ return TICKET_PATH.test(path) || TICKET_CONNECT_PATH.test(path);
} catch {
return false;
}
diff --git a/package/runtime/diagnostics.ts b/package/runtime/diagnostics.ts
index a4812f5..f8e5d3b 100644
--- a/package/runtime/diagnostics.ts
+++ b/package/runtime/diagnostics.ts
@@ -30,6 +30,65 @@ export function recordEffectLogEntry(
diagnostics.effectLog = [...diagnostics.effectLog.slice(-(maxEffectLogEntries - 1)), entry];
}
+/**
+ * One OpenTelemetry-shaped span recorded by the built-in `s.trace` effect
+ * unit: the write's journaling to its settled fate, with the elapsed latency.
+ * The framework emits these into diagnostics with no vendor coupling; an
+ * OTLP exporter is an adapter over this feed, never a concept the author sees.
+ */
+export type EffectTraceEntry = {
+ /** The author's span label, or `null` when the verb name labels it. */
+ label: string | null;
+ /** The declaring verb's name, or `null` for writes without a verb. */
+ verb: string | null;
+ /** The written row's id. */
+ rowId: string;
+ /** Which fate ended the span. */
+ fate: "synced" | "rejected";
+ /** Saved-to-fate latency in milliseconds. */
+ durationMs: number;
+ /** Epoch milliseconds when the span closed. */
+ at: number;
+};
+
+/** One development-only timeline event recorded by the built-in `s.debug` unit. */
+export type EffectDebugEvent = {
+ /** The declaring verb's name, or `null` for writes without a verb. */
+ verb: string | null;
+ /** The obligation's journal id. */
+ journalId: string;
+ /** The stage/fate this event marks. */
+ event: string;
+ /** Epoch milliseconds when the event was recorded. */
+ at: number;
+};
+
+// Recent-entry windows for the trace and dev-debug feeds.
+const maxEffectTraceEntries = 20;
+const maxEffectDebugEvents = 50;
+
+/** Appends one `s.trace` span, keeping the bounded recent window. */
+export function recordEffectTrace(
+ diagnostics: RuntimeDiagnostics,
+ entry: EffectTraceEntry,
+): void {
+ diagnostics.effectTraces = [
+ ...diagnostics.effectTraces.slice(-(maxEffectTraceEntries - 1)),
+ entry,
+ ];
+}
+
+/** Appends one `s.debug` timeline event, keeping the bounded recent window. */
+export function recordEffectDebugEvent(
+ diagnostics: RuntimeDiagnostics,
+ entry: EffectDebugEvent,
+): void {
+ diagnostics.effectDebugTimeline = [
+ ...diagnostics.effectDebugTimeline.slice(-(maxEffectDebugEvents - 1)),
+ entry,
+ ];
+}
+
/**
* Runtime-owned observability counters. These describe the framework's storage,
* subscription, and write machinery and never reference any application schema.
@@ -92,6 +151,15 @@ export type RuntimeDiagnostics = {
quarantinedObligations: number;
/** Recent structured entries recorded by the built-in `s.log` effect unit. */
effectLog: readonly EffectLogEntry[];
+ /** Recent saved→fate spans recorded by the built-in `s.trace` effect unit. */
+ effectTraces: readonly EffectTraceEntry[];
+ /**
+ * Recent development-only timeline events from the built-in `s.debug` unit;
+ * always empty in production builds, where `s.debug` records nothing.
+ */
+ effectDebugTimeline: readonly EffectDebugEvent[];
+ /** How many durable `s.notice` entries are unread (undismissed, unexpired). */
+ activeNotices: number;
/** Recent shared-field key alerts: substitutions, forged wraps, self-key
* conflicts. A non-empty list is the detection surface the threat model
* promises — render it, never swallow it. */
@@ -155,6 +223,9 @@ export function createDiagnostics(): RuntimeDiagnostics {
expiredObligations: 0,
quarantinedObligations: 0,
effectLog: [],
+ effectTraces: [],
+ effectDebugTimeline: [],
+ activeNotices: 0,
sharedFieldAlerts: [],
};
}
diff --git a/package/runtime/mod.ts b/package/runtime/mod.ts
index 6b52032..9585b8e 100644
--- a/package/runtime/mod.ts
+++ b/package/runtime/mod.ts
@@ -208,7 +208,7 @@ export {
type SinkRestoreOutcome,
type SyncTicket,
} from "./data-sink.ts";
-export { RecoveryError } from "./recovery.ts";
+export { RecoveryError, type RecoveryErrorCode } from "./recovery.ts";
export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";
export {
type RowOf,
@@ -230,16 +230,27 @@ export {
} from "./write-handle.ts";
export {
armWriteLedger,
+ dismissAllNotices,
+ dismissNotice,
+ getNoticeQueue,
getWriteLedger,
type LedgerWriteOptions,
type LedgerWriteRequest,
+ listNotices,
type PendingWritesSnapshot,
type PendingWriteSummary,
type ProbeTable,
type RowSyncStatus,
+ subscribeNotices,
WriteLedger,
type WriteLedgerEnvironment,
} from "./write-ledger.ts";
+export {
+ type NoticeEnqueueInput,
+ type NoticeEntry,
+ NoticeQueue,
+ type NoticeTone,
+} from "./notice-queue.ts";
export {
createDefaultJournalStorage,
createMemoryJournalStorage,
@@ -251,7 +262,7 @@ export {
type JournalWriteRecord,
type JournalWriteStage,
} from "./write-journal.ts";
-export type { EffectLogEntry } from "./diagnostics.ts";
+export type { EffectDebugEvent, EffectLogEntry, EffectTraceEntry } from "./diagnostics.ts";
export { pinnedFingerprint, trustPeerKey, verifyAndPinFingerprint } from "./shared-field-keys.ts";
export type {
EffectContext,
diff --git a/package/runtime/notice-queue.ts b/package/runtime/notice-queue.ts
new file mode 100644
index 0000000..c6a2671
--- /dev/null
+++ b/package/runtime/notice-queue.ts
@@ -0,0 +1,249 @@
+/**
+ * Package-owned durable notice queue: the store behind the built-in
+ * `s.notice` effect unit.
+ *
+ * A notice is a user-visible message an effect enqueues when a write settles —
+ * the durable answer to "a rejected write still flashed success". The queue is
+ * deliberately not an imperative toast: an effect can fire at a boot re-arm
+ * with no UI mounted, so the entry must outlive the render and be picked up
+ * later. Entries persist beside the write journal (OPFS when the browser
+ * provides it, `localStorage` otherwise), are keyed by the enqueuing
+ * obligation's journal id so an at-least-once re-delivery adds one entry, and
+ * retire by dismissal or TTL. A component (the built-in notices surface, or an
+ * author's) subscribes and renders; toasts are a userland wrapper over this.
+ *
+ * @module
+ */
+
+import {
+ createDefaultJournalStorage,
+ createMemoryJournalStorage,
+ type JournalStorage,
+} from "./write-journal.ts";
+
+/** How a notice is classified for rendering. */
+export type NoticeTone = "info" | "success" | "warning" | "error";
+
+/** One durable notice entry. */
+export type NoticeEntry = {
+ /** The enqueuing obligation's journal id — the entry's idempotency key. */
+ id: string;
+ /** The user-facing message. */
+ message: string;
+ /** The message classification. */
+ tone: NoticeTone;
+ /** Epoch milliseconds when the entry was enqueued. */
+ createdAt: number;
+ /** Epoch milliseconds when the entry retires by TTL, or `null` for none. */
+ expiresAt: number | null;
+};
+
+/** The persisted queue document. */
+type NoticeDocument = {
+ version: 1;
+ entries: NoticeEntry[];
+};
+
+function parse(text: string | null): NoticeDocument {
+ if (!text) return { version: 1, entries: [] };
+ try {
+ const value = JSON.parse(text) as Partial | null;
+ if (value && value.version === 1 && Array.isArray(value.entries)) {
+ const entries = value.entries.filter((entry): entry is NoticeEntry =>
+ typeof entry?.id === "string" && typeof entry.message === "string"
+ );
+ return { version: 1, entries };
+ }
+ } catch {
+ // A corrupt queue must not brick boot; start empty.
+ }
+ return { version: 1, entries: [] };
+}
+
+/** What one enqueue call carries, before the queue stamps identity and time. */
+export type NoticeEnqueueInput = {
+ /** The idempotency key — the enqueuing obligation's journal id. */
+ id: string;
+ /** The user-facing message. */
+ message: string;
+ /** The message classification. */
+ tone: NoticeTone;
+ /** Lifespan before TTL retirement, or `null` to keep until dismissed. */
+ ttlMs: number | null;
+};
+
+/**
+ * The single reader and writer of the durable notice queue: an in-memory
+ * document with coalesced persistence, a live-notice snapshot, and change
+ * notification. Retirement (dismissal, TTL) is applied lazily on every read
+ * and on a periodic sweep, so a queue loaded at boot never surfaces a message
+ * whose window already closed.
+ */
+export class NoticeQueue {
+ readonly #storage: JournalStorage;
+ readonly #now: () => number;
+ #document: NoticeDocument = { version: 1, entries: [] };
+ #chain: Promise = Promise.resolve();
+ #loaded = false;
+ #loading: Promise | null = null;
+ #listeners = new Set<() => void>();
+ #onCountChange?: (count: number) => void;
+
+ /** Creates a queue over one storage location and clock. */
+ constructor(
+ storage: JournalStorage,
+ now: () => number = Date.now,
+ onCountChange?: (count: number) => void,
+ ) {
+ this.#storage = storage;
+ this.#now = now;
+ this.#onCountChange = onCountChange;
+ }
+
+ /**
+ * Loads the persisted document once, dropping already-expired entries.
+ * Entries enqueued in the async load window — an effect firing at a boot
+ * re-arm before load resolves — are merged in rather than clobbered: the
+ * persisted set is the base, and any in-memory entry (fresher) wins on id.
+ */
+ load(): Promise {
+ if (this.#loaded) return Promise.resolve();
+ this.#loading ??= this.#load().catch((error) => {
+ this.#loading = null;
+ throw error;
+ });
+ return this.#loading;
+ }
+
+ async #load(): Promise {
+ const persisted = parse(await this.#storage.load());
+ const pending = this.#document.entries;
+ const byId = new Map();
+ for (const entry of persisted.entries) byId.set(entry.id, entry);
+ // In-memory entries were enqueued this session and are authoritative for
+ // their id; they overwrite any stale persisted copy.
+ for (const entry of pending) byId.set(entry.id, entry);
+ this.#document = { version: 1, entries: [...byId.values()] };
+ this.#loaded = true;
+ const swept = this.#sweepExpired();
+ // A load that absorbed in-flight entries must re-persist the merged set
+ // and publish it, so the boot-enqueued notice is not silently deferred.
+ if (pending.length > 0 || swept) await this.#persist();
+ this.#emit();
+ }
+
+ /**
+ * Enqueues one notice, idempotent by id: an entry whose id is already
+ * present is left untouched, so an at-least-once re-delivery adds nothing.
+ */
+ async enqueue(input: NoticeEnqueueInput): Promise {
+ await this.load();
+ const exists = this.#document.entries.some((entry) => entry.id === input.id);
+ if (!exists) {
+ const createdAt = this.#now();
+ this.#document.entries.push({
+ id: input.id,
+ message: input.message,
+ tone: input.tone,
+ createdAt,
+ expiresAt: input.ttlMs === null ? null : createdAt + input.ttlMs,
+ });
+ this.#emit();
+ }
+ // Persist duplicate deliveries too: if the first attempt mutated memory
+ // but its save failed, replay is the retry that makes the entry durable.
+ await this.#persist();
+ }
+
+ /** Dismisses one entry by id; unknown ids are a no-op. */
+ dismiss(id: string): void {
+ const next = this.#document.entries.filter((entry) => entry.id !== id);
+ if (next.length === this.#document.entries.length) return;
+ this.#document.entries = next;
+ void this.#persist().catch(() => undefined);
+ this.#emit();
+ }
+
+ /** Dismisses every entry. */
+ dismissAll(): void {
+ if (this.#document.entries.length === 0) return;
+ this.#document.entries = [];
+ void this.#persist().catch(() => undefined);
+ this.#emit();
+ }
+
+ /**
+ * The live notices: enqueued, not dismissed, not past their TTL. A pure
+ * read — it computes the live view without mutating stored state, so a
+ * render never silently prunes the queue (which would drift the persisted
+ * set and the `activeNotices` count from what a later `sweep()` sees).
+ * Actual retirement of expired entries happens in {@link sweep}, which
+ * persists and notifies. A fresh array each call, so callers may hold it.
+ */
+ list(): readonly NoticeEntry[] {
+ return this.#liveEntries();
+ }
+
+ #liveEntries(): NoticeEntry[] {
+ const now = this.#now();
+ return this.#document.entries.filter((entry) =>
+ entry.expiresAt === null || entry.expiresAt > now
+ );
+ }
+
+ /** Subscribes to queue changes; returns an unsubscribe function. */
+ subscribe(listener: () => void): () => void {
+ this.#listeners.add(listener);
+ return () => this.#listeners.delete(listener);
+ }
+
+ /** Resolves once every scheduled persistence has settled. */
+ flush(): Promise {
+ return this.#chain;
+ }
+
+ /** Applies TTL retirement, persisting and notifying if anything changed. */
+ sweep(): void {
+ if (this.#sweepExpired()) {
+ void this.#persist().catch(() => undefined);
+ this.#emit();
+ }
+ }
+
+ #sweepExpired(): boolean {
+ const now = this.#now();
+ const before = this.#document.entries.length;
+ this.#document.entries = this.#document.entries.filter((entry) =>
+ entry.expiresAt === null || entry.expiresAt > now
+ );
+ return this.#document.entries.length !== before;
+ }
+
+ #persist(): Promise {
+ const snapshot = JSON.stringify(this.#document);
+ const attempt = this.#chain.then(() => this.#storage.save(snapshot));
+ // Keep the serialized queue usable after a failed attempt while returning
+ // that failure to the effect handler that requires durable delivery.
+ this.#chain = attempt.catch(() => undefined);
+ return attempt;
+ }
+
+ #emit(): void {
+ // The count and every subscriber see the live view, so `activeNotices`
+ // never overcounts entries that have expired but not yet been swept.
+ this.#onCountChange?.(this.#liveEntries().length);
+ for (const listener of this.#listeners) listener();
+ }
+}
+
+/** Resolves the default notice-queue storage for one app id. */
+export function createDefaultNoticeStorage(appId: string): JournalStorage {
+ // The journal's storage resolver already picks OPFS, then localStorage, then
+ // memory; a distinct file name keeps the two documents apart.
+ return createDefaultJournalStorage(`notices-${appId}`);
+}
+
+/** An in-memory notice queue for tests and storage-less runtimes. */
+export function createMemoryNoticeQueue(now?: () => number): NoticeQueue {
+ return new NoticeQueue(createMemoryJournalStorage(), now);
+}
diff --git a/package/runtime/notice-queue_test.ts b/package/runtime/notice-queue_test.ts
new file mode 100644
index 0000000..9fc1372
--- /dev/null
+++ b/package/runtime/notice-queue_test.ts
@@ -0,0 +1,99 @@
+import { assert, assertCount } from "./test-assert.ts";
+import { createMemoryJournalStorage } from "./write-journal.ts";
+import { NoticeQueue } from "./notice-queue.ts";
+
+function queue(now: () => number = () => 1000): {
+ q: NoticeQueue;
+ storage: ReturnType;
+ counts: number[];
+} {
+ const storage = createMemoryJournalStorage();
+ const counts: number[] = [];
+ return { q: new NoticeQueue(storage, now, (count) => counts.push(count)), storage, counts };
+}
+
+Deno.test("enqueue is idempotent by id, so an at-least-once re-delivery adds one entry", async () => {
+ const { q, counts } = queue();
+ await q.enqueue({ id: "w1:notice#1", message: "Saved.", tone: "success", ttlMs: null });
+ await q.enqueue({ id: "w1:notice#1", message: "Saved.", tone: "success", ttlMs: null });
+ assertCount(q.list().length, 1, "the second enqueue with one id must be a no-op");
+ assertCount(counts.at(-1) ?? -1, 1, "the active-notice count must reflect one entry");
+});
+
+Deno.test("dismiss removes one entry and notifies subscribers", async () => {
+ const { q } = queue();
+ let notified = 0;
+ q.subscribe(() => notified += 1);
+ await q.enqueue({ id: "a", message: "one", tone: "info", ttlMs: null });
+ await q.enqueue({ id: "b", message: "two", tone: "info", ttlMs: null });
+ q.dismiss("a");
+ assertCount(q.list().length, 1, "dismiss must drop exactly the named entry");
+ assert(q.list()[0].id === "b", "the surviving entry must be the one not dismissed");
+ assert(notified >= 3, "each mutation must notify subscribers");
+});
+
+Deno.test("a TTL entry retires once its window closes", async () => {
+ let clock = 1000;
+ const { q } = queue(() => clock);
+ await q.enqueue({ id: "ttl", message: "temporary", tone: "warning", ttlMs: 5000 });
+ assertCount(q.list().length, 1, "the entry is live inside its window");
+ clock = 6001;
+ assertCount(q.list().length, 0, "list must not surface an entry past its TTL");
+});
+
+Deno.test("an entry enqueued during the load window is merged, not clobbered", async () => {
+ const storage = createMemoryJournalStorage(
+ JSON.stringify({
+ version: 1,
+ entries: [{ id: "persisted", message: "old", tone: "info", createdAt: 1, expiresAt: null }],
+ }),
+ );
+ const q = new NoticeQueue(storage, () => 1000);
+ // An effect fires at a boot re-arm and enqueues before load() resolves.
+ const enqueued = q.enqueue({ id: "boot", message: "fresh", tone: "success", ttlMs: null });
+ await Promise.all([q.load(), enqueued]);
+ const ids = q.list().map((entry) => entry.id).sort();
+ assertCount(ids.length, 2, "the boot entry and the persisted entry both survive");
+ assert(ids.includes("boot") && ids.includes("persisted"), "neither entry is clobbered");
+});
+
+Deno.test("a persisted queue reloads its entries and drops expired ones", async () => {
+ let clock = 1000;
+ const storage = createMemoryJournalStorage();
+ const first = new NoticeQueue(storage, () => clock);
+ await first.enqueue({ id: "keep", message: "durable", tone: "info", ttlMs: null });
+ await first.enqueue({ id: "drop", message: "fleeting", tone: "info", ttlMs: 100 });
+ await first.flush();
+
+ clock = 5000;
+ const second = new NoticeQueue(storage, () => clock);
+ await second.load();
+ const ids = second.list().map((entry) => entry.id);
+ assert(ids.includes("keep"), "a persisted entry must survive a reload");
+ assert(!ids.includes("drop"), "an entry whose TTL passed while away must not reappear");
+});
+
+Deno.test("enqueue rejects a failed save and replay retries persistence", async () => {
+ const saves: string[] = [];
+ let attempts = 0;
+ const q = new NoticeQueue({
+ load: () => Promise.resolve(null),
+ save(text) {
+ attempts += 1;
+ if (attempts === 1) return Promise.reject(new Error("quota"));
+ saves.push(text);
+ return Promise.resolve();
+ },
+ });
+ const input = { id: "retry", message: "durable", tone: "error" as const, ttlMs: null };
+ let rejected = false;
+ try {
+ await q.enqueue(input);
+ } catch {
+ rejected = true;
+ }
+ assert(rejected, "the effect handler must observe a failed durable save");
+ await q.enqueue(input);
+ assert(attempts === 2, "re-delivery must retry persistence for an in-memory duplicate");
+ assert(saves[0]?.includes('"id":"retry"') === true, "the replayed entry must become durable");
+});
diff --git a/package/runtime/passkey-recovery.ts b/package/runtime/passkey-recovery.ts
index 41e39ad..cee1390 100644
--- a/package/runtime/passkey-recovery.ts
+++ b/package/runtime/passkey-recovery.ts
@@ -21,7 +21,8 @@ export type RecoverablePasskeyErrorCode =
| "restore-failed";
const messages: Record = {
- cancelled: "The passkey prompt was cancelled. Nothing on this device was replaced.",
+ cancelled:
+ "A passkey was not created or opened. Nothing on this device was replaced; try again or use the recovery phrase.",
unsupported:
"This browser cannot create or restore a recoverable passkey. Use the recovery phrase instead.",
"credential-missing":
diff --git a/package/runtime/passkey-recovery_test.ts b/package/runtime/passkey-recovery_test.ts
index 8344714..17bded7 100644
--- a/package/runtime/passkey-recovery_test.ts
+++ b/package/runtime/passkey-recovery_test.ts
@@ -25,6 +25,12 @@ Deno.test("passkey backup errors become actionable non-secret recovery errors",
!mapped.message.includes("vendor detail"),
"vendor detail leaked into public error text",
);
+ if (code === "cancelled") {
+ assert(
+ mapped.message.includes("not created or opened") && !mapped.message.includes("cancelled"),
+ "NotAllowedError guidance must not assume the person dismissed the prompt",
+ );
+ }
}
});
diff --git a/package/runtime/recovery.ts b/package/runtime/recovery.ts
index 49cd7df..bb94a8a 100644
--- a/package/runtime/recovery.ts
+++ b/package/runtime/recovery.ts
@@ -21,32 +21,39 @@ import { RecoveryPhrase, RecoveryPhraseError } from "jazz-tools/passphrase";
/** The number of words in a lofi recovery phrase. */
export const RECOVERY_PHRASE_WORDS = 24;
+/** Stable recovery-phrase failure categories for actionable UI guidance. */
+export type RecoveryErrorCode =
+ | "invalid-length"
+ | "invalid-word"
+ | "invalid-checksum"
+ | "invalid-secret";
+
+const MESSAGES: Record = {
+ "invalid-length":
+ `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,
+ "invalid-word": "One of the words is not in the recovery word list — check your spelling.",
+ "invalid-checksum":
+ "That phrase is not a valid recovery phrase — re-check the words and their order.",
+ "invalid-secret": "The account secret could not be encoded as a recovery phrase.",
+};
+
/** A precise, non-leaking failure reason for a recovery-phrase operation. */
export class RecoveryError extends Error {
/** Stable error class name for diagnostics and error boundaries. */
override readonly name = "RecoveryError";
/** Actionable category that callers can map to recovery guidance. */
- readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret";
+ readonly code: RecoveryErrorCode;
/** Creates a phrase error without retaining the submitted phrase. */
- constructor(code: RecoveryError["code"], message?: string) {
- super(message ?? `Recovery phrase operation failed: ${code}.`);
+ constructor(code: RecoveryErrorCode, message?: string) {
+ super(message ?? MESSAGES[code]);
this.code = code;
}
}
-const MESSAGES: Record = {
- "invalid-length":
- `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,
- "invalid-word": "One of the words is not in the recovery word list — check your spelling.",
- "invalid-checksum":
- "That phrase is not a valid recovery phrase — re-check the words and their order.",
- "invalid-secret": "The account secret could not be encoded as a recovery phrase.",
-};
-
function mapError(error: unknown): RecoveryError {
if (error instanceof RecoveryError) return error;
if (error instanceof RecoveryPhraseError) {
- const code = error.code as RecoveryError["code"];
+ const code = error.code as RecoveryErrorCode;
return new RecoveryError(code, MESSAGES[code] ?? undefined);
}
return new RecoveryError("invalid-checksum", error instanceof Error ? error.message : undefined);
diff --git a/package/runtime/recovery_test.ts b/package/runtime/recovery_test.ts
index 701d4c9..c020fee 100644
--- a/package/runtime/recovery_test.ts
+++ b/package/runtime/recovery_test.ts
@@ -49,12 +49,20 @@ test("fromRecoveryPhrase tolerates messy whitespace and casing", () => {
test("fromRecoveryPhrase rejects a wrong word count with invalid-length", () => {
let code: string | undefined;
+ let message = "";
try {
fromRecoveryPhrase("one two three");
} catch (error) {
- if (error instanceof RecoveryError) code = error.code;
+ if (error instanceof RecoveryError) {
+ code = error.code;
+ message = error.message;
+ }
}
assert(code === "invalid-length", "too few words must raise invalid-length");
+ assert(
+ message.includes(`${RECOVERY_PHRASE_WORDS} words`) && !message.includes("invalid-length"),
+ "the invalid-length error must give person-readable word-count guidance",
+ );
});
test("fromRecoveryPhrase rejects an empty phrase with invalid-length", () => {
diff --git a/package/runtime/runtime.ts b/package/runtime/runtime.ts
index 453c4a4..6d69b30 100644
--- a/package/runtime/runtime.ts
+++ b/package/runtime/runtime.ts
@@ -294,15 +294,6 @@ async function createClient(state: RuntimeSlot): Promise {
}
notifyDiagnostics(state);
}
- // The store preflight rides alongside database creation, never in front
- // of it: local-first boot must not wait on the network. resolveStoreStatus
- // maps every failure and timeout to a diagnostic value, so this only
- // records state — a schema-less or drifted store surfaces here at boot
- // instead of as a hanging first write, and is never repaired from here.
- void resolveStoreStatus({ connect: effectiveConnect, sink: activeSink() }).then((status) => {
- state.diagnostics.storeStatus = status;
- notifyDiagnostics(state);
- });
// A possession-bound sink proves the device key before connecting: the
// exchange mints a connect token the sync client carries as a path
// segment. A failed exchange (node restart, revocation, key loss) boots
@@ -325,6 +316,17 @@ async function createClient(state: RuntimeSlot): Promise {
keyPair,
}) ?? undefined;
}
+ // The store preflight rides alongside database creation, never in front
+ // of it: local-first boot must not wait on the network. A possession-bound
+ // sink must use the authenticated connect URL minted above; its bare
+ // ticket URL correctly rejects even metadata reads. resolveStoreStatus
+ // maps every remaining failure and timeout to a diagnostic value, so this
+ // only records state and never repairs the store.
+ const statusSink = popSink ? { serverUrl: serverUrlOverride ?? popSink.serverUrl } : null;
+ void resolveStoreStatus({ connect: effectiveConnect, sink: statusSink }).then((status) => {
+ state.diagnostics.storeStatus = status;
+ notifyDiagnostics(state);
+ });
const db = await createDb(
databaseConfig(
secret,
diff --git a/package/runtime/session.ts b/package/runtime/session.ts
index 1c4e10a..b4d43e4 100644
--- a/package/runtime/session.ts
+++ b/package/runtime/session.ts
@@ -48,7 +48,12 @@ import {
secretFingerprint,
SyncOwnerError,
} from "./sync-owner.ts";
-import { type DevicePublicKey, exportDevicePublicKey, getOrCreatePopKeyPair } from "./pop.ts";
+import {
+ completePopExchange,
+ type DevicePublicKey,
+ exportDevicePublicKey,
+ getOrCreatePopKeyPair,
+} from "./pop.ts";
import { holdProvisionCapability } from "./provision.ts";
import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";
import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";
@@ -437,11 +442,13 @@ export async function performTicketEnrollment(
): Promise {
// Only a provision-scoped ticket reaches the scope-down exchange, so only
// then is there a binding to offer.
+ let deviceKeyPair: CryptoKeyPair | undefined;
let devicePublicKey: DevicePublicKey | undefined;
const parsed = parseSyncTicket(ticket);
if (parsed?.scope === "provision") {
try {
- devicePublicKey = await exportDevicePublicKey(await getOrCreatePopKeyPair(parsed.appId));
+ deviceKeyPair = await getOrCreatePopKeyPair(parsed.appId);
+ devicePublicKey = await exportDevicePublicKey(deviceKeyPair);
} catch {
// No usable key custody in this context; the exchange still derives a
// ticket, held as a bearer credential exactly as before.
@@ -450,6 +457,21 @@ export async function performTicketEnrollment(
const split = await splitTicketForEnrollment(ticket, deps.fetcher, devicePublicKey);
const previous = readDeclaredSink();
const declared = await declareSinkFromTicket(split.sinkTicket, deps.keyStore, split.pop);
+ // A PoP-bound derived ticket rejects every bare request, including the
+ // metadata preflight. Prove possession first and ask store-status through
+ // the short-lived connect URL, just as the managed runtime does for sync.
+ // Without this exchange a healthy store looks unreachable, while a real
+ // no-schema refusal can be lost and enrollment incorrectly kept.
+ let preflightServerUrl = declared.serverUrl;
+ if (split.pop && deviceKeyPair) {
+ preflightServerUrl = await completePopExchange({
+ serverUrl: declared.serverUrl,
+ appId: declared.appId,
+ ticketId: split.pop.ticketId,
+ keyPair: deviceKeyPair,
+ ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),
+ }) ?? declared.serverUrl;
+ }
// The preflight decides whether enrollment is kept: a store that answers
// with a definite refusal rolls the declaration back before anything else
// (election, provision custody) observes it. An unreachable store is not a
@@ -457,7 +479,7 @@ export async function performTicketEnrollment(
// the warning recorded where status surfaces read it.
const status = await resolveStoreStatus({
connect: true,
- sink: { serverUrl: declared.serverUrl },
+ sink: { serverUrl: preflightServerUrl },
...(deps.preflight ? { preflight: deps.preflight } : {}),
...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),
});
diff --git a/package/runtime/session_test.ts b/package/runtime/session_test.ts
index e9ea65a..775cc6b 100644
--- a/package/runtime/session_test.ts
+++ b/package/runtime/session_test.ts
@@ -288,6 +288,112 @@ test(
}),
);
+test(
+ "a PoP-bound provision enrollment preflights through its authenticated connect URL",
+ withCleanSyncState(async () => {
+ const derivedSecret = "d".repeat(43);
+ const connectSecret = "c".repeat(43);
+ const requestedUrls: string[] = [];
+ const fetcher: typeof fetch = (input, init) => {
+ const url = String(input);
+ requestedUrls.push(url);
+ if (url.endsWith("/derive-sync-ticket")) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),
+ { status: 200 },
+ ),
+ );
+ }
+ if (url.endsWith("/pop/challenge")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {
+ status: 200,
+ }),
+ );
+ }
+ if (url.endsWith("/pop/answer") && init?.method === "POST") {
+ return Promise.resolve(
+ new Response(JSON.stringify({ v: 1, connect: connectSecret }), { status: 200 }),
+ );
+ }
+ throw new Error(`unexpected fetch: ${url}`);
+ };
+ let preflightUrl = "";
+ await performTicketEnrollment(provisionTicket, {
+ fetcher,
+ keyStore: memoryDeviceKeyStore(),
+ preflight: (url) => {
+ preflightUrl = url;
+ return answering("deployed")();
+ },
+ elect: () => Promise.resolve(undefined as never),
+ });
+ assert(
+ preflightUrl ===
+ `http://192.168.1.10:4802/t/${derivedSecret}/c/${connectSecret}`,
+ `store-status must use the PoP-authenticated connect URL (received ${preflightUrl})`,
+ );
+ assert(
+ requestedUrls.some((url) => url.endsWith("/pop/challenge")) &&
+ requestedUrls.some((url) => url.endsWith("/pop/answer")),
+ "enrollment must complete the PoP exchange before preflight",
+ );
+ }),
+);
+
+test(
+ "a no_schema answer through a PoP connect URL still rolls enrollment back",
+ withCleanSyncState(async () => {
+ const fetcher: typeof fetch = (input) => {
+ const url = String(input);
+ if (url.endsWith("/derive-sync-ticket")) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),
+ { status: 200 },
+ ),
+ );
+ }
+ if (url.endsWith("/pop/challenge")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {
+ status: 200,
+ }),
+ );
+ }
+ if (url.endsWith("/pop/answer")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ v: 1, connect: "c".repeat(43) }), { status: 200 }),
+ );
+ }
+ throw new Error(`unexpected fetch: ${url}`);
+ };
+ let thrown: unknown;
+ try {
+ await performTicketEnrollment(provisionTicket, {
+ fetcher,
+ keyStore: memoryDeviceKeyStore(),
+ preflight: answering("no_schema"),
+ elect: () => Promise.reject(new Error("elect must not run")),
+ });
+ } catch (error) {
+ thrown = error;
+ }
+ assert(
+ isSyncEnrollmentError(thrown) && thrown.code === "no_schema",
+ `the authenticated no-schema answer must retain the enrollment safety gate (received ${
+ thrown instanceof Error ? `${thrown.name}: ${thrown.message}` : String(thrown)
+ })`,
+ );
+ assert(readDeclaredSink() === null, "the PoP-bound sink must be rolled back");
+ assert(
+ !provisionCapabilityStatus().held,
+ "the provision capability must not be held after the refused enrollment",
+ );
+ }),
+);
+
test(
"electing under a foreign owner is refused; an unclaimed election records the owner",
withCleanSyncState(async () => {
diff --git a/package/runtime/store-status_test.ts b/package/runtime/store-status_test.ts
index e285235..bd70107 100644
--- a/package/runtime/store-status_test.ts
+++ b/package/runtime/store-status_test.ts
@@ -97,8 +97,12 @@ Deno.test("a hung or failing preflight degrades to store_unavailable", async ()
);
});
-Deno.test("only a /t/ path counts as a ticket-gated server URL", () => {
+Deno.test("only ticket and PoP connect paths count as ticket-gated server URLs", () => {
assert(isTicketServerUrl(ticketUrl), "a valid ticket URL must be recognized");
+ assert(
+ isTicketServerUrl(`${ticketUrl}/c/${"c".repeat(43)}`),
+ "a valid PoP connect URL must be recognized",
+ );
assert(
!isTicketServerUrl("https://sync.example.com"),
"a first-party server URL must not be probed",
@@ -107,6 +111,10 @@ Deno.test("only a /t/ path counts as a ticket-gated server URL", () => {
!isTicketServerUrl("https://node.example/t/short"),
"a short secret segment must not be treated as a ticket path",
);
+ assert(
+ !isTicketServerUrl(`${ticketUrl}/c/short`),
+ "a short connect token must not be treated as a ticket path",
+ );
assert(!isTicketServerUrl("not a url"), "a malformed URL must not be treated as a ticket path");
});
diff --git a/package/runtime/write-journal.ts b/package/runtime/write-journal.ts
index 1223048..570534d 100644
--- a/package/runtime/write-journal.ts
+++ b/package/runtime/write-journal.ts
@@ -54,6 +54,8 @@ export type JournalWriteStage = "saved" | "synced" | "rejected";
export type JournalWriteRecord = {
/** The stable package-owned write id. */
writeId: string;
+ /** Parent obligation retaining this child until it commits, or `null`. */
+ retainedBy?: string | null;
/** The declaring verb's name, or `null` for writes without a verb. */
verb: string | null;
/** The written table's name. */
@@ -250,11 +252,7 @@ function localStorageStorage(key: string): JournalStorage | null {
}
},
save(text: string) {
- try {
- localStorage.setItem(key, text);
- } catch {
- // A private-mode quota failure degrades to session-only pending state.
- }
+ localStorage.setItem(key, text);
return Promise.resolve();
},
};
@@ -280,6 +278,7 @@ export class WriteJournal {
readonly #storage: JournalStorage;
#document: JournalDocument = emptyJournal();
#chain: Promise = Promise.resolve();
+ #lastAttempt: Promise = Promise.resolve();
#loaded = false;
/** Creates a journal over one storage location. */
@@ -305,13 +304,13 @@ export class WriteJournal {
update(mutate: (document: JournalDocument) => void): void {
mutate(this.#document);
const snapshot = JSON.stringify(this.#document);
- this.#chain = this.#chain
- .then(() => this.#storage.save(snapshot))
- .catch(() => undefined);
+ const attempt = this.#chain.then(() => this.#storage.save(snapshot));
+ this.#lastAttempt = attempt;
+ this.#chain = attempt.catch(() => undefined);
}
/** Resolves once every scheduled persistence has settled. */
flush(): Promise {
- return this.#chain;
+ return this.#lastAttempt;
}
}
diff --git a/package/runtime/write-ledger.ts b/package/runtime/write-ledger.ts
index ee36960..cea6d11 100644
--- a/package/runtime/write-ledger.ts
+++ b/package/runtime/write-ledger.ts
@@ -24,12 +24,19 @@ import {
type EffectContext,
type EffectRow,
type EffectUnit,
+ isPermanentEffectError,
type MutationDescriptor,
resolveEffectUnit,
setMutationRuntime,
} from "../schema/effects.ts";
import { appId, syncing } from "./config.ts";
-import { recordEffectLogEntry, type RuntimeDiagnostics } from "./diagnostics.ts";
+import {
+ recordEffectDebugEvent,
+ recordEffectLogEntry,
+ recordEffectTrace,
+ type RuntimeDiagnostics,
+} from "./diagnostics.ts";
+import { createDefaultNoticeStorage, type NoticeEntry, NoticeQueue } from "./notice-queue.ts";
import { settleDurableWrite } from "./durability.ts";
import { classifyMutationError } from "./mutation-taxonomy.ts";
import {
@@ -44,6 +51,7 @@ import { createWriteHandle, type WriteHandle, type WriteHandleController } from
import {
createDefaultJournalStorage,
hashJournalValue,
+ type JournalDocument,
type JournalEffectState,
journalIdFor,
type JournalStorage,
@@ -103,6 +111,10 @@ export type LedgerWriteOptions = {
units?: readonly EffectUnit<{ id: string }>[];
/** The intent's lifespan in milliseconds, or `null` for none. */
expiresAfterMs?: number | null;
+ /** Internal deterministic identity for a chain child. */
+ writeId?: string;
+ /** Parent obligation retaining this chain child until parent completion. */
+ retainedBy?: string | null;
};
type VendorWrite = {
@@ -272,7 +284,7 @@ export class WriteLedger {
*/
perform(request: LedgerWriteRequest, options: LedgerWriteOptions = {}): WriteHandle {
if (this.#disposed) throw new Error("write ledger has been disposed");
- const { handle, controller } = createWriteHandle(crypto.randomUUID());
+ const { handle, controller } = createWriteHandle(options.writeId ?? crypto.randomUUID());
const db = this.#db;
const guard = this.#environment.guardWrite;
if (db && !guard) {
@@ -321,12 +333,17 @@ export class WriteLedger {
}
/** Performs one declared verb call; the verb dispatcher's entry point. */
- performVerb(descriptor: MutationDescriptor, args: readonly unknown[]): WriteHandle {
+ performVerb(
+ descriptor: MutationDescriptor,
+ args: readonly unknown[],
+ internal: Pick = {},
+ ): WriteHandle {
const table = descriptor.op.table as TableProxy;
const options: LedgerWriteOptions = {
verb: descriptor.verbName,
units: descriptor.units,
expiresAfterMs: descriptor.expiresAfterMs,
+ ...internal,
};
if (descriptor.op.kind === "insert") {
return this.perform({ kind: "insert", table, values: args[0] }, options);
@@ -342,6 +359,22 @@ export class WriteLedger {
return this.perform({ kind: "remove", table, id: args[0] as string }, options);
}
+ /** Performs a chain child once and retains its record through parent commit. */
+ async performChainedVerb(
+ descriptor: MutationDescriptor,
+ args: readonly unknown[],
+ parentJournalId: string,
+ ): Promise {
+ const writeId = `chain:${parentJournalId}`;
+ if (this.#journal.document.writes[writeId]) {
+ await this.#journal.flush();
+ return;
+ }
+ const handle = this.performVerb(descriptor, args, { writeId, retainedBy: parentJournalId });
+ await handle.saved;
+ await this.#journal.flush();
+ }
+
/** Re-attempts outstanding obligations of one effect name after late registration. */
retryObligationsFor(effectName: string): void {
for (const entry of Object.values(this.#journal.document.writes)) {
@@ -557,6 +590,7 @@ export class WriteLedger {
const createdAt = this.#environment.now();
const entry: JournalWriteRecord = {
writeId: handle.writeId,
+ retainedBy: options.retainedBy ?? null,
verb: options.verb ?? null,
table: (request.table as { _table?: string })._table ?? "",
op: request.kind,
@@ -723,8 +757,9 @@ export class WriteLedger {
const fate = entry.stage === "synced" ? "synced" as const : "rejected" as const;
const handler = fate === "synced" ? unit.handlers.onSynced : unit.handlers.onRejected;
if (!handler) {
- this.#journal.update(() => {
+ this.#journal.update((document) => {
state.status = "done";
+ this.#releaseRetainedChildren(document, journalId);
});
this.#afterChange();
return;
@@ -743,7 +778,9 @@ export class WriteLedger {
writeId: entry.writeId,
verb: entry.verb,
table: entry.table,
+ op: entry.op,
rowId: entry.rowId,
+ writeCreatedAt: entry.createdAt,
fate,
cause: fate === "rejected" ? entry.cause ?? "denied" : null,
code: entry.code,
@@ -751,9 +788,10 @@ export class WriteLedger {
};
try {
await handler(row as EffectRow<{ id: string }>, context);
- this.#journal.update(() => {
+ this.#journal.update((document) => {
state.status = "done";
state.lastError = null;
+ this.#releaseRetainedChildren(document, journalId);
});
} catch (error) {
const maxAttempts = unit.maxAttempts ?? defaultMaxAttempts;
@@ -764,9 +802,11 @@ export class WriteLedger {
this.#environment.updateDiagnostics((diagnostics) =>
diagnostics.effectHandlerFailures += 1
);
- // Quarantine: a handler that keeps failing retires instead of
- // re-arming forever; the retirement is diagnosable and prunable.
- if (state.attempts >= maxAttempts) {
+ // A handler that declared its failure permanent retires now: retrying
+ // a request the receiver will keep refusing only burns the budget and
+ // delays the quarantine diagnostic. Otherwise a failing handler
+ // re-arms until repeated attempts quarantine it.
+ if (isPermanentEffectError(error) || state.attempts >= maxAttempts) {
this.#retireObligation(entry, effectName, "failed-permanent");
}
}
@@ -808,6 +848,12 @@ export class WriteLedger {
return { id: entry.rowId };
}
+ #releaseRetainedChildren(document: JournalDocument, parentJournalId: string): void {
+ for (const child of Object.values(document.writes)) {
+ if (child.retainedBy === parentJournalId) child.retainedBy = null;
+ }
+ }
+
#afterChange(): void {
const document = this.#journal.document;
for (const entry of Object.values(document.writes)) {
@@ -818,7 +864,7 @@ export class WriteLedger {
}
const settled = Object.values(entry.effects)
.every((state) => isRetired(state.status));
- if (settled) {
+ if (settled && !entry.retainedBy) {
// Fully settled entries leave the journal; failed-but-not-quarantined
// obligations keep theirs so the next boot can re-arm the handler.
this.#journal.update(() => {
@@ -898,10 +944,66 @@ export function armWriteLedger(): Promise {
return getWriteLedger().arm();
}
+let defaultNotices: NoticeQueue | null = null;
+let noticeSweepTimer: ReturnType | null = null;
+
+/**
+ * The package-wide durable notice queue behind `s.notice`, created lazily and
+ * kept in sync with the `activeNotices` diagnostic. Public read/subscribe/
+ * dismiss surfaces ({@link listNotices}, {@link subscribeNotices},
+ * {@link dismissNotice}) are re-exported from the runtime entry.
+ */
+export function getNoticeQueue(): NoticeQueue {
+ if (!defaultNotices) {
+ const queue = new NoticeQueue(
+ createDefaultNoticeStorage(appId),
+ Date.now,
+ (count) =>
+ updateRuntimeDiagnostics((diagnostics) => {
+ diagnostics.activeNotices = count;
+ }),
+ );
+ defaultNotices = queue;
+ // Load persisted entries; the merged initial count publishes through the
+ // queue's own onCountChange. A storage-less or prerender context simply
+ // starts empty.
+ void queue.load().catch(() => undefined);
+ // A TTL-only expiry has no queue mutation to repaint it, so a modest
+ // periodic sweep retires expired entries and republishes the count.
+ if (typeof setInterval === "function") {
+ noticeSweepTimer = setInterval(() => queue.sweep(), 30_000);
+ (noticeSweepTimer as { unref?: () => void })?.unref?.();
+ }
+ }
+ return defaultNotices;
+}
+
+/** The live durable notices for the current document. */
+export function listNotices(): readonly NoticeEntry[] {
+ return getNoticeQueue().list();
+}
+
+/** Subscribes to notice-queue changes; returns an unsubscribe function. */
+export function subscribeNotices(listener: () => void): () => void {
+ return getNoticeQueue().subscribe(listener);
+}
+
+/** Dismisses one durable notice by id. */
+export function dismissNotice(id: string): void {
+ getNoticeQueue().dismiss(id);
+}
+
+/** Dismisses every durable notice. */
+export function dismissAllNotices(): void {
+ getNoticeQueue().dismissAll();
+}
+
// The runtime half of the schema-declared verb surface: importing the runtime
// package installs dispatch, so verbs work wherever islands import hooks.
setMutationRuntime({
dispatch: (descriptor, args) => getWriteLedger().performVerb(descriptor, args),
+ dispatchChained: (descriptor, args, parentJournalId) =>
+ getWriteLedger().performChainedVerb(descriptor, args, parentJournalId),
recordLog: (label, context) =>
updateRuntimeDiagnostics((diagnostics) =>
recordEffectLogEntry(diagnostics, {
@@ -913,10 +1015,52 @@ setMutationRuntime({
at: Date.now(),
})
),
+ recordTrace: (label, context) =>
+ updateRuntimeDiagnostics((diagnostics) =>
+ recordEffectTrace(diagnostics, {
+ label,
+ verb: context.verb,
+ rowId: context.rowId,
+ fate: context.fate,
+ durationMs: Math.max(0, Date.now() - context.writeCreatedAt),
+ at: Date.now(),
+ })
+ ),
+ recordDebug: (event, context) =>
+ updateRuntimeDiagnostics((diagnostics) =>
+ recordEffectDebugEvent(diagnostics, {
+ verb: context.verb,
+ journalId: context.journalId,
+ event,
+ at: Date.now(),
+ })
+ ),
+ enqueueNotice: (input, context) =>
+ getNoticeQueue().enqueue({
+ id: context.journalId,
+ message: input.message,
+ tone: input.tone,
+ ttlMs: input.ttlMs,
+ }),
+ applyMark: async (table, rowId, patch) => {
+ try {
+ await getWriteLedger().perform({ kind: "update", table, id: rowId, patch }).saved;
+ } catch {
+ // A mark is best-effort: the status patch may find no row (a row removed
+ // elsewhere) or be denied by policy. Swallow so the mark obligation does
+ // not re-arm and quarantine over a patch that will never land; the
+ // fate-as-data guarantee for must-survive signals belongs to s.notice.
+ }
+ },
unitRegistered: (name) => defaultLedger?.retryObligationsFor(name),
});
import.meta.hot?.dispose(() => {
defaultLedger?.dispose();
defaultLedger = null;
+ defaultNotices = null;
+ if (noticeSweepTimer !== null) {
+ clearInterval(noticeSweepTimer);
+ noticeSweepTimer = null;
+ }
});
diff --git a/package/runtime/write-ledger_test.ts b/package/runtime/write-ledger_test.ts
index 17e91de..32d1759 100644
--- a/package/runtime/write-ledger_test.ts
+++ b/package/runtime/write-ledger_test.ts
@@ -1,6 +1,11 @@
import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";
import { createDiagnostics } from "./diagnostics.ts";
-import type { EffectContext, EffectUnit } from "../schema/effects.ts";
+import {
+ type EffectContext,
+ type EffectUnit,
+ type MutationDescriptor,
+ PermanentEffectError,
+} from "../schema/effects.ts";
import { assert, assertCount } from "./test-assert.ts";
import {
createMemoryJournalStorage,
@@ -135,6 +140,7 @@ function unit(
options: {
failFirst?: { onSynced?: boolean };
failAlways?: boolean;
+ failPermanent?: boolean;
expiresAfterMs?: number;
maxAttempts?: number;
} = {},
@@ -144,6 +150,7 @@ function unit(
effectName: name,
handlers: {
onSynced: (row, context) => {
+ if (options.failPermanent) throw new PermanentEffectError("receiver refused for good");
if (options.failAlways) throw new Error("handler always explodes");
if (options.failFirst?.onSynced && !failed) {
failed = true;
@@ -747,6 +754,38 @@ Deno.test("a closed delivery window retires the obligation: no handler, diagnosa
fixture.ledger.dispose();
});
+Deno.test("a PermanentEffectError retires the obligation on the first failure", async () => {
+ const storage = createMemoryJournalStorage();
+ const calls: Array<
+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }
+ > = [];
+ // A high attempt bound proves the retirement is the permanent signal, not
+ // the attempt count: an ordinary throw would re-arm four more times.
+ const refusing = unit("permanent", calls, { failPermanent: true, maxAttempts: 5 });
+ const fixture = harness({ storage, units: new Map([["permanent", refusing]]) });
+ await fixture.ledger.arm();
+ const handle = fixture.ledger.perform(
+ { kind: "insert", table: table as TableProxy, values: { title: "gone" } },
+ { units: [refusing] },
+ );
+ await handle;
+ fixture.db.settleGlobal();
+ await flush();
+ await fixture.ledger.flush();
+ assertCount(fixture.diagnostics.effectHandlerFailures, 1, "the failure is counted once");
+ assertCount(
+ fixture.diagnostics.quarantinedObligations,
+ 1,
+ "a permanent failure quarantines without exhausting attempts",
+ );
+ assert(
+ (storage.text() ?? "").includes(handle.writeId) === false,
+ "the retired obligation makes the entry prunable, so it never re-arms",
+ );
+ assertCount(calls.length, 0, "a permanently refused handler never reports success");
+ fixture.ledger.dispose();
+});
+
Deno.test("quarantine retires a permanently failing handler after its attempt bound", async () => {
const storage = createMemoryJournalStorage();
const calls: Array<
@@ -784,3 +823,27 @@ Deno.test("quarantine retires a permanently failing handler after its attempt bo
assertCount(calls.length, 0, "a quarantined handler never reports success");
second.ledger.dispose();
});
+
+Deno.test("a chained child uses one retained deterministic write across replay", async () => {
+ const fixture = harness();
+ await fixture.ledger.arm();
+ const descriptor: MutationDescriptor = {
+ verbName: "createChild",
+ op: { kind: "insert", table: table as TableProxy },
+ units: [],
+ expiresAfterMs: null,
+ };
+ const parentJournalId = "parent-write:chain#0";
+ await fixture.ledger.performChainedVerb(descriptor, [{ title: "child" }], parentJournalId);
+ assertCount(fixture.db.nextBatch, 1, "the first delivery must issue one child mutation");
+ const writeId = `chain:${parentJournalId}`;
+ const first = JSON.parse(fixture.storage.text() ?? "{}") as JournalDocument;
+ assert(
+ first.writes[writeId]?.retainedBy === parentJournalId,
+ "the child record must remain retained until its parent obligation commits",
+ );
+
+ await fixture.ledger.performChainedVerb(descriptor, [{ title: "child" }], parentJournalId);
+ assertCount(fixture.db.nextBatch, 1, "replay must observe the retained child, not issue another");
+ fixture.ledger.dispose();
+});
diff --git a/package/schema/effect-library.ts b/package/schema/effect-library.ts
new file mode 100644
index 0000000..ac4774e
--- /dev/null
+++ b/package/schema/effect-library.ts
@@ -0,0 +1,361 @@
+///
+/**
+ * The built-in effect library: reusable {@link EffectUnit}s tiered by risk,
+ * each idempotent by construction so it doubles as a reference implementation
+ * of the at-least-once discipline a custom unit author must follow.
+ *
+ * The tiers are a learning ramp, one new concept each:
+ *
+ * - **Observation** — {@link trace}, {@link debug}. Cannot change anything;
+ * they only record. (The first observation unit, `s.log`, lives in
+ * `effects.ts` beside the core because the runtime records it directly.)
+ * - **Data-internal** — {@link notice}, {@link mark}, {@link chain}. Write
+ * back into the app: a durable message, a status column, a follow-up verb.
+ * Fate stops being a callback and becomes replicated data.
+ * - **External** — {@link webhook}. Calls the outside world, where the
+ * idempotency key and a bounded, backed-off retry earn their keep.
+ *
+ * Every unit here is built on the public authoring surface from `effects.ts`
+ * ({@link effect}, {@link EffectContext}, {@link PermanentEffectError}, and the
+ * optional runtime capabilities the package runtime installs) — nothing
+ * private. The authoring guide points custom-unit authors at these as models.
+ *
+ * @module
+ */
+
+import type { TableProxy } from "jazz-tools";
+import type { WriteHandle } from "../runtime/write-handle.ts";
+import {
+ anonymousUnit,
+ cachedBuiltin,
+ dispatchChainedMutation,
+ type EffectContext,
+ type EffectHandlers,
+ type EffectRow,
+ type EffectUnit,
+ type NoticeInput,
+ PermanentEffectError,
+ requireMutationRuntime,
+} from "./effects.ts";
+
+/**
+ * Observation unit: a span from the write's journaling to its settled fate,
+ * recorded in runtime diagnostics as an OpenTelemetry-shaped event with the
+ * saved→synced/rejected latency. Pure instrumentation — it changes no state
+ * and cannot fail a write. The optional label groups related spans; without
+ * one the verb name labels the span. Repeated calls with one label share a
+ * unit, so a label can be reused across verbs.
+ *
+ * @example
+ * ```ts
+ * export const placeOrder = s.mutation("placeOrder", s.insert(app.orders), {
+ * effects: [s.trace("checkout")],
+ * });
+ * ```
+ */
+export function trace(label?: string): EffectUnit<{ id: string }> {
+ if (label !== undefined && !label.trim()) throw new Error("trace label must not be empty");
+ const name = label ? `trace:${label}` : "trace";
+ return cachedBuiltin(name, () => {
+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {
+ requireMutationRuntime().recordTrace?.(label ?? null, context);
+ };
+ return { onSynced: record, onRejected: record };
+ });
+}
+
+/**
+ * Observation unit: a development-only timeline entry for each fate an
+ * obligation settles, for eyeballing effect delivery in the inspector.
+ * Stripped from production builds — in a `PROD` bundle the handlers record
+ * nothing, so it costs a closure and no more.
+ *
+ * @example
+ * ```ts
+ * export const editNote = s.mutation("editNote", s.update(app.notes), {
+ * effects: [s.debug()],
+ * });
+ * ```
+ */
+export function debug(): EffectUnit<{ id: string }> {
+ return cachedBuiltin("debug", () => {
+ const production = !import.meta.env.DEV;
+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {
+ if (production) return;
+ requireMutationRuntime().recordDebug?.(context.fate, context);
+ };
+ return { onSynced: record, onRejected: record };
+ });
+}
+
+/** A notice message resolved from the settled row, or a static string. */
+export type NoticeResolver = string | ((row: EffectRow) => string);
+
+/** Per-fate notice configuration for {@link notice}. */
+export type NoticeConfig = {
+ /** The message (or resolver) enqueued when the write syncs. */
+ synced?: NoticeResolver;
+ /** The message (or resolver) enqueued when the write is rejected. */
+ rejected?: NoticeResolver;
+ /**
+ * Lifespan of an enqueued entry before the durable queue retires it. Omit
+ * for the queue default; pass `null` to keep the entry until dismissed.
+ */
+ ttlMs?: number | null;
+};
+
+function resolveNotice(resolver: NoticeResolver, row: EffectRow): string {
+ return typeof resolver === "function" ? resolver(row) : resolver;
+}
+
+/**
+ * Data-internal unit: enqueues a durable, user-visible message when the write
+ * settles — the fix for the "a rejected write still flashed success" failure
+ * mode. The queue is durable and UI-agnostic: an entry created at a boot
+ * re-arm survives with nothing mounted, and a component (the built-in notices
+ * surface, or an author's) renders it. Toasts are a userland wrapper over the
+ * queue, never an imperative call from here.
+ *
+ * Idempotent by the obligation's journal id: a re-delivered handler enqueues
+ * the same keyed entry once, so a crash-and-replay shows one message.
+ *
+ * @example
+ * ```ts
+ * export const publish = s.mutation("publish", s.update(app.posts), {
+ * effects: [s.notice({
+ * synced: "Published.",
+ * rejected: (post) => `Could not publish "${post.title}".`,
+ * })],
+ * });
+ * ```
+ */
+export function notice(
+ config: NoticeConfig,
+): EffectUnit {
+ if (config.synced === undefined && config.rejected === undefined) {
+ throw new Error("notice must configure at least one of synced or rejected");
+ }
+ const ttlMs = config.ttlMs;
+ const enqueue = (
+ resolver: NoticeResolver | undefined,
+ tone: NoticeInput["tone"],
+ ) =>
+ async (row: EffectRow<{ id: string }>, context: EffectContext) => {
+ if (resolver === undefined) return;
+ const runtime = requireMutationRuntime();
+ await runtime.enqueueNotice?.(
+ { message: resolveNotice(resolver, row as EffectRow), tone, ttlMs: ttlMs ?? null },
+ context,
+ );
+ };
+ return anonymousUnit<{ id: string }>("notice", {
+ onSynced: enqueue(config.synced, "success"),
+ onRejected: enqueue(config.rejected, "error"),
+ }) as unknown as EffectUnit;
+}
+
+/** Per-fate row patches for {@link mark}. */
+export type MarkConfig = {
+ /** The patch applied to the row when the write syncs. */
+ synced?: Partial;
+ /** The patch applied to the row when the write is rejected. */
+ rejected?: Partial;
+};
+
+/**
+ * Data-internal unit: patches the written row when its fate resolves, so write
+ * fate becomes replicated data every device and query sees — the hand-rolled
+ * `status` column made declarative. The patch is absolute (a static
+ * set-column-to-value object), so it is convergent under re-delivery: applying
+ * it twice lands the same row.
+ *
+ * A rejected *insert* has no row to mark — the engine rolled it out — so the
+ * rejected patch is skipped for inserts; on updates and removes the row
+ * survives the rollback and the patch records the failure.
+ *
+ * The mark is best-effort: its patch is an ordinary update that the store may
+ * itself deny (a policy that governs the status column), or that finds no row
+ * (an update whose row was concurrently removed elsewhere). In those cases the
+ * mark simply does not land — it is a convenience over the manual status
+ * column, not a delivery guarantee. For a fate signal that must survive, pair
+ * it with {@link notice} (durable queue) or a `webhook`.
+ *
+ * @example
+ * ```ts
+ * export const submit = s.mutation("submit", s.update(app.claims), {
+ * effects: [s.mark(app.claims, {
+ * synced: { status: "confirmed" },
+ * rejected: { status: "failed" },
+ * })],
+ * });
+ * ```
+ */
+export function mark(
+ table: TableProxy,
+ config: MarkConfig,
+): EffectUnit {
+ if (config.synced === undefined && config.rejected === undefined) {
+ throw new Error("mark must configure at least one of synced or rejected");
+ }
+ const apply = async (
+ patch: Partial | undefined,
+ row: EffectRow,
+ skipForInsertOp: boolean,
+ context: EffectContext,
+ ) => {
+ if (patch === undefined) return;
+ // A rejected insert left no row behind; patching a vanished id would only
+ // fail the obligation into a pointless retry.
+ if (skipForInsertOp && context.op === "insert") return;
+ await requireMutationRuntime().applyMark?.(
+ table as TableProxy,
+ row.id,
+ patch as Record,
+ );
+ };
+ return anonymousUnit("mark", {
+ onSynced: (row, context) => apply(config.synced, row, false, context),
+ onRejected: (row, context) => apply(config.rejected, row, true, context),
+ });
+}
+
+/**
+ * Data-internal unit: issues a follow-up verb once the write syncs, mapping
+ * the settled row to the next verb's input. Reifies a chain of writes
+ * (reserve → charge → fulfill) declaratively, without a saga API — each link
+ * is an ordinary verb, so its own effects and rejection handling apply. Fires
+ * only on `synced`; a rejected write starts no chain.
+ *
+ * The follow-up is journaled under a deterministic child write id derived
+ * from this obligation. Its record remains retained until the parent commits
+ * delivery, so a crash between those steps reuses the child instead of
+ * issuing the mutation again.
+ *
+ * @example
+ * ```ts
+ * export const reserve = s.mutation("reserve", s.insert(app.holds), {
+ * effects: [s.chain(charge, (hold) => ({ holdId: hold.id, amount: hold.total }))],
+ * });
+ * ```
+ */
+export function chain(
+ next: (input: NextInput) => WriteHandle,
+ toInput: (row: EffectRow) => NextInput,
+): EffectUnit {
+ return anonymousUnit<{ id: string }>("chain", {
+ onSynced: async (row, context) => {
+ // The ledger derives the child write id from this obligation and keeps
+ // its record until the parent is committed done. A replay therefore
+ // observes the existing child instead of issuing the mutation twice.
+ const input = toInput(row as EffectRow);
+ await dispatchChainedMutation(next, [input], context.journalId);
+ },
+ }) as unknown as EffectUnit;
+}
+
+/** Options for the {@link webhook} unit. */
+export type WebhookOptions = {
+ /** Which fates POST; defaults to both. */
+ on?: ReadonlyArray<"synced" | "rejected">;
+ /**
+ * Delivery window in milliseconds. Receiver idempotency windows are finite
+ * (Stripe forgets keys after ~24h), so external delivery defaults to a
+ * conservative 24 hours rather than infinity; pass `null` to opt into no
+ * expiry.
+ *
+ * @default 86_400_000
+ */
+ expiresAfterMs?: number | null;
+ /** Failing attempts before the obligation is quarantined. */
+ maxAttempts?: number;
+ /** Extra headers merged onto the POST (the idempotency key is always set). */
+ headers?: Record;
+};
+
+const defaultWebhookExpiry = 24 * 60 * 60 * 1000;
+
+/**
+ * External unit: POSTs the settled row and its fate to `url`, using `name` as
+ * its safe durable identity and with the
+ * obligation's journal id auto-injected as `Idempotency-Key` so a re-delivery
+ * the receiver already saw is dropped receiver-side. The generic
+ * outside-world workhorse and the reference for the at-least-once contract.
+ *
+ * Failure severity follows the response: a transient failure (network error,
+ * `5xx`, `429`) throws an ordinary error, so the ledger's bounded backoff
+ * retries it; a `4xx` (other than `429`) throws {@link PermanentEffectError},
+ * retiring the obligation without burning the retry budget on a request the
+ * receiver will keep refusing. Because receiver keys expire, delivery defaults
+ * to a 24-hour window ({@link WebhookOptions.expiresAfterMs}).
+ *
+ * @example
+ * ```ts
+ * export const order = s.mutation("order", s.insert(app.orders), {
+ * effects: [s.webhook("orders", "https://hooks.example.com/orders")],
+ * });
+ * ```
+ */
+export function webhook(
+ name: string,
+ url: string,
+ options: WebhookOptions = {},
+): EffectUnit<{ id: string }> {
+ if (!name.trim()) throw new Error("webhook name must not be empty");
+ if (!url.trim()) throw new Error("webhook url must not be empty");
+ const fates = new Set(options.on ?? ["synced", "rejected"]);
+ const configuredHeaders = new Headers(options.headers);
+ configuredHeaders.delete("content-type");
+ configuredHeaders.delete("idempotency-key");
+ const signature = JSON.stringify({
+ url,
+ on: [...fates].sort(),
+ expiresAfterMs: options.expiresAfterMs === undefined
+ ? defaultWebhookExpiry
+ : options.expiresAfterMs,
+ maxAttempts: options.maxAttempts ?? null,
+ headers: [...configuredHeaders.entries()].sort(([left], [right]) => left.localeCompare(right)),
+ });
+ const build = (): EffectHandlers<{ id: string }> => {
+ const post = async (row: EffectRow<{ id: string }>, context: EffectContext) => {
+ if (!fates.has(context.fate)) return;
+ let response: Response;
+ try {
+ const headers = new Headers(configuredHeaders);
+ headers.set("content-type", "application/json");
+ headers.set("idempotency-key", context.journalId);
+ response = await fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ journalId: context.journalId,
+ verb: context.verb,
+ table: context.table,
+ op: context.op,
+ fate: context.fate,
+ code: context.code,
+ reason: context.reason,
+ row,
+ }),
+ });
+ } catch {
+ // A network-level failure is transient by nature: rethrow so the
+ // ledger retries with backoff inside the delivery window.
+ throw new Error(`webhook "${name}" unreachable`);
+ }
+ if (response.ok) return;
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
+ throw new PermanentEffectError(`webhook "${name}" refused with ${response.status}`);
+ }
+ throw new Error(`webhook "${name}" transient failure ${response.status}`);
+ };
+ return { onSynced: post, onRejected: post };
+ };
+ // Only the author-chosen name is durable. Configuration (including headers)
+ // is compared in memory so secrets never enter journal identifiers.
+ return cachedBuiltin(`webhook:${name}`, build, {
+ expiresAfterMs: options.expiresAfterMs === undefined
+ ? defaultWebhookExpiry
+ : options.expiresAfterMs,
+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),
+ }, signature);
+}
diff --git a/package/schema/effect_library_test.ts b/package/schema/effect_library_test.ts
new file mode 100644
index 0000000..e336456
--- /dev/null
+++ b/package/schema/effect_library_test.ts
@@ -0,0 +1,331 @@
+import { schema } from "jazz-tools";
+import { assert, assertCount } from "../runtime/test-assert.ts";
+import type { WriteHandle } from "../runtime/write-handle.ts";
+import { chain, mark, notice, trace, webhook } from "./effect-library.ts";
+import {
+ clearEffectDeclarations,
+ effect,
+ type EffectContext,
+ insert,
+ mutation,
+ type MutationRuntime,
+ type NoticeInput,
+ resolveEffectUnit,
+ setMutationRuntime,
+} from "./effects.ts";
+
+const app = schema.defineApp({
+ claims: schema.table({ title: schema.string(), status: schema.string() }),
+});
+type Claim = schema.RowOf;
+
+type Recorder = {
+ traces: Array<{ label: string | null; context: EffectContext }>;
+ notices: Array<{ input: NoticeInput; context: EffectContext }>;
+ marks: Array<{ rowId: string; patch: Record }>;
+ dispatched: Array<{
+ descriptor: unknown;
+ args: readonly unknown[];
+ parentJournalId?: string;
+ }>;
+};
+
+function install(): Recorder {
+ const recorder: Recorder = { traces: [], notices: [], marks: [], dispatched: [] };
+ const runtime: MutationRuntime = {
+ dispatch(descriptor, args) {
+ recorder.dispatched.push({ descriptor, args });
+ return { saved: Promise.resolve() } as unknown as WriteHandle;
+ },
+ dispatchChained(descriptor, args, parentJournalId) {
+ recorder.dispatched.push({ descriptor, args, parentJournalId });
+ return Promise.resolve();
+ },
+ recordLog() {},
+ recordTrace(label, context) {
+ recorder.traces.push({ label, context });
+ },
+ enqueueNotice(input, context) {
+ recorder.notices.push({ input, context });
+ return Promise.resolve();
+ },
+ applyMark(_table, rowId, patch) {
+ recorder.marks.push({ rowId, patch });
+ return Promise.resolve();
+ },
+ };
+ setMutationRuntime(runtime);
+ return recorder;
+}
+
+function context(overrides: Partial = {}): EffectContext {
+ return {
+ journalId: "w1:unit#1",
+ writeId: "w1",
+ verb: "submit",
+ table: "claims",
+ op: "update",
+ rowId: "row-1",
+ writeCreatedAt: 500,
+ fate: "synced",
+ cause: null,
+ code: null,
+ reason: null,
+ ...overrides,
+ };
+}
+
+// The identity guarantee: an anonymous built-in takes its durable name from
+// the verb it is attached to and its position in that verb's effects, NOT from
+// a global declaration counter. This is what makes a journaled notice/mark/
+// chain obligation re-arm against the same logical unit regardless of which
+// module evaluated first on the re-arming boot.
+Deno.test("anonymous units are named #, stable and verb-scoped", () => {
+ clearEffectDeclarations();
+ install();
+ const orders = schema.defineApp({ orders: schema.table({ item: schema.string() }) }).orders;
+ mutation("placeOrder", insert(orders), {
+ effects: [notice({ synced: "Placed." }), mark(orders, { synced: { item: "x" } })],
+ });
+ mutation("reorder", insert(orders), {
+ effects: [notice({ synced: "Reordered." })],
+ });
+ // Position within the verb, not a global counter: reorder's notice is #0,
+ // not #2, so adding placeOrder above it cannot shift reorder's identity.
+ assert(resolveEffectUnit("placeOrder#0") !== null, "the first unit is #0");
+ assert(resolveEffectUnit("placeOrder#1") !== null, "the second unit is #1");
+ assert(resolveEffectUnit("reorder#0") !== null, "a second verb restarts the index at 0");
+ assert(resolveEffectUnit("reorder#1") === null, "reorder declares only one anonymous unit");
+ clearEffectDeclarations();
+});
+
+Deno.test("trace records a span with the saved-to-fate latency on either fate", () => {
+ clearEffectDeclarations();
+ const recorder = install();
+ const unit = trace("checkout");
+ unit.handlers.onSynced?.({ id: "row-1" }, context({ writeCreatedAt: 500 }));
+ unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));
+ assertCount(recorder.traces.length, 2, "both fates must record a span");
+ assert(recorder.traces[0].label === "checkout", "the span must carry the author label");
+ clearEffectDeclarations();
+});
+
+// The content-named built-ins share one unit per identity instead of throwing
+// a duplicate-name error when reused across verbs.
+Deno.test("trace and webhook share one unit per durable identity", () => {
+ clearEffectDeclarations();
+ install();
+ assert(trace("checkout") === trace("checkout"), "one label shares one trace unit");
+ assert(trace() === trace(), "the unlabeled trace shares one unit");
+ const url = "https://hooks.example.com/x";
+ assert(webhook("orders", url) === webhook("orders", url), "one name+config shares one unit");
+ let mismatched = false;
+ try {
+ webhook("orders", url, { maxAttempts: 9 });
+ } catch (error) {
+ mismatched = !(error as Error).message.includes(url);
+ }
+ assert(mismatched, "reusing a name with different config must fail without leaking config");
+ assert(
+ webhook("orders-v2", url, { maxAttempts: 9 }) !== webhook("orders", url),
+ "a distinct author name creates a distinct durable unit",
+ );
+ clearEffectDeclarations();
+});
+
+Deno.test("notice enqueues a message keyed by the obligation journal id", async () => {
+ clearEffectDeclarations();
+ const recorder = install();
+ const unit = notice({
+ synced: "Submitted.",
+ rejected: (claim) => `Could not submit "${claim.title ?? "claim"}".`,
+ });
+ await unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:notice#1" }));
+ await unit.handlers.onRejected?.(
+ { id: "row-1", title: "roof" } as Claim & { id: string },
+ context({ fate: "rejected", journalId: "w2:notice#1" }),
+ );
+ assertCount(recorder.notices.length, 2, "each fate with a message enqueues one notice");
+ assert(
+ recorder.notices[0].input.message === "Submitted.",
+ "the static message must pass through",
+ );
+ assert(recorder.notices[0].input.tone === "success", "a synced notice is a success tone");
+ assert(
+ recorder.notices[1].input.message === 'Could not submit "roof".',
+ "the resolver must see the settled row",
+ );
+ assert(recorder.notices[1].input.tone === "error", "a rejected notice is an error tone");
+ clearEffectDeclarations();
+});
+
+Deno.test("notice fires only the configured fate", async () => {
+ clearEffectDeclarations();
+ const recorder = install();
+ const unit = notice({ rejected: "Failed." });
+ await unit.handlers.onSynced?.({ id: "row-1" }, context());
+ assertCount(recorder.notices.length, 0, "an unconfigured synced fate enqueues nothing");
+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));
+ assertCount(recorder.notices.length, 1, "the configured rejected fate enqueues");
+ clearEffectDeclarations();
+});
+
+Deno.test("mark patches the row on synced and on a rejected update", async () => {
+ clearEffectDeclarations();
+ const recorder = install();
+ const unit = mark(app.claims, {
+ synced: { status: "confirmed" },
+ rejected: { status: "failed" },
+ });
+ await unit.handlers.onSynced?.({ id: "row-1" }, context({ op: "update" }));
+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ op: "update", fate: "rejected" }));
+ assertCount(recorder.marks.length, 2, "both fates patch the surviving row");
+ assert(recorder.marks[0].patch.status === "confirmed", "synced must apply its patch");
+ assert(recorder.marks[1].patch.status === "failed", "a rejected update must apply its patch");
+ clearEffectDeclarations();
+});
+
+Deno.test("mark skips a rejected insert, which left no row to patch", async () => {
+ clearEffectDeclarations();
+ const recorder = install();
+ const unit = mark(app.claims, { rejected: { status: "failed" } });
+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ op: "insert", fate: "rejected" }));
+ assertCount(recorder.marks.length, 0, "a rolled-back insert has no row, so mark must skip it");
+ clearEffectDeclarations();
+});
+
+Deno.test("chain issues the follow-up verb only on synced", async () => {
+ clearEffectDeclarations();
+ const recorder = install();
+ const charge = mutation("chargeClaim", insert(app.claims));
+ const unit = chain(charge, (row: { id: string }) => ({
+ title: row.id,
+ status: "charging",
+ }));
+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));
+ assertCount(recorder.dispatched.length, 0, "a rejected write starts no chain");
+ await unit.handlers.onSynced?.({ id: "row-1" }, context());
+ assertCount(recorder.dispatched.length, 1, "a synced write issues the follow-up verb once");
+ assert(
+ (recorder.dispatched[0].args[0] as { title: string }).title === "row-1",
+ "the mapper must receive the settled row",
+ );
+ assert(
+ recorder.dispatched[0].parentJournalId === "w1:unit#1",
+ "the child must be tied to its parent obligation identity",
+ );
+ clearEffectDeclarations();
+});
+
+Deno.test("webhook injects the journal id as the idempotency key and posts the fate", async () => {
+ clearEffectDeclarations();
+ install();
+ const requests: Array<{ url: string; init: RequestInit }> = [];
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = ((url: string | URL, init?: RequestInit) => {
+ requests.push({ url: String(url), init: init ?? {} });
+ return Promise.resolve(new Response(null, { status: 200 }));
+ }) as typeof fetch;
+ try {
+ const unit = webhook("claims", "https://hooks.example.com/claims", {
+ headers: { authorization: "secret", "idempotency-key": "override" },
+ });
+ assert(unit.effectName === "webhook:claims", "durable identity must contain only the name");
+ assert(!unit.effectName.includes("secret"), "header secrets must not enter durable identity");
+ await unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:webhook#1" }));
+ assertCount(requests.length, 1, "a synced write posts once");
+ const key = new Headers(requests[0].init.headers as HeadersInit).get("idempotency-key") ?? "";
+ assert(key === "w1:webhook#1", "the journal id must ride as the idempotency key");
+ const body = JSON.parse(String(requests[0].init.body));
+ assert(body.fate === "synced", "the payload must carry the settled fate");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ clearEffectDeclarations();
+});
+
+Deno.test("webhook treats a 4xx as permanent and a 5xx as retryable", async () => {
+ clearEffectDeclarations();
+ install();
+ const originalFetch = globalThis.fetch;
+ const respond = (status: number) => {
+ globalThis.fetch = (() => Promise.resolve(new Response(null, { status }))) as typeof fetch;
+ };
+ try {
+ const unit = webhook("claims", "https://hooks.example.com/claims");
+ respond(400);
+ let permanent = false;
+ try {
+ await unit.handlers.onSynced?.({ id: "row-1" }, context());
+ } catch (error) {
+ permanent = (error as Error).name === "PermanentEffectError";
+ }
+ assert(permanent, "a 4xx must throw a PermanentEffectError so the ledger retires it");
+
+ respond(503);
+ let retryable = false;
+ try {
+ await unit.handlers.onSynced?.({ id: "row-1" }, context());
+ } catch (error) {
+ retryable = (error as Error).name !== "PermanentEffectError";
+ }
+ assert(retryable, "a 5xx must throw an ordinary error so the ledger retries it");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ clearEffectDeclarations();
+});
+
+// The contract's proof: a custom unit written with nothing but the public
+// authoring surface — s.effect, the context's journal id as the idempotency
+// key, and PermanentEffectError for a hopeless failure — behaves like a
+// built-in. This is also the authoring guide's worked example.
+Deno.test("a custom unit on the public contract is idempotent by journal id", async () => {
+ clearEffectDeclarations();
+ install();
+ const seen = new Set();
+ let sends = 0;
+ const sendOnce = effect("sendReceipt", app.claims, {
+ onSynced: (_row, context) => {
+ // The at-least-once duty: dedupe on the journal id so a re-delivered
+ // handler produces one external effect.
+ if (seen.has(context.journalId)) return;
+ seen.add(context.journalId);
+ sends += 1;
+ },
+ });
+ await sendOnce.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:sendReceipt" }));
+ await sendOnce.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:sendReceipt" }));
+ assertCount(sends, 1, "a re-delivery under one journal id must produce one effect");
+ clearEffectDeclarations();
+});
+
+Deno.test("webhook defaults to a finite 24-hour delivery window", () => {
+ clearEffectDeclarations();
+ install();
+ const unit = webhook("claims", "https://hooks.example.com/claims");
+ assert(unit.expiresAfterMs === 24 * 60 * 60 * 1000, "external delivery must default to 24h");
+ const forever = webhook("claims-forever", "https://hooks.example.com/claims2", {
+ expiresAfterMs: null,
+ });
+ assert(
+ forever.expiresAfterMs === null || forever.expiresAfterMs === undefined,
+ "null must opt into no expiry",
+ );
+ clearEffectDeclarations();
+});
+
+Deno.test("webhook default expiry and explicit no-expiry do not alias", () => {
+ clearEffectDeclarations();
+ install();
+ const url = "https://hooks.example.com/expiry";
+ webhook("expiry", url);
+ let thrown = false;
+ try {
+ webhook("expiry", url, { expiresAfterMs: null });
+ } catch {
+ thrown = true;
+ }
+ assert(thrown, "explicit null must not reuse the default 24-hour configuration");
+ clearEffectDeclarations();
+});
diff --git a/package/schema/effects.ts b/package/schema/effects.ts
index 4ab44a9..8a5e514 100644
--- a/package/schema/effects.ts
+++ b/package/schema/effects.ts
@@ -49,8 +49,12 @@ export type EffectContext = {
verb: string | null;
/** The written table's name. */
table: string;
+ /** Which operation the write performed. */
+ op: "insert" | "update" | "remove";
/** The written row's id. */
rowId: string;
+ /** Epoch milliseconds when the write was journaled, for latency spans. */
+ writeCreatedAt: number;
/** Which fate settled the write. */
fate: "synced" | "rejected";
/**
@@ -68,6 +72,43 @@ export type EffectContext = {
reason: string | null;
};
+/**
+ * Thrown from an effect handler to retire the obligation immediately instead
+ * of re-arming it. Delivery is at-least-once by default: an ordinary thrown
+ * error is *retryable* — the handler re-runs at the next boot until it
+ * succeeds or {@link EffectUnitOptions.maxAttempts} quarantines it. Some
+ * failures are known to be *permanent* — a webhook receiver answered `400`, a
+ * row a handler needed is gone for good — and retrying only burns attempts and
+ * delays the quarantine diagnostic. Throwing this retires the obligation now,
+ * counted as a permanent handler failure. The message reaches diagnostics.
+ *
+ * @example
+ * ```ts
+ * s.effect("charge", app.orders, {
+ * onSynced: async (order, { journalId }) => {
+ * const res = await fetch(url, { headers: { "Idempotency-Key": journalId } });
+ * if (res.status >= 400 && res.status < 500) {
+ * throw new PermanentEffectError(`charge refused: ${res.status}`);
+ * }
+ * if (!res.ok) throw new Error(`charge transient failure: ${res.status}`);
+ * },
+ * });
+ * ```
+ */
+export class PermanentEffectError extends Error {
+ /** Stable class name for diagnostics and the ledger's severity check. */
+ override readonly name = "PermanentEffectError";
+ /** Creates the permanent-failure signal with a diagnostics message. */
+ constructor(message: string) {
+ super(message);
+ }
+}
+
+/** True when a handler failure asked to retire rather than retry. */
+export function isPermanentEffectError(error: unknown): error is PermanentEffectError {
+ return error instanceof PermanentEffectError;
+}
+
/** The action and compensation handlers one effect unit pairs. */
export type EffectHandlers = {
/** Runs on the originating device once the store confirms the write. */
@@ -121,6 +162,15 @@ export type EffectUnit = {
readonly expiresAfterMs?: number | null;
/** Failing attempts before quarantine retires the obligation. */
readonly maxAttempts?: number;
+ /**
+ * Set on a built-in unit the author did not name (`s.notice`, `s.mark`,
+ * `s.chain`). Such a unit is registered lazily, when a {@link mutation}
+ * includes it: its durable name becomes `#` — stable across
+ * reloads because it derives from the author-chosen verb name and the unit's
+ * fixed position in that verb's `effects`, not from module-evaluation order.
+ * Absent on named units, which register at declaration.
+ */
+ readonly anonymousPrefix?: string;
};
/** Which operation a {@link MutationOp} performs. */
@@ -206,12 +256,50 @@ export type MutationDescriptor = {
readonly expiresAfterMs: number | null;
};
+/**
+ * One durable notice a {@link notice} unit enqueues. The queue is persistent
+ * and UI-agnostic: entries may be created at a boot re-arm with nothing
+ * mounted, and a component renders them later. `tone` classifies the message
+ * for the render; `ttlMs` bounds its life when the author sets no explicit
+ * dismissal.
+ */
+export type NoticeInput = {
+ /** The user-facing message. */
+ message: string;
+ /** How to classify the message for rendering. */
+ tone: "info" | "success" | "warning" | "error";
+ /** Lifespan in milliseconds before the queue retires the entry, or `null`. */
+ ttlMs: number | null;
+};
+
/** The runtime half installed by the package runtime before verbs are called. */
export type MutationRuntime = {
/** Performs one declared verb call and returns its write handle. */
dispatch(descriptor: MutationDescriptor, args: readonly unknown[]): WriteHandle;
+ /** Performs a chain child under a deterministic id retained by its parent. */
+ dispatchChained(
+ descriptor: MutationDescriptor,
+ args: readonly unknown[],
+ parentJournalId: string,
+ ): Promise;
/** Records one structured {@link log} entry. */
recordLog(label: string, context: EffectContext): void;
+ /** Records one {@link trace} span from the write's journaling to its fate. */
+ recordTrace?(label: string | null, context: EffectContext): void;
+ /** Records one {@link debug} timeline event; a no-op in production builds. */
+ recordDebug?(event: string, context: EffectContext): void;
+ /** Enqueues one durable {@link notice} entry. */
+ enqueueNotice?(input: NoticeInput, context: EffectContext): Promise;
+ /**
+ * Patches a row on behalf of a {@link mark} unit: a bare update carrying no
+ * further units, so write fate becomes replicated row data without
+ * recursion. Resolves at local durability; rejects if the row is gone.
+ */
+ applyMark?(
+ table: TableProxy,
+ rowId: string,
+ patch: Record,
+ ): Promise;
/** Re-attempts outstanding journal obligations after a late unit registration. */
unitRegistered?(name: string): void;
};
@@ -221,6 +309,13 @@ type EffectsSlot = {
effects: Map>;
verbs: Map;
logs: Map>;
+ /** Cache for shared built-ins (trace, debug, webhook) keyed by durable name. */
+ cache: Map;
+};
+
+type CachedBuiltinEntry = {
+ unit: EffectUnit<{ id: string }>;
+ signature: string | null;
};
const slotName = "__LOFI_EFFECT_DECLARATIONS__";
@@ -232,6 +327,7 @@ function slot(): EffectsSlot {
effects: new Map(),
verbs: new Map(),
logs: new Map(),
+ cache: new Map(),
};
return effectsGlobal[slotName];
}
@@ -256,6 +352,7 @@ export function clearEffectDeclarations(): void {
state.effects.clear();
state.verbs.clear();
state.logs.clear();
+ state.cache.clear();
}
// During dev hot replacement author modules re-evaluate routinely; the newest
@@ -365,6 +462,79 @@ function requireRuntime(): MutationRuntime {
return runtime;
}
+/**
+ * The installed runtime, or a thrown explanation when none is. The built-in
+ * effect library ({@link EffectUnit} factories in `effect-library.ts`) reaches
+ * the runtime through this so it stays on the public authoring surface — the
+ * same requirement custom units must meet.
+ */
+export function requireMutationRuntime(): MutationRuntime {
+ return requireRuntime();
+}
+
+/**
+ * Builds an anonymous built-in unit (a notice, mark, or chain the author did
+ * not name) without registering it. Registration is deferred to
+ * {@link mutation}, which names it `#` from the verb it is
+ * attached to and its position in that verb's `effects`. That identity is
+ * stable across reloads regardless of which module evaluates first, so a
+ * journaled obligation always re-arms against the same logical unit; the only
+ * way to orphan one is to reorder the effects within its own verb.
+ */
+export function anonymousUnit(
+ prefix: string,
+ handlers: EffectHandlers,
+ options: EffectUnitOptions = {},
+): EffectUnit {
+ return {
+ effectName: "",
+ handlers,
+ anonymousPrefix: prefix,
+ expiresAfterMs: options.expiresAfterMs ?? null,
+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),
+ };
+}
+
+/**
+ * Returns the shared built-in unit registered under `name`, building and
+ * registering it on first use. For observation and external units (`s.trace`,
+ * `s.debug`, `s.webhook`) whose whole identity is their durable name, so
+ * reusing one across verbs shares a single unit instead of colliding — the
+ * same discipline {@link log} follows. A supplied in-memory signature makes
+ * reuse under different configuration fail without putting that configuration
+ * into the durable name.
+ */
+export function cachedBuiltin(
+ name: string,
+ build: () => EffectHandlers,
+ options: Omit & { expiresAfterMs?: number | null } = {},
+ signature?: string,
+): EffectUnit {
+ const state = slot();
+ const existing = state.cache.get(name);
+ if (existing && "unit" in existing) {
+ if (existing.signature !== (signature ?? null)) {
+ throw new Error(`built-in effect "${name}" is already declared with different options`);
+ }
+ return existing.unit as unknown as EffectUnit;
+ }
+ // A dev hot-reload may retain the pre-signature cache shape. Rebuild that
+ // entry so future declarations receive the same configuration checks.
+ if (existing) state.cache.delete(name);
+ const unit: EffectUnit = {
+ effectName: name,
+ handlers: build(),
+ expiresAfterMs: options.expiresAfterMs ?? null,
+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),
+ };
+ state.cache.set(name, {
+ unit: unit as unknown as EffectUnit<{ id: string }>,
+ signature: signature ?? null,
+ });
+ registerUnit(unit as unknown as EffectUnit<{ id: string }>);
+ return unit;
+}
+
/**
* Declares a typed, callable verb: a named mutation over one table operation,
* carrying its effect units. Call sites invoke the verb like a function and
@@ -409,12 +579,28 @@ export function mutation[] = [];
const seen = new Set();
- for (const unit of options.effects ?? []) {
+ const seenDeclarations = new Set>();
+ const declared = options.effects ?? [];
+ for (let index = 0; index < declared.length; index += 1) {
+ const declaration = declared[index];
+ if (seenDeclarations.has(declaration)) {
+ throw new Error(`mutation "${name}" declares the same effect unit twice`);
+ }
+ seenDeclarations.add(declaration);
+ let unit = declaration as EffectUnit<{ id: string }>;
+ if (unit.anonymousPrefix !== undefined) {
+ // An anonymous built-in (s.notice/s.mark/s.chain) is named and
+ // registered here, from the verb it is attached to and its position in
+ // this verb's effects — a durable identity independent of module load
+ // order. Register a finalized copy so the ledger resolves it at re-arm.
+ unit = { ...unit, effectName: `${name}#${index}`, anonymousPrefix: undefined };
+ registerUnit(unit);
+ }
if (seen.has(unit.effectName)) {
throw new Error(`mutation "${name}" declares effect "${unit.effectName}" twice`);
}
seen.add(unit.effectName);
- units.push(unit as EffectUnit<{ id: string }>);
+ units.push(unit);
}
if (options.onSynced || options.onRejected) {
if (seen.has(name)) {
@@ -437,5 +623,23 @@ export function mutation requireRuntime().dispatch(descriptor, args);
+ Object.defineProperty(verb, mutationDescriptor, { value: descriptor });
return verb as MutationVerb>;
}
+
+const mutationDescriptor = Symbol.for("@nzip/lofi/mutationDescriptor");
+
+/** Dispatches a declared mutation as a deterministic child of an effect. */
+export function dispatchChainedMutation(
+ verb: (input: Input) => WriteHandle,
+ args: readonly [Input],
+ parentJournalId: string,
+): Promise {
+ const descriptor = (verb as unknown as { [mutationDescriptor]?: MutationDescriptor })[
+ mutationDescriptor
+ ];
+ if (!descriptor) {
+ throw new Error("chain target must be a mutation declared with s.mutation");
+ }
+ return requireRuntime().dispatchChained(descriptor, args, parentJournalId);
+}
diff --git a/package/schema/effects_test.ts b/package/schema/effects_test.ts
index fd04834..e979e35 100644
--- a/package/schema/effects_test.ts
+++ b/package/schema/effects_test.ts
@@ -19,6 +19,9 @@ function installRecorder(): { dispatched: Dispatched[]; logs: string[] } {
dispatched.push({ descriptor, args });
return { stage: "saving" } as unknown as WriteHandle;
},
+ dispatchChained() {
+ return Promise.resolve();
+ },
recordLog(label) {
logs.push(label);
},
@@ -99,6 +102,19 @@ Deno.test("verb names are unique per app and collide fast", () => {
clearEffectDeclarations();
});
+Deno.test("one anonymous unit object cannot occupy two mutation positions", () => {
+ clearEffectDeclarations();
+ const unit = s.notice({ synced: "Saved." });
+ let thrown = false;
+ try {
+ s.mutation("duplicateNotice", s.insert(app.orders), { effects: [unit, unit] });
+ } catch {
+ thrown = true;
+ }
+ assert(thrown, "reusing one anonymous declaration must fail before it gains two identities");
+ clearEffectDeclarations();
+});
+
Deno.test("s.log reuses one unit per label and records through the runtime", () => {
clearEffectDeclarations();
const recorder = installRecorder();
@@ -110,7 +126,9 @@ Deno.test("s.log reuses one unit per label and records through the runtime", ()
writeId: "w1",
verb: "placeOrder",
table: "orders",
+ op: "insert",
rowId: "row-1",
+ writeCreatedAt: 0,
fate: "synced",
cause: null,
code: null,
diff --git a/package/schema/mod.ts b/package/schema/mod.ts
index 8ff36eb..4575e6e 100644
--- a/package/schema/mod.ts
+++ b/package/schema/mod.ts
@@ -30,6 +30,7 @@
*/
import { schema } from "jazz-tools";
import { effect, insert, log, mutation, remove, update } from "./effects.ts";
+import { chain, debug, mark, notice, trace, webhook } from "./effect-library.ts";
import {
encryptedDate,
encryptedJson,
@@ -100,6 +101,12 @@ export type SchemaDsl =
mutation: typeof mutation;
effect: typeof effect;
log: typeof log;
+ trace: typeof trace;
+ debug: typeof debug;
+ notice: typeof notice;
+ mark: typeof mark;
+ chain: typeof chain;
+ webhook: typeof webhook;
insert: typeof insert;
update: typeof update;
remove: typeof remove;
@@ -150,6 +157,12 @@ export const s: SchemaDsl = {
mutation,
effect,
log,
+ trace,
+ debug,
+ notice,
+ mark,
+ chain,
+ webhook,
insert,
update,
remove,
@@ -211,17 +224,32 @@ export {
type EffectUnitOptions,
insert,
type InsertVerb,
+ isPermanentEffectError,
log,
mutation,
type MutationOp,
type MutationOpKind,
type MutationOptions,
type MutationVerb,
+ type NoticeInput,
+ PermanentEffectError,
remove,
type RemoveVerb,
update,
type UpdateVerb,
} from "./effects.ts";
+export {
+ chain,
+ debug,
+ mark,
+ type MarkConfig,
+ notice,
+ type NoticeConfig,
+ type NoticeResolver,
+ trace,
+ webhook,
+ type WebhookOptions,
+} from "./effect-library.ts";
// Type-only, so the authoring-only module graph stays free of runtime code:
// verb calls settle through the runtime write handle, and these names let
// schema-graph modules annotate that contract without importing the runtime.
diff --git a/package/starter/src/islands/AccountGate.tsx.txt b/package/starter/src/islands/AccountGate.tsx.txt
index 6e8b66b..cf82c82 100644
--- a/package/starter/src/islands/AccountGate.tsx.txt
+++ b/package/starter/src/islands/AccountGate.tsx.txt
@@ -50,7 +50,7 @@ function describe(error: unknown): string {
if (isAuthError(error)) {
switch (error.code) {
case "cancelled":
- return "Passkey prompt dismissed — your recovery phrase was not shown.";
+ return "Passkey verification did not complete — your recovery phrase was not shown.";
case "unsupported":
return "This browser does not support passkeys.";
default:
diff --git a/package/starter/src/islands/TaskList.tsx.txt b/package/starter/src/islands/TaskList.tsx.txt
index 57fe382..bcc1caf 100644
--- a/package/starter/src/islands/TaskList.tsx.txt
+++ b/package/starter/src/islands/TaskList.tsx.txt
@@ -2,11 +2,12 @@ import { useState } from "preact/hooks";
import { settleUiMutation } from "@nzip/lofi";
import {
type BootProgress,
+ Notices,
useBootProgress,
usePendingWrites,
useSyncStatus,
} from "@nzip/lofi/preact";
-import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";
+import { type Task, useTasks } from "./use-tasks.ts";
// A cold first visit waits on the engine download, not on storage; name the
// wait it is actually in, with byte progress while the download runs.
@@ -26,7 +27,6 @@ function loadingLabel(boot: BootProgress): string {
*/
export default function TaskList() {
const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();
- const notice = useTaskNotice();
const pending = usePendingWrites();
const boot = useBootProgress();
const [text, setText] = useState("");
@@ -78,11 +78,7 @@ export default function TaskList() {
{pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync
)}
- {notice && (
-
- {notice.text}
-
- )}
+
{tasks.map((task) => )}
diff --git a/package/starter/src/islands/use-tasks.ts.txt b/package/starter/src/islands/use-tasks.ts.txt
index 4c85582..6b923c7 100644
--- a/package/starter/src/islands/use-tasks.ts.txt
+++ b/package/starter/src/islands/use-tasks.ts.txt
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "preact/hooks";
+import { useCallback, useState } from "preact/hooks";
import type { RowOf, WriteHandle } from "@nzip/lofi";
import { useLiveQuery, useWrite } from "@nzip/lofi/preact";
import { s } from "@nzip/lofi/schema";
@@ -17,54 +17,27 @@ const tasksTable = app.schema.tasks;
/** The row type comes straight from the declared schema. */
export type Task = RowOf;
-/** A one-line consequence or compensation surfaced to the UI. */
-export type TaskNotice = { kind: "synced" | "rejected"; text: string };
-
-// A tiny author-owned notice channel: effect handlers run outside any
-// component, so they publish through module state and hooks subscribe.
-let notice: TaskNotice | null = null;
-const noticeListeners = new Set<() => void>();
-
-function publishNotice(next: TaskNotice | null): void {
- notice = next;
- for (const listener of [...noticeListeners]) listener();
-}
-
/**
* The verb call sites use. Its effect units are declared once, here: the
* consequence runs when the store confirms the task, the compensation runs if
* a stale-policy write is denied — even if the app restarted in between.
*/
export const addTask = s.mutation("addTask", s.insert(tasksTable), {
- effects: [s.log("task-added")],
- onSynced: (task) => {
- publishNotice({ kind: "synced", text: `"${task.text ?? "Task"}" synced to your account` });
- },
- onRejected: (task) => {
- // The engine already rolled the denied row back out of local reads; this
- // compensates what the user was told.
- publishNotice({
- kind: "rejected",
- text: `"${task.text ?? "Task"}" was declined by the store and has been removed`,
- });
- },
+ effects: [
+ s.log("task-added"),
+ s.trace("task-added"),
+ s.notice({
+ synced: (task) => `"${task.text ?? "Task"}" synced to your account`,
+ // The engine already rolled a denied insert out of local reads; this
+ // durable notice compensates what the user was told, even after reload.
+ rejected: (task) => `"${task.text ?? "Task"}" was declined by the store and has been removed`,
+ }),
+ ],
});
/** Toggling completion is a plain verb: no consequences, same lifecycle. */
export const setTaskCompleted = s.mutation("setTaskCompleted", s.update(tasksTable));
-/** Subscribes to the latest effect notice; `null` until one is published. */
-export function useTaskNotice(): TaskNotice | null {
- const [current, setCurrent] = useState(notice);
- useEffect(() => {
- const listener = () => setCurrent(notice);
- noticeListeners.add(listener);
- listener();
- return () => void noticeListeners.delete(listener);
- }, []);
- return current;
-}
-
export function useTasks() {
const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);
const [lastWrite, setLastWrite] = useState | null>(null);
diff --git a/package/testdata/starter.snapshot.json b/package/testdata/starter.snapshot.json
index f049db9..2c988e6 100644
--- a/package/testdata/starter.snapshot.json
+++ b/package/testdata/starter.snapshot.json
@@ -14,9 +14,9 @@
"README.md": "125e906d85029b14d788b8e2dfe6e3037700770b9fd50e76ce69f98c3a82bb05",
"src/app.ts": "2021889ec895b7c758e9c541eb968a480f63610073fe2c2d316faa7b66e85113",
"src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",
- "src/islands/AccountGate.tsx": "0faa82edb05ea9c6da1f575ebb75cc2f60d8e95e88524c0720e3c885a1eee659",
- "src/islands/TaskList.tsx": "7b0da8830e5fab9c59948eff1cbaa357805ecea9c5d070249dd46157e5e36bdf",
- "src/islands/use-tasks.ts": "0859b1bbd18a7e9fee54cbe919b4e1c9c4b23665227bffa54bd7f0d910192589",
+ "src/islands/AccountGate.tsx": "bce8ea5f64d4c9bb399b8665b0e14b3c20e6e3a9ab1663e011e811057b1f8a5e",
+ "src/islands/TaskList.tsx": "5b76707bf953d12a42d2be26c0a4f3eab77e972e228685a6cd945a65891e18f0",
+ "src/islands/use-tasks.ts": "277a54f6f77afebc303d869f0aac9db8423a2b5c707333046ce4d95993577a17",
"src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",
"src/pages/index.astro": "8a696a8154b77c34deba3919f6383dc2ed0a6af15612fd9b16f04416e3331e93",
"src/permissions.ts": "7a2dad8ce48816c1611c85049209f4fddef08ab332dcbd94bac4fa162fc6c1b0",
diff --git a/package/testing/mod.ts b/package/testing/mod.ts
index a5b251b..b53de9e 100644
--- a/package/testing/mod.ts
+++ b/package/testing/mod.ts
@@ -102,6 +102,7 @@ export {
type EffectContext,
type MutationDescriptor,
type MutationRuntime,
+ type NoticeInput,
setMutationRuntime,
} from "../schema/effects.ts";
export {
diff --git a/tools/demo_overlay_test.ts b/tools/demo_overlay_test.ts
index 59c2065..265134e 100644
--- a/tools/demo_overlay_test.ts
+++ b/tools/demo_overlay_test.ts
@@ -34,3 +34,18 @@ Deno.test("the demo landing page stamps the released version", async () => {
`which release produced the demo`,
);
});
+
+Deno.test("the demo uses the starter's durable notice surface", async () => {
+ const incidents = await Deno.readTextFile(
+ join(OVERLAY_ROOT, "src/islands/use-incidents.ts"),
+ );
+ const board = await Deno.readTextFile(
+ join(OVERLAY_ROOT, "src/islands/IncidentBoard.tsx"),
+ );
+ assert(incidents.includes("s.notice"), "incident effects must enqueue durable notices");
+ assert(
+ !incidents.includes("publishNotice") && !incidents.includes("useIncidentNotice"),
+ "the overlay must not restore the hand-rolled in-memory notice channel",
+ );
+ assert(board.includes("