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 {
- - + + +
+ {credentialOrigin && credentialOrigin.status !== "stable" && ( +

{credentialOrigin.action}

+ )}
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("