@substrat-run/adapter-cloudflare 0.111.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.
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * driving `drainDue`/`runDueSchedules` per scope (#461)
15
15
  */
16
16
  export { CloudflareScopeHost } from './host.js';
17
- export type { CloudflareScopeHostOptions, ConnectorDelegation } from './host.js';
17
+ export type { CloudflareScopeHostOptions, ConnectorDelegation, EventDrainDelegation } from './host.js';
18
18
  export { defineScopeDO } from './scope-do.js';
19
19
  export { ControlPlaneDO } from './control-plane-do.js';
20
20
  export { OperationQueue } from './serialization.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAChD,YAAY,EAAE,0BAA0B,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AACjF,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAC1F,YAAY,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AACjF,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC7E,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAChD,YAAY,EAAE,0BAA0B,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC;AACvG,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAC1F,YAAY,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AACjF,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC7E,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC"}
@@ -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,CAi2FjE"}
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';
3
- import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, PermissionDenied, assertImpersonationWrites, 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';
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
+ 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,
@@ -767,6 +773,14 @@ export function defineScopeDO(modules, bareOps) {
767
773
  ...this.parseOutboxRow(r),
768
774
  operation: r.operation ?? null,
769
775
  version: r.version ?? null,
776
+ // #1237 — lifted like the two above, and for the same reason: the column
777
+ // exists on the outbox but not on the envelope `parseOutboxRow` returns,
778
+ // whose `domainEvent.parse` strips anything it does not declare.
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,
770
784
  }));
771
785
  }
772
786
  /**
@@ -798,6 +812,37 @@ export function defineScopeDO(modules, bareOps) {
798
812
  return drained;
799
813
  });
800
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
+ }
801
846
  /**
802
847
  * Facet this scope's own outbox (#1239) — `facetEvents`, which is the
803
848
  * sanctioned read: an erased payload yields the same NULL a missing field
@@ -830,6 +875,10 @@ export function defineScopeDO(modules, bareOps) {
830
875
  eventEffects(input) {
831
876
  return walkEventEffects({ sql: doScopedSql(this.sql) }, input.eventId, input.maxNodes);
832
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
+ }
833
882
  migrationBookmarks(limit = 20) {
834
883
  return this.sql
835
884
  .exec(`SELECT bookmark, taken_at, pending FROM _substrat_migration_bookmarks
@@ -1094,153 +1143,176 @@ export function defineScopeDO(modules, bareOps) {
1094
1143
  // Fingerprinted from the PARSED input (defaults applied), before the queue:
1095
1144
  // a pure hash of what the caller sent has no business inside a transaction.
1096
1145
  const fingerprint = idempotencyKey === undefined ? undefined : await requestFingerprint(operation, parsed);
1097
- return this.queue.enqueue(async () => {
1098
- let result;
1099
- let committedVersion = null;
1100
- // #116: set when this invocation was answered from a recording rather
1101
- // than run. Read after the transaction, where it decides both the
1102
- // envelope's acknowledgement and whether there is anything to dispatch.
1103
- let replayed = false;
1104
- // #458: how many platform intents THIS invoke enqueued. Counted inside the
1105
- // transaction, reported only after commit — a rolled-back intent is no signal.
1106
- // The envelope return (below) is the DO↔coordinator wire for it; both sides
1107
- // live in this package and deploy as one script, so the shape never skews.
1108
- const signals = { platformRequests: 0 };
1109
- /**
1110
- * K-42: how a read-only session is made to BE read-only.
1111
- *
1112
- * Thrown on success, so the transaction never commits — the same
1113
- * mechanism `queryScope`'s console uses, for the same reason: the DO's
1114
- * `exec` exposes no read-only flag, so refusing to commit is the only
1115
- * enforcement that survives a handler writing rows with plain SQL.
1116
- * `ctx.emit` and the other effecting verbs refuse outright as well; that
1117
- * is the half a support engineer sees, this is the half that holds.
1118
- */
1119
- const rollback = new Error('read-only impersonation rollback');
1120
- // The async transaction is the K-4 boundary: guards + handler + emits
1121
- // commit together, or a throw (from either) rolls domain writes AND
1122
- // 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;
1123
1161
  try {
1124
- await this.ctx.storage.transaction(async () => {
1125
- const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation);
1126
- // #116: a retry is answered from the recording, and nothing else runs
1127
- // — not the guards, not the handler, not the permission check inside
1128
- // it. Keyed by SUBJECT, so a caller only ever reaches its own
1129
- // responses; `idempotency.ts` states what that does not promise.
1130
- if (idempotencyKey !== undefined && fingerprint !== undefined) {
1131
- const lookup = idempotencyLookupQuery(idempotencySubjectRef, idempotencyKey);
1132
- const prior = this.sql.exec(lookup.sql, ...lookup.params).toArray()[0];
1133
- if (prior) {
1134
- const replay = replayFor(idempotencyKey, fingerprint, prior);
1135
- result = replay.result;
1136
- // Only a guarded operation may report a tag (#129).
1137
- if (guardedRef)
1138
- committedVersion = replay.entityVersion;
1139
- replayed = true;
1140
- 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
+ }
1141
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
+ };
1142
1265
  }
1143
- // #129: snapshot the version BEFORE the handler, compare AFTER it.
1144
- // Before, because the handler's own `emit` moves it; after, because the
1145
- // permission check lives inside the handler and must answer first — a
1146
- // precondition evaluated ahead of it turns the operation into a version
1147
- // oracle for a principal who may not read the entity at all. The full
1148
- // reasoning is on the pure adapter, which does the identical thing.
1149
- const seen = guardedRef && invokeOptions?.ifMatch !== undefined
1150
- ? this.versionAt(guardedRef)
1151
- : undefined;
1152
- await this.runGuards(operation, ctx, parsed);
1153
- result = await handler(ctx, parsed);
1154
- if (guardedRef && invokeOptions?.ifMatch !== undefined) {
1155
- assertIfMatch(guardedRef, invokeOptions.ifMatch, seen ?? null);
1156
- }
1157
- // The tag describes the row as THIS write left it, so it is read after
1158
- // the handler and still inside the transaction.
1159
- if (guardedRef)
1160
- committedVersion = this.versionAt(guardedRef);
1161
- // #116: recorded INSIDE the transaction, which is what makes a failed
1162
- // request retried rather than replayed — it rolls back with the writes
1163
- // it describes. The prune rides along, on the only path that adds a row.
1164
- if (idempotencyKey !== undefined && fingerprint !== undefined) {
1165
- const at = new Date().toISOString();
1166
- const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint, result, committedVersion, at);
1167
- this.sql.exec(record.sql, ...record.params);
1168
- const prune = idempotencyPruneStatement(at);
1169
- 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);
1170
1274
  }
