@substrat-run/adapter-cloudflare 0.137.0 → 0.139.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/checker.d.ts +7 -1
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +2 -2
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +126 -35
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +366 -106
- package/dist/control-plane-do.js.map +1 -1
- package/dist/do-reply.d.ts +25 -0
- package/dist/do-reply.d.ts.map +1 -0
- package/dist/do-reply.js +17 -0
- package/dist/do-reply.js.map +1 -0
- package/dist/host.d.ts +141 -18
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +893 -462
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +39 -1
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +79 -0
- package/dist/live-reads.js.map +1 -1
- package/dist/scope-do.d.ts +2 -7
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +455 -161
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +5 -1
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +6 -2
- package/dist/sql.js.map +1 -1
- package/package.json +14 -12
package/dist/scope-do.js
CHANGED
|
@@ -2,12 +2,13 @@ import { REWIND_REFUSED } from './rewind-refusal.js';
|
|
|
2
2
|
import { SYSTEM_DOOR_MOVED } from './system-door.js';
|
|
3
3
|
import { DurableObject } from 'cloudflare:workers';
|
|
4
4
|
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, } from '@substrat-run/kernel';
|
|
6
|
-
import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
|
|
5
|
+
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexDdl, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
|
|
6
|
+
import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, decodeLiveWithin, } from './live-reads.js';
|
|
7
|
+
import { replyOf } from './do-reply.js';
|
|
7
8
|
import { OperationQueue } from './serialization.js';
|
|
8
9
|
import { doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
|
|
9
|
-
import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
10
|
-
import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
|
|
10
|
+
import { actorOf, admitPeer, collectPeers, switchPeer, peerSubjectRef, subjectGrantState, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, SWITCH_FENCES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, searchStateWhere, uncheckedView, addStatePlans, entityStateTriggerDdl, statefulTablesOf, assertEntityStateIntact, stateListIndexNames, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readOperationSeries, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
11
|
+
import { createDoTupleChecker, createLocalControlPlaneReader, scopeTupleReader } from './checker.js';
|
|
11
12
|
import { CARRIED_AWAY_KEY, COPY_MARK_CLEARED_KEY, KEPT_COPY_REFUSAL, KEPT_DIVERGENT_KEY, LOAD_STAMP_KEY, STORE_LOCAL_META_KEYS, WRITE_REVISION_KEY, carriedAwayDump, isCopyMarkInsert, isWriteStatement, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
|
|
12
13
|
/**
|
|
13
14
|
* The key marking a scope DO whose storage was destroyed (`destroyStorage`).
|
|
@@ -190,6 +191,7 @@ const KERNEL_DDL = `
|
|
|
190
191
|
taken_at TEXT NOT NULL,
|
|
191
192
|
pending TEXT NOT NULL
|
|
192
193
|
);
|
|
194
|
+
${SWITCH_FENCES_DDL}
|
|
193
195
|
CREATE TABLE IF NOT EXISTS _substrat_tuples (
|
|
194
196
|
subject TEXT NOT NULL,
|
|
195
197
|
relation TEXT NOT NULL,
|
|
@@ -347,6 +349,9 @@ const KERNEL_DDL = `
|
|
|
347
349
|
-- #1686: where a copied scope's data came from, and the event id its own events start above.
|
|
348
350
|
-- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
|
|
349
351
|
${COPY_ORIGIN_DDL}
|
|
352
|
+
-- #119: the kernel's authorization for one archive/trash move, read by the derived update
|
|
353
|
+
-- trigger. Shared with the other adapter from @substrat-run/kernel; the comments are there.
|
|
354
|
+
${ENTITY_STATE_MOVES_DDL}
|
|
350
355
|
`;
|
|
351
356
|
/**
|
|
352
357
|
* Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
|
|
@@ -706,6 +711,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
706
711
|
searchPlans = new Map();
|
|
707
712
|
/** #811: the paged lists modules declare, by entity type. Same one-owner rule. */
|
|
708
713
|
listPlans = new Map();
|
|
714
|
+
/** #119: entity type → its archive/trash plan, from every registered module. */
|
|
715
|
+
statePlans = new Map();
|
|
709
716
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
710
717
|
attachmentTargets = new Map();
|
|
711
718
|
/**
|
|
@@ -817,8 +824,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
817
824
|
}
|
|
818
825
|
this.searchPlans.set(plan.entityType, plan);
|
|
819
826
|
}
|
|
827
|
+
// #119: the archive/trash plans, refusing a second owner of one entity type.
|
|
828
|
+
addStatePlans(this.statePlans, manifest.id, manifest.entityStates, manifest.permissions);
|
|
820
829
|
// #811: the list indexes `lists` declares, same placement and same reason.
|
|
821
|
-
for (const plan of listIndexPlans(manifest.id, manifest.lists)) {
|
|
830
|
+
for (const plan of listIndexPlans(manifest.id, manifest.lists, manifest.entityStates)) {
|
|
822
831
|
const existing = this.listPlans.get(plan.entityType);
|
|
823
832
|
if (existing) {
|
|
824
833
|
throw new Error(`list: '${plan.entityType}' declares a paged list in both '${existing.moduleId}' and ` +
|
|
@@ -1067,7 +1076,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1067
1076
|
* On the queue, so no operation is mid-transaction while the outbox is read. An event
|
|
1068
1077
|
* must not reach another vertical before the transaction that wrote it has committed.
|
|
1069
1078
|
*/
|
|
1070
|
-
async exportedEventsRead(input, tenantId, scopeId
|
|
1079
|
+
async exportedEventsRead(input, tenantId, scopeId,
|
|
1080
|
+
/**
|
|
1081
|
+
* #2029: the consumer is a peer, so the read is gated by its door. `instance` pins it to the
|
|
1082
|
+
* gate's read, as every door call is; `held` says the rewind hold keeps the consumer off, and
|
|
1083
|
+
* then it holds nothing here: every key is missing, the answer a switched-off consumer gets.
|
|
1084
|
+
*/
|
|
1085
|
+
door) {
|
|
1086
|
+
const held = door !== undefined && 'held' in door;
|
|
1087
|
+
if (!held && this.peerDoorMoved(input.consumer, door?.instance))
|
|
1088
|
+
return SYSTEM_DOOR_MOVED;
|
|
1071
1089
|
await this.ensureMigrations();
|
|
1072
1090
|
const plan = exportReadPlan(this.crossVertical.exports(), input.wants);
|
|
1073
1091
|
const quiet = {
|
|
@@ -1080,9 +1098,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1080
1098
|
};
|
|
1081
1099
|
if (plan.types.length === 0)
|
|
1082
1100
|
return quiet;
|
|
1083
|
-
const missing =
|
|
1084
|
-
|
|
1085
|
-
.
|
|
1101
|
+
const missing = held
|
|
1102
|
+
? plan.keys
|
|
1103
|
+
: (await this.peerCoverage(tenantId, scopeId, input.consumer, plan.keys))
|
|
1104
|
+
.filter((c) => !c.held)
|
|
1105
|
+
.map((c) => c.permission);
|
|
1086
1106
|
if (missing.length > 0)
|
|
1087
1107
|
return { ...quiet, paused: { missing } };
|
|
1088
1108
|
return await this.queue.enqueue(() => {
|
|
@@ -1221,20 +1241,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1221
1241
|
* The coordinator parses the batch before it gets here and runs the executors after.
|
|
1222
1242
|
* Executors live on the coordinator here, as they do after an invoke.
|
|
1223
1243
|
*/
|
|
1224
|
-
async importApply(batch, tenantId, scopeId
|
|
1225
|
-
|
|
1244
|
+
async importApply(batch, tenantId, scopeId,
|
|
1245
|
+
/** #2029: the instance the peer door's gate read for the producer; on another, nothing applies. */
|
|
1246
|
+
doorInstance) {
|
|
1226
1247
|
const source = batch.source;
|
|
1248
|
+
if (this.peerDoorMoved(source.vertical, doorInstance))
|
|
1249
|
+
return SYSTEM_DOOR_MOVED;
|
|
1250
|
+
await this.ensureMigrations();
|
|
1227
1251
|
return await this.queue.enqueue(async () => {
|
|
1228
1252
|
const liveSince = this.liveHighWaterMark();
|
|
1229
|
-
const result =
|
|
1230
|
-
delivered: 0,
|
|
1231
|
-
deadLettered: 0,
|
|
1232
|
-
duplicates: 0,
|
|
1233
|
-
withheld: 0,
|
|
1234
|
-
cursor: batch.after,
|
|
1235
|
-
stale: false,
|
|
1236
|
-
paused: null,
|
|
1237
|
-
};
|
|
1253
|
+
const result = emptyImportResult(batch);
|
|
1238
1254
|
// #1706's door, for a delivery (`operation: null`), inside the queued body: the producer
|
|
1239
1255
|
// is a declared peer with its switch on, or nothing runs and the edge pauses.
|
|
1240
1256
|
let peerSubject;
|
|
@@ -1297,13 +1313,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1297
1313
|
const { hops: _hops, ...fact } = e;
|
|
1298
1314
|
const event = structuredClone({ ...fact, source });
|
|
1299
1315
|
const unit = unitOf(imp.moduleId, e.id, e.type);
|
|
1300
|
-
this.causedBy = e.id;
|
|
1301
|
-
this.unitInvocationId = unit.invocationId;
|
|
1302
1316
|
try {
|
|
1303
1317
|
await this.revision.transaction(async () => {
|
|
1304
1318
|
// As the producer's principal: real checks against the grants this vertical's
|
|
1305
1319
|
// `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
|
|
1306
|
-
|
|
1320
|
+
// What it emits was emitted BECAUSE of the producer's event (#1237).
|
|
1321
|
+
await imp.handler(this.importContext(tenantId, scopeId, peerSubject, { causedBy: e.id, invocationId: unit.invocationId }), event);
|
|
1307
1322
|
this.sql.exec(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
|
|
1308
1323
|
VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
|
|
1309
1324
|
});
|
|
@@ -1316,10 +1331,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1316
1331
|
result.deadLettered += 1;
|
|
1317
1332
|
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
1318
1333
|
}
|
|
1319
|
-
finally {
|
|
1320
|
-
this.causedBy = null;
|
|
1321
|
-
this.unitInvocationId = null;
|
|
1322
|
-
}
|
|
1323
1334
|
}
|
|
1324
1335
|
if (!ran)
|
|
1325
1336
|
result.duplicates += 1;
|
|
@@ -1336,8 +1347,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1336
1347
|
* passes it (#1706). `principal` is a placeholder that the subject never reads, and there is
|
|
1337
1348
|
* no operation, so the emitted rows' `operation` is NULL, as it is for any consumer.
|
|
1338
1349
|
*/
|
|
1339
|
-
|
|
1340
|
-
|
|
1350
|
+
/** A module consumer's context: the system override, and the delivery it runs for (#2055). */
|
|
1351
|
+
consumerContext(tenantId, scopeId, moduleId, delivery) {
|
|
1352
|
+
return this.operationContext(this.systemPrincipal, tenantId, scopeId, { system: moduleId }, undefined, undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, delivery);
|
|
1353
|
+
}
|
|
1354
|
+
importContext(tenantId, scopeId, peerSubject, delivery) {
|
|
1355
|
+
return this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [], undefined, peerSubject, delivery);
|
|
1341
1356
|
}
|
|
1342
1357
|
async markEventsDrained(eventIds, at) {
|
|
1343
1358
|
if (eventIds.length === 0)
|
|
@@ -1460,6 +1475,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1460
1475
|
lifecycleFlow(input) {
|
|
1461
1476
|
return readLifecycleFlow({ sql: doScopedSql(this.sql) }, input);
|
|
1462
1477
|
}
|
|
1478
|
+
/** #1750: business volumes per bucket, counted over this scope's outbox. */
|
|
1479
|
+
operationSeries(input) {
|
|
1480
|
+
return readOperationSeries({ sql: doScopedSql(this.sql) }, input);
|
|
1481
|
+
}
|
|
1463
1482
|
migrationBookmarks(limit = 20) {
|
|
1464
1483
|
return this.sql
|
|
1465
1484
|
.exec(`SELECT bookmark, taken_at, pending FROM _substrat_migration_bookmarks
|
|
@@ -1845,6 +1864,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1845
1864
|
// operation off its allowlist are refused `forbidden`; none is a K-35 denial.
|
|
1846
1865
|
let peerSubject;
|
|
1847
1866
|
if (verticalCaller !== undefined) {
|
|
1867
|
+
// #2029: pinned to the instance the peer door's gate read against the rewind hold.
|
|
1868
|
+
this.assertPeerDoor(verticalCaller.vertical, systemDoorInstance);
|
|
1848
1869
|
peerSubject = admitPeer(this.switchSql(), this.peers, verticalCaller, operation);
|
|
1849
1870
|
idempotencySubjectRef = peerSubject;
|
|
1850
1871
|
}
|
|
@@ -2119,6 +2140,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2119
2140
|
status: 500,
|
|
2120
2141
|
});
|
|
2121
2142
|
}
|
|
2143
|
+
// A narrowing the coordinator sent but this end cannot read is refused, never
|
|
2144
|
+
// dropped: dropping it would open the whole scope's feed — to a vouched
|
|
2145
|
+
// subscriber, one the principal's check was never going to filter (#1853).
|
|
2146
|
+
const withinHeader = request.headers.get(LIVE_WITHIN_HEADER);
|
|
2147
|
+
const within = withinHeader === null ? undefined : decodeLiveWithin(withinHeader);
|
|
2148
|
+
if (withinHeader !== null && !within) {
|
|
2149
|
+
return new Response('live reads: unreadable within narrowing', { status: 500 });
|
|
2150
|
+
}
|
|
2122
2151
|
// A subscriber arriving before the scope's migrations have run would be told
|
|
2123
2152
|
// about events against a schema it cannot read back through. Same gate every
|
|
2124
2153
|
// other entry point takes, for the same reason.
|
|
@@ -2143,6 +2172,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2143
2172
|
tenantId: tenantIdOf.parse(tenantId),
|
|
2144
2173
|
scopeId: scopeIdOf.parse(scopeId),
|
|
2145
2174
|
since: new Date().toISOString(),
|
|
2175
|
+
...(within ? { within } : {}),
|
|
2146
2176
|
});
|
|
2147
2177
|
return new Response(null, { status: 101, webSocket: client });
|
|
2148
2178
|
}
|
|
@@ -2266,6 +2296,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2266
2296
|
* to nobody. Knowing that a row exists and changed at 14:02 is information about
|
|
2267
2297
|
* that row, so the empty payload is not what makes this safe; this is.
|
|
2268
2298
|
*
|
|
2299
|
+
* *Narrowed, when the subscription asked to be* (#1853). A socket opened `within` an
|
|
2300
|
+
* entity hears only rows that reach it through live parent edges — each row's
|
|
2301
|
+
* ancestors are walked once per pass, however many sockets ask. A vouched root
|
|
2302
|
+
* replaces the check above, and its frames are `LiveNudge`s.
|
|
2303
|
+
*
|
|
2269
2304
|
* *Never able to fail the operation.* The write has committed and the caller has
|
|
2270
2305
|
* its answer. A socket that has gone away mid-fan-out, or a check that cannot be
|
|
2271
2306
|
* evaluated, costs a subscriber its live update — which it survives, because the
|
|
@@ -2285,6 +2320,25 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2285
2320
|
const announceable = rows.filter((r) => this.liveTargets.has(r.entity_type));
|
|
2286
2321
|
if (announceable.length === 0)
|
|
2287
2322
|
return;
|
|
2323
|
+
/**
|
|
2324
|
+
* Is a row's entity beneath a `within` root (#1853)? Each row's ancestors are walked
|
|
2325
|
+
* once per pass, on first need, so a check per socket is a set lookup — every widget
|
|
2326
|
+
* visitor's root is a different session, and a walk per (row, socket) would read the
|
|
2327
|
+
* same parent edges once per visitor. Post-commit state: a `ctx.relink` in the same
|
|
2328
|
+
* operation has already moved the edge the walk follows.
|
|
2329
|
+
*/
|
|
2330
|
+
const parents = scopeTupleReader(this.sql);
|
|
2331
|
+
const now = new Date().toISOString();
|
|
2332
|
+
const ancestors = new Map();
|
|
2333
|
+
const reaches = async (row, root) => {
|
|
2334
|
+
let up = ancestors.get(row.id);
|
|
2335
|
+
if (!up) {
|
|
2336
|
+
// A walk that cannot answer is a frame not sent — the same fail-closed rule as the check.
|
|
2337
|
+
up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
|
|
2338
|
+
ancestors.set(row.id, up);
|
|
2339
|
+
}
|
|
2340
|
+
return (await up).has(`${root.entityType}:${root.entityId}`);
|
|
2341
|
+
};
|
|
2288
2342
|
for (const ws of sockets) {
|
|
2289
2343
|
let subscription = null;
|
|
2290
2344
|
try {
|
|
@@ -2303,8 +2357,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2303
2357
|
// assumption about how the subscription was created.
|
|
2304
2358
|
if (subscription.tenantId !== tenantId || subscription.scopeId !== scopeId)
|
|
2305
2359
|
continue;
|
|
2360
|
+
const { within } = subscription;
|
|
2306
2361
|
// One context per subscriber, not per event: `ctx.check` is the expensive part
|
|
2307
|
-
// and the context is only the subject it is evaluated for.
|
|
2362
|
+
// and the context is only the subject it is evaluated for. Built on first use, so
|
|
2363
|
+
// a vouched subscriber — whose frames the walk alone decides — never builds one.
|
|
2308
2364
|
//
|
|
2309
2365
|
// A frame this subscriber does not pass is NOT recorded as a denial (K-35),
|
|
2310
2366
|
// and that is deliberate: `recordDenial` is called on a refused REQUEST, where
|
|
@@ -2315,35 +2371,50 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2315
2371
|
//
|
|
2316
2372
|
// The operation name is carried anyway, for the events a fan-out cannot emit
|
|
2317
2373
|
// but a future reader of this context might.
|
|
2318
|
-
|
|
2374
|
+
let ctx;
|
|
2375
|
+
const { principal } = subscription;
|
|
2376
|
+
const context = () => (ctx ??= this.operationContext(principal, tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe'));
|
|
2319
2377
|
for (const row of announceable) {
|
|
2320
|
-
//
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2378
|
+
// Narrowing first: it is memoised across sockets, and a row outside the root
|
|
2379
|
+
// is out whatever the principal holds.
|
|
2380
|
+
if (within && !(await reaches(row, within)))
|
|
2381
|
+
continue;
|
|
2382
|
+
let frame;
|
|
2383
|
+
if (within?.vouched !== undefined) {
|
|
2384
|
+
// The vertical vouched for the root, so the walk above was the whole filter
|
|
2385
|
+
// — and the subscriber holds no read on this row, so it is told only that
|
|
2386
|
+
// something beneath its root changed. Never which row, never how.
|
|
2387
|
+
frame = { kind: 'nudge', id: row.id, at: row.occurred_at };
|
|
2388
|
+
}
|
|
2389
|
+
else {
|
|
2390
|
+
// Non-null: `announceable` is exactly the rows whose type is in the map.
|
|
2391
|
+
const permission = this.liveTargets.get(row.entity_type);
|
|
2392
|
+
let allowed = false;
|
|
2393
|
+
try {
|
|
2394
|
+
const decision = await context().check(permission, {
|
|
2395
|
+
entityType: row.entity_type,
|
|
2396
|
+
entityId: row.entity_id,
|
|
2397
|
+
});
|
|
2398
|
+
allowed = decision.allowed;
|
|
2399
|
+
}
|
|
2400
|
+
catch {
|
|
2401
|
+
// A check that cannot answer is a check that refuses. The alternative —
|
|
2402
|
+
// treating an evaluator failure as an allow — turns an outage in the
|
|
2403
|
+
// permission path into a disclosure, which is the one failure mode this
|
|
2404
|
+
// surface must not have.
|
|
2405
|
+
allowed = false;
|
|
2406
|
+
}
|
|
2407
|
+
if (!allowed)
|
|
2408
|
+
continue;
|
|
2409
|
+
frame = {
|
|
2410
|
+
kind: 'change',
|
|
2411
|
+
id: row.id,
|
|
2412
|
+
type: row.type,
|
|
2325
2413
|
entityType: row.entity_type,
|
|
2326
2414
|
entityId: row.entity_id,
|
|
2327
|
-
|
|
2328
|
-
|
|
2415
|
+
at: row.occurred_at,
|
|
2416
|
+
};
|
|
2329
2417
|
}
|
|
2330
|
-
catch {
|
|
2331
|
-
// A check that cannot answer is a check that refuses. The alternative —
|
|
2332
|
-
// treating an evaluator failure as an allow — turns an outage in the
|
|
2333
|
-
// permission path into a disclosure, which is the one failure mode this
|
|
2334
|
-
// surface must not have.
|
|
2335
|
-
allowed = false;
|
|
2336
|
-
}
|
|
2337
|
-
if (!allowed)
|
|
2338
|
-
continue;
|
|
2339
|
-
const frame = {
|
|
2340
|
-
kind: 'change',
|
|
2341
|
-
id: row.id,
|
|
2342
|
-
type: row.type,
|
|
2343
|
-
entityType: row.entity_type,
|
|
2344
|
-
entityId: row.entity_id,
|
|
2345
|
-
at: row.occurred_at,
|
|
2346
|
-
};
|
|
2347
2418
|
try {
|
|
2348
2419
|
ws.send(JSON.stringify(frame));
|
|
2349
2420
|
}
|
|
@@ -2565,13 +2636,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2565
2636
|
*/
|
|
2566
2637
|
async attachmentSearch(term, limit, principal, tenantId, scopeId, connectionId) {
|
|
2567
2638
|
await this.ensureMigrations();
|
|
2568
|
-
|
|
2639
|
+
return replyOf(() => {
|
|
2569
2640
|
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.search');
|
|
2570
|
-
return
|
|
2571
|
-
}
|
|
2572
|
-
catch (err) {
|
|
2573
|
-
return { failure: toWireFailure(err) };
|
|
2574
|
-
}
|
|
2641
|
+
return this.searchAttachmentsAs(ctx, term, limit);
|
|
2642
|
+
});
|
|
2575
2643
|
}
|
|
2576
2644
|
/**
|
|
2577
2645
|
* #1575: the extraction job's read of one record. No gate, deliberately — extraction
|
|
@@ -2591,6 +2659,20 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2591
2659
|
await this.ensureMigrations();
|
|
2592
2660
|
return this.queue.enqueue(async () => this.revision.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
|
|
2593
2661
|
}
|
|
2662
|
+
/**
|
|
2663
|
+
* #1575: start the scope's one-shot backfill, unless it is marked or holds no
|
|
2664
|
+
* attachments (`startAttachmentTextBackfill` reads before it writes, so a marked scope
|
|
2665
|
+
* writes nothing — and moves no write revision — on every drive).
|
|
2666
|
+
*/
|
|
2667
|
+
async attachmentTextBackfillStart() {
|
|
2668
|
+
await this.ensureMigrations();
|
|
2669
|
+
await this.queue.enqueue(async () => this.revision.transactionSync(() => startAttachmentTextBackfill(doSpineSql(this.sql), ulid(), new Date().toISOString())));
|
|
2670
|
+
}
|
|
2671
|
+
/** #1575: one backfill batch, in one transaction (`queueAttachmentTextBackfill`). */
|
|
2672
|
+
async attachmentTextBackfillBatch(after) {
|
|
2673
|
+
await this.ensureMigrations();
|
|
2674
|
+
return this.queue.enqueue(async () => this.revision.transactionSync(() => queueAttachmentTextBackfill(doSpineSql(this.sql), after, ulid, new Date().toISOString())));
|
|
2675
|
+
}
|
|
2594
2676
|
// -- attachments through a capability (#1686) -------------------------------
|
|
2595
2677
|
// The coordinator hashed the session token; only the hash arrives. Each verb resolves it
|
|
2596
2678
|
// INSIDE the queue, as `invoke` does, so nothing can revoke between the resolution and
|
|
@@ -2714,19 +2796,48 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2714
2796
|
assertSystemDoor(moduleId, expected) {
|
|
2715
2797
|
if (moduleId === undefined)
|
|
2716
2798
|
return undefined;
|
|
2799
|
+
this.assertDoorPin(`module '${moduleId}'`, 'system door', expected);
|
|
2800
|
+
return { moduleId, [SYSTEM_DOOR_PASSED]: true };
|
|
2801
|
+
}
|
|
2802
|
+
/** #2029: the peer door's pin, for a call acting as `vertical:<slug>` — `assertDoorPin`. */
|
|
2803
|
+
assertPeerDoor(vertical, expected) {
|
|
2804
|
+
this.assertDoorPin(`vertical '${vertical}'`, 'peer door', expected);
|
|
2805
|
+
}
|
|
2806
|
+
/**
|
|
2807
|
+
* #2029: the peer door's pin for an RPC that answers a missed pin as `SystemDoorMoved` rather
|
|
2808
|
+
* than through `invoke`'s envelope: true when it missed. A call with no pin still throws.
|
|
2809
|
+
*/
|
|
2810
|
+
peerDoorMoved(vertical, expected) {
|
|
2811
|
+
try {
|
|
2812
|
+
this.assertPeerDoor(vertical, expected);
|
|
2813
|
+
return false;
|
|
2814
|
+
}
|
|
2815
|
+
catch (err) {
|
|
2816
|
+
if (err instanceof SystemDoorMovedError)
|
|
2817
|
+
return true;
|
|
2818
|
+
throw err;
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* #1834's pin, for any door (#2029: the peer door's too). A call acting as a switched subject
|
|
2823
|
+
* must carry the instance the host's door gate read, and is refused on any other instance —
|
|
2824
|
+
* the moved one as the module-private `SystemDoorMovedError`, so the door re-gates.
|
|
2825
|
+
*/
|
|
2826
|
+
assertDoorPin(what, door, expected) {
|
|
2717
2827
|
// Strict, and deliberately so: no pin means no door gated this call, so it is refused rather
|
|
2718
2828
|
// than run unchecked. The one innocent caller is a worker a deploy behind, still running host
|
|
2719
2829
|
// code from before the pin, during a rolling deploy. Its refusal is transient: the host and this
|
|
2720
2830
|
// object ship in the same bundle, so the window lasts as long as the rollout. Nothing ran, and
|
|
2721
2831
|
// the reason says "not now" (`SYSTEM_DOOR_WAIT`) to whoever reads it: a schedule fires on the
|
|
2722
|
-
// next pass,
|
|
2832
|
+
// next pass, a job run retries on its next drive, and a peer caller retries.
|
|
2723
2833
|
if (expected === undefined) {
|
|
2724
|
-
throw substratError('forbidden', `a call acting as
|
|
2834
|
+
throw substratError('forbidden', `a call acting as ${what} reached this scope without passing the ${door}`, {
|
|
2835
|
+
reason: SYSTEM_DOOR_WAIT,
|
|
2836
|
+
});
|
|
2725
2837
|
}
|
|
2726
2838
|
if (expected !== this.instanceId) {
|
|
2727
|
-
throw new SystemDoorMovedError(
|
|
2839
|
+
throw new SystemDoorMovedError(`the scope restarted after the ${door}'s gate read it; gate it again`);
|
|
2728
2840
|
}
|
|
2729
|
-
return { moduleId, [SYSTEM_DOOR_PASSED]: true };
|
|
2730
2841
|
}
|
|
2731
2842
|
/**
|
|
2732
2843
|
* The pre-#1666 read, kept for ONE reason: a coordinator a deploy behind this DO still
|
|
@@ -2757,8 +2868,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2757
2868
|
* `switchSystemSchedules`, serialized on the queue with every other tuple write and run
|
|
2758
2869
|
* as one `transactionSync`, so its reads and writes are one unit.
|
|
2759
2870
|
*/
|
|
2760
|
-
async switchSystemSchedules(moduleId, scopeId, to, at, tenantHeld = false
|
|
2761
|
-
|
|
2871
|
+
async switchSystemSchedules(moduleId, scopeId, to, at, tenantHeld = false,
|
|
2872
|
+
/** #2045: the switch call's fence — the scope refuses a move older than the one it applied. */
|
|
2873
|
+
fence) {
|
|
2874
|
+
const outcome = await this.queue.enqueue(() => this.revision.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at, tenantHeld, fence })));
|
|
2762
2875
|
// #1819: the instance that applied it, which the rewind hold's release rule reads.
|
|
2763
2876
|
return { ...outcome, instance: this.instanceId };
|
|
2764
2877
|
}
|
|
@@ -2779,8 +2892,25 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2779
2892
|
* Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
|
|
2780
2893
|
* the schedule switch's statement with a `vertical:` subject. Queued, one transaction.
|
|
2781
2894
|
*/
|
|
2782
|
-
async switchPeer(vertical, scopeId, to, at
|
|
2783
|
-
|
|
2895
|
+
async switchPeer(vertical, scopeId, to, at,
|
|
2896
|
+
/** #2030: the directory holds a live tenant-level `vertical:` grant for the peer. */
|
|
2897
|
+
tenantHeld = false,
|
|
2898
|
+
/** #2045: the switch call's fence — the scope refuses a move older than the one it applied. */
|
|
2899
|
+
fence) {
|
|
2900
|
+
const outcome = await this.queue.enqueue(() => this.revision.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at, tenantHeld, fence })));
|
|
2901
|
+
// #2029: the instance that applied it, which the rewind hold's release rule reads.
|
|
2902
|
+
return { ...outcome, instance: this.instanceId };
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* #2029: the peer door's state read — where one peer stands here (`subjectGrantState` over its
|
|
2906
|
+
* subject, the predicate `admitPeer` refuses on), and the instance that answered it. The door
|
|
2907
|
+
* consults the rewind hold after this read and pins every call it then makes to this instance.
|
|
2908
|
+
*/
|
|
2909
|
+
async peerDoorState(vertical) {
|
|
2910
|
+
return {
|
|
2911
|
+
state: subjectGrantState(this.switchSql(), peerSubjectRef(vertical), new Date().toISOString()),
|
|
2912
|
+
instance: this.instanceId,
|
|
2913
|
+
};
|
|
2784
2914
|
}
|
|
2785
2915
|
/**
|
|
2786
2916
|
* Does peer `vertical` hold each key at this scope's node now (#1706) — the checker's own
|
|
@@ -2788,7 +2918,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2788
2918
|
* is the target scope only to satisfy the type: `covers` records nothing, and the tuple ref
|
|
2789
2919
|
* it walks is the slug alone.
|
|
2790
2920
|
*/
|
|
2791
|
-
async peerCovers(tenantId, scopeId, vertical, permissions
|
|
2921
|
+
async peerCovers(tenantId, scopeId, vertical, permissions,
|
|
2922
|
+
/** #2029: the instance the peer door's gate read; on another, nothing is read. */
|
|
2923
|
+
doorInstance) {
|
|
2924
|
+
if (this.peerDoorMoved(vertical, doorInstance))
|
|
2925
|
+
return SYSTEM_DOOR_MOVED;
|
|
2926
|
+
return this.peerCoverage(tenantId, scopeId, vertical, permissions);
|
|
2927
|
+
}
|
|
2928
|
+
/** `peerCovers`' read, for this object's own callers, which gated the peer themselves. */
|
|
2929
|
+
async peerCoverage(tenantId, scopeId, vertical, permissions) {
|
|
2792
2930
|
await this.ensureMigrations();
|
|
2793
2931
|
return this.queue.enqueue(async () => {
|
|
2794
2932
|
const coverage = await this.checker.covers({ kind: 'vertical', id: vertical, scope: scopeId }, permissions, { tenantId, scopeId });
|
|
@@ -2801,9 +2939,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2801
2939
|
* so no invoke's open transaction is read half-done. `null` when the tenant defines no such
|
|
2802
2940
|
* role: a typed error thrown here would reach the host flattened, so the host types it.
|
|
2803
2941
|
*/
|
|
2804
|
-
async canAssignFor(tenantId, scopeId, principal, roleKey
|
|
2942
|
+
async canAssignFor(tenantId, scopeId, principal, roleKey,
|
|
2943
|
+
/** #1184: bound at the TENANT node instead — a membership executor assigning tenant-wide. */
|
|
2944
|
+
atTenant = false) {
|
|
2805
2945
|
await this.ensureMigrations();
|
|
2806
|
-
return this.queue.enqueue(() => this.assignmentBound({ kind: 'principal', id: principal }, tenantId, scopeId, roleKey));
|
|
2946
|
+
return this.queue.enqueue(() => this.assignmentBound({ kind: 'principal', id: principal }, tenantId, atTenant ? null : scopeId, roleKey));
|
|
2807
2947
|
}
|
|
2808
2948
|
/** Check and grant in one serialized scope task; a refusal never writes a tuple. */
|
|
2809
2949
|
async assignScopeRoleBoundedFor(tenantId, scopeId, caller, assignee, roleKey) {
|
|
@@ -2811,12 +2951,33 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2811
2951
|
return this.queue.enqueue(async () => {
|
|
2812
2952
|
const bound = await this.assignmentBound({ kind: 'principal', id: caller }, tenantId, scopeId, roleKey);
|
|
2813
2953
|
if (bound?.covered) {
|
|
2814
|
-
this.
|
|
2815
|
-
VALUES (?, ?, ?, NULL)`, `principal:${assignee}`, `role:${roleKey}`, `scope:${scopeId}`);
|
|
2954
|
+
applyScopeRoleChange(this.switchSql(), scopeId, assignee, { revoke: [], grant: roleKey }, new Date().toISOString());
|
|
2816
2955
|
}
|
|
2817
2956
|
return bound;
|
|
2818
2957
|
});
|
|
2819
2958
|
}
|
|
2959
|
+
/** The scope's live scope-level role assignments, or one principal's (#1150). */
|
|
2960
|
+
async scopeRoleHoldersFor(scopeId, principal) {
|
|
2961
|
+
await this.ensureMigrations();
|
|
2962
|
+
return this.queue.enqueue(() => scopeRoleHolders(this.switchSql(), scopeId, new Date().toISOString(), principal));
|
|
2963
|
+
}
|
|
2964
|
+
/**
|
|
2965
|
+
* The kernel's `changeScopeRole` in one serialized scope task (#1150). Its two refusals come
|
|
2966
|
+
* back as values: an error thrown here crosses the RPC flattened, so the coordinator types them.
|
|
2967
|
+
*/
|
|
2968
|
+
async changeScopeRoleBoundedFor(tenantId, scopeId, caller, principal, from, to) {
|
|
2969
|
+
await this.ensureMigrations();
|
|
2970
|
+
return this.queue.enqueue(() => changeScopeRole(this.switchSql(), scopeId, principal, from, to, new Date().toISOString(), this.roleBound(caller, tenantId, scopeId), (run) => this.revision.transactionSync(run)));
|
|
2971
|
+
}
|
|
2972
|
+
/** The kernel's `revokeScopeRoles` in one serialized scope task (#1150). */
|
|
2973
|
+
async revokeScopeRolesBoundedFor(tenantId, scopeId, caller, principal) {
|
|
2974
|
+
await this.ensureMigrations();
|
|
2975
|
+
return this.queue.enqueue(() => revokeScopeRoles(this.switchSql(), scopeId, principal, new Date().toISOString(), this.roleBound(caller, tenantId, scopeId), (run) => this.revision.transactionSync(run)));
|
|
2976
|
+
}
|
|
2977
|
+
/** The caller's bound per role at the scope; `null` for a role the tenant does not define. */
|
|
2978
|
+
roleBound(caller, tenantId, scopeId) {
|
|
2979
|
+
return (roleKey) => this.assignmentBound({ kind: 'principal', id: caller }, tenantId, scopeId, roleKey);
|
|
2980
|
+
}
|
|
2820
2981
|
/**
|
|
2821
2982
|
* §5.1's assignment bound, resolved against the SAME role table the checker
|
|
2822
2983
|
* expands — the tenant's projected role, not a vertical's compile-time `ROLES`
|
|
@@ -2844,7 +3005,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2844
3005
|
// the host can tell storage the rewind will discard from storage it restored. On ONE object
|
|
2845
3006
|
// per deployment, the one named `SWITCH_HOLDS_NAME` and never a scope's own: the claims
|
|
2846
3007
|
// themselves, because a rewind replaces the rewound scope's whole storage. The claims table
|
|
2847
|
-
// is created on first use, so no scope ever carries it.
|
|
3008
|
+
// is created on first use, so no scope ever carries it. #2029: `module_id` holds a hold KEY —
|
|
3009
|
+
// a module id, bare as #1819 stored it, or a peer's `vertical:<slug>`, which no module id can
|
|
3010
|
+
// spell (a module id has no `:`).
|
|
2848
3011
|
/** This instance, and nothing before or after it: a restart is a new id. */
|
|
2849
3012
|
instanceId = crypto.randomUUID();
|
|
2850
3013
|
/** Whether this instance armed a rewind: then its writes are discarded at the restart. */
|
|
@@ -3204,9 +3367,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3204
3367
|
});
|
|
3205
3368
|
return row;
|
|
3206
3369
|
}
|
|
3207
|
-
/**
|
|
3208
|
-
|
|
3209
|
-
|
|
3370
|
+
/**
|
|
3371
|
+
* #2034 (#2042 review r1): the FENCE for a coordinator from before leases. Its only caller is
|
|
3372
|
+
* that coordinator's drive, which re-reads a run here and then runs the pass WITHOUT claiming
|
|
3373
|
+
* it, so a row handed back could run beside a claimed pass. It answers "no such run" to it,
|
|
3374
|
+
* always: the old drive skips, and only a coordinator that claims ever drives. Nothing else
|
|
3375
|
+
* reads a run through this method (the operator read is `jobRunList`). The coordinator ships in
|
|
3376
|
+
* the same script as this class, so an old one exists only for a deploy's overlap.
|
|
3377
|
+
*/
|
|
3378
|
+
async jobRunById(_id) {
|
|
3379
|
+
return null;
|
|
3210
3380
|
}
|
|
3211
3381
|
/** Insert a fresh run. The coordinator has already refused a non-queue-safe payload. */
|
|
3212
3382
|
async jobRunInsert(row) {
|
|
@@ -3216,30 +3386,19 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3216
3386
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.module_id, row.job, row.instance, row.payload, row.status, row.cursor, row.counters, row.attempts, row.last_error, row.started_at, row.updated_at, row.next_attempt_at, row.ended_at, row.subject_id ?? null);
|
|
3217
3387
|
}
|
|
3218
3388
|
/**
|
|
3219
|
-
* `running` runs whose backoff has elapsed, oldest first, after `afterId
|
|
3220
|
-
*
|
|
3221
|
-
* The cursor is what lets the coordinator page past runs it cannot drive: it
|
|
3222
|
-
* skips any whose job this deployment does not register, and without a cursor
|
|
3223
|
-
* those rows head every batch forever (`runDueJobRuns`). `afterId` is LAST, as
|
|
3224
|
-
* every argument added to an RPC on this interface must be.
|
|
3389
|
+
* Was: `running` runs whose backoff has elapsed, oldest first, after `afterId` — the due read of
|
|
3390
|
+
* a coordinator from before #1834, which drove every row it returned. Now always empty (below).
|
|
3225
3391
|
*/
|
|
3226
|
-
async jobRunsDue(
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
// parameters makes the anonymous ones resume from the highest index used,
|
|
3231
|
-
// which is a footgun for the next person to add a clause. Spelled exactly
|
|
3232
|
-
// as the pure adapter spells it.
|
|
3233
|
-
`SELECT * FROM _substrat_job_runs
|
|
3234
|
-
WHERE status = 'running' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
3235
|
-
AND (? IS NULL OR id > ?)
|
|
3236
|
-
ORDER BY id LIMIT ?`, now, afterId ?? null, afterId ?? null, limit)
|
|
3237
|
-
.toArray();
|
|
3392
|
+
async jobRunsDue(_now, _limit, _afterId) {
|
|
3393
|
+
// #2034 (#2042 review r1): fenced, as `jobRunById` is. Its only caller is a coordinator from
|
|
3394
|
+
// before #1834, which runs every row this returns without claiming it.
|
|
3395
|
+
return [];
|
|
3238
3396
|
}
|
|
3239
3397
|
/**
|
|
3240
3398
|
* #1834: the drive's ONE snapshot of due runs — keys only, in the order they became due
|
|
3241
|
-
* (`JOB_RUN_DUE_AT`, then id). Spelled exactly as the pure adapter spells it.
|
|
3242
|
-
*
|
|
3399
|
+
* (`JOB_RUN_DUE_AT`, then id). Spelled exactly as the pure adapter spells it. A coordinator
|
|
3400
|
+
* from before #2034 reads this too, but drives nothing from it: its re-read (`jobRunById`)
|
|
3401
|
+
* is fenced.
|
|
3243
3402
|
*/
|
|
3244
3403
|
async jobRunsDueKeys(now, max) {
|
|
3245
3404
|
return this.sql
|
|
@@ -3278,20 +3437,99 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3278
3437
|
* partial update that wrote three of the four would leave a run carrying the
|
|
3279
3438
|
* last failure's error beside the new cursor, which reads as broken forever.
|
|
3280
3439
|
*/
|
|
3281
|
-
async jobRunPatch(id, patch) {
|
|
3282
|
-
// A compare-and-set on `running` (#1632) — see
|
|
3283
|
-
|
|
3440
|
+
async jobRunPatch(id, patch, owner) {
|
|
3441
|
+
// A compare-and-set on `running` (#1632) and on the pass's lease (#2034) — see
|
|
3442
|
+
// `JOB_RUN_PATCH_SQL`. `owner` is LAST and optional: a coordinator from before leases
|
|
3443
|
+
// sends none, and patches the unleased rows it drives.
|
|
3444
|
+
return this.sql.exec(JOB_RUN_PATCH_SQL, ...this.jobPatchArgs(id, patch, owner)).rowsWritten > 0;
|
|
3445
|
+
}
|
|
3446
|
+
jobPatchArgs(id, patch, owner) {
|
|
3447
|
+
return [
|
|
3448
|
+
patch.status, patch.cursor, patch.counters, patch.attempts, patch.lastError,
|
|
3449
|
+
patch.updatedAt, patch.nextAttemptAt, patch.endedAt, id, owner ?? null,
|
|
3450
|
+
];
|
|
3451
|
+
}
|
|
3452
|
+
/**
|
|
3453
|
+
* #2034: the claim — `JOB_RUN_CLAIM_SQL`, and whether it took over a lease, read in ONE
|
|
3454
|
+
* `transactionSync`: the single round trip is what makes it indivisible, as in
|
|
3455
|
+
* `jobRunStartOrJoin`. Null = the run is no longer running and due.
|
|
3456
|
+
*/
|
|
3457
|
+
async jobRunClaim(id, owner, leaseMs) {
|
|
3458
|
+
return this.revision.transactionSync(() => {
|
|
3459
|
+
// #2042 r2, r4: a takeover is charged only when the lease it takes had BEGUN its pass.
|
|
3460
|
+
const before = this.sql.exec('SELECT lease_began_at FROM _substrat_job_runs WHERE id = ?', id).toArray()[0];
|
|
3461
|
+
// #2042 r4: the due test and the expiry are THIS object's clock, never the coordinator's.
|
|
3462
|
+
const now = Date.now();
|
|
3463
|
+
const at = new Date(now).toISOString();
|
|
3464
|
+
const claimed = this.sql
|
|
3465
|
+
.exec(JOB_RUN_CLAIM_SQL, owner, new Date(now + leaseMs).toISOString(), at, JOB_LEASE_EXPIRED_NOTE, id, at)
|
|
3466
|
+
.toArray()[0];
|
|
3467
|
+
return claimed ? { run: claimed, takeover: (before?.lease_began_at ?? null) !== null } : null;
|
|
3468
|
+
});
|
|
3469
|
+
}
|
|
3470
|
+
/**
|
|
3471
|
+
* #2042 r3, r4: an admission miss — `JOB_RUN_MISS_SQL` (the relative count) and
|
|
3472
|
+
* `JOB_RUN_MISS_SETTLE_SQL` (the backoff or failure that count calls for) in ONE transaction,
|
|
3473
|
+
* by this object's clock. Null = the claim no longer held the run, or had begun.
|
|
3474
|
+
*/
|
|
3475
|
+
async jobRunMiss(id, owner, note) {
|
|
3476
|
+
return this.revision.transactionSync(() => {
|
|
3477
|
+
const at = new Date(Date.now()).toISOString();
|
|
3478
|
+
const counted = this.sql.exec(JOB_RUN_MISS_SQL, at, id, owner).toArray()[0];
|
|
3479
|
+
if (!counted)
|
|
3480
|
+
return null;
|
|
3481
|
+
const o = admissionMissOutcome(counted.admission_misses, at, note);
|
|
3482
|
+
this.sql.exec(JOB_RUN_MISS_SETTLE_SQL, o.status, o.nextAttemptAt, o.endedAt, o.lastError, id, counted.admission_misses);
|
|
3483
|
+
return { misses: counted.admission_misses, failed: o.status === 'failed' };
|
|
3484
|
+
});
|
|
3485
|
+
}
|
|
3486
|
+
/**
|
|
3487
|
+
* #2034 (#2042 r4): BEGIN a claimed pass — `JOB_RUN_BEGIN_SQL`, the commitment point: one
|
|
3488
|
+
* compare-and-set judged by this object's clock as it runs, never by a time the coordinator
|
|
3489
|
+
* computed. The coordinator invokes the handler if and only if this wrote.
|
|
3490
|
+
*/
|
|
3491
|
+
async jobRunBegin(id, owner, marginMs) {
|
|
3492
|
+
const now = Date.now();
|
|
3493
|
+
return (this.sql.exec(JOB_RUN_BEGIN_SQL, new Date(now).toISOString(), id, owner, new Date(now + marginMs).toISOString())
|
|
3494
|
+
.rowsWritten > 0);
|
|
3284
3495
|
}
|
|
3285
|
-
/**
|
|
3496
|
+
/**
|
|
3497
|
+
* #2034: a step boundary — renew the pass's lease, then read the step's ledger row, in one
|
|
3498
|
+
* round trip. A pass that lost its lease reads nothing and stops.
|
|
3499
|
+
*/
|
|
3500
|
+
async jobStepBegin(runId, step, owner, leaseMs) {
|
|
3501
|
+
// #2042 r4: renewed to this object's now plus the lease.
|
|
3502
|
+
const until = new Date(Date.now() + leaseMs).toISOString();
|
|
3503
|
+
if (this.sql.exec(JOB_RUN_RENEW_SQL, until, runId, owner).rowsWritten === 0)
|
|
3504
|
+
return { held: false, row: null };
|
|
3505
|
+
return { held: true, row: this.stepRow(runId, step) };
|
|
3506
|
+
}
|
|
3507
|
+
/**
|
|
3508
|
+
* One step's ledger row — a non-null `result` is what means completed. Not called by a
|
|
3509
|
+
* coordinator since #2034 (`jobStepBegin` reads it while renewing); kept for one a deploy behind.
|
|
3510
|
+
*/
|
|
3286
3511
|
async jobStepRow(runId, step) {
|
|
3512
|
+
return this.stepRow(runId, step);
|
|
3513
|
+
}
|
|
3514
|
+
stepRow(runId, step) {
|
|
3287
3515
|
return (this.sql
|
|
3288
3516
|
.exec('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?', runId, step)
|
|
3289
3517
|
.toArray()[0] ?? null);
|
|
3290
3518
|
}
|
|
3291
3519
|
/** Record one step attempt. `result` non-null = it completed and must not re-run. */
|
|
3292
|
-
async jobStepRecord(runId, step, result, attempts, lastError, at) {
|
|
3293
|
-
// Only while the run is still `running` (#1632)
|
|
3294
|
-
this
|
|
3520
|
+
async jobStepRecord(runId, step, result, attempts, lastError, at, owner, leaseMs) {
|
|
3521
|
+
// Only while the run is still `running` (#1632) and the pass holds its lease (#2034),
|
|
3522
|
+
// renewing the lease — to this object's now plus `leaseMs` (#2042 r4) — in the same
|
|
3523
|
+
// transaction; see `JOB_STEP_RECORD_SQL`. A coordinator from before leases sends neither,
|
|
3524
|
+
// holds no lease, and renews nothing.
|
|
3525
|
+
return this.revision.transactionSync(() => {
|
|
3526
|
+
const until = leaseMs === undefined ? null : new Date(Date.now() + leaseMs).toISOString();
|
|
3527
|
+
if (until !== null && this.sql.exec(JOB_RUN_RENEW_SQL, until, runId, owner ?? null).rowsWritten === 0) {
|
|
3528
|
+
return false;
|
|
3529
|
+
}
|
|
3530
|
+
this.sql.exec(JOB_STEP_RECORD_SQL, runId, step, result, attempts, lastError, at, runId, owner ?? null);
|
|
3531
|
+
return true;
|
|
3532
|
+
});
|
|
3295
3533
|
}
|
|
3296
3534
|
/**
|
|
3297
3535
|
* A COMMITTED pass: the run's new state and the dropping of its step ledger, in
|
|
@@ -3304,13 +3542,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3304
3542
|
* results, not to the cursor) then skips work it never did. The kernel's
|
|
3305
3543
|
* `runJobPass` carries the full argument.
|
|
3306
3544
|
*/
|
|
3307
|
-
async jobCommitPass(id, patch) {
|
|
3545
|
+
async jobCommitPass(id, patch, owner) {
|
|
3308
3546
|
// `transactionSync` with both statements inline — NOT `await
|
|
3309
3547
|
// this.jobRunPatch(...)` then the delete. The await is an output-gate
|
|
3310
3548
|
// boundary, which is precisely the gap this method exists to close.
|
|
3311
|
-
this.revision.transactionSync(() => {
|
|
3312
|
-
|
|
3549
|
+
return this.revision.transactionSync(() => {
|
|
3550
|
+
// #2034: the ledger goes only with a patch that applied — a stale holder's commit must
|
|
3551
|
+
// not empty the ledger of the pass that took the run over.
|
|
3552
|
+
if (this.sql.exec(JOB_RUN_PATCH_SQL, ...this.jobPatchArgs(id, patch, owner)).rowsWritten === 0)
|
|
3553
|
+
return false;
|
|
3313
3554
|
this.sql.exec('DELETE FROM _substrat_job_steps WHERE run_id = ?', id);
|
|
3555
|
+
return true;
|
|
3314
3556
|
});
|
|
3315
3557
|
}
|
|
3316
3558
|
// -- guards (K-17) --------------------------------------------------------
|
|
@@ -3763,13 +4005,26 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3763
4005
|
system: isSystemTable(name),
|
|
3764
4006
|
}));
|
|
3765
4007
|
}
|
|
3766
|
-
/** A bounded page of one table. Unknown table names throw — never queried blind.
|
|
4008
|
+
/** A bounded page of one table. Unknown table names throw — never queried blind. Kept for a
|
|
4009
|
+
* coordinator from before #113; the current one calls `introspectTableReply`. */
|
|
3767
4010
|
introspectTable(table, limit, offset) {
|
|
4011
|
+
try {
|
|
4012
|
+
return this.tablePage(table, limit, offset);
|
|
4013
|
+
}
|
|
4014
|
+
catch (e) {
|
|
4015
|
+
throw toRpcError(e);
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
/** `introspectTable`, its refusal answered as DATA so its code survives the hop (#113). */
|
|
4019
|
+
introspectTableReply(table, limit, offset) {
|
|
4020
|
+
return replyOf(() => this.tablePage(table, limit, offset));
|
|
4021
|
+
}
|
|
4022
|
+
tablePage(table, limit, offset) {
|
|
3768
4023
|
const known = new Set(this.sql
|
|
3769
4024
|
.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
|
3770
4025
|
.toArray().map((r) => r.name));
|
|
3771
4026
|
if (!known.has(table))
|
|
3772
|
-
throw
|
|
4027
|
+
throw substratError('not_found', `unknown table '${table}'`);
|
|
3773
4028
|
// The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
|
|
3774
4029
|
// negative) is refused instead of reaching LIMIT / OFFSET (#1632).
|
|
3775
4030
|
const l = Math.min(assertRowLimit('limit', limit), SCOPE_TABLE_PAGE_MAX);
|
|
@@ -3788,8 +4043,21 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3788
4043
|
* (no sqlite3_stmt_readonly analogue), a transaction that ALWAYS rolls back — a
|
|
3789
4044
|
* statement the gate misclassified still cannot persist a write. Rows are capped
|
|
3790
4045
|
* at SCOPE_QUERY_ROW_MAX with `truncated` set, never an error.
|
|
4046
|
+
* Kept for a coordinator from before #113; the current one calls `introspectQueryReply`.
|
|
3791
4047
|
*/
|
|
3792
4048
|
async introspectQuery(sql) {
|
|
4049
|
+
try {
|
|
4050
|
+
return await this.readOnlyQuery(sql);
|
|
4051
|
+
}
|
|
4052
|
+
catch (e) {
|
|
4053
|
+
throw toRpcError(e);
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
/** `introspectQuery`, its refusal answered as DATA so its code survives the hop (#113). */
|
|
4057
|
+
introspectQueryReply(sql) {
|
|
4058
|
+
return replyOf(() => this.readOnlyQuery(sql));
|
|
4059
|
+
}
|
|
4060
|
+
async readOnlyQuery(sql) {
|
|
3793
4061
|
const stmt = assertReadOnlyQuery(sql);
|
|
3794
4062
|
let result;
|
|
3795
4063
|
const rollback = new Error('read-only console rollback');
|
|
@@ -3813,7 +4081,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3813
4081
|
}
|
|
3814
4082
|
catch (e) {
|
|
3815
4083
|
if (e !== rollback)
|
|
3816
|
-
throw
|
|
4084
|
+
throw e;
|
|
3817
4085
|
}
|
|
3818
4086
|
return result;
|
|
3819
4087
|
}
|
|
@@ -3934,6 +4202,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3934
4202
|
'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
|
|
3935
4203
|
// #1632: legacy runs retain an unknown subject; no content-based backfill.
|
|
3936
4204
|
'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
|
|
4205
|
+
// #2034: the lease. NULL = nobody holds the run, which is right for every row already there.
|
|
4206
|
+
'ALTER TABLE _substrat_job_runs ADD COLUMN lease_owner TEXT',
|
|
4207
|
+
// #2042 r2, r4: whether the holder BEGAN its pass. NULL for any lease already there: free to take over.
|
|
4208
|
+
'ALTER TABLE _substrat_job_runs ADD COLUMN lease_began_at TEXT',
|
|
4209
|
+
// #2042 r3: consecutive admission misses. NULL = none, right for every run already there.
|
|
4210
|
+
'ALTER TABLE _substrat_job_runs ADD COLUMN admission_misses INTEGER',
|
|
3937
4211
|
// Executor retry state (#100). The defaults read as "terminal", which is
|
|
3938
4212
|
// right for every row already there: each is a completed delivery or a
|
|
3939
4213
|
// consumer dead-letter.
|
|
@@ -4231,6 +4505,23 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4231
4505
|
for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
|
|
4232
4506
|
this.sql.exec(stmt);
|
|
4233
4507
|
}
|
|
4508
|
+
// #119: the never-born-archived trigger went with the dropped table. Put back AFTER the
|
|
4509
|
+
// rows, which may legitimately arrive archived or trashed.
|
|
4510
|
+
for (const plan of this.statePlans.values()) {
|
|
4511
|
+
if (!present.has(plan.table))
|
|
4512
|
+
continue;
|
|
4513
|
+
for (const stmt of splitSqlStatements(entityStateTriggerDdl(plan)))
|
|
4514
|
+
this.sql.exec(stmt);
|
|
4515
|
+
}
|
|
4516
|
+
// #811 / #119: the derived list indexes went with the dropped table too, and a load never
|
|
4517
|
+
// put them back — an archivable entity's partial indexes are part of what the kernel
|
|
4518
|
+
// checks after DDL.
|
|
4519
|
+
for (const plan of this.listPlans.values()) {
|
|
4520
|
+
if (!present.has(plan.table))
|
|
4521
|
+
continue;
|
|
4522
|
+
for (const stmt of splitSqlStatements(listIndexDdl(plan)))
|
|
4523
|
+
this.sql.exec(stmt);
|
|
4524
|
+
}
|
|
4234
4525
|
// #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
|
|
4235
4526
|
// as on a wake. A copy's own events then sort above every copied one, which is what
|
|
4236
4527
|
// `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
|
|
@@ -4559,36 +4850,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4559
4850
|
return redacted;
|
|
4560
4851
|
}
|
|
4561
4852
|
// -- event dispatch (port of dispatch) ------------------------------------
|
|
4562
|
-
/**
|
|
4563
|
-
* #1237: the event currently being delivered, or null.
|
|
4564
|
-
*
|
|
4565
|
-
* The host carries a field of the same name for the admin log, and it is
|
|
4566
|
-
* unreachable from here — it lives in the worker that holds the stub, while every
|
|
4567
|
-
* emit runs inside this Durable Object. So the DO keeps its own, and the two are
|
|
4568
|
-
* deliberately separate rather than one passed across the hop.
|
|
4569
|
-
*
|
|
4570
|
-
* One instance IS one scope, so this field is per-scope by construction. The
|
|
4571
|
-
* SQLite twin has to say so explicitly (`ScopeRuntime.causedBy`): that host holds
|
|
4572
|
-
* every scope in the process, so the same field on the host would be read by
|
|
4573
|
-
* another scope's emit the moment a consumer awaits.
|
|
4574
|
-
*/
|
|
4575
|
-
causedBy = null;
|
|
4576
4853
|
/**
|
|
4577
4854
|
* #1237: the invocation currently running in this DO, or null.
|
|
4578
4855
|
*
|
|
4579
|
-
* DO-local
|
|
4580
|
-
*
|
|
4581
|
-
*
|
|
4582
|
-
*
|
|
4856
|
+
* DO-local: a Durable Object IS one scope, so there is no other scope's call to
|
|
4857
|
+
* confuse it with, and `invoke` sets and clears it inside its queued body, which no
|
|
4858
|
+
* other emitting body interleaves with. It still has to be cleared, because the DO
|
|
4859
|
+
* outlives the request and a value left set would stamp a later alarm-driven drain
|
|
4860
|
+
* with a call it had nothing to do with. A consumer's own cause is not kept here: it is
|
|
4861
|
+
* passed into the consumer's context (`ConsumerDelivery`, #2055).
|
|
4583
4862
|
*/
|
|
4584
4863
|
invocationId = null;
|
|
4585
|
-
/**
|
|
4586
|
-
* #1901: the id a unit of async work with no call around it logs under — a consumer a
|
|
4587
|
-
* seed or an import delivered — set for its handler's duration and cleared after. Read
|
|
4588
|
-
* by `ctx.log` only, never the spine: an event a consumer emits outside a call still
|
|
4589
|
-
* records no invocation (#1525), while its log lines join the consumer's own line.
|
|
4590
|
-
*/
|
|
4591
|
-
unitInvocationId = null;
|
|
4592
4864
|
async dispatch(tenantId, scopeId,
|
|
4593
4865
|
/**
|
|
4594
4866
|
* #1525: the call this drain is running in, or null. Every round of the loop
|
|
@@ -4651,16 +4923,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4651
4923
|
lines.write({ ...unit, outcome: 'dead-lettered' });
|
|
4652
4924
|
continue;
|
|
4653
4925
|
}
|
|
4654
|
-
// #1237: anything this consumer emits was emitted BECAUSE of this event
|
|
4655
|
-
// — the step a backwards walk used to stop dead at, since a consumer
|
|
4656
|
-
// emit records no operation either.
|
|
4657
|
-
this.causedBy = event.id;
|
|
4658
|
-
// #1901: the handler's `ctx.log` lines join this delivery's line.
|
|
4659
|
-
this.unitInvocationId = unit.invocationId;
|
|
4660
4926
|
try {
|
|
4661
4927
|
await this.revision.transaction(async () => {
|
|
4662
|
-
|
|
4663
|
-
|
|
4928
|
+
// #1237: anything this consumer emits was emitted BECAUSE of this event —
|
|
4929
|
+
// the step a backwards walk used to stop dead at, since a consumer emit
|
|
4930
|
+
// records no operation either. #1901: its `ctx.log` lines join this
|
|
4931
|
+
// delivery's line.
|
|
4932
|
+
const ctx = this.consumerContext(tenantId, scopeId, mod.id, {
|
|
4933
|
+
causedBy: event.id,
|
|
4934
|
+
invocationId: unit.invocationId,
|
|
4664
4935
|
});
|
|
4665
4936
|
await consumer.handler(ctx, event);
|
|
4666
4937
|
this.sql.exec(`INSERT INTO _substrat_deliveries
|
|
@@ -4678,14 +4949,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4678
4949
|
VALUES (?, ?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4679
4950
|
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
4680
4951
|
}
|
|
4681
|
-
finally {
|
|
4682
|
-
this.unitInvocationId = null;
|
|
4683
|
-
// Cleared on BOTH paths. Left set, the id leaks onto every later emit
|
|
4684
|
-
// this DO makes — an operation's own event stamped as caused by
|
|
4685
|
-
// whatever was delivered last, which is worse than recording nothing
|
|
4686
|
-
// because it reads as a fact.
|
|
4687
|
-
this.causedBy = null;
|
|
4688
|
-
}
|
|
4689
4952
|
}
|
|
4690
4953
|
}
|
|
4691
4954
|
}
|
|
@@ -4800,11 +5063,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4800
5063
|
* the queued body. Mutually exclusive with the other non-person callers; `principal` is
|
|
4801
5064
|
* then a placeholder that the subject below never reads.
|
|
4802
5065
|
*/
|
|
4803
|
-
peerSubject
|
|
5066
|
+
peerSubject,
|
|
5067
|
+
/** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
|
|
5068
|
+
delivery) {
|
|
4804
5069
|
const checker = this.checker;
|
|
4805
5070
|
const relations = this.relations;
|
|
4806
5071
|
const searchPlans = this.searchPlans;
|
|
4807
5072
|
const listPlans = this.listPlans;
|
|
5073
|
+
const statePlans = this.statePlans;
|
|
4808
5074
|
const sql = this.sql;
|
|
4809
5075
|
const mintEventId = this.mintEventId;
|
|
4810
5076
|
/**
|
|
@@ -4927,10 +5193,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4927
5193
|
// about the deploy, so it never rides `DomainEvent` for module code to
|
|
4928
5194
|
// branch on; it exists for the observability joins the column serves.
|
|
4929
5195
|
this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
4930
|
-
// #1237:
|
|
4931
|
-
//
|
|
4932
|
-
//
|
|
4933
|
-
|
|
5196
|
+
// #1237: the delivery this context was built for, if any — passed in, never read
|
|
5197
|
+
// off the DO (#2055). A fact about the surrounding dispatch, never envelope data
|
|
5198
|
+
// module code could set or branch on.
|
|
5199
|
+
delivery?.causedBy ?? null,
|
|
4934
5200
|
// #1237: a fact about the surrounding CALL, like the version above.
|
|
4935
5201
|
this.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
|
|
4936
5202
|
};
|
|
@@ -4940,7 +5206,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4940
5206
|
// #1672: a capability's own id stands in so the type holds — it is not a person, and
|
|
4941
5207
|
// the event actor says what it is instead. Every other door passes its own value.
|
|
4942
5208
|
principal: capabilityId ? capabilityId : principal,
|
|
4943
|
-
sql: guardSecrets(doScopedSql(sql
|
|
5209
|
+
sql: guardSecrets(doScopedSql(sql, statefulTablesOf(statePlans),
|
|
5210
|
+
// #119: after runtime DDL, the stateful tables must still carry what the kernel derived.
|
|
5211
|
+
statePlans.size
|
|
5212
|
+
? () => assertEntityStateIntact(doSpineSql(sql), statePlans, stateListIndexNames(listPlans.values()))
|
|
5213
|
+
: undefined), minted),
|
|
4944
5214
|
now: () => at,
|
|
4945
5215
|
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
4946
5216
|
// and its fields. A consumer runs under the system override, so it logs as `system`.
|
|
@@ -4949,7 +5219,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4949
5219
|
tenantId,
|
|
4950
5220
|
scopeId,
|
|
4951
5221
|
operation: operation ?? null,
|
|
4952
|
-
invocationId: () => this.invocationId ??
|
|
5222
|
+
invocationId: () => this.invocationId ?? delivery?.invocationId ?? null,
|
|
4953
5223
|
principalKind: systemActor ? 'system' : subject.kind,
|
|
4954
5224
|
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
4955
5225
|
// log's text is not JSON.
|
|
@@ -5007,7 +5277,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5007
5277
|
const plan = searchPlans.get(entityType);
|
|
5008
5278
|
if (!plan)
|
|
5009
5279
|
throw new NotSearchable(entityType);
|
|
5010
|
-
const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit)
|
|
5280
|
+
const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit),
|
|
5281
|
+
// #119: active rows unless the archive is asked for.
|
|
5282
|
+
searchStateWhere(statePlans, entityType, options?.view));
|
|
5011
5283
|
return sql.exec(q.sql, ...q.params).toArray().map((row) => ({ entityType, id: row.id, rank: row.rank }));
|
|
5012
5284
|
},
|
|
5013
5285
|
/**
|
|
@@ -5027,10 +5299,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5027
5299
|
order: params.order,
|
|
5028
5300
|
cursor: params.cursor,
|
|
5029
5301
|
filters: params.filters,
|
|
5302
|
+
// #119: the bin is the checked reader's, never this one's.
|
|
5303
|
+
view: uncheckedView('ctx.page', entityType, params.view),
|
|
5030
5304
|
});
|
|
5031
5305
|
const rows = sql.exec(q.sql, ...q.params).toArray();
|
|
5032
5306
|
const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
|
|
5033
|
-
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
|
|
5307
|
+
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order, q.view);
|
|
5034
5308
|
const page = { entries: rows, nextCursor };
|
|
5035
5309
|
if (!params.total)
|
|
5036
5310
|
return page;
|
|
@@ -5096,6 +5370,22 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5096
5370
|
emit: (event) => writeEvent(event, 'kernel'),
|
|
5097
5371
|
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
5098
5372
|
}),
|
|
5373
|
+
// #119: archive and trash — the pure adapter's wiring, over the raw spine seam.
|
|
5374
|
+
...createEntityStateVerbs({
|
|
5375
|
+
sql: doSpineSql(sql),
|
|
5376
|
+
plans: statePlans,
|
|
5377
|
+
now: at,
|
|
5378
|
+
check: runCheck,
|
|
5379
|
+
emit: (event) => writeEvent(event, 'kernel'),
|
|
5380
|
+
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
5381
|
+
}),
|
|
5382
|
+
...createTrashedReads({
|
|
5383
|
+
query: (q, params) => sql.exec(q, ...params).toArray(),
|
|
5384
|
+
listPlans,
|
|
5385
|
+
searchPlans,
|
|
5386
|
+
statePlans,
|
|
5387
|
+
check: runCheck,
|
|
5388
|
+
}),
|
|
5099
5389
|
entitlement: async (key) => {
|
|
5100
5390
|
const held = await entitlementReader().listEntitlements(tenantId);
|
|
5101
5391
|
return held.find((e) => e.key === key) ?? null;
|
|
@@ -5209,6 +5499,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5209
5499
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', ?)`, source);
|
|
5210
5500
|
});
|
|
5211
5501
|
}
|
|
5502
|
+
/** `applyProjection`, its refusal answered as DATA so its code survives the hop (#113). */
|
|
5503
|
+
applyProjectionReply(...args) {
|
|
5504
|
+
return replyOf(() => this.applyProjection(...args));
|
|
5505
|
+
}
|
|
5212
5506
|
/**
|
|
5213
5507
|
* Replace this scope's projected view of a tenant's roles + tenant-level tuples
|
|
5214
5508
|
* with a fresh snapshot, and make it authoritative (source = 'local'). A full
|