@substrat-run/adapter-cloudflare 0.138.0 → 0.140.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/README.md +2 -2
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +4 -0
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +95 -26
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +290 -35
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +55 -14
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +347 -69
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +30 -7
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +43 -9
- package/dist/live-reads.js.map +1 -1
- package/dist/route-resolver.d.ts +1 -1
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +7 -0
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +29 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +811 -257
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +7 -1
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +29 -3
- package/dist/sql.js.map +1 -1
- package/package.json +12 -10
package/dist/scope-do.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
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
|
-
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_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, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable,
|
|
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';
|
|
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, pageOf, countedPageOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
+
import { ulid, LIVE_CLOSE, LIVE_SOCKETS_PER_PRINCIPAL, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleErasurePlan, 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, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, topUpEntityGrantShapes, 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, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSteps, planMigrations, 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_DECIDE_ATTEMPTS, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, decodeLiveWithin, liveInstant, } from './live-reads.js';
|
|
7
7
|
import { replyOf } from './do-reply.js';
|
|
8
8
|
import { OperationQueue } from './serialization.js';
|
|
9
|
-
import { doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
|
|
10
|
-
import { actorOf, admitPeer, collectPeers, switchPeer, peerSubjectRef, subjectGrantState, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, SWITCH_FENCES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, 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';
|
|
9
|
+
import { assertNoWorkerdStorage, doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
|
|
10
|
+
import { actorOf, admitPeer, collectPeers, switchPeer, peerSubjectRef, subjectGrantState, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, splitSqlStatements, executableSqlStatements, blankSqlComments, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, assertNoCallerPurge, isUnreachableParent, PURGE_BATCH, heldPurgePass, lifecycleRefusal, purgeHeldBy, purgeDueOf, purgeOnlyKeysOf, runPurgePass, refuseTrashedTarget, registerTrashTargets, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, 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
11
|
import { createDoTupleChecker, createLocalControlPlaneReader, scopeTupleReader } from './checker.js';
|
|
12
|
-
import {
|
|
12
|
+
import { settlePlatformRequestIn } from '@substrat-run/kernel';
|
|
13
|
+
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, assertSpineTablesBuilt, capabilitiesForLoad, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
|
|
14
|
+
import { GRANT_CHILDREN_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
|
|
15
|
+
/** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
|
|
16
|
+
function readAppliedMigrations(sql) {
|
|
17
|
+
const rows = sql.exec('SELECT module_id, version, sql_digest FROM _substrat_migrations').toArray();
|
|
18
|
+
return new Map(rows.map((r) => [`${r.module_id}@${r.version}`, r.sql_digest]));
|
|
19
|
+
}
|
|
13
20
|
/**
|
|
14
21
|
* The key marking a scope DO whose storage was destroyed (`destroyStorage`).
|
|
15
22
|
* Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
|
|
@@ -179,8 +186,13 @@ const KERNEL_DDL = `
|
|
|
179
186
|
applied_at TEXT NOT NULL,
|
|
180
187
|
duration_ms INTEGER,
|
|
181
188
|
rows_changed INTEGER,
|
|
189
|
+
-- #2066: SHA-256 of the SQL that ran (kernel migrationDigest). NULL on a row written
|
|
190
|
+
-- before the column: unrecorded, accepted, never backfilled.
|
|
191
|
+
sql_digest TEXT,
|
|
182
192
|
PRIMARY KEY (module_id, version)
|
|
183
193
|
);
|
|
194
|
+
-- #2066: no new journal row without its digest (the kernel's comment says why).
|
|
195
|
+
${MIGRATION_DIGEST_FENCE_DDL}
|
|
184
196
|
-- #286: the PITR bookmark taken immediately BEFORE a migration pass runs on a
|
|
185
197
|
-- scope that already holds data -- the precise rewind point a backout restores
|
|
186
198
|
-- to. Rows live in the same storage they describe, so a rewind erases the rows
|
|
@@ -192,6 +204,7 @@ const KERNEL_DDL = `
|
|
|
192
204
|
pending TEXT NOT NULL
|
|
193
205
|
);
|
|
194
206
|
${SWITCH_FENCES_DDL}
|
|
207
|
+
${TABLE_OWNERS_DDL}
|
|
195
208
|
CREATE TABLE IF NOT EXISTS _substrat_tuples (
|
|
196
209
|
subject TEXT NOT NULL,
|
|
197
210
|
relation TEXT NOT NULL,
|
|
@@ -202,6 +215,7 @@ const KERNEL_DDL = `
|
|
|
202
215
|
revoked_at TEXT,
|
|
203
216
|
PRIMARY KEY (subject, relation, object)
|
|
204
217
|
);
|
|
218
|
+
${GRANT_CHILDREN_INDEX_DDL};
|
|
205
219
|
CREATE TABLE IF NOT EXISTS _substrat_deliveries (
|
|
206
220
|
event_id TEXT NOT NULL,
|
|
207
221
|
consumer_module TEXT NOT NULL,
|
|
@@ -349,6 +363,9 @@ const KERNEL_DDL = `
|
|
|
349
363
|
-- #1686: where a copied scope's data came from, and the event id its own events start above.
|
|
350
364
|
-- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
|
|
351
365
|
${COPY_ORIGIN_DDL}
|
|
366
|
+
-- #119: the kernel's authorization for one archive/trash move, read by the derived update
|
|
367
|
+
-- trigger. Shared with the other adapter from @substrat-run/kernel; the comments are there.
|
|
368
|
+
${ENTITY_STATE_MOVES_DDL}
|
|
352
369
|
`;
|
|
353
370
|
/**
|
|
354
371
|
* Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
|
|
@@ -417,6 +434,17 @@ class WriteRevision {
|
|
|
417
434
|
keeping = false;
|
|
418
435
|
/** A statement's text is almost always a constant, so its answer is remembered (bounded). */
|
|
419
436
|
writes = new Map();
|
|
437
|
+
/**
|
|
438
|
+
* Every write statement run through this handle since the object woke, in memory (#938, Codex
|
|
439
|
+
* #2077 r4) — counted per STATEMENT, bookkeeping and suspended ones included, where the durable
|
|
440
|
+
* revision above is bumped once per run. A live fan-out pass decides who may hear a frame across
|
|
441
|
+
* awaits; it reads this immediately before deciding and again immediately before sending, with
|
|
442
|
+
* no await between that second read and the send, and decides again when it moved. Per statement
|
|
443
|
+
* because a decision taken between a transaction's first write (its bump) and a later one (the
|
|
444
|
+
* revoke itself) must not read as current. Over-counting (a rolled-back write, a write no check
|
|
445
|
+
* reads) only costs a pass a re-check.
|
|
446
|
+
*/
|
|
447
|
+
written = 0;
|
|
420
448
|
sql;
|
|
421
449
|
constructor(raw, storage, suspended,
|
|
422
450
|
/** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
|
|
@@ -426,7 +454,10 @@ class WriteRevision {
|
|
|
426
454
|
this.suspended = suspended;
|
|
427
455
|
this.refusal = refusal;
|
|
428
456
|
const exec = (query, ...bindings) => {
|
|
429
|
-
|
|
457
|
+
const write = this.isWrite(query);
|
|
458
|
+
if (write)
|
|
459
|
+
this.written++;
|
|
460
|
+
if (this.keeping && write) {
|
|
430
461
|
// Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
|
|
431
462
|
// else: any other write here would be one the revision never saw, which is the hole this
|
|
432
463
|
// class exists to close.
|
|
@@ -435,7 +466,7 @@ class WriteRevision {
|
|
|
435
466
|
}
|
|
436
467
|
return raw.exec(query, ...bindings);
|
|
437
468
|
}
|
|
438
|
-
if (
|
|
469
|
+
if (write && !this.suspended()) {
|
|
439
470
|
const refused = this.refusal();
|
|
440
471
|
if (refused)
|
|
441
472
|
throw substratError('conflict', refused);
|
|
@@ -501,6 +532,10 @@ class WriteRevision {
|
|
|
501
532
|
this.covered = false;
|
|
502
533
|
});
|
|
503
534
|
}
|
|
535
|
+
/** How many write statements this handle has run (see `written`). */
|
|
536
|
+
get statementsWritten() {
|
|
537
|
+
return this.written;
|
|
538
|
+
}
|
|
504
539
|
isWrite(query) {
|
|
505
540
|
let known = this.writes.get(query);
|
|
506
541
|
if (known === undefined) {
|
|
@@ -587,8 +622,6 @@ function cellToJson(v) {
|
|
|
587
622
|
* trigger: it passed on better-sqlite3 (whose `exec` takes the whole blob) and
|
|
588
623
|
* failed every scope on the DO host.
|
|
589
624
|
*/
|
|
590
|
-
const IS_CREATE_TRIGGER = /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i;
|
|
591
|
-
const ENDS_WITH_END = /\bEND\s*$/i;
|
|
592
625
|
/**
|
|
593
626
|
* The kernel's two-method SQL handle (`SwitchSql`) over a Durable Object's storage — what the
|
|
594
627
|
* shared switch rules (#1666) and the directory's switch record (#1674) run through. One
|
|
@@ -602,67 +635,8 @@ export function switchSqlOver(sql) {
|
|
|
602
635
|
},
|
|
603
636
|
};
|
|
604
637
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
let cur = '';
|
|
608
|
-
const n = sql.length;
|
|
609
|
-
let i = 0;
|
|
610
|
-
while (i < n) {
|
|
611
|
-
const c = sql[i];
|
|
612
|
-
const c2 = sql[i + 1];
|
|
613
|
-
if (c === '-' && c2 === '-') {
|
|
614
|
-
while (i < n && sql[i] !== '\n')
|
|
615
|
-
i += 1; // line comment → end of line
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
if (c === '/' && c2 === '*') {
|
|
619
|
-
i += 2;
|
|
620
|
-
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/'))
|
|
621
|
-
i += 1;
|
|
622
|
-
i += 2; // block comment → past the closing */
|
|
623
|
-
continue;
|
|
624
|
-
}
|
|
625
|
-
if (c === "'") {
|
|
626
|
-
cur += c;
|
|
627
|
-
i += 1;
|
|
628
|
-
while (i < n) {
|
|
629
|
-
cur += sql[i];
|
|
630
|
-
if (sql[i] === "'") {
|
|
631
|
-
if (sql[i + 1] === "'") {
|
|
632
|
-
cur += sql[i + 1]; // '' is an escaped quote, still inside the string
|
|
633
|
-
i += 2;
|
|
634
|
-
continue;
|
|
635
|
-
}
|
|
636
|
-
i += 1;
|
|
637
|
-
break;
|
|
638
|
-
}
|
|
639
|
-
i += 1;
|
|
640
|
-
}
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
if (c === ';') {
|
|
644
|
-
// Inside a trigger body, a `;` ends an inner statement, not the CREATE.
|
|
645
|
-
// `END` is matched as a bare word at the end of what has accumulated —
|
|
646
|
-
// a string literal ending in END reads as `…END'`, so the quote keeps it
|
|
647
|
-
// from matching, and the string scanner above has already copied it whole.
|
|
648
|
-
if (IS_CREATE_TRIGGER.test(cur) && !ENDS_WITH_END.test(cur)) {
|
|
649
|
-
cur += c;
|
|
650
|
-
i += 1;
|
|
651
|
-
continue;
|
|
652
|
-
}
|
|
653
|
-
if (cur.trim())
|
|
654
|
-
out.push(cur.trim());
|
|
655
|
-
cur = '';
|
|
656
|
-
i += 1;
|
|
657
|
-
continue;
|
|
658
|
-
}
|
|
659
|
-
cur += c;
|
|
660
|
-
i += 1;
|
|
661
|
-
}
|
|
662
|
-
if (cur.trim())
|
|
663
|
-
out.push(cur.trim());
|
|
664
|
-
return out;
|
|
665
|
-
}
|
|
638
|
+
/** The splitter both adapters run migrations through — the kernel's, re-exported for this package's callers. */
|
|
639
|
+
export { splitSqlStatements };
|
|
666
640
|
/**
|
|
667
641
|
* #1834: the pin missed. Thrown only by `assertSystemDoor`, and module-private, so no operation can
|
|
668
642
|
* throw one: the two RPCs a door call reaches turn it into the `SystemDoorMoved` ANSWER, by class
|
|
@@ -672,9 +646,51 @@ class SystemDoorMovedError extends Error {
|
|
|
672
646
|
}
|
|
673
647
|
/** #1834: the brand only `assertSystemDoor` sets, so nothing else can make a `SystemDoorPass`. */
|
|
674
648
|
const SYSTEM_DOOR_PASSED = Symbol('system door passed');
|
|
649
|
+
/** Close a live socket whose session has ended (#938). */
|
|
650
|
+
function closeSessionEnded(ws) {
|
|
651
|
+
try {
|
|
652
|
+
ws.close(LIVE_CLOSE.revoked, 'the session that opened this subscription has ended');
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// Already gone.
|
|
656
|
+
}
|
|
657
|
+
}
|
|
675
658
|
export function defineScopeDO(modules, bareOps) {
|
|
676
659
|
return class ScopeDO extends DurableObject {
|
|
677
660
|
sql;
|
|
661
|
+
continuationOrder = 0;
|
|
662
|
+
/** DO storage KV is private to the adapter; neither module SQL nor dumps can read it. */
|
|
663
|
+
continuationStore(writable) {
|
|
664
|
+
const positionKey = (id, expiresAt) => `continuation:position:${String(expiresAt).padStart(13, '0')}:${id}`;
|
|
665
|
+
return {
|
|
666
|
+
keys: async () => (await this.ctx.storage.get('continuation:keys')) ?? null,
|
|
667
|
+
setKeys: async (keys) => { await this.ctx.storage.put('continuation:keys', keys); },
|
|
668
|
+
position: async (id, expiresAt) => {
|
|
669
|
+
if (writable)
|
|
670
|
+
await this.pruneContinuationPositions();
|
|
671
|
+
return (await this.ctx.storage.get(positionKey(id, expiresAt))) ?? null;
|
|
672
|
+
},
|
|
673
|
+
setPosition: async (id, position) => {
|
|
674
|
+
// Several budget stops can land in one millisecond. Keep insertion
|
|
675
|
+
// order explicit so the cap always evicts the first locator minted.
|
|
676
|
+
this.continuationOrder = Math.max(Date.now() * 1_000, this.continuationOrder + 1);
|
|
677
|
+
await this.ctx.storage.put(positionKey(id, position.expiresAt), {
|
|
678
|
+
...position, order: this.continuationOrder,
|
|
679
|
+
});
|
|
680
|
+
await this.pruneContinuationPositions();
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
async pruneContinuationPositions() {
|
|
685
|
+
const positions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
|
|
686
|
+
const live = [...positions].filter(([, record]) => record.expiresAt > Date.now())
|
|
687
|
+
.sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0));
|
|
688
|
+
const evict = new Set(live.slice(0, Math.max(0, live.length - CONTINUATION_POSITION_CAP)).map(([name]) => name));
|
|
689
|
+
for (const [name, record] of positions) {
|
|
690
|
+
if (record.expiresAt <= Date.now() || evict.has(name))
|
|
691
|
+
await this.ctx.storage.delete(name);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
678
694
|
queue = new OperationQueue();
|
|
679
695
|
operations = new Map();
|
|
680
696
|
/**
|
|
@@ -685,6 +701,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
685
701
|
operationInput = new Map();
|
|
686
702
|
/** #129: name → the entity whose version an `If-Match` is compared against. */
|
|
687
703
|
operationConcurrency = new Map();
|
|
704
|
+
/** #119: name → the entity it addresses by id — the host's trash refusal reads it. */
|
|
705
|
+
operationTarget = new Map();
|
|
706
|
+
/** #119: module id → the keys its system principal holds only for its purge schedules. */
|
|
707
|
+
purgeOnlyKeys = new Map();
|
|
708
|
+
/** #119: a purge schedule's operation → the module that declared it and the entity it purges. */
|
|
709
|
+
purgeSchedules = new Map();
|
|
688
710
|
/** #116: the operations that declared `idempotency: false` — refusals, not participants. */
|
|
689
711
|
operationIdempotencyOptOut = new Set();
|
|
690
712
|
modules = new Map();
|
|
@@ -708,6 +730,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
708
730
|
searchPlans = new Map();
|
|
709
731
|
/** #811: the paged lists modules declare, by entity type. Same one-owner rule. */
|
|
710
732
|
listPlans = new Map();
|
|
733
|
+
/** #119: entity type → its archive/trash plan, from every registered module. */
|
|
734
|
+
statePlans = new Map();
|
|
735
|
+
/** #2068: each registered module's erasure, in registration order — what `redactSubject` runs. */
|
|
736
|
+
erasurePlans = [];
|
|
711
737
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
712
738
|
attachmentTargets = new Map();
|
|
713
739
|
/**
|
|
@@ -731,7 +757,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
731
757
|
* underneath rows it already stored.
|
|
732
758
|
*/
|
|
733
759
|
mintEventId = createUlid();
|
|
734
|
-
|
|
760
|
+
/** `module@version` → the SQL digest its journal row recorded, null for a row from before #2066. */
|
|
761
|
+
applied = new Map();
|
|
735
762
|
migrationPromise;
|
|
736
763
|
/** Latch: the applied count is reported to the directory once per DO instance. */
|
|
737
764
|
schemaVersionReported = false;
|
|
@@ -774,7 +801,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
774
801
|
// transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
|
|
775
802
|
this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
|
|
776
803
|
this.sql = this.revision.sql;
|
|
777
|
-
for (const stmt of
|
|
804
|
+
for (const stmt of executableSqlStatements(KERNEL_DDL)) {
|
|
778
805
|
this.sql.exec(stmt);
|
|
779
806
|
}
|
|
780
807
|
this.applySpineColumnAdditions();
|
|
@@ -785,11 +812,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
785
812
|
for (const [name, handler] of Object.entries(bareOps))
|
|
786
813
|
this.defineOperation(name, handler);
|
|
787
814
|
// Which migrations have already run (a warm DO wakes with rows here).
|
|
788
|
-
|
|
789
|
-
.exec('SELECT module_id, version FROM _substrat_migrations')
|
|
790
|
-
.toArray()) {
|
|
791
|
-
this.applied.add(`${row.module_id}@${row.version}`);
|
|
792
|
-
}
|
|
815
|
+
this.applied = readAppliedMigrations(this.sql);
|
|
793
816
|
// #1335: and where this DO's event ids have to resume from. A revived DO would
|
|
794
817
|
// otherwise start its floor at the wall clock, and a clock that has stepped back
|
|
795
818
|
// — an NTP correction is small but real — mints underneath rows already stored,
|
|
@@ -805,6 +828,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
805
828
|
// -- module registration (port of SqliteScopeHost.registerModule) ---------
|
|
806
829
|
registerModule(registration) {
|
|
807
830
|
const manifest = registration.manifest;
|
|
831
|
+
// #119: refused before anything is recorded, as in the pure adapter.
|
|
832
|
+
const trashTargets = registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
|
|
833
|
+
this.purgeOnlyKeys.set(manifest.id, purgeOnlyKeysOf(manifest.schedules ?? []));
|
|
834
|
+
for (const schedule of manifest.schedules ?? []) {
|
|
835
|
+
if (schedule.purge)
|
|
836
|
+
this.purgeSchedules.set(schedule.operation, { moduleId: manifest.id, entityType: schedule.purge.entityType });
|
|
837
|
+
}
|
|
838
|
+
// #2068: refused before anything is recorded, as on the pure host.
|
|
839
|
+
const erasure = moduleErasurePlan(registration);
|
|
808
840
|
if (manifest.peers?.length)
|
|
809
841
|
this.peerSources.push({ peers: manifest.peers });
|
|
810
842
|
// #827: the FTS indexes `searchables` declares, appended after the module's
|
|
@@ -819,8 +851,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
819
851
|
}
|
|
820
852
|
this.searchPlans.set(plan.entityType, plan);
|
|
821
853
|
}
|
|
854
|
+
// #119: the archive/trash plans, refusing a second owner of one entity type.
|
|
855
|
+
addStatePlans(this.statePlans, manifest.id, manifest.entityStates, manifest.permissions);
|
|
822
856
|
// #811: the list indexes `lists` declares, same placement and same reason.
|
|
823
|
-
for (const plan of listIndexPlans(manifest.id, manifest.lists)) {
|
|
857
|
+
for (const plan of listIndexPlans(manifest.id, manifest.lists, manifest.entityStates)) {
|
|
824
858
|
const existing = this.listPlans.get(plan.entityType);
|
|
825
859
|
if (existing) {
|
|
826
860
|
throw new Error(`list: '${plan.entityType}' declares a paged list in both '${existing.moduleId}' and ` +
|
|
@@ -834,11 +868,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
834
868
|
id: manifest.id,
|
|
835
869
|
// The order the kernel writes once (#1677): authored, then search, then list indexes.
|
|
836
870
|
migrations: moduleMigrations(registration),
|
|
871
|
+
steps: migrationSteps(registration),
|
|
837
872
|
consumers: Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => ({
|
|
838
873
|
eventType,
|
|
839
874
|
handler,
|
|
840
875
|
})),
|
|
841
876
|
});
|
|
877
|
+
if (erasure)
|
|
878
|
+
this.erasurePlans.push(erasure);
|
|
842
879
|
for (const [name, handler] of Object.entries(registration.predicates ?? {})) {
|
|
843
880
|
this.predicates.set(name, { module: manifest.id, handler });
|
|
844
881
|
}
|
|
@@ -925,6 +962,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
925
962
|
if (declaredOptOuts.includes(name) && this.operations.has(name)) {
|
|
926
963
|
this.operationIdempotencyOptOut.add(name);
|
|
927
964
|
}
|
|
965
|
+
const target = trashTargets.get(name);
|
|
966
|
+
if (target && this.operations.has(name))
|
|
967
|
+
this.operationTarget.set(name, target);
|
|
928
968
|
}
|
|
929
969
|
}
|
|
930
970
|
defineOperation(name, handler) {
|
|
@@ -1306,13 +1346,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1306
1346
|
const { hops: _hops, ...fact } = e;
|
|
1307
1347
|
const event = structuredClone({ ...fact, source });
|
|
1308
1348
|
const unit = unitOf(imp.moduleId, e.id, e.type);
|
|
1309
|
-
this.causedBy = e.id;
|
|
1310
|
-
this.unitInvocationId = unit.invocationId;
|
|
1311
1349
|
try {
|
|
1312
1350
|
await this.revision.transaction(async () => {
|
|
1313
1351
|
// As the producer's principal: real checks against the grants this vertical's
|
|
1314
1352
|
// `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
|
|
1315
|
-
|
|
1353
|
+
// What it emits was emitted BECAUSE of the producer's event (#1237).
|
|
1354
|
+
await imp.handler(this.importContext(tenantId, scopeId, peerSubject, { causedBy: e.id, invocationId: unit.invocationId }), event);
|
|
1316
1355
|
this.sql.exec(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
|
|
1317
1356
|
VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
|
|
1318
1357
|
});
|
|
@@ -1325,10 +1364,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1325
1364
|
result.deadLettered += 1;
|
|
1326
1365
|
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
1327
1366
|
}
|
|
1328
|
-
finally {
|
|
1329
|
-
this.causedBy = null;
|
|
1330
|
-
this.unitInvocationId = null;
|
|
1331
|
-
}
|
|
1332
1367
|
}
|
|
1333
1368
|
if (!ran)
|
|
1334
1369
|
result.duplicates += 1;
|
|
@@ -1345,8 +1380,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1345
1380
|
* passes it (#1706). `principal` is a placeholder that the subject never reads, and there is
|
|
1346
1381
|
* no operation, so the emitted rows' `operation` is NULL, as it is for any consumer.
|
|
1347
1382
|
*/
|
|
1348
|
-
|
|
1349
|
-
|
|
1383
|
+
/** A module consumer's context: the system override, and the delivery it runs for (#2055). */
|
|
1384
|
+
consumerContext(tenantId, scopeId, moduleId, delivery) {
|
|
1385
|
+
return this.operationContext(this.systemPrincipal, tenantId, scopeId, { system: moduleId }, undefined, undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, delivery);
|
|
1386
|
+
}
|
|
1387
|
+
importContext(tenantId, scopeId, peerSubject, delivery) {
|
|
1388
|
+
return this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [], undefined, peerSubject, delivery);
|
|
1350
1389
|
}
|
|
1351
1390
|
async markEventsDrained(eventIds, at) {
|
|
1352
1391
|
if (eventIds.length === 0)
|
|
@@ -1565,10 +1604,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1565
1604
|
* Provisioning does not come through here; it seats with `seatTuple` (#1659).
|
|
1566
1605
|
*/
|
|
1567
1606
|
async writeTuple(subject, relation, object, expiresAt) {
|
|
1568
|
-
|
|
1569
|
-
this.
|
|
1570
|
-
|
|
1571
|
-
});
|
|
1607
|
+
return replyOf(() => this.queue.enqueue(() => {
|
|
1608
|
+
writeExplicitTupleIn(this.switchSql(), subject, relation, object, { kind: 'replace', expiresAt });
|
|
1609
|
+
return null;
|
|
1610
|
+
}));
|
|
1572
1611
|
}
|
|
1573
1612
|
/**
|
|
1574
1613
|
* Provisioning's scope-tuple write (#1659): create the row if it is missing, follow the
|
|
@@ -1598,6 +1637,26 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1598
1637
|
return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
1599
1638
|
}));
|
|
1600
1639
|
}
|
|
1640
|
+
/** A declared entity-grant shape's grant to one person on one entity (#2071), as ONE unit. */
|
|
1641
|
+
async grantEntityShape(principal, entity, permissions) {
|
|
1642
|
+
await this.queue.enqueue(() => this.revision.transactionSync(() => grantEntityShapeIn(this.switchSql(), principal, entity, permissions)));
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* One bounded pass of a declared shape's reconcile (#2071), with its events, in ONE
|
|
1646
|
+
* transaction: how many it topped up, how many it took retired keys from (#2082), and
|
|
1647
|
+
* whether the scope is done.
|
|
1648
|
+
*/
|
|
1649
|
+
async topUpEntityGrantShapes(tenantId, scopeId, shapes, limit) {
|
|
1650
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => topUpEntityGrantShapes(this.switchSql(), {
|
|
1651
|
+
tenantId,
|
|
1652
|
+
scopeId,
|
|
1653
|
+
shapes,
|
|
1654
|
+
now: new Date().toISOString(),
|
|
1655
|
+
limit,
|
|
1656
|
+
mintEventId: (ms) => this.mintEventId(ms),
|
|
1657
|
+
version: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
1658
|
+
})));
|
|
1659
|
+
}
|
|
1601
1660
|
/**
|
|
1602
1661
|
* The dispatch capability's admission (#726 remedy B): this delivery may read the
|
|
1603
1662
|
* attachments of the entity its own spine row names, and no others.
|
|
@@ -1727,7 +1786,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1727
1786
|
// Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
|
|
1728
1787
|
// ZodError, whose `message` is a getter) still arrives with its message.
|
|
1729
1788
|
try {
|
|
1730
|
-
return await this
|
|
1789
|
+
return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
|
|
1731
1790
|
}
|
|
1732
1791
|
catch (err) {
|
|
1733
1792
|
if (err instanceof SystemDoorMovedError)
|
|
@@ -1736,7 +1795,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1736
1795
|
}
|
|
1737
1796
|
}
|
|
1738
1797
|
try {
|
|
1739
|
-
return await this
|
|
1798
|
+
return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
|
|
1740
1799
|
}
|
|
1741
1800
|
catch (err) {
|
|
1742
1801
|
// #1834: the pin missed — an answer, never a failure an operation could have produced.
|
|
@@ -1748,8 +1807,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1748
1807
|
return { result: undefined, platformRequests: 0, failure: toWireFailure(err) };
|
|
1749
1808
|
}
|
|
1750
1809
|
}
|
|
1751
|
-
/**
|
|
1752
|
-
|
|
1810
|
+
/**
|
|
1811
|
+
* The operation path itself. Throws; `invoke` decides how that reaches the caller.
|
|
1812
|
+
*
|
|
1813
|
+
* `#`-private, not merely `private`: workerd's RPC exposes every method on the prototype,
|
|
1814
|
+
* and this one takes `purge` (#119), which only `runPurgeSweep` may set. A `#` method is not
|
|
1815
|
+
* on the prototype, so no stub can reach it.
|
|
1816
|
+
*/
|
|
1817
|
+
async #invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
|
|
1753
1818
|
/** K-42: the session, resolved coordinator-side. See `invoke` above. */
|
|
1754
1819
|
impersonation,
|
|
1755
1820
|
/** #1672: a capability session's hash. See `invoke` above. */
|
|
@@ -1757,7 +1822,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1757
1822
|
/** #1706: the calling vertical, as the platform named it. See `invoke` above. */
|
|
1758
1823
|
verticalCaller,
|
|
1759
1824
|
/** #1834: the instance the system door's gate read. See `invoke` above. */
|
|
1760
|
-
systemDoorInstance
|
|
1825
|
+
systemDoorInstance,
|
|
1826
|
+
/** #119: set only by `runPurgeSweep` on its own invokes — never an RPC argument. */
|
|
1827
|
+
purge) {
|
|
1761
1828
|
await this.ensureMigrations();
|
|
1762
1829
|
const handler = this.operations.get(operation);
|
|
1763
1830
|
// `not_found`, not a bare throw (#113): every vertical hand-matched this message
|
|
@@ -1792,6 +1859,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1792
1859
|
// declaration. Outside the queue and outside the transaction: a malformed
|
|
1793
1860
|
// call takes no turn and opens nothing. Guards read the parsed input too,
|
|
1794
1861
|
// so a K-17 pre-condition sees what the handler will.
|
|
1862
|
+
// #119: purge authority is never an option a caller supplies.
|
|
1863
|
+
assertNoCallerPurge(invokeOptions);
|
|
1795
1864
|
const declaredInput = this.operationInput.get(operation);
|
|
1796
1865
|
const parsed = declaredInput ? declaredInput.parse(input) : input;
|
|
1797
1866
|
// #129. Refused rather than ignored, for the reason the coordinator refuses an
|
|
@@ -1918,8 +1987,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1918
1987
|
// commit together, or a throw (from either) rolls domain writes AND
|
|
1919
1988
|
// emitted events back as one — verified across `await` in workerd.
|
|
1920
1989
|
try {
|
|
1990
|
+
// #119: a module's purge-only keys are withheld from its system principal on every
|
|
1991
|
+
// call but the purge sweep's own invoke of the purge operation.
|
|
1992
|
+
const target = this.operationTarget.get(operation);
|
|
1993
|
+
// #119: purge authority is the system door's, and only `runPurgeSweep` sets `purge`.
|
|
1994
|
+
const purging = purge === true && systemDoor !== undefined;
|
|
1995
|
+
const withheld = systemDoor
|
|
1996
|
+
? withheldKeysFor(this.purgeOnlyKeys.get(systemDoor.moduleId), target, purging)
|
|
1997
|
+
: undefined;
|
|
1921
1998
|
await this.revision.transaction(async () => {
|
|
1922
|
-
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
|
|
1999
|
+
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject, undefined, withheld);
|
|
1923
2000
|
// #116: a retry is answered from the recording, and nothing else runs
|
|
1924
2001
|
// — not the guards, not the handler, not the permission check inside
|
|
1925
2002
|
// it. Keyed by SUBJECT, so a caller only ever reaches its own
|
|
@@ -1946,6 +2023,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1946
2023
|
const seen = guardedRef && invokeOptions?.ifMatch !== undefined
|
|
1947
2024
|
? this.versionAt(guardedRef)
|
|
1948
2025
|
: undefined;
|
|
2026
|
+
// #119: the host's trash refusal, before the guards and the handler — the pure
|
|
2027
|
+
// adapter's, through the same kernel function.
|
|
2028
|
+
await refuseTrashedTarget({ sql: doSpineSql(this.sql), plans: this.statePlans, check: ctx.check }, operation, target, parsed, purging ? { now: new Date().toISOString(), gate: this.purgeGateFacts(systemDoor.moduleId, tenantId) } : undefined);
|
|
1949
2029
|
await this.runGuards(operation, ctx, parsed);
|
|
1950
2030
|
result = await handler(ctx, parsed);
|
|
1951
2031
|
if (guardedRef && invokeOptions?.ifMatch !== undefined) {
|
|
@@ -2142,12 +2222,57 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2142
2222
|
if (withinHeader !== null && !within) {
|
|
2143
2223
|
return new Response('live reads: unreadable within narrowing', { status: 500 });
|
|
2144
2224
|
}
|
|
2225
|
+
// The same for an expiry: dropping it would keep the socket open past its session.
|
|
2226
|
+
const expiresHeader = request.headers.get(LIVE_EXPIRES_HEADER);
|
|
2227
|
+
const expiresAt = expiresHeader === null ? undefined : liveInstant(expiresHeader);
|
|
2228
|
+
if (expiresHeader !== null && !expiresAt) {
|
|
2229
|
+
return new Response('live reads: unreadable expiry', { status: 500 });
|
|
2230
|
+
}
|
|
2145
2231
|
// A subscriber arriving before the scope's migrations have run would be told
|
|
2146
2232
|
// about events against a schema it cannot read back through. Same gate every
|
|
2147
2233
|
// other entry point takes, for the same reason.
|
|
2148
2234
|
await this.ensureMigrations();
|
|
2235
|
+
// `.parse`, not a cast: these three arrived as header strings, and the branded
|
|
2236
|
+
// ids are what every check downstream is keyed on. A malformed one would
|
|
2237
|
+
// otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
|
|
2238
|
+
// which reads as "this subscriber may see nothing" and is indistinguishable from
|
|
2239
|
+
// a correct denial. Refused here instead, where it is still one subscriber's
|
|
2240
|
+
// problem. Throwing is right: the coordinator built this request.
|
|
2241
|
+
const subscriber = {
|
|
2242
|
+
principal: principalId.parse(principal),
|
|
2243
|
+
tenantId: tenantIdOf.parse(tenantId),
|
|
2244
|
+
scopeId: scopeIdOf.parse(scopeId),
|
|
2245
|
+
};
|
|
2246
|
+
// A checked root (#938) is gated here as well as on every pass: a subscriber who may
|
|
2247
|
+
// not watch it gets no socket at all, so its client meets a refusal rather than a
|
|
2248
|
+
// feed that would close on the first thing it had to say. A session already over is
|
|
2249
|
+
// refused the same way.
|
|
2250
|
+
const expired = expiresAt !== undefined && expiresAt <= new Date().toISOString();
|
|
2251
|
+
if (expired ||
|
|
2252
|
+
(within?.checked !== undefined && !(await this.mayWatchRoot(() => this.liveContext(subscriber), within)))) {
|
|
2253
|
+
return new Response('live reads: the subscriber may not watch this root', {
|
|
2254
|
+
status: 403,
|
|
2255
|
+
headers: { [LIVE_MODE_HEADER]: 'forbidden' },
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2149
2258
|
const pair = new WebSocketPair();
|
|
2150
2259
|
const [client, server] = Object.values(pair);
|
|
2260
|
+
/**
|
|
2261
|
+
* At most `LIVE_SOCKETS_PER_PRINCIPAL` sockets per principal on this scope (#938):
|
|
2262
|
+
* each is work on every post-commit pass. The extra one is accepted and closed at
|
|
2263
|
+
* once with `4429`, rather than refused with a status, because a browser never sees
|
|
2264
|
+
* a failed handshake's status — only a close code, which the client reads as "poll
|
|
2265
|
+
* and stop asking". Not hibernated, so it never joins the roster the fan-out walks.
|
|
2266
|
+
*
|
|
2267
|
+
* Counted over the sockets still live: one whose session has ended is closed here
|
|
2268
|
+
* first, so sockets that expired on an idle scope never hold a fresh session out.
|
|
2269
|
+
*/
|
|
2270
|
+
const held = this.reapExpiredLive().filter((s) => s.principal === subscriber.principal).length;
|
|
2271
|
+
if (held >= LIVE_SOCKETS_PER_PRINCIPAL) {
|
|
2272
|
+
server.accept();
|
|
2273
|
+
server.close(LIVE_CLOSE.tooMany, 'too many live subscriptions for this principal; poll instead');
|
|
2274
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
2275
|
+
}
|
|
2151
2276
|
// HIBERNATABLE, not `server.accept()`. A scope with a watcher open would
|
|
2152
2277
|
// otherwise be pinned in memory for as long as somebody has a tab open, which is
|
|
2153
2278
|
// the cost model inverted: a support desk being WATCHED is the normal state.
|
|
@@ -2155,21 +2280,94 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2155
2280
|
// eviction, so the socket would stay open and silently stop receiving, which is
|
|
2156
2281
|
// indistinguishable from a quiet scope.
|
|
2157
2282
|
this.ctx.acceptWebSocket(server);
|
|
2158
|
-
// `.parse`, not a cast: these three arrived as header strings, and the branded
|
|
2159
|
-
// ids are what every check downstream is keyed on. A malformed one would
|
|
2160
|
-
// otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
|
|
2161
|
-
// which reads as "this subscriber may see nothing" and is indistinguishable from
|
|
2162
|
-
// a correct denial. Refused here instead, where it is still one subscriber's
|
|
2163
|
-
// problem. Throwing is right: the coordinator built this request.
|
|
2164
2283
|
server.serializeAttachment({
|
|
2165
|
-
|
|
2166
|
-
tenantId: tenantIdOf.parse(tenantId),
|
|
2167
|
-
scopeId: scopeIdOf.parse(scopeId),
|
|
2284
|
+
...subscriber,
|
|
2168
2285
|
since: new Date().toISOString(),
|
|
2169
2286
|
...(within ? { within } : {}),
|
|
2287
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
2170
2288
|
});
|
|
2289
|
+
if (expiresAt)
|
|
2290
|
+
await this.armLiveExpiry();
|
|
2171
2291
|
return new Response(null, { status: 101, webSocket: client });
|
|
2172
2292
|
}
|
|
2293
|
+
/**
|
|
2294
|
+
* Close every live socket whose session has ended (`1008`), and return the
|
|
2295
|
+
* subscriptions still open (#938). Reads only the sockets' own attachments, never the
|
|
2296
|
+
* database, so it is as cheap on a scope nobody writes to as on a busy one.
|
|
2297
|
+
*/
|
|
2298
|
+
reapExpiredLive(now = new Date().toISOString()) {
|
|
2299
|
+
const live = [];
|
|
2300
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
2301
|
+
let s = null;
|
|
2302
|
+
try {
|
|
2303
|
+
s = readSubscription(ws.deserializeAttachment());
|
|
2304
|
+
}
|
|
2305
|
+
catch {
|
|
2306
|
+
s = null;
|
|
2307
|
+
}
|
|
2308
|
+
if (!s)
|
|
2309
|
+
continue;
|
|
2310
|
+
if (s.expiresAt !== undefined && s.expiresAt <= now) {
|
|
2311
|
+
closeSessionEnded(ws);
|
|
2312
|
+
continue;
|
|
2313
|
+
}
|
|
2314
|
+
live.push(s);
|
|
2315
|
+
}
|
|
2316
|
+
return live;
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* Arm the scope's alarm for the earliest session end among its live sockets (#938), so
|
|
2320
|
+
* an expired socket is closed on a scope nobody writes to — the fan-out closes them
|
|
2321
|
+
* too, but only on a pass, and an idle scope has none.
|
|
2322
|
+
*
|
|
2323
|
+
* Only ever moved EARLIER: an alarm already set before that instant is kept, and the
|
|
2324
|
+
* handler re-arms for whatever is next. Re-arming on a close is not needed: an alarm
|
|
2325
|
+
* that finds the socket already gone simply arms for the next one, or for nothing.
|
|
2326
|
+
*
|
|
2327
|
+
* The live reaper is this DO's only alarm use. A second one must share `alarm()` and
|
|
2328
|
+
* this "earliest wins" rule, not call `setAlarm` on its own. The alarm makes workerd
|
|
2329
|
+
* keep a `_cf_METADATA` table in the scope's SQLite; every table walk here skips
|
|
2330
|
+
* `_cf_*` for that reason (`exportDump`, `importDump`, `introspectTables`).
|
|
2331
|
+
*/
|
|
2332
|
+
async armLiveExpiry() {
|
|
2333
|
+
let earliest;
|
|
2334
|
+
for (const s of this.reapExpiredLive()) {
|
|
2335
|
+
if (s.expiresAt !== undefined && (earliest === undefined || s.expiresAt < earliest))
|
|
2336
|
+
earliest = s.expiresAt;
|
|
2337
|
+
}
|
|
2338
|
+
if (earliest === undefined)
|
|
2339
|
+
return;
|
|
2340
|
+
const at = Date.parse(earliest);
|
|
2341
|
+
const current = await this.ctx.storage.getAlarm();
|
|
2342
|
+
if (current === null || current > at)
|
|
2343
|
+
await this.ctx.storage.setAlarm(at);
|
|
2344
|
+
}
|
|
2345
|
+
/** The live reaper's alarm (#938): close what has expired, then arm for what is next. */
|
|
2346
|
+
async alarm() {
|
|
2347
|
+
await this.armLiveExpiry();
|
|
2348
|
+
}
|
|
2349
|
+
/** The context a live subscriber's checks run in: its own principal, under `live.subscribe`. */
|
|
2350
|
+
liveContext(subscriber) {
|
|
2351
|
+
return this.operationContext(subscriber.principal, subscriber.tenantId, subscriber.scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe');
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* May this subscriber watch its `checkedWithin` root (#938)? Its own `ctx.check` of the
|
|
2355
|
+
* stated key on the root, the same walk a read of the root would make. A check that
|
|
2356
|
+
* throws is a refusal: an outage in the permission path must not become a feed.
|
|
2357
|
+
*/
|
|
2358
|
+
async mayWatchRoot(context, within) {
|
|
2359
|
+
try {
|
|
2360
|
+
// Built inside the try: a context that cannot be built is a refusal too.
|
|
2361
|
+
const decision = await context().check(within.checked, {
|
|
2362
|
+
entityType: within.entityType,
|
|
2363
|
+
entityId: within.entityId,
|
|
2364
|
+
});
|
|
2365
|
+
return decision.allowed;
|
|
2366
|
+
}
|
|
2367
|
+
catch {
|
|
2368
|
+
return false;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2173
2371
|
/**
|
|
2174
2372
|
* A subscriber said something.
|
|
2175
2373
|
*
|
|
@@ -2189,6 +2387,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2189
2387
|
*/
|
|
2190
2388
|
webSocketMessage(ws, message) {
|
|
2191
2389
|
this.webSocketMessagesHandled++;
|
|
2390
|
+
// Ungated, and safe to be: the reply is the constant `'pong'` and carries no event, no
|
|
2391
|
+
// entity and no time, so it tells the socket nothing it did not already know. Every
|
|
2392
|
+
// frame that is about the scope's data goes out through `fanOutLive`'s gates instead.
|
|
2192
2393
|
if (message === 'ping')
|
|
2193
2394
|
ws.send('pong');
|
|
2194
2395
|
}
|
|
@@ -2322,12 +2523,33 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2322
2523
|
* operation has already moved the edge the walk follows.
|
|
2323
2524
|
*/
|
|
2324
2525
|
const parents = scopeTupleReader(this.sql);
|
|
2325
|
-
|
|
2526
|
+
/**
|
|
2527
|
+
* A checked root's gate, asked once per (principal, key, root) per pass (#938): a
|
|
2528
|
+
* principal holding that root in several tabs is one check, fanned out to each socket.
|
|
2529
|
+
*/
|
|
2530
|
+
const rootChecks = new Map();
|
|
2326
2531
|
const ancestors = new Map();
|
|
2327
|
-
const
|
|
2532
|
+
const openEpoch = (now) => ({
|
|
2533
|
+
writes: this.revision.statementsWritten,
|
|
2534
|
+
until: this.authorityLapsesAfter(now),
|
|
2535
|
+
remote: this.permissionSourceIsRemote(),
|
|
2536
|
+
});
|
|
2537
|
+
const holds = (e, now) => e.writes === this.revision.statementsWritten && (e.until === null || now < e.until);
|
|
2538
|
+
let epoch = openEpoch(new Date().toISOString());
|
|
2539
|
+
/** The epoch current at `now`, opening a new one (and forgetting every memo) if not. */
|
|
2540
|
+
const epochAt = (now) => {
|
|
2541
|
+
if (!holds(epoch, now)) {
|
|
2542
|
+
epoch = openEpoch(now);
|
|
2543
|
+
rootChecks.clear();
|
|
2544
|
+
ancestors.clear();
|
|
2545
|
+
}
|
|
2546
|
+
return epoch;
|
|
2547
|
+
};
|
|
2548
|
+
const reaches = async (row, root, now) => {
|
|
2328
2549
|
let up = ancestors.get(row.id);
|
|
2329
2550
|
if (!up) {
|
|
2330
2551
|
// A walk that cannot answer is a frame not sent — the same fail-closed rule as the check.
|
|
2552
|
+
// Walked at the decision's own instant; remembered only while its epoch holds.
|
|
2331
2553
|
up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
|
|
2332
2554
|
ancestors.set(row.id, up);
|
|
2333
2555
|
}
|
|
@@ -2351,6 +2573,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2351
2573
|
// assumption about how the subscription was created.
|
|
2352
2574
|
if (subscription.tenantId !== tenantId || subscription.scopeId !== scopeId)
|
|
2353
2575
|
continue;
|
|
2576
|
+
// The session that opened it has ended (#938): closed before anything is sent,
|
|
2577
|
+
// whatever the subscriber's grants still say. Checked on every pass, so it holds for
|
|
2578
|
+
// every kind of subscription, not only the ones with a gate to ask — and asked
|
|
2579
|
+
// again, against a fresh clock, immediately before every send below, because the
|
|
2580
|
+
// walk and the checks between here and there await, and a session can end during
|
|
2581
|
+
// them.
|
|
2582
|
+
const { expiresAt } = subscription;
|
|
2583
|
+
const sessionEnded = () => expiresAt !== undefined && expiresAt <= new Date().toISOString();
|
|
2584
|
+
if (sessionEnded()) {
|
|
2585
|
+
closeSessionEnded(ws);
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
2354
2588
|
const { within } = subscription;
|
|
2355
2589
|
// One context per subscriber, not per event: `ctx.check` is the expensive part
|
|
2356
2590
|
// and the context is only the subject it is evaluated for. Built on first use, so
|
|
@@ -2366,57 +2600,115 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2366
2600
|
// The operation name is carried anyway, for the events a fan-out cannot emit
|
|
2367
2601
|
// but a future reader of this context might.
|
|
2368
2602
|
let ctx;
|
|
2369
|
-
const
|
|
2370
|
-
const context = () => (ctx ??= this.
|
|
2371
|
-
|
|
2603
|
+
const subscriber = subscription;
|
|
2604
|
+
const context = () => (ctx ??= this.liveContext(subscriber));
|
|
2605
|
+
/**
|
|
2606
|
+
* A checked root's gate (#938), asked at most once per (principal, key, root) per
|
|
2607
|
+
* pass (`rootChecks`) and only once a row beneath the root is about to be announced,
|
|
2608
|
+
* so an idle subscription costs no check. A refusal, or a check that throws, closes the socket before anything is
|
|
2609
|
+
* sent: the grant is gone, the principal is, or the root moved out of the grant's
|
|
2610
|
+
* reach. Closed rather than skipped, so the subscription does not sit there asking
|
|
2611
|
+
* on every pass, and the client's reconnect meets the handshake's 403. Held with the
|
|
2612
|
+
* write count it was decided under, and asked again once that has moved.
|
|
2613
|
+
*/
|
|
2614
|
+
let rootVerdict;
|
|
2615
|
+
/** What this subscriber is owed for one row, decided at `now` under epoch `e`. */
|
|
2616
|
+
const decide = async (row, e, now) => {
|
|
2617
|
+
// A remote authority's verdict is never reused: see `Epoch`.
|
|
2618
|
+
if (rootVerdict?.epoch !== e || e.remote)
|
|
2619
|
+
rootVerdict = undefined;
|
|
2372
2620
|
// Narrowing first: it is memoised across sockets, and a row outside the root
|
|
2373
2621
|
// is out whatever the principal holds.
|
|
2374
|
-
if (within && !(await reaches(row, within)))
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2622
|
+
if (within && !(await reaches(row, within, now)))
|
|
2623
|
+
return 'skip';
|
|
2624
|
+
if (within?.checked !== undefined) {
|
|
2625
|
+
if (rootVerdict === undefined) {
|
|
2626
|
+
let check;
|
|
2627
|
+
if (e.remote) {
|
|
2628
|
+
check = this.mayWatchRoot(context, within);
|
|
2629
|
+
}
|
|
2630
|
+
else {
|
|
2631
|
+
const key = `${subscriber.principal}\n${within.checked}\n${within.entityType}:${within.entityId}`;
|
|
2632
|
+
check = rootChecks.get(key);
|
|
2633
|
+
if (!check)
|
|
2634
|
+
rootChecks.set(key, (check = this.mayWatchRoot(context, within)));
|
|
2635
|
+
}
|
|
2636
|
+
rootVerdict = { epoch: e, allowed: await check };
|
|
2637
|
+
}
|
|
2638
|
+
if (!rootVerdict.allowed)
|
|
2639
|
+
return 'revoked';
|
|
2640
|
+
}
|
|
2641
|
+
if (within?.vouched !== undefined || within?.checked !== undefined) {
|
|
2642
|
+
// The root was vouched for or checked, so the walk above was the row filter —
|
|
2643
|
+
// and the subscriber holds no read on this row, so it is told only that
|
|
2380
2644
|
// something beneath its root changed. Never which row, never how.
|
|
2381
|
-
|
|
2645
|
+
return { kind: 'nudge', id: row.id, at: row.occurred_at };
|
|
2382
2646
|
}
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
try {
|
|
2388
|
-
const decision = await context().check(permission, {
|
|
2389
|
-
entityType: row.entity_type,
|
|
2390
|
-
entityId: row.entity_id,
|
|
2391
|
-
});
|
|
2392
|
-
allowed = decision.allowed;
|
|
2393
|
-
}
|
|
2394
|
-
catch {
|
|
2395
|
-
// A check that cannot answer is a check that refuses. The alternative —
|
|
2396
|
-
// treating an evaluator failure as an allow — turns an outage in the
|
|
2397
|
-
// permission path into a disclosure, which is the one failure mode this
|
|
2398
|
-
// surface must not have.
|
|
2399
|
-
allowed = false;
|
|
2400
|
-
}
|
|
2401
|
-
if (!allowed)
|
|
2402
|
-
continue;
|
|
2403
|
-
frame = {
|
|
2404
|
-
kind: 'change',
|
|
2405
|
-
id: row.id,
|
|
2406
|
-
type: row.type,
|
|
2647
|
+
// Non-null: `announceable` is exactly the rows whose type is in the map.
|
|
2648
|
+
const permission = this.liveTargets.get(row.entity_type);
|
|
2649
|
+
try {
|
|
2650
|
+
const decision = await context().check(permission, {
|
|
2407
2651
|
entityType: row.entity_type,
|
|
2408
2652
|
entityId: row.entity_id,
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
try {
|
|
2413
|
-
ws.send(JSON.stringify(frame));
|
|
2653
|
+
});
|
|
2654
|
+
if (!decision.allowed)
|
|
2655
|
+
return 'skip';
|
|
2414
2656
|
}
|
|
2415
2657
|
catch {
|
|
2416
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
|
|
2658
|
+
// A check that cannot answer is a check that refuses. The alternative —
|
|
2659
|
+
// treating an evaluator failure as an allow — turns an outage in the
|
|
2660
|
+
// permission path into a disclosure, which is the one failure mode this
|
|
2661
|
+
// surface must not have.
|
|
2662
|
+
return 'skip';
|
|
2663
|
+
}
|
|
2664
|
+
return {
|
|
2665
|
+
kind: 'change',
|
|
2666
|
+
id: row.id,
|
|
2667
|
+
type: row.type,
|
|
2668
|
+
entityType: row.entity_type,
|
|
2669
|
+
entityId: row.entity_id,
|
|
2670
|
+
at: row.occurred_at,
|
|
2671
|
+
};
|
|
2672
|
+
};
|
|
2673
|
+
rows: for (const row of announceable) {
|
|
2674
|
+
for (let attempt = 0; attempt < LIVE_DECIDE_ATTEMPTS; attempt++) {
|
|
2675
|
+
const decidedAt = new Date().toISOString();
|
|
2676
|
+
const e = epochAt(decidedAt);
|
|
2677
|
+
const verdict = await decide(row, e, decidedAt);
|
|
2678
|
+
// Every gate as close to the send as it can be, with no await between these
|
|
2679
|
+
// reads and the send below — so nothing can land after them and before it.
|
|
2680
|
+
// The store wrote, or something the decision relied on lapsed, while it was being
|
|
2681
|
+
// taken (Codex #2077 r4, r5): decide again, from nothing remembered.
|
|
2682
|
+
if (!holds(e, new Date().toISOString()))
|
|
2683
|
+
continue;
|
|
2684
|
+
if (verdict === 'skip')
|
|
2685
|
+
continue rows;
|
|
2686
|
+
if (verdict === 'revoked') {
|
|
2687
|
+
try {
|
|
2688
|
+
ws.close(LIVE_CLOSE.revoked, 'the subscriber may no longer watch this root');
|
|
2689
|
+
}
|
|
2690
|
+
catch {
|
|
2691
|
+
// Already gone.
|
|
2692
|
+
}
|
|
2693
|
+
break rows;
|
|
2694
|
+
}
|
|
2695
|
+
// The clock is read again here too, after every await above.
|
|
2696
|
+
if (sessionEnded()) {
|
|
2697
|
+
closeSessionEnded(ws);
|
|
2698
|
+
break rows;
|
|
2699
|
+
}
|
|
2700
|
+
try {
|
|
2701
|
+
ws.send(JSON.stringify(verdict));
|
|
2702
|
+
}
|
|
2703
|
+
catch {
|
|
2704
|
+
// The socket went away between `getWebSockets()` and here. Stop writing to
|
|
2705
|
+
// this one and move on; the runtime will deliver `webSocketClose`.
|
|
2706
|
+
break rows;
|
|
2707
|
+
}
|
|
2708
|
+
continue rows;
|
|
2419
2709
|
}
|
|
2710
|
+
// Still moving after every attempt: this row is not announced, and the client's
|
|
2711
|
+
// poll — the floor under every push — picks it up.
|
|
2420
2712
|
}
|
|
2421
2713
|
}
|
|
2422
2714
|
}
|
|
@@ -2653,6 +2945,20 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2653
2945
|
await this.ensureMigrations();
|
|
2654
2946
|
return this.queue.enqueue(async () => this.revision.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
|
|
2655
2947
|
}
|
|
2948
|
+
/**
|
|
2949
|
+
* #1575: start the scope's one-shot backfill, unless it is marked or holds no
|
|
2950
|
+
* attachments (`startAttachmentTextBackfill` reads before it writes, so a marked scope
|
|
2951
|
+
* writes nothing — and moves no write revision — on every drive).
|
|
2952
|
+
*/
|
|
2953
|
+
async attachmentTextBackfillStart() {
|
|
2954
|
+
await this.ensureMigrations();
|
|
2955
|
+
await this.queue.enqueue(async () => this.revision.transactionSync(() => startAttachmentTextBackfill(doSpineSql(this.sql), ulid(), new Date().toISOString())));
|
|
2956
|
+
}
|
|
2957
|
+
/** #1575: one backfill batch, in one transaction (`queueAttachmentTextBackfill`). */
|
|
2958
|
+
async attachmentTextBackfillBatch(after) {
|
|
2959
|
+
await this.ensureMigrations();
|
|
2960
|
+
return this.queue.enqueue(async () => this.revision.transactionSync(() => queueAttachmentTextBackfill(doSpineSql(this.sql), after, ulid, new Date().toISOString())));
|
|
2961
|
+
}
|
|
2656
2962
|
// -- attachments through a capability (#1686) -------------------------------
|
|
2657
2963
|
// The coordinator hashed the session token; only the hash arrives. Each verb resolves it
|
|
2658
2964
|
// INSIDE the queue, as `invoke` does, so nothing can revoke between the resolution and
|
|
@@ -2838,8 +3144,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2838
3144
|
return this.queue.enqueue(() => {
|
|
2839
3145
|
if (systemSwitchedOff(this.switchSql(), moduleId))
|
|
2840
3146
|
return false;
|
|
2841
|
-
this.
|
|
2842
|
-
VALUES (?, ?, ?, ?)`, `system:${moduleId}`, relation, object, expiresAt);
|
|
3147
|
+
writeExplicitTupleIn(this.switchSql(), `system:${moduleId}`, relation, object, { kind: 'replace', expiresAt });
|
|
2843
3148
|
return true;
|
|
2844
3149
|
});
|
|
2845
3150
|
}
|
|
@@ -2931,12 +3236,33 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2931
3236
|
return this.queue.enqueue(async () => {
|
|
2932
3237
|
const bound = await this.assignmentBound({ kind: 'principal', id: caller }, tenantId, scopeId, roleKey);
|
|
2933
3238
|
if (bound?.covered) {
|
|
2934
|
-
this.
|
|
2935
|
-
VALUES (?, ?, ?, NULL)`, `principal:${assignee}`, `role:${roleKey}`, `scope:${scopeId}`);
|
|
3239
|
+
applyScopeRoleChange(this.switchSql(), scopeId, assignee, { revoke: [], grant: roleKey }, new Date().toISOString());
|
|
2936
3240
|
}
|
|
2937
3241
|
return bound;
|
|
2938
3242
|
});
|
|
2939
3243
|
}
|
|
3244
|
+
/** The scope's live scope-level role assignments, or one principal's (#1150). */
|
|
3245
|
+
async scopeRoleHoldersFor(scopeId, principal) {
|
|
3246
|
+
await this.ensureMigrations();
|
|
3247
|
+
return this.queue.enqueue(() => scopeRoleHolders(this.switchSql(), scopeId, new Date().toISOString(), principal));
|
|
3248
|
+
}
|
|
3249
|
+
/**
|
|
3250
|
+
* The kernel's `changeScopeRole` in one serialized scope task (#1150). Its two refusals come
|
|
3251
|
+
* back as values: an error thrown here crosses the RPC flattened, so the coordinator types them.
|
|
3252
|
+
*/
|
|
3253
|
+
async changeScopeRoleBoundedFor(tenantId, scopeId, caller, principal, from, to) {
|
|
3254
|
+
await this.ensureMigrations();
|
|
3255
|
+
return this.queue.enqueue(() => changeScopeRole(this.switchSql(), scopeId, principal, from, to, new Date().toISOString(), this.roleBound(caller, tenantId, scopeId), (run) => this.revision.transactionSync(run)));
|
|
3256
|
+
}
|
|
3257
|
+
/** The kernel's `revokeScopeRoles` in one serialized scope task (#1150). */
|
|
3258
|
+
async revokeScopeRolesBoundedFor(tenantId, scopeId, caller, principal) {
|
|
3259
|
+
await this.ensureMigrations();
|
|
3260
|
+
return this.queue.enqueue(() => revokeScopeRoles(this.switchSql(), scopeId, principal, new Date().toISOString(), this.roleBound(caller, tenantId, scopeId), (run) => this.revision.transactionSync(run)));
|
|
3261
|
+
}
|
|
3262
|
+
/** The caller's bound per role at the scope; `null` for a role the tenant does not define. */
|
|
3263
|
+
roleBound(caller, tenantId, scopeId) {
|
|
3264
|
+
return (roleKey) => this.assignmentBound({ kind: 'principal', id: caller }, tenantId, scopeId, roleKey);
|
|
3265
|
+
}
|
|
2940
3266
|
/**
|
|
2941
3267
|
* §5.1's assignment bound, resolved against the SAME role table the checker
|
|
2942
3268
|
* expands — the tenant's projected role, not a vertical's compile-time `ROLES`
|
|
@@ -3207,6 +3533,70 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3207
3533
|
await this.ensureMigrations();
|
|
3208
3534
|
return readCapabilityPage(doSpineSql(this.sql), filter);
|
|
3209
3535
|
}
|
|
3536
|
+
/**
|
|
3537
|
+
* One pass of a purge horizon's schedule on this scope (#119): the oldest entities due for
|
|
3538
|
+
* purge, each deleted by the module's own `trashed: 'purges'` operation, one call and one
|
|
3539
|
+
* transaction per entity, as `system:<moduleId>`.
|
|
3540
|
+
*
|
|
3541
|
+
* The caller names only WHICH schedule. Everything that decides what is purged is this
|
|
3542
|
+
* object's: the module and the entity come from the registered schedule, the cutoff from this
|
|
3543
|
+
* object's clock and the entity's declared horizon, the ids from its own bin — and each purge
|
|
3544
|
+
* re-checks the cutoff inside its transaction (`refuseTrashedTarget`). Purge authority lives
|
|
3545
|
+
* here and nowhere else: `invoke` takes no purge argument, so a stub holding this namespace
|
|
3546
|
+
* cannot hand the system principal its purge-only key, and calling this early, often or from
|
|
3547
|
+
* outside the sweep can only purge what is genuinely due.
|
|
3548
|
+
*
|
|
3549
|
+
* Pinned like every system-door call (#1834): a missed pin answers `SystemDoorMoved` before
|
|
3550
|
+
* anything is read, and the coordinator's door gates again.
|
|
3551
|
+
*/
|
|
3552
|
+
async runPurgeSweep(operation, tenantId,
|
|
3553
|
+
/**
|
|
3554
|
+
* The node the purges' checks and events are stamped with. This object records no scope id of
|
|
3555
|
+
* its own, so it cannot hold this one to anything — and it routes nothing: the sweep runs on
|
|
3556
|
+
* the scope that received the call, whatever this says. A wrong one fails closed, since the
|
|
3557
|
+
* module's grant is seated on this scope's node and not on the one named.
|
|
3558
|
+
*/
|
|
3559
|
+
scopeId, systemDoorInstance) {
|
|
3560
|
+
await this.ensureMigrations();
|
|
3561
|
+
const schedule = this.purgeSchedules.get(operation);
|
|
3562
|
+
if (!schedule)
|
|
3563
|
+
throw toRpcError(substratError('not_found', `no purge schedule runs ${operation} on this scope`));
|
|
3564
|
+
try {
|
|
3565
|
+
this.assertSystemDoor(schedule.moduleId, systemDoorInstance);
|
|
3566
|
+
}
|
|
3567
|
+
catch (err) {
|
|
3568
|
+
if (err instanceof SystemDoorMovedError)
|
|
3569
|
+
return SYSTEM_DOOR_MOVED;
|
|
3570
|
+
throw toRpcError(err);
|
|
3571
|
+
}
|
|
3572
|
+
await this.pruneContinuationPositions();
|
|
3573
|
+
// #119: the gate the coordinator applies before any schedule fires, applied here from what
|
|
3574
|
+
// this scope records of it (`purgeGateFacts`). A foreign tenant throws.
|
|
3575
|
+
const held = purgeHeldBy(this.purgeGateFacts(schedule.moduleId, tenantId));
|
|
3576
|
+
if (held !== null)
|
|
3577
|
+
return heldPurgePass(held);
|
|
3578
|
+
const due = purgeDueOf(doSpineSql(this.sql), this.statePlans, this.operationTarget, operation, schedule.entityType, new Date().toISOString(), PURGE_BATCH);
|
|
3579
|
+
return runPurgePass(operation, due, async (entityId) => {
|
|
3580
|
+
await this.#invokeOrThrow(operation, { [due.idFrom]: entityId }, schedule.moduleId, tenantId, scopeId, undefined, undefined, schedule.moduleId, { invocationId: ulid() }, undefined, undefined, undefined, systemDoorInstance, true);
|
|
3581
|
+
}, (write) => write(doSpineSql(this.sql)));
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* #119: the purge gate's facts (`PurgeGateFacts`), from this scope's own storage: the module's
|
|
3585
|
+
* switch, the lifecycle the platform delivered (#1713), the copy classification (#2009) and the
|
|
3586
|
+
* tenant receipt (`tenantVerdict` — `unknown` passes, as at every door). A directory-backed
|
|
3587
|
+
* deployment records no receipt and delivers neither a lifecycle nor a copy classification here,
|
|
3588
|
+
* so on one those read clear: this object enforces the kill switch, and the coordinator's gate
|
|
3589
|
+
* is the authority for tenant, lifecycle, copy and the rewind hold.
|
|
3590
|
+
*/
|
|
3591
|
+
purgeGateFacts(moduleId, tenantId) {
|
|
3592
|
+
const now = new Date().toISOString();
|
|
3593
|
+
return {
|
|
3594
|
+
switched: systemScheduleState(this.switchSql(), moduleId, now),
|
|
3595
|
+
lifecycle: lifecycleRefusal(readLifecycle(this.switchSql())),
|
|
3596
|
+
copy: this.isCopy(),
|
|
3597
|
+
foreignTenant: this.tenantVerdict(tenantId).verdict === 'foreign',
|
|
3598
|
+
};
|
|
3599
|
+
}
|
|
3210
3600
|
/**
|
|
3211
3601
|
* The last time a schedule's operation ran on this scope (#383), or null.
|
|
3212
3602
|
*
|
|
@@ -3547,18 +3937,55 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3547
3937
|
this.migrationPromise = this.applyPendingMigrations();
|
|
3548
3938
|
return this.migrationPromise;
|
|
3549
3939
|
}
|
|
3940
|
+
/** Every registered module's derivation plans — what `repairDerivedObjects` and its checks read. */
|
|
3941
|
+
derivedPlans() {
|
|
3942
|
+
return { state: this.statePlans, lists: this.listPlans, search: this.searchPlans };
|
|
3943
|
+
}
|
|
3944
|
+
/** A multi-statement script on this DO's own handle, one statement per exec. */
|
|
3945
|
+
runScript(ddl) {
|
|
3946
|
+
for (const stmt of executableSqlStatements(ddl))
|
|
3947
|
+
this.sql.exec(stmt);
|
|
3948
|
+
}
|
|
3949
|
+
/**
|
|
3950
|
+
* #2090: the repair a pass with nothing pending runs, in a transaction of its own. A lost
|
|
3951
|
+
* state column fails the scope closed like a failed migration, named by the journaled
|
|
3952
|
+
* migration that added the column — the pure host's `repairDerived`.
|
|
3953
|
+
*/
|
|
3954
|
+
async repairDerived() {
|
|
3955
|
+
try {
|
|
3956
|
+
await this.revision.transaction(async () => {
|
|
3957
|
+
this.revisionSuspended = true;
|
|
3958
|
+
try {
|
|
3959
|
+
repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
|
|
3960
|
+
after: 'an applied migration',
|
|
3961
|
+
});
|
|
3962
|
+
}
|
|
3963
|
+
finally {
|
|
3964
|
+
this.revisionSuspended = false;
|
|
3965
|
+
}
|
|
3966
|
+
});
|
|
3967
|
+
}
|
|
3968
|
+
catch (err) {
|
|
3969
|
+
const version = err instanceof StateColumnLost ? err.migration : 'kernel@derived-objects';
|
|
3970
|
+
this.lastFailure = { version, error: err.message };
|
|
3971
|
+
throw migrationFailedError(version, err.message);
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3550
3974
|
/** Resolves true if this call applied at least one migration. */
|
|
3551
3975
|
async applyPendingMigrations() {
|
|
3552
|
-
const pending =
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
}
|
|
3558
|
-
}
|
|
3976
|
+
const { pending, diverged } = await planMigrations(this.modules.values(), this.applied);
|
|
3977
|
+
if (diverged) {
|
|
3978
|
+
// Recorded as a failed migration is, so the coordinator projects it the same way.
|
|
3979
|
+
this.lastFailure = diverged;
|
|
3980
|
+
throw migrationFailedError(diverged.version, diverged.error);
|
|
3559
3981
|
}
|
|
3560
|
-
if (pending.length === 0)
|
|
3982
|
+
if (pending.length === 0) {
|
|
3983
|
+
// #2090: once per pass — so once per wake, and on every `retryMigrations` — check and
|
|
3984
|
+
// repair what the kernel derived onto the tables. Their migrations are journaled and will
|
|
3985
|
+
// never run again, so this is what reaches a scope stripped before the pass checked.
|
|
3986
|
+
await this.queue.enqueue(async () => this.repairDerived());
|
|
3561
3987
|
return false;
|
|
3988
|
+
}
|
|
3562
3989
|
this.migrationRuns += 1;
|
|
3563
3990
|
await this.queue.enqueue(async () => {
|
|
3564
3991
|
// #286: bookmark the instant before an UPGRADE migrates live data — the
|
|
@@ -3581,34 +4008,53 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3581
4008
|
// (#278) remains the fallback rewind point.
|
|
3582
4009
|
}
|
|
3583
4010
|
}
|
|
3584
|
-
for (const { moduleId, migration } of pending) {
|
|
4011
|
+
for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
|
|
3585
4012
|
const key = `${moduleId}@${migration.version}`;
|
|
3586
4013
|
if (this.applied.has(key))
|
|
3587
4014
|
continue;
|
|
4015
|
+
let recorded = digest;
|
|
3588
4016
|
try {
|
|
3589
4017
|
await this.revision.transaction(async () => {
|
|
3590
4018
|
const already = this.sql
|
|
3591
|
-
.exec('SELECT
|
|
4019
|
+
.exec('SELECT sql_digest FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
|
|
3592
4020
|
.toArray()[0];
|
|
3593
|
-
if (
|
|
4021
|
+
if (already) {
|
|
4022
|
+
// Applied since this pass read the journal: held to the same digest rule.
|
|
4023
|
+
const error = migrationDivergence(already.sql_digest, digest, authored);
|
|
4024
|
+
if (error)
|
|
4025
|
+
throw new Error(error);
|
|
4026
|
+
recorded = already.sql_digest;
|
|
4027
|
+
}
|
|
4028
|
+
else {
|
|
3594
4029
|
const started = performance.now();
|
|
3595
4030
|
const before = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
|
|
3596
|
-
// #1898: a migration runs on this DO's own handle, not `ctx.sql`, so the
|
|
3597
|
-
// spine
|
|
3598
|
-
|
|
4031
|
+
// #1898, #2066: a migration runs on this DO's own handle, not `ctx.sql`, so the
|
|
4032
|
+
// spine rules a migration is held to are applied here.
|
|
4033
|
+
assertMigrationSql(migration.sql, { key, digest, authored });
|
|
4034
|
+
// #2068: the ownership ledger is the kernel's — no migration may name it, not even to
|
|
4035
|
+
// read it (the spine rules above allow reads).
|
|
4036
|
+
assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
|
|
3599
4037
|
// #1722: not counted per statement, so `total_changes()` measures the migration
|
|
3600
4038
|
// alone. The journal row below is a write, and advances the revision once.
|
|
4039
|
+
// #2068: run one statement at a time, keeping the table set either side of each.
|
|
4040
|
+
let steps;
|
|
3601
4041
|
this.revisionSuspended = true;
|
|
3602
4042
|
try {
|
|
3603
|
-
|
|
4043
|
+
steps = runMigrationStatements(doSpineSql(this.sql), migration.sql, (stmt) => {
|
|
3604
4044
|
this.sql.exec(stmt);
|
|
3605
|
-
}
|
|
4045
|
+
});
|
|
4046
|
+
// #2090: the state columns this migration must have left, and after the last of
|
|
4047
|
+
// the pass, the triggers and indexes a create-copy-rename rebuild dropped.
|
|
4048
|
+
const last = i === pending.length - 1;
|
|
4049
|
+
afterMigration(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), key, last);
|
|
3606
4050
|
}
|
|
3607
4051
|
finally {
|
|
3608
4052
|
this.revisionSuspended = false;
|
|
3609
4053
|
}
|
|
3610
4054
|
const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
|
|
3611
|
-
|
|
4055
|
+
// After the count: `rows_changed` is the migration's own, never the kernel's bookkeeping.
|
|
4056
|
+
recordOwnershipSteps(doSpineSql(this.sql), moduleId, steps, new Date().toISOString());
|
|
4057
|
+
this.sql.exec('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed, sql_digest) VALUES (?, ?, ?, ?, ?, ?)', moduleId, migration.version, new Date().toISOString(), Math.max(0, Math.round(performance.now() - started)), after - before, digest);
|
|
3612
4058
|
}
|
|
3613
4059
|
});
|
|
3614
4060
|
}
|
|
@@ -3622,9 +4068,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3622
4068
|
// It is not an unhandled rejection: every caller awaits the memoised promise, and
|
|
3623
4069
|
// the coordinator records the failure it receives (#1898 review).
|
|
3624
4070
|
this.lastFailure = { version: key, error: err.message };
|
|
3625
|
-
throw
|
|
4071
|
+
throw migrationFailedError(key, err.message);
|
|
3626
4072
|
}
|
|
3627
|
-
this.applied.
|
|
4073
|
+
this.applied.set(key, recorded);
|
|
3628
4074
|
}
|
|
3629
4075
|
});
|
|
3630
4076
|
return true;
|
|
@@ -3863,6 +4309,30 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3863
4309
|
attempts = attempts + 1, settled_at = ?
|
|
3864
4310
|
WHERE id = ? AND status = 'pending'`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
|
|
3865
4311
|
}
|
|
4312
|
+
/**
|
|
4313
|
+
* A settle that writes an outcome event (#2102): the kernel's one settle — the same
|
|
4314
|
+
* compare-and-set as the verb above — and the event in its transaction, written only when
|
|
4315
|
+
* this settle moved the row out of `pending`. Then the event reaches this scope's consumers,
|
|
4316
|
+
* as any committed write's does.
|
|
4317
|
+
*
|
|
4318
|
+
* A verb of its own rather than an argument on the one above, so a settle carrying no event
|
|
4319
|
+
* keeps reaching a DO class older than this change; only the new kind's settle needs it.
|
|
4320
|
+
*/
|
|
4321
|
+
async settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome) {
|
|
4322
|
+
await this.ensureMigrations();
|
|
4323
|
+
return this.queue.enqueue(async () => {
|
|
4324
|
+
const liveSince = this.liveHighWaterMark();
|
|
4325
|
+
this.revision.transactionSync(() => settlePlatformRequestIn(this.switchSql(), id, outcome, {
|
|
4326
|
+
tenantId,
|
|
4327
|
+
scopeId,
|
|
4328
|
+
now: new Date().toISOString(),
|
|
4329
|
+
mintEventId: (ms) => this.mintEventId(ms),
|
|
4330
|
+
version: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
4331
|
+
}));
|
|
4332
|
+
// #1525: null — a settle is the platform's call, not one a vertical's caller made.
|
|
4333
|
+
await this.settleCommitted(tenantId, scopeId, liveSince, null);
|
|
4334
|
+
});
|
|
4335
|
+
}
|
|
3866
4336
|
/**
|
|
3867
4337
|
* Turn one connector delivery into a `connector:<provider>` platform intent (#574
|
|
3868
4338
|
* phase 3) — the CP-less host's substitute for running the handler it cannot run.
|
|
@@ -3956,7 +4426,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3956
4426
|
/** Every table in this scope's DB, with row counts; spine/internal tables flagged. */
|
|
3957
4427
|
introspectTables() {
|
|
3958
4428
|
const names = this.sql
|
|
3959
|
-
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
|
4429
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*' ORDER BY name`)
|
|
3960
4430
|
.toArray().map((r) => r.name);
|
|
3961
4431
|
return names.map((name) => ({
|
|
3962
4432
|
name,
|
|
@@ -3980,7 +4450,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3980
4450
|
}
|
|
3981
4451
|
tablePage(table, limit, offset) {
|
|
3982
4452
|
const known = new Set(this.sql
|
|
3983
|
-
.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
|
4453
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*'`)
|
|
3984
4454
|
.toArray().map((r) => r.name));
|
|
3985
4455
|
if (!known.has(table))
|
|
3986
4456
|
throw substratError('not_found', `unknown table '${table}'`);
|
|
@@ -4018,6 +4488,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4018
4488
|
}
|
|
4019
4489
|
async readOnlyQuery(sql) {
|
|
4020
4490
|
const stmt = assertReadOnlyQuery(sql);
|
|
4491
|
+
assertNoWorkerdStorage(stmt);
|
|
4021
4492
|
let result;
|
|
4022
4493
|
const rollback = new Error('read-only console rollback');
|
|
4023
4494
|
try {
|
|
@@ -4095,7 +4566,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4095
4566
|
// carries the rows, never the index over them.
|
|
4096
4567
|
const defs = this.sql
|
|
4097
4568
|
.exec(`SELECT name, sql FROM sqlite_master
|
|
4098
|
-
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND sql IS NOT NULL
|
|
4569
|
+
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' AND sql IS NOT NULL
|
|
4099
4570
|
ORDER BY name`)
|
|
4100
4571
|
.toArray().filter(({ name }) => !isSearchIndexTable(name));
|
|
4101
4572
|
return defs.map(({ name, sql }) => {
|
|
@@ -4158,6 +4629,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4158
4629
|
// #1763: rows written before these fields keep NULL, meaning unrecorded.
|
|
4159
4630
|
'ALTER TABLE _substrat_migrations ADD COLUMN duration_ms INTEGER',
|
|
4160
4631
|
'ALTER TABLE _substrat_migrations ADD COLUMN rows_changed INTEGER',
|
|
4632
|
+
// #2066: the rows already there get the legacy mark below, never a digest.
|
|
4633
|
+
'ALTER TABLE _substrat_migrations ADD COLUMN sql_digest TEXT',
|
|
4161
4634
|
'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
|
|
4162
4635
|
// #1632: legacy runs retain an unknown subject; no content-based backfill.
|
|
4163
4636
|
'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
|
|
@@ -4214,6 +4687,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4214
4687
|
// every legacy row — a past run's id cannot be recovered afterwards, exactly
|
|
4215
4688
|
// as the other #1525 columns above argued.
|
|
4216
4689
|
'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
|
|
4690
|
+
// #2096: a purge horizon's lap in progress, on a scope DO built before the column. NULL is
|
|
4691
|
+
// the start of a lap — the honest reading of every row already there.
|
|
4692
|
+
'ALTER TABLE _substrat_schedule_state ADD COLUMN purge_cursor TEXT',
|
|
4217
4693
|
// #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
|
|
4218
4694
|
// see `COPY_ORIGIN_DDL`).
|
|
4219
4695
|
'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
|
|
@@ -4239,6 +4715,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4239
4715
|
// boot. `lint:spine-ddl` compares KERNEL_DDL's indexes only, so this one is held to
|
|
4240
4716
|
// both adapters by the query-plan test rather than by that gate.
|
|
4241
4717
|
this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
|
|
4718
|
+
this.sql.exec(GRANT_CHILDREN_INDEX_DDL);
|
|
4719
|
+
// #2066: what they ran was never measured. KERNEL_DDL's fence keeps any other NULL out.
|
|
4720
|
+
this.sql.exec(MIGRATION_DIGEST_MARK_LEGACY);
|
|
4242
4721
|
this.ensureScheduleStateKind();
|
|
4243
4722
|
this.ensureRefusalsAdmitGuards();
|
|
4244
4723
|
}
|
|
@@ -4279,7 +4758,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4279
4758
|
// synchronous, which is the one case the sync API is for (it commits at the
|
|
4280
4759
|
// first await, and there is none). It also has to be sync because the caller is.
|
|
4281
4760
|
this.revision.transactionSync(() => {
|
|
4282
|
-
for (const stmt of
|
|
4761
|
+
for (const stmt of executableSqlStatements(script))
|
|
4283
4762
|
this.sql.exec(stmt);
|
|
4284
4763
|
});
|
|
4285
4764
|
}
|
|
@@ -4356,7 +4835,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4356
4835
|
this.sql.exec('PRAGMA defer_foreign_keys = ON');
|
|
4357
4836
|
// Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
|
|
4358
4837
|
const existing = this.sql
|
|
4359
|
-
|
|
4838
|
+
// Nor workerd's own `_cf_*` (the live reaper's alarm keeps `_cf_METADATA`), which
|
|
4839
|
+
// it refuses to drop.
|
|
4840
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'`)
|
|
4360
4841
|
.toArray();
|
|
4361
4842
|
// Search index tables are left alone here and rebuilt below (#827): dropping a
|
|
4362
4843
|
// shadow table directly is an error, and `sqlite_master` order would reach one
|
|
@@ -4371,9 +4852,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4371
4852
|
// decide how this DO's permission checks match. Every `_substrat_*` table is built
|
|
4372
4853
|
// from KERNEL_DDL instead, and the dump contributes only rows, by column name
|
|
4373
4854
|
// (`spineRowsInsert`), a missing column taking the kernel's default.
|
|
4855
|
+
// Comment-blanked (#2068): a dump records `sqlite_master.sql` verbatim, comments included,
|
|
4856
|
+
// and replaying it raw would store them again — and a later DROP COLUMN of the last
|
|
4857
|
+
// column fails on workerd. The validated statement is otherwise executed exactly as is.
|
|
4374
4858
|
for (const t of replayable)
|
|
4375
4859
|
if (!isSpineTable(t.name))
|
|
4376
|
-
this.sql.exec(t.ddl);
|
|
4860
|
+
this.sql.exec(blankSqlComments(t.ddl));
|
|
4377
4861
|
// KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
|
|
4378
4862
|
// WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
|
|
4379
4863
|
// `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
|
|
@@ -4383,11 +4867,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4383
4867
|
// restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
|
|
4384
4868
|
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
4385
4869
|
// leaves to it.
|
|
4386
|
-
for (const stmt of
|
|
4870
|
+
for (const stmt of executableSqlStatements(KERNEL_DDL))
|
|
4387
4871
|
this.sql.exec(stmt);
|
|
4388
4872
|
this.applySpineColumnAdditions();
|
|
4389
4873
|
const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
|
|
4390
4874
|
assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
|
|
4875
|
+
assertJournalDumpCoherent(replayable);
|
|
4391
4876
|
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
4392
4877
|
// untyped column the checker never reads.
|
|
4393
4878
|
for (const t of replayable) {
|
|
@@ -4438,6 +4923,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4438
4923
|
if (destScopeId) {
|
|
4439
4924
|
this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
|
|
4440
4925
|
}
|
|
4926
|
+
// #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
|
|
4927
|
+
// sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows
|
|
4928
|
+
// that are gone. Repaired, and the search index rebuilt, AFTER the rows — which may
|
|
4929
|
+
// legitimately arrive archived or trashed — as the dump's own journal says they were
|
|
4930
|
+
// derived (#2090). Inside the replay's transaction: a dump whose journal owes a table a
|
|
4931
|
+
// state column it does not carry rolls the whole load back. A plan whose table this dump
|
|
4932
|
+
// did not carry is skipped: a restore must not invent one.
|
|
4933
|
+
repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
|
|
4934
|
+
after: 'the restored dump',
|
|
4935
|
+
absentTable: 'skip',
|
|
4936
|
+
});
|
|
4441
4937
|
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
4442
4938
|
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
4443
4939
|
// the dump's grants never commit live without the switch that should cover them.
|
|
@@ -4450,20 +4946,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4450
4946
|
}
|
|
4451
4947
|
});
|
|
4452
4948
|
this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
|
|
4453
|
-
// Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
|
|
4454
|
-
// create, so it also repairs an index a dump left stale, and the triggers it
|
|
4455
|
-
// recreates are what keep the restored scope in step from here. Skipped for a plan
|
|
4456
|
-
// whose content table this dump did not carry — a restore must not invent a table
|
|
4457
|
-
// for an index to point at.
|
|
4458
|
-
const present = new Set(this.sql
|
|
4459
|
-
.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
|
4460
|
-
.toArray().map((r) => r.name));
|
|
4461
|
-
for (const plan of this.searchPlans.values()) {
|
|
4462
|
-
if (!present.has(plan.table))
|
|
4463
|
-
continue;
|
|
4464
|
-
for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
|
|
4465
|
-
this.sql.exec(stmt);
|
|
4466
|
-
}
|
|
4467
4949
|
// #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
|
|
4468
4950
|
// as on a wake. A copy's own events then sort above every copied one, which is what
|
|
4469
4951
|
// `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
|
|
@@ -4478,12 +4960,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4478
4960
|
}
|
|
4479
4961
|
// The frontier arrived with the dump — refresh the in-memory applied set so a
|
|
4480
4962
|
// later migrate() builds on the imported state, not the provisioning state.
|
|
4481
|
-
this.applied.
|
|
4482
|
-
for (const row of this.sql
|
|
4483
|
-
.exec('SELECT module_id, version FROM _substrat_migrations')
|
|
4484
|
-
.toArray()) {
|
|
4485
|
-
this.applied.add(`${row.module_id}@${row.version}`);
|
|
4486
|
-
}
|
|
4963
|
+
this.applied = readAppliedMigrations(this.sql);
|
|
4487
4964
|
// …and forget that this INSTANCE ever ran a migration pass (#1589). Refreshing
|
|
4488
4965
|
// the set above is not enough on its own: `ensureMigrations` memoises its
|
|
4489
4966
|
// promise, so a warm DO answers "already migrated" from the cache and never
|
|
@@ -4507,6 +4984,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4507
4984
|
// is complete applies nothing, so there is nothing to report either way.
|
|
4508
4985
|
this.migrationPromise = undefined;
|
|
4509
4986
|
this.lastFailure = null;
|
|
4987
|
+
// The KV key never travels in a SQL dump. A load into this same DO would
|
|
4988
|
+
// otherwise retain its old epoch, so explicitly invalidate all old tokens.
|
|
4989
|
+
await this.ctx.storage.delete('continuation:keys');
|
|
4990
|
+
const oldPositions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
|
|
4991
|
+
for (const name of oldPositions.keys())
|
|
4992
|
+
await this.ctx.storage.delete(name);
|
|
4510
4993
|
return switched;
|
|
4511
4994
|
}
|
|
4512
4995
|
/**
|
|
@@ -4743,9 +5226,41 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4743
5226
|
* outranks their Article 17 right.
|
|
4744
5227
|
*/
|
|
4745
5228
|
async redactSubject(subjectId) {
|
|
5229
|
+
// #2068: the module half reaches the scope's own tables, so they have to exist.
|
|
5230
|
+
await this.ensureMigrations();
|
|
4746
5231
|
// One instant for the whole erasure — the intent tombstones must not disagree with
|
|
4747
5232
|
// each other about when a person was erased.
|
|
4748
5233
|
const at = new Date().toISOString();
|
|
5234
|
+
// ONE transaction (#2068): a module's `onSubjectErased` hook that throws rolls the
|
|
5235
|
+
// whole redaction back, the spine half with it, and the coordinator — which destroys
|
|
5236
|
+
// the key only after this returns — never reaches the key.
|
|
5237
|
+
//
|
|
5238
|
+
// Its refusal is answered as DATA (`toWireFailure`, #113): workerd delivers a throw across
|
|
5239
|
+
// this boundary as its message alone, and a hook's `forbidden` (it reached past its own
|
|
5240
|
+
// tables) is a different answer from an `internal`. In this method's own reply rather than
|
|
5241
|
+
// a `…Reply` sibling, so every coordinator — old ones too — keeps calling one verb: an old
|
|
5242
|
+
// one reads a failure as a reply missing its counts and refuses before the key, which is
|
|
5243
|
+
// the safe reading.
|
|
5244
|
+
try {
|
|
5245
|
+
return this.revision.transactionSync(() => this.redactSubjectInTransaction(subjectId, at));
|
|
5246
|
+
}
|
|
5247
|
+
catch (err) {
|
|
5248
|
+
return { failure: toWireFailure(err) };
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5251
|
+
/** The body of `redactSubject`, inside its transaction. */
|
|
5252
|
+
redactSubjectInTransaction(subjectId, at) {
|
|
5253
|
+
// The module half first (#2068): the declared entities, then each hook, with the search
|
|
5254
|
+
// indexes over them under FTS5 secure-delete. The spine half follows in the same transaction.
|
|
5255
|
+
const vertical = eraseSubjectFromModules({
|
|
5256
|
+
sql: doSpineSql(this.sql),
|
|
5257
|
+
plans: this.erasurePlans,
|
|
5258
|
+
searchPlans: this.searchPlans.values(),
|
|
5259
|
+
statefulTables: statefulTablesOf(this.statePlans),
|
|
5260
|
+
subjectId,
|
|
5261
|
+
at,
|
|
5262
|
+
migrationSqlOf: (moduleId, version) => this.modules.get(moduleId)?.migrations.find((m) => m.version === version)?.sql,
|
|
5263
|
+
});
|
|
4749
5264
|
const doomed = this.sql
|
|
4750
5265
|
.exec(`SELECT id FROM _substrat_outbox
|
|
4751
5266
|
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`, subjectId)
|
|
@@ -4766,6 +5281,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4766
5281
|
// The free-text copies (#1632), and the tombstoned intents the coordinator hands to
|
|
4767
5282
|
// the directory half. Last, so those ids include every intent tombstoned above.
|
|
4768
5283
|
...redactSubjectScopeText(sql, subjectId, at),
|
|
5284
|
+
vertical,
|
|
4769
5285
|
};
|
|
4770
5286
|
}
|
|
4771
5287
|
/**
|
|
@@ -4792,36 +5308,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4792
5308
|
return redacted;
|
|
4793
5309
|
}
|
|
4794
5310
|
// -- event dispatch (port of dispatch) ------------------------------------
|
|
4795
|
-
/**
|
|
4796
|
-
* #1237: the event currently being delivered, or null.
|
|
4797
|
-
*
|
|
4798
|
-
* The host carries a field of the same name for the admin log, and it is
|
|
4799
|
-
* unreachable from here — it lives in the worker that holds the stub, while every
|
|
4800
|
-
* emit runs inside this Durable Object. So the DO keeps its own, and the two are
|
|
4801
|
-
* deliberately separate rather than one passed across the hop.
|
|
4802
|
-
*
|
|
4803
|
-
* One instance IS one scope, so this field is per-scope by construction. The
|
|
4804
|
-
* SQLite twin has to say so explicitly (`ScopeRuntime.causedBy`): that host holds
|
|
4805
|
-
* every scope in the process, so the same field on the host would be read by
|
|
4806
|
-
* another scope's emit the moment a consumer awaits.
|
|
4807
|
-
*/
|
|
4808
|
-
causedBy = null;
|
|
4809
5311
|
/**
|
|
4810
5312
|
* #1237: the invocation currently running in this DO, or null.
|
|
4811
5313
|
*
|
|
4812
|
-
* DO-local
|
|
4813
|
-
*
|
|
4814
|
-
*
|
|
4815
|
-
*
|
|
5314
|
+
* DO-local: a Durable Object IS one scope, so there is no other scope's call to
|
|
5315
|
+
* confuse it with, and `invoke` sets and clears it inside its queued body, which no
|
|
5316
|
+
* other emitting body interleaves with. It still has to be cleared, because the DO
|
|
5317
|
+
* outlives the request and a value left set would stamp a later alarm-driven drain
|
|
5318
|
+
* with a call it had nothing to do with. A consumer's own cause is not kept here: it is
|
|
5319
|
+
* passed into the consumer's context (`ConsumerDelivery`, #2055).
|
|
4816
5320
|
*/
|
|
4817
5321
|
invocationId = null;
|
|
4818
|
-
/**
|
|
4819
|
-
* #1901: the id a unit of async work with no call around it logs under — a consumer a
|
|
4820
|
-
* seed or an import delivered — set for its handler's duration and cleared after. Read
|
|
4821
|
-
* by `ctx.log` only, never the spine: an event a consumer emits outside a call still
|
|
4822
|
-
* records no invocation (#1525), while its log lines join the consumer's own line.
|
|
4823
|
-
*/
|
|
4824
|
-
unitInvocationId = null;
|
|
4825
5322
|
async dispatch(tenantId, scopeId,
|
|
4826
5323
|
/**
|
|
4827
5324
|
* #1525: the call this drain is running in, or null. Every round of the loop
|
|
@@ -4884,16 +5381,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4884
5381
|
lines.write({ ...unit, outcome: 'dead-lettered' });
|
|
4885
5382
|
continue;
|
|
4886
5383
|
}
|
|
4887
|
-
// #1237: anything this consumer emits was emitted BECAUSE of this event
|
|
4888
|
-
// — the step a backwards walk used to stop dead at, since a consumer
|
|
4889
|
-
// emit records no operation either.
|
|
4890
|
-
this.causedBy = event.id;
|
|
4891
|
-
// #1901: the handler's `ctx.log` lines join this delivery's line.
|
|
4892
|
-
this.unitInvocationId = unit.invocationId;
|
|
4893
5384
|
try {
|
|
4894
5385
|
await this.revision.transaction(async () => {
|
|
4895
|
-
|
|
4896
|
-
|
|
5386
|
+
// #1237: anything this consumer emits was emitted BECAUSE of this event —
|
|
5387
|
+
// the step a backwards walk used to stop dead at, since a consumer emit
|
|
5388
|
+
// records no operation either. #1901: its `ctx.log` lines join this
|
|
5389
|
+
// delivery's line.
|
|
5390
|
+
const ctx = this.consumerContext(tenantId, scopeId, mod.id, {
|
|
5391
|
+
causedBy: event.id,
|
|
5392
|
+
invocationId: unit.invocationId,
|
|
4897
5393
|
});
|
|
4898
5394
|
await consumer.handler(ctx, event);
|
|
4899
5395
|
this.sql.exec(`INSERT INTO _substrat_deliveries
|
|
@@ -4911,14 +5407,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4911
5407
|
VALUES (?, ?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4912
5408
|
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
4913
5409
|
}
|
|
4914
|
-
finally {
|
|
4915
|
-
this.unitInvocationId = null;
|
|
4916
|
-
// Cleared on BOTH paths. Left set, the id leaks onto every later emit
|
|
4917
|
-
// this DO makes — an operation's own event stamped as caused by
|
|
4918
|
-
// whatever was delivered last, which is worse than recording nothing
|
|
4919
|
-
// because it reads as a fact.
|
|
4920
|
-
this.causedBy = null;
|
|
4921
|
-
}
|
|
4922
5410
|
}
|
|
4923
5411
|
}
|
|
4924
5412
|
}
|
|
@@ -5033,11 +5521,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5033
5521
|
* the queued body. Mutually exclusive with the other non-person callers; `principal` is
|
|
5034
5522
|
* then a placeholder that the subject below never reads.
|
|
5035
5523
|
*/
|
|
5036
|
-
peerSubject
|
|
5524
|
+
peerSubject,
|
|
5525
|
+
/** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
|
|
5526
|
+
delivery,
|
|
5527
|
+
/** #119: keys this context's checks refuse whatever the subject holds (`purgeOnlyKeysOf`). */
|
|
5528
|
+
withheld) {
|
|
5037
5529
|
const checker = this.checker;
|
|
5038
5530
|
const relations = this.relations;
|
|
5039
5531
|
const searchPlans = this.searchPlans;
|
|
5040
5532
|
const listPlans = this.listPlans;
|
|
5533
|
+
const statePlans = this.statePlans;
|
|
5041
5534
|
const sql = this.sql;
|
|
5042
5535
|
const mintEventId = this.mintEventId;
|
|
5043
5536
|
/**
|
|
@@ -5093,6 +5586,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5093
5586
|
// #1642: parsed before the system actor's early return, which never reaches
|
|
5094
5587
|
// the checker — a cast key would otherwise become that path's proof relation.
|
|
5095
5588
|
const permission = assertPermissionKey(unparsed);
|
|
5589
|
+
if (withheld?.has(permission)) {
|
|
5590
|
+
return { allowed: false, checked: permission, node: { tenantId, scopeId } };
|
|
5591
|
+
}
|
|
5096
5592
|
if (systemActor) {
|
|
5097
5593
|
return {
|
|
5098
5594
|
allowed: true,
|
|
@@ -5160,10 +5656,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5160
5656
|
// about the deploy, so it never rides `DomainEvent` for module code to
|
|
5161
5657
|
// branch on; it exists for the observability joins the column serves.
|
|
5162
5658
|
this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
5163
|
-
// #1237:
|
|
5164
|
-
//
|
|
5165
|
-
//
|
|
5166
|
-
|
|
5659
|
+
// #1237: the delivery this context was built for, if any — passed in, never read
|
|
5660
|
+
// off the DO (#2055). A fact about the surrounding dispatch, never envelope data
|
|
5661
|
+
// module code could set or branch on.
|
|
5662
|
+
delivery?.causedBy ?? null,
|
|
5167
5663
|
// #1237: a fact about the surrounding CALL, like the version above.
|
|
5168
5664
|
this.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
|
|
5169
5665
|
};
|
|
@@ -5173,7 +5669,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5173
5669
|
// #1672: a capability's own id stands in so the type holds — it is not a person, and
|
|
5174
5670
|
// the event actor says what it is instead. Every other door passes its own value.
|
|
5175
5671
|
principal: capabilityId ? capabilityId : principal,
|
|
5176
|
-
|
|
5672
|
+
pageContinuation: (list, query) => visibleContinuation(this.continuationStore(impersonation?.mode !== 'read-only' && !this.isCopy()), { scopeId, principal: `${subject.kind}:${subject.id}`, operation: operation ?? 'kernel', list, query }, Date.now, () => ctxRef.log.info('legacy filtered-list cursor accepted', { list }), impersonation?.mode !== 'read-only' && !this.isCopy()),
|
|
5673
|
+
sql: guardSecrets(doScopedSql(sql, statefulTablesOf(statePlans),
|
|
5674
|
+
// #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
|
|
5675
|
+
// derived onto any table is repaired — and a lost state column fails the operation.
|
|
5676
|
+
derivesAnything(this.derivedPlans())
|
|
5677
|
+
? () => afterRuntimeDdl(doSpineSql(sql), (ddl) => this.runScript(ddl), this.derivedPlans())
|
|
5678
|
+
: undefined), minted),
|
|
5177
5679
|
now: () => at,
|
|
5178
5680
|
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
5179
5681
|
// and its fields. A consumer runs under the system override, so it logs as `system`.
|
|
@@ -5182,7 +5684,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5182
5684
|
tenantId,
|
|
5183
5685
|
scopeId,
|
|
5184
5686
|
operation: operation ?? null,
|
|
5185
|
-
invocationId: () => this.invocationId ??
|
|
5687
|
+
invocationId: () => this.invocationId ?? delivery?.invocationId ?? null,
|
|
5186
5688
|
principalKind: systemActor ? 'system' : subject.kind,
|
|
5187
5689
|
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
5188
5690
|
// log's text is not JSON.
|
|
@@ -5231,6 +5733,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5231
5733
|
return entityVersionOf(sql.exec(q.sql, ...q.params).toArray());
|
|
5232
5734
|
},
|
|
5233
5735
|
check: runCheck,
|
|
5736
|
+
grantedEntities: async (unparsed, entityType, options) => {
|
|
5737
|
+
const permission = assertPermissionKey(unparsed);
|
|
5738
|
+
return grantedEntitiesForContext(checker, subject, permission, { tenantId, scopeId }, entityType, options, runCheck, withheld, Boolean(systemActor));
|
|
5739
|
+
},
|
|
5234
5740
|
canAssign: runCanAssign,
|
|
5235
5741
|
// #827. Mirror of the pure adapter: the plan comes from registration, the
|
|
5236
5742
|
// rows from this DO's own SQLite, and the index is maintained by triggers
|
|
@@ -5240,7 +5746,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5240
5746
|
const plan = searchPlans.get(entityType);
|
|
5241
5747
|
if (!plan)
|
|
5242
5748
|
throw new NotSearchable(entityType);
|
|
5243
|
-
const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit)
|
|
5749
|
+
const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit),
|
|
5750
|
+
// #119: active rows unless the archive is asked for.
|
|
5751
|
+
searchStateWhere(statePlans, entityType, options?.view));
|
|
5244
5752
|
return sql.exec(q.sql, ...q.params).toArray().map((row) => ({ entityType, id: row.id, rank: row.rank }));
|
|
5245
5753
|
},
|
|
5246
5754
|
/**
|
|
@@ -5260,17 +5768,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5260
5768
|
order: params.order,
|
|
5261
5769
|
cursor: params.cursor,
|
|
5262
5770
|
filters: params.filters,
|
|
5771
|
+
// #119: the bin is the checked reader's, never this one's.
|
|
5772
|
+
view: uncheckedView('ctx.page', entityType, params.view),
|
|
5263
5773
|
});
|
|
5264
5774
|
const rows = sql.exec(q.sql, ...q.params).toArray();
|
|
5265
|
-
|
|
5266
|
-
const
|
|
5267
|
-
const page = { entries: rows, nextCursor };
|
|
5775
|
+
// `pageOf`'s rule, and each row's own cursor when asked (#2073).
|
|
5776
|
+
const mint = (row) => cursorOf(row, q.sortColumn, plan.idColumn, q.order, q.view);
|
|
5268
5777
|
if (!params.total)
|
|
5269
|
-
return
|
|
5778
|
+
return pageOf(rows, limit, mint, params.rowCursors);
|
|
5270
5779
|
const counted = sql
|
|
5271
5780
|
.exec(q.countSql, ...q.countParams)
|
|
5272
5781
|
.toArray();
|
|
5273
|
-
return
|
|
5782
|
+
return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
|
|
5274
5783
|
},
|
|
5275
5784
|
/**
|
|
5276
5785
|
* Delegate a permission this caller holds onto one entity — see the
|
|
@@ -5288,7 +5797,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5288
5797
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
5289
5798
|
'the caller does not hold it there (a grant delegates, it never elevates)');
|
|
5290
5799
|
}
|
|
5291
|
-
|
|
5800
|
+
// #2071: an explicit grant, so it clears a tombstone `revoke` left.
|
|
5801
|
+
writeExplicitTupleIn(this.switchSql(), `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, { kind: 'delegated' });
|
|
5292
5802
|
},
|
|
5293
5803
|
/**
|
|
5294
5804
|
* Deliberately NOT the #1856 grammar check `grant` and `link` make: a revoke writes
|
|
@@ -5302,7 +5812,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5302
5812
|
throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
5303
5813
|
'the caller does not hold it there');
|
|
5304
5814
|
}
|
|
5305
|
-
|
|
5815
|
+
// K-21 (#2071): a tombstone, never a delete — a declared shape's top-up must be
|
|
5816
|
+
// able to tell a key taken back from one never held.
|
|
5817
|
+
const r = delegatedRevokeSql(principal, permission, `${entity.entityType}:${entity.entityId}`, at);
|
|
5818
|
+
sql.exec(r.sql, ...r.params);
|
|
5306
5819
|
},
|
|
5307
5820
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
5308
5821
|
// #1672: mint / revoke / list, written once in the kernel — the pure adapter hands
|
|
@@ -5328,6 +5841,24 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5328
5841
|
now: at,
|
|
5329
5842
|
emit: (event) => writeEvent(event, 'kernel'),
|
|
5330
5843
|
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
5844
|
+
isUnreachableParent: (entity) => isUnreachableParent(doSpineSql(sql), statePlans, entity),
|
|
5845
|
+
}),
|
|
5846
|
+
// #119: archive and trash — the pure adapter's wiring, over the raw spine seam.
|
|
5847
|
+
...createEntityStateVerbs({
|
|
5848
|
+
sql: doSpineSql(sql),
|
|
5849
|
+
plans: statePlans,
|
|
5850
|
+
now: at,
|
|
5851
|
+
check: runCheck,
|
|
5852
|
+
emit: (event) => writeEvent(event, 'kernel'),
|
|
5853
|
+
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
5854
|
+
}),
|
|
5855
|
+
...createTrashedReads({
|
|
5856
|
+
query: (q, params) => sql.exec(q, ...params).toArray(),
|
|
5857
|
+
listPlans,
|
|
5858
|
+
searchPlans,
|
|
5859
|
+
statePlans,
|
|
5860
|
+
check: runCheck,
|
|
5861
|
+
continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
|
|
5331
5862
|
}),
|
|
5332
5863
|
entitlement: async (key) => {
|
|
5333
5864
|
const held = await entitlementReader().listEntitlements(tenantId);
|
|
@@ -5370,6 +5901,29 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5370
5901
|
.toArray()[0];
|
|
5371
5902
|
return row?.value === 'local' ? 'local' : 'control-plane';
|
|
5372
5903
|
}
|
|
5904
|
+
/**
|
|
5905
|
+
* Whether a check here reads the directory over RPC (#938, Codex #2077 r5): the same
|
|
5906
|
+
* choice `controlPlaneReader` makes per call, asked each time a live fan-out pass opens an epoch.
|
|
5907
|
+
*/
|
|
5908
|
+
permissionSourceIsRemote() {
|
|
5909
|
+
return this.permissionSource() !== 'local' && Boolean(this.env.CONTROL_PLANE);
|
|
5910
|
+
}
|
|
5911
|
+
/**
|
|
5912
|
+
* The earliest `expires_at` after `now` in any local store a check or the parent walk
|
|
5913
|
+
* reads (#938, Codex #2077 r5), or null when nothing here lapses. Until then, no grant,
|
|
5914
|
+
* tenant tuple, parent edge or entitlement this scope holds can stop authorizing without
|
|
5915
|
+
* a write. Revoked rows are not excluded: an earlier bound only costs a re-check.
|
|
5916
|
+
*/
|
|
5917
|
+
authorityLapsesAfter(now) {
|
|
5918
|
+
const row = this.sql
|
|
5919
|
+
.exec(`SELECT MIN(e) AS until FROM (
|
|
5920
|
+
SELECT MIN(expires_at) AS e FROM _substrat_tuples WHERE expires_at > ?
|
|
5921
|
+
UNION ALL SELECT MIN(expires_at) FROM _substrat_tenant_tuples WHERE expires_at > ?
|
|
5922
|
+
UNION ALL SELECT MIN(expires_at) FROM _substrat_entitlements WHERE expires_at > ?
|
|
5923
|
+
)`, now, now, now)
|
|
5924
|
+
.toArray()[0];
|
|
5925
|
+
return row?.until ?? null;
|
|
5926
|
+
}
|
|
5373
5927
|
/**
|
|
5374
5928
|
* The checker's tenant-tuple/role reader. Chosen **per call** (the source can
|
|
5375
5929
|
* flip at runtime, and the checker holds this wrapper for the DO's lifetime):
|