@sekiban/dcb-runtime 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +5 -1
  2. package/dist/allocator/AllocatorDurableObject.js +25 -2
  3. package/dist/bootstrap/BootstrapCoordinatorDurableObject.d.ts +2 -0
  4. package/dist/bootstrap/BootstrapCoordinatorDurableObject.js +64 -1
  5. package/dist/bootstrap/types.d.ts +6 -0
  6. package/dist/cloudflare.d.ts +3 -0
  7. package/dist/cloudflare.js +1493 -451
  8. package/dist/commit/CommitWorker.d.ts +19 -0
  9. package/dist/commit/CommitWorker.js +176 -27
  10. package/dist/completeness/IncidentLifecycle.d.ts +75 -0
  11. package/dist/completeness/IncidentLifecycle.js +503 -0
  12. package/dist/completeness/types.d.ts +67 -0
  13. package/dist/cosmos.d.ts +34 -4
  14. package/dist/cosmos.js +214 -49
  15. package/dist/downstream/OutboxDrain.d.ts +4 -1
  16. package/dist/downstream/OutboxDrain.js +24 -2
  17. package/dist/generated/cosmos-layout.d.ts +87 -0
  18. package/dist/generated/cosmos-layout.js +99 -0
  19. package/dist/http/IncidentMaintenance.d.ts +8 -0
  20. package/dist/http/IncidentMaintenance.js +145 -0
  21. package/dist/index.d.ts +11 -0
  22. package/dist/index.js +1548 -451
  23. package/dist/read/SerializedReadWorker.d.ts +3 -0
  24. package/dist/read/SerializedReadWorker.js +3 -3
  25. package/dist/shard/ShardRotation.d.ts +48 -0
  26. package/dist/shard/ShardRotation.js +120 -0
  27. package/dist/store/CosmosEventStore.js +56 -46
  28. package/dist/store/PostgresEventStore.d.ts +20 -0
  29. package/dist/store/PostgresEventStore.js +53 -0
  30. package/dist/store/provider.d.ts +4 -0
  31. package/dist/tag/TagDurableObject.js +66 -14
  32. package/dist/tag/TagSqlSchema.d.ts +6 -1
  33. package/dist/tag/TagSqlSchema.js +15 -0
  34. package/dist/tag/types.d.ts +9 -0
  35. package/dist/trace/CommitTrace.d.ts +3 -3
  36. package/dist/trace/CommitTraceVerifier.js +1 -1
  37. package/package.json +6 -15
