/** * `transitionGuard` — declarative state-machine sugar over the guard * service. * * Any record with a lifecycle field (invoice `status`, order state, * ticket workflow, subscription phase) needs transition validation: a * write may only move the field along a declared arc. That is expressible * with a hand-rolled `withGuard({ check })`, but every consumer * re-implements the same graph lookup + error. `transitionGuard` * generates exactly that guard from a state graph, reusing the guard * machinery wholesale — `check` rejection, the ledgered `amendment` * override, and composition with `periods`/`history`. * * It generalizes {@link immutableGuard}: WORM is the special case "every * state has no outgoing arcs", i.e. `transitions` mapping each state to `[]`. * * ```ts * createNoydb({ guardStrategies: [ * transitionGuard({ * collection: 'sales', field: 'status', * transitions: { // absence of an arc = forbidden * draft: ['to_verify', 'cancelled'], * to_verify: ['proforma', 'draft', 'cancelled'], * proforma: ['invoiced', 'cancelled'], * invoiced: ['paid'], paid: [], cancelled: [], * }, * initial: ['draft', 'to_verify'], // allowed status on insert * }), * ] }) * ``` * * Semantics: * - **Insert** (`ctx.existing === null`): `incoming[field]` must be in * `initial`. When `initial` is omitted, any value is allowed on insert. * - **Update**: the arc `(existing[field] → incoming[field])` must be * listed in `transitions[from]`, else `IllegalTransitionError`. A * same-value write (`from === to`) is allowed when `allowIdempotent` * (default `true`) — so writes that touch other fields without moving * state pass. * - **Override**: inside an `amendment` transaction by an authorized role * the check is skipped and the change is ledgered (mirrors every guard). * * The status graph is caller-supplied data — no UI, no domain logic. */ import type { GuardStrategyHandle, GuardContext, GuardChange } from './types.js'; export interface TransitionGuardConfig> { /** The collection whose state field is governed. */ collection: string; /** The state field on the record (e.g. `'status'`). */ field: keyof T & string; /** * The transition graph: each state maps to the states it may move to. * A state absent from the map (or mapped to `[]`) is terminal — no * outgoing arc, so any non-idempotent write from it is rejected. */ transitions: Readonly>; /** * States allowed as the initial value on insert (`existing === null`). * Omit to allow any value on insert. */ initial?: readonly string[]; /** * Allow a same-value write (`from === to`) on update. Default `true` — * lets a put that changes other fields, but not the state, through. */ allowIdempotent?: boolean; /** Roles permitted to override via an amendment transaction. Default `['admin', 'owner']`. */ amendmentRoles?: ReadonlyArray<'admin' | 'owner'>; /** * Optional set-level invariant run over the amendment change-set after * the writes execute. Signature matches `GuardStrategy.amendment.invariant` * exactly. When omitted the amendment is unconditionally allowed (the * amendment itself is the sanctioned, ledgered override) — the * backward-compatible default. Mirrors {@link immutableGuard}. */ amendmentInvariant?: (changes: ReadonlyArray>, ctx: GuardContext) => Promise | void; } /** * Build a state-machine transition guard. Pass the returned handle to * `createNoydb({ guardStrategies: [...] })`. */ export declare function transitionGuard>(config: TransitionGuardConfig): GuardStrategyHandle;