@substrat-run/adapter-cloudflare 0.112.0 → 0.113.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;AAkB9B;;;;;;;;;;;;;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,CAy8FjE"}
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, 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,10 @@ 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
+ }
837
882
  migrationBookmarks(limit = 20) {
838
883
  return this.sql
839
884
  .exec(`SELECT bookmark, taken_at, pending FROM _substrat_migration_bookmarks
@@ -1098,153 +1143,176 @@ export function defineScopeDO(modules, bareOps) {
1098
1143
  // Fingerprinted from the PARSED input (defaults applied), before the queue:
1099
1144
  // a pure hash of what the caller sent has no business inside a transaction.
1100
1145
  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.
1146
+ // `return await`, not a bare return: the work is QUEUED, and `try { return p }`
1147
+ // runs its `finally` when the RETURN executes rather than when `p` settles — so
1148
+ // the invocation id was cleared before the queued body had emitted anything.
1149
+ return await this.queue.enqueue(async () => {
1150
+ // #1237: the invocation this call belongs to, for the duration of it.
1151
+ //
1152
+ // Set INSIDE the queued body, which is the only region where one call holds the
1153
+ // DO to itself. The input gate reopens around every await, and there are two
1154
+ // before this point (`ensureMigrations`, `requestFingerprint`) — so assigning at
1155
+ // the top of the RPC let a second call overwrite the field while the first was
1156
+ // suspended, and the first would then emit under the second's id and clear it on
1157
+ // the way out. `OperationQueue` is what makes this a plain field rather than a
1158
+ // stack: the bodies do not interleave, so set-and-clear here brackets exactly
1159
+ // one call. Same placement as the SQLite adapter's actor task, for this reason.
1160
+ this.invocationId = invokeOptions?.invocationId ?? null;
1127
1161
  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;
1162
+ let result;
1163
+ let committedVersion = null;
1164
+ // #116: set when this invocation was answered from a recording rather
1165
+ // than run. Read after the transaction, where it decides both the
1166
+ // envelope's acknowledgement and whether there is anything to dispatch.
1167
+ let replayed = false;
1168
+ // #458: how many platform intents THIS invoke enqueued. Counted inside the
1169
+ // transaction, reported only after commit — a rolled-back intent is no signal.
1170
+ // The envelope return (below) is the DO↔coordinator wire for it; both sides
1171
+ // live in this package and deploy as one script, so the shape never skews.
1172
+ const signals = { platformRequests: 0 };
1173
+ /**
1174
+ * K-42: how a read-only session is made to BE read-only.
1175
+ *
1176
+ * Thrown on success, so the transaction never commits — the same
1177
+ * mechanism `queryScope`'s console uses, for the same reason: the DO's
1178
+ * `exec` exposes no read-only flag, so refusing to commit is the only
1179
+ * enforcement that survives a handler writing rows with plain SQL.
1180
+ * `ctx.emit` and the other effecting verbs refuse outright as well; that
1181
+ * is the half a support engineer sees, this is the half that holds.
1182
+ */
1183
+ const rollback = new Error('read-only impersonation rollback');
1184
+ // The async transaction is the K-4 boundary: guards + handler + emits
1185
+ // commit together, or a throw (from either) rolls domain writes AND
1186
+ // emitted events back as one — verified across `await` in workerd.
1187
+ try {
1188
+ await this.ctx.storage.transaction(async () => {
1189
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation);
1190
+ // #116: a retry is answered from the recording, and nothing else runs
1191
+ // — not the guards, not the handler, not the permission check inside
1192
+ // it. Keyed by SUBJECT, so a caller only ever reaches its own
1193
+ // responses; `idempotency.ts` states what that does not promise.
1194
+ if (idempotencyKey !== undefined && fingerprint !== undefined) {
1195
+ const lookup = idempotencyLookupQuery(idempotencySubjectRef, idempotencyKey);
1196
+ const prior = this.sql.exec(lookup.sql, ...lookup.params).toArray()[0];
1197
+ if (prior) {
1198
+ const replay = replayFor(idempotencyKey, fingerprint, prior);
1199
+ result = replay.result;
1200
+ // Only a guarded operation may report a tag (#129).
1201
+ if (guardedRef)
1202
+ committedVersion = replay.entityVersion;
1203
+ replayed = true;
1204
+ return;
1205
+ }
1145
1206
  }
1207
+ // #129: snapshot the version BEFORE the handler, compare AFTER it.
1208
+ // Before, because the handler's own `emit` moves it; after, because the
1209
+ // permission check lives inside the handler and must answer first — a
1210
+ // precondition evaluated ahead of it turns the operation into a version
1211
+ // oracle for a principal who may not read the entity at all. The full
1212
+ // reasoning is on the pure adapter, which does the identical thing.
1213
+ const seen = guardedRef && invokeOptions?.ifMatch !== undefined
1214
+ ? this.versionAt(guardedRef)
1215
+ : undefined;
1216
+ await this.runGuards(operation, ctx, parsed);
1217
+ result = await handler(ctx, parsed);
1218
+ if (guardedRef && invokeOptions?.ifMatch !== undefined) {
1219
+ assertIfMatch(guardedRef, invokeOptions.ifMatch, seen ?? null);
1220
+ }
1221
+ // The tag describes the row as THIS write left it, so it is read after
1222
+ // the handler and still inside the transaction.
1223
+ if (guardedRef)
1224
+ committedVersion = this.versionAt(guardedRef);
1225
+ // #116: recorded INSIDE the transaction, which is what makes a failed
1226
+ // request retried rather than replayed — it rolls back with the writes
1227
+ // it describes. The prune rides along, on the only path that adds a row.
1228
+ if (idempotencyKey !== undefined && fingerprint !== undefined) {
1229
+ const at = new Date().toISOString();
1230
+ const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint, result, committedVersion, at);
1231
+ this.sql.exec(record.sql, ...record.params);
1232
+ const prune = idempotencyPruneStatement(at);
1233
+ this.sql.exec(prune.sql, ...prune.params);
1234
+ }
1235
+ if (impersonation?.mode === 'read-only')
1236
+ throw rollback;
1237
+ });
1238
+ }
1239
+ catch (err) {
1240
+ // The read-only unwind, not a failure: `result` was assigned before the
1241
+ // throw and is the answer, while every row the handler wrote is gone.
1242
+ //
1243
+ // The acknowledgements ride along unchanged, because they are about
1244
+ // whether this DO UNDERSTOOD the arguments, not about what committed —
1245
+ // and a coordinator that sent `If-Match` to a read-only session must not
1246
+ // be told the host is too old to have evaluated it. The version reported
1247
+ // is the rolled-back one, which is the honest answer: nothing moved.
1248
+ if (err === rollback) {
1249
+ return {
1250
+ result,
1251
+ platformRequests: 0,
1252
+ impersonation: { honoured: true },
1253
+ ...(idempotencyKey !== undefined
1254
+ ? { idempotency: { keyHonoured: true, replayed } }
1255
+ : {}),
1256
+ ...(guardedRef
1257
+ ? {
1258
+ concurrency: {
1259
+ version: committedVersion,
1260
+ ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1261
+ },
1262
+ }
1263
+ : {}),
1264
+ };
1146
1265
  }
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);
1266
+ // K-35: the transaction has rolled back; record a refused check now, as its own
1267
+ // write (outside that transaction), so the denial survives the rollback.
1268
+ if (err instanceof PermissionDenied) {
1269
+ this.recordDenial(systemModuleId
1270
+ ? { kind: 'system', id: systemModuleId }
1271
+ : connectionId
1272
+ ? { kind: 'connection', id: connectionId }
1273
+ : { kind: 'principal', id: principal }, tenantId, operation, err, impersonation);
1174
1274
  }
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);
1275
+ // The ORIGINAL error, deliberately: `invoke` flattens it for the envelope
1276
+ // (which keeps its code and extensions) or rewraps it for the legacy throw
1277
+ // path. Collapsing it here would lose the structure before either can look.
1278
+ throw err;
1214
1279
  }
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;
1280
+ // Post-commit: drain the outbox to consumers, each delivery its own txn.
1281
+ // Skipped on a replay: nothing was written, so there is nothing this
1282
+ // invocation added to drain. Anything the ORIGINAL left undrained is the
1283
+ // outbox's own retry backstop, which is what that backstop is for.
1284
+ if (!replayed)
1285
+ await this.dispatch(tenantId, scopeId);
1286
+ return {
1287
+ result,
1288
+ platformRequests: signals.platformRequests,
1289
+ ...(impersonation ? { impersonation: { honoured: true } } : {}),
1290
+ // The acknowledgement the coordinator's skew check reads (#116), on the
1291
+ // same reasoning as `ifMatchChecked` below and with a sharper failure: a
1292
+ // DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
1293
+ // return 200, which is the duplicate the header was sent to prevent.
1294
+ ...(idempotencyKey !== undefined
1295
+ ? { idempotency: { keyHonoured: true, replayed } }
1296
+ : {}),
1297
+ // The acknowledgement the coordinator's skew check reads. Present only
1298
+ // for a guarded operation, so an unguarded one costs nothing.
1299
+ ...(guardedRef
1300
+ ? {
1301
+ concurrency: {
1302
+ version: committedVersion,
1303
+ ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1304
+ },
1305
+ }
1306
+ : {}),
1307
+ };
1308
+ }
1309
+ finally {
1310
+ // Cleared on BOTH paths. The DO outlives the request, so a value left set here
1311
+ // is read by whatever runs next — an alarm-driven drain, a consumer retry —
1312
+ // and stamps its events with a call they had nothing to do with. A wrong
1313
+ // recorded fact, which is worse than the honest NULL this column uses.
1314
+ this.invocationId = null;
1219
1315
  }
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
1316
  });
1249
1317
  }
1250
1318
  // -- attachments (#473): the metadata half of the attachment surface --------
@@ -1904,6 +1972,9 @@ export function defineScopeDO(modules, bareOps) {
1904
1972
  // null is honestly "unrecorded" for every legacy row — nothing can go back and
1905
1973
  // decide what a past consumer was reacting to.
1906
1974
  'ALTER TABLE _substrat_outbox ADD COLUMN caused_by TEXT',
1975
+ // #1237: the invocation column on a DO created before it. Nullable, and the
1976
+ // null is honestly "none was carried" for every legacy row.
1977
+ 'ALTER TABLE _substrat_outbox ADD COLUMN invocation_id TEXT',
1907
1978
  ]) {
1908
1979
  try {
1909
1980
  this.sql.exec(alter);
@@ -1913,6 +1984,19 @@ export function defineScopeDO(modules, bareOps) {
1913
1984
  throw err;
1914
1985
  }
1915
1986
  }
1987
+ // #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
1988
+ // that is never pruned. No index leads with invocation_id, so without this one SQLite
1989
+ // walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a
1990
+ // recent invocation costs the scope's lifetime event count. The trailing id gives the
1991
+ // ORDER BY for free, as it does on `_substrat_outbox_drained`.
1992
+ //
1993
+ // HERE, after the column is ensured, and deliberately NOT in KERNEL_DDL beside the
1994
+ // other outbox indexes. KERNEL_DDL runs FIRST on every wake, and on a scope created
1995
+ // before #1237 its `CREATE TABLE IF NOT EXISTS` does not add the column — so an index
1996
+ // naming invocation_id there throws "no such column" and every existing scope fails to
1997
+ // boot. `lint:spine-ddl` compares KERNEL_DDL's indexes only, so this one is held to
1998
+ // both adapters by the query-plan test rather than by that gate.
1999
+ this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
1916
2000
  }
1917
2001
  async importDump(tables, destScopeId) {
1918
2002
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
@@ -2103,6 +2187,15 @@ export function defineScopeDO(modules, bareOps) {
2103
2187
  * another scope's emit the moment a consumer awaits.
2104
2188
  */
2105
2189
  causedBy = null;
2190
+ /**
2191
+ * #1237: the invocation currently running in this DO, or null.
2192
+ *
2193
+ * DO-local, like `causedBy` and for a simpler reason: a Durable Object IS one
2194
+ * scope, so there is no other scope's call to confuse it with. It still has to be
2195
+ * cleared, because the DO outlives the request and a value left set would stamp a
2196
+ * later alarm-driven drain with a call it had nothing to do with.
2197
+ */
2198
+ invocationId = null;
2106
2199
  async dispatch(tenantId, scopeId) {
2107
2200
  for (let round = 0; round < 50; round++) {
2108
2201
  let deliveredAny = false;
@@ -2335,8 +2428,8 @@ export function defineScopeDO(modules, bareOps) {
2335
2428
  sql.exec(`INSERT INTO _substrat_outbox
2336
2429
  (id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
2337
2430
  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,
2431
+ impersonation, operation, version, caused_by, invocation_id, payload)
2432
+ 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
2433
  // #1242: script configuration, not envelope data — the version is a fact
2341
2434
  // about the deploy, so it never rides `DomainEvent` for module code to
2342
2435
  // branch on; it exists for the observability joins the column serves.
@@ -2344,7 +2437,9 @@ export function defineScopeDO(modules, bareOps) {
2344
2437
  // #1237: whatever delivery is in flight, if any — read off the DO the same
2345
2438
  // way the version is read off its env. A fact about the surrounding
2346
2439
  // dispatch, never envelope data module code could set or branch on.
2347
- this.causedBy, full.payload === undefined ? null : JSON.stringify(full.payload));
2440
+ this.causedBy,
2441
+ // #1237: a fact about the surrounding CALL, like the version above.
2442
+ this.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
2348
2443
  },
2349
2444
  requestPlatform: (request) => {
2350
2445
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');