/** * Entity lifecycles — the state machines, declared (#844). * * #697 declared the entities, #707 the operations over them. Both left the * *edges* undeclared: six entities across four engines and two demos carry a * `status` enum, and every one of them describes its transitions a second time, * as hand-written guards in operation bodies. * * ```ts * function requireStatus(row: OrderRow, ...allowed: OrderRow['status'][]): void { * if (!allowed.includes(row.status)) throw conflict('invalid_transition', …); * } * ``` * * That function is the whole state machine, spread across six call sites, held * to the enum by nothing. Booking does not even hold the state *set* in one * place — the same seven values are written twice, as two independent `z.enum` * literals in its `entities.ts` and its `index.ts`. * * ## What this is not * * It is not a workflow language, and the omissions are the design. * * There are no actions, no effects, no `context`, no parallel regions and no * expression language. An edge names the operation that performs it and nothing * more; the operation keeps its body. The moment an edge can carry a condition, * this is BPMN in TypeScript — the tarpit the master plan named in the same * breath as adopting durable execution, and the reason that row says * "conventions" rather than "build". * * Durable execution is a separate concern with a separate answer (the outbox → * `_substrat_deliveries` → sweeper substrate). A lifecycle says which states an * entity may be in and which operation moves it between them. It never says * when, and it never runs anything. * * ## Guards are NOT declared here * * The obvious next field is `guard` on an edge, and it would be a second * description of something already shipped. K-38 ratified manifest-declared * guards: a module contributes a named predicate, a manifest wires it with * `guards: [{ before, predicate, config }]`, and the kernel evaluates it inside * the guarded operation's own transaction. `before` names an operation — and * every edge here names its operation too, so a guard on an operation already * *is* a guard on that operation's edges. * * The join key exists, so the views that want guards on edges (the XState * config, the docs diagram) derive them. Declaring them twice is how they come * to disagree. */ import { z } from 'zod'; import type { EntityDef } from './model.js'; /** * The values the state field may hold, read off the entity's OWN `fields` * schema. * * This is what makes the declaration bite. The states are not restated here — * they are `z.enum(['planned', 'in_progress', 'completed', 'closed'])` in * `workorderEntities`, and naming a fifth one is a compile error rather than an * edge that silently never resolves. It also runs the other way: `states` is a * TOTAL record over this union, so adding a value to the enum and forgetting * the machine fails to compile. */ export type StateValues = E extends { fields: infer S; } ? S extends z.ZodObject ? F extends keyof z.infer ? z.infer[F] & string : never : never : never; /** The field names of one entity, for the `field` position. */ type FieldNames = E extends { fields: infer S; } ? S extends z.ZodObject ? keyof z.infer & string : never : never; /** * One state. * * `on` and `allow` are separate because most state checks in the repo are not * transitions. Nine of booking's `requireState` call sites gate operations, and * several of them — `requireState(row, 'held', 'confirmed')` before attaching a * note — change no state at all. A format with only edges would have described * the majority of them as transitions, which is worse than not describing them: * the emitted diagram would show edges that do not exist. */ export interface StateDef { /** Operations that move the entity OUT of this state, to the named target. */ readonly on?: Readonly>>; /** Operations legal in this state that change no state. A precondition, not an edge. */ readonly allow?: readonly Ops[]; /** * This state admits vertical substates (K-17 `extensibleStates`). * * kernel-design §7.5 specifies substates as *"the engine's state-machine * declaration marks which states admit substates"*. This is that mark. An * invariant-bearing state — one holding something signed, exported or * otherwise frozen — declares nothing, and the absence reads as intent. */ readonly extensible?: boolean; /** * No edges out, ever. * * Distinguished from "no edges yet" deliberately: an empty `on` is ambiguous * between a finished state and an unfinished declaration, and only one of * those should emit `type: 'final'`. */ readonly terminal?: boolean; } /** One entity's machine. */ export interface LifecycleDef { /** The column holding the state. Must be a field of the entity. */ readonly field: string; /** Where a freshly created row starts. Must be a declared state. */ readonly initial: States; /** Every state the field may hold — total over the field's enum, by construction. */ readonly states: Readonly>>; } /** * The shape one entry must have, resolved against the entity it names and the * operations bag it draws from. * * Self-referential through `Self['field']`: the `states` keys depend on which * field was named in the same object literal. Written the obvious way — `field: * string` — every state name compiles and the check enforces nothing, which is * the failure `defineEntities` documents at length and `test/lifecycle.test.ts` * exists to prove has not returned. */ type LifecycleShape = Self extends { readonly field: infer F extends string; } ? { readonly field: F & FieldNames; readonly initial: StateValues; readonly states: { readonly [S in StateValues]: StateDef, keyof Ops & string>; }; } : never; /** * Declare an entity's lifecycle. * * Curried for the same reason `defineOperations` is: the entity registry and * the operation bag are both needed to check the declaration, and neither * should have to be re-stated at every call site. * * ```ts * export const workorderLifecycles = defineLifecycles(workorderEntities, workorderOperations)({ * workorder: { * field: 'status', * initial: 'planned', * states: { * planned: { on: { 'workorder/start': 'in_progress' }, allow: ['workorder/assign'] }, * in_progress: { on: { 'workorder/complete': 'completed' }, extensible: true }, * completed: { on: { 'workorder/close': 'closed' } }, * closed: { terminal: true }, * }, * }, * }); * ``` */ export declare function defineLifecycles, const O extends Record>(_entities: E, _operations: O): : never; }>(lifecycles: L) => L; /** * The checks the type system cannot make, made loudly at module load. * * Exported so the emitters can run it over a registry they were handed rather * than one they built — the same posture as `primaryKeyOf`, which throws rather * than returning nothing because a table with no identity is not a shape the * model may express. * * **`allowed` is checked here rather than in the types**, and that placement is * measured rather than chosen: TypeScript applies no excess-property check when * a value satisfies a generic constraint, so a `states` object carrying a fifth * key the enum does not have compiles clean. The same caveat `defineEntities` * records for `renamedFrom`. A check that reads like it works and does not is * worse than an absent one, so this one is where it can bite. */ export declare function assertCoherent(entity: string, lc: LifecycleDef, allowed?: readonly string[]): void; /** * What an operation does to an entity in a given state. * * `allowed` is not a degenerate transition — the caller uses the distinction. * An engine writing `status = ?` after a `transition` outcome is correct; doing * it after an `allowed` one would move an entity the declaration says stays put. */ export type Outcome = { readonly kind: 'transition'; readonly to: string; } | { readonly kind: 'allowed'; }; /** * What `operation` does from `from`, or `null` if it is not legal there. * * Total and side-effect free, which is what lets `xstate`'s own * `machine.transition()` stand beside it in tests as an independent oracle * without either one shipping to production. */ export declare function transitionFor(lc: LifecycleDef, from: string, operation: string): Outcome | null; /** * The reason an illegal transition is refused with. * * Exported so an engine's own conflict vocabulary can REFERENCE it rather than * spell it again — the reason is raised here now, and an engine whose exported * `*_CONFLICT_REASONS` list said something subtly different would be publishing * a slug no consumer would ever match. */ export declare const INVALID_TRANSITION = "invalid_transition"; /** * The replacement for every hand-written `requireStatus` / `requireState`. * * Throws the platform's own `conflict` with `reason: 'invalid_transition'` — * the reason four engines already narrow to, and the one two demos were * silently not using. `demos/shop` threw a bare `new Error(...)`, so a caller * branching on the refusal got a 500 where every engine gives a 409. * * The message names what WAS legal, because the failure a user hits is almost * never "this operation does not exist" — it is "someone else already moved it." */ export declare function assertTransition(lc: LifecycleDef, entity: string, from: string, operation: string): Outcome; /** Every operation the declaration mentions, sorted. The join key for guards and docs. */ export declare function operationsOf(lc: LifecycleDef): readonly string[]; /** One state, serialised. Absent optionals stay absent — a diff should show facts, not defaults. */ export interface EmittedState { readonly on?: Record; readonly allow?: readonly string[]; readonly extensible?: true; readonly terminal?: true; } export interface EmittedLifecycle { readonly field: string; readonly initial: string; readonly states: Record; } /** * Render lifecycles to plain JSON for `model.json`. * * Deterministic in the same way `emitModel` is: entities, states and edges are * emitted in sorted order, so a reordered declaration is not a spurious diff * and the checked-in artifact stays reviewable. */ export declare function emitLifecycles(lifecycles: Record): Record; export {}; //# sourceMappingURL=lifecycle.d.ts.map