@ai-digital-factory/state-flow-runtime 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +195 -41
  2. package/dist/index.d.ts +536 -251
  3. package/dist/index.js +3368 -1840
  4. package/package.json +1 -5
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Application-owned durable execution with SQLite storage. Requires Node.js 22.13 or later.
4
4
 
5
- Import from `@ai-digital-factory/state-flow-runtime`. The `/durable` subpath resolves to the same module and types. There is one execution model: callback checkpoints, request deduplication, bounded recovery and manual resolution.
5
+ Import from `@ai-digital-factory/state-flow-runtime`. There is one execution model: callback checkpoints, request deduplication, bounded recovery and manual resolution.
6
6
 
7
7
  Every handlers package compiles for this runtime. No execution opt-in is needed. Each callback declares how it recovers, and `state-flow compile` type-checks it against its generated contract:
8
8
 
@@ -14,7 +14,7 @@ export type Payload = z.output<typeof payloadSchema>;
14
14
 
15
15
  export const onTransition = {
16
16
  run: async (context, payload) => {
17
- await context.config.payments.charge({
17
+ await context.dependencies.payments.charge({
18
18
  idempotencyKey: context.operation.operationId,
19
19
  amount: payload.amount,
20
20
  });
@@ -22,19 +22,72 @@ export const onTransition = {
22
22
  recovery: {
23
23
  strategy: "reconcile",
24
24
  reconcile: async (context) =>
25
- (await context.config.payments.find(context.operation.operationId))
25
+ (await context.dependencies.payments.find(context.operation.operationId))
26
26
  ? { decision: "completed" }
27
27
  : { decision: "safe-to-retry" },
28
28
  },
29
- } satisfies DurableTransitionCallback<TransitionContext<Config>, Payload>;
29
+ } satisfies TransitionCallback<TransitionContext<Dependencies>, Payload>;
30
30
  ```
31
31
 
32
32
  Strategies: `safe-replay` (running again cannot duplicate an effect), `reconcile` (check the external system and return `completed`, `safe-to-retry`, `pending` or `needs-attention`), and `manual` (stop for an operator on failure).
33
33
 
34
- Services configure one shared policy for non-applied dispatch outcomes:
34
+ Callbacks read service-supplied implementations through `context.dependencies` (formerly `context.config`). The handlers package owns the dependency interfaces; the service passes implementations to `createFlow(compiled, dependencies)` again after every restart, and State Flow never persists them.
35
+
36
+ ## Transition destinations and FinalState
37
+
38
+ A Transition declares its destinations with `<To>` children (ADR 0087). With one destination, `onTransition` is optional and returns nothing. With several, `onTransition` is required and returns one of the authored destination names; the generated `TransitionResult` type is that exact union:
39
+
40
+ ```ts
41
+ import type {
42
+ TransitionContext,
43
+ TransitionResult, // "placed" | "rejected"
44
+ } from "@scope/flows/place-order.submit-order";
45
+
46
+ export const onTransition = {
47
+ run: async (context, payload) => {
48
+ const decision = await context.dependencies.broker.submit({
49
+ operationId: context.operation.operationId,
50
+ ...payload,
51
+ });
52
+ return decision === "accepted" ? "placed" : "rejected";
53
+ },
54
+ recovery: {
55
+ strategy: "reconcile",
56
+ reconcile: async (context) => {
57
+ const decision = await context.dependencies.broker.lookup(
58
+ context.operation.operationId,
59
+ );
60
+ return decision === null
61
+ ? { decision: "safe-to-retry" }
62
+ : {
63
+ decision: "completed",
64
+ result: decision === "accepted" ? "placed" : "rejected",
65
+ };
66
+ },
67
+ },
68
+ } satisfies TransitionCallback<
69
+ TransitionContext<Dependencies>,
70
+ Payload,
71
+ TransitionResult
72
+ >;
73
+ ```
74
+
75
+ - Guards stay boolean admission checks. Guard and `onTransition` contexts expose the allowed `context.destinations`; there is no `toState`, because a branching destination is unknown until the operation returns.
76
+ - The lifecycle is unchanged: guard, source `onExit`, `onTransition`, commit the destination, destination `onEnter`. A false guard stops before `onExit`.
77
+ - The selected destination is checkpointed with the callback's completion before the state commits, so a restart resumes the same choice without running the callback again.
78
+ - Throwing, returning nothing or returning an undeclared destination is a failure handled by the declared recovery strategy; it never selects a branch implicitly. Returning an authored rejection destination is a successful business result.
79
+ - An operator confirms a branching operation with its destination: `recoveryTarget(work).confirm(reason, "rejected")`.
80
+ - While a transition runs, the snapshot keeps the source `state` and reports `status: { kind: "running", transition, requestId }` for UI progress.
81
+ - `applied` dispatch results carry `destination`: the state that request's execution recorded. Duplicates report the original destination even after the instance has moved on; use `snapshot` for the current condition.
82
+
83
+ A `<FinalState>` has no callbacks and no outgoing transitions. Committing it also sets `status.kind` to `completed`, atomically. Completed is distinct from operational termination and says nothing about business success. A new request against a completed instance resolves to the `completed` outcome without running callbacks; duplicates of applied requests still return their recorded results.
84
+
85
+ A service registers each flow once, ensures instances by ID, and maps every dispatch outcome at compile time (ADR 0090):
35
86
 
36
87
  ```ts
37
- import visitorAccess from "@scope/handlers/visitor-access";
88
+ import visitorAccess, {
89
+ type Dependencies,
90
+ } from "@scope/handlers/visitor-access";
38
91
  import {
39
92
  createRuntime,
40
93
  instanceId,
@@ -45,39 +98,96 @@ await using runtime = createRuntime({
45
98
  databasePath: "state-flow.db",
46
99
  shutdownTimeoutMs: 10_000,
47
100
  });
48
- const flow = await runtime
49
- .createFlow(visitorAccess, config)
50
- .handleFailure((failure) => {
51
- // One application policy for flow setup, instance acquisition and dispatch.
52
- throw failure;
53
- })
54
- .run();
101
+ // Rejects with DefinitionError for an invalid module.
102
+ const flow = await runtime.createFlow(visitorAccess, dependencies);
55
103
 
56
- const instance = await flow
57
- .ensureInstance({ instanceId: instanceId("visitor-1") })
58
- .run();
104
+ // Opens the instance, or creates it once. Rejects with DefinitionMismatchError when the stored
105
+ // instance belongs to another definition or has unresolved work from other handler code.
106
+ const instance = await flow.ensureInstance({
107
+ instanceId: instanceId("visitor-1"),
108
+ });
59
109
 
60
- // No callback map is needed for each command. Fulfillment means applied.
61
- const applied = await instance.when("guest").dispatch({
110
+ // One callback per outcome; adding an outcome is a compile error until every map handles it.
111
+ // Build the dispatcher once per state and reuse it.
112
+ const fromGuest = instance.when("guest").handle({
113
+ applied: ({ duplicate, destination }) => ({
114
+ ok: true,
115
+ destination,
116
+ duplicate,
117
+ }),
118
+ blocked: () => ({ ok: false, reason: "blocked" }),
119
+ "invalid-payload": ({ issues }) => ({ ok: false, reason: "invalid", issues }),
120
+ busy: () => ({ ok: false, reason: "busy" }),
121
+ "recovery-pending": () => ({ ok: false, reason: "recovering" }),
122
+ "needs-attention": ({ reason }) => ({ ok: false, reason }),
123
+ "state-mismatch": ({ actualState }) => ({ ok: false, reason: actualState }),
124
+ "request-conflict": () => ({ ok: false, reason: "conflict" }),
125
+ terminated: ({ reason }) => ({ ok: false, reason }),
126
+ completed: () => ({ ok: false, reason: "completed" }),
127
+ });
128
+ const result = await fromGuest.dispatch({
62
129
  requestId: requestId("unlock-1"),
63
130
  transition: "unlock-succeeds",
64
131
  payload: { operatorSessionCookie: "…" },
65
132
  });
66
- console.log(applied.duplicate ? "Already applied" : "Applied");
67
133
 
68
134
  // Runtime cleanup is awaited automatically when this scope exits.
69
135
  ```
70
136
 
71
- - **One owner per database.** A second runtime on the same file throws `DurableOwnershipError`; a crashed process releases the lock.
137
+ `ensureInstance` is the only way to acquire an instance. A service that must not create one (for example, a webhook for an order that should already exist) checks first: `runtime.inspector.getSnapshot(instanceId)` returns `null` for an unknown ID. Programmer and infrastructure errors (an unknown transition, a closed runtime, a failing disk) reject the dispatch promise; they are not outcomes.
138
+
139
+ - **One owner per database.** A second runtime on the same file throws `OwnershipError`; a crashed process releases the lock.
72
140
  - **Recovery** runs in the background with at most 4 attempts per callback (waits of 1 s, 5 s, 30 s), then marks the instance needs-attention. Budgets survive restarts. Opening an instance resumes interrupted work.
73
141
  - **Observation:** `getSnapshot()`, `subscribe()` and paged `history()` per instance; `runtime.inspector` lists instances; authorized `runtime.controls` resolves work across them for operator tools such as the durable companion in `@ai-digital-factory/state-flow-observe`.
74
- - **Manual recovery:** `instance.resolve({ action: "retry" | "confirm-completed" | "terminate", operationId, expectedAttempts, reason, … })`, taking `operationId` and `attempts` from the snapshot's unresolved work. An action based on an older attempt is rejected as `stale`. Your application authorizes the caller; `parseResolveInput` validates input from untrusted sources.
75
- - **Handler changes:** unresolved work started under different handler code (handlers, local helpers, `config.ts`, declared dependencies) is not resumed (`definition-mismatch` with `reason: "handler-revision"`). Finish it with the previous build, or terminate it with `runtime.controls.resolve(id, { action: "terminate", … })`, which runs no handler.
76
- - **Stalls:** if an infrastructure error stops execution, `snapshot.stall` shows it; fix the cause and call `instance.restartRecovery()`.
77
- - **Shutdown:** `close({ timeoutMs })` rejects with `DurableShutdownTimeoutError` if callbacks ignore their `AbortSignal`; the database stays owned until they settle or the process exits.
142
+ - **Manual recovery:** `instance.handleResolve({ accepted, rejected }).resolve({ action: "retry" | "confirm-completed" | "terminate", operationId, expectedAttempts, reason, … })`, or `runtime.controls.resolve(instanceId, …)` for operator tooling, taking `operationId` and `attempts` from the snapshot's unresolved work. An action based on an older attempt is rejected as `stale`. Your application authorizes the caller; `parseResolveInput` validates input from untrusted sources.
143
+ - **Handler changes:** unresolved work started under different handler code (handlers, local helpers, `dependencies.ts`, declared dependencies) is not resumed (`definition-mismatch` with `reason: "handler-revision"`). Finish it with the previous build, or terminate it with `runtime.controls.resolve(id, { action: "terminate", … })`, which runs no handler.
144
+ - **Stalls:** if an infrastructure error stops execution, `snapshot.stall` shows it; fix the cause and call `instance.handleRestart({ restarted, "not-restarted" }).restart()`.
145
+ - **Shutdown:** `close({ timeoutMs })` rejects with `ShutdownTimeoutError` if callbacks ignore their `AbortSignal`; the database stays owned until they settle or the process exits.
78
146
 
79
147
  See ADR 0075 for the exact rules and what is not covered yet.
80
148
 
149
+ ## Composition: nested scopes (ADR 0095, 0096)
150
+
151
+ A Flow that places other Flows needs them registered first, each with its own dependencies:
152
+
153
+ ```ts
154
+ await runtime.createFlow(fileUpload, uploadDependencies);
155
+ await runtime.createFlow(placeOrder, orderDependencies);
156
+ const workspaces = await runtime.createFlow(
157
+ workspaceSession,
158
+ workspaceDependencies,
159
+ );
160
+ const session = await workspaces.ensureInstance({
161
+ instanceId: instanceId("user-1"),
162
+ });
163
+
164
+ const signedIn = session.scope("signed-in"); // compound State: its name
165
+ const upload = signedIn.scope("uploading", "report.pdf"); // FlowList member by key
166
+ const order = signedIn.scope("order-desk:order"); // parallel region: state:region
167
+
168
+ await upload
169
+ .when("queued")
170
+ .handle({
171
+ ...outcomes,
172
+ interrupted: ({ duplicate }) =>
173
+ "a parent Transition interrupted this request",
174
+ inactive: () => "no active upload with that key",
175
+ })
176
+ .dispatch({
177
+ requestId: requestId("send-1"),
178
+ transition: "send-file",
179
+ payload: null,
180
+ });
181
+ ```
182
+
183
+ - Each nested activation is a durable scope with its own State, executions and recovery; regions and members progress independently. `session.getSnapshot().scopes` lists active scopes and exited ones that still retain unresolved work.
184
+ - Scope handles resolve the current activation on every call. Their outcomes add `interrupted` (a parent `interrupt` fenced this execution) and `inactive` (no activation at that path). A request ID belongs to its original activation.
185
+ - `childWork="block"` parent Transitions answer `busy` while descendant work is running or unresolved; nothing queues. While a parent execution runs, its descendants answer `busy`.
186
+ - `childWork="interrupt"` aborts the descendants' `context.signal` and proceeds. Interrupted work never advances the graph and is never replayed: a failure is reconciled (`safe-to-retry` then needs attention), and `safe-replay` or `manual` work needs attention. Retry runs only a reconcile check, Confirm it completed records the outcome, and Terminate ends the root and every activation. Unresolved interrupted work blocks `handover()` and `checkDeployment()`.
187
+ - Late outcomes (a late result, a post-exit reconciliation or a confirmation) stay in `runtime.inspector.lateOutcomes()` and arrive through `subscribeLateOutcomes()` until `runtime.controls.acknowledgeLateOutcome(sequence)`, across restarts.
188
+ - Manual resolution takes the root instance ID and the operation ID of work at any depth. A root's handler revision covers every placed Flow.
189
+ - Databases from 0.1.0 upgrade in place to schema version 3; flat Flows keep their definition identity.
190
+
81
191
  ## Schema contracts
82
192
 
83
193
  Every durable transition module exports a Zod `payloadSchema`; use `z.null()` for an explicitly empty payload. Compile derives the service payload type from the schema and rejects missing declarations or mismatched handlers. The compiled module carries the schema so State Flow validates dynamic input before running a handler or reserving a request ID. Consumers must handle `invalid-payload` alongside every other dispatch outcome. Both invalid-payload and request-conflict contexts expose the rejected payload as unknown.
@@ -87,17 +197,12 @@ Use validation-only schemas. Async refinements are supported; State Flow recheck
87
197
  ## Scoped runtime lifetime
88
198
 
89
199
  ```ts
90
- import { withDurableRuntime } from "@ai-digital-factory/state-flow-runtime";
200
+ import { withRuntime } from "@ai-digital-factory/state-flow-runtime";
91
201
 
92
- await withDurableRuntime(
202
+ await withRuntime(
93
203
  { databasePath: "state-flow.db", shutdownTimeoutMs: 5000 },
94
204
  async (runtime) => {
95
- const flow = await runtime
96
- .createFlow(compiled, config)
97
- .handleFailure((failure) => {
98
- throw failure;
99
- })
100
- .run();
205
+ const flow = await runtime.createFlow(compiled, dependencies);
101
206
  // Ensure instances and dispatch within this managed scope.
102
207
  },
103
208
  );
@@ -121,25 +226,74 @@ if (snapshot.status.kind === "needs-attention") {
121
226
  }
122
227
  ```
123
228
 
124
- Import `recoveryTarget` from the durable entrypoint. It captures the operation ID and attempt count and exposes callback-specific confirmation input: boolean for a guard, an authored state for start-state resolution, and only a reason for void callbacks. Captured targets remain stale after later attempts; they never silently retarget refreshed work. `handleRestart({ restarted, "not-restarted" }).restart()` requires both explicit restart outcomes.
229
+ Import `recoveryTarget` from the durable entrypoint. It captures the operation ID and attempt count and exposes callback-specific confirmation input: boolean for a guard, an authored state for start-state resolution, the selected destination for a branching `onTransition`, and only a reason for void callbacks. Captured targets remain stale after later attempts; they never silently retarget refreshed work. `handleRestart({ restarted, "not-restarted" }).restart()` requires both explicit restart outcomes.
125
230
 
126
- `runtime.inspector` exposes only listing, snapshot/history reads and subscriptions. `runtime.controls` is the explicit privileged capability used by authorized operator tooling, including companion recovery. Raw instance `resolve`/`restartRecovery` remain deprecated operator primitives; the published service lint configuration requires application instances to use the exhaustive interfaces. Application authorization remains mandatory before forwarding untrusted actions.
231
+ `runtime.inspector` exposes only listing, snapshot/history reads and subscriptions. `runtime.controls` is the explicit privileged capability used by authorized operator tooling, including companion recovery. Instances expose recovery only through these exhaustive interfaces; the raw primitives live on `runtime.controls`. Application authorization remains mandatory before forwarding untrusted actions.
127
232
 
128
233
  Transient SQLite busy/locked infrastructure errors receive at most three automatic restart attempts (1 s, 5 s, 30 s). Other infrastructure failures remain visible stalls requiring explicit intervention. These in-memory infrastructure retries neither reset durable callback budgets nor bypass a callback's declared recovery strategy.
129
234
 
130
- ## Consumer linting
235
+ ## Deployment gate
131
236
 
132
- Use `serviceConfig` or `handlersConfig` from the corresponding `@ai-digital-factory/eslint-plugin-state-flow/eslint.*.config.js` entrypoint and run lint in CI alongside compile and typecheck. These type-aware configurations enforce promise observation, require automatic runtime ownership through await using or withDurableRuntime for services, prevent legacy execution bypasses, and keep runtime construction out of handlers. Explicit framework/operator lifecycle adapters may override the constructor restriction. Prepared dispatchers and unrelated transport methods are permitted. Fire-and-forget work requires explicit rejection handling; `void` alone does not satisfy the lint rule.
237
+ Unfinished execution never resumes under different handler code (ADR 0076), so deploying over it would strand it. Run the deployment gate before a new build replaces the old one (ADR 0091, 0094). Instances that are **Running**, **Recovering** or **Needs attention** block a deployment; **Ready** instances waiting for their next event, **Completed** and **Terminated** instances do not. These are the durable companion's status filters: an empty result for those three filters is what the gate checks, but only the gate's check is protected against new work starting.
133
238
 
134
- `flow.ensureInstance({ instanceId }).run()` is the primary acquisition API. The advanced `handleEnsure(...).ensure({ instanceId })` interface supports explicit outcome handling. It opens an existing instance or reserves and creates it once, including overlapping callers. It starts interrupted recovery and handles the internal not-found/already-exists cases. Awaiting ensure returns the instance directly; its snapshot may be running, recovering, awaiting attention or terminated. The mandatory mismatch callback must throw/reject or transfer control (`never` or `Promise<never>`); it cannot replace the instance with another return value. Dispatch still requires a shared failure policy or exhaustive local outcome handlers. The mismatch callback receives structured identity/revision details and a library-provided diagnostic message. Use `handleCreate` or `handleOpen` when your application deliberately requires create-only or open-only behavior.
239
+ The running application performs the gate with `runtime.handover()`:
240
+
241
+ ```ts
242
+ // Called by the deployment system, for example through an authenticated admin endpoint.
243
+ app.post("/admin/deployment/handover", async (_request, response) => {
244
+ try {
245
+ const gate = await runtime.handover({ timeoutMs: 10_000 });
246
+ switch (gate.outcome) {
247
+ case "blocked":
248
+ // Nothing changed: this build keeps running so the work can finish or be resolved.
249
+ return response.status(409).json({ blockers: gate.blockers });
250
+ case "handed-over":
251
+ // The runtime is closed and the database released; stop this process now.
252
+ response.status(200).json({ outcome: "handed-over" });
253
+ setImmediate(() => process.exit(0));
254
+ return;
255
+ }
256
+ } catch (error) {
257
+ // A failed check or a timed-out shutdown is never permission to deploy.
258
+ response.status(503).json({ error: String(error) });
259
+ if (error instanceof ShutdownTimeoutError) {
260
+ // The runtime admits no more work but still owns the database: exit so the supervisor
261
+ // restarts this build, which recovers the interrupted work.
262
+ setImmediate(() => process.exit(1));
263
+ }
264
+ }
265
+ });
266
+ ```
267
+
268
+ ```bash
269
+ # Deployment script: deploy only after a 200 response.
270
+ curl --fail -X POST https://app.internal/admin/deployment/handover || exit 1
271
+ start-new-build || start-old-build # an abandoned deployment restarts the old build
272
+ ```
273
+
274
+ - `blocked` lists every blocking instance (ID, flow, state, status, reason) read from storage, including instances this process has not opened and beyond any listing page. It changes nothing: no waiting, termination or budget reset.
275
+ - When nothing blocks, `handover` stops admission and starts closing in the same step, so no dispatch, creation, recovery or manual action can start between the check and the handover. Later calls reject with `RuntimeClosedError`. It resolves `handed-over` once the database is released. If the deployment is then abandoned, start the old build again; every remaining instance is Ready, Completed or Terminated, so nothing needs repair.
276
+ - It rejects when the check fails, and with `ShutdownTimeoutError` when the close times out (the database stays owned until the process exits). Neither permits a deployment. `createRuntime`'s `shutdownTimeoutMs` applies when `timeoutMs` is omitted.
277
+
278
+ When no runtime owns the database, for example after a crash or a manual stop, use `checkDeployment({ databasePath })` or `state-flow deployment-check --database <file>`. It returns the same `ready` / `blocked` result and blockers. While a runtime owns the database it returns `owned`, never readiness; a missing file or one that is not a State Flow database throws. It never creates or migrates the database or changes its data (opening it can roll back a journal a crashed process left behind, as any open does). A new build can run it at startup and refuse to accept work unless it is `ready`.
279
+
280
+ There is no bypass. If a handler defect keeps an instance in Needs attention, the deployment that fixes the defect is blocked too: Terminate that instance with a reason through `runtime.controls` (or the companion's Terminate action), then deploy. Retry and Confirm it completed also resolve blockers, under the handler revision that started the work.
281
+
282
+ The deployment system must call the gate; the library cannot stop a process from being replaced without it. Passing the gate does not waive definition checks: a Ready instance still opens only under a compatible Flow definition.
283
+
284
+ ### Restarts and their limits
285
+
286
+ After a restart, callbacks durably recorded as completed are never run again and their results are reused; a callback that had started but not completed recovers by its declared strategy (reconcile, safe replay, or manual attention) under the same operation ID, with the interrupted run counted against its attempt budget; callbacks that had not started run normally (ADR 0093). State Flow does not provide exactly-once external effects: an effect can complete before its callback's completion is recorded, which is what reconciliation is for. It does not infer business expiry from a restart or a delay; validity checks belong in handlers. Terminating an instance stops its execution and keeps its history, but does not enter a business State or cancel external work already started.
287
+
288
+ ## Consumer linting
135
289
 
136
- `flow.handleDispatch(onFailure)` configures the shared policy once. Every non-applied outcome (`blocked`, `invalid-payload`, `busy`, `recovery-pending`, `needs-attention`, `state-mismatch`, `request-conflict`, `terminated`) produces a `DurableDispatchError` with `outcome`, `instanceId` and correlated `context`. The callback must throw/reject or transfer control; types and runtime guards prevent it from converting a non-applied command into fulfilled success. Infrastructure and programmer errors still reject directly. Pending recovery continues independently while the runtime remains open. Direct dispatch does not automatically retry caller requests or wait for background recovery. If one call needs local result mapping, the existing exhaustive `when(state).handle(outcomes).dispatch(input)` remains available and uses those local handlers instead of the shared policy.
290
+ Use `serviceConfig` or `handlersConfig` from the corresponding `@ai-digital-factory/eslint-plugin-state-flow/eslint.*.config.js` entrypoint and run lint in CI alongside compile and typecheck. These type-aware configurations enforce promise observation, require automatic runtime ownership through await using or withRuntime for services, prevent legacy execution bypasses, and keep runtime construction out of handlers. Explicit framework/operator lifecycle adapters may override the constructor restriction. Prepared dispatchers and unrelated transport methods are permitted. Fire-and-forget work requires explicit rejection handling; `void` alone does not satisfy the lint rule.
137
291
 
138
- `createRuntime({ databasePath, shutdownTimeoutMs })` creates a standalone runtime synchronously. Use `await using runtime = createRuntime(...)` to call operations sequentially and automatically await shutdown on normal completion or exceptions. TypeScript projects should include `ESNext.Disposable` in `compilerOptions.lib`. The configured shutdown timeout applies to disposal and to `runtime.close()`; explicit `close({ timeoutMs })` overrides it. `withDurableRuntime` remains the callback-style alternative. Applications with an existing framework lifecycle can explicitly close the runtime, with a deliberate override of the managed-runtime lint rule.
292
+ `flow.ensureInstance({ instanceId })` opens an existing instance or reserves and creates it once, including for overlapping callers, and starts interrupted recovery. Its snapshot may be running, recovering, awaiting attention, terminated or completed; acquiring an instance does not imply it is ready. Pending recovery continues independently while the runtime remains open, and dispatch does not retry caller requests or wait for background recovery.
139
293
 
140
- The primary flow builder is `runtime.createFlow(compiled, config).handleFailure(onFailure).run()`. Await its terminal run to register the flow. Before that terminal call it neither validates the module nor registers the chart. Acquisition uses `flow.ensureInstance({ instanceId }).run()`, inheriting the same failure policy. An optional `.handleEnsureInstance({ "definition-mismatch": callback })` before run provides a local mismatch override; it must also throw/reject or transfer control.
294
+ `createRuntime({ databasePath, shutdownTimeoutMs })` creates a standalone runtime synchronously. Use `await using runtime = createRuntime(...)` to call operations sequentially and automatically await shutdown on normal completion or exceptions. TypeScript projects should include `ESNext.Disposable` in `compilerOptions.lib`. The configured shutdown timeout applies to disposal and to `runtime.close()`; explicit `close({ timeoutMs })` overrides it. `withRuntime` remains the callback-style alternative. Applications with an existing framework lifecycle can explicitly close the runtime, with a deliberate override of the managed-runtime lint rule.
141
295
 
142
- Both terminal calls are asynchronous and each builder memoizes its run promise. New builders can acquire the same existing instance without recreating it. Failure phases are `create-flow`, `ensure-instance` and `dispatch`. Setup/acquisition operation errors retain the original cause; `DurableDefinitionMismatchError` supplies its structured mismatch context; dispatch errors retain their outcome-specific context. A local mismatch override is exclusive: its exceptions are not rerouted through the shared policy. Advanced eager registration and explicit local outcome APIs remain available for tooling.
296
+ `runtime.createFlow(compiled, dependencies)` validates the module, including every handler's recovery declaration, and registers it before any handler runs. It rejects with `DefinitionError` for an invalid module and `InputError` for a duplicate registration; a failed registration does not reserve the flow name.
143
297
 
144
298
  ## License
145
299