@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/host.d.ts +72 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +146 -7
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +255 -150
- package/dist/scope-do.js.map +1 -1
- package/package.json +4 -4
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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/scope-do.d.ts.map
CHANGED
|
@@ -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;
|
|
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,
|
|
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
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
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
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|