1# The effect library23Effect units give a verb consequences: a handler that runs on the originating device when the4write's fate settles. [Nouns and verbs](nouns-and-verbs.md) covers the machinery — declaration, the5journal, at-least-once delivery, retention. This page is the built-in library and the contract for6authoring your own.78The teaching path runs one new idea at a time: declare a verb, **observe** it (`s.log`, `s.trace`),9make its fate **data** (`s.notice`, `s.mark`), **compose** further writes (`s.chain`), then reach10the **outside world** (`s.webhook`). Every built-in is idempotent by construction, so each is also a11worked example of the discipline your own units must follow.1213## The authoring contract1415A custom unit is `s.effect(name, table, handlers, options)`. Four rules make it safe:1617- **The name is a durable identity.** The journal re-arms a unit's handlers by name after a reload,18 so names are app-unique (a duplicate throws at declaration) and renaming a unit orphans its19 in-flight obligations. The content-named built-ins (`s.log`, `s.trace`, `s.debug`, `s.webhook`)20 share one unit per identity, so reusing one across verbs aggregates rather than collides. The21 anonymous built-ins (`s.notice`, `s.mark`, `s.chain`) are named `<verb>#<position>` from the verb22 they are attached to and their slot in that verb's `effects` — a durable identity independent of23 which module loads first, so the only way to orphan one is to reorder the effects within its own24 verb.25- **Delivery is at-least-once, so handlers must be idempotent.** A crash between handler start and26 journal completion re-runs the handler at the next boot. The `context.journalId` a handler27 receives — the write's `(write id, effect name)` key — is the idempotency key: pass it to any28 external call, and dedupe your own side effects on it.29- **Failure has two severities.** An ordinary thrown error is _retryable_: the obligation re-arms at30 the next boot until it succeeds or `maxAttempts` (default 5) quarantines it. Throwing31 `PermanentEffectError` is _permanent_: the obligation retires immediately, without burning the32 retry budget on a call that will keep failing. Both are counted in runtime diagnostics; a failed33 handler is never silently swallowed.34- **`onRejected` compensates ancillary state, never row data.** The engine rolls a rejected write35 out of local query results itself. After a reload a rejected handler receives the row id alone, so36 compensate what the user was told or what you called externally — not the row.3738A unit written to these rules behaves like a built-in. This is the smallest one:3940```ts41import { PermanentEffectError, s } from "@nzip/lofi/schema";4243const sent = new Set<string>();44export const sendReceipt = s.effect("sendReceipt", app.schema.orders, {45 onSynced: async (order, { journalId }) => {46 if (sent.has(journalId)) return; // at-least-once: dedupe on the key47 const res = await fetch(receiptUrl, { headers: { "Idempotency-Key": journalId } });48 if (res.status >= 400 && res.status < 500) {49 throw new PermanentEffectError(`receipt refused: ${res.status}`); // do not retry50 }51 if (!res.ok) throw new Error(`receipt transient failure: ${res.status}`); // retry52 sent.add(journalId);53 },54});55```5657## Observation tier5859Cannot change anything — they only record.6061### `s.log(label)`6263Records a structured diagnostics entry (write id, fate, timing) on either fate. Repeated calls with64one label share a unit. See [nouns and verbs](nouns-and-verbs.md#effect-units).6566### `s.trace(label?)`6768A span from the write's journaling to its settled fate, recorded in runtime diagnostics as an69OpenTelemetry-shaped event with the saved→synced/rejected latency. Pure instrumentation and the70observability hook; an OTLP exporter is an adapter over this feed, never a concept you configure per71verb. Without a label the verb name labels the span.7273```ts74export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {75 effects: [s.trace("checkout")],76});77```7879### `s.debug()`8081A development-only timeline of each fate an obligation settles, for eyeballing delivery in the82inspector. Stripped from production builds — in a `PROD` bundle the handlers record nothing.8384## Data-internal tier8586Write back into the app. Fate stops being a callback and becomes replicated data.8788### `s.notice(config)`8990Enqueues a durable, user-visible message when the write settles — the fix for the "a rejected write91still flashed success" failure mode. The queue is durable and UI-agnostic: an entry created at a92boot re-arm survives with nothing mounted, and a component renders it later. Render with the93built-in `<Notices />` or the `useNotices()` hook from `@nzip/lofi/preact`; a toast stack is a94userland wrapper over the same queue, never an imperative call. Idempotent by the obligation's95journal id, so a re-delivery enqueues one entry. `ttlMs` bounds an entry's life; pass `null` to keep96it until dismissed.9798```ts99export const publish = s.mutation("publish", s.update(app.schema.posts), {100 effects: [s.notice<Post>({101 synced: "Published.",102 rejected: (post) => `Could not publish "${post.title}".`,103 })],104});105```106107### `s.mark(table, config)`108109Patches the written row when its fate resolves, so write fate becomes replicated data every device110and query sees — the hand-rolled `status` column made declarative. The patch is absolute (a static111set-column-to-value object), so it is convergent under re-delivery. A rejected _insert_ has no row112to mark — the engine rolled it out — so the rejected patch is skipped for inserts; on updates and113removes the row survives the rollback.114115```ts116export const submit = s.mutation("submit", s.update(app.schema.claims), {117 effects: [s.mark(app.schema.claims, {118 synced: { status: "confirmed" },119 rejected: { status: "failed" },120 })],121});122```123124### `s.chain(verb, toInput)`125126Issues a follow-up verb once the write syncs, mapping the settled row to the next verb's input.127Reifies a chain of writes (reserve → charge → fulfill) declaratively without a saga API — each link128is an ordinary verb with its own effects and rejection handling. Fires only on `synced`.129130```ts131export const reserve = s.mutation("reserve", s.insert(app.schema.holds), {132 effects: [s.chain(charge, (hold) => ({ holdId: hold.id, amount: hold.total }))],133});134```135136## External tier137138Where the guardrails earn their keep.139140### `s.webhook(name, url, options?)`141142POSTs the settled row and its fate to `url`, with the obligation's journal id auto-injected as143`Idempotency-Key` so a re-delivery the receiver already saw is dropped receiver-side. The generic144integration workhorse and the reference for the at-least-once contract.145146- **`name` is the durable identity.** Keep it stable across releases. URLs and header values are147 checked only in memory and never become journal keys, so credentials do not leak into persisted148 effect identifiers. Reusing a name with different configuration fails fast.149- **Failure severity follows the response.** A network error, `5xx`, or `429` throws an ordinary150 error, so the ledger's bounded backoff retries it; any other `4xx` throws `PermanentEffectError`,151 retiring the obligation rather than retrying a request the receiver will keep refusing.152- **Delivery defaults to a finite window.** Receiver idempotency windows are finite (Stripe forgets153 keys after ~24h), so external delivery defaults to a 24-hour `expiresAfterMs` rather than154 infinity; pass `null` to opt into no expiry.155156```ts157export const order = s.mutation("order", s.insert(app.schema.orders), {158 effects: [s.webhook("orders", "https://hooks.example.com/orders")],159});160```161162Provider-specific units (email, SMS, push) are userland wrappers over `s.webhook`; the library ships163the workhorse, not the integrations.