@substrat-run/adapter-cloudflare 0.112.0 → 0.114.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.
@@ -1 +1 @@
1
- {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAgDnD,OAAO,EA0BL,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAsCtB,MAAM,sBAAsB,CAAC;AAiB9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAyZD;uEACuE;AACvE,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AA6DD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAy2FjE"}
1
+ {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAiDnD,OAAO,EA0BL,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAsCtB,MAAM,sBAAsB,CAAC;AAmB9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAiaD;uEACuE;AACvE,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AA6DD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CA88FjE"}
package/dist/scope-do.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, } from '@substrat-run/contracts';
2
+ import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
3
3
  import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, } from '@substrat-run/kernel';
4
4
  import { OperationQueue } from './serialization.js';
5
5
  import { doScopedSql } from './sql.js';
6
- import { facetEvents, readHistory, walkEventCause, walkEventEffects } from '@substrat-run/kernel';
6
+ import { facetEvents, readDeadLetters, readHistory, readInvocation, walkEventCause, walkEventEffects } from '@substrat-run/kernel';
7
7
  import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
8
8
  /**
9
9
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
@@ -71,6 +71,12 @@ const KERNEL_DDL = `
71
71
  -- nothing was being delivered (an operation emitted it directly), or the row
72
72
  -- predates the column.
73
73
  caused_by TEXT,
74
+ -- #1237: the INVOCATION this event belongs to, minted by the transport and carried
75
+ -- on InvokeOptions. The spine could say what caused an event and which operation
76
+ -- emitted it, and still not say which two events came from the same call — the
77
+ -- runtime's request id is stamped by the log platform at ingestion, so no vertical
78
+ -- code can read it. NULL = the transport minted none, or the row predates the column.
79
+ invocation_id TEXT,
74
80
  drained_at TEXT
75
81
  );
76
82
  -- #1232: the freshness evaluator's read - MAX(occurred_at) per type, every pass,
@@ -771,6 +777,10 @@ export function defineScopeDO(modules, bareOps) {
771
777
  // exists on the outbox but not on the envelope `parseOutboxRow` returns,
772
778
  // whose `domainEvent.parse` strips anything it does not declare.
773
779
  causedBy: r.caused_by ?? null,
780
+ // …and the invocation, for the same reason again: a lake that kept cause and
781
+ // dropped the call could say what set an event off and never which request did
782
+ // it, which is the grouping a trace is built on.
783
+ invocationId: r.invocation_id ?? null,
774
784
  }));
775
785
  }
776
786
  /**
@@ -802,6 +812,37 @@ export function defineScopeDO(modules, bareOps) {
802
812
  return drained;
803
813
  });
804
814
  }
815
+ /**
816
+ * Reopen rows stamped strictly before `drainedBefore`, so the drain ships them again
817
+ * (#1334). The kernel contract says why the instant is required; this is the mechanism.
818
+ *
819
+ * Counted BEFORE the update, on `markEventsDrained`'s reasoning: `rowsWritten` includes
820
+ * index entries, and `_substrat_outbox_drained` leads with `drained_at`, so clearing N
821
+ * rows reports more than N writes. The queue has this scope to itself, so the count
822
+ * taken here is the count the update goes on to change.
823
+ *
824
+ * BOUNDED at `REDRAIN_BATCH`, and the caller loops until this returns 0. The outbox is
825
+ * never pruned, so "every stamped row before an instant" grows with the scope's whole
826
+ * lifetime — and this runs inside ONE Durable Object request, against a fixed budget.
827
+ * Unbounded, a big enough scope would exceed it, and exceed it again on every retry, so
828
+ * the one scope that most needs reopening could never make progress. Oldest first, so a
829
+ * partial run leaves a prefix of the window rather than holes scattered through it.
830
+ */
831
+ async redrainEvents(drainedBefore) {
832
+ return await this.queue.enqueue(() => {
833
+ const ids = this.sql
834
+ .exec(`SELECT id FROM _substrat_outbox
835
+ WHERE drained_at IS NOT NULL AND drained_at < ?
836
+ ORDER BY id LIMIT ?`, drainedBefore, REDRAIN_BATCH)
837
+ .toArray();
838
+ if (ids.length === 0)
839
+ return 0;
840
+ // By id, not by the window again: the rows just chosen are exactly the rows
841
+ // cleared, so the count returned cannot drift from what the statement touched.
842
+ this.sql.exec(`UPDATE _substrat_outbox SET drained_at = NULL WHERE id IN (${ids.map(() => '?').join(',')})`, ...ids.map((r) => r.id));
843
+ return ids.length;
844
+ });
845
+ }
805
846
  /**
806
847
  * Facet this scope's own outbox (#1239) — `facetEvents`, which is the
807
848
  * sanctioned read: an erased payload yields the same NULL a missing field
@@ -834,6 +875,14 @@ export function defineScopeDO(modules, bareOps) {
834
875
  eventEffects(input) {
835
876
  return walkEventEffects({ sql: doScopedSql(this.sql) }, input.eventId, input.maxNodes);
836
877
  }
878
+ /** #1237: everything one call emitted, inside the DO where the outbox lives. */
879
+ invocationEvents(input) {
880
+ return readInvocation({ sql: doScopedSql(this.sql) }, input.invocationId, input.limit);
881
+ }
882
+ /** #1525: every delivery in this scope that gave up, inside the DO where both tables live. */
883
+ deadLetters(input) {
884
+ return readDeadLetters({ sql: doScopedSql(this.sql) }, { limit: input.limit, cursor: input.cursor });
885
+ }
837
886
  migrationBookmarks(limit = 20) {
838
887
  return this.sql
839
888
  .exec(`SELECT bookmark, taken_at, pending FROM _substrat_migration_bookmarks
@@ -1098,153 +1147,176 @@ export function defineScopeDO(modules, bareOps) {
1098
1147
  // Fingerprinted from the PARSED input (defaults applied), before the queue:
1099
1148
  // a pure hash of what the caller sent has no business inside a transaction.
1100
1149
  const fingerprint = idempotencyKey === undefined ? undefined : await requestFingerprint(operation, parsed);
1101
- return this.queue.enqueue(async () => {
1102
- let result;
1103
- let committedVersion = null;
1104
- // #116: set when this invocation was answered from a recording rather
1105
- // than run. Read after the transaction, where it decides both the
1106
- // envelope's acknowledgement and whether there is anything to dispatch.
1107
- let replayed = false;
1108
- // #458: how many platform intents THIS invoke enqueued. Counted inside the
1109
- // transaction, reported only after commit — a rolled-back intent is no signal.
1110
- // The envelope return (below) is the DO↔coordinator wire for it; both sides
1111
- // live in this package and deploy as one script, so the shape never skews.
1112
- const signals = { platformRequests: 0 };
1113
- /**
1114
- * K-42: how a read-only session is made to BE read-only.
1115
- *
1116
- * Thrown on success, so the transaction never commits — the same
1117
- * mechanism `queryScope`'s console uses, for the same reason: the DO's
1118
- * `exec` exposes no read-only flag, so refusing to commit is the only
1119
- * enforcement that survives a handler writing rows with plain SQL.
1120
- * `ctx.emit` and the other effecting verbs refuse outright as well; that
1121
- * is the half a support engineer sees, this is the half that holds.
1122
- */
1123
- const rollback = new Error('read-only impersonation rollback');
1124
- // The async transaction is the K-4 boundary: guards + handler + emits
1125
- // commit together, or a throw (from either) rolls domain writes AND
1126
- // emitted events back as one — verified across `await` in workerd.
1150
+ // `return await`, not a bare return: the work is QUEUED, and `try { return p }`
1151
+ // runs its `finally` when the RETURN executes rather than when `p` settles — so
1152
+ // the invocation id was cleared before the queued body had emitted anything.
1153
+ return await this.queue.enqueue(async () => {
1154
+ // #1237: the invocation this call belongs to, for the duration of it.
1155
+ //
1156
+ // Set INSIDE the queued body, which is the only region where one call holds the
1157
+ // DO to itself. The input gate reopens around every await, and there are two
1158
+ // before this point (`ensureMigrations`, `requestFingerprint`) — so assigning at
1159
+ // the top of the RPC let a second call overwrite the field while the first was
1160
+ // suspended, and the first would then emit under the second's id and clear it on
1161
+ // the way out. `OperationQueue` is what makes this a plain field rather than a
1162
+ // stack: the bodies do not interleave, so set-and-clear here brackets exactly
1163
+ // one call. Same placement as the SQLite adapter's actor task, for this reason.
1164
+ this.invocationId = invokeOptions?.invocationId ?? null;
1127
1165
  try {
1128
- await this.ctx.storage.transaction(async () => {
1129
- const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation);
1130
- // #116: a retry is answered from the recording, and nothing else runs
1131
- // — not the guards, not the handler, not the permission check inside
1132
- // it. Keyed by SUBJECT, so a caller only ever reaches its own
1133
- // responses; `idempotency.ts` states what that does not promise.
1134
- if (idempotencyKey !== undefined && fingerprint !== undefined) {
1135
- const lookup = idempotencyLookupQuery(idempotencySubjectRef, idempotencyKey);
1136
- const prior = this.sql.exec(lookup.sql, ...lookup.params).toArray()[0];
1137
- if (prior) {
1138
- const replay = replayFor(idempotencyKey, fingerprint, prior);
1139
- result = replay.result;
1140
- // Only a guarded operation may report a tag (#129).
1141
- if (guardedRef)
1142
- committedVersion = replay.entityVersion;
1143
- replayed = true;
1144
- return;
1166
+ let result;
1167
+ let committedVersion = null;
1168
+ // #116: set when this invocation was answered from a recording rather
1169
+ // than run. Read after the transaction, where it decides both the
1170
+ // envelope's acknowledgement and whether there is anything to dispatch.
1171
+ let replayed = false;
1172
+ // #458: how many platform intents THIS invoke enqueued. Counted inside the
1173
+ // transaction, reported only after commit — a rolled-back intent is no signal.
1174
+ // The envelope return (below) is the DO↔coordinator wire for it; both sides
1175
+ // live in this package and deploy as one script, so the shape never skews.
1176
+ const signals = { platformRequests: 0 };
1177
+ /**
1178
+ * K-42: how a read-only session is made to BE read-only.
1179
+ *
1180
+ * Thrown on success, so the transaction never commits — the same
1181
+ * mechanism `queryScope`'s console uses, for the same reason: the DO's
1182
+ * `exec` exposes no read-only flag, so refusing to commit is the only
1183
+ * enforcement that survives a handler writing rows with plain SQL.
1184
+ * `ctx.emit` and the other effecting verbs refuse outright as well; that
1185
+ * is the half a support engineer sees, this is the half that holds.
1186
+ */
1187
+ const rollback = new Error('read-only impersonation rollback');
1188
+ // The async transaction is the K-4 boundary: guards + handler + emits
1189
+ // commit together, or a throw (from either) rolls domain writes AND
1190
+ // emitted events back as one — verified across `await` in workerd.
1191
+ try {
1192
+ await this.ctx.storage.transaction(async () => {
1193
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation);
1194
+ // #116: a retry is answered from the recording, and nothing else runs
1195
+ // — not the guards, not the handler, not the permission check inside
1196
+ // it. Keyed by SUBJECT, so a caller only ever reaches its own
1197
+ // responses; `idempotency.ts` states what that does not promise.
1198
+ if (idempotencyKey !== undefined && fingerprint !== undefined) {
1199
+ const lookup = idempotencyLookupQuery(idempotencySubjectRef, idempotencyKey);
1200
+ const prior = this.sql.exec(lookup.sql, ...lookup.params).toArray()[0];
1201
+ if (prior) {
1202
+ const replay = replayFor(idempotencyKey, fingerprint, prior);
1203
+ result = replay.result;
1204
+ // Only a guarded operation may report a tag (#129).
1205
+ if (guardedRef)
1206
+ committedVersion = replay.entityVersion;
1207
+ replayed = true;
1208
+ return;
1209
+ }
1145
1210
  }
1211
+ // #129: snapshot the version BEFORE the handler, compare AFTER it.
1212
+ // Before, because the handler's own `emit` moves it; after, because the
1213
+ // permission check lives inside the handler and must answer first — a
1214
+ // precondition evaluated ahead of it turns the operation into a version
1215
+ // oracle for a principal who may not read the entity at all. The full
1216
+ // reasoning is on the pure adapter, which does the identical thing.
1217
+ const seen = guardedRef && invokeOptions?.ifMatch !== undefined
1218
+ ? this.versionAt(guardedRef)
1219
+ : undefined;
1220
+ await this.runGuards(operation, ctx, parsed);
1221
+ result = await handler(ctx, parsed);
1222
+ if (guardedRef && invokeOptions?.ifMatch !== undefined) {
1223
+ assertIfMatch(guardedRef, invokeOptions.ifMatch, seen ?? null);
1224
+ }
1225
+ // The tag describes the row as THIS write left it, so it is read after
1226
+ // the handler and still inside the transaction.
1227
+ if (guardedRef)
1228
+ committedVersion = this.versionAt(guardedRef);
1229
+ // #116: recorded INSIDE the transaction, which is what makes a failed
1230
+ // request retried rather than replayed — it rolls back with the writes
1231
+ // it describes. The prune rides along, on the only path that adds a row.
1232
+ if (idempotencyKey !== undefined && fingerprint !== undefined) {
1233
+ const at = new Date().toISOString();
1234
+ const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint, result, committedVersion, at);
1235
+ this.sql.exec(record.sql, ...record.params);
1236
+ const prune = idempotencyPruneStatement(at);
1237
+ this.sql.exec(prune.sql, ...prune.params);
1238
+ }
1239
+ if (impersonation?.mode === 'read-only')
1240
+ throw rollback;
1241
+ });
1242
+ }
1243
+ catch (err) {
1244
+ // The read-only unwind, not a failure: `result` was assigned before the
1245
+ // throw and is the answer, while every row the handler wrote is gone.
1246
+ //
1247
+ // The acknowledgements ride along unchanged, because they are about
1248
+ // whether this DO UNDERSTOOD the arguments, not about what committed —
1249
+ // and a coordinator that sent `If-Match` to a read-only session must not
1250
+ // be told the host is too old to have evaluated it. The version reported
1251
+ // is the rolled-back one, which is the honest answer: nothing moved.
1252
+ if (err === rollback) {
1253
+ return {
1254
+ result,
1255
+ platformRequests: 0,
1256
+ impersonation: { honoured: true },
1257
+ ...(idempotencyKey !== undefined
1258
+ ? { idempotency: { keyHonoured: true, replayed } }
1259
+ : {}),
1260
+ ...(guardedRef
1261
+ ? {
1262
+ concurrency: {
1263
+ version: committedVersion,
1264
+ ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1265
+ },
1266
+ }
1267
+ : {}),
1268
+ };
1146
1269
  }
1147
- // #129: snapshot the version BEFORE the handler, compare AFTER it.
1148
- // Before, because the handler's own `emit` moves it; after, because the
1149
- // permission check lives inside the handler and must answer first — a
1150
- // precondition evaluated ahead of it turns the operation into a version
1151
- // oracle for a principal who may not read the entity at all. The full
1152
- // reasoning is on the pure adapter, which does the identical thing.
1153
- const seen = guardedRef && invokeOptions?.ifMatch !== undefined
1154
- ? this.versionAt(guardedRef)
1155
- : undefined;
1156
- await this.runGuards(operation, ctx, parsed);
1157
- result = await handler(ctx, parsed);
1158
- if (guardedRef && invokeOptions?.ifMatch !== undefined) {
1159
- assertIfMatch(guardedRef, invokeOptions.ifMatch, seen ?? null);
1160
- }
1161
- // The tag describes the row as THIS write left it, so it is read after
1162
- // the handler and still inside the transaction.
1163
- if (guardedRef)
1164
- committedVersion = this.versionAt(guardedRef);
1165
- // #116: recorded INSIDE the transaction, which is what makes a failed
1166
- // request retried rather than replayed — it rolls back with the writes
1167
- // it describes. The prune rides along, on the only path that adds a row.
1168
- if (idempotencyKey !== undefined && fingerprint !== undefined) {
1169
- const at = new Date().toISOString();
1170
- const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint, result, committedVersion, at);
1171
- this.sql.exec(record.sql, ...record.params);
1172
- const prune = idempotencyPruneStatement(at);
1173
- this.sql.exec(prune.sql, ...prune.params);
1270
+ // K-35: the transaction has rolled back; record a refused check now, as its own
1271
+ // write (outside that transaction), so the denial survives the rollback.
1272
+ if (err instanceof PermissionDenied) {
1273
+ this.recordDenial(systemModuleId
1274
+ ? { kind: 'system', id: systemModuleId }
1275
+ : connectionId
1276
+ ? { kind: 'connection', id: connectionId }
1277
+ : { kind: 'principal', id: principal }, tenantId, operation, err, impersonation);
1174
1278
  }
1175
- if (impersonation?.mode === 'read-only')
1176
- throw rollback;
1177
- });
1178
- }
1179
- catch (err) {
1180
- // The read-only unwind, not a failure: `result` was assigned before the
1181
- // throw and is the answer, while every row the handler wrote is gone.
1182
- //
1183
- // The acknowledgements ride along unchanged, because they are about
1184
- // whether this DO UNDERSTOOD the arguments, not about what committed —
1185
- // and a coordinator that sent `If-Match` to a read-only session must not
1186
- // be told the host is too old to have evaluated it. The version reported
1187
- // is the rolled-back one, which is the honest answer: nothing moved.
1188
- if (err === rollback) {
1189
- return {
1190
- result,
1191
- platformRequests: 0,
1192
- impersonation: { honoured: true },
1193
- ...(idempotencyKey !== undefined
1194
- ? { idempotency: { keyHonoured: true, replayed } }
1195
- : {}),
1196
- ...(guardedRef
1197
- ? {
1198
- concurrency: {
1199
- version: committedVersion,
1200
- ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1201
- },
1202
- }
1203
- : {}),
1204
- };
1205
- }
1206
- // K-35: the transaction has rolled back; record a refused check now, as its own
1207
- // write (outside that transaction), so the denial survives the rollback.
1208
- if (err instanceof PermissionDenied) {
1209
- this.recordDenial(systemModuleId
1210
- ? { kind: 'system', id: systemModuleId }
1211
- : connectionId
1212
- ? { kind: 'connection', id: connectionId }
1213
- : { kind: 'principal', id: principal }, tenantId, operation, err, impersonation);
1279
+ // The ORIGINAL error, deliberately: `invoke` flattens it for the envelope
1280
+ // (which keeps its code and extensions) or rewraps it for the legacy throw
1281
+ // path. Collapsing it here would lose the structure before either can look.
1282
+ throw err;
1214
1283
  }
1215
- // The ORIGINAL error, deliberately: `invoke` flattens it for the envelope
1216
- // (which keeps its code and extensions) or rewraps it for the legacy throw
1217
- // path. Collapsing it here would lose the structure before either can look.
1218
- throw err;
1284
+ // Post-commit: drain the outbox to consumers, each delivery its own txn.
1285
+ // Skipped on a replay: nothing was written, so there is nothing this
1286
+ // invocation added to drain. Anything the ORIGINAL left undrained is the
1287
+ // outbox's own retry backstop, which is what that backstop is for.
1288
+ if (!replayed)
1289
+ await this.dispatch(tenantId, scopeId);
1290
+ return {
1291
+ result,
1292
+ platformRequests: signals.platformRequests,
1293
+ ...(impersonation ? { impersonation: { honoured: true } } : {}),
1294
+ // The acknowledgement the coordinator's skew check reads (#116), on the
1295
+ // same reasoning as `ifMatchChecked` below and with a sharper failure: a
1296
+ // DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
1297
+ // return 200, which is the duplicate the header was sent to prevent.
1298
+ ...(idempotencyKey !== undefined
1299
+ ? { idempotency: { keyHonoured: true, replayed } }
1300
+ : {}),
1301
+ // The acknowledgement the coordinator's skew check reads. Present only
1302
+ // for a guarded operation, so an unguarded one costs nothing.
1303
+ ...(guardedRef
1304
+ ? {
1305
+ concurrency: {
1306
+ version: committedVersion,
1307
+ ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1308
+ },
1309
+ }
1310
+ : {}),
1311
+ };
1312
+ }
1313
+ finally {
1314
+ // Cleared on BOTH paths. The DO outlives the request, so a value left set here
1315
+ // is read by whatever runs next — an alarm-driven drain, a consumer retry —
1316
+ // and stamps its events with a call they had nothing to do with. A wrong
1317
+ // recorded fact, which is worse than the honest NULL this column uses.
1318
+ this.invocationId = null;
1219
1319
  }
1220
- // Post-commit: drain the outbox to consumers, each delivery its own txn.
1221
- // Skipped on a replay: nothing was written, so there is nothing this
1222
- // invocation added to drain. Anything the ORIGINAL left undrained is the
1223
- // outbox's own retry backstop, which is what that backstop is for.
1224
- if (!replayed)
1225
- await this.dispatch(tenantId, scopeId);
1226
- return {
1227
- result,
1228
- platformRequests: signals.platformRequests,
1229
- ...(impersonation ? { impersonation: { honoured: true } } : {}),
1230
- // The acknowledgement the coordinator's skew check reads (#116), on the
1231
- // same reasoning as `ifMatchChecked` below and with a sharper failure: a
1232
- // DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
1233
- // return 200, which is the duplicate the header was sent to prevent.
1234
- ...(idempotencyKey !== undefined
1235
- ? { idempotency: { keyHonoured: true, replayed } }
1236
- : {}),
1237
- // The acknowledgement the coordinator's skew check reads. Present only
1238
- // for a guarded operation, so an unguarded one costs nothing.
1239
- ...(guardedRef
1240
- ? {
1241
- concurrency: {
1242
- version: committedVersion,
1243
- ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1244
- },
1245
- }
1246
- : {}),
1247
- };
1248
1320
  });
1249
1321
  }
1250
1322
  // -- attachments (#473): the metadata half of the attachment surface --------
@@ -1904,6 +1976,9 @@ export function defineScopeDO(modules, bareOps) {
1904
1976
  // null is honestly "unrecorded" for every legacy row — nothing can go back and
1905
1977
  // decide what a past consumer was reacting to.
1906
1978
  'ALTER TABLE _substrat_outbox ADD COLUMN caused_by TEXT',
1979
+ // #1237: the invocation column on a DO created before it. Nullable, and the
1980
+ // null is honestly "none was carried" for every legacy row.
1981
+ 'ALTER TABLE _substrat_outbox ADD COLUMN invocation_id TEXT',
1907
1982
  ]) {
1908
1983
  try {
1909
1984
  this.sql.exec(alter);
@@ -1913,6 +1988,19 @@ export function defineScopeDO(modules, bareOps) {
1913
1988
  throw err;
1914
1989
  }
1915
1990
  }
1991
+ // #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
1992
+ // that is never pruned. No index leads with invocation_id, so without this one SQLite
1993
+ // walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a
1994
+ // recent invocation costs the scope's lifetime event count. The trailing id gives the
1995
+ // ORDER BY for free, as it does on `_substrat_outbox_drained`.
1996
+ //
1997
+ // HERE, after the column is ensured, and deliberately NOT in KERNEL_DDL beside the
1998
+ // other outbox indexes. KERNEL_DDL runs FIRST on every wake, and on a scope created
1999
+ // before #1237 its `CREATE TABLE IF NOT EXISTS` does not add the column — so an index
2000
+ // naming invocation_id there throws "no such column" and every existing scope fails to
2001
+ // boot. `lint:spine-ddl` compares KERNEL_DDL's indexes only, so this one is held to
2002
+ // both adapters by the query-plan test rather than by that gate.
2003
+ this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
1916
2004
  }
1917
2005
  async importDump(tables, destScopeId) {
1918
2006
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
@@ -2103,6 +2191,15 @@ export function defineScopeDO(modules, bareOps) {
2103
2191
  * another scope's emit the moment a consumer awaits.
2104
2192
  */
2105
2193
  causedBy = null;
2194
+ /**
2195
+ * #1237: the invocation currently running in this DO, or null.
2196
+ *
2197
+ * DO-local, like `causedBy` and for a simpler reason: a Durable Object IS one
2198
+ * scope, so there is no other scope's call to confuse it with. It still has to be
2199
+ * cleared, because the DO outlives the request and a value left set would stamp a
2200
+ * later alarm-driven drain with a call it had nothing to do with.
2201
+ */
2202
+ invocationId = null;
2106
2203
  async dispatch(tenantId, scopeId) {
2107
2204
  for (let round = 0; round < 50; round++) {
2108
2205
  let deliveredAny = false;
@@ -2335,8 +2432,8 @@ export function defineScopeDO(modules, bareOps) {
2335
2432
  sql.exec(`INSERT INTO _substrat_outbox
2336
2433
  (id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
2337
2434
  entity_type, entity_id, pii_class, subject_id, authorization,
2338
- impersonation, operation, version, caused_by, payload)
2339
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.operation ?? null,
2435
+ impersonation, operation, version, caused_by, invocation_id, payload)
2436
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.operation ?? null,
2340
2437
  // #1242: script configuration, not envelope data — the version is a fact
2341
2438
  // about the deploy, so it never rides `DomainEvent` for module code to
2342
2439
  // branch on; it exists for the observability joins the column serves.
@@ -2344,7 +2441,9 @@ export function defineScopeDO(modules, bareOps) {
2344
2441
  // #1237: whatever delivery is in flight, if any — read off the DO the same
2345
2442
  // way the version is read off its env. A fact about the surrounding
2346
2443
  // dispatch, never envelope data module code could set or branch on.
2347
- this.causedBy, full.payload === undefined ? null : JSON.stringify(full.payload));
2444
+ this.causedBy,
2445
+ // #1237: a fact about the surrounding CALL, like the version above.
2446
+ this.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
2348
2447
  },
2349
2448
  requestPlatform: (request) => {
2350
2449
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');