clone

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

release: 0.7.0 — the effect system and the schema compatibility gate (#144)

FelineStateMachine committed 6b9dd96
* 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>
22 files changed, +317 -58
M.github/workflows/site.yml2+-
MREADME.md6++
Mapps/reference/deno.lock2+-
Mdeno.json4+-
Mpackage/create_core_test.ts8+-
Mpackage/preact/DeviceStatus.tsx14++---
Mpackage/preact/live-data.ts20+++++--
Mpackage/preact/mod.ts12+++-
Mpackage/preact/use-schema-compat.ts9+++-
Mpackage/preact/write-hooks.ts18+++++-
Mpackage/runtime/diagnostics.ts27+++++++++-
Mpackage/runtime/mod.ts9+++
Mpackage/runtime/pwa.ts83++++++++++++++++++++++++++++--
Mpackage/runtime/schema-compat.ts11++++-
Mpackage/runtime/table-mutations.ts8+++
Mpackage/runtime/table-store.ts9+++
Mpackage/runtime/upgrade-coordination.ts3+
Mpackage/runtime/write-handle.ts35+++++++++++--
Mpackage/schema/effects.ts82+++++++++++++++++++++++++----
Mpackage/schema/mod.ts9+++-
Mpackage/testdata/starter.snapshot.json2+-
Mpackage/version.ts2+-
M .github/workflows/site.yml +1 -1
M README.md +6 -0
M apps/reference/deno.lock +1 -1
M deno.json +2 -2
M package/create_core_test.ts +4 -4
M package/preact/DeviceStatus.tsx +6 -8
M package/preact/live-data.ts +15 -5
@@ -87,18 +87,28 @@ export type TableMutations<T extends TableRow, Init> = TableMutationSnapshot & {
8787 * `durability`, and `error` state.8888 *8989 * @example90- * ```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 * @param table The typed schema table to mutate, e.g. `app.schema.records`.101108 * @returns 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+ * {@link WriteHandle} with `useWrite`.102112 */103113 export function useTableMutations<T extends TableRow, Init>(104114 table: TableProxy<T, Init>,
M package/preact/mod.ts +8 -4
M package/preact/use-schema-compat.ts +8 -1
M package/preact/write-hooks.ts +14 -4
@@ -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
M package/runtime/diagnostics.ts +26 -1
@@ -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;
M package/runtime/mod.ts +9 -0
M package/runtime/pwa.ts +78 -5
@@ -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
M package/runtime/schema-compat.ts +10 -1
M package/runtime/table-mutations.ts +8 -0
M package/runtime/table-store.ts +9 -0
M package/runtime/upgrade-coordination.ts +3 -0
M package/runtime/write-handle.ts +30 -5
@@ -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 {@link 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+ * @example106+ * ```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;
M package/schema/effects.ts +70 -12
@@ -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+ * {@link EffectContext.journalId} as the idempotency key.287+ *253288 * @example254289 * ```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+ * @example328+ * ```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,
M package/schema/mod.ts +7 -2
M package/testdata/starter.snapshot.json +1 -1
M package/version.ts +1 -1