clone

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

feat(effects): built-in effect library and the custom-unit authoring contract

Dami committed 6232275
Closes #141. Grows the #139 effect machinery into a tiered standard library
and formalizes the contract for authoring custom units.

Contract (effects.ts):
- EffectContext gains `op` and `writeCreatedAt` so units can see the operation
  and measure saved→fate latency.
- PermanentEffectError / isPermanentEffectError: a handler throws it to retire
  an obligation immediately (a hopeless failure) instead of re-arming until
  maxAttempts. The ledger honors the severity.
- requireMutationRuntime + anonymousEffectName expose the seams the library is
  built on, keeping every built-in on the public authoring surface.

Built-in units (schema/effect-library.ts), tiered by risk:
- Observation — s.trace (saved→fate span in diagnostics, OTLP-shaped, no vendor
  coupling), s.debug (dev-only timeline, stripped in PROD). s.log stays in the
  core beside the runtime recorder.
- Data-internal — s.notice (durable, UI-agnostic message queue, idempotent by
  journal id; closes "a rejected write flashed success"), s.mark (patch the row
  on fate, convergent, skips a rolled-back insert), s.chain (issue a follow-up
  verb on synced, no saga API).
- External — s.webhook (POST row+fate with journalId as Idempotency-Key; 4xx →
  permanent, 5xx/429/network → retryable; default 24h delivery window).

Runtime:
- notice-queue.ts: durable queue (OPFS→localStorage→memory), idempotent
  enqueue, TTL + dismissal, subscribe; activeNotices diagnostic.
- write-ledger.ts installs recordTrace/recordDebug/enqueueNotice/applyMark and
  owns the queue singleton; public listNotices/subscribeNotices/dismissNotice.
- diagnostics.ts: effectTraces, effectDebugTimeline, activeNotices.

Preact: useNotices hook + Notices component render the durable queue; a toast
stack is a userland wrapper over the same hook.

Docs: docs/effects.md (library reference + authoring contract + worked custom
unit), teaching-path link from nouns-and-verbs, README reference entries.

Tests: per-unit happy/rejection/idempotency, notice-queue idempotence/TTL/
reload, ledger permanent-failure retirement, and a custom unit built purely on
the public contract. Reference app adds s.trace to addTask (dogfood); starter
mirror + snapshot regenerated. Manifest updated for the three new modules.