1171
- if (impersonation?.mode === 'read-only')
1172
- throw rollback;
1173
- });
1174
- }
1175
- catch (err) {
1176
- // The read-only unwind, not a failure: `result` was assigned before the
1177
- // throw and is the answer, while every row the handler wrote is gone.
1178
- //
1179
- // The acknowledgements ride along unchanged, because they are about
1180
- // whether this DO UNDERSTOOD the arguments, not about what committed —
1181
- // and a coordinator that sent `If-Match` to a read-only session must not
1182
- // be told the host is too old to have evaluated it. The version reported
1183
- // is the rolled-back one, which is the honest answer: nothing moved.
1184
- if (err === rollback) {
1185
- return {
1186
- result,
1187
- platformRequests: 0,
1188
- impersonation: { honoured: true },
1189
- ...(idempotencyKey !== undefined
1190
- ? { idempotency: { keyHonoured: true, replayed } }
1191
- : {}),
1192
- ...(guardedRef
1193
- ? {
1194
- concurrency: {
1195
- version: committedVersion,
1196
- ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1197
- },
1198
- }
1199
- : {}),
1200
- };
1201
- }
1202
- // K-35: the transaction has rolled back; record a refused check now, as its own
1203
- // write (outside that transaction), so the denial survives the rollback.
1204
- if (err instanceof PermissionDenied) {
1205
- this.recordDenial(systemModuleId
1206
- ? { kind: 'system', id: systemModuleId }
1207
- : connectionId
1208
- ? { kind: 'connection', id: connectionId }
1209
- : { 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;
1210
1279
  }
1211
- // The ORIGINAL error, deliberately: `invoke` flattens it for the envelope
1212
- // (which keeps its code and extensions) or rewraps it for the legacy throw
1213
- // path. Collapsing it here would lose the structure before either can look.
1214
- 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;
1215
1315
  }
1216
- // Post-commit: drain the outbox to consumers, each delivery its own txn.
1217
- // Skipped on a replay: nothing was written, so there is nothing this
1218
- // invocation added to drain. Anything the ORIGINAL left undrained is the
1219
- // outbox's own retry backstop, which is what that backstop is for.
1220
- if (!replayed)
1221
- await this.dispatch(tenantId, scopeId);
1222
- return {
1223
- result,
1224
- platformRequests: signals.platformRequests,
1225
- ...(impersonation ? { impersonation: { honoured: true } } : {}),
1226
- // The acknowledgement the coordinator's skew check reads (#116), on the
1227
- // same reasoning as `ifMatchChecked` below and with a sharper failure: a
1228
- // DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
1229
- // return 200, which is the duplicate the header was sent to prevent.
1230
- ...(idempotencyKey !== undefined
1231
- ? { idempotency: { keyHonoured: true, replayed } }
1232
- : {}),
1233
- // The acknowledgement the coordinator's skew check reads. Present only
1234
- // for a guarded operation, so an unguarded one costs nothing.
1235
- ...(guardedRef
1236
- ? {
1237
- concurrency: {
1238
- version: committedVersion,
1239
- ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1240
- },
1241
- }
1242
- : {}),
1243
- };
1244
1316
  });
