@affordance/core 0.1.0 → 0.2.0

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 (82) hide show
  1. package/README.md +17 -6
  2. package/dist/engine/compute.d.ts +2 -2
  3. package/dist/engine/compute.js.map +1 -1
  4. package/dist/engine/engine.d.ts +14 -22
  5. package/dist/engine/engine.js +35 -17
  6. package/dist/engine/engine.js.map +1 -1
  7. package/dist/errors.d.ts +3 -9
  8. package/dist/errors.js +17 -0
  9. package/dist/errors.js.map +1 -1
  10. package/dist/execution/delta.d.ts +1 -1
  11. package/dist/execution/delta.js +1 -1
  12. package/dist/execution/delta.js.map +1 -1
  13. package/dist/execution/execute.d.ts +19 -18
  14. package/dist/execution/execute.js +22 -25
  15. package/dist/execution/execute.js.map +1 -1
  16. package/dist/execution/index.d.ts +1 -3
  17. package/dist/execution/index.js +1 -3
  18. package/dist/execution/index.js.map +1 -1
  19. package/dist/execution/journal.d.ts +4 -12
  20. package/dist/execution/journal.js +20 -93
  21. package/dist/execution/journal.js.map +1 -1
  22. package/dist/execution/port.d.ts +15 -44
  23. package/dist/execution/port.js +1 -100
  24. package/dist/execution/port.js.map +1 -1
  25. package/dist/execution/replay.d.ts +1 -1
  26. package/dist/execution/replay.js.map +1 -1
  27. package/dist/index.d.ts +7 -5
  28. package/dist/index.js +4 -4
  29. package/dist/index.js.map +1 -1
  30. package/dist/ingestion/correlation.d.ts +0 -32
  31. package/dist/ingestion/correlation.js +1 -77
  32. package/dist/ingestion/correlation.js.map +1 -1
  33. package/dist/ingestion/index.d.ts +1 -2
  34. package/dist/ingestion/index.js +1 -2
  35. package/dist/ingestion/index.js.map +1 -1
  36. package/dist/ingestion/ingest.d.ts +10 -16
  37. package/dist/ingestion/ingest.js +12 -100
  38. package/dist/ingestion/ingest.js.map +1 -1
  39. package/dist/migration/migrate.d.ts +6 -7
  40. package/dist/migration/migrate.js +11 -40
  41. package/dist/migration/migrate.js.map +1 -1
  42. package/dist/model/casetype.d.ts +8 -8
  43. package/dist/model/casetype.js.map +1 -1
  44. package/dist/model/handler.d.ts +21 -15
  45. package/dist/model/handler.js.map +1 -1
  46. package/dist/model/index.d.ts +3 -3
  47. package/dist/model/index.js +1 -1
  48. package/dist/model/index.js.map +1 -1
  49. package/dist/model/step.d.ts +18 -13
  50. package/dist/model/step.js +2 -1
  51. package/dist/model/step.js.map +1 -1
  52. package/dist/model/target.d.ts +12 -15
  53. package/dist/model/target.js +2 -5
  54. package/dist/model/target.js.map +1 -1
  55. package/dist/storage.d.ts +93 -0
  56. package/dist/storage.js +4 -0
  57. package/dist/storage.js.map +1 -0
  58. package/dist/store/ids.d.ts +1 -2
  59. package/dist/store/ids.js +1 -2
  60. package/dist/store/ids.js.map +1 -1
  61. package/dist/store/index.d.ts +3 -8
  62. package/dist/store/index.js +2 -6
  63. package/dist/store/index.js.map +1 -1
  64. package/dist/store/resolve.d.ts +7 -14
  65. package/dist/store/resolve.js +4 -11
  66. package/dist/store/resolve.js.map +1 -1
  67. package/dist/store/store.d.ts +4 -41
  68. package/dist/store/store.js +1 -95
  69. package/dist/store/store.js.map +1 -1
  70. package/package.json +8 -8
  71. package/dist/execution/transaction.d.ts +0 -24
  72. package/dist/execution/transaction.js +0 -49
  73. package/dist/execution/transaction.js.map +0 -1
  74. package/dist/store/bootstrap.d.ts +0 -57
  75. package/dist/store/bootstrap.js +0 -268
  76. package/dist/store/bootstrap.js.map +0 -1
  77. package/dist/store/queryable.d.ts +0 -60
  78. package/dist/store/queryable.js +0 -7
  79. package/dist/store/queryable.js.map +0 -1
  80. package/dist/store/sql.d.ts +0 -26
  81. package/dist/store/sql.js +0 -21
  82. package/dist/store/sql.js.map +0 -1
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ```
5
5
  * ┌── transaction ──────────────┐ ┌── transaction ──────────────┐
6
- * │ lock the case row │ │ lock the case row
6
+ * │ serialize the case │ │ serialize the case
7
7
  * │ take over an expired claim │ handler runs │ verify the claim is ours │
8
8
  * │ re-evaluate the guard ←────┼── here, outside ───┼→ write state, bump seq │
9
9
  * │ insert the claim (lease) │ any transaction │ run ctx.onCommit writes │
@@ -20,9 +20,8 @@
20
20
  * same race, handled the same way). A claim that fails rejects with the
21
21
  * *current* unmet conditions and writes nothing at all.
22
22
  * 2. **No transaction spans a handler.** Handlers call the outside world;
23
- * a transaction held across an escrow API call would tie up a pooled
24
- * connection and the case row's lock for as long as the external service
25
- * takes to answer. The lease (a `claims` row keyed by case id) carries
23
+ * an atomic case operation held across an external call would block other
24
+ * work for as long as the provider takes to answer. An expiring claim carries
26
25
  * the exclusivity instead, and a heartbeat keeps it alive.
27
26
  * 3. **A crashed handler cannot deadlock a case.** The lease expires. The
28
27
  * next claimant takes the case over, journaling the abandonment; if the
@@ -32,7 +31,8 @@
32
31
  */
33
32
  import type { GuardEvaluation, Instant } from '../guards/index.js';
34
33
  import type { AnyCaseType, RetryOptions } from '../model/index.js';
35
- import type { DatabaseAccess, Dormancy } from '../store/index.js';
34
+ import type { EngineStorage } from '../storage.js';
35
+ import type { Dormancy } from '../store/index.js';
36
36
  import type { StateDelta } from './delta.js';
37
37
  import type { LifecyclePort } from './port.js';
38
38
  import type { Timers } from './timers.js';
@@ -40,10 +40,10 @@ import type { Timers } from './timers.js';
40
40
  export declare const DEFAULT_CLAIM_TTL_MS = 30000;
41
41
  export declare const DEFAULT_HEARTBEAT_MS = 5000;
42
42
  /** What {@link executeStep} needs from its caller (the engine supplies all of it). */
43
- export interface ExecutionEnvironment {
44
- readonly db: DatabaseAccess;
43
+ export interface ExecutionEnvironment<TCommit = unknown> {
44
+ readonly storage: EngineStorage<TCommit>;
45
45
  /** Resolve a persisted `case_type` name to its registered definition; throws if unknown. */
46
- readonly caseTypeFor: (caseTypeName: string) => AnyCaseType;
46
+ readonly caseTypeFor: (caseTypeName: string) => AnyCaseType<TCommit>;
47
47
  readonly claimTtlMs: number;
48
48
  readonly heartbeatMs: number;
49
49
  /**
@@ -101,10 +101,11 @@ export interface ExecutionResult<TState = unknown> {
101
101
  }
102
102
  /**
103
103
  * What the lifecycle core needs besides storage: the clock, the timers, and
104
- * the lease timings. Definition resolution is the port's own (its `loadCase`
105
- * returns the case resolved whole), so it is no part of this interface.
104
+ * the lease timings and registry. Storage returns raw records; definition
105
+ * resolution and schema validation belong to this shared lifecycle.
106
106
  */
107
- export interface LifecycleDeps {
107
+ export interface LifecycleDeps<TCommit = unknown> {
108
+ readonly caseTypeFor: (caseTypeName: string) => AnyCaseType<TCommit>;
108
109
  readonly now: () => Date;
109
110
  readonly timers: Timers;
110
111
  readonly claimTtlMs: number;
@@ -113,11 +114,11 @@ export interface LifecycleDeps {
113
114
  /**
114
115
  * The lifecycle core: claim → run → commit against a {@link LifecyclePort}.
115
116
  *
116
- * {@link executeStep} binds this to the pg port; the claim state machine's
117
+ * {@link executeStep} supplies the configured storage adapter; the claim state machine's
117
118
  * own tests bind it to an in-memory port instead. Same body either way — the
118
119
  * port is the only storage the lifecycle knows.
119
120
  */
120
- export declare const runLifecycle: (port: LifecyclePort, deps: LifecycleDeps, caseId: string, stepName: string, options: ExecuteOptions) => Promise<ExecutionResult>;
121
+ export declare const runLifecycle: <TCommit>(port: LifecyclePort<TCommit>, deps: LifecycleDeps<TCommit>, caseId: string, stepName: string, options: ExecuteOptions) => Promise<ExecutionResult>;
121
122
  /**
122
123
  * Execute one step on one case: claim it, run its handler, commit the result.
123
124
  *
@@ -133,9 +134,9 @@ export declare const runLifecycle: (port: LifecyclePort, deps: LifecycleDeps, ca
133
134
  * run out, a `failed` entry is journaled, the case is released, and
134
135
  * {@link StepExecutionError} is thrown.
135
136
  *
136
- * This is {@link runLifecycle} bound to the pg port.
137
+ * This runs the shared lifecycle with the configured storage adapter.
137
138
  */
138
- export declare const executeStep: (env: ExecutionEnvironment, caseId: string, stepName: string, options: ExecuteOptions) => Promise<ExecutionResult>;
139
+ export declare const executeStep: <TCommit>(env: ExecutionEnvironment<TCommit>, caseId: string, stepName: string, options: ExecuteOptions) => Promise<ExecutionResult>;
139
140
  /** A committed system run. */
140
141
  export interface SystemCommit {
141
142
  readonly outcome: 'committed';
@@ -156,13 +157,13 @@ export interface SystemSettled {
156
157
  /** How a step the system ran ended — total over every way the lifecycle can answer. */
157
158
  export type SystemRunOutcome = SystemCommit | SystemSettled;
158
159
  /** What a sweep may ask for beyond the lifecycle's own execute options. */
159
- export interface SystemRunOptions extends ExecuteOptions {
160
+ export interface SystemRunOptions<TCommit = unknown> extends ExecuteOptions {
160
161
  /**
161
162
  * Run against this definition instead of the registry's — how a migration
162
163
  * executes its synthetic `migrate:<name>` step. First-class here so no
163
164
  * sweep has to smuggle a definition in by rewriting `caseTypeFor`.
164
165
  */
165
- readonly definition?: AnyCaseType;
166
+ readonly definition?: AnyCaseType<TCommit>;
166
167
  }
167
168
  /**
168
169
  * Turn a throw into the settled value. No classification happens here —
@@ -176,4 +177,4 @@ export declare const settleSystemRun: (error: unknown) => SystemSettled;
176
177
  * so the answer is a value — committed (with the result) or settled (with
177
178
  * the error) — and each sweep decides what its kind of sweep does with it.
178
179
  */
179
- export declare const runAsSystem: (env: ExecutionEnvironment, caseId: string, stepName: string, options: SystemRunOptions) => Promise<SystemRunOutcome>;
180
+ export declare const runAsSystem: <TCommit>(env: ExecutionEnvironment<TCommit>, caseId: string, stepName: string, options: SystemRunOptions<TCommit>) => Promise<SystemRunOutcome>;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ```
5
5
  * ┌── transaction ──────────────┐ ┌── transaction ──────────────┐
