@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.
- package/README.md +17 -6
- package/dist/engine/compute.d.ts +2 -2
- package/dist/engine/compute.js.map +1 -1
- package/dist/engine/engine.d.ts +14 -22
- package/dist/engine/engine.js +35 -17
- package/dist/engine/engine.js.map +1 -1
- package/dist/errors.d.ts +3 -9
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -1
- package/dist/execution/delta.d.ts +1 -1
- package/dist/execution/delta.js +1 -1
- package/dist/execution/delta.js.map +1 -1
- package/dist/execution/execute.d.ts +19 -18
- package/dist/execution/execute.js +22 -25
- package/dist/execution/execute.js.map +1 -1
- package/dist/execution/index.d.ts +1 -3
- package/dist/execution/index.js +1 -3
- package/dist/execution/index.js.map +1 -1
- package/dist/execution/journal.d.ts +4 -12
- package/dist/execution/journal.js +20 -93
- package/dist/execution/journal.js.map +1 -1
- package/dist/execution/port.d.ts +15 -44
- package/dist/execution/port.js +1 -100
- package/dist/execution/port.js.map +1 -1
- package/dist/execution/replay.d.ts +1 -1
- package/dist/execution/replay.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/ingestion/correlation.d.ts +0 -32
- package/dist/ingestion/correlation.js +1 -77
- package/dist/ingestion/correlation.js.map +1 -1
- package/dist/ingestion/index.d.ts +1 -2
- package/dist/ingestion/index.js +1 -2
- package/dist/ingestion/index.js.map +1 -1
- package/dist/ingestion/ingest.d.ts +10 -16
- package/dist/ingestion/ingest.js +12 -100
- package/dist/ingestion/ingest.js.map +1 -1
- package/dist/migration/migrate.d.ts +6 -7
- package/dist/migration/migrate.js +11 -40
- package/dist/migration/migrate.js.map +1 -1
- package/dist/model/casetype.d.ts +8 -8
- package/dist/model/casetype.js.map +1 -1
- package/dist/model/handler.d.ts +21 -15
- package/dist/model/handler.js.map +1 -1
- package/dist/model/index.d.ts +3 -3
- package/dist/model/index.js +1 -1
- package/dist/model/index.js.map +1 -1
- package/dist/model/step.d.ts +18 -13
- package/dist/model/step.js +2 -1
- package/dist/model/step.js.map +1 -1
- package/dist/model/target.d.ts +12 -15
- package/dist/model/target.js +2 -5
- package/dist/model/target.js.map +1 -1
- package/dist/storage.d.ts +93 -0
- package/dist/storage.js +4 -0
- package/dist/storage.js.map +1 -0
- package/dist/store/ids.d.ts +1 -2
- package/dist/store/ids.js +1 -2
- package/dist/store/ids.js.map +1 -1
- package/dist/store/index.d.ts +3 -8
- package/dist/store/index.js +2 -6
- package/dist/store/index.js.map +1 -1
- package/dist/store/resolve.d.ts +7 -14
- package/dist/store/resolve.js +4 -11
- package/dist/store/resolve.js.map +1 -1
- package/dist/store/store.d.ts +4 -41
- package/dist/store/store.js +1 -95
- package/dist/store/store.js.map +1 -1
- package/package.json +8 -8
- package/dist/execution/transaction.d.ts +0 -24
- package/dist/execution/transaction.js +0 -49
- package/dist/execution/transaction.js.map +0 -1
- package/dist/store/bootstrap.d.ts +0 -57
- package/dist/store/bootstrap.js +0 -268
- package/dist/store/bootstrap.js.map +0 -1
- package/dist/store/queryable.d.ts +0 -60
- package/dist/store/queryable.js +0 -7
- package/dist/store/queryable.js.map +0 -1
- package/dist/store/sql.d.ts +0 -26
- package/dist/store/sql.js +0 -21
- package/dist/store/sql.js.map +0 -1
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
* them has to be visible afterwards:
|
|
6
6
|
*
|
|
7
7
|
* 1. **Dedup.** Providers retry; "at least once" is the delivery guarantee
|
|
8
|
-
* every one of them offers.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and two `duplicate` results.
|
|
8
|
+
* every one of them offers. Storage atomically acquires a delivery, so
|
|
9
|
+
* concurrent deliveries of one event produce one Execution and the
|
|
10
|
+
* remaining callers receive `duplicate`.
|
|
12
11
|
* 2. **Correlate.** The external identifier is resolved to (case, scope,
|
|
13
12
|
* step) through the registry the initiating handler wrote.
|
|
14
13
|
* 3. **Execute.** Through the ordinary claim → run → commit, with the
|
|
@@ -21,9 +20,8 @@
|
|
|
21
20
|
* surface. "The webhook definitely arrived, so why is the case still waiting"
|
|
22
21
|
* is a question this table answers without anyone reading a log file.
|
|
23
22
|
*/
|
|
24
|
-
import type {
|
|
23
|
+
import type { AffordanceErrorCode } from '../errors.js';
|
|
25
24
|
import type { ExecutionEnvironment, ExecutionResult, SystemSettled } from '../execution/index.js';
|
|
26
|
-
import type { Queryable } from '../store/index.js';
|
|
27
25
|
import type { Correlation } from './correlation.js';
|
|
28
26
|
/** An event as an external system delivered it. */
|
|
29
27
|
export interface ExternalEvent {
|
|
@@ -48,10 +46,9 @@ export interface ExternalEvent {
|
|
|
48
46
|
* How an ingested event ended up: `executed` (the target step ran and
|
|
49
47
|
* committed), `duplicate` (already seen — this delivery changed nothing, by
|
|
50
48
|
* design), or `dead-lettered` (nothing could be done with it, and it is
|
|
51
|
-
* sitting in the dead-letter surface).
|
|
52
|
-
* declares it and this is the same closed set.
|
|
49
|
+
* sitting in the dead-letter surface).
|
|
53
50
|
*/
|
|
54
|
-
export type IngestionStatus =
|
|
51
|
+
export type IngestionStatus = 'executed' | 'duplicate' | 'dead-lettered';
|
|
55
52
|
/**
|
|
56
53
|
* Why an event was dead-lettered — the operator's first question, answered.
|
|
57
54
|
*
|
|
@@ -60,10 +57,9 @@ export type IngestionStatus = ContractIngestionStatus;
|
|
|
60
57
|
* names a step); every other reason **is** the `AffordanceErrorCode`
|
|
61
58
|
* the refused Execution already declared at its raise site. Ingestion does
|
|
62
59
|
* not re-derive the kind of a Refusal — it projects the code the error
|
|
63
|
-
* carries, so a new refusal class can never misroute here.
|
|
64
|
-
* declared once by the contract; this is the same closed set.
|
|
60
|
+
* carries, so a new refusal class can never misroute here.
|
|
65
61
|
*/
|
|
66
|
-
export type DeadLetterReason =
|
|
62
|
+
export type DeadLetterReason = 'unrouted' | 'no-step' | AffordanceErrorCode;
|
|
67
63
|
/**
|
|
68
64
|
* Whether a provider redelivery of the same event deserves another attempt.
|
|
69
65
|
*
|
|
@@ -142,7 +138,7 @@ export interface IngestionSettings {
|
|
|
142
138
|
* of them — named here so that contract is a type, not a coincidence of an
|
|
143
139
|
* object literal.
|
|
144
140
|
*/
|
|
145
|
-
export interface IngestionEnvironment extends ExecutionEnvironment {
|
|
141
|
+
export interface IngestionEnvironment<TCommit = unknown> extends ExecutionEnvironment<TCommit> {
|
|
146
142
|
readonly ingestion: IngestionSettings;
|
|
147
143
|
}
|
|
148
144
|
export declare const externalActor: (event: ExternalEvent) => ExternalActor;
|
|
@@ -180,7 +176,7 @@ export declare const routedStep: (event: {
|
|
|
180
176
|
* succeed. Infrastructure failures (the database is gone) do still throw,
|
|
181
177
|
* because those the caller must not acknowledge.
|
|
182
178
|
*/
|
|
183
|
-
export declare const ingest: (env: IngestionEnvironment
|
|
179
|
+
export declare const ingest: <TCommit>(env: IngestionEnvironment<TCommit>, event: ExternalEvent) => Promise<IngestionResult>;
|
|
184
180
|
/**
|
|
185
181
|
* Project a settled system run onto the reason an operator needs to see —
|
|
186
182
|
* the one place that asks whether what settled was a Refusal.
|
|
@@ -192,5 +188,3 @@ export declare const ingest: (env: IngestionEnvironment, event: ExternalEvent) =
|
|
|
192
188
|
* silently dropped, with the crash's own words as the detail.
|
|
193
189
|
*/
|
|
194
190
|
export declare const classifyDeadLetter: (ran: SystemSettled) => [DeadLetterReason, string];
|
|
195
|
-
/** Read the dead-letter surface, newest first — the ops view of "arrived, did nothing". */
|
|
196
|
-
export declare const readDeadLetters: (db: Queryable, filter?: DeadLetterFilter) => Promise<readonly DeadLetter[]>;
|
package/dist/ingestion/ingest.js
CHANGED
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
* them has to be visible afterwards:
|
|
6
6
|
*
|
|
7
7
|
* 1. **Dedup.** Providers retry; "at least once" is the delivery guarantee
|
|
8
|
-
* every one of them offers.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and two `duplicate` results.
|
|
8
|
+
* every one of them offers. Storage atomically acquires a delivery, so
|
|
9
|
+
* concurrent deliveries of one event produce one Execution and the
|
|
10
|
+
* remaining callers receive `duplicate`.
|
|
12
11
|
* 2. **Correlate.** The external identifier is resolved to (case, scope,
|
|
13
12
|
* step) through the registry the initiating handler wrote.
|
|
14
13
|
* 3. **Execute.** Through the ordinary claim → run → commit, with the
|
|
@@ -24,9 +23,6 @@
|
|
|
24
23
|
import { createHash } from 'node:crypto';
|
|
25
24
|
import { isAffordanceError } from '../errors.js';
|
|
26
25
|
import { runAsSystem } from '../execution/index.js';
|
|
27
|
-
import { FRAMEWORK_SCHEMA, mintId, queryableOf, sqlWhere, } from '../store/index.js';
|
|
28
|
-
import { lookupCorrelation } from './correlation.js';
|
|
29
|
-
const EVENTS = `${FRAMEWORK_SCHEMA}.ingested_events`;
|
|
30
26
|
/**
|
|
31
27
|
* Whether a provider redelivery of the same event deserves another attempt.
|
|
32
28
|
*
|
|
@@ -83,71 +79,17 @@ export const idempotencyKeyFor = (event) => {
|
|
|
83
79
|
.slice(0, 32);
|
|
84
80
|
return `${event.system}/${event.externalId}/${event.type}/${tail}`;
|
|
85
81
|
};
|
|
86
|
-
/**
|
|
87
|
-
* The dedup gate. Inserts the event's row and reports whether this delivery
|
|
88
|
-
* is the one that got it.
|
|
89
|
-
*
|
|
90
|
-
* `on conflict do nothing` is the whole mechanism: exactly one of N
|
|
91
|
-
* concurrent deliveries inserts, and the losers read what the winner wrote.
|
|
92
|
-
* A previous delivery that ended `dead-lettered` for a *transient* reason is
|
|
93
|
-
* reopened rather than deduplicated — a provider retry after "the case was
|
|
94
|
-
* busy" should get its chance, which is precisely what provider retries are
|
|
95
|
-
* for.
|
|
96
|
-
*/
|
|
97
|
-
const claimDelivery = async (db, event, idempotencyKey) => {
|
|
98
|
-
const inserted = await db.query(`insert into ${EVENTS} (id, system, external_id, type, idempotency_key, status, event)
|
|
99
|
-
values ($1, $2, $3, $4, $5, 'pending', $6::jsonb)
|
|
100
|
-
on conflict (idempotency_key) do nothing
|
|
101
|
-
returning *`, [
|
|
102
|
-
mintId('event'),
|
|
103
|
-
event.system,
|
|
104
|
-
event.externalId,
|
|
105
|
-
event.type,
|
|
106
|
-
idempotencyKey,
|
|
107
|
-
JSON.stringify(event),
|
|
108
|
-
]);
|
|
109
|
-
const row = inserted.rows[0];
|
|
110
|
-
if (row)
|
|
111
|
-
return { row, fresh: true };
|
|
112
|
-
const retried = await db.query(`update ${EVENTS}
|
|
113
|
-
set status = 'pending', reason = null, detail = null, received_at = now(), event = $2::jsonb
|
|
114
|
-
where idempotency_key = $1 and status = 'dead-lettered' and reason = any($3)
|
|
115
|
-
returning *`, [idempotencyKey, JSON.stringify(event), REOPENABLE]);
|
|
116
|
-
const reopened = retried.rows[0];
|
|
117
|
-
if (reopened)
|
|
118
|
-
return { row: reopened, fresh: true };
|
|
119
|
-
const existing = await db.query(`select * from ${EVENTS} where idempotency_key = $1`, [idempotencyKey]);
|
|
120
|
-
const previous = existing.rows[0];
|
|
121
|
-
if (!previous)
|
|
122
|
-
throw new Error(`${EVENTS}: delivery neither inserted nor found — key ${idempotencyKey}`);
|
|
123
|
-
return { row: previous, fresh: false };
|
|
124
|
-
};
|
|
125
|
-
/** Record how a delivery ended. The row is the dead-letter surface, so this is the only settle path. */
|
|
126
|
-
const settle = async (db, id, fields) => {
|
|
127
|
-
await db.query(`update ${EVENTS}
|
|
128
|
-
set status = $2, case_id = $3, scope_key = $4, step = $5, reason = $6, detail = $7, execution_id = $8
|
|
129
|
-
where id = $1`, [
|
|
130
|
-
id,
|
|
131
|
-
fields.status,
|
|
132
|
-
fields.caseId ?? null,
|
|
133
|
-
fields.scopeKey ?? null,
|
|
134
|
-
fields.step ?? null,
|
|
135
|
-
fields.reason ?? null,
|
|
136
|
-
fields.detail ?? null,
|
|
137
|
-
fields.executionId ?? null,
|
|
138
|
-
]);
|
|
139
|
-
};
|
|
140
82
|
const result = (row, status, extra = {}) => ({
|
|
141
83
|
id: row.id,
|
|
142
84
|
status,
|
|
143
85
|
system: row.system,
|
|
144
|
-
externalId: row.
|
|
145
|
-
idempotencyKey: row.
|
|
86
|
+
externalId: row.externalId,
|
|
87
|
+
idempotencyKey: row.idempotencyKey,
|
|
146
88
|
correlation: null,
|
|
147
89
|
execution: null,
|
|
148
90
|
reason: null,
|
|
149
91
|
detail: null,
|
|
150
|
-
receivedAt: row.
|
|
92
|
+
receivedAt: row.receivedAt,
|
|
151
93
|
...extra,
|
|
152
94
|
});
|
|
153
95
|
/**
|
|
@@ -167,19 +109,18 @@ export const routedStep = (event, correlation) => event.step ?? correlation.step
|
|
|
167
109
|
* because those the caller must not acknowledge.
|
|
168
110
|
*/
|
|
169
111
|
export const ingest = async (env, event) => {
|
|
170
|
-
const db = queryableOf(env.db);
|
|
171
112
|
const idempotencyKey = idempotencyKeyFor(event);
|
|
172
|
-
const { row, fresh } = await
|
|
113
|
+
const { row, fresh } = await env.storage.deliveries.acquire(event, idempotencyKey, REOPENABLE);
|
|
173
114
|
if (!fresh) {
|
|
174
115
|
return result(row, 'duplicate', {
|
|
175
|
-
reason: row.reason
|
|
176
|
-
detail: `already ingested as ${row.status} at ${row.
|
|
116
|
+
reason: row.reason,
|
|
117
|
+
detail: `already ingested as ${row.status} at ${row.receivedAt}`,
|
|
177
118
|
});
|
|
178
119
|
}
|
|
179
120
|
// Settle the row and answer the caller with the same reason/detail pair —
|
|
180
121
|
// one helper, so a branch cannot record one thing and report another.
|
|
181
122
|
const deadLetter = async (fields) => {
|
|
182
|
-
await settle(
|
|
123
|
+
await env.storage.deliveries.settle(row.id, {
|
|
183
124
|
status: 'dead-lettered',
|
|
184
125
|
reason: fields.reason,
|
|
185
126
|
detail: fields.detail,
|
|
@@ -197,7 +138,7 @@ export const ingest = async (env, event) => {
|
|
|
197
138
|
}),
|
|
198
139
|
});
|
|
199
140
|
};
|
|
200
|
-
const correlation = await
|
|
141
|
+
const correlation = await env.storage.correlations.lookup(event.system, event.externalId);
|
|
201
142
|
if (correlation === null) {
|
|
202
143
|
return deadLetter({
|
|
203
144
|
reason: 'unrouted',
|
|
@@ -218,7 +159,7 @@ export const ingest = async (env, event) => {
|
|
|
218
159
|
...(event.payload !== undefined && { input: event.payload }),
|
|
219
160
|
});
|
|
220
161
|
if (ran.outcome === 'committed') {
|
|
221
|
-
await settle(
|
|
162
|
+
await env.storage.deliveries.settle(row.id, {
|
|
222
163
|
status: 'executed',
|
|
223
164
|
caseId: correlation.caseId,
|
|
224
165
|
scopeKey: correlation.scopeKey,
|
|
@@ -243,33 +184,4 @@ export const ingest = async (env, event) => {
|
|
|
243
184
|
export const classifyDeadLetter = (ran) => isAffordanceError(ran.error)
|
|
244
185
|
? [ran.error.code, ran.error.message]
|
|
245
186
|
: ['execution-failed', ran.error.message];
|
|
246
|
-
const toDeadLetter = (row) => ({
|
|
247
|
-
id: row.id,
|
|
248
|
-
system: row.system,
|
|
249
|
-
externalId: row.external_id,
|
|
250
|
-
type: row.type,
|
|
251
|
-
idempotencyKey: row.idempotency_key,
|
|
252
|
-
caseId: row.case_id,
|
|
253
|
-
scopeKey: row.scope_key,
|
|
254
|
-
step: row.step,
|
|
255
|
-
reason: row.reason,
|
|
256
|
-
detail: row.detail,
|
|
257
|
-
event: row.event,
|
|
258
|
-
receivedAt: row.received_at.toISOString(),
|
|
259
|
-
});
|
|
260
|
-
/** Read the dead-letter surface, newest first — the ops view of "arrived, did nothing". */
|
|
261
|
-
export const readDeadLetters = async (db, filter = {}) => {
|
|
262
|
-
const { conditions, values, bind, where } = sqlWhere([
|
|
263
|
-
`status = 'dead-lettered'`,
|
|
264
|
-
]);
|
|
265
|
-
if (filter.system !== undefined)
|
|
266
|
-
conditions.push(`system = ${bind(filter.system)}`);
|
|
267
|
-
if (filter.caseId !== undefined)
|
|
268
|
-
conditions.push(`case_id = ${bind(filter.caseId)}`);
|
|
269
|
-
if (filter.reason !== undefined)
|
|
270
|
-
conditions.push(`reason = ${bind(filter.reason)}`);
|
|
271
|
-
const limit = filter.limit === undefined ? '' : ` limit ${bind(filter.limit)}`;
|
|
272
|
-
const { rows } = await db.query(`select * from ${EVENTS} where ${where()} order by received_at desc${limit}`, values);
|
|
273
|
-
return rows.map(toDeadLetter);
|
|
274
|
-
};
|
|
275
187
|
//# sourceMappingURL=ingest.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ingest.js","sourceRoot":"","sources":["../../src/ingestion/ingest.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Ingestion: external events become Executions.\n *\n * A webhook arrives. Three things have to happen, in this order, and each of\n * them has to be visible afterwards:\n *\n * 1. **Dedup.** Providers retry; \"at least once\" is the delivery guarantee\n * every one of them offers. The gate is a unique insert into\n * `ingested_events` — not a lookup-then-insert, which races itself — so\n * three concurrent deliveries of one event produce exactly one Execution\n * and two `duplicate` results.\n * 2. **Correlate.** The external identifier is resolved to (case, scope,\n * step) through the registry the initiating handler wrote.\n * 3. **Execute.** Through the ordinary claim → run → commit, with the\n * external system as the journaled actor. Ingestion has no privileged\n * path: the guard still decides, transactionally.\n *\n * What is *not* allowed is a quiet drop. An event nothing can route, an event\n * whose step the guard refuses, an event whose case is busy — each lands in\n * the same table with a status and a reason, which is the dead-letter\n * surface. \"The webhook definitely arrived, so why is the case still waiting\"\n * is a question this table answers without anyone reading a log file.\n */\n\nimport { createHash } from 'node:crypto'\nimport type {\n DeadLetterReason as ContractDeadLetterReason,\n IngestionStatus as ContractIngestionStatus,\n} from '@affordance/contract'\nimport { isAffordanceError } from '../errors.js'\nimport type {\n ExecutionEnvironment,\n ExecutionResult,\n SystemSettled,\n} from '../execution/index.js'\nimport { runAsSystem } from '../execution/index.js'\nimport type { Queryable } from '../store/index.js'\nimport {\n FRAMEWORK_SCHEMA,\n mintId,\n queryableOf,\n sqlWhere,\n} from '../store/index.js'\nimport type { Correlation } from './correlation.js'\nimport { lookupCorrelation } from './correlation.js'\n\nconst EVENTS = `${FRAMEWORK_SCHEMA}.ingested_events`\n\n/** An event as an external system delivered it. */\nexport interface ExternalEvent {\n /** The system that emitted it, matching the correlation's `system`. */\n readonly system: string\n /** The identifier that routes it — the one the initiating handler registered. */\n readonly externalId: string\n /** What happened, in the provider's own words: `'envelope.completed'`. */\n readonly type: string\n /** The provider's delivery id, when it has one — the strongest dedup key. */\n readonly eventId?: string\n /** The payload, passed to the target step as its input (validated by the step's schema). */\n readonly payload?: unknown\n /** Execute this step instead of the correlation's registered one. */\n readonly step?: string\n /** Override the derived idempotency key — for a provider whose retries are not identical. */\n readonly idempotencyKey?: string\n /** When the external system says it happened (ISO-8601); recorded, never used as `asOf`. */\n readonly occurredAt?: string\n}\n\n/**\n * How an ingested event ended up: `executed` (the target step ran and\n * committed), `duplicate` (already seen — this delivery changed nothing, by\n * design), or `dead-lettered` (nothing could be done with it, and it is\n * sitting in the dead-letter surface). Wire vocabulary, so the contract\n * declares it and this is the same closed set.\n */\nexport type IngestionStatus = ContractIngestionStatus\n\n/**\n * Why an event was dead-lettered — the operator's first question, answered.\n *\n * Two reasons are ingestion's own (`unrouted`: no correlation claims the\n * identifier; `no-step`: routed, but neither the event nor the correlation\n * names a step); every other reason **is** the `AffordanceErrorCode`\n * the refused Execution already declared at its raise site. Ingestion does\n * not re-derive the kind of a Refusal — it projects the code the error\n * carries, so a new refusal class can never misroute here. Wire vocabulary,\n * declared once by the contract; this is the same closed set.\n */\nexport type DeadLetterReason = ContractDeadLetterReason\n\n/**\n * Whether a provider redelivery of the same event deserves another attempt.\n *\n * Total over {@link DeadLetterReason}, so a new code cannot be added without\n * deciding its reopen policy. `true` marks the outcomes that another delivery\n * could genuinely cure — the case was busy, the handler crashed. Everything\n * deterministic (a payload its schema rejects, an address that does not\n * resolve, a guard that said no) stays dead-lettered: it will be refused the\n * same way on every retry, so reopening it would only invite endless\n * redelivery.\n */\nexport const REOPENS_ON_REDELIVERY: Record<DeadLetterReason, boolean> = {\n unrouted: false,\n 'no-step': false,\n 'step-not-available': false,\n 'case-busy': true,\n 'invalid-input': false,\n 'not-found': false,\n 'bad-request': false,\n 'execution-failed': true,\n 'invalid-state': false,\n}\n\nconst REOPENABLE = Object.entries(REOPENS_ON_REDELIVERY)\n .filter(([, reopens]) => reopens)\n .map(([reason]) => reason)\n\n/** What {@link ingest} resolves to — always a record, never a throw. */\nexport interface IngestionResult {\n readonly id: string\n readonly status: IngestionStatus\n readonly system: string\n readonly externalId: string\n readonly idempotencyKey: string\n /** Where it routed, when it routed. */\n readonly correlation: Correlation | null\n /** The Execution it produced, on `executed`. */\n readonly execution: ExecutionResult | null\n /** Why it is in the dead-letter surface, on `dead-lettered`. */\n readonly reason: DeadLetterReason | null\n /** Human-readable detail: the unmet conditions, the failure message. */\n readonly detail: string | null\n readonly receivedAt: string\n}\n\n/** A row of the dead-letter surface. */\nexport interface DeadLetter {\n readonly id: string\n readonly system: string\n readonly externalId: string\n readonly type: string\n readonly idempotencyKey: string\n readonly caseId: string | null\n readonly scopeKey: string | null\n readonly step: string | null\n readonly reason: DeadLetterReason\n readonly detail: string | null\n readonly event: ExternalEvent\n readonly receivedAt: string\n}\n\n/** Filters for {@link readDeadLetters}; all optional, all AND-ed. */\nexport interface DeadLetterFilter {\n readonly system?: string\n readonly caseId?: string\n readonly reason?: DeadLetterReason\n readonly limit?: number\n}\n\n/** How ingestion presents the external system to guards and the journal. */\nexport interface ExternalActor {\n readonly kind: 'external'\n readonly system: string\n readonly externalId: string\n readonly eventType: string\n}\n\n/** Engine-level ingestion settings. */\nexport interface IngestionOptions {\n /**\n * Map an event to the Actor its Execution runs as. Defaults to an\n * {@link ExternalActor}; an app whose `permits` conditions read its own\n * actor shape supplies a mapping instead of bending that shape.\n */\n readonly actor?: (event: ExternalEvent) => unknown\n}\n\n/** The normalized settings the environment carries. */\nexport interface IngestionSettings {\n readonly actor: (event: ExternalEvent) => unknown\n}\n\n/**\n * What {@link ingest} needs from its caller: the execution environment plus\n * ingestion's own settings. This is the widest environment any subsystem\n * asks for, so it is also the shape the engine builds once and hands to all\n * of them — named here so that contract is a type, not a coincidence of an\n * object literal.\n */\nexport interface IngestionEnvironment extends ExecutionEnvironment {\n readonly ingestion: IngestionSettings\n}\n\nexport const externalActor = (event: ExternalEvent): ExternalActor => ({\n kind: 'external',\n system: event.system,\n externalId: event.externalId,\n eventType: event.type,\n})\n\nexport const normalizeIngestion = (\n options: IngestionOptions = {},\n): IngestionSettings => ({\n actor: options.actor ?? externalActor,\n})\n\n/**\n * The idempotency key an event dedups on.\n *\n * `(system, externalId, type)` is the key's fixed prefix, so the *same*\n * notification about the *same* envelope produces the same key however many\n * times it is delivered — and, because an external id resolves to one (case,\n * scope element), the key is scoped exactly as far as the correlation is. The\n * tail is the provider's own delivery id where there is one, and a hash of\n * the payload where there is not: a provider that cannot tell you which\n * delivery this is gets content-addressed dedup, which is the best anyone\n * can do.\n */\nexport const idempotencyKeyFor = (event: ExternalEvent): string => {\n if (event.idempotencyKey !== undefined) return event.idempotencyKey\n const tail =\n event.eventId ??\n createHash('sha256')\n .update(JSON.stringify(event.payload ?? null))\n .digest('hex')\n .slice(0, 32)\n return `${event.system}/${event.externalId}/${event.type}/${tail}`\n}\n\ntype EventRow = {\n id: string\n system: string\n external_id: string\n type: string\n idempotency_key: string\n case_id: string | null\n scope_key: string | null\n step: string | null\n status: string\n reason: string | null\n detail: string | null\n execution_id: string | null\n event: ExternalEvent\n received_at: Date\n}\n\n/**\n * The dedup gate. Inserts the event's row and reports whether this delivery\n * is the one that got it.\n *\n * `on conflict do nothing` is the whole mechanism: exactly one of N\n * concurrent deliveries inserts, and the losers read what the winner wrote.\n * A previous delivery that ended `dead-lettered` for a *transient* reason is\n * reopened rather than deduplicated — a provider retry after \"the case was\n * busy\" should get its chance, which is precisely what provider retries are\n * for.\n */\nconst claimDelivery = async (\n db: Queryable,\n event: ExternalEvent,\n idempotencyKey: string,\n): Promise<{ row: EventRow; fresh: boolean }> => {\n const inserted = await db.query<EventRow>(\n `insert into ${EVENTS} (id, system, external_id, type, idempotency_key, status, event)\n values ($1, $2, $3, $4, $5, 'pending', $6::jsonb)\n on conflict (idempotency_key) do nothing\n returning *`,\n [\n mintId('event'),\n event.system,\n event.externalId,\n event.type,\n idempotencyKey,\n JSON.stringify(event),\n ],\n )\n const row = inserted.rows[0]\n if (row) return { row, fresh: true }\n\n const retried = await db.query<EventRow>(\n `update ${EVENTS}\n set status = 'pending', reason = null, detail = null, received_at = now(), event = $2::jsonb\n where idempotency_key = $1 and status = 'dead-lettered' and reason = any($3)\n returning *`,\n [idempotencyKey, JSON.stringify(event), REOPENABLE],\n )\n const reopened = retried.rows[0]\n if (reopened) return { row: reopened, fresh: true }\n\n const existing = await db.query<EventRow>(\n `select * from ${EVENTS} where idempotency_key = $1`,\n [idempotencyKey],\n )\n const previous = existing.rows[0]\n if (!previous)\n throw new Error(\n `${EVENTS}: delivery neither inserted nor found — key ${idempotencyKey}`,\n )\n return { row: previous, fresh: false }\n}\n\n/** Record how a delivery ended. The row is the dead-letter surface, so this is the only settle path. */\nconst settle = async (\n db: Queryable,\n id: string,\n fields: {\n status: 'executed' | 'dead-lettered'\n caseId?: string | null\n scopeKey?: string | null\n step?: string | null\n reason?: DeadLetterReason | null\n detail?: string | null\n executionId?: string | null\n },\n): Promise<void> => {\n await db.query(\n `update ${EVENTS}\n set status = $2, case_id = $3, scope_key = $4, step = $5, reason = $6, detail = $7, execution_id = $8\n where id = $1`,\n [\n id,\n fields.status,\n fields.caseId ?? null,\n fields.scopeKey ?? null,\n fields.step ?? null,\n fields.reason ?? null,\n fields.detail ?? null,\n fields.executionId ?? null,\n ],\n )\n}\n\nconst result = (\n row: EventRow,\n status: IngestionStatus,\n extra: Partial<IngestionResult> = {},\n): IngestionResult => ({\n id: row.id,\n status,\n system: row.system,\n externalId: row.external_id,\n idempotencyKey: row.idempotency_key,\n correlation: null,\n execution: null,\n reason: null,\n detail: null,\n receivedAt: row.received_at.toISOString(),\n ...extra,\n})\n\n/**\n * Which step an ingested event executes: the event's own naming wins,\n * falling back to what the correlation registered. The precedence is stated\n * once so any surface that *previews* routing (a dev console's world panel)\n * asks the same question `ingest` will answer.\n */\nexport const routedStep = (\n event: { readonly step?: string },\n correlation: { readonly step: string | null },\n): string | null => event.step ?? correlation.step ?? null\n\n/**\n * Ingest one external event: dedup, correlate, execute — or dead-letter it\n * with a reason.\n *\n * Never throws for an event's own sake: a webhook endpoint that 500s because\n * a guard said no teaches the provider to retry something that will never\n * succeed. Infrastructure failures (the database is gone) do still throw,\n * because those the caller must not acknowledge.\n */\nexport const ingest = async (\n env: IngestionEnvironment,\n event: ExternalEvent,\n): Promise<IngestionResult> => {\n const db = queryableOf(env.db)\n const idempotencyKey = idempotencyKeyFor(event)\n const { row, fresh } = await claimDelivery(db, event, idempotencyKey)\n if (!fresh) {\n return result(row, 'duplicate', {\n reason: (row.reason as DeadLetterReason | null) ?? null,\n detail: `already ingested as ${row.status} at ${row.received_at.toISOString()}`,\n })\n }\n\n // Settle the row and answer the caller with the same reason/detail pair —\n // one helper, so a branch cannot record one thing and report another.\n const deadLetter = async (fields: {\n readonly reason: DeadLetterReason\n readonly detail: string\n readonly correlation?: Correlation\n readonly step?: string\n }): Promise<IngestionResult> => {\n await settle(db, row.id, {\n status: 'dead-lettered',\n reason: fields.reason,\n detail: fields.detail,\n ...(fields.correlation !== undefined && {\n caseId: fields.correlation.caseId,\n scopeKey: fields.correlation.scopeKey,\n }),\n ...(fields.step !== undefined && { step: fields.step }),\n })\n return result(row, 'dead-lettered', {\n reason: fields.reason,\n detail: fields.detail,\n ...(fields.correlation !== undefined && {\n correlation: fields.correlation,\n }),\n })\n }\n\n const correlation = await lookupCorrelation(\n db,\n event.system,\n event.externalId,\n )\n if (correlation === null) {\n return deadLetter({\n reason: 'unrouted',\n detail: `no correlation registered for ${event.system}/${event.externalId}`,\n })\n }\n\n const stepName = routedStep(event, correlation)\n if (stepName === null) {\n return deadLetter({\n correlation,\n reason: 'no-step',\n detail: `correlation ${event.system}/${event.externalId} names no step, and the event does not either`,\n })\n }\n\n const ran = await runAsSystem(env, correlation.caseId, stepName, {\n actor: env.ingestion.actor(event),\n ...(correlation.scopeKey !== null && { scopeKey: correlation.scopeKey }),\n ...(event.payload !== undefined && { input: event.payload }),\n })\n if (ran.outcome === 'committed') {\n await settle(db, row.id, {\n status: 'executed',\n caseId: correlation.caseId,\n scopeKey: correlation.scopeKey,\n step: stepName,\n executionId: ran.result.executionId,\n })\n return result(row, 'executed', { correlation, execution: ran.result })\n }\n const [reason, detail] = classifyDeadLetter(ran)\n return deadLetter({ correlation, step: stepName, reason, detail })\n}\n\n/**\n * Project a settled system run onto the reason an operator needs to see —\n * the one place that asks whether what settled was a Refusal.\n *\n * A framework Refusal already names its own kind and explains itself — the\n * reason is its code, the detail its message (a refused guard's message\n * carries the unmet conditions, addressed). Anything else is a bug, not a\n * Refusal; it lands as `execution-failed` so the event is kept, never\n * silently dropped, with the crash's own words as the detail.\n */\nexport const classifyDeadLetter = (\n ran: SystemSettled,\n): [DeadLetterReason, string] =>\n isAffordanceError(ran.error)\n ? [ran.error.code, ran.error.message]\n : ['execution-failed', ran.error.message]\n\nconst toDeadLetter = (row: EventRow): DeadLetter => ({\n id: row.id,\n system: row.system,\n externalId: row.external_id,\n type: row.type,\n idempotencyKey: row.idempotency_key,\n caseId: row.case_id,\n scopeKey: row.scope_key,\n step: row.step,\n reason: row.reason as DeadLetterReason,\n detail: row.detail,\n event: row.event,\n receivedAt: row.received_at.toISOString(),\n})\n\n/** Read the dead-letter surface, newest first — the ops view of \"arrived, did nothing\". */\nexport const readDeadLetters = async (\n db: Queryable,\n filter: DeadLetterFilter = {},\n): Promise<readonly DeadLetter[]> => {\n const { conditions, values, bind, where } = sqlWhere([\n `status = 'dead-lettered'`,\n ])\n\n if (filter.system !== undefined)\n conditions.push(`system = ${bind(filter.system)}`)\n if (filter.caseId !== undefined)\n conditions.push(`case_id = ${bind(filter.caseId)}`)\n if (filter.reason !== undefined)\n conditions.push(`reason = ${bind(filter.reason)}`)\n const limit = filter.limit === undefined ? '' : ` limit ${bind(filter.limit)}`\n\n const { rows } = await db.query<EventRow>(\n `select * from ${EVENTS} where ${where()} order by received_at desc${limit}`,\n values,\n )\n return rows.map(toDeadLetter)\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ingest.js","sourceRoot":"","sources":["../../src/ingestion/ingest.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Ingestion: external events become Executions.\n *\n * A webhook arrives. Three things have to happen, in this order, and each of\n * them has to be visible afterwards:\n *\n * 1. **Dedup.** Providers retry; \"at least once\" is the delivery guarantee\n * every one of them offers. Storage atomically acquires a delivery, so\n * concurrent deliveries of one event produce one Execution and the\n * remaining callers receive `duplicate`.\n * 2. **Correlate.** The external identifier is resolved to (case, scope,\n * step) through the registry the initiating handler wrote.\n * 3. **Execute.** Through the ordinary claim → run → commit, with the\n * external system as the journaled actor. Ingestion has no privileged\n * path: the guard still decides, transactionally.\n *\n * What is *not* allowed is a quiet drop. An event nothing can route, an event\n * whose step the guard refuses, an event whose case is busy — each lands in\n * the same table with a status and a reason, which is the dead-letter\n * surface. \"The webhook definitely arrived, so why is the case still waiting\"\n * is a question this table answers without anyone reading a log file.\n */\n\nimport { createHash } from 'node:crypto'\nimport type { AffordanceErrorCode } from '../errors.js'\nimport { isAffordanceError } from '../errors.js'\nimport type {\n ExecutionEnvironment,\n ExecutionResult,\n SystemSettled,\n} from '../execution/index.js'\nimport { runAsSystem } from '../execution/index.js'\nimport type { DeliveryRecord } from '../storage.js'\nimport type { Correlation } from './correlation.js'\n\n/** An event as an external system delivered it. */\nexport interface ExternalEvent {\n /** The system that emitted it, matching the correlation's `system`. */\n readonly system: string\n /** The identifier that routes it — the one the initiating handler registered. */\n readonly externalId: string\n /** What happened, in the provider's own words: `'envelope.completed'`. */\n readonly type: string\n /** The provider's delivery id, when it has one — the strongest dedup key. */\n readonly eventId?: string\n /** The payload, passed to the target step as its input (validated by the step's schema). */\n readonly payload?: unknown\n /** Execute this step instead of the correlation's registered one. */\n readonly step?: string\n /** Override the derived idempotency key — for a provider whose retries are not identical. */\n readonly idempotencyKey?: string\n /** When the external system says it happened (ISO-8601); recorded, never used as `asOf`. */\n readonly occurredAt?: string\n}\n\n/**\n * How an ingested event ended up: `executed` (the target step ran and\n * committed), `duplicate` (already seen — this delivery changed nothing, by\n * design), or `dead-lettered` (nothing could be done with it, and it is\n * sitting in the dead-letter surface).\n */\nexport type IngestionStatus = 'executed' | 'duplicate' | 'dead-lettered'\n\n/**\n * Why an event was dead-lettered — the operator's first question, answered.\n *\n * Two reasons are ingestion's own (`unrouted`: no correlation claims the\n * identifier; `no-step`: routed, but neither the event nor the correlation\n * names a step); every other reason **is** the `AffordanceErrorCode`\n * the refused Execution already declared at its raise site. Ingestion does\n * not re-derive the kind of a Refusal — it projects the code the error\n * carries, so a new refusal class can never misroute here.\n */\nexport type DeadLetterReason = 'unrouted' | 'no-step' | AffordanceErrorCode\n\n/**\n * Whether a provider redelivery of the same event deserves another attempt.\n *\n * Total over {@link DeadLetterReason}, so a new code cannot be added without\n * deciding its reopen policy. `true` marks the outcomes that another delivery\n * could genuinely cure — the case was busy, the handler crashed. Everything\n * deterministic (a payload its schema rejects, an address that does not\n * resolve, a guard that said no) stays dead-lettered: it will be refused the\n * same way on every retry, so reopening it would only invite endless\n * redelivery.\n */\nexport const REOPENS_ON_REDELIVERY: Record<DeadLetterReason, boolean> = {\n unrouted: false,\n 'no-step': false,\n 'step-not-available': false,\n 'case-busy': true,\n 'invalid-input': false,\n 'not-found': false,\n 'bad-request': false,\n 'execution-failed': true,\n 'invalid-state': false,\n}\n\nconst REOPENABLE = Object.entries(REOPENS_ON_REDELIVERY)\n .filter(([, reopens]) => reopens)\n .map(([reason]) => reason as DeadLetterReason)\n\n/** What {@link ingest} resolves to — always a record, never a throw. */\nexport interface IngestionResult {\n readonly id: string\n readonly status: IngestionStatus\n readonly system: string\n readonly externalId: string\n readonly idempotencyKey: string\n /** Where it routed, when it routed. */\n readonly correlation: Correlation | null\n /** The Execution it produced, on `executed`. */\n readonly execution: ExecutionResult | null\n /** Why it is in the dead-letter surface, on `dead-lettered`. */\n readonly reason: DeadLetterReason | null\n /** Human-readable detail: the unmet conditions, the failure message. */\n readonly detail: string | null\n readonly receivedAt: string\n}\n\n/** A row of the dead-letter surface. */\nexport interface DeadLetter {\n readonly id: string\n readonly system: string\n readonly externalId: string\n readonly type: string\n readonly idempotencyKey: string\n readonly caseId: string | null\n readonly scopeKey: string | null\n readonly step: string | null\n readonly reason: DeadLetterReason\n readonly detail: string | null\n readonly event: ExternalEvent\n readonly receivedAt: string\n}\n\n/** Filters for {@link readDeadLetters}; all optional, all AND-ed. */\nexport interface DeadLetterFilter {\n readonly system?: string\n readonly caseId?: string\n readonly reason?: DeadLetterReason\n readonly limit?: number\n}\n\n/** How ingestion presents the external system to guards and the journal. */\nexport interface ExternalActor {\n readonly kind: 'external'\n readonly system: string\n readonly externalId: string\n readonly eventType: string\n}\n\n/** Engine-level ingestion settings. */\nexport interface IngestionOptions {\n /**\n * Map an event to the Actor its Execution runs as. Defaults to an\n * {@link ExternalActor}; an app whose `permits` conditions read its own\n * actor shape supplies a mapping instead of bending that shape.\n */\n readonly actor?: (event: ExternalEvent) => unknown\n}\n\n/** The normalized settings the environment carries. */\nexport interface IngestionSettings {\n readonly actor: (event: ExternalEvent) => unknown\n}\n\n/**\n * What {@link ingest} needs from its caller: the execution environment plus\n * ingestion's own settings. This is the widest environment any subsystem\n * asks for, so it is also the shape the engine builds once and hands to all\n * of them — named here so that contract is a type, not a coincidence of an\n * object literal.\n */\nexport interface IngestionEnvironment<TCommit = unknown>\n extends ExecutionEnvironment<TCommit> {\n readonly ingestion: IngestionSettings\n}\n\nexport const externalActor = (event: ExternalEvent): ExternalActor => ({\n kind: 'external',\n system: event.system,\n externalId: event.externalId,\n eventType: event.type,\n})\n\nexport const normalizeIngestion = (\n options: IngestionOptions = {},\n): IngestionSettings => ({\n actor: options.actor ?? externalActor,\n})\n\n/**\n * The idempotency key an event dedups on.\n *\n * `(system, externalId, type)` is the key's fixed prefix, so the *same*\n * notification about the *same* envelope produces the same key however many\n * times it is delivered — and, because an external id resolves to one (case,\n * scope element), the key is scoped exactly as far as the correlation is. The\n * tail is the provider's own delivery id where there is one, and a hash of\n * the payload where there is not: a provider that cannot tell you which\n * delivery this is gets content-addressed dedup, which is the best anyone\n * can do.\n */\nexport const idempotencyKeyFor = (event: ExternalEvent): string => {\n if (event.idempotencyKey !== undefined) return event.idempotencyKey\n const tail =\n event.eventId ??\n createHash('sha256')\n .update(JSON.stringify(event.payload ?? null))\n .digest('hex')\n .slice(0, 32)\n return `${event.system}/${event.externalId}/${event.type}/${tail}`\n}\n\nconst result = (\n row: DeliveryRecord,\n status: IngestionStatus,\n extra: Partial<IngestionResult> = {},\n): IngestionResult => ({\n id: row.id,\n status,\n system: row.system,\n externalId: row.externalId,\n idempotencyKey: row.idempotencyKey,\n correlation: null,\n execution: null,\n reason: null,\n detail: null,\n receivedAt: row.receivedAt,\n ...extra,\n})\n\n/**\n * Which step an ingested event executes: the event's own naming wins,\n * falling back to what the correlation registered. The precedence is stated\n * once so any surface that *previews* routing (a dev console's world panel)\n * asks the same question `ingest` will answer.\n */\nexport const routedStep = (\n event: { readonly step?: string },\n correlation: { readonly step: string | null },\n): string | null => event.step ?? correlation.step ?? null\n\n/**\n * Ingest one external event: dedup, correlate, execute — or dead-letter it\n * with a reason.\n *\n * Never throws for an event's own sake: a webhook endpoint that 500s because\n * a guard said no teaches the provider to retry something that will never\n * succeed. Infrastructure failures (the database is gone) do still throw,\n * because those the caller must not acknowledge.\n */\nexport const ingest = async <TCommit>(\n env: IngestionEnvironment<TCommit>,\n event: ExternalEvent,\n): Promise<IngestionResult> => {\n const idempotencyKey = idempotencyKeyFor(event)\n const { row, fresh } = await env.storage.deliveries.acquire(\n event,\n idempotencyKey,\n REOPENABLE,\n )\n if (!fresh) {\n return result(row, 'duplicate', {\n reason: row.reason,\n detail: `already ingested as ${row.status} at ${row.receivedAt}`,\n })\n }\n\n // Settle the row and answer the caller with the same reason/detail pair —\n // one helper, so a branch cannot record one thing and report another.\n const deadLetter = async (fields: {\n readonly reason: DeadLetterReason\n readonly detail: string\n readonly correlation?: Correlation\n readonly step?: string\n }): Promise<IngestionResult> => {\n await env.storage.deliveries.settle(row.id, {\n status: 'dead-lettered',\n reason: fields.reason,\n detail: fields.detail,\n ...(fields.correlation !== undefined && {\n caseId: fields.correlation.caseId,\n scopeKey: fields.correlation.scopeKey,\n }),\n ...(fields.step !== undefined && { step: fields.step }),\n })\n return result(row, 'dead-lettered', {\n reason: fields.reason,\n detail: fields.detail,\n ...(fields.correlation !== undefined && {\n correlation: fields.correlation,\n }),\n })\n }\n\n const correlation = await env.storage.correlations.lookup(\n event.system,\n event.externalId,\n )\n if (correlation === null) {\n return deadLetter({\n reason: 'unrouted',\n detail: `no correlation registered for ${event.system}/${event.externalId}`,\n })\n }\n\n const stepName = routedStep(event, correlation)\n if (stepName === null) {\n return deadLetter({\n correlation,\n reason: 'no-step',\n detail: `correlation ${event.system}/${event.externalId} names no step, and the event does not either`,\n })\n }\n\n const ran = await runAsSystem(env, correlation.caseId, stepName, {\n actor: env.ingestion.actor(event),\n ...(correlation.scopeKey !== null && { scopeKey: correlation.scopeKey }),\n ...(event.payload !== undefined && { input: event.payload }),\n })\n if (ran.outcome === 'committed') {\n await env.storage.deliveries.settle(row.id, {\n status: 'executed',\n caseId: correlation.caseId,\n scopeKey: correlation.scopeKey,\n step: stepName,\n executionId: ran.result.executionId,\n })\n return result(row, 'executed', { correlation, execution: ran.result })\n }\n const [reason, detail] = classifyDeadLetter(ran)\n return deadLetter({ correlation, step: stepName, reason, detail })\n}\n\n/**\n * Project a settled system run onto the reason an operator needs to see —\n * the one place that asks whether what settled was a Refusal.\n *\n * A framework Refusal already names its own kind and explains itself — the\n * reason is its code, the detail its message (a refused guard's message\n * carries the unmet conditions, addressed). Anything else is a bug, not a\n * Refusal; it lands as `execution-failed` so the event is kept, never\n * silently dropped, with the crash's own words as the detail.\n */\nexport const classifyDeadLetter = (\n ran: SystemSettled,\n): [DeadLetterReason, string] =>\n isAffordanceError(ran.error)\n ? [ran.error.code, ran.error.message]\n : ['execution-failed', ran.error.message]\n"]}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* if a total condition can read the old shape, write the total condition.
|
|
33
33
|
*/
|
|
34
34
|
import type { ExecutionEnvironment, StateDelta } from '../execution/index.js';
|
|
35
|
-
import type {
|
|
35
|
+
import type { EngineStorage } from '../storage.js';
|
|
36
36
|
/** The synthetic step name a migration executes under — its journal marker. */
|
|
37
37
|
export declare const migrationStepName: (name: string) => string;
|
|
38
38
|
/** The Actor a migration runs as, when the app supplies none. */
|
|
@@ -110,10 +110,9 @@ export interface MigrationReport {
|
|
|
110
110
|
* state schema) is reported and the run moves on, and the next run will pick
|
|
111
111
|
* it up because it never got its marker.
|
|
112
112
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* concurrently-running instance of the same migration simply is not returned.
|
|
113
|
+
* Storage owns candidate order and continuation. It excludes completed
|
|
114
|
+
* migration markers on every page; the core passes its cursor back unchanged.
|
|
116
115
|
*/
|
|
117
|
-
export declare const migrate: (env: ExecutionEnvironment
|
|
118
|
-
/** Whether one case
|
|
119
|
-
export declare const hasMigrated: (
|
|
116
|
+
export declare const migrate: <TCommit>(env: ExecutionEnvironment<TCommit>, caseTypeName: string, name: string, transform: MigrationTransform, options?: MigrationOptions) => Promise<MigrationReport>;
|
|
117
|
+
/** Whether one case carries a completed migration marker. */
|
|
118
|
+
export declare const hasMigrated: (storage: Pick<EngineStorage, "migrations">, caseId: string, name: string) => Promise<boolean>;
|
|
@@ -34,9 +34,7 @@
|
|
|
34
34
|
import { toError } from '../errors.js';
|
|
35
35
|
import { diffState, runAsSystem } from '../execution/index.js';
|
|
36
36
|
import { step } from '../model/index.js';
|
|
37
|
-
import {
|
|
38
|
-
const CASES = `${FRAMEWORK_SCHEMA}.cases`;
|
|
39
|
-
const JOURNAL = `${FRAMEWORK_SCHEMA}.journal`;
|
|
37
|
+
import { resolveStoredState } from '../store/index.js';
|
|
40
38
|
/** The synthetic step name a migration executes under — its journal marker. */
|
|
41
39
|
export const migrationStepName = (name) => `migrate:${name}`;
|
|
42
40
|
/**
|
|
@@ -62,30 +60,6 @@ const withMigrationStep = (definition, migration) => ({
|
|
|
62
60
|
steps: [...definition.steps, migration],
|
|
63
61
|
getStep: (stepName) => stepName === migration.name ? migration : definition.getStep(stepName),
|
|
64
62
|
});
|
|
65
|
-
/** Case ids of this type that do not yet carry the migration's marker, oldest first. */
|
|
66
|
-
const findCandidates = async (db, caseTypeName, marker, options, afterId, batchSize) => {
|
|
67
|
-
const { conditions, values, bind, where } = sqlWhere([
|
|
68
|
-
`c.case_type = $1`,
|
|
69
|
-
// The marker: a completed Execution of this migration on this case. The
|
|
70
|
-
// journal is the record of what has happened, so it is also the record of
|
|
71
|
-
// what has already been migrated — no bookkeeping table, no state flag.
|
|
72
|
-
`not exists (
|
|
73
|
-
select 1 from ${JOURNAL} j
|
|
74
|
-
where j.case_id = c.id and j.step = $2 and j.entry = 'completed'
|
|
75
|
-
)`,
|
|
76
|
-
], [caseTypeName, marker]);
|
|
77
|
-
if (options.includeEnded !== true)
|
|
78
|
-
conditions.push(`c.ended_at is null`);
|
|
79
|
-
if (options.caseIds !== undefined)
|
|
80
|
-
conditions.push(`c.id = any(${bind(options.caseIds)}::text[])`);
|
|
81
|
-
if (afterId !== null)
|
|
82
|
-
conditions.push(`c.id > ${bind(afterId)}`);
|
|
83
|
-
const { rows } = await db.query(`select c.id, c.state from ${CASES} c
|
|
84
|
-
where ${where()}
|
|
85
|
-
order by c.id asc
|
|
86
|
-
limit ${bind(batchSize)}`, values);
|
|
87
|
-
return rows;
|
|
88
|
-
};
|
|
89
63
|
/**
|
|
90
64
|
* Run a state restructure over every case of a case type, as journaled
|
|
91
65
|
* system Executions.
|
|
@@ -97,9 +71,8 @@ const findCandidates = async (db, caseTypeName, marker, options, afterId, batchS
|
|
|
97
71
|
* state schema) is reported and the run moves on, and the next run will pick
|
|
98
72
|
* it up because it never got its marker.
|
|
99
73
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* concurrently-running instance of the same migration simply is not returned.
|
|
74
|
+
* Storage owns candidate order and continuation. It excludes completed
|
|
75
|
+
* migration markers on every page; the core passes its cursor back unchanged.
|
|
103
76
|
*/
|
|
104
77
|
export const migrate = async (env, caseTypeName, name, transform, options = {}) => {
|
|
105
78
|
if (typeof name !== 'string' || name.trim() === '') {
|
|
@@ -162,11 +135,10 @@ export const migrate = async (env, caseTypeName, name, transform, options = {})
|
|
|
162
135
|
const remaining = options.limit === undefined ? batchSize : options.limit - scanned;
|
|
163
136
|
if (remaining <= 0)
|
|
164
137
|
break;
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
138
|
+
const page = await env.storage.migrations.candidates(caseTypeName, marker, options, cursor, Math.min(batchSize, remaining));
|
|
139
|
+
if (page.cases.length === 0)
|
|
167
140
|
break;
|
|
168
|
-
for (const candidate of
|
|
169
|
-
cursor = candidate.id;
|
|
141
|
+
for (const candidate of page.cases) {
|
|
170
142
|
scanned += 1;
|
|
171
143
|
const { outcome, delta, error } = await runOne(candidate);
|
|
172
144
|
if (error !== null)
|
|
@@ -183,13 +155,12 @@ export const migrate = async (env, caseTypeName, name, transform, options = {})
|
|
|
183
155
|
processed: scanned,
|
|
184
156
|
});
|
|
185
157
|
}
|
|
158
|
+
cursor = page.nextCursor;
|
|
159
|
+
if (cursor === null)
|
|
160
|
+
break;
|
|
186
161
|
}
|
|
187
162
|
return { name, caseTypeName, dryRun, scanned, migrated, unchanged, failed };
|
|
188
163
|
};
|
|
189
|
-
/** Whether one case
|
|
190
|
-
export const hasMigrated =
|
|
191
|
-
const { rows } = await db.query(`select 1 as one from ${JOURNAL}
|
|
192
|
-
where case_id = $1 and step = $2 and entry = 'completed' limit 1`, [caseId, migrationStepName(name)]);
|
|
193
|
-
return rows.length > 0;
|
|
194
|
-
};
|
|
164
|
+
/** Whether one case carries a completed migration marker. */
|
|
165
|
+
export const hasMigrated = (storage, caseId, name) => storage.migrations.hasCompleted(caseId, migrationStepName(name));
|
|
195
166
|
//# sourceMappingURL=migrate.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migrate.js","sourceRoot":"","sources":["../../src/migration/migrate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The migration escape hatch.\n *\n * Cases float to the latest definitions: a deploy changes what the steps are\n * and every in-flight case is immediately governed by the new ones, with no\n * migration executed and no version pinned. That covers almost everything,\n * because almost every change is *additive* — a new step, a new condition, a\n * new optional field — and the `?? fallback` discipline makes old\n * state readable by new code.\n *\n * What float cannot cover is a **restructure**: a field renamed, one field\n * split into two, a scalar turned into a collection. No fallback expression\n * makes `state.buyer` readable as `state.buyers[]`. For exactly that,\n * this module.\n *\n * Two properties make it an escape hatch rather than a second way of working:\n *\n * 1. **A migration is an Execution.** Not a script with a database\n * connection — the same claim → run → commit as any step, so the case row\n * lock, the in-flight claim, state schema validation\n * and the journal entry all apply unchanged. The transform runs\n * as a synthetic step named `migrate:<name>`, with a migration actor.\n * An auditor asking \"why did this case's shape change in March\" gets the\n * same kind of answer as for anything else that ever happened to it.\n * 2. **The journal is the marker.** A case has been migrated iff it carries a\n * completed `migrate:<name>` entry. Nothing is written into app state to\n * record it, so a migration leaves no residue in the document it\n * restructured — and re-running is a no-op by construction, which is also\n * what makes an interrupted run resumable.\n *\n * When *not* to reach for this is in `docs/migration.md`. The short version:\n * if a total condition can read the old shape, write the total condition.\n */\n\nimport { toError } from '../errors.js'\nimport type { ExecutionEnvironment, StateDelta } from '../execution/index.js'\nimport { diffState, runAsSystem } from '../execution/index.js'\nimport type { AnyCaseType, StepDefinition } from '../model/index.js'\nimport { step } from '../model/index.js'\nimport type { Queryable } from '../store/index.js'\nimport {\n FRAMEWORK_SCHEMA,\n queryableOf,\n resolveStoredState,\n sqlWhere,\n} from '../store/index.js'\n\nconst CASES = `${FRAMEWORK_SCHEMA}.cases`\nconst JOURNAL = `${FRAMEWORK_SCHEMA}.journal`\n\n/** The synthetic step name a migration executes under — its journal marker. */\nexport const migrationStepName = (name: string): string => `migrate:${name}`\n\n/** The Actor a migration runs as, when the app supplies none. */\nexport interface MigrationActor {\n readonly kind: 'migration'\n readonly migration: string\n}\n\n/**\n * The state transform. Pure and total, like a condition: it will\n * be handed state written by every definition the case has ever floated\n * through, and returning the input unchanged must be safe.\n */\nexport type MigrationTransform<TState = any> = (state: TState) => TState\n\n/** Options for {@link migrate}. */\nexport interface MigrationOptions {\n /** How many cases to claim per round trip when scanning (default 100). */\n readonly batchSize?: number\n /** Stop after this many cases — for a cautious first run. */\n readonly limit?: number\n /** Restrict the run to these cases; they are still skipped if already migrated. */\n readonly caseIds?: readonly string[]\n /** The Actor the Executions run as; defaults to a {@link MigrationActor}. */\n readonly actor?: unknown\n /** Include dormant (ended) cases. Off by default — a closed case is usually not worth restructuring. */\n readonly includeEnded?: boolean\n /**\n * Compute what each case *would* become and report the deltas without\n * writing anything. Nothing is journaled, so a dry run leaves no marker\n * and the real run still has every case to do.\n */\n readonly dryRun?: boolean\n /** Called after each case, for a progress bar or a log line. */\n readonly onProgress?: (progress: MigrationProgress) => void\n}\n\n/** Progress after one case. */\nexport interface MigrationProgress {\n readonly caseId: string\n readonly outcome: 'migrated' | 'unchanged' | 'failed'\n /** What the transform changed, as JSON Patch; empty when it changed nothing. */\n readonly delta: StateDelta\n readonly error: Error | null\n /** How many cases have been processed so far in this run. */\n readonly processed: number\n}\n\n/** One case a migration could not restructure. */\nexport interface MigrationFailure {\n readonly caseId: string\n readonly error: Error\n}\n\n/** What {@link migrate} resolves to. */\nexport interface MigrationReport {\n readonly name: string\n readonly caseTypeName: string\n readonly dryRun: boolean\n /** Cases examined — those not already bearing the marker. */\n readonly scanned: number\n /** Cases whose state the transform changed and which committed. */\n readonly migrated: number\n /**\n * Cases the transform left identical. Still journaled (and so still\n * marked), because \"this migration considered this case and had nothing to\n * do\" is a fact worth being able to prove.\n */\n readonly unchanged: number\n readonly failed: readonly MigrationFailure[]\n}\n\n/**\n * Build the synthetic step the transform runs as. It declares no conditions:\n * a migration is not a business affordance and must not be blockable by one\n * — it is the deliberate exception float reserves, and the guard model\n * would only ever be in its way. `maxAttempts: 1` because a transform that\n * throws is a defect in the transform, and retrying it just throws again.\n */\nconst migrationStep = (\n name: string,\n transform: MigrationTransform,\n): StepDefinition<unknown, unknown> =>\n step({\n name: migrationStepName(name),\n retry: { maxAttempts: 1 },\n handler: async (state: unknown) => transform(state),\n })\n\n/**\n * A view of the case type with the migration step spliced in, so the\n * ordinary lifecycle can address it by name. The real definition is left\n * untouched: the migration step exists for the duration of the run and is\n * never part of anything a client could see as an affordance.\n */\nconst withMigrationStep = (\n definition: AnyCaseType,\n migration: StepDefinition<unknown, unknown>,\n): AnyCaseType => ({\n ...definition,\n steps: [...definition.steps, migration],\n getStep: (stepName: string) =>\n stepName === migration.name ? migration : definition.getStep(stepName),\n})\n\n/** Case ids of this type that do not yet carry the migration's marker, oldest first. */\nconst findCandidates = async (\n db: Queryable,\n caseTypeName: string,\n marker: string,\n options: MigrationOptions,\n afterId: string | null,\n batchSize: number,\n): Promise<readonly { id: string; state: unknown }[]> => {\n const { conditions, values, bind, where } = sqlWhere(\n [\n `c.case_type = $1`,\n // The marker: a completed Execution of this migration on this case. The\n // journal is the record of what has happened, so it is also the record of\n // what has already been migrated — no bookkeeping table, no state flag.\n `not exists (\n select 1 from ${JOURNAL} j\n where j.case_id = c.id and j.step = $2 and j.entry = 'completed'\n )`,\n ],\n [caseTypeName, marker],\n )\n if (options.includeEnded !== true) conditions.push(`c.ended_at is null`)\n if (options.caseIds !== undefined)\n conditions.push(`c.id = any(${bind(options.caseIds)}::text[])`)\n if (afterId !== null) conditions.push(`c.id > ${bind(afterId)}`)\n\n const { rows } = await db.query<{ id: string; state: unknown }>(\n `select c.id, c.state from ${CASES} c\n where ${where()}\n order by c.id asc\n limit ${bind(batchSize)}`,\n values,\n )\n return rows\n}\n\n/**\n * Run a state restructure over every case of a case type, as journaled\n * system Executions.\n *\n * Idempotent: a case that already carries the migration's marker is never\n * examined again, so re-running is a no-op and an interrupted run resumes\n * where it stopped. Failures do not stop the run — a case that cannot be\n * migrated (its claim is held, the transform threw, the result fails the\n * state schema) is reported and the run moves on, and the next run will pick\n * it up because it never got its marker.\n *\n * The cursor walks case ids ascending, and the \"already migrated\" filter is\n * applied by the database on every batch — so a case migrated by a\n * concurrently-running instance of the same migration simply is not returned.\n */\nexport const migrate = async (\n env: ExecutionEnvironment,\n caseTypeName: string,\n name: string,\n transform: MigrationTransform,\n options: MigrationOptions = {},\n): Promise<MigrationReport> => {\n if (typeof name !== 'string' || name.trim() === '') {\n throw new TypeError(\n 'migrate: a migration must have a non-empty name — it is the journal marker',\n )\n }\n if (typeof transform !== 'function') {\n throw new TypeError(\n `migrate '${name}': the transform must be a function of case state`,\n )\n }\n\n const definition = env.caseTypeFor(caseTypeName)\n const marker = migrationStepName(name)\n const migration = migrationStep(name, transform)\n const augmented = withMigrationStep(definition, migration)\n const actor: unknown = options.actor ?? { kind: 'migration', migration: name }\n const batchSize = Math.max(1, options.batchSize ?? 100)\n const dryRun = options.dryRun === true\n\n let scanned = 0\n let migrated = 0\n let unchanged = 0\n const failed: MigrationFailure[] = []\n let cursor: string | null = null\n\n /** One candidate's verdict, with early returns — no outcome threading. */\n const runOne = async (candidate: {\n readonly id: string\n readonly state: unknown\n }): Promise<Pick<MigrationProgress, 'outcome' | 'delta' | 'error'>> => {\n try {\n if (dryRun) {\n // The transform is pure, so \"what would this become\" needs no\n // claim, no lock and no write — and leaves no marker behind.\n const resolved = await resolveStoredState(definition, candidate.state)\n const before = resolved === null ? candidate.state : resolved.state\n const delta = diffState(before, transform(before))\n return {\n outcome: delta.length === 0 ? 'unchanged' : 'migrated',\n delta,\n error: null,\n }\n }\n // The system runner returns case-level failures as values rather than\n // throwing, and the sweep filters on that outcome: only a commit\n // advances this case. A settled run — a held claim, a throwing\n // transform, a result its schema rejects — is reported with no marker\n // written, so the next run picks the case up again.\n const ran = await runAsSystem(env, candidate.id, marker, {\n actor,\n definition: augmented,\n })\n if (ran.outcome === 'settled')\n return { outcome: 'failed', delta: [], error: ran.error }\n const delta = ran.result.delta\n return {\n outcome: delta.length === 0 ? 'unchanged' : 'migrated',\n delta,\n error: null,\n }\n } catch (caught) {\n // The dry-run path's own throws (a transform is app code).\n return { outcome: 'failed', delta: [], error: toError(caught) }\n }\n }\n\n for (;;) {\n const remaining =\n options.limit === undefined ? batchSize : options.limit - scanned\n if (remaining <= 0) break\n const candidates = await findCandidates(\n queryableOf(env.db),\n caseTypeName,\n marker,\n options,\n cursor,\n Math.min(batchSize, remaining),\n )\n if (candidates.length === 0) break\n\n for (const candidate of candidates) {\n cursor = candidate.id\n scanned += 1\n const { outcome, delta, error } = await runOne(candidate)\n if (error !== null) failed.push({ caseId: candidate.id, error })\n else if (outcome === 'unchanged') unchanged += 1\n else migrated += 1\n\n options.onProgress?.({\n caseId: candidate.id,\n outcome,\n delta,\n error,\n processed: scanned,\n })\n }\n }\n\n return { name, caseTypeName, dryRun, scanned, migrated, unchanged, failed }\n}\n\n/** Whether one case already carries a migration's marker. */\nexport const hasMigrated = async (\n db: Queryable,\n caseId: string,\n name: string,\n): Promise<boolean> => {\n const { rows } = await db.query<{ one: number }>(\n `select 1 as one from ${JOURNAL}\n where case_id = $1 and step = $2 and entry = 'completed' limit 1`,\n [caseId, migrationStepName(name)],\n )\n return rows.length > 0\n}\n"]}
|
|
1
|
+
{"version":3,"file":"migrate.js","sourceRoot":"","sources":["../../src/migration/migrate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The migration escape hatch.\n *\n * Cases float to the latest definitions: a deploy changes what the steps are\n * and every in-flight case is immediately governed by the new ones, with no\n * migration executed and no version pinned. That covers almost everything,\n * because almost every change is *additive* — a new step, a new condition, a\n * new optional field — and the `?? fallback` discipline makes old\n * state readable by new code.\n *\n * What float cannot cover is a **restructure**: a field renamed, one field\n * split into two, a scalar turned into a collection. No fallback expression\n * makes `state.buyer` readable as `state.buyers[]`. For exactly that,\n * this module.\n *\n * Two properties make it an escape hatch rather than a second way of working:\n *\n * 1. **A migration is an Execution.** Not a script with a database\n * connection — the same claim → run → commit as any step, so the case row\n * lock, the in-flight claim, state schema validation\n * and the journal entry all apply unchanged. The transform runs\n * as a synthetic step named `migrate:<name>`, with a migration actor.\n * An auditor asking \"why did this case's shape change in March\" gets the\n * same kind of answer as for anything else that ever happened to it.\n * 2. **The journal is the marker.** A case has been migrated iff it carries a\n * completed `migrate:<name>` entry. Nothing is written into app state to\n * record it, so a migration leaves no residue in the document it\n * restructured — and re-running is a no-op by construction, which is also\n * what makes an interrupted run resumable.\n *\n * When *not* to reach for this is in `docs/migration.md`. The short version:\n * if a total condition can read the old shape, write the total condition.\n */\n\nimport { toError } from '../errors.js'\nimport type { ExecutionEnvironment, StateDelta } from '../execution/index.js'\nimport { diffState, runAsSystem } from '../execution/index.js'\nimport type { AnyCaseType, StepDefinition } from '../model/index.js'\nimport { step } from '../model/index.js'\nimport type { EngineStorage } from '../storage.js'\nimport { resolveStoredState } from '../store/index.js'\n\n/** The synthetic step name a migration executes under — its journal marker. */\nexport const migrationStepName = (name: string): string => `migrate:${name}`\n\n/** The Actor a migration runs as, when the app supplies none. */\nexport interface MigrationActor {\n readonly kind: 'migration'\n readonly migration: string\n}\n\n/**\n * The state transform. Pure and total, like a condition: it will\n * be handed state written by every definition the case has ever floated\n * through, and returning the input unchanged must be safe.\n */\nexport type MigrationTransform<TState = any> = (state: TState) => TState\n\n/** Options for {@link migrate}. */\nexport interface MigrationOptions {\n /** How many cases to claim per round trip when scanning (default 100). */\n readonly batchSize?: number\n /** Stop after this many cases — for a cautious first run. */\n readonly limit?: number\n /** Restrict the run to these cases; they are still skipped if already migrated. */\n readonly caseIds?: readonly string[]\n /** The Actor the Executions run as; defaults to a {@link MigrationActor}. */\n readonly actor?: unknown\n /** Include dormant (ended) cases. Off by default — a closed case is usually not worth restructuring. */\n readonly includeEnded?: boolean\n /**\n * Compute what each case *would* become and report the deltas without\n * writing anything. Nothing is journaled, so a dry run leaves no marker\n * and the real run still has every case to do.\n */\n readonly dryRun?: boolean\n /** Called after each case, for a progress bar or a log line. */\n readonly onProgress?: (progress: MigrationProgress) => void\n}\n\n/** Progress after one case. */\nexport interface MigrationProgress {\n readonly caseId: string\n readonly outcome: 'migrated' | 'unchanged' | 'failed'\n /** What the transform changed, as JSON Patch; empty when it changed nothing. */\n readonly delta: StateDelta\n readonly error: Error | null\n /** How many cases have been processed so far in this run. */\n readonly processed: number\n}\n\n/** One case a migration could not restructure. */\nexport interface MigrationFailure {\n readonly caseId: string\n readonly error: Error\n}\n\n/** What {@link migrate} resolves to. */\nexport interface MigrationReport {\n readonly name: string\n readonly caseTypeName: string\n readonly dryRun: boolean\n /** Cases examined — those not already bearing the marker. */\n readonly scanned: number\n /** Cases whose state the transform changed and which committed. */\n readonly migrated: number\n /**\n * Cases the transform left identical. Still journaled (and so still\n * marked), because \"this migration considered this case and had nothing to\n * do\" is a fact worth being able to prove.\n */\n readonly unchanged: number\n readonly failed: readonly MigrationFailure[]\n}\n\n/**\n * Build the synthetic step the transform runs as. It declares no conditions:\n * a migration is not a business affordance and must not be blockable by one\n * — it is the deliberate exception float reserves, and the guard model\n * would only ever be in its way. `maxAttempts: 1` because a transform that\n * throws is a defect in the transform, and retrying it just throws again.\n */\nconst migrationStep = <TCommit>(\n name: string,\n transform: MigrationTransform,\n): StepDefinition<unknown, unknown, TCommit> =>\n step({\n name: migrationStepName(name),\n retry: { maxAttempts: 1 },\n handler: async (state: unknown) => transform(state),\n })\n\n/**\n * A view of the case type with the migration step spliced in, so the\n * ordinary lifecycle can address it by name. The real definition is left\n * untouched: the migration step exists for the duration of the run and is\n * never part of anything a client could see as an affordance.\n */\nconst withMigrationStep = <TCommit>(\n definition: AnyCaseType<TCommit>,\n migration: StepDefinition<unknown, unknown, TCommit>,\n): AnyCaseType<TCommit> => ({\n ...definition,\n steps: [...definition.steps, migration],\n getStep: (stepName: string) =>\n stepName === migration.name ? migration : definition.getStep(stepName),\n})\n\n/**\n * Run a state restructure over every case of a case type, as journaled\n * system Executions.\n *\n * Idempotent: a case that already carries the migration's marker is never\n * examined again, so re-running is a no-op and an interrupted run resumes\n * where it stopped. Failures do not stop the run — a case that cannot be\n * migrated (its claim is held, the transform threw, the result fails the\n * state schema) is reported and the run moves on, and the next run will pick\n * it up because it never got its marker.\n *\n * Storage owns candidate order and continuation. It excludes completed\n * migration markers on every page; the core passes its cursor back unchanged.\n */\nexport const migrate = async <TCommit>(\n env: ExecutionEnvironment<TCommit>,\n caseTypeName: string,\n name: string,\n transform: MigrationTransform,\n options: MigrationOptions = {},\n): Promise<MigrationReport> => {\n if (typeof name !== 'string' || name.trim() === '') {\n throw new TypeError(\n 'migrate: a migration must have a non-empty name — it is the journal marker',\n )\n }\n if (typeof transform !== 'function') {\n throw new TypeError(\n `migrate '${name}': the transform must be a function of case state`,\n )\n }\n\n const definition = env.caseTypeFor(caseTypeName)\n const marker = migrationStepName(name)\n const migration = migrationStep<TCommit>(name, transform)\n const augmented = withMigrationStep(definition, migration)\n const actor: unknown = options.actor ?? { kind: 'migration', migration: name }\n const batchSize = Math.max(1, options.batchSize ?? 100)\n const dryRun = options.dryRun === true\n\n let scanned = 0\n let migrated = 0\n let unchanged = 0\n const failed: MigrationFailure[] = []\n let cursor: string | null = null\n\n /** One candidate's verdict, with early returns — no outcome threading. */\n const runOne = async (candidate: {\n readonly id: string\n readonly state: unknown\n }): Promise<Pick<MigrationProgress, 'outcome' | 'delta' | 'error'>> => {\n try {\n if (dryRun) {\n // The transform is pure, so \"what would this become\" needs no\n // claim, no lock and no write — and leaves no marker behind.\n const resolved = await resolveStoredState(definition, candidate.state)\n const before = resolved === null ? candidate.state : resolved.state\n const delta = diffState(before, transform(before))\n return {\n outcome: delta.length === 0 ? 'unchanged' : 'migrated',\n delta,\n error: null,\n }\n }\n // The system runner returns case-level failures as values rather than\n // throwing, and the sweep filters on that outcome: only a commit\n // advances this case. A settled run — a held claim, a throwing\n // transform, a result its schema rejects — is reported with no marker\n // written, so the next run picks the case up again.\n const ran = await runAsSystem(env, candidate.id, marker, {\n actor,\n definition: augmented,\n })\n if (ran.outcome === 'settled')\n return { outcome: 'failed', delta: [], error: ran.error }\n const delta = ran.result.delta\n return {\n outcome: delta.length === 0 ? 'unchanged' : 'migrated',\n delta,\n error: null,\n }\n } catch (caught) {\n // The dry-run path's own throws (a transform is app code).\n return { outcome: 'failed', delta: [], error: toError(caught) }\n }\n }\n\n for (;;) {\n const remaining =\n options.limit === undefined ? batchSize : options.limit - scanned\n if (remaining <= 0) break\n const page = await env.storage.migrations.candidates(\n caseTypeName,\n marker,\n options,\n cursor,\n Math.min(batchSize, remaining),\n )\n if (page.cases.length === 0) break\n\n for (const candidate of page.cases) {\n scanned += 1\n const { outcome, delta, error } = await runOne(candidate)\n if (error !== null) failed.push({ caseId: candidate.id, error })\n else if (outcome === 'unchanged') unchanged += 1\n else migrated += 1\n\n options.onProgress?.({\n caseId: candidate.id,\n outcome,\n delta,\n error,\n processed: scanned,\n })\n }\n cursor = page.nextCursor\n if (cursor === null) break\n }\n\n return { name, caseTypeName, dryRun, scanned, migrated, unchanged, failed }\n}\n\n/** Whether one case carries a completed migration marker. */\nexport const hasMigrated = (\n storage: Pick<EngineStorage, 'migrations'>,\n caseId: string,\n name: string,\n): Promise<boolean> =>\n storage.migrations.hasCompleted(caseId, migrationStepName(name))\n"]}
|
package/dist/model/casetype.d.ts
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
15
15
|
import type { StepDefinition } from './step.js';
|
|
16
16
|
/** Options for {@link caseType}. */
|
|
17
|
-
export interface CaseTypeOptions<S extends StandardSchemaV1, TActor = unknown> {
|
|
17
|
+
export interface CaseTypeOptions<S extends StandardSchemaV1, TActor = unknown, TCommit = unknown> {
|
|
18
18
|
/**
|
|
19
|
-
* The case type's name — what
|
|
19
|
+
* The case type's name — what the persisted case records. Only the name is
|
|
20
20
|
* stored: the code definition floats, meaning existing cases always run
|
|
21
21
|
* against the latest deployed definition.
|
|
22
22
|
*/
|
|
@@ -24,15 +24,15 @@ export interface CaseTypeOptions<S extends StandardSchemaV1, TActor = unknown> {
|
|
|
24
24
|
/** The Case State schema (any Standard Schema — zod v4 qualifies). */
|
|
25
25
|
readonly state: S;
|
|
26
26
|
/** The case type's steps, in declaration order (which affordance listings preserve). */
|
|
27
|
-
readonly steps: readonly StepDefinition<StandardSchemaV1.InferOutput<S>, TActor>[];
|
|
27
|
+
readonly steps: readonly StepDefinition<StandardSchemaV1.InferOutput<S>, TActor, TCommit>[];
|
|
28
28
|
}
|
|
29
29
|
/** A validated case type definition — the unit the engine registers. */
|
|
30
|
-
export interface CaseTypeDefinition<S extends StandardSchemaV1 = StandardSchemaV1, TActor = unknown> {
|
|
30
|
+
export interface CaseTypeDefinition<S extends StandardSchemaV1 = StandardSchemaV1, TActor = unknown, TCommit = unknown> {
|
|
31
31
|
readonly name: string;
|
|
32
32
|
readonly state: S;
|
|
33
|
-
readonly steps: readonly StepDefinition<StandardSchemaV1.InferOutput<S>, TActor>[];
|
|
33
|
+
readonly steps: readonly StepDefinition<StandardSchemaV1.InferOutput<S>, TActor, TCommit>[];
|
|
34
34
|
/** Look up a step by name; `undefined` when the case type declares no such step. */
|
|
35
|
-
readonly getStep: (name: string) => StepDefinition<StandardSchemaV1.InferOutput<S>, TActor> | undefined;
|
|
35
|
+
readonly getStep: (name: string) => StepDefinition<StandardSchemaV1.InferOutput<S>, TActor, TCommit> | undefined;
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
38
|
* A case type with its schema and actor generics erased — what heterogeneous
|
|
@@ -41,7 +41,7 @@ export interface CaseTypeDefinition<S extends StandardSchemaV1 = StandardSchemaV
|
|
|
41
41
|
* `CaseTypeDefinition<LoanSchema, Servicer>` fit one list; every use is
|
|
42
42
|
* re-anchored by the state schema validation the engine performs on load.
|
|
43
43
|
*/
|
|
44
|
-
export type AnyCaseType = CaseTypeDefinition<any, any>;
|
|
44
|
+
export type AnyCaseType<TCommit = unknown> = CaseTypeDefinition<any, any, TCommit>;
|
|
45
45
|
/**
|
|
46
46
|
* Define a case type. Validates loudly at construction time:
|
|
47
47
|
*
|
|
@@ -50,4 +50,4 @@ export type AnyCaseType = CaseTypeDefinition<any, any>;
|
|
|
50
50
|
* - step names must be unique within the case type — a duplicate would make
|
|
51
51
|
* affordance identity (step × scope key) ambiguous
|
|
52
52
|
*/
|
|
53
|
-
export declare const caseType: <S extends StandardSchemaV1, TActor = unknown>(options: CaseTypeOptions<S, TActor>) => CaseTypeDefinition<S, TActor>;
|
|
53
|
+
export declare const caseType: <S extends StandardSchemaV1, TActor = unknown, TCommit = unknown>(options: CaseTypeOptions<S, TActor, TCommit>) => CaseTypeDefinition<S, TActor, TCommit>;
|