@affordance/core 0.1.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/engine/compute.d.ts +147 -0
  4. package/dist/engine/compute.js +152 -0
  5. package/dist/engine/compute.js.map +1 -0
  6. package/dist/engine/engine.d.ts +162 -0
  7. package/dist/engine/engine.js +115 -0
  8. package/dist/engine/engine.js.map +1 -0
  9. package/dist/engine/errors.d.ts +6 -0
  10. package/dist/engine/errors.js +11 -0
  11. package/dist/engine/errors.js.map +1 -0
  12. package/dist/engine/index.d.ts +16 -0
  13. package/dist/engine/index.js +15 -0
  14. package/dist/engine/index.js.map +1 -0
  15. package/dist/errors.d.ts +46 -0
  16. package/dist/errors.js +42 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/execution/delta.d.ts +42 -0
  19. package/dist/execution/delta.js +93 -0
  20. package/dist/execution/delta.js.map +1 -0
  21. package/dist/execution/errors.d.ts +94 -0
  22. package/dist/execution/errors.js +132 -0
  23. package/dist/execution/errors.js.map +1 -0
  24. package/dist/execution/execute.d.ts +179 -0
  25. package/dist/execution/execute.js +356 -0
  26. package/dist/execution/execute.js.map +1 -0
  27. package/dist/execution/index.d.ts +25 -0
  28. package/dist/execution/index.js +20 -0
  29. package/dist/execution/index.js.map +1 -0
  30. package/dist/execution/journal.d.ts +190 -0
  31. package/dist/execution/journal.js +192 -0
  32. package/dist/execution/journal.js.map +1 -0
  33. package/dist/execution/port.d.ts +81 -0
  34. package/dist/execution/port.js +101 -0
  35. package/dist/execution/port.js.map +1 -0
  36. package/dist/execution/replay.d.ts +58 -0
  37. package/dist/execution/replay.js +59 -0
  38. package/dist/execution/replay.js.map +1 -0
  39. package/dist/execution/timers.d.ts +21 -0
  40. package/dist/execution/timers.js +26 -0
  41. package/dist/execution/timers.js.map +1 -0
  42. package/dist/execution/transaction.d.ts +24 -0
  43. package/dist/execution/transaction.js +49 -0
  44. package/dist/execution/transaction.js.map +1 -0
  45. package/dist/guards/condition.d.ts +86 -0
  46. package/dist/guards/condition.js +11 -0
  47. package/dist/guards/condition.js.map +1 -0
  48. package/dist/guards/evaluate.d.ts +143 -0
  49. package/dist/guards/evaluate.js +167 -0
  50. package/dist/guards/evaluate.js.map +1 -0
  51. package/dist/guards/guard.d.ts +136 -0
  52. package/dist/guards/guard.js +118 -0
  53. package/dist/guards/guard.js.map +1 -0
  54. package/dist/guards/index.d.ts +20 -0
  55. package/dist/guards/index.js +17 -0
  56. package/dist/guards/index.js.map +1 -0
  57. package/dist/guards/time.d.ts +32 -0
  58. package/dist/guards/time.js +45 -0
  59. package/dist/guards/time.js.map +1 -0
  60. package/dist/index.d.ts +31 -0
  61. package/dist/index.js +20 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/ingestion/correlation.d.ts +54 -0
  64. package/dist/ingestion/correlation.js +78 -0
  65. package/dist/ingestion/correlation.js.map +1 -0
  66. package/dist/ingestion/index.d.ts +16 -0
  67. package/dist/ingestion/index.js +15 -0
  68. package/dist/ingestion/index.js.map +1 -0
  69. package/dist/ingestion/ingest.d.ts +196 -0
  70. package/dist/ingestion/ingest.js +275 -0
  71. package/dist/ingestion/ingest.js.map +1 -0
  72. package/dist/migration/index.d.ts +10 -0
  73. package/dist/migration/index.js +10 -0
  74. package/dist/migration/index.js.map +1 -0
  75. package/dist/migration/migrate.d.ts +119 -0
  76. package/dist/migration/migrate.js +195 -0
  77. package/dist/migration/migrate.js.map +1 -0
  78. package/dist/model/casetype.d.ts +53 -0
  79. package/dist/model/casetype.js +51 -0
  80. package/dist/model/casetype.js.map +1 -0
  81. package/dist/model/errors.d.ts +28 -0
  82. package/dist/model/errors.js +39 -0
  83. package/dist/model/errors.js.map +1 -0
  84. package/dist/model/handler.d.ts +120 -0
  85. package/dist/model/handler.js +16 -0
  86. package/dist/model/handler.js.map +1 -0
  87. package/dist/model/index.d.ts +24 -0
  88. package/dist/model/index.js +19 -0
  89. package/dist/model/index.js.map +1 -0
  90. package/dist/model/retry.d.ts +40 -0
  91. package/dist/model/retry.js +51 -0
  92. package/dist/model/retry.js.map +1 -0
  93. package/dist/model/scope.d.ts +67 -0
  94. package/dist/model/scope.js +19 -0
  95. package/dist/model/scope.js.map +1 -0
  96. package/dist/model/step.d.ts +232 -0
  97. package/dist/model/step.js +210 -0
  98. package/dist/model/step.js.map +1 -0
  99. package/dist/model/target.d.ts +175 -0
  100. package/dist/model/target.js +236 -0
  101. package/dist/model/target.js.map +1 -0
  102. package/dist/store/bootstrap.d.ts +57 -0
  103. package/dist/store/bootstrap.js +268 -0
  104. package/dist/store/bootstrap.js.map +1 -0
  105. package/dist/store/errors.d.ts +18 -0
  106. package/dist/store/errors.js +26 -0
  107. package/dist/store/errors.js.map +1 -0
  108. package/dist/store/ids.d.ts +15 -0
  109. package/dist/store/ids.js +12 -0
  110. package/dist/store/ids.js.map +1 -0
  111. package/dist/store/index.d.ts +12 -0
  112. package/dist/store/index.js +9 -0
  113. package/dist/store/index.js.map +1 -0
  114. package/dist/store/queryable.d.ts +60 -0
  115. package/dist/store/queryable.js +7 -0
  116. package/dist/store/queryable.js.map +1 -0
  117. package/dist/store/resolve.d.ts +66 -0
  118. package/dist/store/resolve.js +71 -0
  119. package/dist/store/resolve.js.map +1 -0
  120. package/dist/store/sql.d.ts +26 -0
  121. package/dist/store/sql.js +21 -0
  122. package/dist/store/sql.js.map +1 -0
  123. package/dist/store/store.d.ts +66 -0
  124. package/dist/store/store.js +108 -0
  125. package/dist/store/store.js.map +1 -0
  126. package/package.json +61 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The journal: the immutable, append-only record of a case's Executions
