@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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/engine/compute.d.ts +147 -0
- package/dist/engine/compute.js +152 -0
- package/dist/engine/compute.js.map +1 -0
- package/dist/engine/engine.d.ts +162 -0
- package/dist/engine/engine.js +115 -0
- package/dist/engine/engine.js.map +1 -0
- package/dist/engine/errors.d.ts +6 -0
- package/dist/engine/errors.js +11 -0
- package/dist/engine/errors.js.map +1 -0
- package/dist/engine/index.d.ts +16 -0
- package/dist/engine/index.js +15 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +42 -0
- package/dist/errors.js.map +1 -0
- package/dist/execution/delta.d.ts +42 -0
- package/dist/execution/delta.js +93 -0
- package/dist/execution/delta.js.map +1 -0
- package/dist/execution/errors.d.ts +94 -0
- package/dist/execution/errors.js +132 -0
- package/dist/execution/errors.js.map +1 -0
- package/dist/execution/execute.d.ts +179 -0
- package/dist/execution/execute.js +356 -0
- package/dist/execution/execute.js.map +1 -0
- package/dist/execution/index.d.ts +25 -0
- package/dist/execution/index.js +20 -0
- package/dist/execution/index.js.map +1 -0
- package/dist/execution/journal.d.ts +190 -0
- package/dist/execution/journal.js +192 -0
- package/dist/execution/journal.js.map +1 -0
- package/dist/execution/port.d.ts +81 -0
- package/dist/execution/port.js +101 -0
- package/dist/execution/port.js.map +1 -0
- package/dist/execution/replay.d.ts +58 -0
- package/dist/execution/replay.js +59 -0
- package/dist/execution/replay.js.map +1 -0
- package/dist/execution/timers.d.ts +21 -0
- package/dist/execution/timers.js +26 -0
- package/dist/execution/timers.js.map +1 -0
- package/dist/execution/transaction.d.ts +24 -0
- package/dist/execution/transaction.js +49 -0
- package/dist/execution/transaction.js.map +1 -0
- package/dist/guards/condition.d.ts +86 -0
- package/dist/guards/condition.js +11 -0
- package/dist/guards/condition.js.map +1 -0
- package/dist/guards/evaluate.d.ts +143 -0
- package/dist/guards/evaluate.js +167 -0
- package/dist/guards/evaluate.js.map +1 -0
- package/dist/guards/guard.d.ts +136 -0
- package/dist/guards/guard.js +118 -0
- package/dist/guards/guard.js.map +1 -0
- package/dist/guards/index.d.ts +20 -0
- package/dist/guards/index.js +17 -0
- package/dist/guards/index.js.map +1 -0
- package/dist/guards/time.d.ts +32 -0
- package/dist/guards/time.js +45 -0
- package/dist/guards/time.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/ingestion/correlation.d.ts +54 -0
- package/dist/ingestion/correlation.js +78 -0
- package/dist/ingestion/correlation.js.map +1 -0
- package/dist/ingestion/index.d.ts +16 -0
- package/dist/ingestion/index.js +15 -0
- package/dist/ingestion/index.js.map +1 -0
- package/dist/ingestion/ingest.d.ts +196 -0
- package/dist/ingestion/ingest.js +275 -0
- package/dist/ingestion/ingest.js.map +1 -0
- package/dist/migration/index.d.ts +10 -0
- package/dist/migration/index.js +10 -0
- package/dist/migration/index.js.map +1 -0
- package/dist/migration/migrate.d.ts +119 -0
- package/dist/migration/migrate.js +195 -0
- package/dist/migration/migrate.js.map +1 -0
- package/dist/model/casetype.d.ts +53 -0
- package/dist/model/casetype.js +51 -0
- package/dist/model/casetype.js.map +1 -0
- package/dist/model/errors.d.ts +28 -0
- package/dist/model/errors.js +39 -0
- package/dist/model/errors.js.map +1 -0
- package/dist/model/handler.d.ts +120 -0
- package/dist/model/handler.js +16 -0
- package/dist/model/handler.js.map +1 -0
- package/dist/model/index.d.ts +24 -0
- package/dist/model/index.js +19 -0
- package/dist/model/index.js.map +1 -0
- package/dist/model/retry.d.ts +40 -0
- package/dist/model/retry.js +51 -0
- package/dist/model/retry.js.map +1 -0
- package/dist/model/scope.d.ts +67 -0
- package/dist/model/scope.js +19 -0
- package/dist/model/scope.js.map +1 -0
- package/dist/model/step.d.ts +232 -0
- package/dist/model/step.js +210 -0
- package/dist/model/step.js.map +1 -0
- package/dist/model/target.d.ts +175 -0
- package/dist/model/target.js +236 -0
- package/dist/model/target.js.map +1 -0
- package/dist/store/bootstrap.d.ts +57 -0
- package/dist/store/bootstrap.js +268 -0
- package/dist/store/bootstrap.js.map +1 -0
- package/dist/store/errors.d.ts +18 -0
- package/dist/store/errors.js +26 -0
- package/dist/store/errors.js.map +1 -0
- package/dist/store/ids.d.ts +15 -0
- package/dist/store/ids.js +12 -0
- package/dist/store/ids.js.map +1 -0
- package/dist/store/index.d.ts +12 -0
- package/dist/store/index.js +9 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/queryable.d.ts +60 -0
- package/dist/store/queryable.js +7 -0
- package/dist/store/queryable.js.map +1 -0
- package/dist/store/resolve.d.ts +66 -0
- package/dist/store/resolve.js +71 -0
- package/dist/store/resolve.js.map +1 -0
- package/dist/store/sql.d.ts +26 -0
- package/dist/store/sql.js +21 -0
- package/dist/store/sql.js.map +1 -0
- package/dist/store/store.d.ts +66 -0
- package/dist/store/store.js +108 -0
- package/dist/store/store.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingestion: external events become Executions.
|
|
3
|
+
*
|
|
4
|
+
* A webhook arrives. Three things have to happen, in this order, and each of
|
|
5
|
+
* them has to be visible afterwards:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Dedup.** Providers retry; "at least once" is the delivery guarantee
|
|
8
|
+
* every one of them offers. The gate is a unique insert into
|
|
9
|
+
* `ingested_events` — not a lookup-then-insert, which races itself — so
|
|
10
|
+
* three concurrent deliveries of one event produce exactly one Execution
|
|
11
|
+
* and two `duplicate` results.
|
|
12
|
+
* 2. **Correlate.** The external identifier is resolved to (case, scope,
|
|
13
|
+
* step) through the registry the initiating handler wrote.
|
|
14
|
+
* 3. **Execute.** Through the ordinary claim → run → commit, with the
|
|
15
|
+
* external system as the journaled actor. Ingestion has no privileged
|
|
16
|
+
* path: the guard still decides, transactionally.
|
|
17
|
+
*
|
|
18
|
+
* What is *not* allowed is a quiet drop. An event nothing can route, an event
|
|
19
|
+
* whose step the guard refuses, an event whose case is busy — each lands in
|
|
20
|
+
* the same table with a status and a reason, which is the dead-letter
|
|
21
|
+
* surface. "The webhook definitely arrived, so why is the case still waiting"
|
|
22
|
+
* is a question this table answers without anyone reading a log file.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { isAffordanceError } from '../errors.js';
|
|
26
|
+
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
|
+
/**
|
|
31
|
+
* Whether a provider redelivery of the same event deserves another attempt.
|
|
32
|
+
*
|
|
33
|
+
* Total over {@link DeadLetterReason}, so a new code cannot be added without
|
|
34
|
+
* deciding its reopen policy. `true` marks the outcomes that another delivery
|
|
35
|
+
* could genuinely cure — the case was busy, the handler crashed. Everything
|
|
36
|
+
* deterministic (a payload its schema rejects, an address that does not
|
|
37
|
+
* resolve, a guard that said no) stays dead-lettered: it will be refused the
|
|
38
|
+
* same way on every retry, so reopening it would only invite endless
|
|
39
|
+
* redelivery.
|
|
40
|
+
*/
|
|
41
|
+
export const REOPENS_ON_REDELIVERY = {
|
|
42
|
+
unrouted: false,
|
|
43
|
+
'no-step': false,
|
|
44
|
+
'step-not-available': false,
|
|
45
|
+
'case-busy': true,
|
|
46
|
+
'invalid-input': false,
|
|
47
|
+
'not-found': false,
|
|
48
|
+
'bad-request': false,
|
|
49
|
+
'execution-failed': true,
|
|
50
|
+
'invalid-state': false,
|
|
51
|
+
};
|
|
52
|
+
const REOPENABLE = Object.entries(REOPENS_ON_REDELIVERY)
|
|
53
|
+
.filter(([, reopens]) => reopens)
|
|
54
|
+
.map(([reason]) => reason);
|
|
55
|
+
export const externalActor = (event) => ({
|
|
56
|
+
kind: 'external',
|
|
57
|
+
system: event.system,
|
|
58
|
+
externalId: event.externalId,
|
|
59
|
+
eventType: event.type,
|
|
60
|
+
});
|
|
61
|
+
export const normalizeIngestion = (options = {}) => ({
|
|
62
|
+
actor: options.actor ?? externalActor,
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* The idempotency key an event dedups on.
|
|
66
|
+
*
|
|
67
|
+
* `(system, externalId, type)` is the key's fixed prefix, so the *same*
|
|
68
|
+
* notification about the *same* envelope produces the same key however many
|
|
69
|
+
* times it is delivered — and, because an external id resolves to one (case,
|
|
70
|
+
* scope element), the key is scoped exactly as far as the correlation is. The
|
|
71
|
+
* tail is the provider's own delivery id where there is one, and a hash of
|
|
72
|
+
* the payload where there is not: a provider that cannot tell you which
|
|
73
|
+
* delivery this is gets content-addressed dedup, which is the best anyone
|
|
74
|
+
* can do.
|
|
75
|
+
*/
|
|
76
|
+
export const idempotencyKeyFor = (event) => {
|
|
77
|
+
if (event.idempotencyKey !== undefined)
|
|
78
|
+
return event.idempotencyKey;
|
|
79
|
+
const tail = event.eventId ??
|
|
80
|
+
createHash('sha256')
|
|
81
|
+
.update(JSON.stringify(event.payload ?? null))
|
|
82
|
+
.digest('hex')
|
|
83
|
+
.slice(0, 32);
|
|
84
|
+
return `${event.system}/${event.externalId}/${event.type}/${tail}`;
|
|
85
|
+
};
|
|
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
|
+
const result = (row, status, extra = {}) => ({
|
|
141
|
+
id: row.id,
|
|
142
|
+
status,
|
|
143
|
+
system: row.system,
|
|
144
|
+
externalId: row.external_id,
|
|
145
|
+
idempotencyKey: row.idempotency_key,
|
|
146
|
+
correlation: null,
|
|
147
|
+
execution: null,
|
|
148
|
+
reason: null,
|
|
149
|
+
detail: null,
|
|
150
|
+
receivedAt: row.received_at.toISOString(),
|
|
151
|
+
...extra,
|
|
152
|
+
});
|
|
153
|
+
/**
|
|
154
|
+
* Which step an ingested event executes: the event's own naming wins,
|
|
155
|
+
* falling back to what the correlation registered. The precedence is stated
|
|
156
|
+
* once so any surface that *previews* routing (a dev console's world panel)
|
|
157
|
+
* asks the same question `ingest` will answer.
|
|
158
|
+
*/
|
|
159
|
+
export const routedStep = (event, correlation) => event.step ?? correlation.step ?? null;
|
|
160
|
+
/**
|
|
161
|
+
* Ingest one external event: dedup, correlate, execute — or dead-letter it
|
|
162
|
+
* with a reason.
|
|
163
|
+
*
|
|
164
|
+
* Never throws for an event's own sake: a webhook endpoint that 500s because
|
|
165
|
+
* a guard said no teaches the provider to retry something that will never
|
|
166
|
+
* succeed. Infrastructure failures (the database is gone) do still throw,
|
|
167
|
+
* because those the caller must not acknowledge.
|
|
168
|
+
*/
|
|
169
|
+
export const ingest = async (env, event) => {
|
|
170
|
+
const db = queryableOf(env.db);
|
|
171
|
+
const idempotencyKey = idempotencyKeyFor(event);
|
|
172
|
+
const { row, fresh } = await claimDelivery(db, event, idempotencyKey);
|
|
173
|
+
if (!fresh) {
|
|
174
|
+
return result(row, 'duplicate', {
|
|
175
|
+
reason: row.reason ?? null,
|
|
176
|
+
detail: `already ingested as ${row.status} at ${row.received_at.toISOString()}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Settle the row and answer the caller with the same reason/detail pair —
|
|
180
|
+
// one helper, so a branch cannot record one thing and report another.
|
|
181
|
+
const deadLetter = async (fields) => {
|
|
182
|
+
await settle(db, row.id, {
|
|
183
|
+
status: 'dead-lettered',
|
|
184
|
+
reason: fields.reason,
|
|
185
|
+
detail: fields.detail,
|
|
186
|
+
...(fields.correlation !== undefined && {
|
|
187
|
+
caseId: fields.correlation.caseId,
|
|
188
|
+
scopeKey: fields.correlation.scopeKey,
|
|
189
|
+
}),
|
|
190
|
+
...(fields.step !== undefined && { step: fields.step }),
|
|
191
|
+
});
|
|
192
|
+
return result(row, 'dead-lettered', {
|
|
193
|
+
reason: fields.reason,
|
|
194
|
+
detail: fields.detail,
|
|
195
|
+
...(fields.correlation !== undefined && {
|
|
196
|
+
correlation: fields.correlation,
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
const correlation = await lookupCorrelation(db, event.system, event.externalId);
|
|
201
|
+
if (correlation === null) {
|
|
202
|
+
return deadLetter({
|
|
203
|
+
reason: 'unrouted',
|
|
204
|
+
detail: `no correlation registered for ${event.system}/${event.externalId}`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const stepName = routedStep(event, correlation);
|
|
208
|
+
if (stepName === null) {
|
|
209
|
+
return deadLetter({
|
|
210
|
+
correlation,
|
|
211
|
+
reason: 'no-step',
|
|
212
|
+
detail: `correlation ${event.system}/${event.externalId} names no step, and the event does not either`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const ran = await runAsSystem(env, correlation.caseId, stepName, {
|
|
216
|
+
actor: env.ingestion.actor(event),
|
|
217
|
+
...(correlation.scopeKey !== null && { scopeKey: correlation.scopeKey }),
|
|
218
|
+
...(event.payload !== undefined && { input: event.payload }),
|
|
219
|
+
});
|
|
220
|
+
if (ran.outcome === 'committed') {
|
|
221
|
+
await settle(db, row.id, {
|
|
222
|
+
status: 'executed',
|
|
223
|
+
caseId: correlation.caseId,
|
|
224
|
+
scopeKey: correlation.scopeKey,
|
|
225
|
+
step: stepName,
|
|
226
|
+
executionId: ran.result.executionId,
|
|
227
|
+
});
|
|
228
|
+
return result(row, 'executed', { correlation, execution: ran.result });
|
|
229
|
+
}
|
|
230
|
+
const [reason, detail] = classifyDeadLetter(ran);
|
|
231
|
+
return deadLetter({ correlation, step: stepName, reason, detail });
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Project a settled system run onto the reason an operator needs to see —
|
|
235
|
+
* the one place that asks whether what settled was a Refusal.
|
|
236
|
+
*
|
|
237
|
+
* A framework Refusal already names its own kind and explains itself — the
|
|
238
|
+
* reason is its code, the detail its message (a refused guard's message
|
|
239
|
+
* carries the unmet conditions, addressed). Anything else is a bug, not a
|
|
240
|
+
* Refusal; it lands as `execution-failed` so the event is kept, never
|
|
241
|
+
* silently dropped, with the crash's own words as the detail.
|
|
242
|
+
*/
|
|
243
|
+
export const classifyDeadLetter = (ran) => isAffordanceError(ran.error)
|
|
244
|
+
? [ran.error.code, ran.error.message]
|
|
245
|
+
: ['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
|
+
//# sourceMappingURL=ingest.js.map
|
|
@@ -0,0 +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"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The migration primitive for the one change float cannot cover.
|
|
3
|
+
*
|
|
4
|
+
* Explicit state restructures — renames, splits, a scalar becoming a
|
|
5
|
+
* collection — run as journaled system Executions, never as silent
|
|
6
|
+
* mutation. Everything else should be handled by writing total conditions
|
|
7
|
+
* over the old shape; see `docs/migration.md` for where the line is.
|
|
8
|
+
*/
|
|
9
|
+
export type { MigrationActor, MigrationFailure, MigrationOptions, MigrationProgress, MigrationReport, MigrationTransform, } from './migrate.js';
|
|
10
|
+
export { hasMigrated, migrate, migrationStepName } from './migrate.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The migration primitive for the one change float cannot cover.
|
|
3
|
+
*
|
|
4
|
+
* Explicit state restructures — renames, splits, a scalar becoming a
|
|
5
|
+
* collection — run as journaled system Executions, never as silent
|
|
6
|
+
* mutation. Everything else should be handled by writing total conditions
|
|
7
|
+
* over the old shape; see `docs/migration.md` for where the line is.
|
|
8
|
+
*/
|
|
9
|
+
export { hasMigrated, migrate, migrationStepName } from './migrate.js';
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migration/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAUH,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA","sourcesContent":["/**\n * The migration primitive for the one change float cannot cover.\n *\n * Explicit state restructures — renames, splits, a scalar becoming a\n * collection — run as journaled system Executions, never as silent\n * mutation. Everything else should be handled by writing total conditions\n * over the old shape; see `docs/migration.md` for where the line is.\n */\n\nexport type {\n MigrationActor,\n MigrationFailure,\n MigrationOptions,\n MigrationProgress,\n MigrationReport,\n MigrationTransform,\n} from './migrate.js'\nexport { hasMigrated, migrate, migrationStepName } from './migrate.js'\n"]}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The migration escape hatch.
|
|
3
|
+
*
|
|
4
|
+
* Cases float to the latest definitions: a deploy changes what the steps are
|
|
5
|
+
* and every in-flight case is immediately governed by the new ones, with no
|
|
6
|
+
* migration executed and no version pinned. That covers almost everything,
|
|
7
|
+
* because almost every change is *additive* — a new step, a new condition, a
|
|
8
|
+
* new optional field — and the `?? fallback` discipline makes old
|
|
9
|
+
* state readable by new code.
|
|
10
|
+
*
|
|
11
|
+
* What float cannot cover is a **restructure**: a field renamed, one field
|
|
12
|
+
* split into two, a scalar turned into a collection. No fallback expression
|
|
13
|
+
* makes `state.buyer` readable as `state.buyers[]`. For exactly that,
|
|
14
|
+
* this module.
|
|
15
|
+
*
|
|
16
|
+
* Two properties make it an escape hatch rather than a second way of working:
|
|
17
|
+
*
|
|
18
|
+
* 1. **A migration is an Execution.** Not a script with a database
|
|
19
|
+
* connection — the same claim → run → commit as any step, so the case row
|
|
20
|
+
* lock, the in-flight claim, state schema validation
|
|
21
|
+
* and the journal entry all apply unchanged. The transform runs
|
|
22
|
+
* as a synthetic step named `migrate:<name>`, with a migration actor.
|
|
23
|
+
* An auditor asking "why did this case's shape change in March" gets the
|
|
24
|
+
* same kind of answer as for anything else that ever happened to it.
|
|
25
|
+
* 2. **The journal is the marker.** A case has been migrated iff it carries a
|
|
26
|
+
* completed `migrate:<name>` entry. Nothing is written into app state to
|
|
27
|
+
* record it, so a migration leaves no residue in the document it
|
|
28
|
+
* restructured — and re-running is a no-op by construction, which is also
|
|
29
|
+
* what makes an interrupted run resumable.
|
|
30
|
+
*
|
|
31
|
+
* When *not* to reach for this is in `docs/migration.md`. The short version:
|
|
32
|
+
* if a total condition can read the old shape, write the total condition.
|
|
33
|
+
*/
|
|
34
|
+
import type { ExecutionEnvironment, StateDelta } from '../execution/index.js';
|
|
35
|
+
import type { Queryable } from '../store/index.js';
|
|
36
|
+
/** The synthetic step name a migration executes under — its journal marker. */
|
|
37
|
+
export declare const migrationStepName: (name: string) => string;
|
|
38
|
+
/** The Actor a migration runs as, when the app supplies none. */
|
|
39
|
+
export interface MigrationActor {
|
|
40
|
+
readonly kind: 'migration';
|
|
41
|
+
readonly migration: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The state transform. Pure and total, like a condition: it will
|
|
45
|
+
* be handed state written by every definition the case has ever floated
|
|
46
|
+
* through, and returning the input unchanged must be safe.
|
|
47
|
+
*/
|
|
48
|
+
export type MigrationTransform<TState = any> = (state: TState) => TState;
|
|
49
|
+
/** Options for {@link migrate}. */
|
|
50
|
+
export interface MigrationOptions {
|
|
51
|
+
/** How many cases to claim per round trip when scanning (default 100). */
|
|
52
|
+
readonly batchSize?: number;
|
|
53
|
+
/** Stop after this many cases — for a cautious first run. */
|
|
54
|
+
readonly limit?: number;
|
|
55
|
+
/** Restrict the run to these cases; they are still skipped if already migrated. */
|
|
56
|
+
readonly caseIds?: readonly string[];
|
|
57
|
+
/** The Actor the Executions run as; defaults to a {@link MigrationActor}. */
|
|
58
|
+
readonly actor?: unknown;
|
|
59
|
+
/** Include dormant (ended) cases. Off by default — a closed case is usually not worth restructuring. */
|
|
60
|
+
readonly includeEnded?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Compute what each case *would* become and report the deltas without
|
|
63
|
+
* writing anything. Nothing is journaled, so a dry run leaves no marker
|
|
64
|
+
* and the real run still has every case to do.
|
|
65
|
+
*/
|
|
66
|
+
readonly dryRun?: boolean;
|
|
67
|
+
/** Called after each case, for a progress bar or a log line. */
|
|
68
|
+
readonly onProgress?: (progress: MigrationProgress) => void;
|
|
69
|
+
}
|
|
70
|
+
/** Progress after one case. */
|
|
71
|
+
export interface MigrationProgress {
|
|
72
|
+
readonly caseId: string;
|
|
73
|
+
readonly outcome: 'migrated' | 'unchanged' | 'failed';
|
|
74
|
+
/** What the transform changed, as JSON Patch; empty when it changed nothing. */
|
|
75
|
+
readonly delta: StateDelta;
|
|
76
|
+
readonly error: Error | null;
|
|
77
|
+
/** How many cases have been processed so far in this run. */
|
|
78
|
+
readonly processed: number;
|
|
79
|
+
}
|
|
80
|
+
/** One case a migration could not restructure. */
|
|
81
|
+
export interface MigrationFailure {
|
|
82
|
+
readonly caseId: string;
|
|
83
|
+
readonly error: Error;
|
|
84
|
+
}
|
|
85
|
+
/** What {@link migrate} resolves to. */
|
|
86
|
+
export interface MigrationReport {
|
|
87
|
+
readonly name: string;
|
|
88
|
+
readonly caseTypeName: string;
|
|
89
|
+
readonly dryRun: boolean;
|
|
90
|
+
/** Cases examined — those not already bearing the marker. */
|
|
91
|
+
readonly scanned: number;
|
|
92
|
+
/** Cases whose state the transform changed and which committed. */
|
|
93
|
+
readonly migrated: number;
|
|
94
|
+
/**
|
|
95
|
+
* Cases the transform left identical. Still journaled (and so still
|
|
96
|
+
* marked), because "this migration considered this case and had nothing to
|
|
97
|
+
* do" is a fact worth being able to prove.
|
|
98
|
+
*/
|
|
99
|
+
readonly unchanged: number;
|
|
100
|
+
readonly failed: readonly MigrationFailure[];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Run a state restructure over every case of a case type, as journaled
|
|
104
|
+
* system Executions.
|
|
105
|
+
*
|
|
106
|
+
* Idempotent: a case that already carries the migration's marker is never
|
|
107
|
+
* examined again, so re-running is a no-op and an interrupted run resumes
|
|
108
|
+
* where it stopped. Failures do not stop the run — a case that cannot be
|
|
109
|
+
* migrated (its claim is held, the transform threw, the result fails the
|
|
110
|
+
* state schema) is reported and the run moves on, and the next run will pick
|
|
111
|
+
* it up because it never got its marker.
|
|
112
|
+
*
|
|
113
|
+
* The cursor walks case ids ascending, and the "already migrated" filter is
|
|
114
|
+
* applied by the database on every batch — so a case migrated by a
|
|
115
|
+
* concurrently-running instance of the same migration simply is not returned.
|
|
116
|
+
*/
|
|
117
|
+
export declare const migrate: (env: ExecutionEnvironment, caseTypeName: string, name: string, transform: MigrationTransform, options?: MigrationOptions) => Promise<MigrationReport>;
|
|
118
|
+
/** Whether one case already carries a migration's marker. */
|
|
119
|
+
export declare const hasMigrated: (db: Queryable, caseId: string, name: string) => Promise<boolean>;
|