* release: 0.7.0 — the effect system and the schema compatibility gate * docs(site): pull the corrected node write-verdict taxonomy * docs(package): put the load-bearing contracts on the symbols
- commit
- 6b9dd9654eda0e8fa695299c044c2d6e7203a974
- parent
- c4aefb6c75bf35b0d90a7a9bec1987f6886aeec0
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | .github/ | 2 | +- |
| M | README.md | 6 | ++ |
| M | apps/ | 2 | +- |
| M | deno.json | 4 | +- |
| M | package/ | 8 | +- |
| M | package/ | 14 | ++--- |
| M | package/ | 20 | +++++-- |
| M | package/ | 12 | +++- |
| M | package/ | 9 | +++- |
| M | package/ | 18 | +++++- |
| M | package/ | 27 | +++++++++- |
| M | package/ | 9 | +++ |
| M | package/ | 83 | ++++++++++++++++++++++++++++-- |
| M | package/ | 11 | ++++- |
| M | package/ | 8 | +++ |
| M | package/ | 9 | +++ |
| M | package/ | 3 | + |
| M | package/ | 35 | +++++++++++-- |
| M | package/ | 82 | +++++++++++++++++++++++++---- |
| M | package/ | 9 | +++- |
| M | package/ | 2 | +- |
| M | package/ | 2 | +- |
@@ -24,7 +24,7 @@ permissions:
2424 # Bump procedure: point LOFI_NODE_REF at the new lofi-node commit SHA in the2525 # same PR that reviews the resulting /node pages.2626 env:27- LOFI_NODE_REF: 3efb3159ec58cb56fca6e91edf3dfad211f523d927+ LOFI_NODE_REF: 622044ff8522b36c8515fefb253a76e97b2b450f2828 2929 jobs:3030 build:@@ -46,6 +46,12 @@ replace the task example with your own schema, permissions, hook, and UI.
4646 24-word phrase.4747 - **Narrow collaboration templates** — private, direct-share, and fixed-role group access behind a4848 small [`@nzip/lofi/access`](https://lofi.host/api/access) API.49+- **Verbs with sync-boundary effects** — `s.mutation` declares named verbs whose writes return a50+ `WriteHandle`; `onSynced`/`onRejected` consequences run on the originating device from a durable51+ journal that survives reloads, with pending-write and per-row sync status hooks for the UI.52+- **A schema compatibility gate** — each build stamps the schema range it understands, so a stale53+ offline shell that meets newer data pauses editing (reads continue), shows the update path, and54+ coordinates the worker swap across tabs.4955 - **Local-first test helpers** — Playwright-backed two-client fixtures for offline writes and5056 convergence.5157 - **An explicit authoring boundary**: product code owns schema, permissions, config, islands,@@ -1,8 +1,8 @@
11 {22 "name": "@nzip/lofi",3- "version": "0.6.1",3+ "version": "0.7.0",44 "license": "MIT",5- "description": "A local-first Deno PWA framework with durable local data, recoverable accounts, optional Jazz sync, and private, direct-share, or group access templates.",5+ "description": "A local-first Deno PWA framework with durable local data, recoverable accounts, optional Jazz sync, mutations with sync-boundary effects, and private, direct-share, or group access templates.",66 "exports": {77 ".": "./package/runtime/mod.ts",88 "./astro": "./package/astro/mod.ts",@@ -99,9 +99,9 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
9999 );100100 }101101 const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));102- assertEquals(config.imports["@nzip/lofi"], "jsr:@nzip/lofi@0.6.1");103- assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.6.1/");104- assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.6.1/testing");102+ assertEquals(config.imports["@nzip/lofi"], "jsr:@nzip/lofi@0.7.0");103+ assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.7.0/");104+ assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.7.0/testing");105105 const lofiSpecifiers = Object.entries(config.imports)106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));@@ -110,7 +110,7 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
110110 `expected one package prefix, access, schema, two integrations, six commands, seven recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113- lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.6.1/")),113+ lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.7.0/")),114114 "generated lofi commands do not resolve through one exact package version",115115 );116116 const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];@@ -15,13 +15,6 @@ import { readSession, type Session } from "../runtime/session.ts";
1515 import { describeStoreStatus } from "../runtime/store-status.ts";1616 import { RuntimeRecovery } from "./RuntimeRecovery.tsx";1717 18-/**19- * The Device gate: every subsystem's live status, grouped by the system it20- * belongs to. It doubles as a map of where to hook in — each category names the21- * module that owns it, so a reading of the deployment is also a reading of the22- * framework's seams.23- */24-2518 // One label/value row inside a category's definition list.2619 function Row({ label, value }: { label: string; value: string }): JSX.Element {2720 return (@@ -35,7 +28,12 @@ function Row({ label, value }: { label: string; value: string }): JSX.Element {
3528 const available = (present: boolean) => (present ? "available" : "missing");3629 3730 /**38- * Renders live storage, sync, auth, and PWA capability diagnostics.31+ * Renders the Device gate: every subsystem's live status — storage, sync,32+ * auth, and PWA — grouped by the module that owns it, so the panel doubles33+ * as a map of where to hook in. Optional: it is built entirely on public34+ * runtime APIs (device capabilities, PWA state, runtime diagnostics, the35+ * session), so an application can replace it with its own status UI over the36+ * same sources.3937 *4038 * @example4139 * ```tsx@@ -87,18 +87,28 @@ export type TableMutations<T extends TableRow, Init> = TableMutationSnapshot & {
8787 * `durability`, and `error` state.8888 *8989 * 90- * ```ts90+ * ```tsx91+ * import { settleUiMutation } from "@nzip/lofi";9192 * import { useTableMutations } from "@nzip/lofi/preact";9293 * import { app } from "../app.ts";9394 *94- * const records = useTableMutations(app.schema.records);95- * const created = await records.insert({ title: "Release notes", archived: false });96- * await records.update(created.id, { archived: true });97- * await records.remove(created.id);95+ * function ArchiveButton({ id }: { id: string }) {96+ * const records = useTableMutations(app.schema.records);97+ * // The handle is thenable: settled at saved, while the sync fate and the98+ * // shared pending/durability state continue in the background.99+ * return (100+ * <button onClick={() => void settleUiMutation(records.update(id, { archived: true }))}>101+ * Archive {records.pending > 0 && "…"}102+ * </button>103+ * );104+ * }98105 * ```99106 *100107 * table The typed schema table to mutate, e.g. `app.schema.records`.101108 * Stable `insert`, `update`, and `remove` methods plus the shared mutation state.109+ * To attach declared effects to a write, wrap the same table in a verb110+ * (`s.mutation(name, s.insert(table), { … })`) and observe its111+ * { WriteHandle} with `useWrite`.102112 */103113 export function useTableMutations<T extends TableRow, Init>(104114 table: TableProxy<T, Init>,@@ -1,8 +1,12 @@
11 /**2- * Optional Preact bindings for lofi device capabilities and PWA controls.3- *4- * These components are package-owned examples that application layouts may5- * compose or replace with UI built on the same public runtime APIs.2+ * Optional Preact bindings for the lofi runtime: live typed queries3+ * ({@link useLiveQuery}), table mutations ({@link useTableMutations}), the4+ * per-write sync lifecycle ({@link useWrite}, {@link usePendingWrites},5+ * {@link useSyncStatus}), and the schema-compatibility gate6+ * ({@link useSchemaCompat}), plus package-owned example components7+ * ({@link DeviceStatus}, {@link PwaActions}, {@link RuntimeRecovery},8+ * {@link TicketEnrollForm}) that application layouts may compose or replace9+ * with UI built on the same public runtime APIs.610 *711 * @module812 */@@ -1,5 +1,12 @@
1+/**2+ * Preact binding for the schema-compatibility gate: the live compatibility3+ * state, for apps that render their own surface in place of the framework's4+ * default read-only banner.5+ *6+ * @module7+ */8+19 import { useEffect, useState } from "preact/hooks";2-// Package-owned optional Preact binding for the schema-compatibility gate.310 import {411 getSchemaCompatState,512 type SchemaCompatState,@@ -24,9 +24,12 @@ export type WriteState = {
2424 };2525 2626 /**27- * Observes one write's lifecycle. Pass the handle returned by a verb or a28- * table mutation; the component re-renders on each stage change and a29- * late-mounted component sees the current stage immediately.27+ * Observes one write's lifecycle. Pass the {@link WriteHandle} returned by a28+ * verb or a table mutation; the component re-renders on each29+ * {@link WriteStage} change, and because handles are level-triggered a30+ * component that mounts after a transition sees the current stage31+ * immediately — no missed events. `reason` carries the {@link WriteRejection}32+ * once a write settles as `rejected`.3033 *3134 * @example3235 * ```tsx@@ -60,6 +63,9 @@ export function useWrite<T>(write: WriteHandle<T> | null | undefined): WriteStat
6063 * const pending = usePendingWrites();6164 * return pending.count > 0 ? <p>{pending.count} change(s) waiting to sync</p> : null;6265 * ```66+ *67+ * @returns The current {@link PendingWritesSnapshot}: the count and the68+ * journaled writes still awaiting their sync fate, oldest first.6369 */6470 export function usePendingWrites(): PendingWritesSnapshot {6571 const [snapshot, setSnapshot] = useState<PendingWritesSnapshot>({ count: 0, writes: [] });@@ -75,7 +81,11 @@ export function usePendingWrites(): PendingWritesSnapshot {
7581 /**7682 * The sync status of one row for per-row badges: `waiting` while a journaled7783 * write touching the row has not settled, `rejected` when the row's latest78- * settled write was denied, and `synced` otherwise.84+ * settled write was denied, and `synced` otherwise. `waiting` is85+ * reload-safe — it derives from the durable journal. `rejected` outlives the86+ * pruned journal entry only for the current session: after a reload the87+ * badge is gone. For a durable rejection response, declare an `onRejected`88+ * effect on the verb instead of relying on the badge.7989 *8090 * @example8191 * ```tsx@@ -30,27 +30,52 @@ export function recordEffectLogEntry(
3030 }3131 3232 /**33-// Package-owned runtime diagnostics.3433 * Runtime-owned observability counters. These describe the framework's storage,3534 * subscription, and write machinery and never reference any application schema.35+ *36+ * For a device-status widget the load-bearing fields are37+ * {@link RuntimeDiagnostics.storeStatus}, {@link RuntimeDiagnostics.schemaCompat},38+ * `journaledPendingWrites` (the "N changes waiting to sync" count), and39+ * `lastWriteDurability`; the remaining counters exist for the development40+ * inspector and bug reports.3641 */3742 export type RuntimeDiagnostics = {43+ /** Persistent-storage posture: requested, granted with the driver open, or failed. */3844 storageState: "persistent-requested" | "persistent-driver-open" | "failed";45+ /** The boot failure the runtime is stuck on, or `null` when boot succeeded. */3946 startupFailure: RuntimeStartupFailure | null;47+ /** Whether the configured store answered; feeds connected/unreachable UI. */4048 storeStatus: RuntimeStoreStatus;49+ /** The schema gate's verdict; `data-ahead` means this device is read-only. */4150 schemaCompat: SchemaCompatState;51+ /** Storage clients opened since page load, runtime recreations included. */4252 clientsCreated: number;53+ /** Storage clients currently open; one for a healthy runtime. */4354 activeClients: number;55+ /** Mounted subscribers across live-query and table stores. */4456 activeConsumers: number;57+ /** Engine subscriptions currently open to feed those consumers. */4558 activeVendorSubscriptions: number;59+ /** Engine subscriptions opened since page load. */4660 totalVendorSubscriptions: number;61+ /** Engine mutation-error listeners currently attached. */4762 activeMutationListeners: number;63+ /** Engine mutation-error listeners attached since page load. */4864 totalMutationListeners: number;65+ /** Engine subscription teardowns performed since page load. */4966 unsubscribeCalls: number;67+ /** Writes whose local-durability wait completed. */5068 localWaitCalls: number;69+ /** Writes currently awaiting local durability. */5170 pendingLocalWrites: number;71+ /** Writes currently awaiting the store's confirmation. */5272 pendingGlobalWrites: number;73+ /**74+ * The most recent write's deepest durability: `local` is saved on this75+ * device, `global` is confirmed by the store.76+ */5377 lastWriteDurability: "none" | "local" | "global" | "failed";78+ /** Writes refused or rejected since boot, including guard refusals. */5479 mutationErrors: number;5580 /** Journaled writes that have not yet settled as synced or rejected. */5681 journaledPendingWrites: number;@@ -9,6 +9,15 @@
99 * { LofiRuntime.store} to bind declared Jazz tables to application UI, and1010 * add account backup only after managed sync is configured.1111 *12+ * Every mutation returns a { WriteHandle}: `await` it for local13+ * durability (`saved`), observe `synced`/`rejected` for the store's verdict,14+ * and read the reload-safe pending set through {@link getWriteLedger} or the15+ * `usePendingWrites` hook. Status UI reads {@link RuntimeDiagnostics} (via16+ * {@link getRuntimeDiagnostics}), the schema gate through17+ * {@link getSchemaCompatState} — `data-ahead` means this device is read-only18+ * until {@link applyPwaUpdate} lands a newer bundle — and install/update19+ * state through {@link getPwaState}.20+ *1221 * @module1322 */1423 @@ -1,6 +1,30 @@
11 /// <reference path="./env.d.ts" />2-// Package-owned PWA lifecycle.3-/** Service-worker lifecycle states exposed to application UI. */2+/**3+ * Package-owned PWA lifecycle: the browser install flow, production4+ * service-worker registration, staged updates discovered by bounded5+ * foreground checks, and the coordinated swap that activates a waiting6+ * worker and moves controlled tabs onto the new revision.7+ *8+ * Most applications consume the shared controller through the wrapper9+ * functions — {@link getPwaState}, {@link subscribePwaState},10+ * {@link requestPwaInstall}, {@link checkPwaUpdate}, {@link applyPwaUpdate} —11+ * or the Preact bindings in `@nzip/lofi/preact`; boot wiring registers the12+ * worker once through {@link registerProductionServiceWorker}.13+ *14+ * @module15+ */16+17+/**18+ * Service-worker lifecycle states exposed to application UI.19+ *20+ * - `development-disabled` — registration is skipped outside production21+ * builds.22+ * - `unsupported` — the environment offers no service-worker container.23+ * - `registering` — registration and first activation are in progress.24+ * - `ready` — an active worker controls the app and serves the offline shell.25+ * - `failed` — registration, installation, or precaching failed;26+ * {@link PwaState.failure} carries the cause.27+ */428 export type PwaWorkerState =529 | "development-disabled"630 | "unsupported"@@ -8,7 +32,20 @@ export type PwaWorkerState =
832 | "ready"933 | "failed";1034 11-/** Browser installation states exposed to application UI. */35+/**36+ * Browser installation states exposed to application UI.37+ *38+ * - `installed` — running standalone or the browser reports the app39+ * installed.40+ * - `available` — the browser offered an install prompt;41+ * {@link requestPwaInstall} opens it.42+ * - `prompting` — the deferred prompt is open.43+ * - `accepted` / `dismissed` — the prompt's outcome.44+ * - `manual-ios` — install via Share → Add to Home Screen; iOS exposes no45+ * prompt API.46+ * - `manual-browser` — install is available only through the browser menu.47+ * - `unsupported` — no secure context or no service-worker support.48+ */1249 export type PwaInstallState =1350 | "installed"1451 | "available"@@ -19,7 +56,19 @@ export type PwaInstallState =
1956 | "manual-browser"2057 | "unsupported";2158 22-/** Foreground update-check and waiting-worker states exposed to application UI. */59+/**60+ * Foreground update-check and waiting-worker states exposed to application61+ * UI.62+ *63+ * - `idle` — no check running and nothing staged.64+ * - `checking` — a bounded update check is in flight.65+ * - `installing` — a newly discovered worker is downloading and installing.66+ * - `ready` — a new worker is staged and waiting: show the update affordance67+ * and call {@link applyPwaUpdate}.68+ * - `applying` — covers the swap until controlled tabs reload.69+ * - `failed` — the check or installation failed; the cause is in70+ * {@link PwaState.failure}.71+ */2372 export type PwaUpdateState =2473 | "idle"2574 | "checking"@@ -123,13 +172,37 @@ export type PwaControllerDependencies = {
123172 readonly onStaleTab?: () => void;124173 };125174 126-/** Stateful controller for browser installation and service-worker updates. */175+/**176+ * Stateful controller for browser installation and service-worker updates.177+ * Most apps use the shared {@link pwaController} through the wrapper178+ * functions ({@link getPwaState}, {@link applyPwaUpdate}) or the Preact179+ * bindings.180+ */127181 export type PwaController = {182+ /** The current install, worker, and update state snapshot. */128183 getState(): PwaState;184+ /** Subscribes to state changes; the subscriber runs immediately with the current state. */129185 subscribe(subscriber: (state: PwaState) => void): () => void;186+ /**187+ * Opens the deferred browser install prompt and resolves with the outcome.188+ * When no prompt is available (already installed, iOS, unsupported) it189+ * resolves with the current install state unchanged.190+ */130191 requestInstall(): Promise<PwaInstallState>;192+ /**193+ * Runs a bounded update check. Resolves `true` when a check ran or was194+ * already in flight, `false` when checking is not possible right now (not195+ * registered, an update already staged, or rate-limited). The outcome196+ * lands in {@link PwaState.update}, not the return value.197+ */131198 checkForUpdate(): Promise<boolean>;199+ /**200+ * Activates the waiting worker when {@link PwaState.update} is `ready`;201+ * returns `false` when no update is staged. Controlled tabs then reload202+ * onto the new revision (see `staleTabBehavior` for the alternative).203+ */132204 applyUpdate(): boolean;205+ /** Registers the worker and starts watching; called once by boot wiring. */133206 initialize(): void;134207 };135208 @@ -55,7 +55,16 @@ const staleTabMessage =
5555 "A newer version of the app is now active in another tab. Reload this tab to continue editing.";5656 const updatingMessage = "Updating the app… editing resumes when the update finishes.";5757 58-/** Raised when a mutation is refused because the local data is ahead of the code. */58+/**59+ * Raised when a mutation is refused because the local data is ahead of the60+ * running code. It surfaces through the ordinary error path of whatever61+ * performed the write — a verb call's handle fails, a table62+ * `insert`/`update`/`delete` rejects — with no journal entry or effect;63+ * reads keep working. The remediation is updating the app: watch64+ * {@link SchemaCompatState} via `useSchemaCompat` or65+ * {@link subscribeSchemaCompat}, and offer `applyPwaUpdate` when the update66+ * is ready.67+ */5968 export class SchemaCompatibilityError extends Error {6069 /** Stable error class name for diagnostics and error boundaries. */6170 override readonly name = "SchemaCompatibilityError";@@ -8,8 +8,16 @@ import { getWriteLedger, type WriteLedger } from "./write-ledger.ts";
88 99 /** Observable state shared by every mutation consumer for one table. */1010 export type TableMutationSnapshot = {11+ /** Writes on this table still awaiting their sync fate. */1112 pending: number;13+ /**14+ * How far the latest write has travelled: `none` before any write settles,15+ * `local` once saved on this device (the resting state when sync is not16+ * configured), `global` once the store confirms it, `failed` when it was17+ * denied.18+ */1219 durability: "none" | "local" | "global" | "failed";20+ /** The latest table-scoped rejection message, or `null`. */1321 error: string | null;1422 };1523 @@ -22,9 +22,18 @@ export type TableHandle<T extends TableRow, Init> = TableProxy<T, Init> & QueryB
2222 2323 /** Reactive table state including rows and the last observed durability tier. */2424 export type TableSnapshot<T extends TableRow> = {25+ /** `ready` once the first result arrived; an empty `rows` is not `loading`. */2526 status: "loading" | "ready" | "error";27+ /** The current typed rows of the table. */2628 rows: T[];29+ /**30+ * How far the latest write has travelled: `none` before any write settles,31+ * `local` once saved on this device (the resting state when sync is not32+ * configured), `global` once the store confirms it, `failed` when it was33+ * denied.34+ */2735 durability: "none" | "local" | "global" | "failed";36+ /** The latest subscription or write error message, or `null`. */2837 error: string | null;2938 };3039 @@ -14,6 +14,9 @@
1414 * Locks the coordinator degrades to local-only pausing, and the swap still1515 * proceeds after a bounded quiescence timeout.1616 *17+ * Internal module: not part of the package export map. Applications reach18+ * this behavior through `applyUpdate()` and the `pwa` application config.19+ *1720 * 1821 */1922 @@ -13,9 +13,11 @@
1313 1414 /**1515 * The closed, framework-owned write lifecycle. Stages are monotonic:16- * `saving → saved → syncing → synced | rejected`. `syncing` appears only when17- * the runtime can observe the transport; a runtime without that signal moves18- * writes from `saved` directly to `synced`.16+ * `saving → saved → syncing → synced | rejected`. `syncing` is reserved for a17+ * runtime that can observe the transport; the current storage engine exposes18+ * no such signal, so today writes move from `saved` directly to `synced` or19+ * `rejected` and no handle ever reports `syncing`. Branch on `saved` vs20+ * settled, not on seeing `syncing`.1921 */2022 export type WriteStage = "saving" | "saved" | "syncing" | "synced" | "rejected";2123 @@ -23,7 +25,10 @@ export type WriteStage = "saving" | "saved" | "syncing" | "synced" | "rejected";
2325 export type WriteRejection = {2426 /**2527 * The structured cause: `denied` for a store verdict, `expired` for an26- * intent retired past its lifespan.28+ * intent retired past its lifespan. The current runtime never retires a29+ * locally accepted write, so every rejection today is `denied`; an overdue30+ * intent surfaces as `PendingWriteSummary.expired` instead. `expired`31+ * exists so handlers written now stay correct if retirement ships.2732 */2833 cause: "denied" | "expired";2934 /** The sync node's rejection code, or `null` when none was carried. */@@ -39,7 +44,13 @@ export type WriteRejection = {
3944 export class WriteRejectedError extends Error {4045 /** Stable error class name for diagnostics and error boundaries. */4146 override readonly name = "WriteRejectedError";42- /** The structured cause: `denied` or `expired`. */47+ /**48+ * The structured cause: `denied` or `expired`. The current runtime never49+ * retires a locally accepted write, so every rejection today is `denied`;50+ * an overdue intent surfaces as `PendingWriteSummary.expired` instead.51+ * `expired` exists so handlers written now stay correct if retirement52+ * ships.53+ */4354 readonly rejectionCause: "denied" | "expired";4455 /** The adjudicated rejection code, or `null` when none was carried. */4556 readonly code: string | null;@@ -85,6 +96,20 @@ function exposedPromise<T>(): { promise: Promise<T>; resolver: Resolver<T> } {
8596 * confirms the write and rejects with { WriteRejectedError} when the8697 * store denies it. `stage` and `reason` are current-state properties;8798 * `subscribe` notifies immediately and on every later change.99+ *100+ * On a device without managed sync there is no store to confirm anything:101+ * local durability is settlement, and the handle reaches `synced` as soon as102+ * it is `saved`. In Preact components, render a handle with `useWrite` and103+ * the app-wide pending set with `usePendingWrites`.104+ *105+ * 106+ * ```ts107+ * const write = placeOrder({ sku, quantity }); // a verb returns a WriteHandle108+ * const order = await write; // resolves at saved — safe to navigate109+ * write.synced.catch((error) => {110+ * if (error instanceof WriteRejectedError) showDenied(error.message);111+ * });112+ * ```88113 */89114 export class WriteHandle<T> implements PromiseLike<T> {90115 readonly #writeId: string;@@ -23,9 +23,14 @@ import type { TableProxy } from "jazz-tools";
2323 import type { WriteHandle } from "../runtime/write-handle.ts";2424 2525 /**26- * The row snapshot an effect handler receives: always the row id, plus the27- * journaled columns — the full row for inserts, the changed columns for28- * updates, and only the id for removes.26+ * The row an effect handler receives. In the session that performed the write27+ * it is the write's snapshot: the full row for inserts, the changed columns28+ * for updates, only the id for removes. After a reload the journal holds no29+ * column values: a `synced` handler receives the row fetched live from the30+ * store — the final merged state — and a `rejected` handler receives the id31+ * alone, because the engine rolled the row back and identity plus32+ * {@link EffectContext.cause} is all that remains. Treat every column except33+ * `id` as optional.2934 */3035 export type EffectRow<Row> = Partial<Row> & { id: string };3136 @@ -49,8 +54,11 @@ export type EffectContext = {
4954 /** Which fate settled the write. */5055 fate: "synced" | "rejected";5156 /**52- * Why a `rejected` write settled: `denied` for a store verdict, `expired`53- * for an intent retired past its lifespan; `null` on the `synced` fate.57+ * Why a `rejected` write settled: `denied` for a store verdict; `null` on58+ * the `synced` fate. `expired` is reserved for store-side expiry59+ * enforcement — at the pinned runtime an overdue intent is surfaced, never60+ * retired ({@link MutationOptions.expires}), so rejections today carry61+ * `denied`.5462 */5563 cause: "denied" | "expired" | null;5664 /** The adjudicated rejection code, on the `rejected` fate. */@@ -65,8 +73,11 @@ export type EffectHandlers<Row> = {
6573 onSynced?: (row: EffectRow<Row>, context: EffectContext) => void | Promise<void>;6674 /**6775 * Runs on the originating device when the store adjudicates the write and68- * denies it permanently. Compensates optimistic state that was shown to the69- * user; a synchronous local refusal never fires it.76+ * denies it permanently. The engine has already rolled the row out of local77+ * query results, so compensate what the user was told — notices, external78+ * calls, follow-up writes — not the row data. After a reload the payload is79+ * identity-only ({@link EffectRow}). A synchronous local refusal throws80+ * from the verb call instead and never fires this handler.7081 */7182 onRejected?: (row: EffectRow<Row>, context: EffectContext) => void | Promise<void>;7283 };@@ -84,7 +95,14 @@ export type EffectUnitOptions = {
8495 * whose receivers keep finite idempotency windows should set this.8596 */8697 expiresAfter?: number;87- /** Failing attempts before quarantine retires the obligation. Default 5. */98+ /**99+ * Failing handler attempts before quarantine: past this count the100+ * obligation retires as failed-permanent instead of re-arming at every101+ * boot. The retirement is counted in runtime diagnostics and the journal102+ * entry becomes prunable.103+ *104+ * @default 5105+ */88106 maxAttempts?: number;89107 };90108 @@ -115,17 +133,29 @@ export type MutationOp<T, Init, Kind extends MutationOpKind = MutationOpKind> =
115133 readonly table: TableProxy<T, Init>;116134 };117135 118-/** Declares that a verb inserts rows into `table`. */136+/**137+ * Declares that a verb inserts rows into `table`. A {@link mutation} declared138+ * over this op is called as `(values) => WriteHandle<Row>`; `await` resolves139+ * at `saved` with the created row.140+ */119141 export function insert<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "insert"> {120142 return { kind: "insert", table };121143 }122144 123-/** Declares that a verb updates rows of `table`. */145+/**146+ * Declares that a verb updates rows of `table`. A {@link mutation} declared147+ * over this op is called as `(id, patch) => WriteHandle<void>`; `await`148+ * resolves at `saved`.149+ */124150 export function update<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "update"> {125151 return { kind: "update", table };126152 }127153 128-/** Declares that a verb removes rows from `table`. */154+/**155+ * Declares that a verb removes rows from `table`. A {@link mutation} declared156+ * over this op is called as `(id) => WriteHandle<void>`; `await` resolves at157+ * `saved`.158+ */129159 export function remove<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "remove"> {130160 return { kind: "remove", table };131161 }@@ -250,6 +280,11 @@ function registerUnit(unit: EffectUnit<{ id: string }>): void {
250280 * a reload, so renaming a unit orphans obligations journaled under the old251281 * name. Names must be unique per app; a duplicate declaration throws.252282 *283+ * Handlers run once, on the originating device, with at-least-once delivery:284+ * a crash between handler start and journal completion re-runs the handler at285+ * the next boot, so handlers that call external services should pass286+ * { EffectContext.journalId} as the idempotency key.287+ *253288 * 254289 * ```ts255290 * const chargeCard = s.effect("chargeCard", app.schema.orders, {@@ -261,6 +296,10 @@ function registerUnit(unit: EffectUnit<{ id: string }>): void {
261296 * },262297 * });263298 * ```299+ *300+ * @throws Error when `name` is empty or already declared by a different unit;301+ * in dev hot replacement the newest declaration replaces its predecessor302+ * instead of throwing.264303 */265304 export function effect<T extends { id: string }, Init>(266305 name: string,@@ -284,6 +323,15 @@ export function effect<T extends { id: string }, Init>(
284323 * A built-in effect unit that records a structured entry in runtime285324 * diagnostics on either fate. Repeated calls with one label return the same286325 * unit, so a label can be shared by several verbs.326+ *327+ * 328+ * ```ts329+ * export const addTask = s.mutation("addTask", s.insert(app.schema.tasks), {330+ * effects: [s.log("task-writes")],331+ * });332+ * // Each addTask write records a "task-writes" diagnostics entry when it333+ * // syncs or is rejected.334+ * ```287335 */288336 export function log(label: string): EffectUnit<{ id: string }> {289337 if (!label.trim()) throw new Error("log label must not be empty");@@ -315,7 +363,7 @@ function requireRuntime(): MutationRuntime {
315363 /**316364 * Declares a typed, callable verb: a named mutation over one table operation,317365 * carrying its effect units. Call sites invoke the verb like a function and318- * receive a `WriteHandle`; `await` resolves at `saved`.366+ * receive a {@link WriteHandle}; `await` resolves at `saved`.319367 *320368 * Verb names are app-unique and durable — the journal attributes re-armed321369 * obligations through them — and read as imperative verb phrases@@ -332,6 +380,16 @@ function requireRuntime(): MutationRuntime {
332380 * await write; // saved: durable on this device333381 * await write.synced; // confirmed by the store334382 * ```383+ *384+ * @throws Error at declaration when `name` is empty, already declared385+ * (outside dev hot replacement, where the newest declaration replaces its386+ * predecessor under the same name), or when `options` list one effect unit387+ * twice.388+ * @throws Error from a call to the returned verb when the local policy389+ * refuses the write: the refusal is synchronous, creates no stage and no390+ * journal entry, and fires no effect — see {@link EffectHandlers.onRejected}391+ * for the adjudicated case. Calls also throw when the lofi runtime is not392+ * installed.335393 */336394 export function mutation<T extends { id: string }, Init, Kind extends MutationOpKind>(337395 name: string,@@ -88,8 +88,13 @@ export type SchemaDsl =
8888 /**8989 * The lofi schema surface. Use in `src/schema.ts` and `src/permissions.ts` in9090 * place of a raw `jazz-tools` import; Jazz member names and behavior are91- * identical to the pinned Jazz 2 DSL, and the nested-namespace members are92- * the lofi naming layer documented on {@link defineNestedApp}.91+ * identical to the pinned Jazz 2 DSL. The lofi-owned members: nested92+ * namespaces ({@link defineNestedApp}), sealed columns ({@link encryptedText},93+ * {@link encryptedJson}), and the verb grammar — {@link mutation} declares94+ * callable verbs over {@link insert}, {@link update}, and {@link remove},95+ * carrying {@link effect} units and {@link log} entries. Verb calls return a96+ * `WriteHandle`, observed in UI through `useWrite` and `usePendingWrites`97+ * from `@nzip/lofi/preact`.9398 */9499 export const s: SchemaDsl = {95100 ...schema,@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "05da5b9859589bed3c05fce89037113c743d6e0525a2e5bd13466e6d6ce410e2",4+ "deno.json": "ce5b9c8df2c45798ebc3e229abcb5c67f73804b9dfafaac70942bb8c45951c90",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",@@ -1,5 +1,5 @@
11 /** The single source version for generated @nzip/lofi references. */2-export const LOFI_VERSION = "0.6.1";2+export const LOFI_VERSION = "0.7.0";33 44 /** The exact JSR package prefix emitted into generated projects. */55 export const LOFI_PACKAGE_PREFIX = `jsr:@nzip/lofi@${LOFI_VERSION}/`;