3
+ * (CONTEXT.md).
4
+ *
5
+ * Historical questions are answered from what the system actually believed at
6
+ * the time, never by re-deriving the past through present-day code — so a
7
+ * `claimed` entry stores the guard evaluation *and* the Case State it was
8
+ * evaluated against, and every entry repeats the Execution's identity (step,
9
+ * scope key, actor). Per-track audit — "everything that happened on buyer
10
+ * #7" — is therefore a filter, not a reconstruction.
11
+ *
12
+ * Only inserts exist in this module. There is no update or delete path for a
13
+ * journal row anywhere in the library.
14
+ */
15
+ import type { JournalEntryKind } from '@affordance/contract';
16
+ import type { GuardEvaluation } from '../guards/index.js';
17
+ import type { Queryable } from '../store/index.js';
18
+ import type { StateDelta } from './delta.js';
19
+ /**
20
+ * Which lifecycle moment an entry records.
21
+ *
22
+ * - `claimed` — the claim's transactional guard re-evaluation passed and the
23
+ * Execution took the case; carries `guard`, `asOf` and `state`
24
+ * - `attempt-failed` — one attempt threw and another will follow
25
+ * - `completed` — the handler's Case State was committed; carries `delta`
26
+ * - `failed` — retries exhausted (or a deterministic defect); case released
27
+ * - `expired` — the claim lapsed without a terminal entry: the handler's
28
+ * process died, and a later claimant recorded the abandonment
29
+ */
30
+ export type JournalEntryType = JournalEntryKind;
31
+ /** A failure as journaled — the error's identity, not a live Error object. */
32
+ export interface JournalError {
33
+ readonly name: string;
34
+ readonly message: string;
35
+ }
36
+ /** One journal entry, JSON-serializable throughout (timestamps are ISO-8601 UTC). */
37
+ export interface JournalEntry {
38
+ /** Total insertion order across all cases; per-case order is `(caseId, ordinal)`. */
39
+ readonly ordinal: number;
40
+ readonly id: string;
41
+ readonly caseId: string;
42
+ /** The Execution this entry belongs to — several entries share one. */
43
+ readonly executionId: string;
44
+ readonly entry: JournalEntryType;
45
+ /** 1-based attempt this entry is about. */
46
+ readonly attempt: number;
47
+ readonly step: string;
48
+ /** The bound scope key, or `null` for an unscoped step. */
49
+ readonly scopeKey: string | null;
50
+ /** The acting Actor, as supplied by the app. */
51
+ readonly actor: unknown;
52
+ /** The step input, post-validation (schema output), or `null`. */
53
+ readonly input: unknown;
54
+ /** The instant the claim's guard re-evaluation was made as of, on `claimed` entries. */
55
+ readonly asOf: string | null;
56
+ /** The claim-time guard evaluation — the enforcement moment's full record. */
57
+ readonly guard: GuardEvaluation | null;
58
+ /** The Case State the guard was evaluated against, on `claimed` entries. */
59
+ readonly state: unknown;
60
+ /** The committed state delta, on `completed` entries. */
61
+ readonly delta: StateDelta | null;
62
+ /** `end()` / `reopen()` called by the handler, on `completed` entries. */
63
+ readonly dormancy: 'ended' | 'reopened' | null;
64
+ /** The failure, on `attempt-failed` / `failed` / `expired` entries. */
65
+ readonly error: JournalError | null;
66
+ readonly recordedAt: string;
67
+ }
68
+ /** The identity every journal entry carries, whatever its kind. */
69
+ interface JournalEntryIdentity {
70
+ readonly caseId: string;
71
+ readonly executionId: string;
72
+ readonly attempt: number;
73
+ readonly step: string;
74
+ readonly scopeKey?: string | null;
75
+ readonly actor?: unknown;
76
+ readonly input?: unknown;
77
+ }
78
+ /**
79
+ * A `claimed` entry records the enforcement moment, so the evidence is
80
+ * required: the instant, the guard evaluation, and the Case State it ran
81
+ * against.
82
+ */
83
+ export interface ClaimedEntryInput extends JournalEntryIdentity {
84
+ readonly entry: 'claimed';
85
+ readonly asOf: string;
86
+ readonly guard: GuardEvaluation;
87
+ readonly state: unknown;
88
+ }
89
+ /** A `completed` entry records what the commit changed. */
90
+ export interface CompletedEntryInput extends JournalEntryIdentity {
91
+ readonly entry: 'completed';
92
+ readonly delta: StateDelta;
93
+ readonly dormancy?: 'ended' | 'reopened' | null;
94
+ }
95
+ /** Every way an Execution stops without committing carries the failure that stopped it. */
96
+ export interface FailureEntryInput extends JournalEntryIdentity {
97
+ readonly entry: 'attempt-failed' | 'failed' | 'expired';
98
+ readonly error: JournalError;
99
+ }
100
+ /**
101
+ * What {@link appendEntry} needs — a discriminated union on `entry`, so
102
+ * which fields accompany which lifecycle moment is stated by the type
103
+ * itself rather than re-derived from prose by every reader.
104
+ * `{ entry: 'failed', guard, delta }` is unrepresentable rather than
105
+ * quietly journaled.
106
+ */
107
+ export type JournalEntryInput = ClaimedEntryInput | CompletedEntryInput | FailureEntryInput;
108
+ /**
109
+ * A `claimed` entry as read back, with the enforcement-moment evidence
110
+ * present — what {@link appendEntry}'s input union guarantees was written.
111
+ */
112
+ export type ClaimedJournalEntry = JournalEntry & {
113
+ readonly entry: 'claimed';
114
+ readonly asOf: string;
115
+ readonly guard: GuardEvaluation;
116
+ };
117
+ /**
118
+ * Narrow a read entry to the claimed moment. The one predicate every reader
119
+ * of claim-time evidence (`foldExecutions`, audit replay) shares, so what
120
+ * counts as "carries the evidence" is decided once.
121
+ */
122
+ export declare const isClaimedEntry: (entry: JournalEntry) => entry is ClaimedJournalEntry;
123
+ /** Filters for {@link readJournal}; all optional, all AND-ed. */
124
+ export interface JournalFilter {
125
+ /** Per-track audit: only entries bound to this scope key. */
126
+ readonly scopeKey?: string;
127
+ /** Only entries for this step. */
128
+ readonly step?: string;
129
+ /** Only entries belonging to this Execution. */
130
+ readonly executionId?: string;
131
+ /** Only these entry types. */
132
+ readonly entry?: JournalEntryType | readonly JournalEntryType[];
133
+ /** Only entries after this ordinal (exclusive) — cursor paging. */
134
+ readonly since?: number;
135
+ /** Cap the number of entries returned; the oldest matching entries win. */
136
+ readonly limit?: number;
137
+ }
138
+ /** A stored entry minus what storage assigns: `ordinal`, `id`, `recordedAt`. */
139
+ export type JournalEntryColumns = Omit<JournalEntry, 'ordinal' | 'id' | 'recordedAt'>;
140
+ /**
141
+ * Project an input onto a stored entry's fields — the one statement of the
142
+ * defaulting and of which fields accompany which lifecycle moment. Every
143
+ * adapter persists exactly this and assigns the rest; an adapter that could
144
+ * disagree with another about what a `failed` entry looks like would be
145
+ * a second, divergent copy of the journal's semantics.
146
+ */
147
+ 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
+ /** How an Execution ended up, folded from its entries. */
156
+ export type ExecutionStatus = 'in-progress' | 'completed' | 'failed' | 'expired';
157
+ /**
158
+ * One Execution as a single record: its identity, the claim-time evidence,
159
+ * and how it settled. This is a fold over entries of the *same* Execution —
160
+ * assembling one record from the moments that constitute it, not deriving
161
+ * state from a log (the design rejects the latter, not the former).
162
+ */
163
+ export interface ExecutionRecord {
164
+ readonly executionId: string;
165
+ readonly caseId: string;
166
+ readonly step: string;
167
+ readonly scopeKey: string | null;
168
+ readonly actor: unknown;
169
+ readonly input: unknown;
170
+ readonly status: ExecutionStatus;
171
+ /** Attempts observed — the highest attempt number any of its entries carries. */
172
+ readonly attempts: number;
173
+ readonly asOf: string | null;
174
+ readonly guard: GuardEvaluation | null;
175
+ /** The Case State the claim's guard was evaluated against. */
176
+ readonly state: unknown;
177
+ readonly delta: StateDelta | null;
178
+ readonly dormancy: 'ended' | 'reopened' | null;
179
+ readonly error: JournalError | null;
180
+ readonly claimedAt: string | null;
181
+ /** When the Execution reached a terminal entry; `null` while in progress. */
182
+ readonly settledAt: string | null;
183
+ }
184
+ /**
185
+ * Fold journal entries into one record per Execution, in first-appearance
186
+ * order. Feed it a filtered read (by scope key, say) to get that track's
187
+ * Executions.
188
+ */
189
+ export declare const foldExecutions: (entries: readonly JournalEntry[]) => readonly ExecutionRecord[];
190
+ export {};
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The journal: the immutable, append-only record of a case's Executions
3
+ * (CONTEXT.md).
4
+ *
5
+ * Historical questions are answered from what the system actually believed at
6
+ * the time, never by re-deriving the past through present-day code — so a
7
+ * `claimed` entry stores the guard evaluation *and* the Case State it was
8
+ * evaluated against, and every entry repeats the Execution's identity (step,
9
+ * scope key, actor). Per-track audit — "everything that happened on buyer
10
+ * #7" — is therefore a filter, not a reconstruction.
11
+ *
12
+ * Only inserts exist in this module. There is no update or delete path for a
13
+ * journal row anywhere in the library.
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';
18
+ /**
19
+ * Narrow a read entry to the claimed moment. The one predicate every reader
20
+ * of claim-time evidence (`foldExecutions`, audit replay) shares, so what
21
+ * counts as "carries the evidence" is decided once.
22
+ */
23
+ 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
+ /**
61
+ * Project an input onto a stored entry's fields — the one statement of the
62
+ * defaulting and of which fields accompany which lifecycle moment. Every
63
+ * adapter persists exactly this and assigns the rest; an adapter that could
64
+ * disagree with another about what a `failed` entry looks like would be
65
+ * a second, divergent copy of the journal's semantics.
66
+ */
67
+ export const projectEntry = (input) => {
68
+ const claimed = input.entry === 'claimed' ? input : null;
69
+ const completed = input.entry === 'completed' ? input : null;
70
+ const failure = input.entry !== 'claimed' && input.entry !== 'completed' ? input : null;
71
+ return {
72
+ caseId: input.caseId,
73
+ executionId: input.executionId,
74
+ entry: input.entry,
75
+ attempt: input.attempt,
76
+ step: input.step,
77
+ scopeKey: input.scopeKey ?? null,
78
+ actor: input.actor ?? null,
79
+ input: input.input ?? null,
80
+ asOf: claimed?.asOf ?? null,
81
+ guard: claimed?.guard ?? null,
82
+ state: claimed?.state ?? null,
83
+ delta: completed?.delta ?? null,
84
+ dormancy: completed?.dormancy ?? null,
85
+ error: failure?.error ?? null,
86
+ };
87
+ };
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
+ const TERMINAL = {
141
+ completed: 'completed',
142
+ failed: 'failed',
143
+ expired: 'expired',
144
+ };
145
+ /**
146
+ * Fold journal entries into one record per Execution, in first-appearance
147
+ * order. Feed it a filtered read (by scope key, say) to get that track's
148
+ * Executions.
149
+ */
150
+ export const foldExecutions = (entries) => {
151
+ const byExecution = new Map();
152
+ for (const entry of entries) {
153
+ const previous = byExecution.get(entry.executionId);
154
+ const terminal = TERMINAL[entry.entry];
155
+ const claimed = isClaimedEntry(entry) ? entry : null;
156
+ const base = previous ?? {
157
+ executionId: entry.executionId,
158
+ caseId: entry.caseId,
159
+ step: entry.step,
160
+ scopeKey: entry.scopeKey,
161
+ actor: entry.actor,
162
+ input: entry.input,
163
+ status: 'in-progress',
164
+ attempts: entry.attempt,
165
+ asOf: null,
166
+ guard: null,
167
+ state: undefined,
168
+ delta: null,
169
+ dormancy: null,
170
+ error: null,
171
+ claimedAt: null,
172
+ settledAt: null,
173
+ };
174
+ byExecution.set(entry.executionId, {
175
+ ...base,
176
+ attempts: Math.max(base.attempts, entry.attempt),
177
+ status: terminal ?? base.status,
178
+ asOf: claimed !== null ? claimed.asOf : base.asOf,
179
+ guard: claimed !== null ? claimed.guard : base.guard,
180
+ state: claimed !== null ? claimed.state : base.state,
181
+ delta: entry.delta ?? base.delta,
182
+ dormancy: entry.dormancy ?? base.dormancy,
183
+ // The terminal error is the one that matters; an attempt-failed error
184
+ // only stands while nothing has superseded it.
185
+ error: entry.error ?? base.error,
186
+ claimedAt: claimed !== null ? claimed.recordedAt : base.claimedAt,
187
+ settledAt: terminal === undefined ? base.settledAt : entry.recordedAt,
188
+ });
189
+ }
190
+ return [...byExecution.values()];
191
+ };
192
+ //# sourceMappingURL=journal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"journal.js","sourceRoot":"","sources":["../../src/execution/journal.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The journal: the immutable, append-only record of a case's Executions\n * (CONTEXT.md).\n *\n * Historical questions are answered from what the system actually believed at\n * the time, never by re-deriving the past through present-day code — so a\n * `claimed` entry stores the guard evaluation *and* the Case State it was\n * evaluated against, and every entry repeats the Execution's identity (step,\n * scope key, actor). Per-track audit — \"everything that happened on buyer\n * #7\" — is therefore a filter, not a reconstruction.\n *\n * Only inserts exist in this module. There is no update or delete path for a\n * journal row anywhere in the library.\n */\n\nimport type { JournalEntryKind } from '@affordance/contract'\nimport type { GuardEvaluation } from '../guards/index.js'\nimport type { Queryable } from '../store/index.js'\nimport { FRAMEWORK_SCHEMA, mintId, sqlWhere } from '../store/index.js'\nimport type { StateDelta } from './delta.js'\n\nconst JOURNAL = `${FRAMEWORK_SCHEMA}.journal`\nconst JOURNAL_COLUMNS =\n 'ordinal, id, case_id, execution_id, entry, attempt, step, scope_key, actor, input, as_of, guard, state, delta, dormancy, error, recorded_at'\n\n/**\n * Which lifecycle moment an entry records.\n *\n * - `claimed` — the claim's transactional guard re-evaluation passed and the\n * Execution took the case; carries `guard`, `asOf` and `state`\n * - `attempt-failed` — one attempt threw and another will follow\n * - `completed` — the handler's Case State was committed; carries `delta`\n * - `failed` — retries exhausted (or a deterministic defect); case released\n * - `expired` — the claim lapsed without a terminal entry: the handler's\n * process died, and a later claimant recorded the abandonment\n */\nexport type JournalEntryType = JournalEntryKind\n\n/** A failure as journaled — the error's identity, not a live Error object. */\nexport interface JournalError {\n readonly name: string\n readonly message: string\n}\n\n/** One journal entry, JSON-serializable throughout (timestamps are ISO-8601 UTC). */\nexport interface JournalEntry {\n /** Total insertion order across all cases; per-case order is `(caseId, ordinal)`. */\n readonly ordinal: number\n readonly id: string\n readonly caseId: string\n /** The Execution this entry belongs to — several entries share one. */\n readonly executionId: string\n readonly entry: JournalEntryType\n /** 1-based attempt this entry is about. */\n readonly attempt: number\n readonly step: string\n /** The bound scope key, or `null` for an unscoped step. */\n readonly scopeKey: string | null\n /** The acting Actor, as supplied by the app. */\n readonly actor: unknown\n /** The step input, post-validation (schema output), or `null`. */\n readonly input: unknown\n /** The instant the claim's guard re-evaluation was made as of, on `claimed` entries. */\n readonly asOf: string | null\n /** The claim-time guard evaluation — the enforcement moment's full record. */\n readonly guard: GuardEvaluation | null\n /** The Case State the guard was evaluated against, on `claimed` entries. */\n readonly state: unknown\n /** The committed state delta, on `completed` entries. */\n readonly delta: StateDelta | null\n /** `end()` / `reopen()` called by the handler, on `completed` entries. */\n readonly dormancy: 'ended' | 'reopened' | null\n /** The failure, on `attempt-failed` / `failed` / `expired` entries. */\n readonly error: JournalError | null\n readonly recordedAt: string\n}\n\n/** The identity every journal entry carries, whatever its kind. */\ninterface JournalEntryIdentity {\n readonly caseId: string\n readonly executionId: string\n readonly attempt: number\n readonly step: string\n readonly scopeKey?: string | null\n readonly actor?: unknown\n readonly input?: unknown\n}\n\n/**\n * A `claimed` entry records the enforcement moment, so the evidence is\n * required: the instant, the guard evaluation, and the Case State it ran\n * against.\n */\nexport interface ClaimedEntryInput extends JournalEntryIdentity {\n readonly entry: 'claimed'\n readonly asOf: string\n readonly guard: GuardEvaluation\n readonly state: unknown\n}\n\n/** A `completed` entry records what the commit changed. */\nexport interface CompletedEntryInput extends JournalEntryIdentity {\n readonly entry: 'completed'\n readonly delta: StateDelta\n readonly dormancy?: 'ended' | 'reopened' | null\n}\n\n/** Every way an Execution stops without committing carries the failure that stopped it. */\nexport interface FailureEntryInput extends JournalEntryIdentity {\n readonly entry: 'attempt-failed' | 'failed' | 'expired'\n readonly error: JournalError\n}\n\n/**\n * What {@link appendEntry} needs — a discriminated union on `entry`, so\n * which fields accompany which lifecycle moment is stated by the type\n * itself rather than re-derived from prose by every reader.\n * `{ entry: 'failed', guard, delta }` is unrepresentable rather than\n * quietly journaled.\n */\nexport type JournalEntryInput =\n | ClaimedEntryInput\n | CompletedEntryInput\n | FailureEntryInput\n\n/**\n * A `claimed` entry as read back, with the enforcement-moment evidence\n * present — what {@link appendEntry}'s input union guarantees was written.\n */\nexport type ClaimedJournalEntry = JournalEntry & {\n readonly entry: 'claimed'\n readonly asOf: string\n readonly guard: GuardEvaluation\n}\n\n/**\n * Narrow a read entry to the claimed moment. The one predicate every reader\n * of claim-time evidence (`foldExecutions`, audit replay) shares, so what\n * counts as \"carries the evidence\" is decided once.\n */\nexport const isClaimedEntry = (\n entry: JournalEntry,\n): entry is ClaimedJournalEntry =>\n entry.entry === 'claimed' && entry.guard !== null && entry.asOf !== null\n\n/** Filters for {@link readJournal}; all optional, all AND-ed. */\nexport interface JournalFilter {\n /** Per-track audit: only entries bound to this scope key. */\n readonly scopeKey?: string\n /** Only entries for this step. */\n readonly step?: string\n /** Only entries belonging to this Execution. */\n readonly executionId?: string\n /** Only these entry types. */\n readonly entry?: JournalEntryType | readonly JournalEntryType[]\n /** Only entries after this ordinal (exclusive) — cursor paging. */\n readonly since?: number\n /** Cap the number of entries returned; the oldest matching entries win. */\n readonly limit?: number\n}\n\ntype JournalRow = {\n ordinal: string | number\n id: string\n case_id: string\n execution_id: string\n entry: string\n attempt: number\n step: string\n scope_key: string | null\n actor: unknown\n input: unknown\n as_of: Date | null\n guard: GuardEvaluation | null\n state: unknown\n delta: StateDelta | null\n dormancy: string | null\n error: JournalError | null\n recorded_at: Date\n}\n\nconst toEntry = (row: JournalRow): JournalEntry => ({\n ordinal: Number(row.ordinal),\n id: row.id,\n caseId: row.case_id,\n executionId: row.execution_id,\n entry: row.entry as JournalEntryType,\n attempt: row.attempt,\n step: row.step,\n scopeKey: row.scope_key,\n actor: row.actor,\n input: row.input,\n asOf: row.as_of === null ? null : row.as_of.toISOString(),\n guard: row.guard,\n state: row.state,\n delta: row.delta,\n dormancy: row.dormancy as 'ended' | 'reopened' | null,\n error: row.error,\n recordedAt: row.recorded_at.toISOString(),\n})\n\n/**\n * Serialize a value for a jsonb column. Actors and inputs are app-owned\n * shapes, and a journal append must never be the thing that fails an\n * otherwise-good Execution: a value that will not stringify (a cycle, a\n * BigInt) is journaled as a marker string rather than thrown over.\n */\nconst toJsonb = (value: unknown): string | null => {\n if (value === undefined || value === null) return null\n try {\n const json = JSON.stringify(value)\n return json === undefined ? null : json\n } catch {\n return JSON.stringify({ '~unserializable': String(value) })\n }\n}\n\n/** A stored entry minus what storage assigns: `ordinal`, `id`, `recordedAt`. */\nexport type JournalEntryColumns = Omit<\n JournalEntry,\n 'ordinal' | 'id' | 'recordedAt'\n>\n\n/**\n * Project an input onto a stored entry's fields — the one statement of the\n * defaulting and of which fields accompany which lifecycle moment. Every\n * adapter persists exactly this and assigns the rest; an adapter that could\n * disagree with another about what a `failed` entry looks like would be\n * a second, divergent copy of the journal's semantics.\n */\nexport const projectEntry = (input: JournalEntryInput): JournalEntryColumns => {\n const claimed = input.entry === 'claimed' ? input : null\n const completed = input.entry === 'completed' ? input : null\n const failure =\n input.entry !== 'claimed' && input.entry !== 'completed' ? input : null\n return {\n caseId: input.caseId,\n executionId: input.executionId,\n entry: input.entry,\n attempt: input.attempt,\n step: input.step,\n scopeKey: input.scopeKey ?? null,\n actor: input.actor ?? null,\n input: input.input ?? null,\n asOf: claimed?.asOf ?? null,\n guard: claimed?.guard ?? null,\n state: claimed?.state ?? null,\n delta: completed?.delta ?? null,\n dormancy: completed?.dormancy ?? null,\n error: failure?.error ?? null,\n }\n}\n\n/** Append one entry. Inserts only — journal rows are never updated or deleted. */\nexport const appendEntry = async (\n db: Queryable,\n input: JournalEntryInput,\n): Promise<JournalEntry> => {\n const entry = projectEntry(input)\n const { rows } = await db.query<JournalRow>(\n `insert into ${JOURNAL}\n (id, case_id, execution_id, entry, attempt, step, scope_key, actor, input, as_of, guard, state, delta, dormancy, error)\n values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::timestamptz, $11::jsonb, $12::jsonb, $13::jsonb, $14, $15::jsonb)\n returning ${JOURNAL_COLUMNS}`,\n [\n mintId('journal'),\n entry.caseId,\n entry.executionId,\n entry.entry,\n entry.attempt,\n entry.step,\n entry.scopeKey,\n toJsonb(entry.actor),\n toJsonb(entry.input),\n entry.asOf,\n toJsonb(entry.guard),\n toJsonb(entry.state),\n toJsonb(entry.delta),\n entry.dormancy,\n toJsonb(entry.error),\n ],\n )\n const row = rows[0]\n if (!row) throw new Error(`insert into ${JOURNAL} returned no row`)\n return toEntry(row)\n}\n\n/**\n * Read a case's journal in insertion order, oldest first. With no filter this\n * is the whole story of the case; with `scopeKey` it is one track's audit.\n */\nexport const readJournal = async (\n db: Queryable,\n caseId: string,\n filter: JournalFilter = {},\n): Promise<readonly JournalEntry[]> => {\n const { conditions, values, bind, where } = sqlWhere(\n ['case_id = $1'],\n [caseId],\n )\n\n if (filter.scopeKey !== undefined)\n conditions.push(`scope_key = ${bind(filter.scopeKey)}`)\n if (filter.step !== undefined) conditions.push(`step = ${bind(filter.step)}`)\n if (filter.executionId !== undefined)\n conditions.push(`execution_id = ${bind(filter.executionId)}`)\n if (filter.entry !== undefined) {\n const entries = Array.isArray(filter.entry) ? filter.entry : [filter.entry]\n conditions.push(`entry = any(${bind(entries)}::text[])`)\n }\n if (filter.since !== undefined)\n conditions.push(`ordinal > ${bind(filter.since)}`)\n\n const limit = filter.limit === undefined ? '' : ` limit ${bind(filter.limit)}`\n const { rows } = await db.query<JournalRow>(\n `select ${JOURNAL_COLUMNS} from ${JOURNAL}\n where ${where()}\n order by ordinal asc${limit}`,\n values,\n )\n return rows.map(toEntry)\n}\n\n/** How an Execution ended up, folded from its entries. */\nexport type ExecutionStatus = 'in-progress' | 'completed' | 'failed' | 'expired'\n\n/**\n * One Execution as a single record: its identity, the claim-time evidence,\n * and how it settled. This is a fold over entries of the *same* Execution —\n * assembling one record from the moments that constitute it, not deriving\n * state from a log (the design rejects the latter, not the former).\n */\nexport interface ExecutionRecord {\n readonly executionId: string\n readonly caseId: string\n readonly step: string\n readonly scopeKey: string | null\n readonly actor: unknown\n readonly input: unknown\n readonly status: ExecutionStatus\n /** Attempts observed — the highest attempt number any of its entries carries. */\n readonly attempts: number\n readonly asOf: string | null\n readonly guard: GuardEvaluation | null\n /** The Case State the claim's guard was evaluated against. */\n readonly state: unknown\n readonly delta: StateDelta | null\n readonly dormancy: 'ended' | 'reopened' | null\n readonly error: JournalError | null\n readonly claimedAt: string | null\n /** When the Execution reached a terminal entry; `null` while in progress. */\n readonly settledAt: string | null\n}\n\nconst TERMINAL: Record<string, ExecutionStatus | undefined> = {\n completed: 'completed',\n failed: 'failed',\n expired: 'expired',\n}\n\n/**\n * Fold journal entries into one record per Execution, in first-appearance\n * order. Feed it a filtered read (by scope key, say) to get that track's\n * Executions.\n */\nexport const foldExecutions = (\n entries: readonly JournalEntry[],\n): readonly ExecutionRecord[] => {\n const byExecution = new Map<string, ExecutionRecord>()\n for (const entry of entries) {\n const previous = byExecution.get(entry.executionId)\n const terminal = TERMINAL[entry.entry]\n const claimed = isClaimedEntry(entry) ? entry : null\n const base: ExecutionRecord = previous ?? {\n executionId: entry.executionId,\n caseId: entry.caseId,\n step: entry.step,\n scopeKey: entry.scopeKey,\n actor: entry.actor,\n input: entry.input,\n status: 'in-progress',\n attempts: entry.attempt,\n asOf: null,\n guard: null,\n state: undefined,\n delta: null,\n dormancy: null,\n error: null,\n claimedAt: null,\n settledAt: null,\n }\n byExecution.set(entry.executionId, {\n ...base,\n attempts: Math.max(base.attempts, entry.attempt),\n status: terminal ?? base.status,\n asOf: claimed !== null ? claimed.asOf : base.asOf,\n guard: claimed !== null ? claimed.guard : base.guard,\n state: claimed !== null ? claimed.state : base.state,\n delta: entry.delta ?? base.delta,\n dormancy: entry.dormancy ?? base.dormancy,\n // The terminal error is the one that matters; an attempt-failed error\n // only stands while nothing has superseded it.\n error: entry.error ?? base.error,\n claimedAt: claimed !== null ? claimed.recordedAt : base.claimedAt,\n settledAt: terminal === undefined ? base.settledAt : entry.recordedAt,\n })\n }\n return [...byExecution.values()]\n}\n"]}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The lifecycle port: what claim → run → commit asks of storage, and nothing
3
+ * else.
4
+ *
5
+ * An **internal** seam, deliberately. `docs/architecture.md` stands:
6
+ * Postgres is a hard dependency and there is no public storage-adapter
7
+ * abstraction — an app never sees this interface. It exists because the
8
+ * claim state machine (busy vs. takeover, the holder check, retry
9
+ * classification, per-attempt write discard) upholds more always-true
10
+ * rules in one place than anything else in the package, and testing those
11
+ * rules means staging precise situations — an expired lease, a takeover
12
+ * mid-run — that should not require a running database. Two adapters make
13
+ * the seam real: the pg one below for production, and the in-memory one in
14
+ * `test/execution/memory-port.ts` for the tests.
15
+ *
16
+ * The shape keeps the lifecycle's transactional structure explicit:
17
+ * {@link LifecyclePort.withCaseLock} is "one short transaction holding the
18
+ * case row's lock" — the claim and the commit are each exactly one of those
19
+ * — and everything else deliberately runs outside any transaction, because
20
+ * a handler is running and the lease, not a lock, carries exclusivity.
21
+ */
22
+ import type { CommitWrite } from '../model/index.js';
23
+ import type { CaseTypeLookup, DatabaseAccess, Dormancy, ResolvedCase } from '../store/index.js';
24
+ import type { JournalEntry, JournalEntryInput } from './journal.js';
25
+ /** The claim sitting on a case, as the lifecycle needs to judge it. */
26
+ export interface HeldClaim {
27
+ readonly executionId: string;
28
+ readonly step: string;
29
+ readonly scopeKey: string | null;
30
+ readonly attempt: number;
31
+ readonly expiresAt: string;
32
+ /** True when the lease has lapsed — the next claimant may take the case over. */
33
+ readonly expired: boolean;
34
+ }
35
+ /** What the lifecycle asks of storage inside one case-locked transaction. */
36
+ export interface LifecycleTx {
37
+ /**
38
+ * The case, loaded under its lock and resolved whole — definition looked
39
+ * up, stored state validated against its schema — the serialization
40
+ * point. Returning the resolved triple rather than a raw row is what
41
+ * keeps "load, resolve, validate, in that order" spelled once (in
42
+ * `store/resolve.ts`) instead of re-derived by each adapter's caller.
43
+ */
44
+ readonly loadCase: () => Promise<ResolvedCase>;
45
+ /**
46
+ * Take the case row's lock without interpreting the row — the commit's
47
+ * serialization point. The commit already holds everything it computed at
48
+ * the claim; what it needs from the row is only the lock (and proof the
49
+ * row exists), never a second read-and-validate of the state document.
50
+ */
51
+ readonly lockCase: () => Promise<void>;
52
+ /** The claim on the case, `null` when nobody holds it. */
53
+ readonly currentClaim: () => Promise<HeldClaim | null>;
54
+ readonly insertClaim: (executionId: string, step: string, scopeKey: string | null, ttlMs: number) => Promise<{
55
+ readonly claimedAt: string;
56
+ }>;
57
+ readonly deleteClaim: (executionId: string) => Promise<void>;
58
+ readonly appendEntry: (input: JournalEntryInput) => Promise<JournalEntry>;
59
+ /** Write the next Case State, bump `seq`, apply the dormancy transition. */
60
+ readonly updateCaseState: (state: unknown, dormancy: Dormancy | null) => Promise<{
61
+ readonly seq: number;
62
+ readonly endedAt: string | null;
63
+ }>;
64
+ /** The app's own `ctx.onCommit` writes, riding the same transaction. */
65
+ readonly appWrites: (writes: readonly CommitWrite[]) => Promise<void>;
66
+ }
67
+ /** The verbs the execution lifecycle needs from storage. */
68
+ export interface LifecyclePort {
69
+ /** One short transaction holding the case row's lock — a claim or a commit. */
70
+ readonly withCaseLock: <T>(caseId: string, fn: (tx: LifecycleTx) => Promise<T>) => Promise<T>;
71
+ /** Journal outside any transaction — `attempt-failed` / `failed` entries. */
72
+ readonly appendEntry: (input: JournalEntryInput) => Promise<JournalEntry>;
73
+ /** Refresh the lease. Best-effort: a failed beat just lets the claim age. */
74
+ readonly heartbeat: (caseId: string, executionId: string, ttlMs: number) => Promise<void>;
75
+ /** Keep the lease's attempt counter current across retries. Best-effort. */
76
+ readonly bumpAttempt: (caseId: string, executionId: string, attempt: number) => Promise<void>;
77
+ /** Delete this Execution's claim — scoped to `executionId`, so releasing a lease we no longer hold is a no-op. */
78
+ readonly releaseClaim: (caseId: string, executionId: string) => Promise<void>;
79
+ }
80
+ /** The production adapter: each port verb implemented as SQL over the claims, journal and cases tables. */
81
+ export declare const pgLifecyclePort: (db: DatabaseAccess, caseTypeFor: CaseTypeLookup) => LifecyclePort;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The lifecycle port: what claim → run → commit asks of storage, and nothing
3
+ * else.
4
+ *
5
+ * An **internal** seam, deliberately. `docs/architecture.md` stands:
6
+ * Postgres is a hard dependency and there is no public storage-adapter
7
+ * abstraction — an app never sees this interface. It exists because the
8
+ * claim state machine (busy vs. takeover, the holder check, retry
9
+ * classification, per-attempt write discard) upholds more always-true
10
+ * rules in one place than anything else in the package, and testing those
11
+ * rules means staging precise situations — an expired lease, a takeover
12
+ * mid-run — that should not require a running database. Two adapters make
13
+ * the seam real: the pg one below for production, and the in-memory one in
14
+ * `test/execution/memory-port.ts` for the tests.
15
+ *
16
+ * The shape keeps the lifecycle's transactional structure explicit:
17
+ * {@link LifecyclePort.withCaseLock} is "one short transaction holding the
18
+ * case row's lock" — the claim and the commit are each exactly one of those
19
+ * — and everything else deliberately runs outside any transaction, because
20
+ * a handler is running and the lease, not a lock, carries exclusivity.
21
+ */
22
+ import { CaseNotFoundError, FRAMEWORK_SCHEMA, queryableOf, resolveCaseForUpdate, updateCaseState, } from '../store/index.js';
23
+ import { appendEntry } from './journal.js';
24
+ import { withTransaction } from './transaction.js';
25
+ const CASES = `${FRAMEWORK_SCHEMA}.cases`;
26
+ const CLAIMS = `${FRAMEWORK_SCHEMA}.claims`;
27
+ /** `now() + <ms>` as a SQL expression against a bound millisecond parameter. */
28
+ const expiryExpression = (parameter) => `now() + (${parameter}::double precision * interval '1 millisecond')`;
29
+ /** The production adapter: each port verb implemented as SQL over the claims, journal and cases tables. */
30
+ export const pgLifecyclePort = (db, caseTypeFor) => {
31
+ // The lease verbs are single self-contained statements; only the
32
+ // case-locked transactions care which arm of the access the caller brought.
33
+ const q = queryableOf(db);
34
+ return {
35
+ withCaseLock: (caseId, fn) => withTransaction(db, (tx) => fn({
36
+ loadCase: () => resolveCaseForUpdate(tx, caseTypeFor, caseId),
37
+ lockCase: async () => {
38
+ const { rows } = await tx.query(`select id from ${CASES} where id = $1 for update`, [caseId]);
39
+ if (rows.length === 0)
40
+ throw new CaseNotFoundError(caseId);
41
+ },
42
+ currentClaim: async () => {
43
+ const { rows } = await tx.query(`select execution_id, step, scope_key, attempt, expires_at, expires_at <= now() as expired
44
+ from ${CLAIMS} where case_id = $1`, [caseId]);
45
+ const row = rows[0];
46
+ if (!row)
47
+ return null;
48
+ return {
49
+ executionId: row.execution_id,
50
+ step: row.step,
51
+ scopeKey: row.scope_key,
52
+ attempt: row.attempt,
53
+ expiresAt: row.expires_at.toISOString(),
54
+ expired: row.expired,
55
+ };
56
+ },
57
+ insertClaim: async (executionId, step, scopeKey, ttlMs) => {
58
+ const { rows } = await tx.query(`insert into ${CLAIMS} (case_id, execution_id, step, scope_key, expires_at)
59
+ values ($1, $2, $3, $4, ${expiryExpression('$5')})
60
+ returning claimed_at`, [caseId, executionId, step, scopeKey, ttlMs]);
61
+ return {
62
+ claimedAt: rows[0]?.claimed_at.toISOString() ?? new Date().toISOString(),
63
+ };
64
+ },
65
+ deleteClaim: async (executionId) => {
66
+ await tx.query(`delete from ${CLAIMS} where case_id = $1 and execution_id = $2`, [caseId, executionId]);
67
+ },
68
+ appendEntry: (input) => appendEntry(tx, input),
69
+ updateCaseState: async (state, dormancy) => {
70
+ const updated = await updateCaseState(tx, caseId, state, dormancy);
71
+ return {
72
+ seq: updated.seq,
73
+ endedAt: updated.endedAt === null ? null : updated.endedAt.toISOString(),
74
+ };
75
+ },
76
+ appWrites: async (writes) => {
77
+ for (const write of writes)
78
+ await write(tx);
79
+ },
80
+ })),
81
+ appendEntry: (input) => appendEntry(q, input),
82
+ heartbeat: async (caseId, executionId, ttlMs) => {
83
+ await q
84
+ .query(`update ${CLAIMS}
85
+ set heartbeat_at = now(), expires_at = ${expiryExpression('$3')}
86
+ where case_id = $1 and execution_id = $2`, [caseId, executionId, ttlMs])
87
+ .catch(() => undefined);
88
+ },
89
+ bumpAttempt: async (caseId, executionId, attempt) => {
90
+ await q
91
+ .query(`update ${CLAIMS} set attempt = $3 where case_id = $1 and execution_id = $2`, [caseId, executionId, attempt])
92
+ .catch(() => undefined);
93
+ },
94
+ releaseClaim: async (caseId, executionId) => {
95
+ await q
96
+ .query(`delete from ${CLAIMS} where case_id = $1 and execution_id = $2`, [caseId, executionId])
97
+ .catch(() => undefined);
98
+ },
99
+ };
100
+ };
101
+ //# sourceMappingURL=port.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"port.js","sourceRoot":"","sources":["../../src/execution/port.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The lifecycle port: what claim → run → commit asks of storage, and nothing\n * else.\n *\n * An **internal** seam, deliberately. `docs/architecture.md` stands:\n * Postgres is a hard dependency and there is no public storage-adapter\n * abstraction — an app never sees this interface. It exists because the\n * claim state machine (busy vs. takeover, the holder check, retry\n * classification, per-attempt write discard) upholds more always-true\n * rules in one place than anything else in the package, and testing those\n * rules means staging precise situations — an expired lease, a takeover\n * mid-run — that should not require a running database. Two adapters make\n * the seam real: the pg one below for production, and the in-memory one in\n * `test/execution/memory-port.ts` for the tests.\n *\n * The shape keeps the lifecycle's transactional structure explicit:\n * {@link LifecyclePort.withCaseLock} is \"one short transaction holding the\n * case row's lock\" — the claim and the commit are each exactly one of those\n * — and everything else deliberately runs outside any transaction, because\n * a handler is running and the lease, not a lock, carries exclusivity.\n */\n\nimport type { CommitWrite } from '../model/index.js'\nimport type {\n CaseTypeLookup,\n DatabaseAccess,\n Dormancy,\n ResolvedCase,\n} from '../store/index.js'\nimport {\n CaseNotFoundError,\n FRAMEWORK_SCHEMA,\n queryableOf,\n resolveCaseForUpdate,\n updateCaseState,\n} from '../store/index.js'\nimport type { JournalEntry, JournalEntryInput } from './journal.js'\nimport { appendEntry } from './journal.js'\nimport { withTransaction } from './transaction.js'\n\nconst CASES = `${FRAMEWORK_SCHEMA}.cases`\nconst CLAIMS = `${FRAMEWORK_SCHEMA}.claims`\n\n/** `now() + <ms>` as a SQL expression against a bound millisecond parameter. */\nconst expiryExpression = (parameter: string): string =>\n `now() + (${parameter}::double precision * interval '1 millisecond')`\n\n/** The claim sitting on a case, as the lifecycle needs to judge it. */\nexport interface HeldClaim {\n readonly executionId: string\n readonly step: string\n readonly scopeKey: string | null\n readonly attempt: number\n readonly expiresAt: string\n /** True when the lease has lapsed — the next claimant may take the case over. */\n readonly expired: boolean\n}\n\n/** What the lifecycle asks of storage inside one case-locked transaction. */\nexport interface LifecycleTx {\n /**\n * The case, loaded under its lock and resolved whole — definition looked\n * up, stored state validated against its schema — the serialization\n * point. Returning the resolved triple rather than a raw row is what\n * keeps \"load, resolve, validate, in that order\" spelled once (in\n * `store/resolve.ts`) instead of re-derived by each adapter's caller.\n */\n readonly loadCase: () => Promise<ResolvedCase>\n /**\n * Take the case row's lock without interpreting the row — the commit's\n * serialization point. The commit already holds everything it computed at\n * the claim; what it needs from the row is only the lock (and proof the\n * row exists), never a second read-and-validate of the state document.\n */\n readonly lockCase: () => Promise<void>\n /** The claim on the case, `null` when nobody holds it. */\n readonly currentClaim: () => Promise<HeldClaim | null>\n readonly insertClaim: (\n executionId: string,\n step: string,\n scopeKey: string | null,\n ttlMs: number,\n ) => Promise<{ readonly claimedAt: string }>\n readonly deleteClaim: (executionId: string) => Promise<void>\n readonly appendEntry: (input: JournalEntryInput) => Promise<JournalEntry>\n /** Write the next Case State, bump `seq`, apply the dormancy transition. */\n readonly updateCaseState: (\n state: unknown,\n dormancy: Dormancy | null,\n ) => Promise<{ readonly seq: number; readonly endedAt: string | null }>\n /** The app's own `ctx.onCommit` writes, riding the same transaction. */\n readonly appWrites: (writes: readonly CommitWrite[]) => Promise<void>\n}\n\n/** The verbs the execution lifecycle needs from storage. */\nexport interface LifecyclePort {\n /** One short transaction holding the case row's lock — a claim or a commit. */\n readonly withCaseLock: <T>(\n caseId: string,\n fn: (tx: LifecycleTx) => Promise<T>,\n ) => Promise<T>\n /** Journal outside any transaction — `attempt-failed` / `failed` entries. */\n readonly appendEntry: (input: JournalEntryInput) => Promise<JournalEntry>\n /** Refresh the lease. Best-effort: a failed beat just lets the claim age. */\n readonly heartbeat: (\n caseId: string,\n executionId: string,\n ttlMs: number,\n ) => Promise<void>\n /** Keep the lease's attempt counter current across retries. Best-effort. */\n readonly bumpAttempt: (\n caseId: string,\n executionId: string,\n attempt: number,\n ) => Promise<void>\n /** Delete this Execution's claim — scoped to `executionId`, so releasing a lease we no longer hold is a no-op. */\n readonly releaseClaim: (caseId: string, executionId: string) => Promise<void>\n}\n\ntype ClaimRow = {\n execution_id: string\n step: string\n scope_key: string | null\n attempt: number\n expires_at: Date\n expired: boolean\n}\n\n/** The production adapter: each port verb implemented as SQL over the claims, journal and cases tables. */\nexport const pgLifecyclePort = (\n db: DatabaseAccess,\n caseTypeFor: CaseTypeLookup,\n): LifecyclePort => {\n // The lease verbs are single self-contained statements; only the\n // case-locked transactions care which arm of the access the caller brought.\n const q = queryableOf(db)\n return {\n withCaseLock: (caseId, fn) =>\n withTransaction(db, (tx) =>\n fn({\n loadCase: () => resolveCaseForUpdate(tx, caseTypeFor, caseId),\n lockCase: async () => {\n const { rows } = await tx.query<{ id: string }>(\n `select id from ${CASES} where id = $1 for update`,\n [caseId],\n )\n if (rows.length === 0) throw new CaseNotFoundError(caseId)\n },\n currentClaim: async () => {\n const { rows } = await tx.query<ClaimRow>(\n `select execution_id, step, scope_key, attempt, expires_at, expires_at <= now() as expired\n from ${CLAIMS} where case_id = $1`,\n [caseId],\n )\n const row = rows[0]\n if (!row) return null\n return {\n executionId: row.execution_id,\n step: row.step,\n scopeKey: row.scope_key,\n attempt: row.attempt,\n expiresAt: row.expires_at.toISOString(),\n expired: row.expired,\n }\n },\n insertClaim: async (executionId, step, scopeKey, ttlMs) => {\n const { rows } = await tx.query<{ claimed_at: Date }>(\n `insert into ${CLAIMS} (case_id, execution_id, step, scope_key, expires_at)\n values ($1, $2, $3, $4, ${expiryExpression('$5')})\n returning claimed_at`,\n [caseId, executionId, step, scopeKey, ttlMs],\n )\n return {\n claimedAt:\n rows[0]?.claimed_at.toISOString() ?? new Date().toISOString(),\n }\n },\n deleteClaim: async (executionId) => {\n await tx.query(\n `delete from ${CLAIMS} where case_id = $1 and execution_id = $2`,\n [caseId, executionId],\n )\n },\n appendEntry: (input) => appendEntry(tx, input),\n updateCaseState: async (state, dormancy) => {\n const updated = await updateCaseState(tx, caseId, state, dormancy)\n return {\n seq: updated.seq,\n endedAt:\n updated.endedAt === null ? null : updated.endedAt.toISOString(),\n }\n },\n appWrites: async (writes) => {\n for (const write of writes) await write(tx)\n },\n }),\n ),\n appendEntry: (input) => appendEntry(q, input),\n heartbeat: async (caseId, executionId, ttlMs) => {\n await q\n .query(\n `update ${CLAIMS}\n set heartbeat_at = now(), expires_at = ${expiryExpression('$3')}\n where case_id = $1 and execution_id = $2`,\n [caseId, executionId, ttlMs],\n )\n .catch(() => undefined)\n },\n bumpAttempt: async (caseId, executionId, attempt) => {\n await q\n .query(\n `update ${CLAIMS} set attempt = $3 where case_id = $1 and execution_id = $2`,\n [caseId, executionId, attempt],\n )\n .catch(() => undefined)\n },\n releaseClaim: async (caseId, executionId) => {\n await q\n .query(\n `delete from ${CLAIMS} where case_id = $1 and execution_id = $2`,\n [caseId, executionId],\n )\n .catch(() => undefined)\n },\n }\n}\n"]}