6
- * │ lock the case row │ │ lock the case row
6
+ * │ serialize the case │ │ serialize the case
7
7
  * │ take over an expired claim │ handler runs │ verify the claim is ours │
8
8
  * │ re-evaluate the guard ←────┼── here, outside ───┼→ write state, bump seq │
9
9
  * │ insert the claim (lease) │ any transaction │ run ctx.onCommit writes │
@@ -20,9 +20,8 @@
20
20
  * same race, handled the same way). A claim that fails rejects with the
21
21
  * *current* unmet conditions and writes nothing at all.
22
22
  * 2. **No transaction spans a handler.** Handlers call the outside world;
23
- * a transaction held across an escrow API call would tie up a pooled
24
- * connection and the case row's lock for as long as the external service
25
- * takes to answer. The lease (a `claims` row keyed by case id) carries
23
+ * an atomic case operation held across an external call would block other
24
+ * work for as long as the provider takes to answer. An expiring claim carries
26
25
  * the exclusivity instead, and a heartbeat keeps it alive.
27
26
  * 3. **A crashed handler cannot deadlock a case.** The lease expires. The
28
27
  * next claimant takes the case over, journaling the abandonment; if the
@@ -32,12 +31,10 @@
32
31
  */
33
32
  import { thrownMessage, toError } from '../errors.js';
34
33
  import { toIso } from '../guards/index.js';
35
- import { registerCorrelation } from '../ingestion/correlation.js';
36
34
  import { evaluateTarget, normalizeRetry, resolveTarget, validateStepInput, } from '../model/index.js';
37
- import { mintId, validateCaseState } from '../store/index.js';
35
+ import { mintId, resolveCase, validateCaseState } from '../store/index.js';
38
36
  import { diffState } from './delta.js';
39
37
  import { CaseBusyError, ClaimLostError, StepExecutionError, StepNotAvailableError, } from './errors.js';
40
- import { pgLifecyclePort } from './port.js';
41
38
  /** How long a claim survives without a heartbeat, and how often the heartbeat beats. */
42
39
  export const DEFAULT_CLAIM_TTL_MS = 30_000;
43
40
  export const DEFAULT_HEARTBEAT_MS = 5_000;
@@ -59,7 +56,7 @@ const toJournalError = (error) => error instanceof Error
59
56
  : { name: 'UnknownError', message: String(error) };
60
57
  /**
61
58
  * Resolve whatever claim already sits on this case, inside the claim
62
- * transaction (the case row is locked, so no two claimants decide this at
59
+ * transaction (the case operation is serialized, so no two claimants decide this at
63
60
  * once): a live claim means the case is busy; an expired one is a crashed
64
61
  * handler's abandoned lease — journal the abandonment and take it.
65
62
  *
@@ -98,12 +95,12 @@ const clearStaleClaim = async (tx, caseId, claimant) => {
98
95
  /**
99
96
  * The claim: one short transaction that either takes the case and journals a
100
97
  * `claimed` entry, or writes nothing and throws the reason. Loading, guard
101
- * re-evaluation, and the exclusivity decision all happen under the case row's
102
- * lock, which is what makes "exactly one of N concurrent attempts claims"
98
+ * re-evaluation, and the exclusivity decision all happen under the case's
99
+ * serialization, which is what makes "exactly one of N concurrent attempts claims"
103
100
  * true rather than likely.
104
101
  */