@@ -0,0 +1,503 @@
1
+ const MAX_IDENTITY_BYTES = 256;
2
+ const MAX_LONG_TEXT_BYTES = 2_048;
3
+ const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/;
4
+ const LIFECYCLE_STATES = [
5
+ "OPEN",
6
+ "ACKNOWLEDGED",
7
+ "CORRECTION_RECORDED",
8
+ "CLOSED",
9
+ "REOPENED",
10
+ ];
11
+ export class IncidentLifecycleError extends Error {
12
+ code;
13
+ status;
14
+ constructor(code, status, message) {
15
+ super(message);
16
+ this.name = "IncidentLifecycleError";
17
+ this.code = code;
18
+ this.status = status;
19
+ }
20
+ }
21
+ function isObject(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ function hasOnlyKeys(value, keys) {
25
+ const allowed = new Set(keys);
26
+ return Object.keys(value).every((key) => allowed.has(key));
27
+ }
28
+ function bytes(value) {
29
+ return new TextEncoder().encode(value).byteLength;
30
+ }
31
+ function boundedString(value, name, maximum, required = true) {
32
+ if (typeof value !== "string") {
33
+ throw new IncidentLifecycleError("incident_invalid_request", 400, `${name} must be a string`);
34
+ }
35
+ const normalized = value.trim();
36
+ if (required && normalized.length === 0) {
37
+ throw new IncidentLifecycleError("incident_invalid_request", 400, `${name} must be non-empty`);
38
+ }
39
+ if (bytes(normalized) > maximum) {
40
+ throw new IncidentLifecycleError("incident_invalid_request", 400, `${name} is too long`);
41
+ }
42
+ return normalized.length === 0 ? null : normalized;
43
+ }
44
+ export function normalizeIncidentIdentity(value) {
45
+ const identity = boundedString(value, "incidentIdentity", MAX_IDENTITY_BYTES);
46
+ if (identity === null) {
47
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "incidentIdentity must be non-empty");
48
+ }
49
+ return identity;
50
+ }
51
+ function safeInteger(value, name) {
52
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
53
+ throw new IncidentLifecycleError("incident_invalid_request", 400, `${name} must be a safe integer`);
54
+ }
55
+ return value;
56
+ }
57
+ function normalizeCorrection(value) {
58
+ if (!isObject(value) || !hasOnlyKeys(value, ["kind", "reference", "digest"])) {
59
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "correction has unknown or missing keys");
60
+ }
61
+ if (value.kind !== "event" && value.kind !== "receipt") {
62
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "correction.kind must be event or receipt");
63
+ }
64
+ const reference = boundedString(value.reference, "correction.reference", MAX_LONG_TEXT_BYTES);
65
+ const digest = boundedString(value.digest, "correction.digest", MAX_IDENTITY_BYTES);
66
+ if (reference === null || digest === null || !SHA256_DIGEST.test(digest)) {
67
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "correction.digest must be lowercase sha256");
68
+ }
69
+ return { kind: value.kind, reference, digest: digest };
70
+ }
71
+ function normalizeRequest(value) {
72
+ if (!isObject(value) || !hasOnlyKeys(value, ["action", "incidentIdentity", "transitionKey", "expectedVersion", "reason", "ownerId", "deadlineAt", "correction", "resolution"])) {
73
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "transition has unknown keys");
74
+ }
75
+ const action = value.action;
76
+ if (typeof action !== "string" || !["ACKNOWLEDGE", "UPDATE_ASSIGNMENT", "RECORD_CORRECTION", "CLOSE", "REOPEN"].includes(action)) {
77
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "transition action is invalid");
78
+ }
79
+ const actionKeys = action === "RECORD_CORRECTION"
80
+ ? ["action", "incidentIdentity", "transitionKey", "expectedVersion", "reason", "correction"]
81
+ : action === "CLOSE"
82
+ ? ["action", "incidentIdentity", "transitionKey", "expectedVersion", "reason", "resolution"]
83
+ : ["action", "incidentIdentity", "transitionKey", "expectedVersion", "reason", "ownerId", "deadlineAt"];
84
+ if (!hasOnlyKeys(value, actionKeys)) {
85
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "transition has keys not valid for its action");
86
+ }
87
+ const base = {
88
+ incidentIdentity: normalizeIncidentIdentity(value.incidentIdentity),
89
+ transitionKey: boundedString(value.transitionKey, "transitionKey", MAX_IDENTITY_BYTES),
90
+ expectedVersion: safeInteger(value.expectedVersion, "expectedVersion"),
91
+ reason: boundedString(value.reason, "reason", MAX_LONG_TEXT_BYTES),
92
+ };
93
+ if (base.expectedVersion < 0) {
94
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "expectedVersion must not be negative");
95
+ }
96
+ if (action === "ACKNOWLEDGE" || action === "UPDATE_ASSIGNMENT" || action === "REOPEN") {
97
+ const ownerId = boundedString(value.ownerId, "ownerId", MAX_IDENTITY_BYTES);
98
+ const deadlineAt = safeInteger(value.deadlineAt, "deadlineAt");
99
+ if (ownerId === null)
100
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "ownerId must be non-empty");
101
+ return { ...base, action, ownerId, deadlineAt };
102
+ }
103
+ if (action === "RECORD_CORRECTION") {
104
+ return { ...base, action, correction: normalizeCorrection(value.correction) };
105
+ }
106
+ if (!isObject(value.resolution) || !hasOnlyKeys(value.resolution, ["kind", "explanation"])) {
107
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "resolution has unknown or missing keys");
108
+ }
109
+ if (value.resolution.kind === "CORRECTED") {
110
+ if (value.resolution.explanation !== undefined) {
111
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "CORRECTED resolution has unknown keys");
112
+ }
113
+ return { ...base, action, resolution: { kind: "CORRECTED" } };
114
+ }
115
+ if (value.resolution.kind === "ACCEPTED_AS_IS") {
116
+ const explanation = boundedString(value.resolution.explanation, "resolution.explanation", MAX_LONG_TEXT_BYTES);
117
+ if (explanation === null)
118
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "resolution.explanation is required");
119
+ return { ...base, action, resolution: { kind: "ACCEPTED_AS_IS", explanation } };
120
+ }
121
+ throw new IncidentLifecycleError("incident_invalid_request", 400, "resolution.kind is invalid");
122
+ }
123
+ function canonicalize(value) {
124
+ if (Array.isArray(value))
125
+ return value.map(canonicalize);
126
+ if (!isObject(value))
127
+ return value;
128
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
129
+ }
130
+ async function digestFor(request, actorId) {
131
+ const envelope = canonicalize({ action: request, actor: actorId });
132
+ const encoded = new TextEncoder().encode(JSON.stringify(envelope));
133
+ const digest = await crypto.subtle.digest("SHA-256", encoded);
134
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
135
+ }
136
+ function projectionFromRow(row, finding) {
137
+ if (row === null) {
138
+ return {
139
+ serviceId: finding.service_id,
140
+ incidentIdentity: finding.incident_identity,
141
+ lifecycleState: "OPEN",
142
+ ownerId: null,
143
+ deadlineAt: null,
144
+ correction: null,
145
+ closeResolution: null,
146
+ closeReason: null,
147
+ version: 0,
148
+ lastTransitionKey: "",
149
+ updatedAt: finding.last_observed_at,
150
+ };
151
+ }
152
+ return {
153
+ serviceId: row.service_id,
154
+ incidentIdentity: row.incident_identity,
155
+ lifecycleState: row.lifecycle_state,
156
+ ownerId: row.owner_id,
157
+ deadlineAt: row.deadline_at,
158
+ correction: row.correction_kind === null ? null : {
159
+ kind: row.correction_kind,
160
+ reference: row.correction_reference,
161
+ digest: row.correction_digest,
162
+ },
163
+ closeResolution: row.close_resolution,
164
+ closeReason: row.close_reason,
165
+ version: row.version,
166
+ lastTransitionKey: row.last_transition_key,
167
+ updatedAt: row.updated_at,
168
+ };
169
+ }
170
+ function findingFromRow(row) {
171
+ return {
172
+ serviceId: row.service_id,
173
+ incidentIdentity: row.incident_identity,
174
+ incidentType: row.incident_type,
175
+ partitionTag: row.partition_tag,
176
+ obligationSequence: row.obligation_sequence,
177
+ eventId: row.event_id,
178
+ eventDigest: row.event_digest,
179
+ state: row.finding_state,
180
+ firstObservedAt: row.first_observed_at,
181
+ lastObservedAt: row.last_observed_at,
182
+ };
183
+ }
184
+ function transitionFromRow(row) {
185
+ const before = projectionFromAudit(row, "before");
186
+ const after = projectionFromAudit(row, "after");
187
+ return {
188
+ transitionId: row.transition_id,
189
+ serviceId: row.service_id,
190
+ incidentIdentity: row.incident_identity,
191
+ transitionKey: row.transition_key,
192
+ requestDigest: row.request_digest,
193
+ action: row.action,
194
+ fromState: row.from_state,
195
+ toState: row.to_state,
196
+ fromVersion: row.from_version,
197
+ toVersion: row.to_version,
198
+ actorId: row.actor_id,
199
+ before,
200
+ after,
201
+ reason: row.reason,
202
+ occurredAt: row.occurred_at,
203
+ };
204
+ }
205
+ function projectionFromAudit(row, prefix) {
206
+ const state = prefix === "before" ? row.from_state : row.to_state;
207
+ const version = prefix === "before" ? row.from_version : row.to_version;
208
+ const ownerId = row[`${prefix}_owner_id`];
209
+ const correctionKind = row[`${prefix}_correction_kind`];
210
+ return {
211
+ serviceId: row.service_id,
212
+ incidentIdentity: row.incident_identity,
213
+ lifecycleState: state,
214
+ ownerId,
215
+ deadlineAt: row[`${prefix}_deadline_at`],
216
+ correction: correctionKind === null ? null : {
217
+ kind: correctionKind,
218
+ reference: row[`${prefix}_correction_reference`],
219
+ digest: row[`${prefix}_correction_digest`],
220
+ },
221
+ closeResolution: row[`${prefix}_close_resolution`],
222
+ closeReason: row[`${prefix}_close_reason`],
223
+ version,
224
+ lastTransitionKey: prefix === "after" ? row.transition_key : "",
225
+ updatedAt: row.occurred_at,
226
+ };
227
+ }
228
+ function active(state) {
229
+ return state !== "CLOSED";
230
+ }
231
+ function compareItems(left, right) {
232
+ if (left.overdue !== right.overdue)
233
+ return left.overdue ? -1 : 1;
234
+ if (left.unowned !== right.unowned)
235
+ return left.unowned ? -1 : 1;
236
+ if (left.finding.lastObservedAt !== right.finding.lastObservedAt)
237
+ return right.finding.lastObservedAt - left.finding.lastObservedAt;
238
+ return left.finding.incidentIdentity < right.finding.incidentIdentity ? -1 : left.finding.incidentIdentity > right.finding.incidentIdentity ? 1 : 0;
239
+ }
240
+ export class IncidentLifecycle {
241
+ database;
242
+ now;
243
+ constructor(database, now = () => Date.now()) {
244
+ this.database = database;
245
+ this.now = now;
246
+ }
247
+ static parseTransition(value) {
248
+ return normalizeRequest(value);
249
+ }
250
+ async finding(serviceId, incidentIdentity) {
251
+ const row = await this.database.prepare(`SELECT service_id, incident_identity, incident_type, partition_tag,
252
+ obligation_sequence, event_id, event_digest, state AS finding_state,
253
+ first_observed_at, last_observed_at
254
+ FROM serialized_dcb_completeness_findings
255
+ WHERE service_id = ? AND incident_identity = ?`).bind(serviceId, incidentIdentity).first();
256
+ if (row === null || row === undefined) {
257
+ throw new IncidentLifecycleError("incident_not_found", 404, "The completeness incident was not found");
258
+ }
259
+ return row;
260
+ }
261
+ async lifecycle(serviceId, incidentIdentity) {
262
+ const row = await this.database.prepare(`SELECT service_id, incident_identity, lifecycle_state, owner_id, deadline_at,
263
+ correction_kind, correction_reference, correction_digest,
264
+ close_resolution, close_reason, version, last_transition_key, updated_at
265
+ FROM serialized_dcb_incident_lifecycles
266
+ WHERE service_id = ? AND incident_identity = ?`).bind(serviceId, incidentIdentity).first();
267
+ return row === undefined ? null : row;
268
+ }
269
+ async transitions(serviceId, incidentIdentity) {
270
+ const result = await this.database.prepare(`SELECT * FROM serialized_dcb_incident_transitions
271
+ WHERE service_id = ? AND incident_identity = ?
272
+ ORDER BY transition_id ASC`).bind(serviceId, incidentIdentity).all();
273
+ return result.results.map(transitionFromRow);
274
+ }
275
+ async closeTime(serviceId, incidentIdentity) {
276
+ const row = await this.database.prepare(`SELECT occurred_at FROM serialized_dcb_incident_transitions
277
+ WHERE service_id = ? AND incident_identity = ? AND to_state = 'CLOSED'
278
+ ORDER BY transition_id DESC LIMIT 1`).bind(serviceId, incidentIdentity).first();
279
+ return row?.occurred_at ?? null;
280
+ }
281
+ async item(finding, row, at) {
282
+ const lifecycle = projectionFromRow(row, finding);
283
+ const closeTime = lifecycle.lifecycleState === "OPEN" && row === null ? null : await this.closeTime(finding.service_id, finding.incident_identity);
284
+ const observedAfterClose = closeTime !== null && finding.last_observed_at > closeTime;
285
+ const overdue = active(lifecycle.lifecycleState) && lifecycle.deadlineAt !== null && lifecycle.deadlineAt < at;
286
+ return {
287
+ finding: findingFromRow(finding),
288
+ lifecycle,
289
+ overdue,
290
+ unowned: lifecycle.ownerId === null,
291
+ observedAfterClose,
292
+ };
293
+ }
294
+ async list(serviceId, filters = {}, at = this.now()) {
295
+ const rows = await this.database.prepare(`SELECT service_id, incident_identity, incident_type, partition_tag,
296
+ obligation_sequence, event_id, event_digest, state AS finding_state,
297
+ first_observed_at, last_observed_at
298
+ FROM serialized_dcb_completeness_findings
299
+ WHERE service_id = ?`).bind(serviceId).all();
300
+ const items = [];
301
+ for (const finding of rows.results) {
302
+ const item = await this.item(finding, await this.lifecycle(serviceId, finding.incident_identity), at);
303
+ if (filters.state !== undefined && item.lifecycle.lifecycleState !== filters.state)
304
+ continue;
305
+ if (filters.owner !== undefined && item.lifecycle.ownerId !== filters.owner)
306
+ continue;
307
+ if (filters.unowned === true && !item.unowned)
308
+ continue;
309
+ if (filters.overdue === true && !item.overdue)
310
+ continue;
311
+ if (filters.observedAfterClose === true && !item.observedAfterClose)
312
+ continue;
313
+ items.push(item);
314
+ }
315
+ items.sort(compareItems);
316
+ const byState = {
317
+ OPEN: 0, ACKNOWLEDGED: 0, CORRECTION_RECORDED: 0, CLOSED: 0, REOPENED: 0,
318
+ };
319
+ for (const item of items)
320
+ byState[item.lifecycle.lifecycleState] += 1;
321
+ return {
322
+ items,
323
+ summary: {
324
+ total: items.length,
325
+ byState,
326
+ unowned: items.filter((item) => item.unowned).length,
327
+ overdue: items.filter((item) => item.overdue).length,
328
+ observedAfterClose: items.filter((item) => item.observedAfterClose).length,
329
+ },
330
+ };
331
+ }
332
+ async detail(serviceId, incidentIdentity, at = this.now()) {
333
+ const finding = await this.finding(serviceId, incidentIdentity);
334
+ const lifecycle = await this.lifecycle(serviceId, incidentIdentity);
335
+ const item = await this.item(finding, lifecycle, at);
336
+ return {
337
+ finding: item.finding,
338
+ lifecycle: item.lifecycle,
339
+ flags: { overdue: item.overdue, unowned: item.unowned, observedAfterClose: item.observedAfterClose },
340
+ transitions: await this.transitions(serviceId, incidentIdentity),
341
+ };
342
+ }
343
+ async transition(serviceId, value, actor) {
344
+ const request = normalizeRequest(value);
345
+ let actorId;
346
+ try {
347
+ actorId = boundedString(actor, "x-sdt-maintainer", MAX_IDENTITY_BYTES);
348
+ }
349
+ catch (caught) {
350
+ if (caught instanceof IncidentLifecycleError && caught.code === "incident_invalid_request") {
351
+ throw new IncidentLifecycleError("incident_invalid_actor", 400, caught.message);
352
+ }
353
+ throw caught;
354
+ }
355
+ if (actorId === null)
356
+ throw new IncidentLifecycleError("incident_invalid_actor", 400, "x-sdt-maintainer must be non-empty");
357
+ const finding = await this.finding(serviceId, request.incidentIdentity);
358
+ const digest = await digestFor(request, actorId);
359
+ const existing = await this.database.prepare(`SELECT * FROM serialized_dcb_incident_transitions
360
+ WHERE service_id = ? AND incident_identity = ? AND transition_key = ?`).bind(serviceId, request.incidentIdentity, request.transitionKey).first();
361
+ if (existing !== null && existing !== undefined) {
362
+ if (existing.request_digest !== digest) {
363
+ throw new IncidentLifecycleError("incident_idempotency_conflict", 409, "The transition key was already used with different content");
364
+ }
365
+ return { projection: transitionFromRow(existing).after, transition: transitionFromRow(existing), idempotent: true };
366
+ }
367
+ const priorRow = await this.lifecycle(serviceId, request.incidentIdentity);
368
+ const before = projectionFromRow(priorRow, finding);
369
+ if (before.version !== request.expectedVersion) {
370
+ throw new IncidentLifecycleError("incident_version_conflict", 409, "The incident version is stale");
371
+ }
372
+ const at = this.now();
373
+ const after = this.nextProjection(before, request, at);
374
+ const newVersion = before.version + 1;
375
+ const occurredAt = at;
376
+ const seed = this.database.prepare(`INSERT INTO serialized_dcb_incident_lifecycles
377
+ (service_id, incident_identity, lifecycle_state, version, last_transition_key, updated_at)
378
+ VALUES (?, ?, 'OPEN', 0, '', ?)
379
+ ON CONFLICT (service_id, incident_identity) DO NOTHING`).bind(serviceId, request.incidentIdentity, occurredAt);
380
+ const update = this.database.prepare(`UPDATE serialized_dcb_incident_lifecycles
381
+ SET lifecycle_state = ?, owner_id = ?, deadline_at = ?,
382
+ correction_kind = ?, correction_reference = ?, correction_digest = ?,
383
+ close_resolution = ?, close_reason = ?, version = ?,
384
+ last_transition_key = ?, updated_at = ?
385
+ WHERE service_id = ? AND incident_identity = ? AND version = ?
386
+ AND last_transition_key <> ?`).bind(after.lifecycleState, after.ownerId, after.deadlineAt, after.correction?.kind ?? null, after.correction?.reference ?? null, after.correction?.digest ?? null, after.closeResolution, after.closeReason, newVersion, request.transitionKey, occurredAt, serviceId, request.incidentIdentity, before.version, request.transitionKey);
387
+ const audit = this.database.prepare(`INSERT INTO serialized_dcb_incident_transitions
388
+ (service_id, incident_identity, transition_key, request_digest, action,
389
+ from_state, to_state, from_version, to_version, actor_id,
390
+ before_owner_id, before_deadline_at, before_correction_kind,
391
+ before_correction_reference, before_correction_digest, before_close_resolution,
392
+ before_close_reason, after_owner_id, after_deadline_at, after_correction_kind,
393
+ after_correction_reference, after_correction_digest, after_close_resolution,
394
+ after_close_reason, reason, occurred_at)
395
+ SELECT ?, ?, ?, ?, ?, ?, lifecycle_state, ?, version, ?,
396
+ ?, ?, ?, ?, ?, ?, ?, owner_id, deadline_at, correction_kind,
397
+ correction_reference, correction_digest, close_resolution, close_reason, ?, ?
398
+ FROM serialized_dcb_incident_lifecycles
399
+ WHERE service_id = ? AND incident_identity = ? AND version = ? AND last_transition_key = ?`).bind(serviceId, request.incidentIdentity, request.transitionKey, digest, request.action, before.lifecycleState, before.version, actorId, before.ownerId, before.deadlineAt, before.correction?.kind ?? null, before.correction?.reference ?? null, before.correction?.digest ?? null, before.closeResolution, before.closeReason, request.reason, occurredAt, serviceId, request.incidentIdentity, newVersion, request.transitionKey);
400
+ await this.database.batch([seed, update, audit]);
401
+ const inserted = await this.database.prepare(`SELECT * FROM serialized_dcb_incident_transitions
402
+ WHERE service_id = ? AND incident_identity = ? AND transition_key = ?`).bind(serviceId, request.incidentIdentity, request.transitionKey).first();
403
+ if (inserted === null || inserted === undefined) {
404
+ const competing = await this.database.prepare(`SELECT * FROM serialized_dcb_incident_transitions
405
+ WHERE service_id = ? AND incident_identity = ? AND transition_key = ?`).bind(serviceId, request.incidentIdentity, request.transitionKey).first();
406
+ if (competing !== null && competing !== undefined && competing.request_digest !== digest) {
407
+ throw new IncidentLifecycleError("incident_idempotency_conflict", 409, "The transition key was already used with different content");
408
+ }
409
+ if (competing !== null && competing !== undefined) {
410
+ const transition = transitionFromRow(competing);
411
+ return { projection: transition.after, transition, idempotent: true };
412
+ }
413
+ throw new IncidentLifecycleError("incident_version_conflict", 409, "The incident version is stale");
414
+ }
415
+ const transition = transitionFromRow(inserted);
416
+ return { projection: transition.after, transition, idempotent: false };
417
+ }
418
+ nextProjection(before, request, at) {
419
+ const laterDeadline = (deadlineAt) => {
420
+ if (deadlineAt <= at)
421
+ throw new IncidentLifecycleError("incident_invalid_deadline", 400, "deadlineAt must be in the future");
422
+ return deadlineAt;
423
+ };
424
+ let lifecycleState;
425
+ let ownerId = before.ownerId;
426
+ let deadlineAt = before.deadlineAt;
427
+ let correction = before.correction;
428
+ let closeResolution = before.closeResolution;
429
+ let closeReason = before.closeReason;
430
+ if (request.action === "ACKNOWLEDGE") {
431
+ if (before.lifecycleState !== "OPEN" && before.lifecycleState !== "REOPENED") {
432
+ throw new IncidentLifecycleError("incident_invalid_transition", 409, "The incident cannot be acknowledged from its current state");
433
+ }
434
+ lifecycleState = "ACKNOWLEDGED";
435
+ ownerId = request.ownerId;
436
+ deadlineAt = laterDeadline(request.deadlineAt);
437
+ correction = null;
438
+ closeResolution = null;
439
+ closeReason = null;
440
+ }
441
+ else if (request.action === "UPDATE_ASSIGNMENT") {
442
+ if (before.lifecycleState !== "ACKNOWLEDGED" && before.lifecycleState !== "CORRECTION_RECORDED") {
443
+ throw new IncidentLifecycleError("incident_invalid_transition", 409, "The incident cannot be reassigned from its current state");
444
+ }
445
+ lifecycleState = before.lifecycleState;
446
+ ownerId = request.ownerId;
447
+ deadlineAt = laterDeadline(request.deadlineAt);
448
+ }
449
+ else if (request.action === "RECORD_CORRECTION") {
450
+ if (before.lifecycleState !== "ACKNOWLEDGED") {
451
+ throw new IncidentLifecycleError("incident_invalid_transition", 409, "The incident must be acknowledged before correction evidence");
452
+ }
453
+ lifecycleState = "CORRECTION_RECORDED";
454
+ correction = request.correction;
455
+ }
456
+ else if (request.action === "CLOSE") {
457
+ if (before.ownerId === null || before.deadlineAt === null) {
458
+ throw new IncidentLifecycleError("incident_invalid_transition", 409, "The incident must have an owner and deadline before closure");
459
+ }
460
+ if (request.resolution.kind === "CORRECTED") {
461
+ if (before.lifecycleState !== "CORRECTION_RECORDED") {
462
+ throw new IncidentLifecycleError("incident_invalid_transition", 409, "A corrected closure requires recorded correction evidence");
463
+ }
464
+ lifecycleState = "CLOSED";
465
+ closeResolution = "CORRECTED";
466
+ closeReason = request.reason;
467
+ }
468
+ else {
469
+ if (before.lifecycleState !== "ACKNOWLEDGED") {
470
+ throw new IncidentLifecycleError("incident_invalid_transition", 409, "Accepted-as-is closure requires an acknowledged incident");
471
+ }
472
+ lifecycleState = "CLOSED";
473
+ closeResolution = "ACCEPTED_AS_IS";
474
+ closeReason = request.resolution.explanation;
475
+ correction = null;
476
+ }
477
+ }
478
+ else {
479
+ if (before.lifecycleState !== "CLOSED") {
480
+ throw new IncidentLifecycleError("incident_invalid_transition", 409, "Only a closed incident can be reopened");
481
+ }
482
+ lifecycleState = "REOPENED";
483
+ ownerId = request.ownerId;
484
+ deadlineAt = laterDeadline(request.deadlineAt);
485
+ correction = null;
486
+ closeResolution = null;
487
+ closeReason = null;
488
+ }
489
+ return {
490
+ ...before,
491
+ lifecycleState,
492
+ ownerId,
493
+ deadlineAt,
494
+ correction,
495
+ closeResolution,
496
+ closeReason,
497
+ version: before.version + 1,
498
+ lastTransitionKey: request.transitionKey,
499
+ updatedAt: at,
500
+ };
501
+ }
502
+ }
503
+ export { LIFECYCLE_STATES };
@@ -61,6 +61,73 @@ export type GlobalCompletenessScanResult = Readonly<{
61
61
  kind: "FAILED";
62
62
  error: string;
63
63
  }>;
