From 86cfaae2dc76a1f672ce08e51ddf5c3f0796dbf8 Mon Sep 17 00:00:00 2001 From: Dami Date: Sun, 19 Jul 2026 21:28:44 -0600 Subject: [PATCH 1/6] =?UTF-8?q?fix(preact):=20sync-state=20quality=20pass?= =?UTF-8?q?=20=E2=80=94=20first-class=20blocked=20dispositions,=20typed=20?= =?UTF-8?q?enrollment=20errors,=20account-gate=20freshness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template half of #175, following the runtime half in #180. DeviceStatus names why nothing is syncing, not merely that it is not: the one-line Data sync verdict distinguishes owner mismatch, store refusals (no schema, rejected ticket), an unopenable sink record, and the absence of any sync location, with owner-mismatch copy naming the owning account when known. The no-sync hint now leads with ticket enrollment; the compiled-in env-var path is the stated alternative. TicketEnrollForm relays typed refusals verbatim — SyncEnrollmentError, SyncOwnerError, and DataSinkError carry their own remediation, and the generic paste-again line cannot fix an unprovisioned store or a foreign sync owner. AccountGate re-reads the session when the runtime is recreated, gains the owner-mismatch branch offering exactly the two remediations (stop sync releases the election, restore adopts the owner), and surfaces a definite store problem in the backed-up state. Starter mirror and snapshot regenerated. --- apps/reference/src/islands/AccountGate.tsx | 68 ++++++++++++++++++- package/preact/DeviceStatus.tsx | 62 ++++++++++++++--- package/preact/DeviceStatus_test.tsx | 47 +++++++++++++ package/preact/TicketEnrollForm.tsx | 24 +++++-- package/preact/TicketEnrollForm_test.tsx | 31 ++++++++- .../starter/src/islands/AccountGate.tsx.txt | 68 ++++++++++++++++++- package/testdata/starter.snapshot.json | 2 +- 7 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 package/preact/DeviceStatus_test.tsx diff --git a/apps/reference/src/islands/AccountGate.tsx b/apps/reference/src/islands/AccountGate.tsx index dcefb61..6e8b66b 100644 --- a/apps/reference/src/islands/AccountGate.tsx +++ b/apps/reference/src/islands/AccountGate.tsx @@ -3,17 +3,22 @@ import { confirmPhraseAccess, createBackupPasskey, createRecoverablePasskeyBackup, + describeStoreStatus, enableSyncBackup, + getRuntimeDiagnostics, isAccountReplacementError, isAuthError, isRecoverablePasskeyError, isRecoveryError, + isSyncOwnerError, readAccountSession, restoreFromPasskey, restoreFromRecoveryPhrase, revealRecoveryPhrase, + runtimeRecreatedEvent, type Session, stopSyncBackup, + subscribeRuntimeDiagnostics, } from "@nzip/lofi"; import { encodeSharingIdentity } from "@nzip/lofi/access"; import { TicketEnrollForm } from "@nzip/lofi/preact"; @@ -54,6 +59,7 @@ function describe(error: unknown): string { } if (isRecoveryError(error)) return error.message; if (isRecoverablePasskeyError(error) || isAccountReplacementError(error)) return error.message; + if (isSyncOwnerError(error)) return error.message; return error instanceof Error ? error.message : String(error); } @@ -74,9 +80,19 @@ export default function AccountGate() { // so the phrase block can say so honestly rather than imply a confirmation. const [unguarded, setUnguarded] = useState(false); + const [diagnostics, setDiagnostics] = useState(getRuntimeDiagnostics()); + useEffect(() => { - void readAccountSession().then(setSession, (cause) => setError(describe(cause))); + const refresh = () => + void readAccountSession().then(setSession, (cause) => setError(describe(cause))); + refresh(); + // Electing sync, stopping it, or restoring an account recreates the + // runtime; a snapshot read once on mount would keep rendering the old + // account state over the new reality. + globalThis.addEventListener(runtimeRecreatedEvent, refresh); + return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh); }, []); + useEffect(() => subscribeRuntimeDiagnostics(() => setDiagnostics(getRuntimeDiagnostics())), []); // Every account action funnels through here: one busy flag, one error line, // and any returned Session becomes the new snapshot. @@ -182,6 +198,41 @@ export default function AccountGate() { ); + // Sync on this device was elected by a different account: the runtime booted + // with transport suppressed so neither store can merge into the other. The + // two remediations are exactly the actions offered here — stop syncing + // releases the election for the current account, restore adopts the owner. + if (session.syncOwnerMismatch) { + const owner = diagnostics.syncOwner.state === "mismatch" + ? diagnostics.syncOwner.owner_user_id + : null; + return ( +
+
+

