Skip to content
Merged
9 changes: 2 additions & 7 deletions apps/demo/overlay/src/islands/IncidentBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "preact/hooks";
import { settleUiMutation } from "@nzip/lofi";
import {
type BootProgress,
Notices,
useBootProgress,
usePendingWrites,
useSyncStatus,
Expand All @@ -10,7 +11,6 @@ import {
type Incident,
type IncidentStatus,
type Severity,
useIncidentNotice,
useIncidents,
} from "./use-incidents.ts";

Expand Down Expand Up @@ -51,7 +51,6 @@ function openedLabel(value: Incident["openedAt"]): string {
*/
export default function IncidentBoard() {
const { status, error, durability, incidents, failureKind, report, setStatus } = useIncidents();
const notice = useIncidentNotice();
const pending = usePendingWrites();
const boot = useBootProgress();
const [title, setTitle] = useState("");
Expand Down Expand Up @@ -107,11 +106,7 @@ export default function IncidentBoard() {
{pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync
</p>
)}
{notice && (
<p class="state" role="status" data-notice={notice.kind}>
{notice.text}
</p>
)}
<Notices label="Incident notifications" />
<div class="columns">
{COLUMNS.map((column) => {
const rows = incidents.filter((incident) => incident.status === column.status);
Expand Down
52 changes: 11 additions & 41 deletions apps/demo/overlay/src/islands/use-incidents.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { useCallback, useState } from "preact/hooks";
import type { RowOf, WriteHandle } from "@nzip/lofi";
import { useLiveQuery, useWrite } from "@nzip/lofi/preact";
import { s } from "@nzip/lofi/schema";
Expand All @@ -18,57 +18,27 @@ export type Incident = RowOf<typeof incidentsTable>;
export type Severity = Incident["severity"];
export type IncidentStatus = Incident["status"];

/** A one-line consequence or compensation surfaced to the UI. */
export type IncidentNotice = { kind: "synced" | "rejected"; text: string };

// A tiny author-owned notice channel: effect handlers run outside any
// component, so they publish through module state and hooks subscribe.
let notice: IncidentNotice | null = null;
const noticeListeners = new Set<() => void>();

function publishNotice(next: IncidentNotice | null): void {
notice = next;
for (const listener of [...noticeListeners]) listener();
}

/**
* The reporting verb. Its effect units are declared once, here: the
* consequence runs when the store confirms the row, the compensation runs if
* a stale-policy write is denied, even if the app restarted in between.
*/
export const reportIncident = s.mutation("reportIncident", s.insert(incidentsTable), {
effects: [s.log("incident-reported")],
onSynced: (incident) => {
publishNotice({
kind: "synced",
text: `"${incident.title ?? "Incident"}" confirmed by the store`,
});
},
onRejected: (incident) => {
// The engine already rolled the denied row back out of local reads; this
// compensates what the user was told.
publishNotice({
kind: "rejected",
text: `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,
});
},
effects: [
s.log("incident-reported"),
s.notice<Incident>({
synced: (incident) => `"${incident.title ?? "Incident"}" confirmed by the store`,
// The engine already rolled a denied insert out of local reads; this
// durable notice compensates what the user was told, even after reload.
rejected: (incident) =>
`"${incident.title ?? "Incident"}" was declined by the store and has been removed`,
}),
],
});

/** Moving an incident between states is a plain verb: same lifecycle. */
export const setIncidentStatus = s.mutation("setIncidentStatus", s.update(incidentsTable));

/** Subscribes to the latest effect notice; `null` until one is published. */
export function useIncidentNotice(): IncidentNotice | null {
const [current, setCurrent] = useState<IncidentNotice | null>(notice);
useEffect(() => {
const listener = () => setCurrent(notice);
noticeListeners.add(listener);
listener();
return () => void noticeListeners.delete(listener);
}, []);
return current;
}

export function useIncidents() {
const query = useLiveQuery(() => incidentsTable.orderBy("openedAt", "desc"), []);
const [lastWrite, setLastWrite] = useState<WriteHandle<Incident> | null>(null);
Expand Down
2 changes: 1 addition & 1 deletion apps/reference/src/islands/AccountGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function describe(error: unknown): string {
if (isAuthError(error)) {
switch (error.code) {
case "cancelled":
return "Passkey prompt dismissed — your recovery phrase was not shown.";
return "Passkey verification did not complete — your recovery phrase was not shown.";
case "unsupported":
return "This browser does not support passkeys.";
default:
Expand Down
10 changes: 3 additions & 7 deletions apps/reference/src/islands/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { useState } from "preact/hooks";
import { settleUiMutation } from "@nzip/lofi";
import {
type BootProgress,
Notices,
useBootProgress,
usePendingWrites,
useSyncStatus,
} from "@nzip/lofi/preact";
import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";
import { type Task, useTasks } from "./use-tasks.ts";

// A cold first visit waits on the engine download, not on storage; name the
// wait it is actually in, with byte progress while the download runs.
Expand All @@ -26,7 +27,6 @@ function loadingLabel(boot: BootProgress): string {
*/
export default function TaskList() {
const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();
const notice = useTaskNotice();
const pending = usePendingWrites();
const boot = useBootProgress();
const [text, setText] = useState("");
Expand Down Expand Up @@ -78,11 +78,7 @@ export default function TaskList() {
{pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync
</p>
)}
{notice && (
<p class="state" role="status" data-notice={notice.kind}>
{notice.text}
</p>
)}
<Notices />
<ul aria-label="Tasks">
{tasks.map((task) => <TaskItem key={task.id} task={task} setCompleted={setCompleted} />)}
</ul>
Expand Down
49 changes: 11 additions & 38 deletions apps/reference/src/islands/use-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { useCallback, useState } from "preact/hooks";
import type { RowOf, WriteHandle } from "@nzip/lofi";
import { useLiveQuery, useWrite } from "@nzip/lofi/preact";
import { s } from "@nzip/lofi/schema";
Expand All @@ -17,54 +17,27 @@ const tasksTable = app.schema.tasks;
/** The row type comes straight from the declared schema. */
export type Task = RowOf<typeof tasksTable>;

/** A one-line consequence or compensation surfaced to the UI. */
export type TaskNotice = { kind: "synced" | "rejected"; text: string };

// A tiny author-owned notice channel: effect handlers run outside any
// component, so they publish through module state and hooks subscribe.
let notice: TaskNotice | null = null;
const noticeListeners = new Set<() => void>();

function publishNotice(next: TaskNotice | null): void {
notice = next;
for (const listener of [...noticeListeners]) listener();
}

/**
* The verb call sites use. Its effect units are declared once, here: the
* consequence runs when the store confirms the task, the compensation runs if
* a stale-policy write is denied — even if the app restarted in between.
*/
export const addTask = s.mutation("addTask", s.insert(tasksTable), {
effects: [s.log("task-added")],
onSynced: (task) => {
publishNotice({ kind: "synced", text: `"${task.text ?? "Task"}" synced to your account` });
},
onRejected: (task) => {
// The engine already rolled the denied row back out of local reads; this
// compensates what the user was told.
publishNotice({
kind: "rejected",
text: `"${task.text ?? "Task"}" was declined by the store and has been removed`,
});
},
effects: [
s.log("task-added"),
s.trace("task-added"),
s.notice<Task>({
synced: (task) => `"${task.text ?? "Task"}" synced to your account`,
// The engine already rolled a denied insert out of local reads; this
// durable notice compensates what the user was told, even after reload.
rejected: (task) => `"${task.text ?? "Task"}" was declined by the store and has been removed`,
}),
],
});

/** Toggling completion is a plain verb: no consequences, same lifecycle. */
export const setTaskCompleted = s.mutation("setTaskCompleted", s.update(tasksTable));

/** Subscribes to the latest effect notice; `null` until one is published. */
export function useTaskNotice(): TaskNotice | null {
const [current, setCurrent] = useState<TaskNotice | null>(notice);
useEffect(() => {
const listener = () => setCurrent(notice);
noticeListeners.add(listener);
listener();
return () => void noticeListeners.delete(listener);
}, []);
return current;
}

export function useTasks() {
const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);
const [lastWrite, setLastWrite] = useState<WriteHandle<Task> | null>(null);
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ shortest product overview and command summary. AI agents can ingest these docs a
- [Direct sharing](examples/shared.md)
- [Fixed-role group](examples/group.md)
- [Policy conditions on typed columns](examples/policy-conditions.md)
- [Nouns and verbs](nouns-and-verbs.md) — declaring verbs, effect units, and the write lifecycle
- [The effect library](effects.md) — built-in effect units and the custom-unit authoring contract
- Data modeling examples — how to shape tables and columns
- [Collaborative list data model](examples/collaborative-list.md)
- [Collaborative sets](examples/collaborative-sets.md)
Expand Down
163 changes: 163 additions & 0 deletions docs/effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# The effect library

Effect units give a verb consequences: a handler that runs on the originating device when the
write's fate settles. [Nouns and verbs](nouns-and-verbs.md) covers the machinery — declaration, the
journal, at-least-once delivery, retention. This page is the built-in library and the contract for
authoring your own.

The teaching path runs one new idea at a time: declare a verb, **observe** it (`s.log`, `s.trace`),
make its fate **data** (`s.notice`, `s.mark`), **compose** further writes (`s.chain`), then reach
the **outside world** (`s.webhook`). Every built-in is idempotent by construction, so each is also a
worked example of the discipline your own units must follow.

## The authoring contract

A custom unit is `s.effect(name, table, handlers, options)`. Four rules make it safe:

- **The name is a durable identity.** The journal re-arms a unit's handlers by name after a reload,
so names are app-unique (a duplicate throws at declaration) and renaming a unit orphans its
in-flight obligations. The content-named built-ins (`s.log`, `s.trace`, `s.debug`, `s.webhook`)
share one unit per identity, so reusing one across verbs aggregates rather than collides. The
anonymous built-ins (`s.notice`, `s.mark`, `s.chain`) are named `<verb>#<position>` from the verb
they are attached to and their slot in that verb's `effects` — a durable identity independent of
which module loads first, so the only way to orphan one is to reorder the effects within its own
verb.
- **Delivery is at-least-once, so handlers must be idempotent.** A crash between handler start and
journal completion re-runs the handler at the next boot. The `context.journalId` a handler
receives — the write's `(write id, effect name)` key — is the idempotency key: pass it to any
external call, and dedupe your own side effects on it.
- **Failure has two severities.** An ordinary thrown error is _retryable_: the obligation re-arms at
the next boot until it succeeds or `maxAttempts` (default 5) quarantines it. Throwing
`PermanentEffectError` is _permanent_: the obligation retires immediately, without burning the
retry budget on a call that will keep failing. Both are counted in runtime diagnostics; a failed
handler is never silently swallowed.
- **`onRejected` compensates ancillary state, never row data.** The engine rolls a rejected write
out of local query results itself. After a reload a rejected handler receives the row id alone, so
compensate what the user was told or what you called externally — not the row.

A unit written to these rules behaves like a built-in. This is the smallest one:

```ts
import { PermanentEffectError, s } from "@nzip/lofi/schema";

const sent = new Set<string>();
export const sendReceipt = s.effect("sendReceipt", app.schema.orders, {
onSynced: async (order, { journalId }) => {
if (sent.has(journalId)) return; // at-least-once: dedupe on the key
const res = await fetch(receiptUrl, { headers: { "Idempotency-Key": journalId } });
if (res.status >= 400 && res.status < 500) {
throw new PermanentEffectError(`receipt refused: ${res.status}`); // do not retry
}
if (!res.ok) throw new Error(`receipt transient failure: ${res.status}`); // retry
sent.add(journalId);
},
});
```

## Observation tier

Cannot change anything — they only record.

### `s.log(label)`

Records a structured diagnostics entry (write id, fate, timing) on either fate. Repeated calls with
one label share a unit. See [nouns and verbs](nouns-and-verbs.md#effect-units).

### `s.trace(label?)`

A span from the write's journaling to its settled fate, recorded in runtime diagnostics as an
OpenTelemetry-shaped event with the saved→synced/rejected latency. Pure instrumentation and the
observability hook; an OTLP exporter is an adapter over this feed, never a concept you configure per
verb. Without a label the verb name labels the span.

```ts
export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {
effects: [s.trace("checkout")],
});
```

### `s.debug()`

A development-only timeline of each fate an obligation settles, for eyeballing delivery in the
inspector. Stripped from production builds — in a `PROD` bundle the handlers record nothing.

## Data-internal tier

Write back into the app. Fate stops being a callback and becomes replicated data.

### `s.notice(config)`

Enqueues a durable, user-visible message when the write settles — the fix for the "a rejected write
still flashed success" failure mode. The queue is durable and UI-agnostic: an entry created at a
boot re-arm survives with nothing mounted, and a component renders it later. Render with the
built-in `<Notices />` or the `useNotices()` hook from `@nzip/lofi/preact`; a toast stack is a
userland wrapper over the same queue, never an imperative call. Idempotent by the obligation's
journal id, so a re-delivery enqueues one entry. `ttlMs` bounds an entry's life; pass `null` to keep
it until dismissed.

```ts
export const publish = s.mutation("publish", s.update(app.schema.posts), {
effects: [s.notice<Post>({
synced: "Published.",
rejected: (post) => `Could not publish "${post.title}".`,
})],
});
```

### `s.mark(table, config)`

Patches the written row when its fate resolves, so write fate becomes replicated data every device
and query sees — the hand-rolled `status` column made declarative. The patch is absolute (a static
set-column-to-value object), so it is convergent under re-delivery. A rejected _insert_ has no row
to mark — the engine rolled it out — so the rejected patch is skipped for inserts; on updates and
removes the row survives the rollback.

```ts
export const submit = s.mutation("submit", s.update(app.schema.claims), {
effects: [s.mark(app.schema.claims, {
synced: { status: "confirmed" },
rejected: { status: "failed" },
})],
});
```

### `s.chain(verb, toInput)`

Issues a follow-up verb once the write syncs, mapping the settled row to the next verb's input.
Reifies a chain of writes (reserve → charge → fulfill) declaratively without a saga API — each link
is an ordinary verb with its own effects and rejection handling. Fires only on `synced`.

```ts
export const reserve = s.mutation("reserve", s.insert(app.schema.holds), {
effects: [s.chain(charge, (hold) => ({ holdId: hold.id, amount: hold.total }))],
});
```

## External tier

Where the guardrails earn their keep.

### `s.webhook(name, url, options?)`

POSTs the settled row and its fate to `url`, with the obligation's journal id auto-injected as
`Idempotency-Key` so a re-delivery the receiver already saw is dropped receiver-side. The generic
integration workhorse and the reference for the at-least-once contract.

- **`name` is the durable identity.** Keep it stable across releases. URLs and header values are
checked only in memory and never become journal keys, so credentials do not leak into persisted
effect identifiers. Reusing a name with different configuration fails fast.
- **Failure severity follows the response.** A network error, `5xx`, or `429` throws an ordinary
error, so the ledger's bounded backoff retries it; any other `4xx` throws `PermanentEffectError`,
retiring the obligation rather than retrying a request the receiver will keep refusing.
- **Delivery defaults to a finite window.** Receiver idempotency windows are finite (Stripe forgets
keys after ~24h), so external delivery defaults to a 24-hour `expiresAfterMs` rather than
infinity; pass `null` to opt into no expiry.

```ts
export const order = s.mutation("order", s.insert(app.schema.orders), {
effects: [s.webhook("orders", "https://hooks.example.com/orders")],
});
```

Provider-specific units (email, SMS, push) are userland wrappers over `s.webhook`; the library ships
the workhorse, not the integrations.
Loading
Loading