64
+ export type IncidentLifecycleState = "OPEN" | "ACKNOWLEDGED" | "CORRECTION_RECORDED" | "CLOSED" | "REOPENED";
65
+ export type IncidentCorrection = Readonly<{
66
+ kind: "event" | "receipt";
67
+ reference: string;
68
+ digest: `sha256:${string}`;
69
+ }>;
70
+ export type IncidentCloseResolution = Readonly<{
71
+ kind: "CORRECTED";
72
+ }> | Readonly<{
73
+ kind: "ACCEPTED_AS_IS";
74
+ explanation: string;
75
+ }>;
76
+ export interface IncidentTransitionBase {
77
+ readonly incidentIdentity: string;
78
+ readonly transitionKey: string;
79
+ readonly expectedVersion: number;
80
+ readonly reason: string;
81
+ }
82
+ export type IncidentTransitionRequest = (IncidentTransitionBase & Readonly<{
83
+ action: "ACKNOWLEDGE";
84
+ ownerId: string;
85
+ deadlineAt: number;
86
+ }>) | (IncidentTransitionBase & Readonly<{
87
+ action: "UPDATE_ASSIGNMENT";
88
+ ownerId: string;
89
+ deadlineAt: number;
90
+ }>) | (IncidentTransitionBase & Readonly<{
91
+ action: "RECORD_CORRECTION";
92
+ correction: IncidentCorrection;
93
+ }>) | (IncidentTransitionBase & Readonly<{
94
+ action: "CLOSE";
95
+ resolution: IncidentCloseResolution;
96
+ }>) | (IncidentTransitionBase & Readonly<{
97
+ action: "REOPEN";
98
+ ownerId: string;
99
+ deadlineAt: number;
100
+ }>);
101
+ export interface IncidentLifecycleProjection {
102
+ readonly serviceId: string;
103
+ readonly incidentIdentity: string;
104
+ readonly lifecycleState: IncidentLifecycleState;
105
+ readonly ownerId: string | null;
106
+ readonly deadlineAt: number | null;
107
+ readonly correction: IncidentCorrection | null;
108
+ readonly closeResolution: "CORRECTED" | "ACCEPTED_AS_IS" | null;
109
+ readonly closeReason: string | null;
110
+ readonly version: number;
111
+ readonly lastTransitionKey: string;
112
+ readonly updatedAt: number;
113
+ }
114
+ export interface IncidentTransitionRecord {
115
+ readonly transitionId: number;
116
+ readonly serviceId: string;
117
+ readonly incidentIdentity: string;
118
+ readonly transitionKey: string;
119
+ readonly requestDigest: string;
120
+ readonly action: IncidentTransitionRequest["action"];
121
+ readonly fromState: IncidentLifecycleState;
122
+ readonly toState: IncidentLifecycleState;
123
+ readonly fromVersion: number;
124
+ readonly toVersion: number;
125
+ readonly actorId: string;
126
+ readonly before: IncidentLifecycleProjection;
127
+ readonly after: IncidentLifecycleProjection;
128
+ readonly reason: string;
129
+ readonly occurredAt: number;
130
+ }
64
131
  /** The sole temporary operational disposition; policy decisions remain open. */