1245
1317
  }
1246
1318
  // -- attachments (#473): the metadata half of the attachment surface --------
@@ -1803,16 +1875,19 @@ export function defineScopeDO(modules, bareOps) {
1803
1875
  const q = denialListQuery(filter);
1804
1876
  return this.sql.exec(q.sql, ...q.params).toArray().map(mapDenialRow);
1805
1877
  }
1806
- /** The same log bucketed per (actor, permission), with the window's own facts. */
1878
+ /**
1879
+ * The same log bucketed per (actor, permission) — or per operation when the filter
1880
+ * says `groupBy: 'operation'` (#1456) — with the window's own facts.
1881
+ */
1807
1882
  summarizeDenials(filter) {
1808
1883
  const b = denialSummaryQuery(filter);
1809
- const buckets = this.sql.exec(b.sql, ...b.params).toArray().map(mapDenialBucketRow);
1884
+ const grouped = mapDenialSummaryBuckets(b.groupBy, this.sql.exec(b.sql, ...b.params).toArray());
1810
1885
  const t = denialTotalsQuery(filter);
1811
1886
  const totals = this.sql.exec(t.sql, ...t.params).toArray()[0];
1812
1887
  // Unfiltered on purpose — these describe the log, not the query (denial-query.ts).
1813
1888
  const w = this.sql.exec(DENIAL_WINDOW_QUERY).toArray()[0];
1814
1889
  return {
1815
- buckets,
1890
+ ...grouped,
1816
1891
  total: Number(totals.total),
1817
1892
  actors: Number(totals.actors),
1818
1893
  windowOldestAt: w.oldest_at ?? null,
@@ -1897,6 +1972,9 @@ export function defineScopeDO(modules, bareOps) {
1897
1972
  // null is honestly "unrecorded" for every legacy row — nothing can go back and
1898
1973
  // decide what a past consumer was reacting to.
1899
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',
1900
1978
  ]) {
1901
1979
  try {
1902
1980
  this.sql.exec(alter);
@@ -1906,6 +1984,19 @@ export function defineScopeDO(modules, bareOps) {
1906
1984
  throw err;
1907
1985
  }
1908
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)');
1909
2000
  }
1910
2001
  async importDump(tables, destScopeId) {
1911
2002
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
@@ -2096,6 +2187,15 @@ export function defineScopeDO(modules, bareOps) {
2096
2187
  * another scope's emit the moment a consumer awaits.
2097
2188
  */
2098
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;
2099
2199
  async dispatch(tenantId, scopeId) {
2100
2200
  for (let round = 0; round < 50; round++) {
2101
2201
  let deliveredAny = false;
@@ -2328,8 +2428,8 @@ export function defineScopeDO(modules, bareOps) {
2328
2428
  sql.exec(`INSERT INTO _substrat_outbox
2329
2429
  (id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
2330
2430
  entity_type, entity_id, pii_class, subject_id, authorization,
2331
- impersonation, operation, version, caused_by, payload)
2332
- 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,
2333
2433
  // #1242: script configuration, not envelope data — the version is a fact
2334
2434
  // about the deploy, so it never rides `DomainEvent` for module code to
2335
2435
  // branch on; it exists for the observability joins the column serves.
@@ -2337,11 +2437,16 @@ export function defineScopeDO(modules, bareOps) {
2337
2437
  // #1237: whatever delivery is in flight, if any — read off the DO the same
2338
2438
  // way the version is read off its env. A fact about the surrounding
2339
2439
  // dispatch, never envelope data module code could set or branch on.
2340
- 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));
2341
2443
  },
2342
2444
  requestPlatform: (request) => {
2343
2445
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
2344
2446
  const input = platformRequestInput.parse(request);
2447
+ // #1474: a platform-authored kind (`sweep-runs`) never comes from module code —
2448
+ // the sweeper enqueues it through `enqueueSweepRuns`, which does not pass here.
2449
+ assertModuleEnqueueableKind(input.kind);
2345
2450
  // Backpressure (platform-intents.md): refuse when the scope already holds too many pending
2346
2451
  // intents, so a stuck or runaway vertical cannot flood the platform drain.
2347
2452
  const pending = Number(sql