105
- const claimCase = async (port, deps, caseId, stepName, options, executionId, claimTtlMs) => port.withCaseLock(caseId, async (tx) => {
106
- const { definition, handle, state } = await tx.loadCase();
102
+ const claimCase = async (port, deps, caseId, stepName, options, executionId, claimTtlMs) => port.withCase(caseId, async (tx) => {
103
+ const { definition, handle, state } = await resolveCase(await tx.loadCase(), deps.caseTypeFor);
107
104
  await clearStaleClaim(tx, caseId, executionId);
108
105
  const target = resolveTarget(definition, state, stepName, options.scopeKey);
109
106
  const scopeKey = target.binding?.key ?? null;
@@ -148,13 +145,12 @@ const claimCase = async (port, deps, caseId, stepName, options, executionId, cla
148
145
  */
149
146
  const commitExecution = async (port, claim, caseId, stepName, nextState, attempt, dormancy, writes) => {
150
147
  // Both inputs are fixed before the transaction opens, so the deep diff —
151
- // pure CPU over the whole state document — runs before the row lock is
152
- // taken, not while holding it.
148
+ // pure CPU over the whole state document — runs before case serialization
149
+ // begins, not while holding it.
153
150
  const delta = diffState(claim.state, nextState);
154
- return port.withCaseLock(caseId, async (tx) => {
155
- await tx.lockCase();
151
+ return port.withCase(caseId, async (tx) => {
156
152
  // The one check that makes a stale write impossible. Changing Case State
157
- // requires holding the claim, and taking the claim over replaces this row
153
+ // requires holding the claim, and taking the claim over replaces this ownership record
158
154
  // — so finding our own execution id here means nothing has committed on
159
155
  // this case since we claimed it, and the state the handler computed from
160
156
  // is still current. An expired-but-undisturbed lease therefore commits
@@ -165,7 +161,7 @@ const commitExecution = async (port, claim, caseId, stepName, nextState, attempt
165
161
  throw new ClaimLostError(caseId, claim.executionId, holder);
166
162
  }
167
163
  const updated = await tx.updateCaseState(nextState, dormancy);
168
- await tx.appWrites(writes);
164
+ await tx.applyEffects(writes);
169
165
  const entry = await tx.appendEntry({
170
166
  caseId,
171
167
  executionId: claim.executionId,
@@ -199,7 +195,7 @@ const commitExecution = async (port, claim, caseId, stepName, nextState, attempt
199
195
  /**
200
196
  * The lifecycle core: claim → run → commit against a {@link LifecyclePort}.
201
197
  *
202
- * {@link executeStep} binds this to the pg port; the claim state machine's
198
+ * {@link executeStep} supplies the configured storage adapter; the claim state machine's
203
199
  * own tests bind it to an in-memory port instead. Same body either way — the
204
200
  * port is the only storage the lifecycle knows.
205
201
  */
@@ -240,7 +236,7 @@ export const runLifecycle = async (port, deps, caseId, stepName, options) => {
240
236
  attempt,
241
237
  maxAttempts: policy.maxAttempts,
242
238
  onCommit: (write) => {
243
- writes.push(write);
239
+ writes.push({ kind: 'write', write });
244
240
  },
245
241
  // Correlation is an ordinary commit write: the mapping
246
242
  // lands in the same transaction as the state that says the external
@@ -248,14 +244,15 @@ export const runLifecycle = async (port, deps, caseId, stepName, options) => {
248
244
  // its own element — an envelope sent for buyer #7 belongs to
249
245
  // buyer #7 unless the handler says otherwise.
250
246
  correlate: (request) => {
251
- writes.push(async (tx) => {
252
- await registerCorrelation(tx, {
247
+ writes.push({
248
+ kind: 'correlation',
249
+ registration: {
253
250
  ...request,
254
251
  caseId,
255
252
  scopeKey: request.scopeKey === undefined
256
253
  ? claim.scopeKey
257
254
  : request.scopeKey,
258
- });
255
+ },
259
256
  });
260
257
  },
261
258
  end: () => {
@@ -322,9 +319,9 @@ export const runLifecycle = async (port, deps, caseId, stepName, options) => {
322
319
  * run out, a `failed` entry is journaled, the case is released, and
323
320
  * {@link StepExecutionError} is thrown.
324
321
  *
325
- * This is {@link runLifecycle} bound to the pg port.
322
+ * This runs the shared lifecycle with the configured storage adapter.
326
323
  */
327
- export const executeStep = async (env, caseId, stepName, options) => runLifecycle(pgLifecyclePort(env.db, env.caseTypeFor), env, caseId, stepName, options);
324
+ export const executeStep = async (env, caseId, stepName, options) => runLifecycle(env.storage.execution, env, caseId, stepName, options);
328
325
  /**
329
326
  * Turn a throw into the settled value. No classification happens here —
330
327
  * "is this a Refusal" is asked exactly once, where a consumer needs the
@@ -1 +1 @@
1
- {"version":3,"file":"execute.js","sourceRoot":"","sources":["../../src/execution/execute.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The execution lifecycle: **claim → run → commit**.\n *\n * ```\n * ┌── transaction ──────────────┐ ┌── transaction ──────────────┐\n * │ lock the case row │ │ lock the case row │\n * │ take over an expired claim │ handler runs │ verify the claim is ours │\n * │ re-evaluate the guard ←────┼── here, outside ───┼→ write state, bump seq │\n * │ insert the claim (lease) │ any transaction │ run ctx.onCommit writes │\n * │ journal `claimed` │ (heartbeating) │ journal `completed` │\n * └─────────────────────────────┘ └─────────────────────────────┘\n * ```\n *\n * Three things this shape buys, each load-bearing:\n *\n * 1. **Guards advise, handlers enforce.** The affordance a client rendered is\n * advice; the claim's transactional re-evaluation is the enforcement\n * moment. State may have moved (another Execution committed) or the\n * definitions may have (a deploy — definition drift is the\n * same race, handled the same way). A claim that fails rejects with the\n * *current* unmet conditions and writes nothing at all.\n * 2. **No transaction spans a handler.** Handlers call the outside world;\n * a transaction held across an escrow API call would tie up a pooled\n * connection and the case row's lock for as long as the external service\n * takes to answer. The lease (a `claims` row keyed by case id) carries\n * the exclusivity instead, and a heartbeat keeps it alive.\n * 3. **A crashed handler cannot deadlock a case.** The lease expires. The\n * next claimant takes the case over, journaling the abandonment; if the\n * zombie ever comes back to commit, it is refused ({@link ClaimLostError})\n * — at-least-once effects are the handler's problem to deduplicate on\n * `ctx.executionId`, but a stale state write is the framework's to refuse.\n */\n\nimport { thrownMessage, toError } from '../errors.js'\nimport type { GuardEvaluation, Instant } from '../guards/index.js'\nimport { toIso } from '../guards/index.js'\nimport { registerCorrelation } from '../ingestion/correlation.js'\nimport type {\n AnyCaseType,\n CommitWrite,\n CorrelationRequest,\n RetryOptions,\n RetryPolicy,\n StepTarget,\n} from '../model/index.js'\nimport {\n evaluateTarget,\n normalizeRetry,\n resolveTarget,\n validateStepInput,\n} from '../model/index.js'\nimport type { DatabaseAccess, Dormancy } from '../store/index.js'\nimport { mintId, validateCaseState } from '../store/index.js'\nimport type { StateDelta } from './delta.js'\nimport { diffState } from './delta.js'\nimport {\n CaseBusyError,\n ClaimLostError,\n StepExecutionError,\n StepNotAvailableError,\n} from './errors.js'\nimport type { JournalError } from './journal.js'\nimport type { LifecyclePort, LifecycleTx } from './port.js'\nimport { pgLifecyclePort } from './port.js'\nimport type { Timers } from './timers.js'\n\n/** How long a claim survives without a heartbeat, and how often the heartbeat beats. */\nexport const DEFAULT_CLAIM_TTL_MS = 30_000\nexport const DEFAULT_HEARTBEAT_MS = 5_000\n\n/** What {@link executeStep} needs from its caller (the engine supplies all of it). */\nexport interface ExecutionEnvironment {\n readonly db: DatabaseAccess\n /** Resolve a persisted `case_type` name to its registered definition; throws if unknown. */\n readonly caseTypeFor: (caseTypeName: string) => AnyCaseType\n readonly claimTtlMs: number\n readonly heartbeatMs: number\n /**\n * The clock. Conditions never read it; every process-side \"as of now\"\n * comes through here, so a test can hand the lifecycle a deterministic\n * instant instead of building fixtures around far-future dates. (Lease\n * expiry is judged by the storage adapter's own clock — see\n * {@link LifecyclePort}.)\n */\n readonly now: () => Date\n /** The process timers — retry delays and the heartbeat. See {@link Timers}. */\n readonly timers: Timers\n}\n\n/** Options for one execute call. */\nexport interface ExecuteOptions<TActor = unknown> {\n /** The Actor executing the step; `permits` conditions are evaluated against it. */\n readonly actor: TActor\n /** Required for a scoped step, forbidden otherwise — the element's scope key. */\n readonly scopeKey?: string\n /** The step's input, validated against its declared input schema before the handler runs. */\n readonly input?: unknown\n /** The instant to re-evaluate the guard as of; defaults to now. */\n readonly asOf?: Instant\n /** Override the claim lease for this Execution (long-running handler). */\n readonly claimTtlMs?: number\n /** Override the heartbeat interval for this Execution. */\n readonly heartbeatMs?: number\n /** Override the step's declared retry policy for this Execution. */\n readonly retry?: RetryOptions\n}\n\n/** A committed Execution — what `execute` resolves to. */\nexport interface ExecutionResult<TState = unknown> {\n readonly executionId: string\n readonly caseId: string\n readonly caseTypeName: string\n readonly step: string\n /** Present iff the step is scoped. */\n readonly scopeKey?: string\n /** How many attempts ran, including the one that succeeded. */\n readonly attempts: number\n /** The claim-time guard evaluation — the enforcement moment, as journaled. */\n readonly guard: GuardEvaluation\n /** The committed Case State. */\n readonly state: TState\n /** What changed, as JSON Patch. */\n readonly delta: StateDelta\n /** The case's sequence counter after this Execution. */\n readonly seq: number\n /** `end()` / `reopen()` called by the handler, if either was. */\n readonly dormancy: Dormancy | null\n /** The case's dormancy marker after this Execution (ISO-8601 UTC), `null` while active. */\n readonly endedAt: string | null\n readonly claimedAt: string\n readonly committedAt: string\n}\n\n/** A held claim plus everything the run and commit phases need from the claim transaction. */\ninterface Claim {\n readonly executionId: string\n readonly caseTypeName: string\n readonly definition: AnyCaseType\n readonly target: StepTarget<unknown, unknown>\n readonly state: unknown\n readonly input: unknown\n readonly asOf: string\n readonly guard: GuardEvaluation\n readonly claimedAt: string\n readonly scopeKey: string | null\n readonly actor: unknown\n}\n\n/**\n * Wraps a failure that must not be retried because retrying is guaranteed to\n * reproduce it: a handler returning a Case State its schema rejects, or a\n * commit refused because the claim is gone.\n */\nclass NonRetryable extends Error {\n readonly reason: unknown\n constructor(reason: unknown) {\n super(thrownMessage(reason))\n this.name = 'NonRetryable'\n this.reason = reason\n }\n}\n\nconst toJournalError = (error: unknown): JournalError =>\n error instanceof Error\n ? { name: error.name, message: error.message }\n : { name: 'UnknownError', message: String(error) }\n\n/**\n * Resolve whatever claim already sits on this case, inside the claim\n * transaction (the case row is locked, so no two claimants decide this at\n * once): a live claim means the case is busy; an expired one is a crashed\n * handler's abandoned lease — journal the abandonment and take it.\n *\n * Both the `expired` entry and the takeover ride on this transaction, so a\n * claim that goes on to fail its guard rolls the abandonment record back and\n * leaves the dead lease in place. That is deliberate: an expired lease blocks\n * nothing, and the abandonment is only a fact worth recording when somebody\n * actually took the case over.\n */\nconst clearStaleClaim = async (\n tx: LifecycleTx,\n caseId: string,\n claimant: string,\n): Promise<void> => {\n const held = await tx.currentClaim()\n if (!held) return\n if (!held.expired) {\n throw new CaseBusyError(caseId, {\n executionId: held.executionId,\n stepName: held.step,\n scopeKey: held.scopeKey,\n expiresAt: held.expiresAt,\n })\n }\n await tx.appendEntry({\n caseId,\n executionId: held.executionId,\n entry: 'expired',\n attempt: held.attempt,\n step: held.step,\n scopeKey: held.scopeKey,\n error: {\n name: 'ClaimExpiredError',\n message: `claim expired at ${held.expiresAt} with no terminal entry; case taken over by execution ${claimant}`,\n },\n })\n await tx.deleteClaim(held.executionId)\n}\n\n/**\n * What the lifecycle core needs besides storage: the clock, the timers, and\n * the lease timings. Definition resolution is the port's own (its `loadCase`\n * returns the case resolved whole), so it is no part of this interface.\n */\nexport interface LifecycleDeps {\n readonly now: () => Date\n readonly timers: Timers\n readonly claimTtlMs: number\n readonly heartbeatMs: number\n}\n\n/**\n * The claim: one short transaction that either takes the case and journals a\n * `claimed` entry, or writes nothing and throws the reason. Loading, guard\n * re-evaluation, and the exclusivity decision all happen under the case row's\n * lock, which is what makes \"exactly one of N concurrent attempts claims\"\n * true rather than likely.\n */\nconst claimCase = async (\n port: LifecyclePort,\n deps: LifecycleDeps,\n caseId: string,\n stepName: string,\n options: ExecuteOptions,\n executionId: string,\n claimTtlMs: number,\n): Promise<Claim> =>\n port.withCaseLock(caseId, async (tx) => {\n const { definition, handle, state } = await tx.loadCase()\n\n await clearStaleClaim(tx, caseId, executionId)\n\n const target = resolveTarget(\n definition,\n state,\n stepName,\n options.scopeKey,\n ) as StepTarget<unknown, unknown>\n const scopeKey = target.binding?.key ?? null\n const input = await validateStepInput(target.step, options.input)\n\n const asOf = toIso(options.asOf ?? deps.now())\n const guard = evaluateTarget(target, { actor: options.actor, asOf })\n if (!guard.available)\n throw new StepNotAvailableError(caseId, stepName, scopeKey, guard)\n\n const { claimedAt } = await tx.insertClaim(\n executionId,\n stepName,\n scopeKey,\n claimTtlMs,\n )\n\n await tx.appendEntry({\n caseId,\n executionId,\n entry: 'claimed',\n attempt: 1,\n step: stepName,\n scopeKey,\n actor: options.actor,\n input,\n asOf,\n guard,\n // The Case State the guard was evaluated against, stored so audit\n // reconstruction is exact rather than approximate.\n state,\n })\n\n return {\n executionId,\n caseTypeName: handle.caseTypeName,\n definition,\n target,\n state,\n input,\n asOf,\n guard,\n claimedAt,\n scopeKey,\n actor: options.actor,\n }\n })\n\n/**\n * The commit: the new Case State, the app's own writes, and the `completed`\n * journal entry, all in one transaction — or none of them.\n */\nconst commitExecution = async (\n port: LifecyclePort,\n claim: Claim,\n caseId: string,\n stepName: string,\n nextState: unknown,\n attempt: number,\n dormancy: Dormancy | null,\n writes: readonly CommitWrite[],\n): Promise<ExecutionResult> => {\n // Both inputs are fixed before the transaction opens, so the deep diff —\n // pure CPU over the whole state document — runs before the row lock is\n // taken, not while holding it.\n const delta = diffState(claim.state, nextState)\n return port.withCaseLock(caseId, async (tx) => {\n await tx.lockCase()\n // The one check that makes a stale write impossible. Changing Case State\n // requires holding the claim, and taking the claim over replaces this row\n // — so finding our own execution id here means nothing has committed on\n // this case since we claimed it, and the state the handler computed from\n // is still current. An expired-but-undisturbed lease therefore commits\n // quite legitimately: a handler that overran its lease with no contender\n // has raced nobody.\n const holder = (await tx.currentClaim())?.executionId ?? null\n if (holder !== claim.executionId) {\n throw new ClaimLostError(caseId, claim.executionId, holder)\n }\n\n const updated = await tx.updateCaseState(nextState, dormancy)\n await tx.appWrites(writes)\n\n const entry = await tx.appendEntry({\n caseId,\n executionId: claim.executionId,\n entry: 'completed',\n attempt,\n step: stepName,\n scopeKey: claim.scopeKey,\n actor: claim.actor,\n delta,\n dormancy,\n })\n await tx.deleteClaim(claim.executionId)\n\n return {\n executionId: claim.executionId,\n caseId,\n caseTypeName: claim.caseTypeName,\n step: stepName,\n ...(claim.scopeKey !== null && { scopeKey: claim.scopeKey }),\n attempts: attempt,\n guard: claim.guard,\n state: nextState,\n delta,\n seq: updated.seq,\n dormancy,\n endedAt: updated.endedAt,\n claimedAt: claim.claimedAt,\n committedAt: entry.recordedAt,\n }\n })\n}\n\n/**\n * The lifecycle core: claim → run → commit against a {@link LifecyclePort}.\n *\n * {@link executeStep} binds this to the pg port; the claim state machine's\n * own tests bind it to an in-memory port instead. Same body either way — the\n * port is the only storage the lifecycle knows.\n */\nexport const runLifecycle = async (\n port: LifecyclePort,\n deps: LifecycleDeps,\n caseId: string,\n stepName: string,\n options: ExecuteOptions,\n): Promise<ExecutionResult> => {\n const executionId = mintId('execution')\n const claimTtlMs = options.claimTtlMs ?? deps.claimTtlMs\n const heartbeatMs = options.heartbeatMs ?? deps.heartbeatMs\n\n const claim = await claimCase(\n port,\n deps,\n caseId,\n stepName,\n options,\n executionId,\n claimTtlMs,\n )\n const policy: RetryPolicy =\n options.retry === undefined\n ? claim.target.step.retry\n : normalizeRetry(stepName, options.retry)\n // Keep the lease alive while the handler runs. Best-effort: a failed beat\n // just lets the claim age.\n const stopHeartbeat = deps.timers.every(heartbeatMs, () => {\n void port.heartbeat(caseId, executionId, claimTtlMs)\n })\n\n const journal = (\n entry: 'attempt-failed' | 'failed',\n attempt: number,\n error: JournalError,\n ): Promise<unknown> =>\n port.appendEntry({\n caseId,\n executionId,\n entry,\n attempt,\n step: stepName,\n scopeKey: claim.scopeKey,\n actor: claim.actor,\n error,\n })\n\n try {\n for (let attempt = 1; ; attempt += 1) {\n // Per-attempt, never carried over: a failed attempt's registered writes\n // and dormancy intent are discarded with the attempt that made them.\n const writes: CommitWrite[] = []\n let dormancy: Dormancy | null = null\n\n const context = {\n executionId,\n caseId,\n actor: options.actor,\n input: claim.input,\n attempt,\n maxAttempts: policy.maxAttempts,\n onCommit: (write: CommitWrite) => {\n writes.push(write)\n },\n // Correlation is an ordinary commit write: the mapping\n // lands in the same transaction as the state that says the external\n // interaction was started. A scoped step's registration defaults to\n // its own element — an envelope sent for buyer #7 belongs to\n // buyer #7 unless the handler says otherwise.\n correlate: (request: CorrelationRequest) => {\n writes.push(async (tx) => {\n await registerCorrelation(tx, {\n ...request,\n caseId,\n scopeKey:\n request.scopeKey === undefined\n ? claim.scopeKey\n : request.scopeKey,\n })\n })\n },\n end: () => {\n dormancy = 'ended'\n },\n reopen: () => {\n dormancy = 'reopened'\n },\n ...(claim.target.binding !== null && {\n scope: claim.target.binding.element,\n scopeKey: claim.target.binding.key,\n }),\n }\n\n try {\n const returned = await claim.target.step.handler(claim.state, context)\n const nextState = await validateCaseState(\n claim.definition,\n returned,\n `state returned by step '${stepName}'`,\n ).catch((error) => {\n // A handler that returns state its own schema rejects is a\n // deterministic defect: the next attempt would return it again.\n throw new NonRetryable(error)\n })\n return await commitExecution(\n port,\n claim,\n caseId,\n stepName,\n nextState,\n attempt,\n dormancy,\n writes,\n )\n } catch (error) {\n const fatal =\n error instanceof NonRetryable || error instanceof ClaimLostError\n const cause = error instanceof NonRetryable ? error.reason : error\n const journalError = toJournalError(cause)\n\n if (!fatal && attempt < policy.maxAttempts) {\n // The attempt-failed entry and the lease's attempt counter (kept\n // current so a takeover's `expired` entry names the right attempt)\n // are independent writes — one round trip, not two.\n await Promise.all([\n journal('attempt-failed', attempt, journalError),\n port.bumpAttempt(caseId, executionId, attempt + 1),\n ])\n await deps.timers.sleep(policy.delayMs(attempt))\n continue\n }\n await Promise.all([\n journal('failed', attempt, journalError),\n port.releaseClaim(caseId, executionId),\n ])\n if (cause instanceof ClaimLostError) throw cause\n throw new StepExecutionError(\n caseId,\n executionId,\n stepName,\n claim.scopeKey,\n attempt,\n cause,\n )\n }\n }\n } finally {\n stopHeartbeat()\n }\n}\n\n/**\n * Execute one step on one case: claim it, run its handler, commit the result.\n *\n * Rejections before anything runs — {@link StepNotAvailableError} (the guard\n * said no), {@link CaseBusyError} (another Execution holds the case),\n * `UnknownStepError` / `ScopeKeyError` (bad address) — write nothing, not\n * even a journal entry: the journal records Executions, and a refused claim\n * never became one.\n *\n * A handler that throws is retried per the step's retry policy (same\n * `executionId`, same claim, same starting state — nothing else can have\n * moved it), each failure journaled as `attempt-failed`. When the attempts\n * run out, a `failed` entry is journaled, the case is released, and\n * {@link StepExecutionError} is thrown.\n *\n * This is {@link runLifecycle} bound to the pg port.\n */\nexport const executeStep = async (\n env: ExecutionEnvironment,\n caseId: string,\n stepName: string,\n options: ExecuteOptions,\n): Promise<ExecutionResult> =>\n runLifecycle(\n pgLifecyclePort(env.db, env.caseTypeFor),\n env,\n caseId,\n stepName,\n options,\n )\n\n// ── The system runner ──────────────────────────────────────────────────────\n//\n// `docs/architecture.md` draws a lenient/loud pair wherever the same need\n// recurs: loud for an addressed request, lenient for a sweep. For running a\n// step, loud is `Engine.execute` — it propagates, because somebody named a\n// case and a step and is owed the refusal. The lenient half is here: every\n// sweep (ingestion, migration) runs steps *as the system*, with no caller\n// an exception could reach, and each would otherwise wrap the lifecycle in\n// its own try/catch classification. `runAsSystem` is the lenient half\n// stated once — a total, named outcome the sweeps filter, the same way\n// listings are filters over `selectTargets`.\n\n/** A committed system run. */\nexport interface SystemCommit {\n readonly outcome: 'committed'\n readonly result: ExecutionResult\n}\n\n/**\n * A system run that did not commit, carrying whatever the lifecycle threw —\n * a Refusal keeps its identity (its code is the answer, projectable by\n * `isAffordanceError`, never re-derived from a class), a bug or an\n * infrastructure failure passes through as itself. One settled variant, not\n * a refused/failed pair: no sweep ever treated the halves differently, and a\n * discriminant nobody branches on is interface without behaviour.\n */\nexport interface SystemSettled {\n readonly outcome: 'settled'\n readonly error: Error\n}\n\n/** How a step the system ran ended — total over every way the lifecycle can answer. */\nexport type SystemRunOutcome = SystemCommit | SystemSettled\n\n/** What a sweep may ask for beyond the lifecycle's own execute options. */\nexport interface SystemRunOptions extends ExecuteOptions {\n /**\n * Run against this definition instead of the registry's — how a migration\n * executes its synthetic `migrate:<name>` step. First-class here so no\n * sweep has to smuggle a definition in by rewriting `caseTypeFor`.\n */\n readonly definition?: AnyCaseType\n}\n\n/**\n * Turn a throw into the settled value. No classification happens here —\n * \"is this a Refusal\" is asked exactly once, where a consumer needs the\n * distinction (ingestion's `classifyDeadLetter`), via `isAffordanceError`.\n */\nexport const settleSystemRun = (error: unknown): SystemSettled => ({\n outcome: 'settled',\n error: toError(error),\n})\n\n/**\n * Run one step on behalf of the system and say how it ended. Never throws:\n * a sweep ranges over many cases with no caller waiting on any single one,\n * so the answer is a value — committed (with the result) or settled (with\n * the error) — and each sweep decides what its kind of sweep does with it.\n */\nexport const runAsSystem = async (\n env: ExecutionEnvironment,\n caseId: string,\n stepName: string,\n options: SystemRunOptions,\n): Promise<SystemRunOutcome> => {\n const { definition, ...lifecycleOptions } = options\n const environment: ExecutionEnvironment =\n definition === undefined ? env : { ...env, caseTypeFor: () => definition }\n try {\n return {\n outcome: 'committed',\n result: await executeStep(\n environment,\n caseId,\n stepName,\n lifecycleOptions,\n ),\n }\n } catch (error) {\n return settleSystemRun(error)\n }\n}\n"]}
1
+ {"version":3,"file":"execute.js","sourceRoot":"","sources":["../../src/execution/execute.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The execution lifecycle: **claim → run → commit**.\n *\n * ```\n * ┌── transaction ──────────────┐ ┌── transaction ──────────────┐\n * │ serialize the case │ │ serialize the case │\n * │ take over an expired claim │ handler runs │ verify the claim is ours │\n * │ re-evaluate the guard ←────┼── here, outside ───┼→ write state, bump seq │\n * │ insert the claim (lease) │ any transaction │ run ctx.onCommit writes │\n * │ journal `claimed` │ (heartbeating) │ journal `completed` │\n * └─────────────────────────────┘ └─────────────────────────────┘\n * ```\n *\n * Three things this shape buys, each load-bearing:\n *\n * 1. **Guards advise, handlers enforce.** The affordance a client rendered is\n * advice; the claim's transactional re-evaluation is the enforcement\n * moment. State may have moved (another Execution committed) or the\n * definitions may have (a deploy — definition drift is the\n * same race, handled the same way). A claim that fails rejects with the\n * *current* unmet conditions and writes nothing at all.\n * 2. **No transaction spans a handler.** Handlers call the outside world;\n * an atomic case operation held across an external call would block other\n * work for as long as the provider takes to answer. An expiring claim carries\n * the exclusivity instead, and a heartbeat keeps it alive.\n * 3. **A crashed handler cannot deadlock a case.** The lease expires. The\n * next claimant takes the case over, journaling the abandonment; if the\n * zombie ever comes back to commit, it is refused ({@link ClaimLostError})\n * — at-least-once effects are the handler's problem to deduplicate on\n * `ctx.executionId`, but a stale state write is the framework's to refuse.\n */\n\nimport { thrownMessage, toError } from '../errors.js'\nimport type { GuardEvaluation, Instant } from '../guards/index.js'\nimport { toIso } from '../guards/index.js'\nimport type {\n AnyCaseType,\n CommitEffect,\n CommitWrite,\n CorrelationRequest,\n RetryOptions,\n RetryPolicy,\n StepTarget,\n} from '../model/index.js'\nimport {\n evaluateTarget,\n normalizeRetry,\n resolveTarget,\n validateStepInput,\n} from '../model/index.js'\nimport type { EngineStorage } from '../storage.js'\nimport type { Dormancy } from '../store/index.js'\nimport { mintId, resolveCase, validateCaseState } from '../store/index.js'\nimport type { StateDelta } from './delta.js'\nimport { diffState } from './delta.js'\nimport {\n CaseBusyError,\n ClaimLostError,\n StepExecutionError,\n StepNotAvailableError,\n} from './errors.js'\nimport type { JournalError } from './journal.js'\nimport type { LifecyclePort, LifecycleTx } from './port.js'\nimport type { Timers } from './timers.js'\n\n/** How long a claim survives without a heartbeat, and how often the heartbeat beats. */\nexport const DEFAULT_CLAIM_TTL_MS = 30_000\nexport const DEFAULT_HEARTBEAT_MS = 5_000\n\n/** What {@link executeStep} needs from its caller (the engine supplies all of it). */\nexport interface ExecutionEnvironment<TCommit = unknown> {\n readonly storage: EngineStorage<TCommit>\n /** Resolve a persisted `case_type` name to its registered definition; throws if unknown. */\n readonly caseTypeFor: (caseTypeName: string) => AnyCaseType<TCommit>\n readonly claimTtlMs: number\n readonly heartbeatMs: number\n /**\n * The clock. Conditions never read it; every process-side \"as of now\"\n * comes through here, so a test can hand the lifecycle a deterministic\n * instant instead of building fixtures around far-future dates. (Lease\n * expiry is judged by the storage adapter's own clock — see\n * {@link LifecyclePort}.)\n */\n readonly now: () => Date\n /** The process timers — retry delays and the heartbeat. See {@link Timers}. */\n readonly timers: Timers\n}\n\n/** Options for one execute call. */\nexport interface ExecuteOptions<TActor = unknown> {\n /** The Actor executing the step; `permits` conditions are evaluated against it. */\n readonly actor: TActor\n /** Required for a scoped step, forbidden otherwise — the element's scope key. */\n readonly scopeKey?: string\n /** The step's input, validated against its declared input schema before the handler runs. */\n readonly input?: unknown\n /** The instant to re-evaluate the guard as of; defaults to now. */\n readonly asOf?: Instant\n /** Override the claim lease for this Execution (long-running handler). */\n readonly claimTtlMs?: number\n /** Override the heartbeat interval for this Execution. */\n readonly heartbeatMs?: number\n /** Override the step's declared retry policy for this Execution. */\n readonly retry?: RetryOptions\n}\n\n/** A committed Execution — what `execute` resolves to. */\nexport interface ExecutionResult<TState = unknown> {\n readonly executionId: string\n readonly caseId: string\n readonly caseTypeName: string\n readonly step: string\n /** Present iff the step is scoped. */\n readonly scopeKey?: string\n /** How many attempts ran, including the one that succeeded. */\n readonly attempts: number\n /** The claim-time guard evaluation — the enforcement moment, as journaled. */\n readonly guard: GuardEvaluation\n /** The committed Case State. */\n readonly state: TState\n /** What changed, as JSON Patch. */\n readonly delta: StateDelta\n /** The case's sequence counter after this Execution. */\n readonly seq: number\n /** `end()` / `reopen()` called by the handler, if either was. */\n readonly dormancy: Dormancy | null\n /** The case's dormancy marker after this Execution (ISO-8601 UTC), `null` while active. */\n readonly endedAt: string | null\n readonly claimedAt: string\n readonly committedAt: string\n}\n\n/** A held claim plus everything the run and commit phases need from the claim transaction. */\ninterface Claim<TCommit> {\n readonly executionId: string\n readonly caseTypeName: string\n readonly definition: AnyCaseType<TCommit>\n readonly target: StepTarget<unknown, unknown, TCommit>\n readonly state: unknown\n readonly input: unknown\n readonly asOf: string\n readonly guard: GuardEvaluation\n readonly claimedAt: string\n readonly scopeKey: string | null\n readonly actor: unknown\n}\n\n/**\n * Wraps a failure that must not be retried because retrying is guaranteed to\n * reproduce it: a handler returning a Case State its schema rejects, or a\n * commit refused because the claim is gone.\n */\nclass NonRetryable extends Error {\n readonly reason: unknown\n constructor(reason: unknown) {\n super(thrownMessage(reason))\n this.name = 'NonRetryable'\n this.reason = reason\n }\n}\n\nconst toJournalError = (error: unknown): JournalError =>\n error instanceof Error\n ? { name: error.name, message: error.message }\n : { name: 'UnknownError', message: String(error) }\n\n/**\n * Resolve whatever claim already sits on this case, inside the claim\n * transaction (the case operation is serialized, so no two claimants decide this at\n * once): a live claim means the case is busy; an expired one is a crashed\n * handler's abandoned lease — journal the abandonment and take it.\n *\n * Both the `expired` entry and the takeover ride on this transaction, so a\n * claim that goes on to fail its guard rolls the abandonment record back and\n * leaves the dead lease in place. That is deliberate: an expired lease blocks\n * nothing, and the abandonment is only a fact worth recording when somebody\n * actually took the case over.\n */\nconst clearStaleClaim = async <TCommit>(\n tx: LifecycleTx<TCommit>,\n caseId: string,\n claimant: string,\n): Promise<void> => {\n const held = await tx.currentClaim()\n if (!held) return\n if (!held.expired) {\n throw new CaseBusyError(caseId, {\n executionId: held.executionId,\n stepName: held.step,\n scopeKey: held.scopeKey,\n expiresAt: held.expiresAt,\n })\n }\n await tx.appendEntry({\n caseId,\n executionId: held.executionId,\n entry: 'expired',\n attempt: held.attempt,\n step: held.step,\n scopeKey: held.scopeKey,\n error: {\n name: 'ClaimExpiredError',\n message: `claim expired at ${held.expiresAt} with no terminal entry; case taken over by execution ${claimant}`,\n },\n })\n await tx.deleteClaim(held.executionId)\n}\n\n/**\n * What the lifecycle core needs besides storage: the clock, the timers, and\n * the lease timings and registry. Storage returns raw records; definition\n * resolution and schema validation belong to this shared lifecycle.\n */\nexport interface LifecycleDeps<TCommit = unknown> {\n readonly caseTypeFor: (caseTypeName: string) => AnyCaseType<TCommit>\n readonly now: () => Date\n readonly timers: Timers\n readonly claimTtlMs: number\n readonly heartbeatMs: number\n}\n\n/**\n * The claim: one short transaction that either takes the case and journals a\n * `claimed` entry, or writes nothing and throws the reason. Loading, guard\n * re-evaluation, and the exclusivity decision all happen under the case's\n * serialization, which is what makes \"exactly one of N concurrent attempts claims\"\n * true rather than likely.\n */\nconst claimCase = async <TCommit>(\n port: LifecyclePort<TCommit>,\n deps: LifecycleDeps<TCommit>,\n caseId: string,\n stepName: string,\n options: ExecuteOptions,\n executionId: string,\n claimTtlMs: number,\n): Promise<Claim<TCommit>> =>\n port.withCase(caseId, async (tx) => {\n const { definition, handle, state } = await resolveCase(\n await tx.loadCase(),\n deps.caseTypeFor,\n )\n\n await clearStaleClaim(tx, caseId, executionId)\n\n const target = resolveTarget(\n definition,\n state,\n stepName,\n options.scopeKey,\n ) as StepTarget<unknown, unknown, TCommit>\n const scopeKey = target.binding?.key ?? null\n const input = await validateStepInput(target.step, options.input)\n\n const asOf = toIso(options.asOf ?? deps.now())\n const guard = evaluateTarget(target, { actor: options.actor, asOf })\n if (!guard.available)\n throw new StepNotAvailableError(caseId, stepName, scopeKey, guard)\n\n const { claimedAt } = await tx.insertClaim(\n executionId,\n stepName,\n scopeKey,\n claimTtlMs,\n )\n\n await tx.appendEntry({\n caseId,\n executionId,\n entry: 'claimed',\n attempt: 1,\n step: stepName,\n scopeKey,\n actor: options.actor,\n input,\n asOf,\n guard,\n // The Case State the guard was evaluated against, stored so audit\n // reconstruction is exact rather than approximate.\n state,\n })\n\n return {\n executionId,\n caseTypeName: handle.caseTypeName,\n definition,\n target,\n state,\n input,\n asOf,\n guard,\n claimedAt,\n scopeKey,\n actor: options.actor,\n }\n })\n\n/**\n * The commit: the new Case State, the app's own writes, and the `completed`\n * journal entry, all in one transaction — or none of them.\n */\nconst commitExecution = async <TCommit>(\n port: LifecyclePort<TCommit>,\n claim: Claim<TCommit>,\n caseId: string,\n stepName: string,\n nextState: unknown,\n attempt: number,\n dormancy: Dormancy | null,\n writes: readonly CommitEffect<TCommit>[],\n): Promise<ExecutionResult> => {\n // Both inputs are fixed before the transaction opens, so the deep diff —\n // pure CPU over the whole state document — runs before case serialization\n // begins, not while holding it.\n const delta = diffState(claim.state, nextState)\n return port.withCase(caseId, async (tx) => {\n // The one check that makes a stale write impossible. Changing Case State\n // requires holding the claim, and taking the claim over replaces this ownership record\n // — so finding our own execution id here means nothing has committed on\n // this case since we claimed it, and the state the handler computed from\n // is still current. An expired-but-undisturbed lease therefore commits\n // quite legitimately: a handler that overran its lease with no contender\n // has raced nobody.\n const holder = (await tx.currentClaim())?.executionId ?? null\n if (holder !== claim.executionId) {\n throw new ClaimLostError(caseId, claim.executionId, holder)\n }\n\n const updated = await tx.updateCaseState(nextState, dormancy)\n await tx.applyEffects(writes)\n\n const entry = await tx.appendEntry({\n caseId,\n executionId: claim.executionId,\n entry: 'completed',\n attempt,\n step: stepName,\n scopeKey: claim.scopeKey,\n actor: claim.actor,\n delta,\n dormancy,\n })\n await tx.deleteClaim(claim.executionId)\n\n return {\n executionId: claim.executionId,\n caseId,\n caseTypeName: claim.caseTypeName,\n step: stepName,\n ...(claim.scopeKey !== null && { scopeKey: claim.scopeKey }),\n attempts: attempt,\n guard: claim.guard,\n state: nextState,\n delta,\n seq: updated.seq,\n dormancy,\n endedAt: updated.endedAt,\n claimedAt: claim.claimedAt,\n committedAt: entry.recordedAt,\n }\n })\n}\n\n/**\n * The lifecycle core: claim → run → commit against a {@link LifecyclePort}.\n *\n * {@link executeStep} supplies the configured storage adapter; the claim state machine's\n * own tests bind it to an in-memory port instead. Same body either way — the\n * port is the only storage the lifecycle knows.\n */\nexport const runLifecycle = async <TCommit>(\n port: LifecyclePort<TCommit>,\n deps: LifecycleDeps<TCommit>,\n caseId: string,\n stepName: string,\n options: ExecuteOptions,\n): Promise<ExecutionResult> => {\n const executionId = mintId('execution')\n const claimTtlMs = options.claimTtlMs ?? deps.claimTtlMs\n const heartbeatMs = options.heartbeatMs ?? deps.heartbeatMs\n\n const claim = await claimCase(\n port,\n deps,\n caseId,\n stepName,\n options,\n executionId,\n claimTtlMs,\n )\n const policy: RetryPolicy =\n options.retry === undefined\n ? claim.target.step.retry\n : normalizeRetry(stepName, options.retry)\n // Keep the lease alive while the handler runs. Best-effort: a failed beat\n // just lets the claim age.\n const stopHeartbeat = deps.timers.every(heartbeatMs, () => {\n void port.heartbeat(caseId, executionId, claimTtlMs)\n })\n\n const journal = (\n entry: 'attempt-failed' | 'failed',\n attempt: number,\n error: JournalError,\n ): Promise<unknown> =>\n port.appendEntry({\n caseId,\n executionId,\n entry,\n attempt,\n step: stepName,\n scopeKey: claim.scopeKey,\n actor: claim.actor,\n error,\n })\n\n try {\n for (let attempt = 1; ; attempt += 1) {\n // Per-attempt, never carried over: a failed attempt's registered writes\n // and dormancy intent are discarded with the attempt that made them.\n const writes: CommitEffect<TCommit>[] = []\n let dormancy: Dormancy | null = null\n\n const context = {\n executionId,\n caseId,\n actor: options.actor,\n input: claim.input,\n attempt,\n maxAttempts: policy.maxAttempts,\n onCommit: (write: CommitWrite<TCommit>) => {\n writes.push({ kind: 'write', write })\n },\n // Correlation is an ordinary commit write: the mapping\n // lands in the same transaction as the state that says the external\n // interaction was started. A scoped step's registration defaults to\n // its own element — an envelope sent for buyer #7 belongs to\n // buyer #7 unless the handler says otherwise.\n correlate: (request: CorrelationRequest) => {\n writes.push({\n kind: 'correlation',\n registration: {\n ...request,\n caseId,\n scopeKey:\n request.scopeKey === undefined\n ? claim.scopeKey\n : request.scopeKey,\n },\n })\n },\n end: () => {\n dormancy = 'ended'\n },\n reopen: () => {\n dormancy = 'reopened'\n },\n ...(claim.target.binding !== null && {\n scope: claim.target.binding.element,\n scopeKey: claim.target.binding.key,\n }),\n }\n\n try {\n const returned = await claim.target.step.handler(claim.state, context)\n const nextState = await validateCaseState(\n claim.definition,\n returned,\n `state returned by step '${stepName}'`,\n ).catch((error) => {\n // A handler that returns state its own schema rejects is a\n // deterministic defect: the next attempt would return it again.\n throw new NonRetryable(error)\n })\n return await commitExecution(\n port,\n claim,\n caseId,\n stepName,\n nextState,\n attempt,\n dormancy,\n writes,\n )\n } catch (error) {\n const fatal =\n error instanceof NonRetryable || error instanceof ClaimLostError\n const cause = error instanceof NonRetryable ? error.reason : error\n const journalError = toJournalError(cause)\n\n if (!fatal && attempt < policy.maxAttempts) {\n // The attempt-failed entry and the lease's attempt counter (kept\n // current so a takeover's `expired` entry names the right attempt)\n // are independent writes — one round trip, not two.\n await Promise.all([\n journal('attempt-failed', attempt, journalError),\n port.bumpAttempt(caseId, executionId, attempt + 1),\n ])\n await deps.timers.sleep(policy.delayMs(attempt))\n continue\n }\n await Promise.all([\n journal('failed', attempt, journalError),\n port.releaseClaim(caseId, executionId),\n ])\n if (cause instanceof ClaimLostError) throw cause\n throw new StepExecutionError(\n caseId,\n executionId,\n stepName,\n claim.scopeKey,\n attempt,\n cause,\n )\n }\n }\n } finally {\n stopHeartbeat()\n }\n}\n\n/**\n * Execute one step on one case: claim it, run its handler, commit the result.\n *\n * Rejections before anything runs — {@link StepNotAvailableError} (the guard\n * said no), {@link CaseBusyError} (another Execution holds the case),\n * `UnknownStepError` / `ScopeKeyError` (bad address) — write nothing, not\n * even a journal entry: the journal records Executions, and a refused claim\n * never became one.\n *\n * A handler that throws is retried per the step's retry policy (same\n * `executionId`, same claim, same starting state — nothing else can have\n * moved it), each failure journaled as `attempt-failed`. When the attempts\n * run out, a `failed` entry is journaled, the case is released, and\n * {@link StepExecutionError} is thrown.\n *\n * This runs the shared lifecycle with the configured storage adapter.\n */\nexport const executeStep = async <TCommit>(\n env: ExecutionEnvironment<TCommit>,\n caseId: string,\n stepName: string,\n options: ExecuteOptions,\n): Promise<ExecutionResult> =>\n runLifecycle(env.storage.execution, env, caseId, stepName, options)\n\n// ── The system runner ──────────────────────────────────────────────────────\n//\n// `docs/architecture.md` draws a lenient/loud pair wherever the same need\n// recurs: loud for an addressed request, lenient for a sweep. For running a\n// step, loud is `Engine.execute` — it propagates, because somebody named a\n// case and a step and is owed the refusal. The lenient half is here: every\n// sweep (ingestion, migration) runs steps *as the system*, with no caller\n// an exception could reach, and each would otherwise wrap the lifecycle in\n// its own try/catch classification. `runAsSystem` is the lenient half\n// stated once — a total, named outcome the sweeps filter, the same way\n// listings are filters over `selectTargets`.\n\n/** A committed system run. */\nexport interface SystemCommit {\n readonly outcome: 'committed'\n readonly result: ExecutionResult\n}\n\n/**\n * A system run that did not commit, carrying whatever the lifecycle threw —\n * a Refusal keeps its identity (its code is the answer, projectable by\n * `isAffordanceError`, never re-derived from a class), a bug or an\n * infrastructure failure passes through as itself. One settled variant, not\n * a refused/failed pair: no sweep ever treated the halves differently, and a\n * discriminant nobody branches on is interface without behaviour.\n */\nexport interface SystemSettled {\n readonly outcome: 'settled'\n readonly error: Error\n}\n\n/** How a step the system ran ended — total over every way the lifecycle can answer. */\nexport type SystemRunOutcome = SystemCommit | SystemSettled\n\n/** What a sweep may ask for beyond the lifecycle's own execute options. */\nexport interface SystemRunOptions<TCommit = unknown> extends ExecuteOptions {\n /**\n * Run against this definition instead of the registry's — how a migration\n * executes its synthetic `migrate:<name>` step. First-class here so no\n * sweep has to smuggle a definition in by rewriting `caseTypeFor`.\n */\n readonly definition?: AnyCaseType<TCommit>\n}\n\n/**\n * Turn a throw into the settled value. No classification happens here —\n * \"is this a Refusal\" is asked exactly once, where a consumer needs the\n * distinction (ingestion's `classifyDeadLetter`), via `isAffordanceError`.\n */\nexport const settleSystemRun = (error: unknown): SystemSettled => ({\n outcome: 'settled',\n error: toError(error),\n})\n\n/**\n * Run one step on behalf of the system and say how it ended. Never throws:\n * a sweep ranges over many cases with no caller waiting on any single one,\n * so the answer is a value — committed (with the result) or settled (with\n * the error) — and each sweep decides what its kind of sweep does with it.\n */\nexport const runAsSystem = async <TCommit>(\n env: ExecutionEnvironment<TCommit>,\n caseId: string,\n stepName: string,\n options: SystemRunOptions<TCommit>,\n): Promise<SystemRunOutcome> => {\n const { definition, ...lifecycleOptions } = options\n const environment: ExecutionEnvironment<TCommit> =\n definition === undefined ? env : { ...env, caseTypeFor: () => definition }\n try {\n return {\n outcome: 'committed',\n result: await executeStep(\n environment,\n caseId,\n stepName,\n lifecycleOptions,\n ),\n }\n } catch (error) {\n return settleSystemRun(error)\n }\n}\n"]}
@@ -15,11 +15,9 @@ export { CaseBusyError, ClaimLostError, StepExecutionError, StepNotAvailableErro
15
15
  export type { ExecuteOptions, ExecutionEnvironment, ExecutionResult, LifecycleDeps, SystemCommit, SystemRunOptions, SystemRunOutcome, SystemSettled, } from './execute.js';
16
16
  export { DEFAULT_CLAIM_TTL_MS, DEFAULT_HEARTBEAT_MS, executeStep, runAsSystem, runLifecycle, settleSystemRun, } from './execute.js';
17
17
  export type { ClaimedEntryInput, ClaimedJournalEntry, CompletedEntryInput, ExecutionRecord, ExecutionStatus, FailureEntryInput, JournalEntry, JournalEntryColumns, JournalEntryInput, JournalEntryType, JournalError, JournalFilter, } from './journal.js';
18
- export { appendEntry, foldExecutions, isClaimedEntry, projectEntry, readJournal, } from './journal.js';
18
+ export { foldExecutions, isClaimedEntry, projectEntry, } from './journal.js';
19
19
  export type { HeldClaim, LifecyclePort, LifecycleTx } from './port.js';
20
- export { pgLifecyclePort } from './port.js';
21
20
  export type { GuardReplay } from './replay.js';
22
21
  export { replayGuard } from './replay.js';
23
22
  export type { Timers } from './timers.js';
24
23
  export { realTimers } from './timers.js';
25
- export { withTransaction } from './transaction.js';
@@ -12,9 +12,7 @@
12
12
  export { diffState, jsonEqual } from './delta.js';
13
13
  export { CaseBusyError, ClaimLostError, StepExecutionError, StepNotAvailableError, stepLabel, } from './errors.js';
14
14
  export { DEFAULT_CLAIM_TTL_MS, DEFAULT_HEARTBEAT_MS, executeStep, runAsSystem, runLifecycle, settleSystemRun, } from './execute.js';
15
- export { appendEntry, foldExecutions, isClaimedEntry, projectEntry, readJournal, } from './journal.js';
16
- export { pgLifecyclePort } from './port.js';
15
+ export { foldExecutions, isClaimedEntry, projectEntry, } from './journal.js';
17
16
  export { replayGuard } from './replay.js';
18
17
  export { realTimers } from './timers.js';
19
- export { withTransaction } from './transaction.js';
20
18
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/execution/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AACjD,OAAO,EACL,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,qBAAqB,EACrB,SAAS,GACV,MAAM,aAAa,CAAA;AAWpB,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,WAAW,EACX,WAAW,EACX,YAAY,EACZ,eAAe,GAChB,MAAM,cAAc,CAAA;AAerB,OAAO,EACL,WAAW,EACX,cAAc,EACd,cAAc,EACd,YAAY,EACZ,WAAW,GACZ,MAAM,cAAc,CAAA;AAErB,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAE3C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEzC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA","sourcesContent":["/**\n * The execution lifecycle and the journal.\n *\n * An **Execution** is one recorded run of a step on a case (CONTEXT.md): a\n * claim that re-evaluates the guard transactionally, a handler run outside\n * any transaction, and a commit that writes the new Case State together with\n * the journal entry describing it. The **journal** is the immutable,\n * append-only record those Executions leave behind.\n *\n * See `execute.ts` for the lifecycle's shape and the reasoning behind it.\n */\n\nexport type { PatchOp, StateDelta } from './delta.js'\nexport { diffState, jsonEqual } from './delta.js'\nexport {\n CaseBusyError,\n ClaimLostError,\n StepExecutionError,\n StepNotAvailableError,\n stepLabel,\n} from './errors.js'\nexport type {\n ExecuteOptions,\n ExecutionEnvironment,\n ExecutionResult,\n LifecycleDeps,\n SystemCommit,\n SystemRunOptions,\n SystemRunOutcome,\n SystemSettled,\n} from './execute.js'\nexport {\n DEFAULT_CLAIM_TTL_MS,\n DEFAULT_HEARTBEAT_MS,\n executeStep,\n runAsSystem,\n runLifecycle,\n settleSystemRun,\n} from './execute.js'\nexport type {\n ClaimedEntryInput,\n ClaimedJournalEntry,\n CompletedEntryInput,\n ExecutionRecord,\n ExecutionStatus,\n FailureEntryInput,\n JournalEntry,\n JournalEntryColumns,\n JournalEntryInput,\n JournalEntryType,\n JournalError,\n JournalFilter,\n} from './journal.js'\nexport {\n appendEntry,\n foldExecutions,\n isClaimedEntry,\n projectEntry,\n readJournal,\n} from './journal.js'\nexport type { HeldClaim, LifecyclePort, LifecycleTx } from './port.js'\nexport { pgLifecyclePort } from './port.js'\nexport type { GuardReplay } from './replay.js'\nexport { replayGuard } from './replay.js'\nexport type { Timers } from './timers.js'\nexport { realTimers } from './timers.js'\nexport { withTransaction } from './transaction.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/execution/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AACjD,OAAO,EACL,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,qBAAqB,EACrB,SAAS,GACV,MAAM,aAAa,CAAA;AAWpB,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,WAAW,EACX,WAAW,EACX,YAAY,EACZ,eAAe,GAChB,MAAM,cAAc,CAAA;AAerB,OAAO,EACL,cAAc,EACd,cAAc,EACd,YAAY,GACb,MAAM,cAAc,CAAA;AAGrB,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEzC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA","sourcesContent":["/**\n * The execution lifecycle and the journal.\n *\n * An **Execution** is one recorded run of a step on a case (CONTEXT.md): a\n * claim that re-evaluates the guard transactionally, a handler run outside\n * any transaction, and a commit that writes the new Case State together with\n * the journal entry describing it. The **journal** is the immutable,\n * append-only record those Executions leave behind.\n *\n * See `execute.ts` for the lifecycle's shape and the reasoning behind it.\n */\n\nexport type { PatchOp, StateDelta } from './delta.js'\nexport { diffState, jsonEqual } from './delta.js'\nexport {\n CaseBusyError,\n ClaimLostError,\n StepExecutionError,\n StepNotAvailableError,\n stepLabel,\n} from './errors.js'\nexport type {\n ExecuteOptions,\n ExecutionEnvironment,\n ExecutionResult,\n LifecycleDeps,\n SystemCommit,\n SystemRunOptions,\n SystemRunOutcome,\n SystemSettled,\n} from './execute.js'\nexport {\n DEFAULT_CLAIM_TTL_MS,\n DEFAULT_HEARTBEAT_MS,\n executeStep,\n runAsSystem,\n runLifecycle,\n settleSystemRun,\n} from './execute.js'\nexport type {\n ClaimedEntryInput,\n ClaimedJournalEntry,\n CompletedEntryInput,\n ExecutionRecord,\n ExecutionStatus,\n FailureEntryInput,\n JournalEntry,\n JournalEntryColumns,\n JournalEntryInput,\n JournalEntryType,\n JournalError,\n JournalFilter,\n} from './journal.js'\nexport {\n foldExecutions,\n isClaimedEntry,\n projectEntry,\n} from './journal.js'\nexport type { HeldClaim, LifecyclePort, LifecycleTx } from './port.js'\nexport type { GuardReplay } from './replay.js'\nexport { replayGuard } from './replay.js'\nexport type { Timers } from './timers.js'\nexport { realTimers } from './timers.js'\n"]}
@@ -9,12 +9,10 @@
9
9
  * scope key, actor). Per-track audit — "everything that happened on buyer
10
10
  * #7" — is therefore a filter, not a reconstruction.
11
11
  *
12
- * Only inserts exist in this module. There is no update or delete path for a
13
- * journal row anywhere in the library.
12
+ * This module defines journal evidence and its projections. Storage adapters
13
+ * append and read the records; the engine never rewrites historical entries.
14
14
  */
15
- import type { JournalEntryKind } from '@affordance/contract';
16
15
  import type { GuardEvaluation } from '../guards/index.js';
17
- import type { Queryable } from '../store/index.js';
18
16
  import type { StateDelta } from './delta.js';
19
17
  /**
20
18
  * Which lifecycle moment an entry records.
@@ -27,7 +25,8 @@ import type { StateDelta } from './delta.js';
27
25
  * - `expired` — the claim lapsed without a terminal entry: the handler's
28
26
  * process died, and a later claimant recorded the abandonment
29
27
  */
30
- export type JournalEntryType = JournalEntryKind;
28
+ export declare const JOURNAL_ENTRY_KINDS: readonly ["claimed", "attempt-failed", "completed", "failed", "expired"];
29
+ export type JournalEntryType = (typeof JOURNAL_ENTRY_KINDS)[number];
31
30
  /** A failure as journaled — the error's identity, not a live Error object. */
32
31
  export interface JournalError {
33
32
  readonly name: string;
@@ -145,13 +144,6 @@ export type JournalEntryColumns = Omit<JournalEntry, 'ordinal' | 'id' | 'recorde
145
144
  * a second, divergent copy of the journal's semantics.
146
145
  */
147
146
  export declare const projectEntry: (input: JournalEntryInput) => JournalEntryColumns;
148
- /** Append one entry. Inserts only — journal rows are never updated or deleted. */
149
- export declare const appendEntry: (db: Queryable, input: JournalEntryInput) => Promise<JournalEntry>;
150
- /**
151
- * Read a case's journal in insertion order, oldest first. With no filter this
152
- * is the whole story of the case; with `scopeKey` it is one track's audit.
153
- */
154
- export declare const readJournal: (db: Queryable, caseId: string, filter?: JournalFilter) => Promise<readonly JournalEntry[]>;
155
147
  /** How an Execution ended up, folded from its entries. */
156
148
  export type ExecutionStatus = 'in-progress' | 'completed' | 'failed' | 'expired';
157
149
  /**
@@ -9,54 +9,33 @@
9
9
  * scope key, actor). Per-track audit — "everything that happened on buyer
10
10
  * #7" — is therefore a filter, not a reconstruction.
11
11
  *
12
- * Only inserts exist in this module. There is no update or delete path for a
13
- * journal row anywhere in the library.
12
+ * This module defines journal evidence and its projections. Storage adapters
13
+ * append and read the records; the engine never rewrites historical entries.
14
14
  */
15
- import { FRAMEWORK_SCHEMA, mintId, sqlWhere } from '../store/index.js';
16
- const JOURNAL = `${FRAMEWORK_SCHEMA}.journal`;
17
- const JOURNAL_COLUMNS = 'ordinal, id, case_id, execution_id, entry, attempt, step, scope_key, actor, input, as_of, guard, state, delta, dormancy, error, recorded_at';
15
+ /**
16
+ * Which lifecycle moment an entry records.
17
+ *
18
+ * - `claimed` — the claim's transactional guard re-evaluation passed and the
19
+ * Execution took the case; carries `guard`, `asOf` and `state`
20
+ * - `attempt-failed` — one attempt threw and another will follow
21
+ * - `completed` — the handler's Case State was committed; carries `delta`
22
+ * - `failed` — retries exhausted (or a deterministic defect); case released
23
+ * - `expired` — the claim lapsed without a terminal entry: the handler's
24
+ * process died, and a later claimant recorded the abandonment
25
+ */
26
+ export const JOURNAL_ENTRY_KINDS = [
27
+ 'claimed',
28
+ 'attempt-failed',
29
+ 'completed',
30
+ 'failed',
31
+ 'expired',
32
+ ];
18
33
  /**
19
34
  * Narrow a read entry to the claimed moment. The one predicate every reader
20
35
  * of claim-time evidence (`foldExecutions`, audit replay) shares, so what
21
36
  * counts as "carries the evidence" is decided once.
22
37
  */
23
38
  export const isClaimedEntry = (entry) => entry.entry === 'claimed' && entry.guard !== null && entry.asOf !== null;
24
- const toEntry = (row) => ({
25
- ordinal: Number(row.ordinal),
26
- id: row.id,
27
- caseId: row.case_id,
28
- executionId: row.execution_id,
29
- entry: row.entry,
30
- attempt: row.attempt,
31
- step: row.step,
32
- scopeKey: row.scope_key,
33
- actor: row.actor,
34
- input: row.input,
35
- asOf: row.as_of === null ? null : row.as_of.toISOString(),
36
- guard: row.guard,
37
- state: row.state,
38
- delta: row.delta,
39
- dormancy: row.dormancy,
40
- error: row.error,
41
- recordedAt: row.recorded_at.toISOString(),
42
- });
43
- /**
44
- * Serialize a value for a jsonb column. Actors and inputs are app-owned
45
- * shapes, and a journal append must never be the thing that fails an
46
- * otherwise-good Execution: a value that will not stringify (a cycle, a
47
- * BigInt) is journaled as a marker string rather than thrown over.
48
- */
49
- const toJsonb = (value) => {
50
- if (value === undefined || value === null)
51
- return null;
52
- try {
53
- const json = JSON.stringify(value);
54
- return json === undefined ? null : json;
55
- }
56
- catch {
57
- return JSON.stringify({ '~unserializable': String(value) });
58
- }
59
- };
60
39
  /**
61
40
  * Project an input onto a stored entry's fields — the one statement of the
62
41
  * defaulting and of which fields accompany which lifecycle moment. Every
@@ -85,58 +64,6 @@ export const projectEntry = (input) => {
85
64
  error: failure?.error ?? null,
86
65
  };
87
66
  };
88
- /** Append one entry. Inserts only — journal rows are never updated or deleted. */
89
- export const appendEntry = async (db, input) => {
90
- const entry = projectEntry(input);
91
- const { rows } = await db.query(`insert into ${JOURNAL}
92
- (id, case_id, execution_id, entry, attempt, step, scope_key, actor, input, as_of, guard, state, delta, dormancy, error)
93
- values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::timestamptz, $11::jsonb, $12::jsonb, $13::jsonb, $14, $15::jsonb)
94
- returning ${JOURNAL_COLUMNS}`, [
95
- mintId('journal'),
96
- entry.caseId,
97
- entry.executionId,
98
- entry.entry,
99
- entry.attempt,
100
- entry.step,
101
- entry.scopeKey,
102
- toJsonb(entry.actor),
103
- toJsonb(entry.input),
104
- entry.asOf,
105
- toJsonb(entry.guard),
106
- toJsonb(entry.state),
107
- toJsonb(entry.delta),
108
- entry.dormancy,
109
- toJsonb(entry.error),
110
- ]);
111
- const row = rows[0];
112
- if (!row)
113
- throw new Error(`insert into ${JOURNAL} returned no row`);
114
- return toEntry(row);
115
- };
116
- /**
117
- * Read a case's journal in insertion order, oldest first. With no filter this
118
- * is the whole story of the case; with `scopeKey` it is one track's audit.
119
- */
120
- export const readJournal = async (db, caseId, filter = {}) => {
121
- const { conditions, values, bind, where } = sqlWhere(['case_id = $1'], [caseId]);
122
- if (filter.scopeKey !== undefined)
123
- conditions.push(`scope_key = ${bind(filter.scopeKey)}`);
124
- if (filter.step !== undefined)
125
- conditions.push(`step = ${bind(filter.step)}`);
126
- if (filter.executionId !== undefined)
127
- conditions.push(`execution_id = ${bind(filter.executionId)}`);
128
- if (filter.entry !== undefined) {
129
- const entries = Array.isArray(filter.entry) ? filter.entry : [filter.entry];
130
- conditions.push(`entry = any(${bind(entries)}::text[])`);
131
- }
132
- if (filter.since !== undefined)
133
- conditions.push(`ordinal > ${bind(filter.since)}`);
134
- const limit = filter.limit === undefined ? '' : ` limit ${bind(filter.limit)}`;
135
- const { rows } = await db.query(`select ${JOURNAL_COLUMNS} from ${JOURNAL}
136
- where ${where()}
137
- order by ordinal asc${limit}`, values);
138
- return rows.map(toEntry);
139
- };
140
67
  const TERMINAL = {
141
68
  completed: 'completed',
142
69
  failed: 'failed',