65
132
  export declare const GLOBAL_COMPLETENESS_INTERIM_DISPOSITION: "BLOCK/UNSETTLED";
66
133
  /**
package/dist/cosmos.d.ts CHANGED
@@ -1,11 +1,41 @@
1
+ /**
2
+ * @experimental Cosmos support is an explicit opt-in provider and carries no
3
+ * production-support or availability promise.
4
+ */
1
5
  import { CosmosEventStore, type CosmosDocumentClient, type CosmosStoreOptions } from "./store/CosmosEventStore";
2
6
  import type { StoreProvider } from "./store/provider";
3
7
  import { type BootstrapStoreAdapter } from "./bootstrap/BootstrapStoreAdapter";
4
- export { CosmosClientError, CosmosEventStore, CosmosRestClient, DEFAULT_COSMOS_CONTAINERS, compareCosmosSuid, } from "./store/CosmosEventStore";
5
- export type { CosmosContainerNames, CosmosDocumentClient, CosmosDocumentRecord, CosmosStoreOptions, CosmosWriteBoundary, } from "./store/CosmosEventStore";
8
+ /** @experimental */
9
+ export { CosmosClientError } from "./store/CosmosEventStore";
10
+ /** @experimental */
11
+ export { CosmosEventStore } from "./store/CosmosEventStore";
12
+ /** @experimental */
13
+ export { CosmosRestClient } from "./store/CosmosEventStore";
14
+ /** @experimental */
15
+ export { DEFAULT_COSMOS_CONTAINERS } from "./store/CosmosEventStore";
16
+ /** @experimental */
17
+ export { compareCosmosSuid } from "./store/CosmosEventStore";
18
+ /** @experimental */
19
+ export type { CosmosContainerNames } from "./store/CosmosEventStore";
20
+ /** @experimental */
21
+ export type { CosmosDocumentClient } from "./store/CosmosEventStore";
22
+ /** @experimental */
23
+ export type { CosmosDocumentRecord } from "./store/CosmosEventStore";
24
+ /** @experimental */
25
+ export type { CosmosStoreOptions } from "./store/CosmosEventStore";
26
+ /** @experimental */
27
+ export type { CosmosWriteBoundary } from "./store/CosmosEventStore";
28
+ /** @experimental Cosmos is an explicit, opt-in provider. */
29
+ export declare class CosmosConfigurationError extends Error {
30
+ readonly missingBindings: readonly string[];
31
+ readonly code: "COSMOS_CONFIGURATION_INVALID";
32
+ constructor(missingBindings: readonly string[], message: string);
33
+ }
34
+ /** @experimental Configuration for the optional Cosmos provider. */
6
35
  export interface CosmosStoreProviderConfig extends Omit<CosmosStoreOptions, "client"> {
7
36
  readonly client?: CosmosDocumentClient;
8
37
  }
9
- /** Explicit opt-in provider; the default runtime composition never reads Cosmos config. */
10
- export declare function createCosmosStoreProvider(config: CosmosStoreProviderConfig): StoreProvider;
38
+ /** @experimental Explicit opt-in provider; environment values alone never select it. */
39
+ export declare function createCosmosStoreProvider(config?: CosmosStoreProviderConfig): StoreProvider;
40
+ /** @experimental Explicit bootstrap adapter for the opt-in Cosmos provider. */
11
41
  export declare function createCosmosBootstrapAdapter(store: CosmosEventStore): BootstrapStoreAdapter;