@substrat-run/adapter-cloudflare 0.139.0 → 0.141.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 +93 -9
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +368 -71
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +36 -8
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +346 -58
- 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 +30 -2
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +731 -219
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +2 -0
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +23 -1
- package/dist/sql.js.map +1 -1
- package/package.json +12 -12
package/dist/scope-do.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
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, 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,
|
|
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, ENTITY_STATE_MOVES_DDL, SWITCH_FENCES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, searchStateWhere, uncheckedView, addStatePlans,
|
|
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
|
+
import { COPY_RESTORE_FENCE_LAPSED, copyRestoreFenceLapsed } from '@substrat-run/kernel';
|
|
11
12
|
import { createDoTupleChecker, createLocalControlPlaneReader, scopeTupleReader } from './checker.js';
|
|
12
|
-
import {
|
|
13
|
+
import { settlePlatformRequestIn } from '@substrat-run/kernel';
|
|
14
|
+
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';
|
|
15
|
+
import { GRANT_CHILDREN_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
|
|
16
|
+
/** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
|
|
17
|
+
function readAppliedMigrations(sql) {
|
|
18
|
+
const rows = sql.exec('SELECT module_id, version, sql_digest FROM _substrat_migrations').toArray();
|
|
19
|
+
return new Map(rows.map((r) => [`${r.module_id}@${r.version}`, r.sql_digest]));
|
|
20
|
+
}
|
|
13
21
|
/**
|
|
14
22
|
* The key marking a scope DO whose storage was destroyed (`destroyStorage`).
|
|
15
23
|
* Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
|
|
@@ -32,6 +40,9 @@ const foreignTenant = (held, tenantId) => held !== null
|
|
|
32
40
|
: `refused: this scope holds role rows for another tenant and none for ${tenantId}`;
|
|
33
41
|
/** #2016: the load refusals `tenantReceiptRefusal` raised, told apart by identity, not by text. */
|
|
34
42
|
const tenantRefusals = new WeakSet();
|
|
43
|
+
/** #1722: a load refused because its copy move's lease ran out (`CopyRestoreFence`), told apart
|
|
44
|
+
* from a store that moved, since the host must not read it as an applied retry. */
|
|
45
|
+
const fenceRefusals = new WeakSet();
|
|
35
46
|
/**
|
|
36
47
|
* The scope spine, as this adapter builds it — one of two hand-written copies (#969).
|
|
37
48
|
*
|
|
@@ -179,8 +190,13 @@ const KERNEL_DDL = `
|
|
|
179
190
|
applied_at TEXT NOT NULL,
|
|
180
191
|
duration_ms INTEGER,
|
|
181
192
|
rows_changed INTEGER,
|
|
193
|
+
-- #2066: SHA-256 of the SQL that ran (kernel migrationDigest). NULL on a row written
|
|
194
|
+
-- before the column: unrecorded, accepted, never backfilled.
|
|
195
|
+
sql_digest TEXT,
|
|
182
196
|
PRIMARY KEY (module_id, version)
|
|
183
197
|
);
|
|
198
|
+
-- #2066: no new journal row without its digest (the kernel's comment says why).
|
|
199
|
+
${MIGRATION_DIGEST_FENCE_DDL}
|
|
184
200
|
-- #286: the PITR bookmark taken immediately BEFORE a migration pass runs on a
|
|
185
201
|
-- scope that already holds data -- the precise rewind point a backout restores
|
|
186
202
|
-- to. Rows live in the same storage they describe, so a rewind erases the rows
|
|
@@ -192,6 +208,7 @@ const KERNEL_DDL = `
|
|
|
192
208
|
pending TEXT NOT NULL
|
|
193
209
|
);
|
|
194
210
|
${SWITCH_FENCES_DDL}
|
|
211
|
+
${TABLE_OWNERS_DDL}
|
|
195
212
|
CREATE TABLE IF NOT EXISTS _substrat_tuples (
|
|
196
213
|
subject TEXT NOT NULL,
|
|
197
214
|
relation TEXT NOT NULL,
|
|
@@ -202,6 +219,7 @@ const KERNEL_DDL = `
|
|
|
202
219
|
revoked_at TEXT,
|
|
203
220
|
PRIMARY KEY (subject, relation, object)
|
|
204
221
|
);
|
|
222
|
+
${GRANT_CHILDREN_INDEX_DDL};
|
|
205
223
|
CREATE TABLE IF NOT EXISTS _substrat_deliveries (
|
|
206
224
|
event_id TEXT NOT NULL,
|
|
207
225
|
consumer_module TEXT NOT NULL,
|
|
@@ -420,6 +438,17 @@ class WriteRevision {
|
|
|
420
438
|
keeping = false;
|
|
421
439
|
/** A statement's text is almost always a constant, so its answer is remembered (bounded). */
|
|
422
440
|
writes = new Map();
|
|
441
|
+
/**
|
|
442
|
+
* Every write statement run through this handle since the object woke, in memory (#938, Codex
|
|
443
|
+
* #2077 r4) — counted per STATEMENT, bookkeeping and suspended ones included, where the durable
|
|
444
|
+
* revision above is bumped once per run. A live fan-out pass decides who may hear a frame across
|
|
445
|
+
* awaits; it reads this immediately before deciding and again immediately before sending, with
|
|
446
|
+
* no await between that second read and the send, and decides again when it moved. Per statement
|
|
447
|
+
* because a decision taken between a transaction's first write (its bump) and a later one (the
|
|
448
|
+
* revoke itself) must not read as current. Over-counting (a rolled-back write, a write no check
|
|
449
|
+
* reads) only costs a pass a re-check.
|
|
450
|
+
*/
|
|
451
|
+
written = 0;
|
|
423
452
|
sql;
|
|
424
453
|
constructor(raw, storage, suspended,
|
|
425
454
|
/** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
|
|
@@ -429,7 +458,10 @@ class WriteRevision {
|
|
|
429
458
|
this.suspended = suspended;
|
|
430
459
|
this.refusal = refusal;
|
|
431
460
|
const exec = (query, ...bindings) => {
|
|
432
|
-
|
|
461
|
+
const write = this.isWrite(query);
|
|
462
|
+
if (write)
|
|
463
|
+
this.written++;
|
|
464
|
+
if (this.keeping && write) {
|
|
433
465
|
// Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
|
|
434
466
|
// else: any other write here would be one the revision never saw, which is the hole this
|
|
435
467
|
// class exists to close.
|
|
@@ -438,7 +470,7 @@ class WriteRevision {
|
|
|
438
470
|
}
|
|
439
471
|
return raw.exec(query, ...bindings);
|
|
440
472
|
}
|
|
441
|
-
if (
|
|
473
|
+
if (write && !this.suspended()) {
|
|
442
474
|
const refused = this.refusal();
|
|
443
475
|
if (refused)
|
|
444
476
|
throw substratError('conflict', refused);
|
|
@@ -504,6 +536,10 @@ class WriteRevision {
|
|
|
504
536
|
this.covered = false;
|
|
505
537
|
});
|
|
506
538
|
}
|
|
539
|
+
/** How many write statements this handle has run (see `written`). */
|
|
540
|
+
get statementsWritten() {
|
|
541
|
+
return this.written;
|
|
542
|
+
}
|
|
507
543
|
isWrite(query) {
|
|
508
544
|
let known = this.writes.get(query);
|
|
509
545
|
if (known === undefined) {
|
|
@@ -590,8 +626,6 @@ function cellToJson(v) {
|
|
|
590
626
|
* trigger: it passed on better-sqlite3 (whose `exec` takes the whole blob) and
|
|
591
627
|
* failed every scope on the DO host.
|
|
592
628
|
*/
|
|
593
|
-
const IS_CREATE_TRIGGER = /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i;
|
|
594
|
-
const ENDS_WITH_END = /\bEND\s*$/i;
|
|
595
629
|
/**
|
|
596
630
|
* The kernel's two-method SQL handle (`SwitchSql`) over a Durable Object's storage — what the
|
|
597
631
|
* shared switch rules (#1666) and the directory's switch record (#1674) run through. One
|
|
@@ -605,67 +639,8 @@ export function switchSqlOver(sql) {
|
|
|
605
639
|
},
|
|
606
640
|
};
|
|
607
641
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
let cur = '';
|
|
611
|
-
const n = sql.length;
|
|
612
|
-
let i = 0;
|
|
613
|
-
while (i < n) {
|
|
614
|
-
const c = sql[i];
|
|
615
|
-
const c2 = sql[i + 1];
|
|
616
|
-
if (c === '-' && c2 === '-') {
|
|
617
|
-
while (i < n && sql[i] !== '\n')
|
|
618
|
-
i += 1; // line comment → end of line
|
|
619
|
-
continue;
|
|
620
|
-
}
|
|
621
|
-
if (c === '/' && c2 === '*') {
|
|
622
|
-
i += 2;
|
|
623
|
-
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/'))
|
|
624
|
-
i += 1;
|
|
625
|
-
i += 2; // block comment → past the closing */
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
if (c === "'") {
|
|
629
|
-
cur += c;
|
|
630
|
-
i += 1;
|
|
631
|
-
while (i < n) {
|
|
632
|
-
cur += sql[i];
|
|
633
|
-
if (sql[i] === "'") {
|
|
634
|
-
if (sql[i + 1] === "'") {
|
|
635
|
-
cur += sql[i + 1]; // '' is an escaped quote, still inside the string
|
|
636
|
-
i += 2;
|
|
637
|
-
continue;
|
|
638
|
-
}
|
|
639
|
-
i += 1;
|
|
640
|
-
break;
|
|
641
|
-
}
|
|
642
|
-
i += 1;
|
|
643
|
-
}
|
|
644
|
-
continue;
|
|
645
|
-
}
|
|
646
|
-
if (c === ';') {
|
|
647
|
-
// Inside a trigger body, a `;` ends an inner statement, not the CREATE.
|
|
648
|
-
// `END` is matched as a bare word at the end of what has accumulated —
|
|
649
|
-
// a string literal ending in END reads as `…END'`, so the quote keeps it
|
|
650
|
-
// from matching, and the string scanner above has already copied it whole.
|
|
651
|
-
if (IS_CREATE_TRIGGER.test(cur) && !ENDS_WITH_END.test(cur)) {
|
|
652
|
-
cur += c;
|
|
653
|
-
i += 1;
|
|
654
|
-
continue;
|
|
655
|
-
}
|
|
656
|
-
if (cur.trim())
|
|
657
|
-
out.push(cur.trim());
|
|
658
|
-
cur = '';
|
|
659
|
-
i += 1;
|
|
660
|
-
continue;
|
|
661
|
-
}
|
|
662
|
-
cur += c;
|
|
663
|
-
i += 1;
|
|
664
|
-
}
|
|
665
|
-
if (cur.trim())
|
|
666
|
-
out.push(cur.trim());
|
|
667
|
-
return out;
|
|
668
|
-
}
|
|
642
|
+
/** The splitter both adapters run migrations through — the kernel's, re-exported for this package's callers. */
|
|
643
|
+
export { splitSqlStatements };
|
|
669
644
|
/**
|
|
670
645
|
* #1834: the pin missed. Thrown only by `assertSystemDoor`, and module-private, so no operation can
|
|
671
646
|
* throw one: the two RPCs a door call reaches turn it into the `SystemDoorMoved` ANSWER, by class
|
|
@@ -675,9 +650,51 @@ class SystemDoorMovedError extends Error {
|
|
|
675
650
|
}
|
|
676
651
|
/** #1834: the brand only `assertSystemDoor` sets, so nothing else can make a `SystemDoorPass`. */
|
|
677
652
|
const SYSTEM_DOOR_PASSED = Symbol('system door passed');
|
|
653
|
+
/** Close a live socket whose session has ended (#938). */
|
|
654
|
+
function closeSessionEnded(ws) {
|
|
655
|
+
try {
|
|
656
|
+
ws.close(LIVE_CLOSE.revoked, 'the session that opened this subscription has ended');
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// Already gone.
|
|
660
|
+
}
|
|
661
|
+
}
|
|
678
662
|
export function defineScopeDO(modules, bareOps) {
|
|
679
663
|
return class ScopeDO extends DurableObject {
|
|
680
664
|
sql;
|
|
665
|
+
continuationOrder = 0;
|
|
666
|
+
/** DO storage KV is private to the adapter; neither module SQL nor dumps can read it. */
|
|
667
|
+
continuationStore(writable) {
|
|
668
|
+
const positionKey = (id, expiresAt) => `continuation:position:${String(expiresAt).padStart(13, '0')}:${id}`;
|
|
669
|
+
return {
|
|
670
|
+
keys: async () => (await this.ctx.storage.get('continuation:keys')) ?? null,
|
|
671
|
+
setKeys: async (keys) => { await this.ctx.storage.put('continuation:keys', keys); },
|
|
672
|
+
position: async (id, expiresAt) => {
|
|
673
|
+
if (writable)
|
|
674
|
+
await this.pruneContinuationPositions();
|
|
675
|
+
return (await this.ctx.storage.get(positionKey(id, expiresAt))) ?? null;
|
|
676
|
+
},
|
|
677
|
+
setPosition: async (id, position) => {
|
|
678
|
+
// Several budget stops can land in one millisecond. Keep insertion
|
|
679
|
+
// order explicit so the cap always evicts the first locator minted.
|
|
680
|
+
this.continuationOrder = Math.max(Date.now() * 1_000, this.continuationOrder + 1);
|
|
681
|
+
await this.ctx.storage.put(positionKey(id, position.expiresAt), {
|
|
682
|
+
...position, order: this.continuationOrder,
|
|
683
|
+
});
|
|
684
|
+
await this.pruneContinuationPositions();
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
async pruneContinuationPositions() {
|
|
689
|
+
const positions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
|
|
690
|
+
const live = [...positions].filter(([, record]) => record.expiresAt > Date.now())
|
|
691
|
+
.sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0));
|
|
692
|
+
const evict = new Set(live.slice(0, Math.max(0, live.length - CONTINUATION_POSITION_CAP)).map(([name]) => name));
|
|
693
|
+
for (const [name, record] of positions) {
|
|
694
|
+
if (record.expiresAt <= Date.now() || evict.has(name))
|
|
695
|
+
await this.ctx.storage.delete(name);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
681
698
|
queue = new OperationQueue();
|
|
682
699
|
operations = new Map();
|
|
683
700
|
/**
|
|
@@ -688,6 +705,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
688
705
|
operationInput = new Map();
|
|
689
706
|
/** #129: name → the entity whose version an `If-Match` is compared against. */
|
|
690
707
|
operationConcurrency = new Map();
|
|
708
|
+
/** #119: name → the entity it addresses by id — the host's trash refusal reads it. */
|
|
709
|
+
operationTarget = new Map();
|
|
710
|
+
/** #119: module id → the keys its system principal holds only for its purge schedules. */
|
|
711
|
+
purgeOnlyKeys = new Map();
|
|
712
|
+
/** #119: a purge schedule's operation → the module that declared it and the entity it purges. */
|
|
713
|
+
purgeSchedules = new Map();
|
|
691
714
|
/** #116: the operations that declared `idempotency: false` — refusals, not participants. */
|
|
692
715
|
operationIdempotencyOptOut = new Set();
|
|
693
716
|
modules = new Map();
|
|
@@ -713,6 +736,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
713
736
|
listPlans = new Map();
|
|
714
737
|
/** #119: entity type → its archive/trash plan, from every registered module. */
|
|
715
738
|
statePlans = new Map();
|
|
739
|
+
/** #2068: each registered module's erasure, in registration order — what `redactSubject` runs. */
|
|
740
|
+
erasurePlans = [];
|
|
716
741
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
717
742
|
attachmentTargets = new Map();
|
|
718
743
|
/**
|
|
@@ -736,7 +761,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
736
761
|
* underneath rows it already stored.
|
|
737
762
|
*/
|
|
738
763
|
mintEventId = createUlid();
|
|
739
|
-
|
|
764
|
+
/** `module@version` → the SQL digest its journal row recorded, null for a row from before #2066. */
|
|
765
|
+
applied = new Map();
|
|
740
766
|
migrationPromise;
|
|
741
767
|
/** Latch: the applied count is reported to the directory once per DO instance. */
|
|
742
768
|
schemaVersionReported = false;
|
|
@@ -779,7 +805,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
779
805
|
// transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
|
|
780
806
|
this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
|
|
781
807
|
this.sql = this.revision.sql;
|
|
782
|
-
for (const stmt of
|
|
808
|
+
for (const stmt of executableSqlStatements(KERNEL_DDL)) {
|
|
783
809
|
this.sql.exec(stmt);
|
|
784
810
|
}
|
|
785
811
|
this.applySpineColumnAdditions();
|
|
@@ -790,11 +816,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
790
816
|
for (const [name, handler] of Object.entries(bareOps))
|
|
791
817
|
this.defineOperation(name, handler);
|
|
792
818
|
// Which migrations have already run (a warm DO wakes with rows here).
|
|
793
|
-
|
|
794
|
-
.exec('SELECT module_id, version FROM _substrat_migrations')
|
|
795
|
-
.toArray()) {
|
|
796
|
-
this.applied.add(`${row.module_id}@${row.version}`);
|
|
797
|
-
}
|
|
819
|
+
this.applied = readAppliedMigrations(this.sql);
|
|
798
820
|
// #1335: and where this DO's event ids have to resume from. A revived DO would
|
|
799
821
|
// otherwise start its floor at the wall clock, and a clock that has stepped back
|
|
800
822
|
// — an NTP correction is small but real — mints underneath rows already stored,
|
|
@@ -810,6 +832,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
810
832
|
// -- module registration (port of SqliteScopeHost.registerModule) ---------
|
|
811
833
|
registerModule(registration) {
|
|
812
834
|
const manifest = registration.manifest;
|
|
835
|
+
// #119: refused before anything is recorded, as in the pure adapter.
|
|
836
|
+
const trashTargets = registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
|
|
837
|
+
this.purgeOnlyKeys.set(manifest.id, purgeOnlyKeysOf(manifest.schedules ?? []));
|
|
838
|
+
for (const schedule of manifest.schedules ?? []) {
|
|
839
|
+
if (schedule.purge)
|
|
840
|
+
this.purgeSchedules.set(schedule.operation, { moduleId: manifest.id, entityType: schedule.purge.entityType });
|
|
841
|
+
}
|
|
842
|
+
// #2068: refused before anything is recorded, as on the pure host.
|
|
843
|
+
const erasure = moduleErasurePlan(registration);
|
|
813
844
|
if (manifest.peers?.length)
|
|
814
845
|
this.peerSources.push({ peers: manifest.peers });
|
|
815
846
|
// #827: the FTS indexes `searchables` declares, appended after the module's
|
|
@@ -841,11 +872,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
841
872
|
id: manifest.id,
|
|
842
873
|
// The order the kernel writes once (#1677): authored, then search, then list indexes.
|
|
843
874
|
migrations: moduleMigrations(registration),
|
|
875
|
+
steps: migrationSteps(registration),
|
|
844
876
|
consumers: Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => ({
|
|
845
877
|
eventType,
|
|
846
878
|
handler,
|
|
847
879
|
})),
|
|
848
880
|
});
|
|
881
|
+
if (erasure)
|
|
882
|
+
this.erasurePlans.push(erasure);
|
|
849
883
|
for (const [name, handler] of Object.entries(registration.predicates ?? {})) {
|
|
850
884
|
this.predicates.set(name, { module: manifest.id, handler });
|
|
851
885
|
}
|
|
@@ -932,6 +966,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
932
966
|
if (declaredOptOuts.includes(name) && this.operations.has(name)) {
|
|
933
967
|
this.operationIdempotencyOptOut.add(name);
|
|
934
968
|
}
|
|
969
|
+
const target = trashTargets.get(name);
|
|
970
|
+
if (target && this.operations.has(name))
|
|
971
|
+
this.operationTarget.set(name, target);
|
|
935
972
|
}
|
|
936
973
|
}
|
|
937
974
|
defineOperation(name, handler) {
|
|
@@ -1571,10 +1608,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1571
1608
|
* Provisioning does not come through here; it seats with `seatTuple` (#1659).
|
|
1572
1609
|
*/
|
|
1573
1610
|
async writeTuple(subject, relation, object, expiresAt) {
|
|
1574
|
-
|
|
1575
|
-
this.
|
|
1576
|
-
|
|
1577
|
-
});
|
|
1611
|
+
return replyOf(() => this.queue.enqueue(() => {
|
|
1612
|
+
writeExplicitTupleIn(this.switchSql(), subject, relation, object, { kind: 'replace', expiresAt });
|
|
1613
|
+
return null;
|
|
1614
|
+
}));
|
|
1578
1615
|
}
|
|
1579
1616
|
/**
|
|
1580
1617
|
* Provisioning's scope-tuple write (#1659): create the row if it is missing, follow the
|
|
@@ -1604,6 +1641,26 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1604
1641
|
return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
1605
1642
|
}));
|
|
1606
1643
|
}
|
|
1644
|
+
/** A declared entity-grant shape's grant to one person on one entity (#2071), as ONE unit. */
|
|
1645
|
+
async grantEntityShape(principal, entity, permissions) {
|
|
1646
|
+
await this.queue.enqueue(() => this.revision.transactionSync(() => grantEntityShapeIn(this.switchSql(), principal, entity, permissions)));
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* One bounded pass of a declared shape's reconcile (#2071), with its events, in ONE
|
|
1650
|
+
* transaction: how many it topped up, how many it took retired keys from (#2082), and
|
|
1651
|
+
* whether the scope is done.
|
|
1652
|
+
*/
|
|
1653
|
+
async topUpEntityGrantShapes(tenantId, scopeId, shapes, limit) {
|
|
1654
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => topUpEntityGrantShapes(this.switchSql(), {
|
|
1655
|
+
tenantId,
|
|
1656
|
+
scopeId,
|
|
1657
|
+
shapes,
|
|
1658
|
+
now: new Date().toISOString(),
|
|
1659
|
+
limit,
|
|
1660
|
+
mintEventId: (ms) => this.mintEventId(ms),
|
|
1661
|
+
version: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
1662
|
+
})));
|
|
1663
|
+
}
|
|
1607
1664
|
/**
|
|
1608
1665
|
* The dispatch capability's admission (#726 remedy B): this delivery may read the
|
|
1609
1666
|
* attachments of the entity its own spine row names, and no others.
|
|
@@ -1733,7 +1790,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1733
1790
|
// Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
|
|
1734
1791
|
// ZodError, whose `message` is a getter) still arrives with its message.
|
|
1735
1792
|
try {
|
|
1736
|
-
return await this
|
|
1793
|
+
return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
|
|
1737
1794
|
}
|
|
1738
1795
|
catch (err) {
|
|
1739
1796
|
if (err instanceof SystemDoorMovedError)
|
|
@@ -1742,7 +1799,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1742
1799
|
}
|
|
1743
1800
|
}
|
|
1744
1801
|
try {
|
|
1745
|
-
return await this
|
|
1802
|
+
return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
|
|
1746
1803
|
}
|
|
1747
1804
|
catch (err) {
|
|
1748
1805
|
// #1834: the pin missed — an answer, never a failure an operation could have produced.
|
|
@@ -1754,8 +1811,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1754
1811
|
return { result: undefined, platformRequests: 0, failure: toWireFailure(err) };
|
|
1755
1812
|
}
|
|
1756
1813
|
}
|
|
1757
|
-
/**
|
|
1758
|
-
|
|
1814
|
+
/**
|
|
1815
|
+
* The operation path itself. Throws; `invoke` decides how that reaches the caller.
|
|
1816
|
+
*
|
|
1817
|
+
* `#`-private, not merely `private`: workerd's RPC exposes every method on the prototype,
|
|
1818
|
+
* and this one takes `purge` (#119), which only `runPurgeSweep` may set. A `#` method is not
|
|
1819
|
+
* on the prototype, so no stub can reach it.
|
|
1820
|
+
*/
|
|
1821
|
+
async #invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
|
|
1759
1822
|
/** K-42: the session, resolved coordinator-side. See `invoke` above. */
|
|
1760
1823
|
impersonation,
|
|
1761
1824
|
/** #1672: a capability session's hash. See `invoke` above. */
|
|
@@ -1763,7 +1826,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1763
1826
|
/** #1706: the calling vertical, as the platform named it. See `invoke` above. */
|
|
1764
1827
|
verticalCaller,
|
|
1765
1828
|
/** #1834: the instance the system door's gate read. See `invoke` above. */
|
|
1766
|
-
systemDoorInstance
|
|
1829
|
+
systemDoorInstance,
|
|
1830
|
+
/** #119: set only by `runPurgeSweep` on its own invokes — never an RPC argument. */
|
|
1831
|
+
purge) {
|
|
1767
1832
|
await this.ensureMigrations();
|
|
1768
1833
|
const handler = this.operations.get(operation);
|
|
1769
1834
|
// `not_found`, not a bare throw (#113): every vertical hand-matched this message
|
|
@@ -1798,6 +1863,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1798
1863
|
// declaration. Outside the queue and outside the transaction: a malformed
|
|
1799
1864
|
// call takes no turn and opens nothing. Guards read the parsed input too,
|
|
1800
1865
|
// so a K-17 pre-condition sees what the handler will.
|
|
1866
|
+
// #119: purge authority is never an option a caller supplies.
|
|
1867
|
+
assertNoCallerPurge(invokeOptions);
|
|
1801
1868
|
const declaredInput = this.operationInput.get(operation);
|
|
1802
1869
|
const parsed = declaredInput ? declaredInput.parse(input) : input;
|
|
1803
1870
|
// #129. Refused rather than ignored, for the reason the coordinator refuses an
|
|
@@ -1924,8 +1991,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1924
1991
|
// commit together, or a throw (from either) rolls domain writes AND
|
|
1925
1992
|
// emitted events back as one — verified across `await` in workerd.
|
|
1926
1993
|
try {
|
|
1994
|
+
// #119: a module's purge-only keys are withheld from its system principal on every
|
|
1995
|
+
// call but the purge sweep's own invoke of the purge operation.
|
|
1996
|
+
const target = this.operationTarget.get(operation);
|
|
1997
|
+
// #119: purge authority is the system door's, and only `runPurgeSweep` sets `purge`.
|
|
1998
|
+
const purging = purge === true && systemDoor !== undefined;
|
|
1999
|
+
const withheld = systemDoor
|
|
2000
|
+
? withheldKeysFor(this.purgeOnlyKeys.get(systemDoor.moduleId), target, purging)
|
|
2001
|
+
: undefined;
|
|
1927
2002
|
await this.revision.transaction(async () => {
|
|
1928
|
-
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
|
|
2003
|
+
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject, undefined, withheld);
|
|
1929
2004
|
// #116: a retry is answered from the recording, and nothing else runs
|
|
1930
2005
|
// — not the guards, not the handler, not the permission check inside
|
|
1931
2006
|
// it. Keyed by SUBJECT, so a caller only ever reaches its own
|
|
@@ -1952,6 +2027,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1952
2027
|
const seen = guardedRef && invokeOptions?.ifMatch !== undefined
|
|
1953
2028
|
? this.versionAt(guardedRef)
|
|
1954
2029
|
: undefined;
|
|
2030
|
+
// #119: the host's trash refusal, before the guards and the handler — the pure
|
|
2031
|
+
// adapter's, through the same kernel function.
|
|
2032
|
+
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);
|
|
1955
2033
|
await this.runGuards(operation, ctx, parsed);
|
|
1956
2034
|
result = await handler(ctx, parsed);
|
|
1957
2035
|
if (guardedRef && invokeOptions?.ifMatch !== undefined) {
|
|
@@ -2148,12 +2226,57 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2148
2226
|
if (withinHeader !== null && !within) {
|
|
2149
2227
|
return new Response('live reads: unreadable within narrowing', { status: 500 });
|
|
2150
2228
|
}
|
|
2229
|
+
// The same for an expiry: dropping it would keep the socket open past its session.
|
|
2230
|
+
const expiresHeader = request.headers.get(LIVE_EXPIRES_HEADER);
|
|
2231
|
+
const expiresAt = expiresHeader === null ? undefined : liveInstant(expiresHeader);
|
|
2232
|
+
if (expiresHeader !== null && !expiresAt) {
|
|
2233
|
+
return new Response('live reads: unreadable expiry', { status: 500 });
|
|
2234
|
+
}
|
|
2151
2235
|
// A subscriber arriving before the scope's migrations have run would be told
|
|
2152
2236
|
// about events against a schema it cannot read back through. Same gate every
|
|
2153
2237
|
// other entry point takes, for the same reason.
|
|
2154
2238
|
await this.ensureMigrations();
|
|
2239
|
+
// `.parse`, not a cast: these three arrived as header strings, and the branded
|
|
2240
|
+
// ids are what every check downstream is keyed on. A malformed one would
|
|
2241
|
+
// otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
|
|
2242
|
+
// which reads as "this subscriber may see nothing" and is indistinguishable from
|
|
2243
|
+
// a correct denial. Refused here instead, where it is still one subscriber's
|
|
2244
|
+
// problem. Throwing is right: the coordinator built this request.
|
|
2245
|
+
const subscriber = {
|
|
2246
|
+
principal: principalId.parse(principal),
|
|
2247
|
+
tenantId: tenantIdOf.parse(tenantId),
|
|
2248
|
+
scopeId: scopeIdOf.parse(scopeId),
|
|
2249
|
+
};
|
|
2250
|
+
// A checked root (#938) is gated here as well as on every pass: a subscriber who may
|
|
2251
|
+
// not watch it gets no socket at all, so its client meets a refusal rather than a
|
|
2252
|
+
// feed that would close on the first thing it had to say. A session already over is
|
|
2253
|
+
// refused the same way.
|
|
2254
|
+
const expired = expiresAt !== undefined && expiresAt <= new Date().toISOString();
|
|
2255
|
+
if (expired ||
|
|
2256
|
+
(within?.checked !== undefined && !(await this.mayWatchRoot(() => this.liveContext(subscriber), within)))) {
|
|
2257
|
+
return new Response('live reads: the subscriber may not watch this root', {
|
|
2258
|
+
status: 403,
|
|
2259
|
+
headers: { [LIVE_MODE_HEADER]: 'forbidden' },
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2155
2262
|
const pair = new WebSocketPair();
|
|
2156
2263
|
const [client, server] = Object.values(pair);
|
|
2264
|
+
/**
|
|
2265
|
+
* At most `LIVE_SOCKETS_PER_PRINCIPAL` sockets per principal on this scope (#938):
|
|
2266
|
+
* each is work on every post-commit pass. The extra one is accepted and closed at
|
|
2267
|
+
* once with `4429`, rather than refused with a status, because a browser never sees
|
|
2268
|
+
* a failed handshake's status — only a close code, which the client reads as "poll
|
|
2269
|
+
* and stop asking". Not hibernated, so it never joins the roster the fan-out walks.
|
|
2270
|
+
*
|
|
2271
|
+
* Counted over the sockets still live: one whose session has ended is closed here
|
|
2272
|
+
* first, so sockets that expired on an idle scope never hold a fresh session out.
|
|
2273
|
+
*/
|
|
2274
|
+
const held = this.reapExpiredLive().filter((s) => s.principal === subscriber.principal).length;
|
|
2275
|
+
if (held >= LIVE_SOCKETS_PER_PRINCIPAL) {
|
|
2276
|
+
server.accept();
|
|
2277
|
+
server.close(LIVE_CLOSE.tooMany, 'too many live subscriptions for this principal; poll instead');
|
|
2278
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
2279
|
+
}
|
|
2157
2280
|
// HIBERNATABLE, not `server.accept()`. A scope with a watcher open would
|
|
2158
2281
|
// otherwise be pinned in memory for as long as somebody has a tab open, which is
|
|
2159
2282
|
// the cost model inverted: a support desk being WATCHED is the normal state.
|
|
@@ -2161,21 +2284,94 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2161
2284
|
// eviction, so the socket would stay open and silently stop receiving, which is
|
|
2162
2285
|
// indistinguishable from a quiet scope.
|
|
2163
2286
|
this.ctx.acceptWebSocket(server);
|
|
2164
|
-
// `.parse`, not a cast: these three arrived as header strings, and the branded
|
|
2165
|
-
// ids are what every check downstream is keyed on. A malformed one would
|
|
2166
|
-
// otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
|
|
2167
|
-
// which reads as "this subscriber may see nothing" and is indistinguishable from
|
|
2168
|
-
// a correct denial. Refused here instead, where it is still one subscriber's
|
|
2169
|
-
// problem. Throwing is right: the coordinator built this request.
|
|
2170
2287
|
server.serializeAttachment({
|
|
2171
|
-
|
|
2172
|
-
tenantId: tenantIdOf.parse(tenantId),
|
|
2173
|
-
scopeId: scopeIdOf.parse(scopeId),
|
|
2288
|
+
...subscriber,
|
|
2174
2289
|
since: new Date().toISOString(),
|
|
2175
2290
|
...(within ? { within } : {}),
|
|
2291
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
2176
2292
|
});
|
|
2293
|
+
if (expiresAt)
|
|
2294
|
+
await this.armLiveExpiry();
|
|
2177
2295
|
return new Response(null, { status: 101, webSocket: client });
|
|
2178
2296
|
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Close every live socket whose session has ended (`1008`), and return the
|
|
2299
|
+
* subscriptions still open (#938). Reads only the sockets' own attachments, never the
|
|
2300
|
+
* database, so it is as cheap on a scope nobody writes to as on a busy one.
|
|
2301
|
+
*/
|
|
2302
|
+
reapExpiredLive(now = new Date().toISOString()) {
|
|
2303
|
+
const live = [];
|
|
2304
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
2305
|
+
let s = null;
|
|
2306
|
+
try {
|
|
2307
|
+
s = readSubscription(ws.deserializeAttachment());
|
|
2308
|
+
}
|
|
2309
|
+
catch {
|
|
2310
|
+
s = null;
|
|
2311
|
+
}
|
|
2312
|
+
if (!s)
|
|
2313
|
+
continue;
|
|
2314
|
+
if (s.expiresAt !== undefined && s.expiresAt <= now) {
|
|
2315
|
+
closeSessionEnded(ws);
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
live.push(s);
|
|
2319
|
+
}
|
|
2320
|
+
return live;
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Arm the scope's alarm for the earliest session end among its live sockets (#938), so
|
|
2324
|
+
* an expired socket is closed on a scope nobody writes to — the fan-out closes them
|
|
2325
|
+
* too, but only on a pass, and an idle scope has none.
|
|
2326
|
+
*
|
|
2327
|
+
* Only ever moved EARLIER: an alarm already set before that instant is kept, and the
|
|
2328
|
+
* handler re-arms for whatever is next. Re-arming on a close is not needed: an alarm
|
|
2329
|
+
* that finds the socket already gone simply arms for the next one, or for nothing.
|
|
2330
|
+
*
|
|
2331
|
+
* The live reaper is this DO's only alarm use. A second one must share `alarm()` and
|
|
2332
|
+
* this "earliest wins" rule, not call `setAlarm` on its own. The alarm makes workerd
|
|
2333
|
+
* keep a `_cf_METADATA` table in the scope's SQLite; every table walk here skips
|
|
2334
|
+
* `_cf_*` for that reason (`exportDump`, `importDump`, `introspectTables`).
|
|
2335
|
+
*/
|
|
2336
|
+
async armLiveExpiry() {
|
|
2337
|
+
let earliest;
|
|
2338
|
+
for (const s of this.reapExpiredLive()) {
|
|
2339
|
+
if (s.expiresAt !== undefined && (earliest === undefined || s.expiresAt < earliest))
|
|
2340
|
+
earliest = s.expiresAt;
|
|
2341
|
+
}
|
|
2342
|
+
if (earliest === undefined)
|
|
2343
|
+
return;
|
|
2344
|
+
const at = Date.parse(earliest);
|
|
2345
|
+
const current = await this.ctx.storage.getAlarm();
|
|
2346
|
+
if (current === null || current > at)
|
|
2347
|
+
await this.ctx.storage.setAlarm(at);
|
|
2348
|
+
}
|
|
2349
|
+
/** The live reaper's alarm (#938): close what has expired, then arm for what is next. */
|
|
2350
|
+
async alarm() {
|
|
2351
|
+
await this.armLiveExpiry();
|
|
2352
|
+
}
|
|
2353
|
+
/** The context a live subscriber's checks run in: its own principal, under `live.subscribe`. */
|
|
2354
|
+
liveContext(subscriber) {
|
|
2355
|
+
return this.operationContext(subscriber.principal, subscriber.tenantId, subscriber.scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe');
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* May this subscriber watch its `checkedWithin` root (#938)? Its own `ctx.check` of the
|
|
2359
|
+
* stated key on the root, the same walk a read of the root would make. A check that
|
|
2360
|
+
* throws is a refusal: an outage in the permission path must not become a feed.
|
|
2361
|
+
*/
|
|
2362
|
+
async mayWatchRoot(context, within) {
|
|
2363
|
+
try {
|
|
2364
|
+
// Built inside the try: a context that cannot be built is a refusal too.
|
|
2365
|
+
const decision = await context().check(within.checked, {
|
|
2366
|
+
entityType: within.entityType,
|
|
2367
|
+
entityId: within.entityId,
|
|
2368
|
+
});
|
|
2369
|
+
return decision.allowed;
|
|
2370
|
+
}
|
|
2371
|
+
catch {
|
|
2372
|
+
return false;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2179
2375
|
/**
|
|
2180
2376
|
* A subscriber said something.
|
|
2181
2377
|
*
|
|
@@ -2195,6 +2391,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2195
2391
|
*/
|
|
2196
2392
|
webSocketMessage(ws, message) {
|
|
2197
2393
|
this.webSocketMessagesHandled++;
|
|
2394
|
+
// Ungated, and safe to be: the reply is the constant `'pong'` and carries no event, no
|
|
2395
|
+
// entity and no time, so it tells the socket nothing it did not already know. Every
|
|
2396
|
+
// frame that is about the scope's data goes out through `fanOutLive`'s gates instead.
|
|
2198
2397
|
if (message === 'ping')
|
|
2199
2398
|
ws.send('pong');
|
|
2200
2399
|
}
|
|
@@ -2328,12 +2527,33 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2328
2527
|
* operation has already moved the edge the walk follows.
|
|
2329
2528
|
*/
|
|
2330
2529
|
const parents = scopeTupleReader(this.sql);
|
|
2331
|
-
|
|
2530
|
+
/**
|
|
2531
|
+
* A checked root's gate, asked once per (principal, key, root) per pass (#938): a
|
|
2532
|
+
* principal holding that root in several tabs is one check, fanned out to each socket.
|
|
2533
|
+
*/
|
|
2534
|
+
const rootChecks = new Map();
|
|
2332
2535
|
const ancestors = new Map();
|
|
2333
|
-
const
|
|
2536
|
+
const openEpoch = (now) => ({
|
|
2537
|
+
writes: this.revision.statementsWritten,
|
|
2538
|
+
until: this.authorityLapsesAfter(now),
|
|
2539
|
+
remote: this.permissionSourceIsRemote(),
|
|
2540
|
+
});
|
|
2541
|
+
const holds = (e, now) => e.writes === this.revision.statementsWritten && (e.until === null || now < e.until);
|
|
2542
|
+
let epoch = openEpoch(new Date().toISOString());
|
|
2543
|
+
/** The epoch current at `now`, opening a new one (and forgetting every memo) if not. */
|
|
2544
|
+
const epochAt = (now) => {
|
|
2545
|
+
if (!holds(epoch, now)) {
|
|
2546
|
+
epoch = openEpoch(now);
|
|
2547
|
+
rootChecks.clear();
|
|
2548
|
+
ancestors.clear();
|
|
2549
|
+
}
|
|
2550
|
+
return epoch;
|
|
2551
|
+
};
|
|
2552
|
+
const reaches = async (row, root, now) => {
|
|
2334
2553
|
let up = ancestors.get(row.id);
|
|
2335
2554
|
if (!up) {
|
|
2336
2555
|
// A walk that cannot answer is a frame not sent — the same fail-closed rule as the check.
|
|
2556
|
+
// Walked at the decision's own instant; remembered only while its epoch holds.
|
|
2337
2557
|
up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
|
|
2338
2558
|
ancestors.set(row.id, up);
|
|
2339
2559
|
}
|
|
@@ -2357,6 +2577,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2357
2577
|
// assumption about how the subscription was created.
|
|
2358
2578
|
if (subscription.tenantId !== tenantId || subscription.scopeId !== scopeId)
|
|
2359
2579
|
continue;
|
|
2580
|
+
// The session that opened it has ended (#938): closed before anything is sent,
|
|
2581
|
+
// whatever the subscriber's grants still say. Checked on every pass, so it holds for
|
|
2582
|
+
// every kind of subscription, not only the ones with a gate to ask — and asked
|
|
2583
|
+
// again, against a fresh clock, immediately before every send below, because the
|
|
2584
|
+
// walk and the checks between here and there await, and a session can end during
|
|
2585
|
+
// them.
|
|
2586
|
+
const { expiresAt } = subscription;
|
|
2587
|
+
const sessionEnded = () => expiresAt !== undefined && expiresAt <= new Date().toISOString();
|
|
2588
|
+
if (sessionEnded()) {
|
|
2589
|
+
closeSessionEnded(ws);
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2360
2592
|
const { within } = subscription;
|
|
2361
2593
|
// One context per subscriber, not per event: `ctx.check` is the expensive part
|
|
2362
2594
|
// and the context is only the subject it is evaluated for. Built on first use, so
|
|
@@ -2372,57 +2604,115 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2372
2604
|
// The operation name is carried anyway, for the events a fan-out cannot emit
|
|
2373
2605
|
// but a future reader of this context might.
|
|
2374
2606
|
let ctx;
|
|
2375
|
-
const
|
|
2376
|
-
const context = () => (ctx ??= this.
|
|
2377
|
-
|
|
2607
|
+
const subscriber = subscription;
|
|
2608
|
+
const context = () => (ctx ??= this.liveContext(subscriber));
|
|
2609
|
+
/**
|
|
2610
|
+
* A checked root's gate (#938), asked at most once per (principal, key, root) per
|
|
2611
|
+
* pass (`rootChecks`) and only once a row beneath the root is about to be announced,
|
|
2612
|
+
* so an idle subscription costs no check. A refusal, or a check that throws, closes the socket before anything is
|
|
2613
|
+
* sent: the grant is gone, the principal is, or the root moved out of the grant's
|
|
2614
|
+
* reach. Closed rather than skipped, so the subscription does not sit there asking
|
|
2615
|
+
* on every pass, and the client's reconnect meets the handshake's 403. Held with the
|
|
2616
|
+
* write count it was decided under, and asked again once that has moved.
|
|
2617
|
+
*/
|
|
2618
|
+
let rootVerdict;
|
|
2619
|
+
/** What this subscriber is owed for one row, decided at `now` under epoch `e`. */
|
|
2620
|
+
const decide = async (row, e, now) => {
|
|
2621
|
+
// A remote authority's verdict is never reused: see `Epoch`.
|
|
2622
|
+
if (rootVerdict?.epoch !== e || e.remote)
|
|
2623
|
+
rootVerdict = undefined;
|
|
2378
2624
|
// Narrowing first: it is memoised across sockets, and a row outside the root
|
|
2379
2625
|
// is out whatever the principal holds.
|
|
2380
|
-
if (within && !(await reaches(row, within)))
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2626
|
+
if (within && !(await reaches(row, within, now)))
|
|
2627
|
+
return 'skip';
|
|
2628
|
+
if (within?.checked !== undefined) {
|
|
2629
|
+
if (rootVerdict === undefined) {
|
|
2630
|
+
let check;
|
|
2631
|
+
if (e.remote) {
|
|
2632
|
+
check = this.mayWatchRoot(context, within);
|
|
2633
|
+
}
|
|
2634
|
+
else {
|
|
2635
|
+
const key = `${subscriber.principal}\n${within.checked}\n${within.entityType}:${within.entityId}`;
|
|
2636
|
+
check = rootChecks.get(key);
|
|
2637
|
+
if (!check)
|
|
2638
|
+
rootChecks.set(key, (check = this.mayWatchRoot(context, within)));
|
|
2639
|
+
}
|
|
2640
|
+
rootVerdict = { epoch: e, allowed: await check };
|
|
2641
|
+
}
|
|
2642
|
+
if (!rootVerdict.allowed)
|
|
2643
|
+
return 'revoked';
|
|
2644
|
+
}
|
|
2645
|
+
if (within?.vouched !== undefined || within?.checked !== undefined) {
|
|
2646
|
+
// The root was vouched for or checked, so the walk above was the row filter —
|
|
2647
|
+
// and the subscriber holds no read on this row, so it is told only that
|
|
2386
2648
|
// something beneath its root changed. Never which row, never how.
|
|
2387
|
-
|
|
2649
|
+
return { kind: 'nudge', id: row.id, at: row.occurred_at };
|
|
2388
2650
|
}
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
try {
|
|
2394
|
-
const decision = await context().check(permission, {
|
|
2395
|
-
entityType: row.entity_type,
|
|
2396
|
-
entityId: row.entity_id,
|
|
2397
|
-
});
|
|
2398
|
-
allowed = decision.allowed;
|
|
2399
|
-
}
|
|
2400
|
-
catch {
|
|
2401
|
-
// A check that cannot answer is a check that refuses. The alternative —
|
|
2402
|
-
// treating an evaluator failure as an allow — turns an outage in the
|
|
2403
|
-
// permission path into a disclosure, which is the one failure mode this
|
|
2404
|
-
// surface must not have.
|
|
2405
|
-
allowed = false;
|
|
2406
|
-
}
|
|
2407
|
-
if (!allowed)
|
|
2408
|
-
continue;
|
|
2409
|
-
frame = {
|
|
2410
|
-
kind: 'change',
|
|
2411
|
-
id: row.id,
|
|
2412
|
-
type: row.type,
|
|
2651
|
+
// Non-null: `announceable` is exactly the rows whose type is in the map.
|
|
2652
|
+
const permission = this.liveTargets.get(row.entity_type);
|
|
2653
|
+
try {
|
|
2654
|
+
const decision = await context().check(permission, {
|
|
2413
2655
|
entityType: row.entity_type,
|
|
2414
2656
|
entityId: row.entity_id,
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
try {
|
|
2419
|
-
ws.send(JSON.stringify(frame));
|
|
2657
|
+
});
|
|
2658
|
+
if (!decision.allowed)
|
|
2659
|
+
return 'skip';
|
|
2420
2660
|
}
|
|
2421
2661
|
catch {
|
|
2422
|
-
//
|
|
2423
|
-
//
|
|
2424
|
-
|
|
2662
|
+
// A check that cannot answer is a check that refuses. The alternative —
|
|
2663
|
+
// treating an evaluator failure as an allow — turns an outage in the
|
|
2664
|
+
// permission path into a disclosure, which is the one failure mode this
|
|
2665
|
+
// surface must not have.
|
|
2666
|
+
return 'skip';
|
|
2667
|
+
}
|
|
2668
|
+
return {
|
|
2669
|
+
kind: 'change',
|
|
2670
|
+
id: row.id,
|
|
2671
|
+
type: row.type,
|
|
2672
|
+
entityType: row.entity_type,
|
|
2673
|
+
entityId: row.entity_id,
|
|
2674
|
+
at: row.occurred_at,
|
|
2675
|
+
};
|
|
2676
|
+
};
|
|
2677
|
+
rows: for (const row of announceable) {
|
|
2678
|
+
for (let attempt = 0; attempt < LIVE_DECIDE_ATTEMPTS; attempt++) {
|
|
2679
|
+
const decidedAt = new Date().toISOString();
|
|
2680
|
+
const e = epochAt(decidedAt);
|
|
2681
|
+
const verdict = await decide(row, e, decidedAt);
|
|
2682
|
+
// Every gate as close to the send as it can be, with no await between these
|
|
2683
|
+
// reads and the send below — so nothing can land after them and before it.
|
|
2684
|
+
// The store wrote, or something the decision relied on lapsed, while it was being
|
|
2685
|
+
// taken (Codex #2077 r4, r5): decide again, from nothing remembered.
|
|
2686
|
+
if (!holds(e, new Date().toISOString()))
|
|
2687
|
+
continue;
|
|
2688
|
+
if (verdict === 'skip')
|
|
2689
|
+
continue rows;
|
|
2690
|
+
if (verdict === 'revoked') {
|
|
2691
|
+
try {
|
|
2692
|
+
ws.close(LIVE_CLOSE.revoked, 'the subscriber may no longer watch this root');
|
|
2693
|
+
}
|
|
2694
|
+
catch {
|
|
2695
|
+
// Already gone.
|
|
2696
|
+
}
|
|
2697
|
+
break rows;
|
|
2698
|
+
}
|
|
2699
|
+
// The clock is read again here too, after every await above.
|
|
2700
|
+
if (sessionEnded()) {
|
|
2701
|
+
closeSessionEnded(ws);
|
|
2702
|
+
break rows;
|
|
2703
|
+
}
|
|
2704
|
+
try {
|
|
2705
|
+
ws.send(JSON.stringify(verdict));
|
|
2706
|
+
}
|
|
2707
|
+
catch {
|
|
2708
|
+
// The socket went away between `getWebSockets()` and here. Stop writing to
|
|
2709
|
+
// this one and move on; the runtime will deliver `webSocketClose`.
|
|
2710
|
+
break rows;
|
|
2711
|
+
}
|
|
2712
|
+
continue rows;
|
|
2425
2713
|
}
|
|
2714
|
+
// Still moving after every attempt: this row is not announced, and the client's
|
|
2715
|
+
// poll — the floor under every push — picks it up.
|
|
2426
2716
|
}
|
|
2427
2717
|
}
|
|
2428
2718
|
}
|
|
@@ -2696,7 +2986,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2696
2986
|
return this.queue.enqueue(async () => {
|
|
2697
2987
|
let capability;
|
|
2698
2988
|
try {
|
|
2699
|
-
capability = resolveCapabilitySession(doSpineSql(this.sql), sessionHash, instant.parse(new Date().toISOString()), operation);
|
|
2989
|
+
capability = resolveCapabilitySession(doSpineSql(this.sql), sessionHash, instant.parse(new Date().toISOString()), operation, true);
|
|
2700
2990
|
}
|
|
2701
2991
|
catch (err) {
|
|
2702
2992
|
return { failure: toWireFailure(err) };
|
|
@@ -2858,8 +3148,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2858
3148
|
return this.queue.enqueue(() => {
|
|
2859
3149
|
if (systemSwitchedOff(this.switchSql(), moduleId))
|
|
2860
3150
|
return false;
|
|
2861
|
-
this.
|
|
2862
|
-
VALUES (?, ?, ?, ?)`, `system:${moduleId}`, relation, object, expiresAt);
|
|
3151
|
+
writeExplicitTupleIn(this.switchSql(), `system:${moduleId}`, relation, object, { kind: 'replace', expiresAt });
|
|
2863
3152
|
return true;
|
|
2864
3153
|
});
|
|
2865
3154
|
}
|
|
@@ -3248,6 +3537,70 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3248
3537
|
await this.ensureMigrations();
|
|
3249
3538
|
return readCapabilityPage(doSpineSql(this.sql), filter);
|
|
3250
3539
|
}
|
|
3540
|
+
/**
|
|
3541
|
+
* One pass of a purge horizon's schedule on this scope (#119): the oldest entities due for
|
|
3542
|
+
* purge, each deleted by the module's own `trashed: 'purges'` operation, one call and one
|
|
3543
|
+
* transaction per entity, as `system:<moduleId>`.
|
|
3544
|
+
*
|
|
3545
|
+
* The caller names only WHICH schedule. Everything that decides what is purged is this
|
|
3546
|
+
* object's: the module and the entity come from the registered schedule, the cutoff from this
|
|
3547
|
+
* object's clock and the entity's declared horizon, the ids from its own bin — and each purge
|
|
3548
|
+
* re-checks the cutoff inside its transaction (`refuseTrashedTarget`). Purge authority lives
|
|
3549
|
+
* here and nowhere else: `invoke` takes no purge argument, so a stub holding this namespace
|
|
3550
|
+
* cannot hand the system principal its purge-only key, and calling this early, often or from
|
|
3551
|
+
* outside the sweep can only purge what is genuinely due.
|
|
3552
|
+
*
|
|
3553
|
+
* Pinned like every system-door call (#1834): a missed pin answers `SystemDoorMoved` before
|
|
3554
|
+
* anything is read, and the coordinator's door gates again.
|
|
3555
|
+
*/
|
|
3556
|
+
async runPurgeSweep(operation, tenantId,
|
|
3557
|
+
/**
|
|
3558
|
+
* The node the purges' checks and events are stamped with. This object records no scope id of
|
|
3559
|
+
* its own, so it cannot hold this one to anything — and it routes nothing: the sweep runs on
|
|
3560
|
+
* the scope that received the call, whatever this says. A wrong one fails closed, since the
|
|
3561
|
+
* module's grant is seated on this scope's node and not on the one named.
|
|
3562
|
+
*/
|
|
3563
|
+
scopeId, systemDoorInstance) {
|
|
3564
|
+
await this.ensureMigrations();
|
|
3565
|
+
const schedule = this.purgeSchedules.get(operation);
|
|
3566
|
+
if (!schedule)
|
|
3567
|
+
throw toRpcError(substratError('not_found', `no purge schedule runs ${operation} on this scope`));
|
|
3568
|
+
try {
|
|
3569
|
+
this.assertSystemDoor(schedule.moduleId, systemDoorInstance);
|
|
3570
|
+
}
|
|
3571
|
+
catch (err) {
|
|
3572
|
+
if (err instanceof SystemDoorMovedError)
|
|
3573
|
+
return SYSTEM_DOOR_MOVED;
|
|
3574
|
+
throw toRpcError(err);
|
|
3575
|
+
}
|
|
3576
|
+
await this.pruneContinuationPositions();
|
|
3577
|
+
// #119: the gate the coordinator applies before any schedule fires, applied here from what
|
|
3578
|
+
// this scope records of it (`purgeGateFacts`). A foreign tenant throws.
|
|
3579
|
+
const held = purgeHeldBy(this.purgeGateFacts(schedule.moduleId, tenantId));
|
|
3580
|
+
if (held !== null)
|
|
3581
|
+
return heldPurgePass(held);
|
|
3582
|
+
const due = purgeDueOf(doSpineSql(this.sql), this.statePlans, this.operationTarget, operation, schedule.entityType, new Date().toISOString(), PURGE_BATCH);
|
|
3583
|
+
return runPurgePass(operation, due, async (entityId) => {
|
|
3584
|
+
await this.#invokeOrThrow(operation, { [due.idFrom]: entityId }, schedule.moduleId, tenantId, scopeId, undefined, undefined, schedule.moduleId, { invocationId: ulid() }, undefined, undefined, undefined, systemDoorInstance, true);
|
|
3585
|
+
}, (write) => write(doSpineSql(this.sql)));
|
|
3586
|
+
}
|
|
3587
|
+
/**
|
|
3588
|
+
* #119: the purge gate's facts (`PurgeGateFacts`), from this scope's own storage: the module's
|
|
3589
|
+
* switch, the lifecycle the platform delivered (#1713), the copy classification (#2009) and the
|
|
3590
|
+
* tenant receipt (`tenantVerdict` — `unknown` passes, as at every door). A directory-backed
|
|
3591
|
+
* deployment records no receipt and delivers neither a lifecycle nor a copy classification here,
|
|
3592
|
+
* so on one those read clear: this object enforces the kill switch, and the coordinator's gate
|
|
3593
|
+
* is the authority for tenant, lifecycle, copy and the rewind hold.
|
|
3594
|
+
*/
|
|
3595
|
+
purgeGateFacts(moduleId, tenantId) {
|
|
3596
|
+
const now = new Date().toISOString();
|
|
3597
|
+
return {
|
|
3598
|
+
switched: systemScheduleState(this.switchSql(), moduleId, now),
|
|
3599
|
+
lifecycle: lifecycleRefusal(readLifecycle(this.switchSql())),
|
|
3600
|
+
copy: this.isCopy(),
|
|
3601
|
+
foreignTenant: this.tenantVerdict(tenantId).verdict === 'foreign',
|
|
3602
|
+
};
|
|
3603
|
+
}
|
|
3251
3604
|
/**
|
|
3252
3605
|
* The last time a schedule's operation ran on this scope (#383), or null.
|
|
3253
3606
|
*
|
|
@@ -3588,18 +3941,55 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3588
3941
|
this.migrationPromise = this.applyPendingMigrations();
|
|
3589
3942
|
return this.migrationPromise;
|
|
3590
3943
|
}
|
|
3944
|
+
/** Every registered module's derivation plans — what `repairDerivedObjects` and its checks read. */
|
|
3945
|
+
derivedPlans() {
|
|
3946
|
+
return { state: this.statePlans, lists: this.listPlans, search: this.searchPlans };
|
|
3947
|
+
}
|
|
3948
|
+
/** A multi-statement script on this DO's own handle, one statement per exec. */
|
|
3949
|
+
runScript(ddl) {
|
|
3950
|
+
for (const stmt of executableSqlStatements(ddl))
|
|
3951
|
+
this.sql.exec(stmt);
|
|
3952
|
+
}
|
|
3953
|
+
/**
|
|
3954
|
+
* #2090: the repair a pass with nothing pending runs, in a transaction of its own. A lost
|
|
3955
|
+
* state column fails the scope closed like a failed migration, named by the journaled
|
|
3956
|
+
* migration that added the column — the pure host's `repairDerived`.
|
|
3957
|
+
*/
|
|
3958
|
+
async repairDerived() {
|
|
3959
|
+
try {
|
|
3960
|
+
await this.revision.transaction(async () => {
|
|
3961
|
+
this.revisionSuspended = true;
|
|
3962
|
+
try {
|
|
3963
|
+
repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
|
|
3964
|
+
after: 'an applied migration',
|
|
3965
|
+
});
|
|
3966
|
+
}
|
|
3967
|
+
finally {
|
|
3968
|
+
this.revisionSuspended = false;
|
|
3969
|
+
}
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3972
|
+
catch (err) {
|
|
3973
|
+
const version = err instanceof StateColumnLost ? err.migration : 'kernel@derived-objects';
|
|
3974
|
+
this.lastFailure = { version, error: err.message };
|
|
3975
|
+
throw migrationFailedError(version, err.message);
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3591
3978
|
/** Resolves true if this call applied at least one migration. */
|
|
3592
3979
|
async applyPendingMigrations() {
|
|
3593
|
-
const pending =
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
}
|
|
3599
|
-
}
|
|
3980
|
+
const { pending, diverged } = await planMigrations(this.modules.values(), this.applied);
|
|
3981
|
+
if (diverged) {
|
|
3982
|
+
// Recorded as a failed migration is, so the coordinator projects it the same way.
|
|
3983
|
+
this.lastFailure = diverged;
|
|
3984
|
+
throw migrationFailedError(diverged.version, diverged.error);
|
|
3600
3985
|
}
|
|
3601
|
-
if (pending.length === 0)
|
|
3986
|
+
if (pending.length === 0) {
|
|
3987
|
+
// #2090: once per pass — so once per wake, and on every `retryMigrations` — check and
|
|
3988
|
+
// repair what the kernel derived onto the tables. Their migrations are journaled and will
|
|
3989
|
+
// never run again, so this is what reaches a scope stripped before the pass checked.
|
|
3990
|
+
await this.queue.enqueue(async () => this.repairDerived());
|
|
3602
3991
|
return false;
|
|
3992
|
+
}
|
|
3603
3993
|
this.migrationRuns += 1;
|
|
3604
3994
|
await this.queue.enqueue(async () => {
|
|
3605
3995
|
// #286: bookmark the instant before an UPGRADE migrates live data — the
|
|
@@ -3622,34 +4012,53 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3622
4012
|
// (#278) remains the fallback rewind point.
|
|
3623
4013
|
}
|
|
3624
4014
|
}
|
|
3625
|
-
for (const { moduleId, migration } of pending) {
|
|
4015
|
+
for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
|
|
3626
4016
|
const key = `${moduleId}@${migration.version}`;
|
|
3627
4017
|
if (this.applied.has(key))
|
|
3628
4018
|
continue;
|
|
4019
|
+
let recorded = digest;
|
|
3629
4020
|
try {
|
|
3630
4021
|
await this.revision.transaction(async () => {
|
|
3631
4022
|
const already = this.sql
|
|
3632
|
-
.exec('SELECT
|
|
4023
|
+
.exec('SELECT sql_digest FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
|
|
3633
4024
|
.toArray()[0];
|
|
3634
|
-
if (
|
|
4025
|
+
if (already) {
|
|
4026
|
+
// Applied since this pass read the journal: held to the same digest rule.
|
|
4027
|
+
const error = migrationDivergence(already.sql_digest, digest, authored);
|
|
4028
|
+
if (error)
|
|
4029
|
+
throw new Error(error);
|
|
4030
|
+
recorded = already.sql_digest;
|
|
4031
|
+
}
|
|
4032
|
+
else {
|
|
3635
4033
|
const started = performance.now();
|
|
3636
4034
|
const before = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
|
|
3637
|
-
// #1898: a migration runs on this DO's own handle, not `ctx.sql`, so the
|
|
3638
|
-
// spine
|
|
3639
|
-
|
|
4035
|
+
// #1898, #2066: a migration runs on this DO's own handle, not `ctx.sql`, so the
|
|
4036
|
+
// spine rules a migration is held to are applied here.
|
|
4037
|
+
assertMigrationSql(migration.sql, { key, digest, authored });
|
|
4038
|
+
// #2068: the ownership ledger is the kernel's — no migration may name it, not even to
|
|
4039
|
+
// read it (the spine rules above allow reads).
|
|
4040
|
+
assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
|
|
3640
4041
|
// #1722: not counted per statement, so `total_changes()` measures the migration
|
|
3641
4042
|
// alone. The journal row below is a write, and advances the revision once.
|
|
4043
|
+
// #2068: run one statement at a time, keeping the table set either side of each.
|
|
4044
|
+
let steps;
|
|
3642
4045
|
this.revisionSuspended = true;
|
|
3643
4046
|
try {
|
|
3644
|
-
|
|
4047
|
+
steps = runMigrationStatements(doSpineSql(this.sql), migration.sql, (stmt) => {
|
|
3645
4048
|
this.sql.exec(stmt);
|
|
3646
|
-
}
|
|
4049
|
+
});
|
|
4050
|
+
// #2090: the state columns this migration must have left, and after the last of
|
|
4051
|
+
// the pass, the triggers and indexes a create-copy-rename rebuild dropped.
|
|
4052
|
+
const last = i === pending.length - 1;
|
|
4053
|
+
afterMigration(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), key, last);
|
|
3647
4054
|
}
|
|
3648
4055
|
finally {
|
|
3649
4056
|
this.revisionSuspended = false;
|
|
3650
4057
|
}
|
|
3651
4058
|
const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
|
|
3652
|
-
|
|
4059
|
+
// After the count: `rows_changed` is the migration's own, never the kernel's bookkeeping.
|
|
4060
|
+
recordOwnershipSteps(doSpineSql(this.sql), moduleId, steps, new Date().toISOString());
|
|
4061
|
+
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);
|
|
3653
4062
|
}
|
|
3654
4063
|
});
|
|
3655
4064
|
}
|
|
@@ -3663,9 +4072,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3663
4072
|
// It is not an unhandled rejection: every caller awaits the memoised promise, and
|
|
3664
4073
|
// the coordinator records the failure it receives (#1898 review).
|
|
3665
4074
|
this.lastFailure = { version: key, error: err.message };
|
|
3666
|
-
throw
|
|
4075
|
+
throw migrationFailedError(key, err.message);
|
|
3667
4076
|
}
|
|
3668
|
-
this.applied.
|
|
4077
|
+
this.applied.set(key, recorded);
|
|
3669
4078
|
}
|
|
3670
4079
|
});
|
|
3671
4080
|
return true;
|
|
@@ -3904,6 +4313,30 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3904
4313
|
attempts = attempts + 1, settled_at = ?
|
|
3905
4314
|
WHERE id = ? AND status = 'pending'`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
|
|
3906
4315
|
}
|
|
4316
|
+
/**
|
|
4317
|
+
* A settle that writes an outcome event (#2102): the kernel's one settle — the same
|
|
4318
|
+
* compare-and-set as the verb above — and the event in its transaction, written only when
|
|
4319
|
+
* this settle moved the row out of `pending`. Then the event reaches this scope's consumers,
|
|
4320
|
+
* as any committed write's does.
|
|
4321
|
+
*
|
|
4322
|
+
* A verb of its own rather than an argument on the one above, so a settle carrying no event
|
|
4323
|
+
* keeps reaching a DO class older than this change; only the new kind's settle needs it.
|
|
4324
|
+
*/
|
|
4325
|
+
async settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome) {
|
|
4326
|
+
await this.ensureMigrations();
|
|
4327
|
+
return this.queue.enqueue(async () => {
|
|
4328
|
+
const liveSince = this.liveHighWaterMark();
|
|
4329
|
+
this.revision.transactionSync(() => settlePlatformRequestIn(this.switchSql(), id, outcome, {
|
|
4330
|
+
tenantId,
|
|
4331
|
+
scopeId,
|
|
4332
|
+
now: new Date().toISOString(),
|
|
4333
|
+
mintEventId: (ms) => this.mintEventId(ms),
|
|
4334
|
+
version: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
4335
|
+
}));
|
|
4336
|
+
// #1525: null — a settle is the platform's call, not one a vertical's caller made.
|
|
4337
|
+
await this.settleCommitted(tenantId, scopeId, liveSince, null);
|
|
4338
|
+
});
|
|
4339
|
+
}
|
|
3907
4340
|
/**
|
|
3908
4341
|
* Turn one connector delivery into a `connector:<provider>` platform intent (#574
|
|
3909
4342
|
* phase 3) — the CP-less host's substitute for running the handler it cannot run.
|
|
@@ -3997,7 +4430,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3997
4430
|
/** Every table in this scope's DB, with row counts; spine/internal tables flagged. */
|
|
3998
4431
|
introspectTables() {
|
|
3999
4432
|
const names = this.sql
|
|
4000
|
-
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
|
4433
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*' ORDER BY name`)
|
|
4001
4434
|
.toArray().map((r) => r.name);
|
|
4002
4435
|
return names.map((name) => ({
|
|
4003
4436
|
name,
|
|
@@ -4021,7 +4454,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4021
4454
|
}
|
|
4022
4455
|
tablePage(table, limit, offset) {
|
|
4023
4456
|
const known = new Set(this.sql
|
|
4024
|
-
.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
|
4457
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*'`)
|
|
4025
4458
|
.toArray().map((r) => r.name));
|
|
4026
4459
|
if (!known.has(table))
|
|
4027
4460
|
throw substratError('not_found', `unknown table '${table}'`);
|
|
@@ -4059,6 +4492,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4059
4492
|
}
|
|
4060
4493
|
async readOnlyQuery(sql) {
|
|
4061
4494
|
const stmt = assertReadOnlyQuery(sql);
|
|
4495
|
+
assertNoWorkerdStorage(stmt);
|
|
4062
4496
|
let result;
|
|
4063
4497
|
const rollback = new Error('read-only console rollback');
|
|
4064
4498
|
try {
|
|
@@ -4136,7 +4570,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4136
4570
|
// carries the rows, never the index over them.
|
|
4137
4571
|
const defs = this.sql
|
|
4138
4572
|
.exec(`SELECT name, sql FROM sqlite_master
|
|
4139
|
-
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND sql IS NOT NULL
|
|
4573
|
+
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' AND sql IS NOT NULL
|
|
4140
4574
|
ORDER BY name`)
|
|
4141
4575
|
.toArray().filter(({ name }) => !isSearchIndexTable(name));
|
|
4142
4576
|
return defs.map(({ name, sql }) => {
|
|
@@ -4199,7 +4633,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4199
4633
|
// #1763: rows written before these fields keep NULL, meaning unrecorded.
|
|
4200
4634
|
'ALTER TABLE _substrat_migrations ADD COLUMN duration_ms INTEGER',
|
|
4201
4635
|
'ALTER TABLE _substrat_migrations ADD COLUMN rows_changed INTEGER',
|
|
4636
|
+
// #2066: the rows already there get the legacy mark below, never a digest.
|
|
4637
|
+
'ALTER TABLE _substrat_migrations ADD COLUMN sql_digest TEXT',
|
|
4202
4638
|
'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
|
|
4639
|
+
// #2126: NULL keeps every existing capability's attachment behavior unchanged.
|
|
4640
|
+
'ALTER TABLE _substrat_capabilities ADD COLUMN attachments TEXT',
|
|
4203
4641
|
// #1632: legacy runs retain an unknown subject; no content-based backfill.
|
|
4204
4642
|
'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
|
|
4205
4643
|
// #2034: the lease. NULL = nobody holds the run, which is right for every row already there.
|
|
@@ -4255,6 +4693,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4255
4693
|
// every legacy row — a past run's id cannot be recovered afterwards, exactly
|
|
4256
4694
|
// as the other #1525 columns above argued.
|
|
4257
4695
|
'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
|
|
4696
|
+
// #2096: a purge horizon's lap in progress, on a scope DO built before the column. NULL is
|
|
4697
|
+
// the start of a lap — the honest reading of every row already there.
|
|
4698
|
+
'ALTER TABLE _substrat_schedule_state ADD COLUMN purge_cursor TEXT',
|
|
4258
4699
|
// #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
|
|
4259
4700
|
// see `COPY_ORIGIN_DDL`).
|
|
4260
4701
|
'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
|
|
@@ -4280,6 +4721,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4280
4721
|
// boot. `lint:spine-ddl` compares KERNEL_DDL's indexes only, so this one is held to
|
|
4281
4722
|
// both adapters by the query-plan test rather than by that gate.
|
|
4282
4723
|
this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
|
|
4724
|
+
this.sql.exec(GRANT_CHILDREN_INDEX_DDL);
|
|
4725
|
+
// #2066: what they ran was never measured. KERNEL_DDL's fence keeps any other NULL out.
|
|
4726
|
+
this.sql.exec(MIGRATION_DIGEST_MARK_LEGACY);
|
|
4283
4727
|
this.ensureScheduleStateKind();
|
|
4284
4728
|
this.ensureRefusalsAdmitGuards();
|
|
4285
4729
|
}
|
|
@@ -4320,11 +4764,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4320
4764
|
// synchronous, which is the one case the sync API is for (it commits at the
|
|
4321
4765
|
// first await, and there is none). It also has to be sync because the caller is.
|
|
4322
4766
|
this.revision.transactionSync(() => {
|
|
4323
|
-
for (const stmt of
|
|
4767
|
+
for (const stmt of executableSqlStatements(script))
|
|
4324
4768
|
this.sql.exec(stmt);
|
|
4325
4769
|
});
|
|
4326
4770
|
}
|
|
4327
|
-
async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, } = {}) {
|
|
4771
|
+
async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, } = {}) {
|
|
4328
4772
|
// The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
|
|
4329
4773
|
//
|
|
4330
4774
|
// Two distinct FK hazards, and both need the deferral — the second is why it cannot
|
|
@@ -4360,6 +4804,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4360
4804
|
throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
|
|
4361
4805
|
}
|
|
4362
4806
|
const switched = await this.revision.transaction(async () => {
|
|
4807
|
+
// #1722: first, before any read: a store a reap has since emptied must refuse the late load
|
|
4808
|
+
// too, and the check is against this store's clock, inside the load's own transaction.
|
|
4809
|
+
if (fence && copyRestoreFenceLapsed(fence, Date.now())) {
|
|
4810
|
+
const lapsed = substratError('precondition_failed', COPY_RESTORE_FENCE_LAPSED);
|
|
4811
|
+
fenceRefusals.add(lapsed);
|
|
4812
|
+
throw lapsed;
|
|
4813
|
+
}
|
|
4363
4814
|
const before = this.loadMarker();
|
|
4364
4815
|
// #1713: the lifecycle the platform delivered here, read before the drops take it.
|
|
4365
4816
|
const lifecycleBefore = readLifecycle(this.switchSql());
|
|
@@ -4397,7 +4848,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4397
4848
|
this.sql.exec('PRAGMA defer_foreign_keys = ON');
|
|
4398
4849
|
// Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
|
|
4399
4850
|
const existing = this.sql
|
|
4400
|
-
|
|
4851
|
+
// Nor workerd's own `_cf_*` (the live reaper's alarm keeps `_cf_METADATA`), which
|
|
4852
|
+
// it refuses to drop.
|
|
4853
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'`)
|
|
4401
4854
|
.toArray();
|
|
4402
4855
|
// Search index tables are left alone here and rebuilt below (#827): dropping a
|
|
4403
4856
|
// shadow table directly is an error, and `sqlite_master` order would reach one
|
|
@@ -4412,9 +4865,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4412
4865
|
// decide how this DO's permission checks match. Every `_substrat_*` table is built
|
|
4413
4866
|
// from KERNEL_DDL instead, and the dump contributes only rows, by column name
|
|
4414
4867
|
// (`spineRowsInsert`), a missing column taking the kernel's default.
|
|
4868
|
+
// Comment-blanked (#2068): a dump records `sqlite_master.sql` verbatim, comments included,
|
|
4869
|
+
// and replaying it raw would store them again — and a later DROP COLUMN of the last
|
|
4870
|
+
// column fails on workerd. The validated statement is otherwise executed exactly as is.
|
|
4415
4871
|
for (const t of replayable)
|
|
4416
4872
|
if (!isSpineTable(t.name))
|
|
4417
|
-
this.sql.exec(t.ddl);
|
|
4873
|
+
this.sql.exec(blankSqlComments(t.ddl));
|
|
4418
4874
|
// KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
|
|
4419
4875
|
// WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
|
|
4420
4876
|
// `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
|
|
@@ -4424,11 +4880,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4424
4880
|
// restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
|
|
4425
4881
|
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
4426
4882
|
// leaves to it.
|
|
4427
|
-
for (const stmt of
|
|
4883
|
+
for (const stmt of executableSqlStatements(KERNEL_DDL))
|
|
4428
4884
|
this.sql.exec(stmt);
|
|
4429
4885
|
this.applySpineColumnAdditions();
|
|
4430
4886
|
const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
|
|
4431
4887
|
assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
|
|
4888
|
+
assertJournalDumpCoherent(replayable);
|
|
4432
4889
|
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
4433
4890
|
// untyped column the checker never reads.
|
|
4434
4891
|
for (const t of replayable) {
|
|
@@ -4479,6 +4936,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4479
4936
|
if (destScopeId) {
|
|
4480
4937
|
this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
|
|
4481
4938
|
}
|
|
4939
|
+
// #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
|
|
4940
|
+
// sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows
|
|
4941
|
+
// that are gone. Repaired, and the search index rebuilt, AFTER the rows — which may
|
|
4942
|
+
// legitimately arrive archived or trashed — as the dump's own journal says they were
|
|
4943
|
+
// derived (#2090). Inside the replay's transaction: a dump whose journal owes a table a
|
|
4944
|
+
// state column it does not carry rolls the whole load back. A plan whose table this dump
|
|
4945
|
+
// did not carry is skipped: a restore must not invent one.
|
|
4946
|
+
repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
|
|
4947
|
+
after: 'the restored dump',
|
|
4948
|
+
absentTable: 'skip',
|
|
4949
|
+
});
|
|
4482
4950
|
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
4483
4951
|
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
4484
4952
|
// the dump's grants never commit live without the switch that should cover them.
|
|
@@ -4491,37 +4959,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4491
4959
|
}
|
|
4492
4960
|
});
|
|
4493
4961
|
this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
|
|
4494
|
-
// Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
|
|
4495
|
-
// create, so it also repairs an index a dump left stale, and the triggers it
|
|
4496
|
-
// recreates are what keep the restored scope in step from here. Skipped for a plan
|
|
4497
|
-
// whose content table this dump did not carry — a restore must not invent a table
|
|
4498
|
-
// for an index to point at.
|
|
4499
|
-
const present = new Set(this.sql
|
|
4500
|
-
.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
|
4501
|
-
.toArray().map((r) => r.name));
|
|
4502
|
-
for (const plan of this.searchPlans.values()) {
|
|
4503
|
-
if (!present.has(plan.table))
|
|
4504
|
-
continue;
|
|
4505
|
-
for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
|
|
4506
|
-
this.sql.exec(stmt);
|
|
4507
|
-
}
|
|
4508
|
-
// #119: the never-born-archived trigger went with the dropped table. Put back AFTER the
|
|
4509
|
-
// rows, which may legitimately arrive archived or trashed.
|
|
4510
|
-
for (const plan of this.statePlans.values()) {
|
|
4511
|
-
if (!present.has(plan.table))
|
|
4512
|
-
continue;
|
|
4513
|
-
for (const stmt of splitSqlStatements(entityStateTriggerDdl(plan)))
|
|
4514
|
-
this.sql.exec(stmt);
|
|
4515
|
-
}
|
|
4516
|
-
// #811 / #119: the derived list indexes went with the dropped table too, and a load never
|
|
4517
|
-
// put them back — an archivable entity's partial indexes are part of what the kernel
|
|
4518
|
-
// checks after DDL.
|
|
4519
|
-
for (const plan of this.listPlans.values()) {
|
|
4520
|
-
if (!present.has(plan.table))
|
|
4521
|
-
continue;
|
|
4522
|
-
for (const stmt of splitSqlStatements(listIndexDdl(plan)))
|
|
4523
|
-
this.sql.exec(stmt);
|
|
4524
|
-
}
|
|
4525
4962
|
// #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
|
|
4526
4963
|
// as on a wake. A copy's own events then sort above every copied one, which is what
|
|
4527
4964
|
// `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
|
|
@@ -4536,12 +4973,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4536
4973
|
}
|
|
4537
4974
|
// The frontier arrived with the dump — refresh the in-memory applied set so a
|
|
4538
4975
|
// later migrate() builds on the imported state, not the provisioning state.
|
|
4539
|
-
this.applied.
|
|
4540
|
-
for (const row of this.sql
|
|
4541
|
-
.exec('SELECT module_id, version FROM _substrat_migrations')
|
|
4542
|
-
.toArray()) {
|
|
4543
|
-
this.applied.add(`${row.module_id}@${row.version}`);
|
|
4544
|
-
}
|
|
4976
|
+
this.applied = readAppliedMigrations(this.sql);
|
|
4545
4977
|
// …and forget that this INSTANCE ever ran a migration pass (#1589). Refreshing
|
|
4546
4978
|
// the set above is not enough on its own: `ensureMigrations` memoises its
|
|
4547
4979
|
// promise, so a warm DO answers "already migrated" from the cache and never
|
|
@@ -4565,6 +4997,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4565
4997
|
// is complete applies nothing, so there is nothing to report either way.
|
|
4566
4998
|
this.migrationPromise = undefined;
|
|
4567
4999
|
this.lastFailure = null;
|
|
5000
|
+
// The KV key never travels in a SQL dump. A load into this same DO would
|
|
5001
|
+
// otherwise retain its old epoch, so explicitly invalidate all old tokens.
|
|
5002
|
+
await this.ctx.storage.delete('continuation:keys');
|
|
5003
|
+
const oldPositions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
|
|
5004
|
+
for (const name of oldPositions.keys())
|
|
5005
|
+
await this.ctx.storage.delete(name);
|
|
4568
5006
|
return switched;
|
|
4569
5007
|
}
|
|
4570
5008
|
/**
|
|
@@ -4577,6 +5015,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4577
5015
|
return { refused: false, switchedOff: await this.importDump(tables, destScopeId, opts) };
|
|
4578
5016
|
}
|
|
4579
5017
|
catch (e) {
|
|
5018
|
+
if (e instanceof Error && fenceRefusals.has(e))
|
|
5019
|
+
return { refused: 'lapsed' };
|
|
4580
5020
|
const code = errorCodeOf(e);
|
|
4581
5021
|
if (code === 'precondition_failed')
|
|
4582
5022
|
return { refused: 'changed' };
|
|
@@ -4801,9 +5241,41 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4801
5241
|
* outranks their Article 17 right.
|
|
4802
5242
|
*/
|
|
4803
5243
|
async redactSubject(subjectId) {
|
|
5244
|
+
// #2068: the module half reaches the scope's own tables, so they have to exist.
|
|
5245
|
+
await this.ensureMigrations();
|
|
4804
5246
|
// One instant for the whole erasure — the intent tombstones must not disagree with
|
|
4805
5247
|
// each other about when a person was erased.
|
|
4806
5248
|
const at = new Date().toISOString();
|
|
5249
|
+
// ONE transaction (#2068): a module's `onSubjectErased` hook that throws rolls the
|
|
5250
|
+
// whole redaction back, the spine half with it, and the coordinator — which destroys
|
|
5251
|
+
// the key only after this returns — never reaches the key.
|
|
5252
|
+
//
|
|
5253
|
+
// Its refusal is answered as DATA (`toWireFailure`, #113): workerd delivers a throw across
|
|
5254
|
+
// this boundary as its message alone, and a hook's `forbidden` (it reached past its own
|
|
5255
|
+
// tables) is a different answer from an `internal`. In this method's own reply rather than
|
|
5256
|
+
// a `…Reply` sibling, so every coordinator — old ones too — keeps calling one verb: an old
|
|
5257
|
+
// one reads a failure as a reply missing its counts and refuses before the key, which is
|
|
5258
|
+
// the safe reading.
|
|
5259
|
+
try {
|
|
5260
|
+
return this.revision.transactionSync(() => this.redactSubjectInTransaction(subjectId, at));
|
|
5261
|
+
}
|
|
5262
|
+
catch (err) {
|
|
5263
|
+
return { failure: toWireFailure(err) };
|
|
5264
|
+
}
|
|
5265
|
+
}
|
|
5266
|
+
/** The body of `redactSubject`, inside its transaction. */
|
|
5267
|
+
redactSubjectInTransaction(subjectId, at) {
|
|
5268
|
+
// The module half first (#2068): the declared entities, then each hook, with the search
|
|
5269
|
+
// indexes over them under FTS5 secure-delete. The spine half follows in the same transaction.
|
|
5270
|
+
const vertical = eraseSubjectFromModules({
|
|
5271
|
+
sql: doSpineSql(this.sql),
|
|
5272
|
+
plans: this.erasurePlans,
|
|
5273
|
+
searchPlans: this.searchPlans.values(),
|
|
5274
|
+
statefulTables: statefulTablesOf(this.statePlans),
|
|
5275
|
+
subjectId,
|
|
5276
|
+
at,
|
|
5277
|
+
migrationSqlOf: (moduleId, version) => this.modules.get(moduleId)?.migrations.find((m) => m.version === version)?.sql,
|
|
5278
|
+
});
|
|
4807
5279
|
const doomed = this.sql
|
|
4808
5280
|
.exec(`SELECT id FROM _substrat_outbox
|
|
4809
5281
|
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`, subjectId)
|
|
@@ -4824,6 +5296,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4824
5296
|
// The free-text copies (#1632), and the tombstoned intents the coordinator hands to
|
|
4825
5297
|
// the directory half. Last, so those ids include every intent tombstoned above.
|
|
4826
5298
|
...redactSubjectScopeText(sql, subjectId, at),
|
|
5299
|
+
vertical,
|
|
4827
5300
|
};
|
|
4828
5301
|
}
|
|
4829
5302
|
/**
|
|
@@ -5065,7 +5538,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5065
5538
|
*/
|
|
5066
5539
|
peerSubject,
|
|
5067
5540
|
/** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
|
|
5068
|
-
delivery
|
|
5541
|
+
delivery,
|
|
5542
|
+
/** #119: keys this context's checks refuse whatever the subject holds (`purgeOnlyKeysOf`). */
|
|
5543
|
+
withheld) {
|
|
5069
5544
|
const checker = this.checker;
|
|
5070
5545
|
const relations = this.relations;
|
|
5071
5546
|
const searchPlans = this.searchPlans;
|
|
@@ -5126,6 +5601,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5126
5601
|
// #1642: parsed before the system actor's early return, which never reaches
|
|
5127
5602
|
// the checker — a cast key would otherwise become that path's proof relation.
|
|
5128
5603
|
const permission = assertPermissionKey(unparsed);
|
|
5604
|
+
if (withheld?.has(permission)) {
|
|
5605
|
+
return { allowed: false, checked: permission, node: { tenantId, scopeId } };
|
|
5606
|
+
}
|
|
5129
5607
|
if (systemActor) {
|
|
5130
5608
|
return {
|
|
5131
5609
|
allowed: true,
|
|
@@ -5206,10 +5684,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5206
5684
|
// #1672: a capability's own id stands in so the type holds — it is not a person, and
|
|
5207
5685
|
// the event actor says what it is instead. Every other door passes its own value.
|
|
5208
5686
|
principal: capabilityId ? capabilityId : principal,
|
|
5687
|
+
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()),
|
|
5209
5688
|
sql: guardSecrets(doScopedSql(sql, statefulTablesOf(statePlans),
|
|
5210
|
-
// #119: after runtime DDL,
|
|
5211
|
-
|
|
5212
|
-
|
|
5689
|
+
// #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
|
|
5690
|
+
// derived onto any table is repaired — and a lost state column fails the operation.
|
|
5691
|
+
derivesAnything(this.derivedPlans())
|
|
5692
|
+
? () => afterRuntimeDdl(doSpineSql(sql), (ddl) => this.runScript(ddl), this.derivedPlans())
|
|
5213
5693
|
: undefined), minted),
|
|
5214
5694
|
now: () => at,
|
|
5215
5695
|
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
@@ -5268,6 +5748,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5268
5748
|
return entityVersionOf(sql.exec(q.sql, ...q.params).toArray());
|
|
5269
5749
|
},
|
|
5270
5750
|
check: runCheck,
|
|
5751
|
+
grantedEntities: async (unparsed, entityType, options) => {
|
|
5752
|
+
const permission = assertPermissionKey(unparsed);
|
|
5753
|
+
return grantedEntitiesForContext(checker, subject, permission, { tenantId, scopeId }, entityType, options, runCheck, withheld, Boolean(systemActor));
|
|
5754
|
+
},
|
|
5271
5755
|
canAssign: runCanAssign,
|
|
5272
5756
|
// #827. Mirror of the pure adapter: the plan comes from registration, the
|
|
5273
5757
|
// rows from this DO's own SQLite, and the index is maintained by triggers
|
|
@@ -5303,15 +5787,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5303
5787
|
view: uncheckedView('ctx.page', entityType, params.view),
|
|
5304
5788
|
});
|
|
5305
5789
|
const rows = sql.exec(q.sql, ...q.params).toArray();
|
|
5306
|
-
|
|
5307
|
-
const
|
|
5308
|
-
const page = { entries: rows, nextCursor };
|
|
5790
|
+
// `pageOf`'s rule, and each row's own cursor when asked (#2073).
|
|
5791
|
+
const mint = (row) => cursorOf(row, q.sortColumn, plan.idColumn, q.order, q.view);
|
|
5309
5792
|
if (!params.total)
|
|
5310
|
-
return
|
|
5793
|
+
return pageOf(rows, limit, mint, params.rowCursors);
|
|
5311
5794
|
const counted = sql
|
|
5312
5795
|
.exec(q.countSql, ...q.countParams)
|
|
5313
5796
|
.toArray();
|
|
5314
|
-
return
|
|
5797
|
+
return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
|
|
5315
5798
|
},
|
|
5316
5799
|
/**
|
|
5317
5800
|
* Delegate a permission this caller holds onto one entity — see the
|
|
@@ -5329,7 +5812,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5329
5812
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
5330
5813
|
'the caller does not hold it there (a grant delegates, it never elevates)');
|
|
5331
5814
|
}
|
|
5332
|
-
|
|
5815
|
+
// #2071: an explicit grant, so it clears a tombstone `revoke` left.
|
|
5816
|
+
writeExplicitTupleIn(this.switchSql(), `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, { kind: 'delegated' });
|
|
5333
5817
|
},
|
|
5334
5818
|
/**
|
|
5335
5819
|
* Deliberately NOT the #1856 grammar check `grant` and `link` make: a revoke writes
|
|
@@ -5343,7 +5827,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5343
5827
|
throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
5344
5828
|
'the caller does not hold it there');
|
|
5345
5829
|
}
|
|
5346
|
-
|
|
5830
|
+
// K-21 (#2071): a tombstone, never a delete — a declared shape's top-up must be
|
|
5831
|
+
// able to tell a key taken back from one never held.
|
|
5832
|
+
const r = delegatedRevokeSql(principal, permission, `${entity.entityType}:${entity.entityId}`, at);
|
|
5833
|
+
sql.exec(r.sql, ...r.params);
|
|
5347
5834
|
},
|
|
5348
5835
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
5349
5836
|
// #1672: mint / revoke / list, written once in the kernel — the pure adapter hands
|
|
@@ -5369,6 +5856,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5369
5856
|
now: at,
|
|
5370
5857
|
emit: (event) => writeEvent(event, 'kernel'),
|
|
5371
5858
|
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
5859
|
+
isUnreachableParent: (entity) => isUnreachableParent(doSpineSql(sql), statePlans, entity),
|
|
5372
5860
|
}),
|
|
5373
5861
|
// #119: archive and trash — the pure adapter's wiring, over the raw spine seam.
|
|
5374
5862
|
...createEntityStateVerbs({
|
|
@@ -5385,6 +5873,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5385
5873
|
searchPlans,
|
|
5386
5874
|
statePlans,
|
|
5387
5875
|
check: runCheck,
|
|
5876
|
+
continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
|
|
5388
5877
|
}),
|
|
5389
5878
|
entitlement: async (key) => {
|
|
5390
5879
|
const held = await entitlementReader().listEntitlements(tenantId);
|
|
@@ -5427,6 +5916,29 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5427
5916
|
.toArray()[0];
|
|
5428
5917
|
return row?.value === 'local' ? 'local' : 'control-plane';
|
|
5429
5918
|
}
|
|
5919
|
+
/**
|
|
5920
|
+
* Whether a check here reads the directory over RPC (#938, Codex #2077 r5): the same
|
|
5921
|
+
* choice `controlPlaneReader` makes per call, asked each time a live fan-out pass opens an epoch.
|
|
5922
|
+
*/
|
|
5923
|
+
permissionSourceIsRemote() {
|
|
5924
|
+
return this.permissionSource() !== 'local' && Boolean(this.env.CONTROL_PLANE);
|
|
5925
|
+
}
|
|
5926
|
+
/**
|
|
5927
|
+
* The earliest `expires_at` after `now` in any local store a check or the parent walk
|
|
5928
|
+
* reads (#938, Codex #2077 r5), or null when nothing here lapses. Until then, no grant,
|
|
5929
|
+
* tenant tuple, parent edge or entitlement this scope holds can stop authorizing without
|
|
5930
|
+
* a write. Revoked rows are not excluded: an earlier bound only costs a re-check.
|
|
5931
|
+
*/
|
|
5932
|
+
authorityLapsesAfter(now) {
|
|
5933
|
+
const row = this.sql
|
|
5934
|
+
.exec(`SELECT MIN(e) AS until FROM (
|
|
5935
|
+
SELECT MIN(expires_at) AS e FROM _substrat_tuples WHERE expires_at > ?
|
|
5936
|
+
UNION ALL SELECT MIN(expires_at) FROM _substrat_tenant_tuples WHERE expires_at > ?
|
|
5937
|
+
UNION ALL SELECT MIN(expires_at) FROM _substrat_entitlements WHERE expires_at > ?
|
|
5938
|
+
)`, now, now, now)
|
|
5939
|
+
.toArray()[0];
|
|
5940
|
+
return row?.until ?? null;
|
|
5941
|
+
}
|
|
5430
5942
|
/**
|
|
5431
5943
|
* The checker's tenant-tuple/role reader. Chosen **per call** (the source can
|
|
5432
5944
|
* flip at runtime, and the checker holds this wrapper for the DO's lifetime):
|