Deferred, noted for a follow-up: migrating the reference app's hand-rolled
task-notice to s.notice (a focused island refactor).
commit
623227574a0aa06619886f15e2c5a3c54db55d8b
parent
86cfaae2dc76a1f672ce08e51ddf5c3f0796dbf8
author
Dami <dami.etoile@gmail.com>
22 files changed, +1484 -10
Mapps/reference/src/islands/use-tasks.ts2+-
Mdocs/README.md2+
Adocs/effects.md157++++++++++++++
Mdocs/nouns-and-verbs.md5+-
Mpackage/astro/manifest.ts4+
Apackage/preact/Notices.tsx57+++++
Mpackage/preact/mod.ts7+
Apackage/preact/use-notices.ts60+++++
Mpackage/runtime/diagnostics.ts71++++++
Mpackage/runtime/mod.ts13+-
Apackage/runtime/notice-queue.ts200++++++++++++++++++
Apackage/runtime/notice-queue_test.ts58+++++
Mpackage/runtime/write-ledger.ts101+++++++++-
Mpackage/runtime/write-ledger_test.ts36+++-
Apackage/schema/effect-library.ts340++++++++++++++++++++++++++++++
Apackage/schema/effect_library_test.ts245++++++++++++++++++++++
Mpackage/schema/effects.ts101+++++++++
Mpackage/schema/effects_test.ts2+
Mpackage/schema/mod.ts28++
Mpackage/starter/src/islands/use-tasks.ts.txt2+-
Mpackage/testdata/starter.snapshot.json2+-
Mpackage/testing/mod.ts1+
M apps/reference/src/islands/use-tasks.ts +1 -1
M docs/README.md +2 -0
A docs/effects.md +157 -0
@@ -0,0 +1,157 @@
1+# The effect library2+3+Effect units give a verb consequences: a handler that runs on the originating device when the4+write's fate settles. [Nouns and verbs](nouns-and-verbs.md) covers the machinery — declaration, the5+journal, at-least-once delivery, retention. This page is the built-in library and the contract for6+authoring your own.7+8+The teaching path runs one new idea at a time: declare a verb, **observe** it (`s.log`, `s.trace`),9+make its fate **data** (`s.notice`, `s.mark`), **compose** further writes (`s.chain`), then reach10+the **outside world** (`s.webhook`). Every built-in is idempotent by construction, so each is also a11+worked example of the discipline your own units must follow.12+13+## The authoring contract14+15+A custom unit is `s.effect(name, table, handlers, options)`. Four rules make it safe:16+17+- **The name is a durable identity.** The journal re-arms a unit's handlers by name after a reload,18+ so names are app-unique (a duplicate throws at declaration) and renaming a unit orphans its19+ in-flight obligations. The anonymous built-ins (`s.notice`, `s.mark`, `s.chain`) take their20+ identity from declaration order instead; reordering the verbs that declare them has the same21+ orphaning effect as a rename.22+- **Delivery is at-least-once, so handlers must be idempotent.** A crash between handler start and23+ journal completion re-runs the handler at the next boot. The `context.journalId` a handler24+ receives — the write's `(write id, effect name)` key — is the idempotency key: pass it to any25+ external call, and dedupe your own side effects on it.26+- **Failure has two severities.** An ordinary thrown error is _retryable_: the obligation re-arms at27+ the next boot until it succeeds or `maxAttempts` (default 5) quarantines it. Throwing28+ `PermanentEffectError` is _permanent_: the obligation retires immediately, without burning the29+ retry budget on a call that will keep failing. Both are counted in runtime diagnostics; a failed30+ handler is never silently swallowed.31+- **`onRejected` compensates ancillary state, never row data.** The engine rolls a rejected write32+ out of local query results itself. After a reload a rejected handler receives the row id alone, so33+ compensate what the user was told or what you called externally — not the row.34+35+A unit written to these rules behaves like a built-in. This is the smallest one:36+37+```ts38+import { PermanentEffectError, s } from "@nzip/lofi/schema";39+40+const sent = new Set<string>();41+export const sendReceipt = s.effect("sendReceipt", app.schema.orders, {42+ onSynced: async (order, { journalId }) => {43+ if (sent.has(journalId)) return; // at-least-once: dedupe on the key44+ const res = await fetch(receiptUrl, { headers: { "Idempotency-Key": journalId } });45+ if (res.status >= 400 && res.status < 500) {46+ throw new PermanentEffectError(`receipt refused: ${res.status}`); // do not retry47+ }48+ if (!res.ok) throw new Error(`receipt transient failure: ${res.status}`); // retry49+ sent.add(journalId);50+ },51+});52+```53+54+## Observation tier55+56+Cannot change anything — they only record.57+58+### `s.log(label)`59+60+Records a structured diagnostics entry (write id, fate, timing) on either fate. Repeated calls with61+one label share a unit. See [nouns and verbs](nouns-and-verbs.md#effect-units).62+63+### `s.trace(label?)`64+65+A span from the write's journaling to its settled fate, recorded in runtime diagnostics as an66+OpenTelemetry-shaped event with the saved→synced/rejected latency. Pure instrumentation and the67+observability hook; an OTLP exporter is an adapter over this feed, never a concept you configure per68+verb. Without a label the verb name labels the span.69+70+```ts71+export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {72+ effects: [s.trace("checkout")],73+});74+```75+76+### `s.debug()`77+78+A development-only timeline of each fate an obligation settles, for eyeballing delivery in the79+inspector. Stripped from production builds — in a `PROD` bundle the handlers record nothing.80+81+## Data-internal tier82+83+Write back into the app. Fate stops being a callback and becomes replicated data.84+85+### `s.notice(config)`86+87+Enqueues a durable, user-visible message when the write settles — the fix for the "a rejected write88+still flashed success" failure mode. The queue is durable and UI-agnostic: an entry created at a89+boot re-arm survives with nothing mounted, and a component renders it later. Render with the90+built-in `<Notices />` or the `useNotices()` hook from `@nzip/lofi/preact`; a toast stack is a91+userland wrapper over the same queue, never an imperative call. Idempotent by the obligation's92+journal id, so a re-delivery enqueues one entry. `ttlMs` bounds an entry's life; pass `null` to keep93+it until dismissed.94+95+```ts96+export const publish = s.mutation("publish", s.update(app.schema.posts), {97+ effects: [s.notice<Post>({98+ synced: "Published.",99+ rejected: (post) => `Could not publish "${post.title}".`,100+ })],101+});102+```103+104+### `s.mark(table, config)`105+106+Patches the written row when its fate resolves, so write fate becomes replicated data every device107+and query sees — the hand-rolled `status` column made declarative. The patch is absolute (a static108+set-column-to-value object), so it is convergent under re-delivery. A rejected _insert_ has no row109+to mark — the engine rolled it out — so the rejected patch is skipped for inserts; on updates and110+removes the row survives the rollback.111+112+```ts113+export const submit = s.mutation("submit", s.update(app.schema.claims), {114+ effects: [s.mark(app.schema.claims, {115+ synced: { status: "confirmed" },116+ rejected: { status: "failed" },117+ })],118+});119+```120+121+### `s.chain(verb, toInput)`122+123+Issues a follow-up verb once the write syncs, mapping the settled row to the next verb's input.124+Reifies a chain of writes (reserve → charge → fulfill) declaratively without a saga API — each link125+is an ordinary verb with its own effects and rejection handling. Fires only on `synced`.126+127+```ts128+export const reserve = s.mutation("reserve", s.insert(app.schema.holds), {129+ effects: [s.chain(charge, (hold) => ({ holdId: hold.id, amount: hold.total }))],130+});131+```132+133+## External tier134+135+Where the guardrails earn their keep.136+137+### `s.webhook(url, options?)`138+139+POSTs the settled row and its fate to `url`, with the obligation's journal id auto-injected as140+`Idempotency-Key` so a re-delivery the receiver already saw is dropped receiver-side. The generic141+integration workhorse and the reference for the at-least-once contract.142+143+- **Failure severity follows the response.** A network error, `5xx`, or `429` throws an ordinary144+ error, so the ledger's bounded backoff retries it; any other `4xx` throws `PermanentEffectError`,145+ retiring the obligation rather than retrying a request the receiver will keep refusing.146+- **Delivery defaults to a finite window.** Receiver idempotency windows are finite (Stripe forgets147+ keys after ~24h), so external delivery defaults to a 24-hour `expiresAfterMs` rather than148+ infinity; pass `null` to opt into no expiry.149+150+```ts151+export const order = s.mutation("order", s.insert(app.schema.orders), {152+ effects: [s.webhook("https://hooks.example.com/orders")],153+});154+```155+156+Provider-specific units (email, SMS, push) are userland wrappers over `s.webhook`; the library ships157+the workhorse, not the integrations.
M docs/nouns-and-verbs.md +4 -1
M package/astro/manifest.ts +4 -0
A package/preact/Notices.tsx +57 -0
@@ -0,0 +1,57 @@
1+import type { VNode } from "preact";2+// Package-owned optional notices surface.3+import { type NoticeEntry, useNotices } from "./use-notices.ts";4+5+/** Props for the built-in {@link Notices} surface. */6+export type NoticesProps = {7+ /** Accessible label for the region; defaults to "Notifications". */8+ label?: string;9+ /** Renders one notice; defaults to the built-in row with a dismiss button. */10+ children?: (notice: NoticeEntry, dismiss: () => void) => VNode;11+};12+13+/**14+ * The built-in durable-notice surface: renders the live `s.notice` queue as an15+ * ARIA live region so a message enqueued when a write settled — success or the16+ * "a rejected write still flashed success" case — is announced and dismissable.17+ * Optional and entirely public: it is `useNotices` plus default markup, so an18+ * app can drop it and render its own (a toast stack, a banner) over the same19+ * hook.20+ *21+ * @example22+ * ```tsx23+ * import { Notices } from "@nzip/lofi/preact";24+ *25+ * export function AppChrome() {26+ * return <Notices />;27+ * }28+ * ```29+ *30+ * @param props Optional region label and a custom per-notice renderer.31+ * @returns The live notices region, or `null` when the queue is empty.32+ */33+export function Notices({ label = "Notifications", children }: NoticesProps): VNode | null {34+ const { notices, dismiss } = useNotices();35+ if (notices.length === 0) return null;36+ return (37+ <section class="lofi-notices" aria-label={label} aria-live="polite">38+ {notices.map((notice) =>39+ children40+ ? children(notice, () => dismiss(notice.id))41+ : (42+ <div key={notice.id} class="lofi-notice" data-tone={notice.tone} role="status">43+ <p class="lofi-notice-message">{notice.message}</p>44+ <button45+ type="button"46+ class="lofi-notice-dismiss"47+ aria-label="Dismiss notification"48+ onClick={() => dismiss(notice.id)}49+ >50+ Dismiss51+ </button>52+ </div>53+ )54+ )}55+ </section>56+ );57+}
M package/preact/mod.ts +7 -0
A package/preact/use-notices.ts +60 -0
@@ -0,0 +1,60 @@
1+/**2+ * Preact binding for the durable notice queue behind `s.notice`: the live list3+ * of user-visible messages an effect enqueued when a write settled, plus the4+ * dismissal action. Apps render their own surface over this, or use the5+ * built-in {@link Notices} component.6+ *7+ * @module8+ */9+10+import { useEffect, useState } from "preact/hooks";11+import {12+ dismissAllNotices,13+ dismissNotice,14+ listNotices,15+ type NoticeEntry,16+ subscribeNotices,17+} from "../runtime/mod.ts";18+19+export type { NoticeEntry, NoticeTone } from "../runtime/mod.ts";20+21+/** The live notices and the actions to retire them. */22+export type NoticesSurface = {23+ /** The live notices: enqueued, not dismissed, not past their TTL. */24+ notices: readonly NoticeEntry[];25+ /** Dismisses one notice by id. */26+ dismiss: (id: string) => void;27+ /** Dismisses every notice. */28+ dismissAll: () => void;29+};30+31+/**32+ * Subscribes a Preact component to the durable notice queue. The list stays33+ * live across enqueues (including effects that fire at a boot re-arm),34+ * dismissals, and TTL retirement.35+ *36+ * @example37+ * ```tsx38+ * import { useNotices } from "@nzip/lofi/preact";39+ *40+ * const { notices, dismiss } = useNotices();41+ * return notices.map((n) => (42+ * <div key={n.id} data-tone={n.tone}>43+ * {n.message}44+ * <button type="button" onClick={() => dismiss(n.id)}>Dismiss</button>45+ * </div>46+ * ));47+ * ```48+ *49+ * @returns The live notices and the dismissal actions.50+ */51+export function useNotices(): NoticesSurface {52+ const [notices, setNotices] = useState<readonly NoticeEntry[]>(() => listNotices());53+ useEffect(() => {54+ // Publish the current list once on mount — an effect may have enqueued55+ // before this component subscribed — then follow every change.56+ setNotices(listNotices());57+ return subscribeNotices(() => setNotices(listNotices()));58+ }, []);59+ return { notices, dismiss: dismissNotice, dismissAll: dismissAllNotices };60+}
M package/runtime/diagnostics.ts +71 -0
@@ -30,6 +30,65 @@ export function recordEffectLogEntry(
3030 diagnostics.effectLog = [...diagnostics.effectLog.slice(-(maxEffectLogEntries - 1)), entry];3131 }3232 33+/**34+ * One OpenTelemetry-shaped span recorded by the built-in `s.trace` effect35+ * unit: the write's journaling to its settled fate, with the elapsed latency.36+ * The framework emits these into diagnostics with no vendor coupling; an37+ * OTLP exporter is an adapter over this feed, never a concept the author sees.38+ */39+export type EffectTraceEntry = {40+ /** The author's span label, or `null` when the verb name labels it. */41+ label: string | null;42+ /** The declaring verb's name, or `null` for writes without a verb. */43+ verb: string | null;44+ /** The written row's id. */45+ rowId: string;46+ /** Which fate ended the span. */47+ fate: "synced" | "rejected";48+ /** Saved-to-fate latency in milliseconds. */49+ durationMs: number;50+ /** Epoch milliseconds when the span closed. */51+ at: number;52+};53+54+/** One development-only timeline event recorded by the built-in `s.debug` unit. */55+export type EffectDebugEvent = {56+ /** The declaring verb's name, or `null` for writes without a verb. */57+ verb: string | null;58+ /** The obligation's journal id. */59+ journalId: string;60+ /** The stage/fate this event marks. */61+ event: string;62+ /** Epoch milliseconds when the event was recorded. */63+ at: number;64+};65+66+// Recent-entry windows for the trace and dev-debug feeds.67+const maxEffectTraceEntries = 20;68+const maxEffectDebugEvents = 50;69+70+/** Appends one `s.trace` span, keeping the bounded recent window. */71+export function recordEffectTrace(72+ diagnostics: RuntimeDiagnostics,73+ entry: EffectTraceEntry,74+): void {75+ diagnostics.effectTraces = [76+ ...diagnostics.effectTraces.slice(-(maxEffectTraceEntries - 1)),77+ entry,78+ ];79+}80+81+/** Appends one `s.debug` timeline event, keeping the bounded recent window. */82+export function recordEffectDebugEvent(83+ diagnostics: RuntimeDiagnostics,84+ entry: EffectDebugEvent,85+): void {86+ diagnostics.effectDebugTimeline = [87+ ...diagnostics.effectDebugTimeline.slice(-(maxEffectDebugEvents - 1)),88+ entry,89+ ];90+}91+3392 /**3493 * Runtime-owned observability counters. These describe the framework's storage,3594 * subscription, and write machinery and never reference any application schema.
@@ -92,6 +151,15 @@ export type RuntimeDiagnostics = {
92151 quarantinedObligations: number;93152 /** Recent structured entries recorded by the built-in `s.log` effect unit. */94153 effectLog: readonly EffectLogEntry[];154+ /** Recent saved→fate spans recorded by the built-in `s.trace` effect unit. */155+ effectTraces: readonly EffectTraceEntry[];156+ /**157+ * Recent development-only timeline events from the built-in `s.debug` unit;158+ * always empty in production builds, where `s.debug` records nothing.159+ */160+ effectDebugTimeline: readonly EffectDebugEvent[];161+ /** How many durable `s.notice` entries are unread (undismissed, unexpired). */162+ activeNotices: number;95163 /** Recent shared-field key alerts: substitutions, forged wraps, self-key96164 * conflicts. A non-empty list is the detection surface the threat model97165 * promises — render it, never swallow it. */
@@ -155,6 +223,9 @@ export function createDiagnostics(): RuntimeDiagnostics {
155223 expiredObligations: 0,156224 quarantinedObligations: 0,157225 effectLog: [],226+ effectTraces: [],227+ effectDebugTimeline: [],228+ activeNotices: 0,158229 sharedFieldAlerts: [],159230 };160231 }
M package/runtime/mod.ts +12 -1
A package/runtime/notice-queue.ts +200 -0
@@ -0,0 +1,200 @@
1+/**2+ * Package-owned durable notice queue: the store behind the built-in3+ * `s.notice` effect unit.4+ *5+ * A notice is a user-visible message an effect enqueues when a write settles —6+ * the durable answer to "a rejected write still flashed success". The queue is7+ * deliberately not an imperative toast: an effect can fire at a boot re-arm8+ * with no UI mounted, so the entry must outlive the render and be picked up9+ * later. Entries persist beside the write journal (OPFS when the browser10+ * provides it, `localStorage` otherwise), are keyed by the enqueuing11+ * obligation's journal id so an at-least-once re-delivery adds one entry, and12+ * retire by dismissal or TTL. A component (the built-in notices surface, or an13+ * author's) subscribes and renders; toasts are a userland wrapper over this.14+ *15+ * @module16+ */17+18+import {19+ createDefaultJournalStorage,20+ createMemoryJournalStorage,21+ type JournalStorage,22+} from "./write-journal.ts";23+24+/** How a notice is classified for rendering. */25+export type NoticeTone = "info" | "success" | "warning" | "error";26+27+/** One durable notice entry. */28+export type NoticeEntry = {29+ /** The enqueuing obligation's journal id — the entry's idempotency key. */30+ id: string;31+ /** The user-facing message. */32+ message: string;33+ /** The message classification. */34+ tone: NoticeTone;35+ /** Epoch milliseconds when the entry was enqueued. */36+ createdAt: number;37+ /** Epoch milliseconds when the entry retires by TTL, or `null` for none. */38+ expiresAt: number | null;39+};40+41+/** The persisted queue document. */42+type NoticeDocument = {43+ version: 1;44+ entries: NoticeEntry[];45+};46+47+function parse(text: string | null): NoticeDocument {48+ if (!text) return { version: 1, entries: [] };49+ try {50+ const value = JSON.parse(text) as Partial<NoticeDocument> | null;51+ if (value && value.version === 1 && Array.isArray(value.entries)) {52+ const entries = value.entries.filter((entry): entry is NoticeEntry =>53+ typeof entry?.id === "string" && typeof entry.message === "string"54+ );55+ return { version: 1, entries };56+ }57+ } catch {58+ // A corrupt queue must not brick boot; start empty.59+ }60+ return { version: 1, entries: [] };61+}62+63+/** What one enqueue call carries, before the queue stamps identity and time. */64+export type NoticeEnqueueInput = {65+ /** The idempotency key — the enqueuing obligation's journal id. */66+ id: string;67+ /** The user-facing message. */68+ message: string;69+ /** The message classification. */70+ tone: NoticeTone;71+ /** Lifespan before TTL retirement, or `null` to keep until dismissed. */72+ ttlMs: number | null;73+};74+75+/**76+ * The single reader and writer of the durable notice queue: an in-memory77+ * document with coalesced persistence, a live-notice snapshot, and change78+ * notification. Retirement (dismissal, TTL) is applied lazily on every read79+ * and on a periodic sweep, so a queue loaded at boot never surfaces a message80+ * whose window already closed.81+ */82+export class NoticeQueue {83+ readonly #storage: JournalStorage;84+ readonly #now: () => number;85+ #document: NoticeDocument = { version: 1, entries: [] };86+ #chain: Promise<void> = Promise.resolve();87+ #loaded = false;88+ #listeners = new Set<() => void>();89+ #onCountChange?: (count: number) => void;90+91+ /** Creates a queue over one storage location and clock. */92+ constructor(93+ storage: JournalStorage,94+ now: () => number = Date.now,95+ onCountChange?: (count: number) => void,96+ ) {97+ this.#storage = storage;98+ this.#now = now;99+ this.#onCountChange = onCountChange;100+ }101+102+ /** Loads the persisted document once, dropping already-expired entries. */103+ async load(): Promise<void> {104+ if (this.#loaded) return;105+ this.#document = parse(await this.#storage.load());106+ this.#loaded = true;107+ this.#sweepExpired();108+ }109+110+ /**111+ * Enqueues one notice, idempotent by id: an entry whose id is already112+ * present is left untouched, so an at-least-once re-delivery adds nothing.113+ */114+ enqueue(input: NoticeEnqueueInput): void {115+ if (this.#document.entries.some((entry) => entry.id === input.id)) return;116+ const createdAt = this.#now();117+ this.#document.entries.push({118+ id: input.id,119+ message: input.message,120+ tone: input.tone,121+ createdAt,122+ expiresAt: input.ttlMs === null ? null : createdAt + input.ttlMs,123+ });124+ this.#persist();125+ this.#emit();126+ }127+128+ /** Dismisses one entry by id; unknown ids are a no-op. */129+ dismiss(id: string): void {130+ const next = this.#document.entries.filter((entry) => entry.id !== id);131+ if (next.length === this.#document.entries.length) return;132+ this.#document.entries = next;133+ this.#persist();134+ this.#emit();135+ }136+137+ /** Dismisses every entry. */138+ dismissAll(): void {139+ if (this.#document.entries.length === 0) return;140+ this.#document.entries = [];141+ this.#persist();142+ this.#emit();143+ }144+145+ /** The live notices: enqueued, not dismissed, not past their TTL. */146+ list(): readonly NoticeEntry[] {147+ this.#sweepExpired();148+ return this.#document.entries;149+ }150+151+ /** Subscribes to queue changes; returns an unsubscribe function. */152+ subscribe(listener: () => void): () => void {153+ this.#listeners.add(listener);154+ return () => this.#listeners.delete(listener);155+ }156+157+ /** Resolves once every scheduled persistence has settled. */158+ flush(): Promise<void> {159+ return this.#chain;160+ }161+162+ /** Applies TTL retirement, persisting and notifying if anything changed. */163+ sweep(): void {164+ if (this.#sweepExpired()) {165+ this.#persist();166+ this.#emit();167+ }168+ }169+170+ #sweepExpired(): boolean {171+ const now = this.#now();172+ const before = this.#document.entries.length;173+ this.#document.entries = this.#document.entries.filter((entry) =>174+ entry.expiresAt === null || entry.expiresAt > now175+ );176+ return this.#document.entries.length !== before;177+ }178+179+ #persist(): void {180+ const snapshot = JSON.stringify(this.#document);181+ this.#chain = this.#chain.then(() => this.#storage.save(snapshot)).catch(() => undefined);182+ }183+184+ #emit(): void {185+ this.#onCountChange?.(this.#document.entries.length);186+ for (const listener of this.#listeners) listener();187+ }188+}189+190+/** Resolves the default notice-queue storage for one app id. */191+export function createDefaultNoticeStorage(appId: string): JournalStorage {192+ // The journal's storage resolver already picks OPFS, then localStorage, then193+ // memory; a distinct file name keeps the two documents apart.194+ return createDefaultJournalStorage(`notices-${appId}`);195+}196+197+/** An in-memory notice queue for tests and storage-less runtimes. */198+export function createMemoryNoticeQueue(now?: () => number): NoticeQueue {199+ return new NoticeQueue(createMemoryJournalStorage(), now);200+}
A package/runtime/notice-queue_test.ts +58 -0
@@ -0,0 +1,58 @@
1+import { assert, assertCount } from "./test-assert.ts";2+import { createMemoryJournalStorage } from "./write-journal.ts";3+import { NoticeQueue } from "./notice-queue.ts";4+5+function queue(now: () => number = () => 1000): {6+ q: NoticeQueue;7+ storage: ReturnType<typeof createMemoryJournalStorage>;8+ counts: number[];9+} {10+ const storage = createMemoryJournalStorage();11+ const counts: number[] = [];12+ return { q: new NoticeQueue(storage, now, (count) => counts.push(count)), storage, counts };13+}14+15+Deno.test("enqueue is idempotent by id, so an at-least-once re-delivery adds one entry", () => {16+ const { q, counts } = queue();17+ q.enqueue({ id: "w1:notice#1", message: "Saved.", tone: "success", ttlMs: null });18+ q.enqueue({ id: "w1:notice#1", message: "Saved.", tone: "success", ttlMs: null });19+ assertCount(q.list().length, 1, "the second enqueue with one id must be a no-op");20+ assertCount(counts.at(-1) ?? -1, 1, "the active-notice count must reflect one entry");21+});22+23+Deno.test("dismiss removes one entry and notifies subscribers", () => {24+ const { q } = queue();25+ let notified = 0;26+ q.subscribe(() => notified += 1);27+ q.enqueue({ id: "a", message: "one", tone: "info", ttlMs: null });28+ q.enqueue({ id: "b", message: "two", tone: "info", ttlMs: null });29+ q.dismiss("a");30+ assertCount(q.list().length, 1, "dismiss must drop exactly the named entry");31+ assert(q.list()[0].id === "b", "the surviving entry must be the one not dismissed");32+ assert(notified >= 3, "each mutation must notify subscribers");33+});34+35+Deno.test("a TTL entry retires once its window closes", () => {36+ let clock = 1000;37+ const { q } = queue(() => clock);38+ q.enqueue({ id: "ttl", message: "temporary", tone: "warning", ttlMs: 5000 });39+ assertCount(q.list().length, 1, "the entry is live inside its window");40+ clock = 6001;41+ assertCount(q.list().length, 0, "list must not surface an entry past its TTL");42+});43+44+Deno.test("a persisted queue reloads its entries and drops expired ones", async () => {45+ let clock = 1000;46+ const storage = createMemoryJournalStorage();47+ const first = new NoticeQueue(storage, () => clock);48+ first.enqueue({ id: "keep", message: "durable", tone: "info", ttlMs: null });49+ first.enqueue({ id: "drop", message: "fleeting", tone: "info", ttlMs: 100 });50+ await first.flush();51+52+ clock = 5000;53+ const second = new NoticeQueue(storage, () => clock);54+ await second.load();55+ const ids = second.list().map((entry) => entry.id);56+ assert(ids.includes("keep"), "a persisted entry must survive a reload");57+ assert(!ids.includes("drop"), "an entry whose TTL passed while away must not reappear");58+});
M package/runtime/write-ledger.ts +97 -4
@@ -24,12 +24,19 @@ import {
2424 type EffectContext,2525 type EffectRow,2626 type EffectUnit,27+ isPermanentEffectError,2728 type MutationDescriptor,2829 resolveEffectUnit,2930 setMutationRuntime,3031 } from "../schema/effects.ts";3132 import { appId, syncing } from "./config.ts";32-import { recordEffectLogEntry, type RuntimeDiagnostics } from "./diagnostics.ts";33+import {34+ recordEffectDebugEvent,35+ recordEffectLogEntry,36+ recordEffectTrace,37+ type RuntimeDiagnostics,38+} from "./diagnostics.ts";39+import { createDefaultNoticeStorage, type NoticeEntry, NoticeQueue } from "./notice-queue.ts";3340 import { settleDurableWrite } from "./durability.ts";3441 import { classifyMutationError } from "./mutation-taxonomy.ts";3542 import {
@@ -743,7 +750,9 @@ export class WriteLedger {
743750 writeId: entry.writeId,744751 verb: entry.verb,745752 table: entry.table,753+ op: entry.op,746754 rowId: entry.rowId,755+ writeCreatedAt: entry.createdAt,747756 fate,748757 cause: fate === "rejected" ? entry.cause ?? "denied" : null,749758 code: entry.code,
@@ -764,9 +773,11 @@ export class WriteLedger {
764773 this.#environment.updateDiagnostics((diagnostics) =>765774 diagnostics.effectHandlerFailures += 1766775 );767- // Quarantine: a handler that keeps failing retires instead of768- // re-arming forever; the retirement is diagnosable and prunable.769- if (state.attempts >= maxAttempts) {776+ // A handler that declared its failure permanent retires now: retrying777+ // a request the receiver will keep refusing only burns the budget and778+ // delays the quarantine diagnostic. Otherwise a failing handler779+ // re-arms until repeated attempts quarantine it.780+ if (isPermanentEffectError(error) || state.attempts >= maxAttempts) {770781 this.#retireObligation(entry, effectName, "failed-permanent");771782 }772783 }
@@ -898,6 +909,57 @@ export function armWriteLedger(): Promise<void> {
898909 return getWriteLedger().arm();899910 }900911 912+let defaultNotices: NoticeQueue | null = null;913+914+/**915+ * The package-wide durable notice queue behind `s.notice`, created lazily and916+ * kept in sync with the `activeNotices` diagnostic. Public read/subscribe/917+ * dismiss surfaces ({@link listNotices}, {@link subscribeNotices},918+ * {@link dismissNotice}) are re-exported from the runtime entry.919+ */920+export function getNoticeQueue(): NoticeQueue {921+ if (!defaultNotices) {922+ defaultNotices = new NoticeQueue(923+ createDefaultNoticeStorage(appId),924+ Date.now,925+ (count) =>926+ updateRuntimeDiagnostics((diagnostics) => {927+ diagnostics.activeNotices = count;928+ }),929+ );930+ // Load persisted entries and publish the initial count; a storage-less or931+ // prerender context simply starts empty.932+ void defaultNotices.load()933+ .then(() =>934+ updateRuntimeDiagnostics((diagnostics) => {935+ diagnostics.activeNotices = defaultNotices?.list().length ?? 0;936+ })937+ )938+ .catch(() => undefined);939+ }940+ return defaultNotices;941+}942+943+/** The live durable notices for the current document. */944+export function listNotices(): readonly NoticeEntry[] {945+ return getNoticeQueue().list();946+}947+948+/** Subscribes to notice-queue changes; returns an unsubscribe function. */949+export function subscribeNotices(listener: () => void): () => void {950+ return getNoticeQueue().subscribe(listener);951+}952+953+/** Dismisses one durable notice by id. */954+export function dismissNotice(id: string): void {955+ getNoticeQueue().dismiss(id);956+}957+958+/** Dismisses every durable notice. */959+export function dismissAllNotices(): void {960+ getNoticeQueue().dismissAll();961+}962+901963 // The runtime half of the schema-declared verb surface: importing the runtime902964 // package installs dispatch, so verbs work wherever islands import hooks.903965 setMutationRuntime({
@@ -913,10 +975,41 @@ setMutationRuntime({
913975 at: Date.now(),914976 })915977 ),978+ recordTrace: (label, context) =>979+ updateRuntimeDiagnostics((diagnostics) =>980+ recordEffectTrace(diagnostics, {981+ label,982+ verb: context.verb,983+ rowId: context.rowId,984+ fate: context.fate,985+ durationMs: Math.max(0, Date.now() - context.writeCreatedAt),986+ at: Date.now(),987+ })988+ ),989+ recordDebug: (event, context) =>990+ updateRuntimeDiagnostics((diagnostics) =>991+ recordEffectDebugEvent(diagnostics, {992+ verb: context.verb,993+ journalId: context.journalId,994+ event,995+ at: Date.now(),996+ })997+ ),998+ enqueueNotice: (input, context) =>999+ getNoticeQueue().enqueue({1000+ id: context.journalId,1001+ message: input.message,1002+ tone: input.tone,1003+ ttlMs: input.ttlMs,1004+ }),1005+ applyMark: async (table, rowId, patch) => {1006+ await getWriteLedger().perform({ kind: "update", table, id: rowId, patch }).saved;1007+ },9161008 unitRegistered: (name) => defaultLedger?.retryObligationsFor(name),9171009 });9181010 9191011 import.meta.hot?.dispose(() => {9201012 defaultLedger?.dispose();9211013 defaultLedger = null;1014+ defaultNotices = null;9221015 });
M package/runtime/write-ledger_test.ts +35 -1
@@ -1,6 +1,6 @@
11 import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";22 import { createDiagnostics } from "./diagnostics.ts";3-import type { EffectContext, EffectUnit } from "../schema/effects.ts";3+import { type EffectContext, type EffectUnit, PermanentEffectError } from "../schema/effects.ts";44 import { assert, assertCount } from "./test-assert.ts";55 import {66 createMemoryJournalStorage,
@@ -135,6 +135,7 @@ function unit(
135135 options: {136136 failFirst?: { onSynced?: boolean };137137 failAlways?: boolean;138+ failPermanent?: boolean;138139 expiresAfterMs?: number;139140 maxAttempts?: number;140141 } = {},
@@ -144,6 +145,7 @@ function unit(
144145 effectName: name,145146 handlers: {146147 onSynced: (row, context) => {148+ if (options.failPermanent) throw new PermanentEffectError("receiver refused for good");147149 if (options.failAlways) throw new Error("handler always explodes");148150 if (options.failFirst?.onSynced && !failed) {149151 failed = true;
@@ -747,6 +749,38 @@ Deno.test("a closed delivery window retires the obligation: no handler, diagnosa
747749 fixture.ledger.dispose();748750 });749751 752+Deno.test("a PermanentEffectError retires the obligation on the first failure", async () => {753+ const storage = createMemoryJournalStorage();754+ const calls: Array<755+ { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }756+ > = [];757+ // A high attempt bound proves the retirement is the permanent signal, not758+ // the attempt count: an ordinary throw would re-arm four more times.759+ const refusing = unit("permanent", calls, { failPermanent: true, maxAttempts: 5 });760+ const fixture = harness({ storage, units: new Map([["permanent", refusing]]) });761+ await fixture.ledger.arm();762+ const handle = fixture.ledger.perform<Row>(763+ { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "gone" } },764+ { units: [refusing] },765+ );766+ await handle;767+ fixture.db.settleGlobal();768+ await flush();769+ await fixture.ledger.flush();770+ assertCount(fixture.diagnostics.effectHandlerFailures, 1, "the failure is counted once");771+ assertCount(772+ fixture.diagnostics.quarantinedObligations,773+ 1,774+ "a permanent failure quarantines without exhausting attempts",775+ );776+ assert(777+ (storage.text() ?? "").includes(handle.writeId) === false,778+ "the retired obligation makes the entry prunable, so it never re-arms",779+ );780+ assertCount(calls.length, 0, "a permanently refused handler never reports success");781+ fixture.ledger.dispose();782+});783+750784 Deno.test("quarantine retires a permanently failing handler after its attempt bound", async () => {751785 const storage = createMemoryJournalStorage();752786 const calls: Array<
A package/schema/effect-library.ts +340 -0
@@ -0,0 +1,340 @@
1+/// <reference path="../runtime/env.d.ts" />2+/**3+ * The built-in effect library: reusable {@link EffectUnit}s tiered by risk,4+ * each idempotent by construction so it doubles as a reference implementation5+ * of the at-least-once discipline a custom unit author must follow.6+ *7+ * The tiers are a learning ramp, one new concept each:8+ *9+ * - **Observation** — {@link trace}, {@link debug}. Cannot change anything;10+ * they only record. (The first observation unit, `s.log`, lives in11+ * `effects.ts` beside the core because the runtime records it directly.)12+ * - **Data-internal** — {@link notice}, {@link mark}, {@link chain}. Write13+ * back into the app: a durable message, a status column, a follow-up verb.14+ * Fate stops being a callback and becomes replicated data.15+ * - **External** — {@link webhook}. Calls the outside world, where the16+ * idempotency key and a bounded, backed-off retry earn their keep.17+ *18+ * Every unit here is built on the public authoring surface from `effects.ts`19+ * ({@link effect}, {@link EffectContext}, {@link PermanentEffectError}, and the20+ * optional runtime capabilities the package runtime installs) — nothing21+ * private. The authoring guide points custom-unit authors at these as models.22+ *23+ * @module24+ */25+26+import type { TableProxy } from "jazz-tools";27+import type { WriteHandle } from "../runtime/write-handle.ts";28+import {29+ anonymousEffectName,30+ effect,31+ type EffectContext,32+ type EffectRow,33+ type EffectUnit,34+ type EffectUnitOptions,35+ type NoticeInput,36+ PermanentEffectError,37+ requireMutationRuntime,38+} from "./effects.ts";39+40+// A tableless built-in binds no columns; the core `effect` uses its table41+// argument only for row typing, so this placeholder documents the intent in42+// one place instead of scattering casts.43+const noTable = undefined as unknown as TableProxy<{ id: string }, unknown>;44+45+function tablelessEffect(46+ name: string,47+ handlers: {48+ onSynced?: (row: EffectRow<{ id: string }>, context: EffectContext) => void | Promise<void>;49+ onRejected?: (row: EffectRow<{ id: string }>, context: EffectContext) => void | Promise<void>;50+ },51+ options?: EffectUnitOptions,52+): EffectUnit<{ id: string }> {53+ return effect(name, noTable, handlers, options);54+}55+56+/**57+ * Observation unit: a span from the write's journaling to its settled fate,58+ * recorded in runtime diagnostics as an OpenTelemetry-shaped event with the59+ * saved→synced/rejected latency. Pure instrumentation — it changes no state60+ * and cannot fail a write. The optional label groups related spans; without61+ * one the verb name labels the span. Repeated calls with one label share a62+ * unit, so a label can be reused across verbs.63+ *64+ * @example65+ * ```ts66+ * export const placeOrder = s.mutation("placeOrder", s.insert(app.orders), {67+ * effects: [s.trace("checkout")],68+ * });69+ * ```70+ */71+export function trace(label?: string): EffectUnit<{ id: string }> {72+ if (label !== undefined && !label.trim()) throw new Error("trace label must not be empty");73+ const name = label ? `trace:${label}` : "trace";74+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {75+ requireMutationRuntime().recordTrace?.(label ?? null, context);76+ };77+ return tablelessEffect(name, { onSynced: record, onRejected: record });78+}79+80+/**81+ * Observation unit: a development-only timeline entry for each fate an82+ * obligation settles, for eyeballing effect delivery in the inspector.83+ * Stripped from production builds — in a `PROD` bundle the handlers record84+ * nothing, so it costs a closure and no more.85+ *86+ * @example87+ * ```ts88+ * export const editNote = s.mutation("editNote", s.update(app.notes), {89+ * effects: [s.debug()],90+ * });91+ * ```92+ */93+export function debug(): EffectUnit<{ id: string }> {94+ const production = !import.meta.env.DEV;95+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {96+ if (production) return;97+ requireMutationRuntime().recordDebug?.(context.fate, context);98+ };99+ return tablelessEffect("debug", { onSynced: record, onRejected: record });100+}101+102+/** A notice message resolved from the settled row, or a static string. */103+export type NoticeResolver<Row> = string | ((row: EffectRow<Row>) => string);104+105+/** Per-fate notice configuration for {@link notice}. */106+export type NoticeConfig<Row> = {107+ /** The message (or resolver) enqueued when the write syncs. */108+ synced?: NoticeResolver<Row>;109+ /** The message (or resolver) enqueued when the write is rejected. */110+ rejected?: NoticeResolver<Row>;111+ /**112+ * Lifespan of an enqueued entry before the durable queue retires it. Omit113+ * for the queue default; pass `null` to keep the entry until dismissed.114+ */115+ ttlMs?: number | null;116+};117+118+function resolveNotice<Row>(resolver: NoticeResolver<Row>, row: EffectRow<Row>): string {119+ return typeof resolver === "function" ? resolver(row) : resolver;120+}121+122+/**123+ * Data-internal unit: enqueues a durable, user-visible message when the write124+ * settles — the fix for the "a rejected write still flashed success" failure125+ * mode. The queue is durable and UI-agnostic: an entry created at a boot126+ * re-arm survives with nothing mounted, and a component (the built-in notices127+ * surface, or an author's) renders it. Toasts are a userland wrapper over the128+ * queue, never an imperative call from here.129+ *130+ * Idempotent by the obligation's journal id: a re-delivered handler enqueues131+ * the same keyed entry once, so a crash-and-replay shows one message.132+ *133+ * @example134+ * ```ts135+ * export const publish = s.mutation("publish", s.update(app.posts), {136+ * effects: [s.notice<Post>({137+ * synced: "Published.",138+ * rejected: (post) => `Could not publish "${post.title}".`,139+ * })],140+ * });141+ * ```142+ */143+export function notice<Row extends { id: string } = { id: string }>(144+ config: NoticeConfig<Row>,145+): EffectUnit<Row> {146+ if (config.synced === undefined && config.rejected === undefined) {147+ throw new Error("notice must configure at least one of synced or rejected");148+ }149+ const ttlMs = config.ttlMs;150+ const enqueue = (151+ resolver: NoticeResolver<Row> | undefined,152+ tone: NoticeInput["tone"],153+ ) =>154+ (row: EffectRow<{ id: string }>, context: EffectContext) => {155+ if (resolver === undefined) return;156+ const runtime = requireMutationRuntime();157+ runtime.enqueueNotice?.(158+ { message: resolveNotice(resolver, row as EffectRow<Row>), tone, ttlMs: ttlMs ?? null },159+ context,160+ );161+ };162+ return tablelessEffect(anonymousEffectName("notice"), {163+ onSynced: enqueue(config.synced, "success"),164+ onRejected: enqueue(config.rejected, "error"),165+ }) as EffectUnit<Row>;166+}167+168+/** Per-fate row patches for {@link mark}. */169+export type MarkConfig<Init> = {170+ /** The patch applied to the row when the write syncs. */171+ synced?: Partial<Init>;172+ /** The patch applied to the row when the write is rejected. */173+ rejected?: Partial<Init>;174+};175+176+/**177+ * Data-internal unit: patches the written row when its fate resolves, so write178+ * fate becomes replicated data every device and query sees — the hand-rolled179+ * `status` column made declarative. The patch is absolute (a static180+ * set-column-to-value object), so it is convergent under re-delivery: applying181+ * it twice lands the same row.182+ *183+ * A rejected *insert* has no row to mark — the engine rolled it out — so the184+ * rejected patch is skipped for inserts; on updates and removes the row185+ * survives the rollback and the patch records the failure.186+ *187+ * @example188+ * ```ts189+ * export const submit = s.mutation("submit", s.update(app.claims), {190+ * effects: [s.mark(app.claims, {191+ * synced: { status: "confirmed" },192+ * rejected: { status: "failed" },193+ * })],194+ * });195+ * ```196+ */197+export function mark<T extends { id: string }, Init>(198+ table: TableProxy<T, Init>,199+ config: MarkConfig<Init>,200+): EffectUnit<T> {201+ if (config.synced === undefined && config.rejected === undefined) {202+ throw new Error("mark must configure at least one of synced or rejected");203+ }204+ const apply = async (205+ patch: Partial<Init> | undefined,206+ row: EffectRow<T>,207+ skipForInsertOp: boolean,208+ context: EffectContext,209+ ) => {210+ if (patch === undefined) return;211+ // A rejected insert left no row behind; patching a vanished id would only212+ // fail the obligation into a pointless retry.213+ if (skipForInsertOp && context.op === "insert") return;214+ await requireMutationRuntime().applyMark?.(215+ table as TableProxy<unknown, unknown>,216+ row.id,217+ patch as Record<string, unknown>,218+ );219+ };220+ return effect(anonymousEffectName("mark"), table, {221+ onSynced: (row, context) => apply(config.synced, row, false, context),222+ onRejected: (row, context) => apply(config.rejected, row, true, context),223+ });224+}225+226+/**227+ * Data-internal unit: issues a follow-up verb once the write syncs, mapping228+ * the settled row to the next verb's input. Reifies a chain of writes229+ * (reserve → charge → fulfill) declaratively, without a saga API — each link230+ * is an ordinary verb, so its own effects and rejection handling apply. Fires231+ * only on `synced`; a rejected write starts no chain.232+ *233+ * The follow-up is itself journaled and idempotent by its own write id. This234+ * unit's obligation is marked delivered once the next verb is durably saved,235+ * so a crash between links re-issues from the last settled row.236+ *237+ * @example238+ * ```ts239+ * export const reserve = s.mutation("reserve", s.insert(app.holds), {240+ * effects: [s.chain(charge, (hold) => ({ holdId: hold.id, amount: hold.total }))],241+ * });242+ * ```243+ */244+export function chain<Row extends { id: string }, NextInput>(245+ next: (input: NextInput) => WriteHandle<unknown>,246+ toInput: (row: EffectRow<Row>) => NextInput,247+): EffectUnit<Row> {248+ return tablelessEffect(anonymousEffectName("chain"), {249+ onSynced: async (row) => {250+ // Await local durability of the follow-up: only then is this link safe251+ // to mark delivered, so a crash re-issues rather than dropping it.252+ await next(toInput(row as EffectRow<Row>)).saved;253+ },254+ }) as EffectUnit<Row>;255+}256+257+/** Options for the {@link webhook} unit. */258+export type WebhookOptions = {259+ /** Which fates POST; defaults to both. */260+ on?: ReadonlyArray<"synced" | "rejected">;261+ /**262+ * Delivery window in milliseconds. Receiver idempotency windows are finite263+ * (Stripe forgets keys after ~24h), so external delivery defaults to a264+ * conservative 24 hours rather than infinity; pass `null` to opt into no265+ * expiry.266+ *267+ * @default 86_400_000268+ */269+ expiresAfterMs?: number | null;270+ /** Failing attempts before the obligation is quarantined. */271+ maxAttempts?: number;272+ /** Extra headers merged onto the POST (the idempotency key is always set). */273+ headers?: Record<string, string>;274+};275+276+const defaultWebhookExpiry = 24 * 60 * 60 * 1000;277+278+/**279+ * External unit: POSTs the settled row and its fate to `url`, with the280+ * obligation's journal id auto-injected as `Idempotency-Key` so a re-delivery281+ * the receiver already saw is dropped receiver-side. The generic282+ * outside-world workhorse and the reference for the at-least-once contract.283+ *284+ * Failure severity follows the response: a transient failure (network error,285+ * `5xx`, `429`) throws an ordinary error, so the ledger's bounded backoff286+ * retries it; a `4xx` (other than `429`) throws {@link PermanentEffectError},287+ * retiring the obligation without burning the retry budget on a request the288+ * receiver will keep refusing. Because receiver keys expire, delivery defaults289+ * to a 24-hour window ({@link WebhookOptions.expiresAfterMs}).290+ *291+ * @example292+ * ```ts293+ * export const order = s.mutation("order", s.insert(app.orders), {294+ * effects: [s.webhook("https://hooks.example.com/orders")],295+ * });296+ * ```297+ */298+export function webhook(url: string, options: WebhookOptions = {}): EffectUnit<{ id: string }> {299+ if (!url.trim()) throw new Error("webhook url must not be empty");300+ const fates = new Set(options.on ?? ["synced", "rejected"]);301+ const post = async (row: EffectRow<{ id: string }>, context: EffectContext) => {302+ if (!fates.has(context.fate)) return;303+ let response: Response;304+ try {305+ response = await fetch(url, {306+ method: "POST",307+ headers: {308+ "content-type": "application/json",309+ "idempotency-key": context.journalId,310+ ...options.headers,311+ },312+ body: JSON.stringify({313+ journalId: context.journalId,314+ verb: context.verb,315+ table: context.table,316+ op: context.op,317+ fate: context.fate,318+ code: context.code,319+ reason: context.reason,320+ row,321+ }),322+ });323+ } catch (error) {324+ // A network-level failure is transient by nature: rethrow so the ledger325+ // retries with backoff inside the delivery window.326+ throw new Error(`webhook ${url} unreachable: ${(error as Error).message}`);327+ }328+ if (response.ok) return;329+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {330+ throw new PermanentEffectError(`webhook ${url} refused with ${response.status}`);331+ }332+ throw new Error(`webhook ${url} transient failure ${response.status}`);333+ };334+ return tablelessEffect("webhook:" + url, { onSynced: post, onRejected: post }, {335+ expiresAfterMs: options.expiresAfterMs === undefined336+ ? defaultWebhookExpiry337+ : options.expiresAfterMs ?? undefined,338+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),339+ });340+}
A package/schema/effect_library_test.ts +245 -0
@@ -0,0 +1,245 @@
1+import { schema } from "jazz-tools";2+import { assert, assertCount } from "../runtime/test-assert.ts";3+import type { WriteHandle } from "../runtime/write-handle.ts";4+import { chain, mark, notice, trace, webhook } from "./effect-library.ts";5+import {6+ clearEffectDeclarations,7+ effect,8+ type EffectContext,9+ type MutationRuntime,10+ type NoticeInput,11+ setMutationRuntime,12+} from "./effects.ts";13+14+const app = schema.defineApp({15+ claims: schema.table({ title: schema.string(), status: schema.string() }),16+});17+type Claim = schema.RowOf<typeof app.claims>;18+19+type Recorder = {20+ traces: Array<{ label: string | null; context: EffectContext }>;21+ notices: Array<{ input: NoticeInput; context: EffectContext }>;22+ marks: Array<{ rowId: string; patch: Record<string, unknown> }>;23+ dispatched: Array<{ descriptor: unknown; args: readonly unknown[] }>;24+};25+26+function install(): Recorder {27+ const recorder: Recorder = { traces: [], notices: [], marks: [], dispatched: [] };28+ const runtime: MutationRuntime = {29+ dispatch(descriptor, args) {30+ recorder.dispatched.push({ descriptor, args });31+ return { saved: Promise.resolve() } as unknown as WriteHandle<unknown>;32+ },33+ recordLog() {},34+ recordTrace(label, context) {35+ recorder.traces.push({ label, context });36+ },37+ enqueueNotice(input, context) {38+ recorder.notices.push({ input, context });39+ },40+ applyMark(_table, rowId, patch) {41+ recorder.marks.push({ rowId, patch });42+ return Promise.resolve();43+ },44+ };45+ setMutationRuntime(runtime);46+ return recorder;47+}48+49+function context(overrides: Partial<EffectContext> = {}): EffectContext {50+ return {51+ journalId: "w1:unit#1",52+ writeId: "w1",53+ verb: "submit",54+ table: "claims",55+ op: "update",56+ rowId: "row-1",57+ writeCreatedAt: 500,58+ fate: "synced",59+ cause: null,60+ code: null,61+ reason: null,62+ ...overrides,63+ };64+}65+66+Deno.test("trace records a span with the saved-to-fate latency on either fate", () => {67+ clearEffectDeclarations();68+ const recorder = install();69+ const unit = trace("checkout");70+ unit.handlers.onSynced?.({ id: "row-1" }, context({ writeCreatedAt: 500 }));71+ unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));72+ assertCount(recorder.traces.length, 2, "both fates must record a span");73+ assert(recorder.traces[0].label === "checkout", "the span must carry the author label");74+ clearEffectDeclarations();75+});76+77+Deno.test("notice enqueues a message keyed by the obligation journal id", () => {78+ clearEffectDeclarations();79+ const recorder = install();80+ const unit = notice<Claim>({81+ synced: "Submitted.",82+ rejected: (claim) => `Could not submit "${claim.title ?? "claim"}".`,83+ });84+ unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:notice#1" }));85+ unit.handlers.onRejected?.(86+ { id: "row-1", title: "roof" } as Claim & { id: string },87+ context({ fate: "rejected", journalId: "w2:notice#1" }),88+ );89+ assertCount(recorder.notices.length, 2, "each fate with a message enqueues one notice");90+ assert(91+ recorder.notices[0].input.message === "Submitted.",92+ "the static message must pass through",93+ );94+ assert(recorder.notices[0].input.tone === "success", "a synced notice is a success tone");95+ assert(96+ recorder.notices[1].input.message === 'Could not submit "roof".',97+ "the resolver must see the settled row",98+ );99+ assert(recorder.notices[1].input.tone === "error", "a rejected notice is an error tone");100+ clearEffectDeclarations();101+});102+103+Deno.test("notice fires only the configured fate", () => {104+ clearEffectDeclarations();105+ const recorder = install();106+ const unit = notice({ rejected: "Failed." });107+ unit.handlers.onSynced?.({ id: "row-1" }, context());108+ assertCount(recorder.notices.length, 0, "an unconfigured synced fate enqueues nothing");109+ unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));110+ assertCount(recorder.notices.length, 1, "the configured rejected fate enqueues");111+ clearEffectDeclarations();112+});113+114+Deno.test("mark patches the row on synced and on a rejected update", async () => {115+ clearEffectDeclarations();116+ const recorder = install();117+ const unit = mark(app.claims, {118+ synced: { status: "confirmed" },119+ rejected: { status: "failed" },120+ });121+ await unit.handlers.onSynced?.({ id: "row-1" }, context({ op: "update" }));122+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ op: "update", fate: "rejected" }));123+ assertCount(recorder.marks.length, 2, "both fates patch the surviving row");124+ assert(recorder.marks[0].patch.status === "confirmed", "synced must apply its patch");125+ assert(recorder.marks[1].patch.status === "failed", "a rejected update must apply its patch");126+ clearEffectDeclarations();127+});128+129+Deno.test("mark skips a rejected insert, which left no row to patch", async () => {130+ clearEffectDeclarations();131+ const recorder = install();132+ const unit = mark(app.claims, { rejected: { status: "failed" } });133+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ op: "insert", fate: "rejected" }));134+ assertCount(recorder.marks.length, 0, "a rolled-back insert has no row, so mark must skip it");135+ clearEffectDeclarations();136+});137+138+Deno.test("chain issues the follow-up verb only on synced", async () => {139+ clearEffectDeclarations();140+ install();141+ const calls: Array<{ holdId: string }> = [];142+ const charge = (input: { holdId: string }): WriteHandle<unknown> => {143+ calls.push(input);144+ return { saved: Promise.resolve() } as unknown as WriteHandle<unknown>;145+ };146+ const unit = chain(charge, (row: { id: string }) => ({ holdId: row.id }));147+ await unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));148+ assertCount(calls.length, 0, "a rejected write starts no chain");149+ await unit.handlers.onSynced?.({ id: "row-1" }, context());150+ assertCount(calls.length, 1, "a synced write issues the follow-up verb once");151+ assert(calls[0].holdId === "row-1", "the mapper must receive the settled row");152+ clearEffectDeclarations();153+});154+155+Deno.test("webhook injects the journal id as the idempotency key and posts the fate", async () => {156+ clearEffectDeclarations();157+ install();158+ const requests: Array<{ url: string; init: RequestInit }> = [];159+ const originalFetch = globalThis.fetch;160+ globalThis.fetch = ((url: string | URL, init?: RequestInit) => {161+ requests.push({ url: String(url), init: init ?? {} });162+ return Promise.resolve(new Response(null, { status: 200 }));163+ }) as typeof fetch;164+ try {165+ const unit = webhook("https://hooks.example.com/claims");166+ await unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:webhook#1" }));167+ assertCount(requests.length, 1, "a synced write posts once");168+ const key = new Headers(requests[0].init.headers as HeadersInit).get("idempotency-key") ?? "";169+ assert(key === "w1:webhook#1", "the journal id must ride as the idempotency key");170+ const body = JSON.parse(String(requests[0].init.body));171+ assert(body.fate === "synced", "the payload must carry the settled fate");172+ } finally {173+ globalThis.fetch = originalFetch;174+ }175+ clearEffectDeclarations();176+});177+178+Deno.test("webhook treats a 4xx as permanent and a 5xx as retryable", async () => {179+ clearEffectDeclarations();180+ install();181+ const originalFetch = globalThis.fetch;182+ const respond = (status: number) => {183+ globalThis.fetch = (() => Promise.resolve(new Response(null, { status }))) as typeof fetch;184+ };185+ try {186+ const unit = webhook("https://hooks.example.com/claims");187+ respond(400);188+ let permanent = false;189+ try {190+ await unit.handlers.onSynced?.({ id: "row-1" }, context());191+ } catch (error) {192+ permanent = (error as Error).name === "PermanentEffectError";193+ }194+ assert(permanent, "a 4xx must throw a PermanentEffectError so the ledger retires it");195+196+ respond(503);197+ let retryable = false;198+ try {199+ await unit.handlers.onSynced?.({ id: "row-1" }, context());200+ } catch (error) {201+ retryable = (error as Error).name !== "PermanentEffectError";202+ }203+ assert(retryable, "a 5xx must throw an ordinary error so the ledger retries it");204+ } finally {205+ globalThis.fetch = originalFetch;206+ }207+ clearEffectDeclarations();208+});209+210+// The contract's proof: a custom unit written with nothing but the public211+// authoring surface — s.effect, the context's journal id as the idempotency212+// key, and PermanentEffectError for a hopeless failure — behaves like a213+// built-in. This is also the authoring guide's worked example.214+Deno.test("a custom unit on the public contract is idempotent by journal id", async () => {215+ clearEffectDeclarations();216+ install();217+ const seen = new Set<string>();218+ let sends = 0;219+ const sendOnce = effect("sendReceipt", app.claims, {220+ onSynced: (_row, context) => {221+ // The at-least-once duty: dedupe on the journal id so a re-delivered222+ // handler produces one external effect.223+ if (seen.has(context.journalId)) return;224+ seen.add(context.journalId);225+ sends += 1;226+ },227+ });228+ await sendOnce.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:sendReceipt" }));229+ await sendOnce.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:sendReceipt" }));230+ assertCount(sends, 1, "a re-delivery under one journal id must produce one effect");231+ clearEffectDeclarations();232+});233+234+Deno.test("webhook defaults to a finite 24-hour delivery window", () => {235+ clearEffectDeclarations();236+ install();237+ const unit = webhook("https://hooks.example.com/claims");238+ assert(unit.expiresAfterMs === 24 * 60 * 60 * 1000, "external delivery must default to 24h");239+ const forever = webhook("https://hooks.example.com/claims2", { expiresAfterMs: null });240+ assert(241+ forever.expiresAfterMs === null || forever.expiresAfterMs === undefined,242+ "null must opt into no expiry",243+ );244+ clearEffectDeclarations();245+});
M package/schema/effects.ts +101 -0
@@ -49,8 +49,12 @@ export type EffectContext = {
4949 verb: string | null;5050 /** The written table's name. */5151 table: string;52+ /** Which operation the write performed. */53+ op: "insert" | "update" | "remove";5254 /** The written row's id. */5355 rowId: string;56+ /** Epoch milliseconds when the write was journaled, for latency spans. */57+ writeCreatedAt: number;5458 /** Which fate settled the write. */5559 fate: "synced" | "rejected";5660 /**
@@ -68,6 +72,43 @@ export type EffectContext = {
6872 reason: string | null;6973 };7074 75+/**76+ * Thrown from an effect handler to retire the obligation immediately instead77+ * of re-arming it. Delivery is at-least-once by default: an ordinary thrown78+ * error is *retryable* — the handler re-runs at the next boot until it79+ * succeeds or {@link EffectUnitOptions.maxAttempts} quarantines it. Some80+ * failures are known to be *permanent* — a webhook receiver answered `400`, a81+ * row a handler needed is gone for good — and retrying only burns attempts and82+ * delays the quarantine diagnostic. Throwing this retires the obligation now,83+ * counted as a permanent handler failure. The message reaches diagnostics.84+ *85+ * @example86+ * ```ts87+ * s.effect("charge", app.orders, {88+ * onSynced: async (order, { journalId }) => {89+ * const res = await fetch(url, { headers: { "Idempotency-Key": journalId } });90+ * if (res.status >= 400 && res.status < 500) {91+ * throw new PermanentEffectError(`charge refused: ${res.status}`);92+ * }93+ * if (!res.ok) throw new Error(`charge transient failure: ${res.status}`);94+ * },95+ * });96+ * ```97+ */98+export class PermanentEffectError extends Error {99+ /** Stable class name for diagnostics and the ledger's severity check. */100+ override readonly name = "PermanentEffectError";101+ /** Creates the permanent-failure signal with a diagnostics message. */102+ constructor(message: string) {103+ super(message);104+ }105+}106+107+/** True when a handler failure asked to retire rather than retry. */108+export function isPermanentEffectError(error: unknown): error is PermanentEffectError {109+ return error instanceof PermanentEffectError;110+}111+71112 /** The action and compensation handlers one effect unit pairs. */72113 export type EffectHandlers<Row> = {73114 /** Runs on the originating device once the store confirms the write. */
@@ -206,12 +247,44 @@ export type MutationDescriptor = {
206247 readonly expiresAfterMs: number | null;207248 };208249 250+/**251+ * One durable notice a {@link notice} unit enqueues. The queue is persistent252+ * and UI-agnostic: entries may be created at a boot re-arm with nothing253+ * mounted, and a component renders them later. `tone` classifies the message254+ * for the render; `ttlMs` bounds its life when the author sets no explicit255+ * dismissal.256+ */257+export type NoticeInput = {258+ /** The user-facing message. */259+ message: string;260+ /** How to classify the message for rendering. */261+ tone: "info" | "success" | "warning" | "error";262+ /** Lifespan in milliseconds before the queue retires the entry, or `null`. */263+ ttlMs: number | null;264+};265+209266 /** The runtime half installed by the package runtime before verbs are called. */210267 export type MutationRuntime = {211268 /** Performs one declared verb call and returns its write handle. */212269 dispatch(descriptor: MutationDescriptor, args: readonly unknown[]): WriteHandle<unknown>;213270 /** Records one structured {@link log} entry. */214271 recordLog(label: string, context: EffectContext): void;272+ /** Records one {@link trace} span from the write's journaling to its fate. */273+ recordTrace?(label: string | null, context: EffectContext): void;274+ /** Records one {@link debug} timeline event; a no-op in production builds. */275+ recordDebug?(event: string, context: EffectContext): void;276+ /** Enqueues one durable {@link notice} entry. */277+ enqueueNotice?(input: NoticeInput, context: EffectContext): void;278+ /**279+ * Patches a row on behalf of a {@link mark} unit: a bare update carrying no280+ * further units, so write fate becomes replicated row data without281+ * recursion. Resolves at local durability; rejects if the row is gone.282+ */283+ applyMark?(284+ table: TableProxy<unknown, unknown>,285+ rowId: string,286+ patch: Record<string, unknown>,287+ ): Promise<void>;215288 /** Re-attempts outstanding journal obligations after a late unit registration. */216289 unitRegistered?(name: string): void;217290 };
@@ -221,6 +294,8 @@ type EffectsSlot = {
221294 effects: Map<string, EffectUnit<{ id: string }>>;222295 verbs: Map<string, MutationDescriptor>;223296 logs: Map<string, EffectUnit<{ id: string }>>;297+ /** Monotonic counter naming anonymous built-in units in declaration order. */298+ anonSequence: number;224299 };225300 226301 const slotName = "__LOFI_EFFECT_DECLARATIONS__";
@@ -232,6 +307,7 @@ function slot(): EffectsSlot {
232307 effects: new Map(),233308 verbs: new Map(),234309 logs: new Map(),310+ anonSequence: 0,235311 };236312 return effectsGlobal[slotName];237313 }
@@ -256,6 +332,7 @@ export function clearEffectDeclarations(): void {
256332 state.effects.clear();257333 state.verbs.clear();258334 state.logs.clear();335+ state.anonSequence = 0;259336 }260337 261338 // During dev hot replacement author modules re-evaluate routinely; the newest
@@ -365,6 +442,30 @@ function requireRuntime(): MutationRuntime {
365442 return runtime;366443 }367444 445+/**446+ * The installed runtime, or a thrown explanation when none is. The built-in447+ * effect library ({@link EffectUnit} factories in `effect-library.ts`) reaches448+ * the runtime through this so it stays on the public authoring surface — the449+ * same requirement custom units must meet.450+ */451+export function requireMutationRuntime(): MutationRuntime {452+ return requireRuntime();453+}454+455+/**456+ * A stable-per-declaration name for an anonymous built-in unit (a notice,457+ * mark, or chain the author did not name). The journal re-arms by name, and458+ * these carry no author name, so their durable identity is declaration order459+ * within one module-evaluation — deterministic across reloads for an unchanged460+ * bundle. Reordering the verbs that declare them orphans in-flight obligations461+ * the same way renaming a named unit does.462+ */463+export function anonymousEffectName(prefix: string): string {464+ const state = slot();465+ state.anonSequence = (state.anonSequence ?? 0) + 1;466+ return `${prefix}#${state.anonSequence}`;467+}468+368469 /**369470 * Declares a typed, callable verb: a named mutation over one table operation,370471 * carrying its effect units. Call sites invoke the verb like a function and
M package/schema/effects_test.ts +2 -0
M package/schema/mod.ts +28 -0
@@ -30,6 +30,7 @@
3030 */3131 import { schema } from "jazz-tools";3232 import { effect, insert, log, mutation, remove, update } from "./effects.ts";33+import { chain, debug, mark, notice, trace, webhook } from "./effect-library.ts";3334 import {3435 encryptedDate,3536 encryptedJson,
@@ -100,6 +101,12 @@ export type SchemaDsl =
100101 mutation: typeof mutation;101102 effect: typeof effect;102103 log: typeof log;104+ trace: typeof trace;105+ debug: typeof debug;106+ notice: typeof notice;107+ mark: typeof mark;108+ chain: typeof chain;109+ webhook: typeof webhook;103110 insert: typeof insert;104111 update: typeof update;105112 remove: typeof remove;
@@ -150,6 +157,12 @@ export const s: SchemaDsl = {
150157 mutation,151158 effect,152159 log,160+ trace,161+ debug,162+ notice,163+ mark,164+ chain,165+ webhook,153166 insert,154167 update,155168 remove,
@@ -211,17 +224,32 @@ export {
211224 type EffectUnitOptions,212225 insert,213226 type InsertVerb,227+ isPermanentEffectError,214228 log,215229 mutation,216230 type MutationOp,217231 type MutationOpKind,218232 type MutationOptions,219233 type MutationVerb,234+ type NoticeInput,235+ PermanentEffectError,220236 remove,221237 type RemoveVerb,222238 update,223239 type UpdateVerb,224240 } from "./effects.ts";241+export {242+ chain,243+ debug,244+ mark,245+ type MarkConfig,246+ notice,247+ type NoticeConfig,248+ type NoticeResolver,249+ trace,250+ webhook,251+ type WebhookOptions,252+} from "./effect-library.ts";225253 // Type-only, so the authoring-only module graph stays free of runtime code:226254 // verb calls settle through the runtime write handle, and these names let227255 // schema-graph modules annotate that contract without importing the runtime.
M package/starter/src/islands/use-tasks.ts.txt +1 -1
M package/testdata/starter.snapshot.json +1 -1
M package/testing/mod.ts +1 -0