clone

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

feat/141-demo-review-fixes
lofi / docs / nouns-and-verbs.md
nouns-and-verbs.md 10.3K rendered
1# Nouns and verbs23A lofi application has two kinds of declarations. Tables are the nouns: they say what exists.4Policies attach to tables because a policy must be safe to evaluate on every replica that holds the5data. Mutations are the verbs: they say what happens. Consequences attach to verbs because a6consequence needs an owner — the one device that performed the intent. The API keeps the name7`s.table`; this grammar is how to think about where things belong, not a set of identifiers.89The one-sentence contract: **an effect runs after the write syncs, even if the app restarts in10between; if the write is rejected, `onRejected` runs instead.**1112```mermaid13flowchart LR14 Table["Noun: s.table"] --> Policy["Policies (every replica)"]15 Verb["Verb: s.mutation"] --> Effects["Effect units (originating device)"]16 Verb --> Handle["WriteHandle"]17 Handle --> UI["useWrite / usePendingWrites / useSyncStatus"]18```1920## The write lifecycle2122Every write moves through a closed, framework-owned lifecycle. Stages are monotonic — a write never23moves backwards:2425| stage | meaning |26| ---------- | --------------------------------------------------------------------------------- |27| `saving` | accepted by the runtime, not yet durable |28| `saved` | durable on this device; `await write` resolves here |29| `syncing` | in transit to the store (appears only when the runtime can observe the transport) |30| `synced` | the store confirmed the write |31| `rejected` | the store adjudicated the write and denied it |3233`await write` resolves at `saved` — the same local-durability contract every mutation in lofi keeps.34The UI clears the form and moves on. Sync is a property you reach for, not what `await` gives you:3536```ts37const write = placeOrder({ item, qty }); // WriteHandle<Order>38const order = await write; // saved: durable on this device39await write.synced; // confirmed by the store40write.stage; // current stage, always readable41write.reason; // { code, reason } when rejected42```4344The current runtime moves writes from `saved` directly to `synced`; the `syncing` stage and an45offline reason appear once the runtime can observe its connection. On a device with no sync location46at all there is no store to adjudicate, so local durability is settlement: the write reads `synced`47and effects run once it is durable on the device.4849## Declaring verbs5051A verb is a named mutation over one table operation, declared once with `s.mutation`. Call sites52invoke it like a function and carry nothing:5354```ts55import { s } from "@nzip/lofi/schema";56import { app } from "../app.ts";5758const chargeCard = s.effect("chargeCard", app.schema.orders, {59 onSynced: async (order, { journalId }) => {60 // The world accepted the order. Pass journalId to external APIs61 // as the idempotency key.62 },63 onRejected: async (order) => {64 // The order never happened: compensate what the user saw.65 },66});6768export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {69 effects: [chargeCard, s.log("order-placed")],70});71```7273`s.insert(table)`, `s.update(table)`, and `s.remove(table)` declare which operation the verb74performs; the verb's call signature follows from it. Inline `onSynced`/`onRejected` options are75sugar for an implicit single unit named after the verb.7677Naming: tables are plural nouns (`orders`, `tasks`), verbs are imperative verb phrases78(`placeOrder`, not `orderInsert`). Verb and effect names are durable identities — the journal uses79them to re-arm handlers after a reload — and must be unique per app; duplicate declarations fail80fast.8182## Effect units8384An effect unit is a named, reusable pairing of action and compensation. Effects live on verbs: never85per call (forgettable, inconsistent across call sites) and never per table (tables carry no intent,86and data-attached effects would have no owning replica).8788- Handlers run on the originating device only. The journal is local to the device that performed the89 intent; other replicas sync the data and run nothing.90- Units are independent. Each is journaled and retried on its own; one unit's failure never blocks91 another, and there are no ordering guarantees between units of one write. Anything order-dependent92 composes by a unit issuing a further write.93- There is no `onSaved`. Ordinary code after `await write` covers that moment.94- Delivery is at-least-once. A crash between handler start and journal completion re-runs the95 handler at the next boot. Handlers that call external services must be idempotent: pass the96 `journalId` the handler receives — the write's `(write id, effect name)` key — as the idempotency97 key to payment-style APIs.98- A failed handler is surfaced in runtime diagnostics and re-armed at the next boot, never silently99 swallowed.100101`s.log(label)` is a built-in unit that records a structured entry in runtime diagnostics on either102fate. Repeated calls with one label share one unit. It is the first of a tiered built-in library —103observe (`s.log`, `s.trace`), make fate data (`s.notice`, `s.mark`), compose (`s.chain`), reach the104outside world (`s.webhook`) — with the contract for authoring your own. See105[the effect library](effects.md).106107## Retention: durable things know how to die108109Obligations, intents, and residue each have a lifespan and a defined afterlife.110111- **Obligations** — `s.effect(name, table, handlers, { expiresAfterMs })` declares a delivery window112 in milliseconds, measured from the write. When the write synced but the obligation could not be113 delivered inside the window (device offline, handler failing), the obligation retires as expired:114 no handler fires — the write happened, so compensation would be wrong — diagnostics count it, and115 the entry becomes prunable. External-tier handlers whose receivers keep finite idempotency windows116 should always set this.117- **Intents** — `s.mutation(name, op, { expiresAfterMs })` declares the intent's lifespan. A write118 still pending past it is surfaced: the pending-writes set marks it `expired` and diagnostics count119 it. The runtime cannot withdraw a locally accepted write — the engine re-proposes pending batches120 until the store answers, and a rolled-back batch primitive exists only for uncommitted batches —121 so an overdue intent is reported, never retired: retiring it as rejected could fire compensation122 for a write that later syncs anyway. When store-side expiry enforcement lands, an overdue intent123 will settle as `rejected` with cause `expired`; the cause type already carries that value so the124 surface is forward-compatible.125- **Residue** — a handler that keeps failing is quarantined after a bounded number of attempts126 (`maxAttempts`, default 5): the obligation retires as failed-permanent, diagnostics count it, and127 the entry becomes prunable instead of re-arming forever.128129The two expirations are distinct on purpose: an expired _intent_ is a write that never happened130(compensation would apply, once it can be sound); an expired _obligation_ is a write that happened131whose side effect missed its window (compensation would be wrong).132133### What the journal stores134135The journal persists write identity, fate, and per-column keyed hashes (HMAC-SHA-256 under a random136per-journal key) — enough for the boot equality probe, never the column values themselves. This137prevents a second greppable plaintext copy of row data beside the store. It does not protect138low-entropy values against brute force by an attacker holding the whole storage; that attacker's139access already equals the local store's own at-rest posture.140141## Rejection is a verdict, not an error142143Two very different things can say no:144145- **Local refusal.** The runtime refuses a write that violates the policy this device currently146 holds. The refusal throws synchronously from the verb call — ordinary error handling — and never147 creates a stage, a journal entry, or an effect. Nothing was shown to the user, so there is nothing148 to compensate.149- **Adjudicated rejection.** A write accepted locally under a policy that had already tightened at150 the store is denied when it arrives. The write settles as `rejected`, `write.synced` rejects, and151 `onRejected` runs — once — on the originating device. Compensation gates on the store's permanent152 verdicts only; an unknown or transient code keeps the write pending.153154The engine rolls a rejected write back out of local query results itself: the optimistic row155disappears, a rejected patch reverts. `onRejected` therefore compensates what the user was told —156notices, external calls, follow-up writes — not the row data.157158## Pending state in the UI159160The journal persists every unsettled write beside the runtime's storage, readable at boot before161sync connects, so pending state survives a reload:162163```tsx164import { usePendingWrites, useSyncStatus, useWrite } from "@nzip/lofi/preact";165166const pending = usePendingWrites(); // { count, writes } — "N changes waiting to sync"167const status = useSyncStatus(task); // "synced" | "waiting" | "rejected" per row168const { stage, reason } = useWrite(write); // one write's live stage169```170171All three surfaces are level-triggered: they report current truth rather than events, so a component172that mounts late sees the same state as one that watched every transition.173174## API reference175176- `WriteHandle<T>` — thenable per-write handle: `saved`, `synced`, `stage`, `reason`, `subscribe`.177 Returned by verbs and by `useTableMutations` methods.178- `s.mutation(name, op, options?)` — declares a typed callable verb; `options.effects` attaches179 units, inline `onSynced`/`onRejected` declare an implicit unit.180- `s.effect(name, table, handlers)` — declares a named, reusable, typed effect unit.181- `s.log(label)` — built-in unit recording a diagnostics entry.182- `useWrite(write)` — `{ stage, reason }` with re-render on change.183- `usePendingWrites()` — the reload-safe pending set.184- `useSyncStatus(row)` — per-row badge state.185186Generated API pages for these exports live in the [API reference](/api).187188## See also189190- [Data and UI](data-and-ui.md) — tables, queries, and the domain-hook pattern.191- [Permissions](permissions.md) — the policies that attach to nouns.192- [Sync and recovery](sync-and-recovery.md) — how a device elects sync and recovers an account.