1/// <reference path="../runtime/env.d.ts" />2/**3 * Author-declared verbs and effect units.4 *5 * Tables are the nouns of an application: they declare what exists, and6 * policies attach to them because a policy must be safe to evaluate on every7 * replica. Mutations are the verbs: they declare what happens, and8 * consequences attach to them because a consequence needs an owner — the9 * device that performed the intent. This module is the declarative half of10 * that grammar: {@link effect} pairs an action with its compensation under a11 * durable name, {@link mutation} binds effect units to a named verb once, at12 * declaration, so call sites carry nothing. The runtime half journals each13 * unit per write and fires handlers when the write's fate settles.14 *15 * This module stays authoring-only: it declares names, tables, and handlers,16 * and delegates every write to the runtime installed through17 * {@link setMutationRuntime}.18 *19 * @module20 */2122import type { TableProxy } from "jazz-tools";23import type { WriteHandle } from "../runtime/write-handle.ts";2425/**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.34 */35export type EffectRow<Row> = Partial<Row> & { id: string };3637/**38 * Delivery metadata passed to every effect handler. Delivery is39 * at-least-once: a crash between handler start and journal completion re-runs40 * the handler at the next boot, so handlers calling external services should41 * pass {@link EffectContext.journalId} as an idempotency key.42 */43export type EffectContext = {44 /** The `(write id, effect name)` idempotency key for this obligation. */45 journalId: string;46 /** The journaled write's stable id. */47 writeId: string;48 /** The declaring verb's name, or `null` for writes without a verb. */49 verb: string | null;50 /** The written table's name. */51 table: string;52 /** The written row's id. */53 rowId: string;54 /** Which fate settled the write. */55 fate: "synced" | "rejected";56 /**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.expiresAfterMs}), so rejections today61 * carry62 * `denied`.63 */64 cause: "denied" | "expired" | null;65 /** The adjudicated rejection code, on the `rejected` fate. */66 code: string | null;67 /** The adjudicated rejection reason, on the `rejected` fate. */68 reason: string | null;69};7071/** The action and compensation handlers one effect unit pairs. */72export type EffectHandlers<Row> = {73 /** Runs on the originating device once the store confirms the write. */74 onSynced?: (row: EffectRow<Row>, context: EffectContext) => void | Promise<void>;75 /**76 * Runs on the originating device when the store adjudicates the write and77 * denies it permanently. The engine has already rolled the row out of local78 * query results, so compensate what the user was told — notices, external79 * calls, follow-up writes — not the row data. After a reload the payload is80 * identity-only ({@link EffectRow}). A synchronous local refusal throws81 * from the verb call instead and never fires this handler.82 */83 onRejected?: (row: EffectRow<Row>, context: EffectContext) => void | Promise<void>;84};8586/**87 * Retention options for one effect unit: how long delivery may lag the write,88 * and how many failing attempts are made before quarantine.89 */90export type EffectUnitOptions = {91 /**92 * The delivery window in milliseconds, measured from the write. When the93 * write synced but this obligation could not be delivered inside the94 * window, the obligation retires as expired and no handler fires — the95 * write happened, so compensation would be wrong. External-tier handlers96 * whose receivers keep finite idempotency windows should set this.97 */98 expiresAfterMs?: number;99 /**100 * Failing handler attempts before quarantine: past this count the101 * obligation retires as failed-permanent instead of re-arming at every102 * boot. The retirement is counted in runtime diagnostics and the journal103 * entry becomes prunable.104 *105 * @default 5106 */107 maxAttempts?: number;108};109110/**111 * A named, reusable pairing of action and compensation. The name is the112 * durable identity the journal uses to re-arm handlers after a reload; a113 * mutation declares its units once, at the verb declaration.114 */115export type EffectUnit<Row = { id: string }> = {116 /** The app-unique durable name of this unit. */117 readonly effectName: string;118 /** The unit's handlers. */119 readonly handlers: EffectHandlers<Row>;120 /** The delivery window in milliseconds, or `null` for none. */121 readonly expiresAfterMs?: number | null;122 /** Failing attempts before quarantine retires the obligation. */123 readonly maxAttempts?: number;124};125126/** Which operation a {@link MutationOp} performs. */127export type MutationOpKind = "insert" | "update" | "remove";128129/** A table operation a verb is declared over; see {@link insert}, {@link update}, {@link remove}. */130export type MutationOp<T, Init, Kind extends MutationOpKind = MutationOpKind> = {131 /** The operation kind. */132 readonly kind: Kind;133 /** The declared table the verb writes. */134 readonly table: TableProxy<T, Init>;135};136137/**138 * Declares that a verb inserts rows into `table`. A {@link mutation} declared139 * over this op is called as `(values) => WriteHandle<Row>`; `await` resolves140 * at `saved` with the created row.141 */142export function insert<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "insert"> {143 return { kind: "insert", table };144}145146/**147 * Declares that a verb updates rows of `table`. A {@link mutation} declared148 * over this op is called as `(id, patch) => WriteHandle<void>`; `await`149 * resolves at `saved`.150 */151export function update<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "update"> {152 return { kind: "update", table };153}154155/**156 * Declares that a verb removes rows from `table`. A {@link mutation} declared157 * over this op is called as `(id) => WriteHandle<void>`; `await` resolves at158 * `saved`.159 */160export function remove<T, Init>(table: TableProxy<T, Init>): MutationOp<T, Init, "remove"> {161 return { kind: "remove", table };162}163164/** A callable verb declared over an insert operation. */165export type InsertVerb<T, Init> = (values: Init) => WriteHandle<T>;166/** A callable verb declared over an update operation. */167export type UpdateVerb<Init> = (id: string, patch: Partial<Init>) => WriteHandle<void>;168/** A callable verb declared over a remove operation. */169export type RemoveVerb = (id: string) => WriteHandle<void>;170171/** The callable shape of a declared verb, derived from its operation. */172export type MutationVerb<Op> = Op extends MutationOp<infer T, infer Init, "insert">173 ? InsertVerb<T, Init>174 : Op extends MutationOp<infer _T, infer Init, "update"> ? UpdateVerb<Init>175 : Op extends MutationOp<infer _T, infer _Init, "remove"> ? RemoveVerb176 : never;177178/** Options accepted by {@link mutation}: declared units plus inline sugar. */179export type MutationOptions<Row> = {180 /** The effect units this verb carries; declared once, reused by every call site. */181 effects?: readonly EffectUnit<Row>[];182 /** Inline sugar for an implicit single unit named after the verb. */183 onSynced?: EffectHandlers<Row>["onSynced"];184 /** Inline sugar for an implicit single unit named after the verb. */185 onRejected?: EffectHandlers<Row>["onRejected"];186 /**187 * The intent's lifespan in milliseconds. A write of this verb still pending188 * past its lifespan is surfaced through pending-writes state and189 * diagnostics. The engine offers no withdrawal for a locally accepted write190 * at the pinned runtime, so an overdue intent is reported, never retired:191 * retiring it as rejected could fire compensation for a write that later192 * syncs anyway.193 */194 expiresAfterMs?: number;195};196197/** The registered declaration the runtime dispatches for one verb. */198export type MutationDescriptor = {199 /** The verb's app-unique name. */200 readonly verbName: string;201 /** The operation the verb performs. */202 readonly op: MutationOp<unknown, unknown>;203 /** The verb's effect units, implicit unit included. */204 readonly units: readonly EffectUnit<{ id: string }>[];205 /** The intent's lifespan in milliseconds, or `null` for none. */206 readonly expiresAfterMs: number | null;207};208209/** The runtime half installed by the package runtime before verbs are called. */210export type MutationRuntime = {211 /** Performs one declared verb call and returns its write handle. */212 dispatch(descriptor: MutationDescriptor, args: readonly unknown[]): WriteHandle<unknown>;213 /** Records one structured {@link log} entry. */214 recordLog(label: string, context: EffectContext): void;215 /** Re-attempts outstanding journal obligations after a late unit registration. */216 unitRegistered?(name: string): void;217};218219type EffectsSlot = {220 runtime: MutationRuntime | null;221 effects: Map<string, EffectUnit<{ id: string }>>;222 verbs: Map<string, MutationDescriptor>;223 logs: Map<string, EffectUnit<{ id: string }>>;224};225226const slotName = "__LOFI_EFFECT_DECLARATIONS__";227const effectsGlobal = globalThis as typeof globalThis & { [slotName]?: EffectsSlot };228229function slot(): EffectsSlot {230 effectsGlobal[slotName] ??= {231 runtime: null,232 effects: new Map(),233 verbs: new Map(),234 logs: new Map(),235 };236 return effectsGlobal[slotName];237}238239/**240 * Installs the runtime half verbs dispatch through. The package runtime241 * installs it at boot; application code never calls this. Tests install a242 * deterministic fake to unit-test verbs without a booted runtime.243 */244export function setMutationRuntime(runtime: MutationRuntime): void {245 slot().runtime = runtime;246}247248/** Resolves a registered effect unit by its durable name, for the runtime. */249export function resolveEffectUnit(name: string): EffectUnit<{ id: string }> | null {250 return slot().effects.get(name) ?? null;251}252253/** Clears every declaration; deterministic-test seam. */254export function clearEffectDeclarations(): void {255 const state = slot();256 state.effects.clear();257 state.verbs.clear();258 state.logs.clear();259}260261// During dev hot replacement author modules re-evaluate routinely; the newest262// declaration replaces its predecessor under the same durable name. Outside263// dev, a duplicate name is a real collision and fails fast.264const hotReplacement = Boolean(import.meta.hot);265266function registerUnit(unit: EffectUnit<{ id: string }>): void {267 const state = slot();268 const existing = state.effects.get(unit.effectName);269 if (existing && existing !== unit && !hotReplacement) {270 throw new Error(271 `effect name "${unit.effectName}" is already declared; effect names are the journal's ` +272 `durable identities and must be unique per app`,273 );274 }275 state.effects.set(unit.effectName, unit);276 // A unit declared after boot may satisfy obligations journaled before the277 // reload; the runtime re-attempts them on registration.278 state.runtime?.unitRegistered?.(unit.effectName);279}280281/**282 * Declares a named, reusable, typed effect unit over one table.283 *284 * The name is durable: the journal re-arms this unit's handlers by name after285 * a reload, so renaming a unit orphans obligations journaled under the old286 * name. Names must be unique per app; a duplicate declaration throws.287 *288 * Handlers run once, on the originating device, with at-least-once delivery:289 * a crash between handler start and journal completion re-runs the handler at290 * the next boot, so handlers that call external services should pass291 * {@link EffectContext.journalId} as the idempotency key.292 *293 * @example294 * ```ts295 * const chargeCard = s.effect("chargeCard", app.schema.orders, {296 * onSynced: async (order, { journalId }) => {297 * // The world accepted the order. journalId is the idempotency key.298 * },299 * onRejected: async (order) => {300 * // The order never happened: compensate what the user saw.301 * },302 * });303 * ```304 *305 * @throws Error when `name` is empty or already declared by a different unit;306 * in dev hot replacement the newest declaration replaces its predecessor307 * instead of throwing.308 */309export function effect<T extends { id: string }, Init>(310 name: string,311 table: TableProxy<T, Init>,312 handlers: EffectHandlers<T>,313 options: EffectUnitOptions = {},314): EffectUnit<T> {315 if (!name.trim()) throw new Error("effect name must not be empty");316 void table;317 const unit: EffectUnit<T> = {318 effectName: name,319 handlers,320 expiresAfterMs: options.expiresAfterMs ?? null,321 ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),322 };323 registerUnit(unit as EffectUnit<{ id: string }>);324 return unit;325}326327/**328 * A built-in effect unit that records a structured entry in runtime329 * diagnostics on either fate. Repeated calls with one label return the same330 * unit, so a label can be shared by several verbs.331 *332 * @example333 * ```ts334 * export const addTask = s.mutation("addTask", s.insert(app.schema.tasks), {335 * effects: [s.log("task-writes")],336 * });337 * // Each addTask write records a "task-writes" diagnostics entry when it338 * // syncs or is rejected.339 * ```340 */341export function log(label: string): EffectUnit<{ id: string }> {342 if (!label.trim()) throw new Error("log label must not be empty");343 const state = slot();344 const name = `log:${label}`;345 let unit = state.logs.get(name);346 if (!unit) {347 const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {348 requireRuntime().recordLog(label, context);349 };350 unit = { effectName: name, handlers: { onSynced: record, onRejected: record } };351 state.logs.set(name, unit);352 registerUnit(unit);353 }354 return unit;355}356357function requireRuntime(): MutationRuntime {358 const runtime = slot().runtime;359 if (!runtime) {360 throw new Error(361 "the lofi runtime is not installed; import @nzip/lofi (or @nzip/lofi/preact) in the " +362 "module that calls this verb",363 );364 }365 return runtime;366}367368/**369 * Declares a typed, callable verb: a named mutation over one table operation,370 * carrying its effect units. Call sites invoke the verb like a function and371 * receive a {@link WriteHandle}; `await` resolves at `saved`.372 *373 * Verb names are app-unique and durable — the journal attributes re-armed374 * obligations through them — and read as imperative verb phrases375 * (`placeOrder`, not `orderInsert`). Inline `onSynced`/`onRejected` are sugar376 * for an implicit single unit named after the verb.377 *378 * @example379 * ```ts380 * export const placeOrder = s.mutation("placeOrder", s.insert(app.schema.orders), {381 * effects: [chargeCard, s.log("order-placed")],382 * });383 *384 * const write = placeOrder({ item, qty }); // WriteHandle<Order>385 * await write; // saved: durable on this device386 * await write.synced; // confirmed by the store387 * ```388 *389 * @throws Error at declaration when `name` is empty, already declared390 * (outside dev hot replacement, where the newest declaration replaces its391 * predecessor under the same name), or when `options` list one effect unit392 * twice.393 * @throws Error from a call to the returned verb when the local policy394 * refuses the write: the refusal is synchronous, creates no stage and no395 * journal entry, and fires no effect — see {@link EffectHandlers.onRejected}396 * for the adjudicated case. Calls also throw when the lofi runtime is not397 * installed.398 */399export function mutation<T extends { id: string }, Init, Kind extends MutationOpKind>(400 name: string,401 op: MutationOp<T, Init, Kind>,402 options: MutationOptions<T> = {},403): MutationVerb<MutationOp<T, Init, Kind>> {404 if (!name.trim()) throw new Error("mutation name must not be empty");405 const state = slot();406 const existing = state.verbs.get(name);407 if (existing && !hotReplacement) {408 throw new Error(`mutation name "${name}" is already declared; verb names are unique per app`);409 }410 const units: EffectUnit<{ id: string }>[] = [];411 const seen = new Set<string>();412 for (const unit of options.effects ?? []) {413 if (seen.has(unit.effectName)) {414 throw new Error(`mutation "${name}" declares effect "${unit.effectName}" twice`);415 }416 seen.add(unit.effectName);417 units.push(unit as EffectUnit<{ id: string }>);418 }419 if (options.onSynced || options.onRejected) {420 if (seen.has(name)) {421 throw new Error(422 `mutation "${name}" cannot combine inline handlers with an effect unit named "${name}"`,423 );424 }425 const implicit: EffectUnit<T> = {426 effectName: name,427 handlers: { onSynced: options.onSynced, onRejected: options.onRejected },428 };429 registerUnit(implicit as EffectUnit<{ id: string }>);430 units.push(implicit as EffectUnit<{ id: string }>);431 }432 const descriptor: MutationDescriptor = {433 verbName: name,434 op: op as MutationOp<unknown, unknown>,435 units,436 expiresAfterMs: options.expiresAfterMs ?? null,437 };438 state.verbs.set(name, descriptor);439 const verb = (...args: readonly unknown[]) => requireRuntime().dispatch(descriptor, args);440 return verb as MutationVerb<MutationOp<T, Init, Kind>>;441}