Account

+

Sync paused — set up by a different account

+
+

+ Sync on this device was set up by{" "} + {owner ? {owner} : "a different account"}, so nothing connects — syncing now + would merge two accounts’ data. Stop syncing to release this device for the current + account, or restore the owning account to resume where it left off. +

+ + {restoreBlock} + {error && } +
+ ); + } + // No sync location yet: offer the connect step, with restore available so a // fresh device recovers its identity before or after choosing where to sync. if (!session.syncAvailable) { @@ -228,6 +279,16 @@ export default function AccountGate() { ); + // A definite store problem is worth a line here, not only in the device + // report: the account panel is where the user just acted, and each of these + // states means writes are not replicating despite sync being on. + const storeStatus = diagnostics.storeStatus; + const storeProblem = storeStatus.state === "no_schema" || + storeStatus.state === "ticket_rejected" || + storeStatus.state === "store_unavailable" + ? describeStoreStatus(storeStatus) + : null; + if (session.backedUp) { return (
@@ -246,6 +307,11 @@ export default function AccountGate() { Share identity: {sharingIdentity}

)} + {storeProblem && ( + + )} diff --git a/package/preact/DeviceStatus_test.tsx b/package/preact/DeviceStatus_test.tsx new file mode 100644 index 0000000..94824bc --- /dev/null +++ b/package/preact/DeviceStatus_test.tsx @@ -0,0 +1,47 @@ +import { 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 +// cause, and the precedence puts the most specific cause first — an owner +// mismatch explains more than a store answer, a store refusal more than a +// missing sink. +Deno.test("describeSyncState names each blocked disposition first-class", () => { + const base = { + syncing: false, + syncAvailable: false, + ownerMismatch: false, + storeState: "unchecked" as const, + sinkUnopenable: false, + }; + const cases: [Parameters[0], string][] = [ + [{ ...base, ownerMismatch: true, syncing: true }, "another account"], + [{ ...base, storeState: "no_schema", syncing: true }, "no schema"], + [{ ...base, storeState: "ticket_rejected", syncing: true }, "ticket no longer accepted"], + [{ ...base, sinkUnopenable: true }, "unopenable"], + [{ ...base, syncing: true, syncAvailable: true }, "syncing to your account"], + [{ ...base, syncAvailable: true }, "not yet backed up"], + [base, "local-only"], + ]; + for (const [input, needle] of cases) { + const verdict = describeSyncState(input); + if (!verdict.includes(needle)) { + throw new Error(`disposition lost its cause: ${JSON.stringify(input)} → ${verdict}`); + } + } +}); + +// The owner mismatch outranks every other explanation: transport is suppressed +// because of it, so store-level answers describe a connection that is not +// being attempted. +Deno.test("describeSyncState puts the owner mismatch above store answers", () => { + const verdict = describeSyncState({ + syncing: true, + syncAvailable: true, + ownerMismatch: true, + storeState: "no_schema", + sinkUnopenable: false, + }); + if (!verdict.includes("another account")) { + throw new Error(`owner mismatch was outranked: ${verdict}`); + } +}); diff --git a/package/preact/TicketEnrollForm.tsx b/package/preact/TicketEnrollForm.tsx index a5921dc..5d03b77 100644 --- a/package/preact/TicketEnrollForm.tsx +++ b/package/preact/TicketEnrollForm.tsx @@ -7,7 +7,8 @@ import { type SealOutcome, sealProvisionCapability, } from "../runtime/provision.ts"; -import { enrollSyncTicket, type Session } from "../runtime/session.ts"; +import { enrollSyncTicket, isSyncEnrollmentError, type Session } from "../runtime/session.ts"; +import { isSyncOwnerError } from "../runtime/sync-owner.ts"; /** Dependencies {@link TicketEnrollForm} accepts for testing and composition. */ export interface TicketEnrollFormProps { @@ -21,6 +22,22 @@ export interface TicketEnrollFormProps { readonly seal?: () => Promise; } +/** + * Turns an enrollment failure into the sentence the form shows. The typed + * refusals carry user-presentable messages that name their remediation — a + * refused store preflight ({@link isSyncEnrollmentError}), a sync election + * owned by another account ({@link isSyncOwnerError}), and malformed or + * conflicting tickets ({@link isDataSinkError}) — so those pass through + * verbatim; only an unrecognized failure gets the generic retry line. + * Exported for tests; the entry does not re-export it. + */ +export function describeEnrollmentProblem(error: unknown): string { + if (isSyncEnrollmentError(error) || isSyncOwnerError(error) || isDataSinkError(error)) { + return error.message; + } + return "Enrollment failed; check the ticket and the node, then paste it again."; +} + type Phase = | { name: "edit"; problem?: string } | { name: "enrolling" } @@ -101,10 +118,7 @@ export function TicketEnrollForm({ setPhase({ name: "enrolled", sealOffer: provision.held && !provision.sealed }); onEnrolled?.(session); } catch (error) { - const problem = isDataSinkError(error) - ? error.message - : "Enrollment failed; check the ticket and the node, then paste it again."; - setPhase({ name: "edit", problem }); + setPhase({ name: "edit", problem: describeEnrollmentProblem(error) }); } } diff --git a/package/preact/TicketEnrollForm_test.tsx b/package/preact/TicketEnrollForm_test.tsx index 8f2afe2..144dc23 100644 --- a/package/preact/TicketEnrollForm_test.tsx +++ b/package/preact/TicketEnrollForm_test.tsx @@ -1,5 +1,7 @@ import { render } from "npm:preact-render-to-string@6.7.0"; -import { TicketEnrollForm } from "./TicketEnrollForm.tsx"; +import { describeEnrollmentProblem, TicketEnrollForm } from "./TicketEnrollForm.tsx"; +import { SyncEnrollmentError } from "../runtime/session.ts"; +import { SyncOwnerError } from "../runtime/sync-owner.ts"; // The form's manager-facing semantics are the contract: password managers key // on a real form with a current-password field, a username companion, and a @@ -20,6 +22,33 @@ Deno.test("TicketEnrollForm renders password-manager-compatible form semantics", } }); +// The typed refusals carry their own remediation; the form must not flatten +// them into the generic "paste it again" line — re-pasting cannot fix an +// unprovisioned store or a foreign sync owner. +Deno.test("describeEnrollmentProblem relays typed refusals verbatim", () => { + const cases: [Error, string][] = [ + [new SyncEnrollmentError("no_schema", "sync"), "no schema deployed"], + [new SyncEnrollmentError("ticket_rejected"), "revoked or the node was reset"], + [new SyncOwnerError("alice"), "set up by a different account"], + ]; + for (const [error, needle] of cases) { + const problem = describeEnrollmentProblem(error); + if (problem !== error.message) { + throw new Error(`typed refusal was rewritten: ${problem}`); + } + if (!problem.includes(needle)) { + throw new Error(`refusal message lost its remediation: ${problem}`); + } + } +}); + +Deno.test("describeEnrollmentProblem keeps the generic line for unknown failures", () => { + const problem = describeEnrollmentProblem(new TypeError("fetch failed")); + if (!problem.includes("check the ticket and the node")) { + throw new Error(`unknown failure leaked internals: ${problem}`); + } +}); + Deno.test("TicketEnrollForm states the custody story without overclaiming", () => { const html = render(); if (!html.includes("bearer credential")) { diff --git a/package/starter/src/islands/AccountGate.tsx.txt b/package/starter/src/islands/AccountGate.tsx.txt index dcefb61..6e8b66b 100644 --- a/package/starter/src/islands/AccountGate.tsx.txt +++ b/package/starter/src/islands/AccountGate.tsx.txt @@ -3,17 +3,22 @@ import { confirmPhraseAccess, createBackupPasskey, createRecoverablePasskeyBackup, + describeStoreStatus, enableSyncBackup, + getRuntimeDiagnostics, isAccountReplacementError, isAuthError, isRecoverablePasskeyError, isRecoveryError, + isSyncOwnerError, readAccountSession, restoreFromPasskey, restoreFromRecoveryPhrase, revealRecoveryPhrase, + runtimeRecreatedEvent, type Session, stopSyncBackup, + subscribeRuntimeDiagnostics, } from "@nzip/lofi"; import { encodeSharingIdentity } from "@nzip/lofi/access"; import { TicketEnrollForm } from "@nzip/lofi/preact"; @@ -54,6 +59,7 @@ function describe(error: unknown): string { } if (isRecoveryError(error)) return error.message; if (isRecoverablePasskeyError(error) || isAccountReplacementError(error)) return error.message; + if (isSyncOwnerError(error)) return error.message; return error instanceof Error ? error.message : String(error); } @@ -74,9 +80,19 @@ export default function AccountGate() { // so the phrase block can say so honestly rather than imply a confirmation. const [unguarded, setUnguarded] = useState(false); + const [diagnostics, setDiagnostics] = useState(getRuntimeDiagnostics()); + useEffect(() => { - void readAccountSession().then(setSession, (cause) => setError(describe(cause))); + const refresh = () => + void readAccountSession().then(setSession, (cause) => setError(describe(cause))); + refresh(); + // Electing sync, stopping it, or restoring an account recreates the + // runtime; a snapshot read once on mount would keep rendering the old + // account state over the new reality. + globalThis.addEventListener(runtimeRecreatedEvent, refresh); + return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh); }, []); + useEffect(() => subscribeRuntimeDiagnostics(() => setDiagnostics(getRuntimeDiagnostics())), []); // Every account action funnels through here: one busy flag, one error line, // and any returned Session becomes the new snapshot. @@ -182,6 +198,41 @@ export default function AccountGate() { ); + // Sync on this device was elected by a different account: the runtime booted + // with transport suppressed so neither store can merge into the other. The + // two remediations are exactly the actions offered here — stop syncing + // releases the election for the current account, restore adopts the owner. + if (session.syncOwnerMismatch) { + const owner = diagnostics.syncOwner.state === "mismatch" + ? diagnostics.syncOwner.owner_user_id + : null; + return ( + + ); + } + // No sync location yet: offer the connect step, with restore available so a // fresh device recovers its identity before or after choosing where to sync. if (!session.syncAvailable) { @@ -228,6 +279,16 @@ export default function AccountGate() { ); + // A definite store problem is worth a line here, not only in the device + // report: the account panel is where the user just acted, and each of these + // states means writes are not replicating despite sync being on. + const storeStatus = diagnostics.storeStatus; + const storeProblem = storeStatus.state === "no_schema" || + storeStatus.state === "ticket_rejected" || + storeStatus.state === "store_unavailable" + ? describeStoreStatus(storeStatus) + : null; + if (session.backedUp) { return ( + ); +} diff --git a/package/preact/mod.ts b/package/preact/mod.ts index 4d295d9..ea8cb76 100644 --- a/package/preact/mod.ts +++ b/package/preact/mod.ts @@ -14,6 +14,13 @@ * @module */ export { DeviceStatus } from "./DeviceStatus.tsx"; +export { Notices, type NoticesProps } from "./Notices.tsx"; +export { + type NoticeEntry, + type NoticesSurface, + type NoticeTone, + useNotices, +} from "./use-notices.ts"; export { RuntimeRecovery, type RuntimeRecoveryProps } from "./RuntimeRecovery.tsx"; export { type LiveQuerySnapshot, diff --git a/package/preact/use-notices.ts b/package/preact/use-notices.ts new file mode 100644 index 0000000..d716d59 --- /dev/null +++ b/package/preact/use-notices.ts @@ -0,0 +1,60 @@ +/** + * Preact binding for the durable notice queue behind `s.notice`: the live list + * of user-visible messages an effect enqueued when a write settled, plus the + * dismissal action. Apps render their own surface over this, or use the + * built-in {@link Notices} component. + * + * @module + */ + +import { useEffect, useState } from "preact/hooks"; +import { + dismissAllNotices, + dismissNotice, + listNotices, + type NoticeEntry, + subscribeNotices, +} from "../runtime/mod.ts"; + +export type { NoticeEntry, NoticeTone } from "../runtime/mod.ts"; + +/** The live notices and the actions to retire them. */ +export type NoticesSurface = { + /** The live notices: enqueued, not dismissed, not past their TTL. */ + notices: readonly NoticeEntry[]; + /** Dismisses one notice by id. */ + dismiss: (id: string) => 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/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..d6957cc 100644 --- a/package/runtime/mod.ts +++ b/package/runtime/mod.ts @@ -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..55b810a --- /dev/null +++ b/package/runtime/notice-queue.ts @@ -0,0 +1,200 @@ +/** + * 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; + #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. */ + async load(): Promise { + if (this.#loaded) return; + this.#document = parse(await this.#storage.load()); + this.#loaded = true; + this.#sweepExpired(); + } + + /** + * 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. + */ + enqueue(input: NoticeEnqueueInput): void { + if (this.#document.entries.some((entry) => entry.id === input.id)) return; + 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.#persist(); + this.#emit(); + } + + /** 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; + this.#persist(); + this.#emit(); + } + + /** Dismisses every entry. */ + dismissAll(): void { + if (this.#document.entries.length === 0) return; + this.#document.entries = []; + this.#persist(); + this.#emit(); + } + + /** The live notices: enqueued, not dismissed, not past their TTL. */ + list(): readonly NoticeEntry[] { + this.#sweepExpired(); + return this.#document.entries; + } + + /** 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()) { + this.#persist(); + 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(): void { + const snapshot = JSON.stringify(this.#document); + this.#chain = this.#chain.then(() => this.#storage.save(snapshot)).catch(() => undefined); + } + + #emit(): void { + this.#onCountChange?.(this.#document.entries.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..a37d75c --- /dev/null +++ b/package/runtime/notice-queue_test.ts @@ -0,0 +1,58 @@ +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", () => { + const { q, counts } = queue(); + q.enqueue({ id: "w1:notice#1", message: "Saved.", tone: "success", ttlMs: null }); + 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", () => { + const { q } = queue(); + let notified = 0; + q.subscribe(() => notified += 1); + q.enqueue({ id: "a", message: "one", tone: "info", ttlMs: null }); + 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", () => { + let clock = 1000; + const { q } = queue(() => clock); + 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("a persisted queue reloads its entries and drops expired ones", async () => { + let clock = 1000; + const storage = createMemoryJournalStorage(); + const first = new NoticeQueue(storage, () => clock); + first.enqueue({ id: "keep", message: "durable", tone: "info", ttlMs: null }); + 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"); +}); diff --git a/package/runtime/write-ledger.ts b/package/runtime/write-ledger.ts index ee36960..2c72cf3 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 { @@ -743,7 +750,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, @@ -764,9 +773,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"); } } @@ -898,6 +909,57 @@ export function armWriteLedger(): Promise { return getWriteLedger().arm(); } +let defaultNotices: NoticeQueue | 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) { + defaultNotices = new NoticeQueue( + createDefaultNoticeStorage(appId), + Date.now, + (count) => + updateRuntimeDiagnostics((diagnostics) => { + diagnostics.activeNotices = count; + }), + ); + // Load persisted entries and publish the initial count; a storage-less or + // prerender context simply starts empty. + void defaultNotices.load() + .then(() => + updateRuntimeDiagnostics((diagnostics) => { + diagnostics.activeNotices = defaultNotices?.list().length ?? 0; + }) + ) + .catch(() => undefined); + } + 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({ @@ -913,10 +975,41 @@ 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) => { + await getWriteLedger().perform({ kind: "update", table, id: rowId, patch }).saved; + }, unitRegistered: (name) => defaultLedger?.retryObligationsFor(name), }); import.meta.hot?.dispose(() => { defaultLedger?.dispose(); defaultLedger = null; + defaultNotices = null; }); diff --git a/package/runtime/write-ledger_test.ts b/package/runtime/write-ledger_test.ts index 17e91de..7747aa8 100644 --- a/package/runtime/write-ledger_test.ts +++ b/package/runtime/write-ledger_test.ts @@ -1,6 +1,6 @@ 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, PermanentEffectError } from "../schema/effects.ts"; import { assert, assertCount } from "./test-assert.ts"; import { createMemoryJournalStorage, @@ -135,6 +135,7 @@ function unit( options: { failFirst?: { onSynced?: boolean }; failAlways?: boolean; + failPermanent?: boolean; expiresAfterMs?: number; maxAttempts?: number; } = {}, @@ -144,6 +145,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 +749,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< diff --git a/package/schema/effect-library.ts b/package/schema/effect-library.ts new file mode 100644 index 0000000..269030d --- /dev/null +++ b/package/schema/effect-library.ts @@ -0,0 +1,340 @@ +/// +/** + * 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 { + anonymousEffectName, + effect, + type EffectContext, + type EffectRow, + type EffectUnit, + type EffectUnitOptions, + type NoticeInput, + PermanentEffectError, + requireMutationRuntime, +} from "./effects.ts"; + +// A tableless built-in binds no columns; the core `effect` uses its table +// argument only for row typing, so this placeholder documents the intent in +// one place instead of scattering casts. +const noTable = undefined as unknown as TableProxy<{ id: string }, unknown>; + +function tablelessEffect( + name: string, + handlers: { + onSynced?: (row: EffectRow<{ id: string }>, context: EffectContext) => void | Promise; + onRejected?: (row: EffectRow<{ id: string }>, context: EffectContext) => void | Promise; + }, + options?: EffectUnitOptions, +): EffectUnit<{ id: string }> { + return effect(name, noTable, handlers, options); +} + +/** + * 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"; + const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => { + requireMutationRuntime().recordTrace?.(label ?? null, context); + }; + return tablelessEffect(name, { 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 }> { + const production = !import.meta.env.DEV; + const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => { + if (production) return; + requireMutationRuntime().recordDebug?.(context.fate, context); + }; + return tablelessEffect("debug", { 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"], + ) => + (row: EffectRow<{ id: string }>, context: EffectContext) => { + if (resolver === undefined) return; + const runtime = requireMutationRuntime(); + runtime.enqueueNotice?.( + { message: resolveNotice(resolver, row as EffectRow), tone, ttlMs: ttlMs ?? null }, + context, + ); + }; + return tablelessEffect(anonymousEffectName("notice"), { + onSynced: enqueue(config.synced, "success"), + onRejected: enqueue(config.rejected, "error"), + }) 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. + * + * @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 effect(anonymousEffectName("mark"), table, { + 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 itself journaled and idempotent by its own write id. This + * unit's obligation is marked delivered once the next verb is durably saved, + * so a crash between links re-issues from the last settled row. + * + * @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 tablelessEffect(anonymousEffectName("chain"), { + onSynced: async (row) => { + // Await local durability of the follow-up: only then is this link safe + // to mark delivered, so a crash re-issues rather than dropping it. + await next(toInput(row as EffectRow)).saved; + }, + }) 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`, 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("https://hooks.example.com/orders")], + * }); + * ``` + */ +export function webhook(url: string, options: WebhookOptions = {}): EffectUnit<{ id: string }> { + if (!url.trim()) throw new Error("webhook url must not be empty"); + const fates = new Set(options.on ?? ["synced", "rejected"]); + const post = async (row: EffectRow<{ id: string }>, context: EffectContext) => { + if (!fates.has(context.fate)) return; + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": context.journalId, + ...options.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 (error) { + // A network-level failure is transient by nature: rethrow so the ledger + // retries with backoff inside the delivery window. + throw new Error(`webhook ${url} unreachable: ${(error as Error).message}`); + } + if (response.ok) return; + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + throw new PermanentEffectError(`webhook ${url} refused with ${response.status}`); + } + throw new Error(`webhook ${url} transient failure ${response.status}`); + }; + return tablelessEffect("webhook:" + url, { onSynced: post, onRejected: post }, { + expiresAfterMs: options.expiresAfterMs === undefined + ? defaultWebhookExpiry + : options.expiresAfterMs ?? undefined, + ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}), + }); +} diff --git a/package/schema/effect_library_test.ts b/package/schema/effect_library_test.ts new file mode 100644 index 0000000..6f565ad --- /dev/null +++ b/package/schema/effect_library_test.ts @@ -0,0 +1,245 @@ +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, + type MutationRuntime, + type NoticeInput, + 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[] }>; +}; + +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; + }, + recordLog() {}, + recordTrace(label, context) { + recorder.traces.push({ label, context }); + }, + enqueueNotice(input, context) { + recorder.notices.push({ input, context }); + }, + 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, + }; +} + +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(); +}); + +Deno.test("notice enqueues a message keyed by the obligation journal id", () => { + clearEffectDeclarations(); + const recorder = install(); + const unit = notice({ + synced: "Submitted.", + rejected: (claim) => `Could not submit "${claim.title ?? "claim"}".`, + }); + unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:notice#1" })); + 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", () => { + clearEffectDeclarations(); + const recorder = install(); + const unit = notice({ rejected: "Failed." }); + unit.handlers.onSynced?.({ id: "row-1" }, context()); + assertCount(recorder.notices.length, 0, "an unconfigured synced fate enqueues nothing"); + 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(); + install(); + const calls: Array<{ holdId: string }> = []; + const charge = (input: { holdId: string }): WriteHandle => { + calls.push(input); + return { saved: Promise.resolve() } as unknown as WriteHandle; + }; + const unit = chain(charge, (row: { id: string }) => ({ holdId: row.id })); + await unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" })); + assertCount(calls.length, 0, "a rejected write starts no chain"); + await unit.handlers.onSynced?.({ id: "row-1" }, context()); + assertCount(calls.length, 1, "a synced write issues the follow-up verb once"); + assert(calls[0].holdId === "row-1", "the mapper must receive the settled row"); + 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("https://hooks.example.com/claims"); + 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("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("https://hooks.example.com/claims"); + assert(unit.expiresAfterMs === 24 * 60 * 60 * 1000, "external delivery must default to 24h"); + const forever = webhook("https://hooks.example.com/claims2", { expiresAfterMs: null }); + assert( + forever.expiresAfterMs === null || forever.expiresAfterMs === undefined, + "null must opt into no expiry", + ); + clearEffectDeclarations(); +}); diff --git a/package/schema/effects.ts b/package/schema/effects.ts index 4ab44a9..87dc503 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. */ @@ -206,12 +247,44 @@ 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; /** 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): void; + /** + * 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 +294,8 @@ type EffectsSlot = { effects: Map>; verbs: Map; logs: Map>; + /** Monotonic counter naming anonymous built-in units in declaration order. */ + anonSequence: number; }; const slotName = "__LOFI_EFFECT_DECLARATIONS__"; @@ -232,6 +307,7 @@ function slot(): EffectsSlot { effects: new Map(), verbs: new Map(), logs: new Map(), + anonSequence: 0, }; return effectsGlobal[slotName]; } @@ -256,6 +332,7 @@ export function clearEffectDeclarations(): void { state.effects.clear(); state.verbs.clear(); state.logs.clear(); + state.anonSequence = 0; } // During dev hot replacement author modules re-evaluate routinely; the newest @@ -365,6 +442,30 @@ 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(); +} + +/** + * A stable-per-declaration name for an anonymous built-in unit (a notice, + * mark, or chain the author did not name). The journal re-arms by name, and + * these carry no author name, so their durable identity is declaration order + * within one module-evaluation — deterministic across reloads for an unchanged + * bundle. Reordering the verbs that declare them orphans in-flight obligations + * the same way renaming a named unit does. + */ +export function anonymousEffectName(prefix: string): string { + const state = slot(); + state.anonSequence = (state.anonSequence ?? 0) + 1; + return `${prefix}#${state.anonSequence}`; +} + /** * 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 diff --git a/package/schema/effects_test.ts b/package/schema/effects_test.ts index fd04834..e70022f 100644 --- a/package/schema/effects_test.ts +++ b/package/schema/effects_test.ts @@ -110,7 +110,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/use-tasks.ts.txt b/package/starter/src/islands/use-tasks.ts.txt index 4c85582..9cd25eb 100644 --- a/package/starter/src/islands/use-tasks.ts.txt +++ b/package/starter/src/islands/use-tasks.ts.txt @@ -36,7 +36,7 @@ function publishNotice(next: TaskNotice | null): void { * 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")], + effects: [s.log("task-added"), s.trace("task-added")], onSynced: (task) => { publishNotice({ kind: "synced", text: `"${task.text ?? "Task"}" synced to your account` }); }, diff --git a/package/testdata/starter.snapshot.json b/package/testdata/starter.snapshot.json index f049db9..561ce23 100644 --- a/package/testdata/starter.snapshot.json +++ b/package/testdata/starter.snapshot.json @@ -16,7 +16,7 @@ "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da", "src/islands/AccountGate.tsx": "0faa82edb05ea9c6da1f575ebb75cc2f60d8e95e88524c0720e3c885a1eee659", "src/islands/TaskList.tsx": "7b0da8830e5fab9c59948eff1cbaa357805ecea9c5d070249dd46157e5e36bdf", - "src/islands/use-tasks.ts": "0859b1bbd18a7e9fee54cbe919b4e1c9c4b23665227bffa54bd7f0d910192589", + "src/islands/use-tasks.ts": "6ae8e1130c76604dba16e4bb7680b2c66163568db8b250fba065996db3fb2146", "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 { From 48a28208ddb67ed1401f119722deb17074d1946a Mon Sep 17 00:00:00 2001 From: Dami Date: Mon, 20 Jul 2026 03:12:32 -0600 Subject: [PATCH 3/6] =?UTF-8?q?fix(effects):=20review=20remediations=20?= =?UTF-8?q?=E2=80=94=20durable=20anonymous-unit=20identity,=20notice-queue?= =?UTF-8?q?=20boot=20merge,=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses findings from the multi-agent + Codex review of #184. Anonymous-unit identity (HIGH — silent mis-binding across reloads): - notice/mark/chain no longer take their durable name from a global declaration counter (module-load-order dependent, so a re-armed obligation could bind to the WRONG unit — for chain, the wrong follow-up verb). They are now named `#` at mutation() time, from the author-chosen verb and their slot in its effects — an identity independent of which module loads first. - trace/debug/webhook are content-named and now share one unit per identity (cachedBuiltin), so reusing one across verbs aggregates instead of throwing a duplicate-name error. webhook keys on url+config. Notice queue: - Boot-window clobber (HIGH): load() now MERGES persisted entries with any enqueued during the async load window instead of overwriting, so a notice an effect enqueues at a boot re-arm is not silently dropped. - TTL drift (MEDIUM): list() is now a pure read; retirement happens in sweep(), wired to a periodic timer in the queue owner; the activeNotices count is computed from the live view so it never overcounts expired entries. mark best-effort (MEDIUM): applyMark swallows a denied/vanished-row patch instead of quarantine-spamming; documented that mark is a convenience over the status column, not a delivery guarantee (use notice/webhook for that). Notices a11y (MEDIUM): the aria-live region stays mounted (hidden) when empty so the first notice is announced; custom children are keyed. Tests: anonymous-unit verb-scoped naming, content-name sharing across verbs, notice boot-merge. All green; build refreshed. --- docs/effects.md | 9 +- package/preact/Notices.tsx | 16 ++- package/runtime/notice-queue.ts | 43 ++++++-- package/runtime/notice-queue_test.ts | 16 +++ package/runtime/write-ledger.ts | 36 +++++-- package/schema/effect-library.ts | 142 ++++++++++++++------------ package/schema/effect_library_test.ts | 43 ++++++++ package/schema/effects.ts | 84 ++++++++++++--- 8 files changed, 283 insertions(+), 106 deletions(-) diff --git a/docs/effects.md b/docs/effects.md index 74ff024..996849f 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -16,9 +16,12 @@ A custom unit is `s.effect(name, table, handlers, options)`. Four rules make it - **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 anonymous built-ins (`s.notice`, `s.mark`, `s.chain`) take their - identity from declaration order instead; reordering the verbs that declare them has the same - orphaning effect as a rename. + 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 diff --git a/package/preact/Notices.tsx b/package/preact/Notices.tsx index 5094f63..7cca066 100644 --- a/package/preact/Notices.tsx +++ b/package/preact/Notices.tsx @@ -30,14 +30,22 @@ export type NoticesProps = { * @param props Optional region label and a custom per-notice renderer. * @returns The live notices region, or `null` when the queue is empty. */ -export function Notices({ label = "Notifications", children }: NoticesProps): VNode | null { +export function Notices({ label = "Notifications", children }: NoticesProps): VNode { const { notices, dismiss } = useNotices(); - if (notices.length === 0) return null; + // 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 ( -
+