@substrat-run/adapter-cloudflare 0.139.0 → 0.140.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +4 -0
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +57 -6
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +213 -62
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +30 -7
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +247 -40
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +30 -7
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +43 -9
- package/dist/live-reads.js.map +1 -1
- package/dist/route-resolver.d.ts +1 -1
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +7 -0
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +29 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +714 -217
- 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 +9 -9
package/dist/scope-do.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { REWIND_REFUSED } from './rewind-refusal.js';
|
|
2
2
|
import { SYSTEM_DOOR_MOVED } from './system-door.js';
|
|
3
3
|
import { DurableObject } from 'cloudflare:workers';
|
|
4
|
-
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, 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
11
|
import { createDoTupleChecker, createLocalControlPlaneReader, scopeTupleReader } from './checker.js';
|
|
12
|
-
import {
|
|
12
|
+
import { settlePlatformRequestIn } from '@substrat-run/kernel';
|
|
13
|
+
import { CARRIED_AWAY_KEY, COPY_MARK_CLEARED_KEY, KEPT_COPY_REFUSAL, KEPT_DIVERGENT_KEY, LOAD_STAMP_KEY, STORE_LOCAL_META_KEYS, WRITE_REVISION_KEY, carriedAwayDump, isCopyMarkInsert, isWriteStatement, assertSpineTablesBuilt, capabilitiesForLoad, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
|
|
14
|
+
import { GRANT_CHILDREN_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
|
|
15
|
+
/** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
|
|
16
|
+
function readAppliedMigrations(sql) {
|
|
17
|
+
const rows = sql.exec('SELECT module_id, version, sql_digest FROM _substrat_migrations').toArray();
|
|
18
|
+
return new Map(rows.map((r) => [`${r.module_id}@${r.version}`, r.sql_digest]));
|
|
19
|
+
}
|
|
13
20
|
/**
|
|
14
21
|
* The key marking a scope DO whose storage was destroyed (`destroyStorage`).
|
|
15
22
|
* Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
|
|
@@ -179,8 +186,13 @@ const KERNEL_DDL = `
|
|
|
179
186
|
applied_at TEXT NOT NULL,
|
|
180
187
|
duration_ms INTEGER,
|
|
181
188
|
rows_changed INTEGER,
|
|
189
|
+
-- #2066: SHA-256 of the SQL that ran (kernel migrationDigest). NULL on a row written
|
|
190
|
+
-- before the column: unrecorded, accepted, never backfilled.
|
|
191
|
+
sql_digest TEXT,
|
|
182
192
|
PRIMARY KEY (module_id, version)
|
|
183
193
|
);
|
|
194
|
+
-- #2066: no new journal row without its digest (the kernel's comment says why).
|
|
195
|
+
${MIGRATION_DIGEST_FENCE_DDL}
|
|
184
196
|
-- #286: the PITR bookmark taken immediately BEFORE a migration pass runs on a
|
|
185
197
|
-- scope that already holds data -- the precise rewind point a backout restores
|
|
186
198
|
-- to. Rows live in the same storage they describe, so a rewind erases the rows
|
|
@@ -192,6 +204,7 @@ const KERNEL_DDL = `
|
|
|
192
204
|
pending TEXT NOT NULL
|
|
193
205
|
);
|
|
194
206
|
${SWITCH_FENCES_DDL}
|
|
207
|
+
${TABLE_OWNERS_DDL}
|
|
195
208
|
CREATE TABLE IF NOT EXISTS _substrat_tuples (
|
|
196
209
|
subject TEXT NOT NULL,
|
|
197
210
|
relation TEXT NOT NULL,
|
|
@@ -202,6 +215,7 @@ const KERNEL_DDL = `
|
|
|
202
215
|
revoked_at TEXT,
|
|
203
216
|
PRIMARY KEY (subject, relation, object)
|
|
204
217
|
);
|
|
218
|
+
${GRANT_CHILDREN_INDEX_DDL};
|
|
205
219
|
CREATE TABLE IF NOT EXISTS _substrat_deliveries (
|
|
206
220
|
event_id TEXT NOT NULL,
|
|
207
221
|
consumer_module TEXT NOT NULL,
|
|
@@ -420,6 +434,17 @@ class WriteRevision {
|
|
|
420
434
|
keeping = false;
|
|
421
435
|
/** A statement's text is almost always a constant, so its answer is remembered (bounded). */
|
|
422
436
|
writes = new Map();
|
|
437
|
+
/**
|
|
438
|
+
* Every write statement run through this handle since the object woke, in memory (#938, Codex
|
|
439
|
+
* #2077 r4) — counted per STATEMENT, bookkeeping and suspended ones included, where the durable
|
|
440
|
+
* revision above is bumped once per run. A live fan-out pass decides who may hear a frame across
|
|
441
|
+
* awaits; it reads this immediately before deciding and again immediately before sending, with
|
|
442
|
+
* no await between that second read and the send, and decides again when it moved. Per statement
|
|
443
|
+
* because a decision taken between a transaction's first write (its bump) and a later one (the
|
|
444
|
+
* revoke itself) must not read as current. Over-counting (a rolled-back write, a write no check
|
|
445
|
+
* reads) only costs a pass a re-check.
|
|
446
|
+
*/
|
|
447
|
+
written = 0;
|
|
423
448
|
sql;
|
|
424
449
|
constructor(raw, storage, suspended,
|
|
425
450
|
/** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
|
|
@@ -429,7 +454,10 @@ class WriteRevision {
|
|
|
429
454
|
this.suspended = suspended;
|
|
430
455
|
this.refusal = refusal;
|
|
431
456
|
const exec = (query, ...bindings) => {
|
|
432
|
-
|
|
457
|
+
const write = this.isWrite(query);
|
|
458
|
+
if (write)
|
|
459
|
+
this.written++;
|
|
460
|
+
if (this.keeping && write) {
|
|
433
461
|
// Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
|
|
434
462
|
// else: any other write here would be one the revision never saw, which is the hole this
|
|
435
463
|
// class exists to close.
|
|
@@ -438,7 +466,7 @@ class WriteRevision {
|
|
|
438
466
|
}
|
|
439
467
|
return raw.exec(query, ...bindings);
|
|
440
468
|
}
|
|
441
|
-
if (
|
|
469
|
+
if (write && !this.suspended()) {
|
|
442
470
|
const refused = this.refusal();
|
|
443
471
|
if (refused)
|
|
444
472
|
throw substratError('conflict', refused);
|
|
@@ -504,6 +532,10 @@ class WriteRevision {
|
|
|
504
532
|
this.covered = false;
|
|
505
533
|
});
|
|
506
534
|
}
|
|
535
|
+
/** How many write statements this handle has run (see `written`). */
|
|
536
|
+
get statementsWritten() {
|
|
537
|
+
return this.written;
|
|
538
|
+
}
|
|
507
539
|
isWrite(query) {
|
|
508
540
|
let known = this.writes.get(query);
|
|
509
541
|
if (known === undefined) {
|
|
@@ -590,8 +622,6 @@ function cellToJson(v) {
|
|
|
590
622
|
* trigger: it passed on better-sqlite3 (whose `exec` takes the whole blob) and
|
|
591
623
|
* failed every scope on the DO host.
|
|
592
624
|
*/
|
|
593
|
-
const IS_CREATE_TRIGGER = /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i;
|
|
594
|
-
const ENDS_WITH_END = /\bEND\s*$/i;
|
|
595
625
|
/**
|
|
596
626
|
* The kernel's two-method SQL handle (`SwitchSql`) over a Durable Object's storage — what the
|
|
597
627
|
* shared switch rules (#1666) and the directory's switch record (#1674) run through. One
|
|
@@ -605,67 +635,8 @@ export function switchSqlOver(sql) {
|
|
|
605
635
|
},
|
|
606
636
|
};
|
|
607
637
|
}
|
|
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
|
-
}
|
|
638
|
+
/** The splitter both adapters run migrations through — the kernel's, re-exported for this package's callers. */
|
|
639
|
+
export { splitSqlStatements };
|
|
669
640
|
/**
|
|
670
641
|
* #1834: the pin missed. Thrown only by `assertSystemDoor`, and module-private, so no operation can
|
|
671
642
|
* throw one: the two RPCs a door call reaches turn it into the `SystemDoorMoved` ANSWER, by class
|
|
@@ -675,9 +646,51 @@ class SystemDoorMovedError extends Error {
|
|
|
675
646
|
}
|
|
676
647
|
/** #1834: the brand only `assertSystemDoor` sets, so nothing else can make a `SystemDoorPass`. */
|
|
677
648
|
const SYSTEM_DOOR_PASSED = Symbol('system door passed');
|
|
649
|
+
/** Close a live socket whose session has ended (#938). */
|
|
650
|
+
function closeSessionEnded(ws) {
|
|
651
|
+
try {
|
|
652
|
+
ws.close(LIVE_CLOSE.revoked, 'the session that opened this subscription has ended');
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// Already gone.
|
|
656
|
+
}
|
|
657
|
+
}
|
|
678
658
|
export function defineScopeDO(modules, bareOps) {
|
|
679
659
|
return class ScopeDO extends DurableObject {
|
|
680
660
|
sql;
|
|
661
|
+
continuationOrder = 0;
|
|
662
|
+
/** DO storage KV is private to the adapter; neither module SQL nor dumps can read it. */
|
|
663
|
+
continuationStore(writable) {
|
|
664
|
+
const positionKey = (id, expiresAt) => `continuation:position:${String(expiresAt).padStart(13, '0')}:${id}`;
|
|
665
|
+
return {
|
|
666
|
+
keys: async () => (await this.ctx.storage.get('continuation:keys')) ?? null,
|
|
667
|
+
setKeys: async (keys) => { await this.ctx.storage.put('continuation:keys', keys); },
|
|
668
|
+
position: async (id, expiresAt) => {
|
|
669
|
+
if (writable)
|
|
670
|
+
await this.pruneContinuationPositions();
|
|
671
|
+
return (await this.ctx.storage.get(positionKey(id, expiresAt))) ?? null;
|
|
672
|
+
},
|
|
673
|
+
setPosition: async (id, position) => {
|
|
674
|
+
// Several budget stops can land in one millisecond. Keep insertion
|
|
675
|
+
// order explicit so the cap always evicts the first locator minted.
|
|
676
|
+
this.continuationOrder = Math.max(Date.now() * 1_000, this.continuationOrder + 1);
|
|
677
|
+
await this.ctx.storage.put(positionKey(id, position.expiresAt), {
|
|
678
|
+
...position, order: this.continuationOrder,
|
|
679
|
+
});
|
|
680
|
+
await this.pruneContinuationPositions();
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
async pruneContinuationPositions() {
|
|
685
|
+
const positions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
|
|
686
|
+
const live = [...positions].filter(([, record]) => record.expiresAt > Date.now())
|
|
687
|
+
.sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0));
|
|
688
|
+
const evict = new Set(live.slice(0, Math.max(0, live.length - CONTINUATION_POSITION_CAP)).map(([name]) => name));
|
|
689
|
+
for (const [name, record] of positions) {
|
|
690
|
+
if (record.expiresAt <= Date.now() || evict.has(name))
|
|
691
|
+
await this.ctx.storage.delete(name);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
681
694
|
queue = new OperationQueue();
|
|
682
695
|
operations = new Map();
|
|
683
696
|
/**
|
|
@@ -688,6 +701,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
688
701
|
operationInput = new Map();
|
|
689
702
|
/** #129: name → the entity whose version an `If-Match` is compared against. */
|
|
690
703
|
operationConcurrency = new Map();
|
|
704
|
+
/** #119: name → the entity it addresses by id — the host's trash refusal reads it. */
|
|
705
|
+
operationTarget = new Map();
|
|
706
|
+
/** #119: module id → the keys its system principal holds only for its purge schedules. */
|
|
707
|
+
purgeOnlyKeys = new Map();
|
|
708
|
+
/** #119: a purge schedule's operation → the module that declared it and the entity it purges. */
|
|
709
|
+
purgeSchedules = new Map();
|
|
691
710
|
/** #116: the operations that declared `idempotency: false` — refusals, not participants. */
|
|
692
711
|
operationIdempotencyOptOut = new Set();
|
|
693
712
|
modules = new Map();
|
|
@@ -713,6 +732,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
713
732
|
listPlans = new Map();
|
|
714
733
|
/** #119: entity type → its archive/trash plan, from every registered module. */
|
|
715
734
|
statePlans = new Map();
|
|
735
|
+
/** #2068: each registered module's erasure, in registration order — what `redactSubject` runs. */
|
|
736
|
+
erasurePlans = [];
|
|
716
737
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
717
738
|
attachmentTargets = new Map();
|
|
718
739
|
/**
|
|
@@ -736,7 +757,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
736
757
|
* underneath rows it already stored.
|
|
737
758
|
*/
|
|
738
759
|
mintEventId = createUlid();
|
|
739
|
-
|
|
760
|
+
/** `module@version` → the SQL digest its journal row recorded, null for a row from before #2066. */
|
|
761
|
+
applied = new Map();
|
|
740
762
|
migrationPromise;
|
|
741
763
|
/** Latch: the applied count is reported to the directory once per DO instance. */
|
|
742
764
|
schemaVersionReported = false;
|
|
@@ -779,7 +801,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
779
801
|
// transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
|
|
780
802
|
this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
|
|
781
803
|
this.sql = this.revision.sql;
|
|
782
|
-
for (const stmt of
|
|
804
|
+
for (const stmt of executableSqlStatements(KERNEL_DDL)) {
|
|
783
805
|
this.sql.exec(stmt);
|
|
784
806
|
}
|
|
785
807
|
this.applySpineColumnAdditions();
|
|
@@ -790,11 +812,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
790
812
|
for (const [name, handler] of Object.entries(bareOps))
|
|
791
813
|
this.defineOperation(name, handler);
|
|
792
814
|
// 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
|
-
}
|
|
815
|
+
this.applied = readAppliedMigrations(this.sql);
|
|
798
816
|
// #1335: and where this DO's event ids have to resume from. A revived DO would
|
|
799
817
|
// otherwise start its floor at the wall clock, and a clock that has stepped back
|
|
800
818
|
// — an NTP correction is small but real — mints underneath rows already stored,
|
|
@@ -810,6 +828,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
810
828
|
// -- module registration (port of SqliteScopeHost.registerModule) ---------
|
|
811
829
|
registerModule(registration) {
|
|
812
830
|
const manifest = registration.manifest;
|
|
831
|
+
// #119: refused before anything is recorded, as in the pure adapter.
|
|
832
|
+
const trashTargets = registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
|
|
833
|
+
this.purgeOnlyKeys.set(manifest.id, purgeOnlyKeysOf(manifest.schedules ?? []));
|
|
834
|
+
for (const schedule of manifest.schedules ?? []) {
|
|
835
|
+
if (schedule.purge)
|
|
836
|
+
this.purgeSchedules.set(schedule.operation, { moduleId: manifest.id, entityType: schedule.purge.entityType });
|
|
837
|
+
}
|
|
838
|
+
// #2068: refused before anything is recorded, as on the pure host.
|
|
839
|
+
const erasure = moduleErasurePlan(registration);
|
|
813
840
|
if (manifest.peers?.length)
|
|
814
841
|
this.peerSources.push({ peers: manifest.peers });
|
|
815
842
|
// #827: the FTS indexes `searchables` declares, appended after the module's
|
|
@@ -841,11 +868,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
841
868
|
id: manifest.id,
|
|
842
869
|
// The order the kernel writes once (#1677): authored, then search, then list indexes.
|
|
843
870
|
migrations: moduleMigrations(registration),
|
|
871
|
+
steps: migrationSteps(registration),
|
|
844
872
|
consumers: Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => ({
|
|
845
873
|
eventType,
|
|
846
874
|
handler,
|
|
847
875
|
})),
|
|
848
876
|
});
|
|
877
|
+
if (erasure)
|
|
878
|
+
this.erasurePlans.push(erasure);
|
|
849
879
|
for (const [name, handler] of Object.entries(registration.predicates ?? {})) {
|
|
850
880
|
this.predicates.set(name, { module: manifest.id, handler });
|
|
851
881
|
}
|
|
@@ -932,6 +962,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
932
962
|
if (declaredOptOuts.includes(name) && this.operations.has(name)) {
|
|
933
963
|
this.operationIdempotencyOptOut.add(name);
|
|
934
964
|
}
|
|
965
|
+
const target = trashTargets.get(name);
|
|
966
|
+
if (target && this.operations.has(name))
|
|
967
|
+
this.operationTarget.set(name, target);
|
|
935
968
|
}
|
|
936
969
|
}
|
|
937
970
|
defineOperation(name, handler) {
|
|
@@ -1571,10 +1604,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1571
1604
|
* Provisioning does not come through here; it seats with `seatTuple` (#1659).
|
|
1572
1605
|
*/
|
|
1573
1606
|
async writeTuple(subject, relation, object, expiresAt) {
|
|
1574
|
-
|
|
1575
|
-
this.
|
|
1576
|
-
|
|
1577
|
-
});
|
|
1607
|
+
return replyOf(() => this.queue.enqueue(() => {
|
|
1608
|
+
writeExplicitTupleIn(this.switchSql(), subject, relation, object, { kind: 'replace', expiresAt });
|
|
1609
|
+
return null;
|
|
1610
|
+
}));
|
|
1578
1611
|
}
|
|
1579
1612
|
/**
|
|
1580
1613
|
* Provisioning's scope-tuple write (#1659): create the row if it is missing, follow the
|
|
@@ -1604,6 +1637,26 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1604
1637
|
return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
1605
1638
|
}));
|
|
1606
1639
|
}
|
|
1640
|
+
/** A declared entity-grant shape's grant to one person on one entity (#2071), as ONE unit. */
|
|
1641
|
+
async grantEntityShape(principal, entity, permissions) {
|
|
1642
|
+
await this.queue.enqueue(() => this.revision.transactionSync(() => grantEntityShapeIn(this.switchSql(), principal, entity, permissions)));
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* One bounded pass of a declared shape's reconcile (#2071), with its events, in ONE
|
|
1646
|
+
* transaction: how many it topped up, how many it took retired keys from (#2082), and
|
|
1647
|
+
* whether the scope is done.
|
|
1648
|
+
*/
|
|
1649
|
+
async topUpEntityGrantShapes(tenantId, scopeId, shapes, limit) {
|
|
1650
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => topUpEntityGrantShapes(this.switchSql(), {
|
|
1651
|
+
tenantId,
|
|
1652
|
+
scopeId,
|
|
1653
|
+
shapes,
|
|
1654
|
+
now: new Date().toISOString(),
|
|
1655
|
+
limit,
|
|
1656
|
+
mintEventId: (ms) => this.mintEventId(ms),
|
|
1657
|
+
version: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
1658
|
+
})));
|
|
1659
|
+
}
|
|
1607
1660
|
/**
|
|
1608
1661
|
* The dispatch capability's admission (#726 remedy B): this delivery may read the
|
|
1609
1662
|
* attachments of the entity its own spine row names, and no others.
|
|
@@ -1733,7 +1786,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1733
1786
|
// Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
|
|
1734
1787
|
// ZodError, whose `message` is a getter) still arrives with its message.
|
|
1735
1788
|
try {
|
|
1736
|
-
return await this
|
|
1789
|
+
return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
|
|
1737
1790
|
}
|
|
1738
1791
|
catch (err) {
|
|
1739
1792
|
if (err instanceof SystemDoorMovedError)
|
|
@@ -1742,7 +1795,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1742
1795
|
}
|
|
1743
1796
|
}
|
|
1744
1797
|
try {
|
|
1745
|
-
return await this
|
|
1798
|
+
return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
|
|
1746
1799
|
}
|
|
1747
1800
|
catch (err) {
|
|
1748
1801
|
// #1834: the pin missed — an answer, never a failure an operation could have produced.
|
|
@@ -1754,8 +1807,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1754
1807
|
return { result: undefined, platformRequests: 0, failure: toWireFailure(err) };
|
|
1755
1808
|
}
|
|
1756
1809
|
}
|
|
1757
|
-
/**
|
|
1758
|
-
|
|
1810
|
+
/**
|
|
1811
|
+
* The operation path itself. Throws; `invoke` decides how that reaches the caller.
|
|
1812
|
+
*
|
|
1813
|
+
* `#`-private, not merely `private`: workerd's RPC exposes every method on the prototype,
|
|
1814
|
+
* and this one takes `purge` (#119), which only `runPurgeSweep` may set. A `#` method is not
|
|
1815
|
+
* on the prototype, so no stub can reach it.
|
|
1816
|
+
*/
|
|
1817
|
+
async #invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
|
|
1759
1818
|
/** K-42: the session, resolved coordinator-side. See `invoke` above. */
|
|
1760
1819
|
impersonation,
|
|
1761
1820
|
/** #1672: a capability session's hash. See `invoke` above. */
|
|
@@ -1763,7 +1822,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1763
1822
|
/** #1706: the calling vertical, as the platform named it. See `invoke` above. */
|
|
1764
1823
|
verticalCaller,
|
|
1765
1824
|
/** #1834: the instance the system door's gate read. See `invoke` above. */
|
|
1766
|
-
systemDoorInstance
|
|
1825
|
+
systemDoorInstance,
|
|
1826
|
+
/** #119: set only by `runPurgeSweep` on its own invokes — never an RPC argument. */
|
|
1827
|
+
purge) {
|
|
1767
1828
|
await this.ensureMigrations();
|
|
1768
1829
|
const handler = this.operations.get(operation);
|
|
1769
1830
|
// `not_found`, not a bare throw (#113): every vertical hand-matched this message
|
|
@@ -1798,6 +1859,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1798
1859
|
// declaration. Outside the queue and outside the transaction: a malformed
|
|
1799
1860
|
// call takes no turn and opens nothing. Guards read the parsed input too,
|
|
1800
1861
|
// so a K-17 pre-condition sees what the handler will.
|
|
1862
|
+
// #119: purge authority is never an option a caller supplies.
|
|
1863
|
+
assertNoCallerPurge(invokeOptions);
|
|
1801
1864
|
const declaredInput = this.operationInput.get(operation);
|
|
1802
1865
|
const parsed = declaredInput ? declaredInput.parse(input) : input;
|
|
1803
1866
|
// #129. Refused rather than ignored, for the reason the coordinator refuses an
|
|
@@ -1924,8 +1987,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1924
1987
|
// commit together, or a throw (from either) rolls domain writes AND
|
|
1925
1988
|
// emitted events back as one — verified across `await` in workerd.
|
|
1926
1989
|
try {
|
|
1990
|
+
// #119: a module's purge-only keys are withheld from its system principal on every
|
|
1991
|
+
// call but the purge sweep's own invoke of the purge operation.
|
|
1992
|
+
const target = this.operationTarget.get(operation);
|
|
1993
|
+
// #119: purge authority is the system door's, and only `runPurgeSweep` sets `purge`.
|
|
1994
|
+
const purging = purge === true && systemDoor !== undefined;
|
|
1995
|
+
const withheld = systemDoor
|
|
1996
|
+
? withheldKeysFor(this.purgeOnlyKeys.get(systemDoor.moduleId), target, purging)
|
|
1997
|
+
: undefined;
|
|
1927
1998
|
await this.revision.transaction(async () => {
|
|
1928
|
-
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
|
|
1999
|
+
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject, undefined, withheld);
|
|
1929
2000
|
// #116: a retry is answered from the recording, and nothing else runs
|
|
1930
2001
|
// — not the guards, not the handler, not the permission check inside
|
|
1931
2002
|
// it. Keyed by SUBJECT, so a caller only ever reaches its own
|
|
@@ -1952,6 +2023,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1952
2023
|
const seen = guardedRef && invokeOptions?.ifMatch !== undefined
|
|
1953
2024
|
? this.versionAt(guardedRef)
|
|
1954
2025
|
: undefined;
|
|
2026
|
+
// #119: the host's trash refusal, before the guards and the handler — the pure
|
|
2027
|
+
// adapter's, through the same kernel function.
|
|
2028
|
+
await refuseTrashedTarget({ sql: doSpineSql(this.sql), plans: this.statePlans, check: ctx.check }, operation, target, parsed, purging ? { now: new Date().toISOString(), gate: this.purgeGateFacts(systemDoor.moduleId, tenantId) } : undefined);
|
|
1955
2029
|
await this.runGuards(operation, ctx, parsed);
|
|
1956
2030
|
result = await handler(ctx, parsed);
|
|
1957
2031
|
if (guardedRef && invokeOptions?.ifMatch !== undefined) {
|
|
@@ -2148,12 +2222,57 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2148
2222
|
if (withinHeader !== null && !within) {
|
|
2149
2223
|
return new Response('live reads: unreadable within narrowing', { status: 500 });
|
|
2150
2224
|
}
|
|
2225
|
+
// The same for an expiry: dropping it would keep the socket open past its session.
|
|
2226
|
+
const expiresHeader = request.headers.get(LIVE_EXPIRES_HEADER);
|
|
2227
|
+
const expiresAt = expiresHeader === null ? undefined : liveInstant(expiresHeader);
|
|
2228
|
+
if (expiresHeader !== null && !expiresAt) {
|
|
2229
|
+
return new Response('live reads: unreadable expiry', { status: 500 });
|
|
2230
|
+
}
|
|
2151
2231
|
// A subscriber arriving before the scope's migrations have run would be told
|
|
2152
2232
|
// about events against a schema it cannot read back through. Same gate every
|
|
2153
2233
|
// other entry point takes, for the same reason.
|
|
2154
2234
|
await this.ensureMigrations();
|
|
2235
|
+
// `.parse`, not a cast: these three arrived as header strings, and the branded
|
|
2236
|
+
// ids are what every check downstream is keyed on. A malformed one would
|
|
2237
|
+
// otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
|
|
2238
|
+
// which reads as "this subscriber may see nothing" and is indistinguishable from
|
|
2239
|
+
// a correct denial. Refused here instead, where it is still one subscriber's
|
|
2240
|
+
// problem. Throwing is right: the coordinator built this request.
|
|
2241
|
+
const subscriber = {
|
|
2242
|
+
principal: principalId.parse(principal),
|
|
2243
|
+
tenantId: tenantIdOf.parse(tenantId),
|
|
2244
|
+
scopeId: scopeIdOf.parse(scopeId),
|
|
2245
|
+
};
|
|
2246
|
+
// A checked root (#938) is gated here as well as on every pass: a subscriber who may
|
|
2247
|
+
// not watch it gets no socket at all, so its client meets a refusal rather than a
|
|
2248
|
+
// feed that would close on the first thing it had to say. A session already over is
|
|
2249
|
+
// refused the same way.
|
|
2250
|
+
const expired = expiresAt !== undefined && expiresAt <= new Date().toISOString();
|
|
2251
|
+
if (expired ||
|
|
2252
|
+
(within?.checked !== undefined && !(await this.mayWatchRoot(() => this.liveContext(subscriber), within)))) {
|
|
2253
|
+
return new Response('live reads: the subscriber may not watch this root', {
|
|
2254
|
+
status: 403,
|
|
2255
|
+
headers: { [LIVE_MODE_HEADER]: 'forbidden' },
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2155
2258
|
const pair = new WebSocketPair();
|
|
2156
2259
|
const [client, server] = Object.values(pair);
|
|
2260
|
+
/**
|
|
2261
|
+
* At most `LIVE_SOCKETS_PER_PRINCIPAL` sockets per principal on this scope (#938):
|
|
2262
|
+
* each is work on every post-commit pass. The extra one is accepted and closed at
|
|
2263
|
+
* once with `4429`, rather than refused with a status, because a browser never sees
|
|
2264
|
+
* a failed handshake's status — only a close code, which the client reads as "poll
|
|
2265
|
+
* and stop asking". Not hibernated, so it never joins the roster the fan-out walks.
|
|
2266
|
+
*
|
|
2267
|
+
* Counted over the sockets still live: one whose session has ended is closed here
|
|
2268
|
+
* first, so sockets that expired on an idle scope never hold a fresh session out.
|
|
2269
|
+
*/
|
|
2270
|
+
const held = this.reapExpiredLive().filter((s) => s.principal === subscriber.principal).length;
|
|
2271
|
+
if (held >= LIVE_SOCKETS_PER_PRINCIPAL) {
|
|
2272
|
+
server.accept();
|
|
2273
|
+
server.close(LIVE_CLOSE.tooMany, 'too many live subscriptions for this principal; poll instead');
|
|
2274
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
2275
|
+
}
|
|
2157
2276
|
// HIBERNATABLE, not `server.accept()`. A scope with a watcher open would
|
|
2158
2277
|
// otherwise be pinned in memory for as long as somebody has a tab open, which is
|
|
2159
2278
|
// the cost model inverted: a support desk being WATCHED is the normal state.
|
|
@@ -2161,21 +2280,94 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2161
2280
|
// eviction, so the socket would stay open and silently stop receiving, which is
|
|
2162
2281
|
// indistinguishable from a quiet scope.
|
|
2163
2282
|
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
2283
|
server.serializeAttachment({
|
|
2171
|
-
|
|
2172
|
-
tenantId: tenantIdOf.parse(tenantId),
|
|
2173
|
-
scopeId: scopeIdOf.parse(scopeId),
|
|
2284
|
+
...subscriber,
|
|
2174
2285
|
since: new Date().toISOString(),
|
|
2175
2286
|
...(within ? { within } : {}),
|
|
2287
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
2176
2288
|
});
|
|
2289
|
+
if (expiresAt)
|
|
2290
|
+
await this.armLiveExpiry();
|
|
2177
2291
|
return new Response(null, { status: 101, webSocket: client });
|
|
2178
2292
|
}
|
|
2293
|
+
/**
|
|
2294
|
+
* Close every live socket whose session has ended (`1008`), and return the
|
|
2295
|
+
* subscriptions still open (#938). Reads only the sockets' own attachments, never the
|
|
2296
|
+
* database, so it is as cheap on a scope nobody writes to as on a busy one.
|
|
2297
|
+
*/
|
|
2298
|
+
reapExpiredLive(now = new Date().toISOString()) {
|
|
2299
|
+
const live = [];
|
|
2300
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
2301
|
+
let s = null;
|
|
2302
|
+
try {
|
|
2303
|
+
s = readSubscription(ws.deserializeAttachment());
|
|
2304
|
+
}
|
|
2305
|
+
catch {
|
|
2306
|
+
s = null;
|
|
2307
|
+
}
|
|
2308
|
+
if (!s)
|
|
2309
|
+
continue;
|
|
2310
|
+
if (s.expiresAt !== undefined && s.expiresAt <= now) {
|
|
2311
|
+
closeSessionEnded(ws);
|
|
2312
|
+
continue;
|
|
2313
|
+
}
|
|
2314
|
+
live.push(s);
|
|
2315
|
+
}
|
|
2316
|
+
return live;
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* Arm the scope's alarm for the earliest session end among its live sockets (#938), so
|
|
2320
|
+
* an expired socket is closed on a scope nobody writes to — the fan-out closes them
|
|
2321
|
+
* too, but only on a pass, and an idle scope has none.
|
|
2322
|
+
*
|
|
2323
|
+
* Only ever moved EARLIER: an alarm already set before that instant is kept, and the
|
|
2324
|
+
* handler re-arms for whatever is next. Re-arming on a close is not needed: an alarm
|
|
2325
|
+
* that finds the socket already gone simply arms for the next one, or for nothing.
|
|
2326
|
+
*
|
|
2327
|
+
* The live reaper is this DO's only alarm use. A second one must share `alarm()` and
|
|
2328
|
+
* this "earliest wins" rule, not call `setAlarm` on its own. The alarm makes workerd
|
|
2329
|
+
* keep a `_cf_METADATA` table in the scope's SQLite; every table walk here skips
|
|
2330
|
+
* `_cf_*` for that reason (`exportDump`, `importDump`, `introspectTables`).
|
|
2331
|
+
*/
|
|
2332
|
+
async armLiveExpiry() {
|
|
2333
|
+
let earliest;
|
|
2334
|
+
for (const s of this.reapExpiredLive()) {
|
|
2335
|
+
if (s.expiresAt !== undefined && (earliest === undefined || s.expiresAt < earliest))
|
|
2336
|
+
earliest = s.expiresAt;
|
|
2337
|
+
}
|
|
2338
|
+
if (earliest === undefined)
|
|
2339
|
+
return;
|
|
2340
|
+
const at = Date.parse(earliest);
|
|
2341
|
+
const current = await this.ctx.storage.getAlarm();
|
|
2342
|
+
if (current === null || current > at)
|
|
2343
|
+
await this.ctx.storage.setAlarm(at);
|
|
2344
|
+
}
|
|
2345
|
+
/** The live reaper's alarm (#938): close what has expired, then arm for what is next. */
|
|
2346
|
+
async alarm() {
|
|
2347
|
+
await this.armLiveExpiry();
|
|
2348
|
+
}
|
|
2349
|
+
/** The context a live subscriber's checks run in: its own principal, under `live.subscribe`. */
|
|
2350
|
+
liveContext(subscriber) {
|
|
2351
|
+
return this.operationContext(subscriber.principal, subscriber.tenantId, subscriber.scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe');
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* May this subscriber watch its `checkedWithin` root (#938)? Its own `ctx.check` of the
|
|
2355
|
+
* stated key on the root, the same walk a read of the root would make. A check that
|
|
2356
|
+
* throws is a refusal: an outage in the permission path must not become a feed.
|
|
2357
|
+
*/
|
|
2358
|
+
async mayWatchRoot(context, within) {
|
|
2359
|
+
try {
|
|
2360
|
+
// Built inside the try: a context that cannot be built is a refusal too.
|
|
2361
|
+
const decision = await context().check(within.checked, {
|
|
2362
|
+
entityType: within.entityType,
|
|
2363
|
+
entityId: within.entityId,
|
|
2364
|
+
});
|
|
2365
|
+
return decision.allowed;
|
|
2366
|
+
}
|
|
2367
|
+
catch {
|
|
2368
|
+
return false;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2179
2371
|
/**
|
|
2180
2372
|
* A subscriber said something.
|
|
2181
2373
|
*
|
|
@@ -2195,6 +2387,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2195
2387
|
*/
|
|
2196
2388
|
webSocketMessage(ws, message) {
|
|
2197
2389
|
this.webSocketMessagesHandled++;
|
|
2390
|
+
// Ungated, and safe to be: the reply is the constant `'pong'` and carries no event, no
|
|
2391
|
+
// entity and no time, so it tells the socket nothing it did not already know. Every
|
|
2392
|
+
// frame that is about the scope's data goes out through `fanOutLive`'s gates instead.
|
|
2198
2393
|
if (message === 'ping')
|
|
2199
2394
|
ws.send('pong');
|
|
2200
2395
|
}
|
|
@@ -2328,12 +2523,33 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2328
2523
|
* operation has already moved the edge the walk follows.
|
|
2329
2524
|
*/
|
|
2330
2525
|
const parents = scopeTupleReader(this.sql);
|
|
2331
|
-
|
|
2526
|
+
/**
|
|
2527
|
+
* A checked root's gate, asked once per (principal, key, root) per pass (#938): a
|
|
2528
|
+
* principal holding that root in several tabs is one check, fanned out to each socket.
|
|
2529
|
+
*/
|
|
2530
|
+
const rootChecks = new Map();
|
|
2332
2531
|
const ancestors = new Map();
|
|
2333
|
-
const
|
|
2532
|
+
const openEpoch = (now) => ({
|
|
2533
|
+
writes: this.revision.statementsWritten,
|
|
2534
|
+
until: this.authorityLapsesAfter(now),
|
|
2535
|
+
remote: this.permissionSourceIsRemote(),
|
|
2536
|
+
});
|
|
2537
|
+
const holds = (e, now) => e.writes === this.revision.statementsWritten && (e.until === null || now < e.until);
|
|
2538
|
+
let epoch = openEpoch(new Date().toISOString());
|
|
2539
|
+
/** The epoch current at `now`, opening a new one (and forgetting every memo) if not. */
|
|
2540
|
+
const epochAt = (now) => {
|
|
2541
|
+
if (!holds(epoch, now)) {
|
|
2542
|
+
epoch = openEpoch(now);
|
|
2543
|
+
rootChecks.clear();
|
|
2544
|
+
ancestors.clear();
|
|
2545
|
+
}
|
|
2546
|
+
return epoch;
|
|
2547
|
+
};
|
|
2548
|
+
const reaches = async (row, root, now) => {
|
|
2334
2549
|
let up = ancestors.get(row.id);
|
|
2335
2550
|
if (!up) {
|
|
2336
2551
|
// A walk that cannot answer is a frame not sent — the same fail-closed rule as the check.
|
|
2552
|
+
// Walked at the decision's own instant; remembered only while its epoch holds.
|
|
2337
2553
|
up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
|
|
2338
2554
|
ancestors.set(row.id, up);
|
|
2339
2555
|
}
|
|
@@ -2357,6 +2573,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2357
2573
|
// assumption about how the subscription was created.
|
|
2358
2574
|
if (subscription.tenantId !== tenantId || subscription.scopeId !== scopeId)
|
|
2359
2575
|
continue;
|
|
2576
|
+
// The session that opened it has ended (#938): closed before anything is sent,
|
|
2577
|
+
// whatever the subscriber's grants still say. Checked on every pass, so it holds for
|
|
2578
|
+
// every kind of subscription, not only the ones with a gate to ask — and asked
|
|
2579
|
+
// again, against a fresh clock, immediately before every send below, because the
|
|
2580
|
+
// walk and the checks between here and there await, and a session can end during
|
|
2581
|
+
// them.
|
|
2582
|
+
const { expiresAt } = subscription;
|
|
2583
|
+
const sessionEnded = () => expiresAt !== undefined && expiresAt <= new Date().toISOString();
|
|
2584
|
+
if (sessionEnded()) {
|
|
2585
|
+
closeSessionEnded(ws);
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
2360
2588
|
const { within } = subscription;
|
|
2361
2589
|
// One context per subscriber, not per event: `ctx.check` is the expensive part
|
|
2362
2590
|
// and the context is only the subject it is evaluated for. Built on first use, so
|
|
@@ -2372,57 +2600,115 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2372
2600
|
// The operation name is carried anyway, for the events a fan-out cannot emit
|
|
2373
2601
|
// but a future reader of this context might.
|
|
2374
2602
|
let ctx;
|
|
2375
|
-
const
|
|
2376
|
-
const context = () => (ctx ??= this.
|
|
2377
|
-
|
|
2603
|
+
const subscriber = subscription;
|
|
2604
|
+
const context = () => (ctx ??= this.liveContext(subscriber));
|
|
2605
|
+
/**
|
|
2606
|
+
* A checked root's gate (#938), asked at most once per (principal, key, root) per
|
|
2607
|
+
* pass (`rootChecks`) and only once a row beneath the root is about to be announced,
|
|
2608
|
+
* so an idle subscription costs no check. A refusal, or a check that throws, closes the socket before anything is
|
|
2609
|
+
* sent: the grant is gone, the principal is, or the root moved out of the grant's
|
|
2610
|
+
* reach. Closed rather than skipped, so the subscription does not sit there asking
|
|
2611
|
+
* on every pass, and the client's reconnect meets the handshake's 403. Held with the
|
|
2612
|
+
* write count it was decided under, and asked again once that has moved.
|
|
2613
|
+
*/
|
|
2614
|
+
let rootVerdict;
|
|
2615
|
+
/** What this subscriber is owed for one row, decided at `now` under epoch `e`. */
|
|
2616
|
+
const decide = async (row, e, now) => {
|
|
2617
|
+
// A remote authority's verdict is never reused: see `Epoch`.
|
|
2618
|
+
if (rootVerdict?.epoch !== e || e.remote)
|
|
2619
|
+
rootVerdict = undefined;
|
|
2378
2620
|
// Narrowing first: it is memoised across sockets, and a row outside the root
|
|
2379
2621
|
// is out whatever the principal holds.
|
|
2380
|
-
if (within && !(await reaches(row, within)))
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2622
|
+
if (within && !(await reaches(row, within, now)))
|
|
2623
|
+
return 'skip';
|
|
2624
|
+
if (within?.checked !== undefined) {
|
|
2625
|
+
if (rootVerdict === undefined) {
|
|
2626
|
+
let check;
|
|
2627
|
+
if (e.remote) {
|
|
2628
|
+
check = this.mayWatchRoot(context, within);
|
|
2629
|
+
}
|
|
2630
|
+
else {
|
|
2631
|
+
const key = `${subscriber.principal}\n${within.checked}\n${within.entityType}:${within.entityId}`;
|
|
2632
|
+
check = rootChecks.get(key);
|
|
2633
|
+
if (!check)
|
|
2634
|
+
rootChecks.set(key, (check = this.mayWatchRoot(context, within)));
|
|
2635
|
+
}
|
|
2636
|
+
rootVerdict = { epoch: e, allowed: await check };
|
|
2637
|
+
}
|
|
2638
|
+
if (!rootVerdict.allowed)
|
|
2639
|
+
return 'revoked';
|
|
2640
|
+
}
|
|
2641
|
+
if (within?.vouched !== undefined || within?.checked !== undefined) {
|
|
2642
|
+
// The root was vouched for or checked, so the walk above was the row filter —
|
|
2643
|
+
// and the subscriber holds no read on this row, so it is told only that
|
|
2386
2644
|
// something beneath its root changed. Never which row, never how.
|
|
2387
|
-
|
|
2645
|
+
return { kind: 'nudge', id: row.id, at: row.occurred_at };
|
|
2388
2646
|
}
|
|
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,
|
|
2647
|
+
// Non-null: `announceable` is exactly the rows whose type is in the map.
|
|
2648
|
+
const permission = this.liveTargets.get(row.entity_type);
|
|
2649
|
+
try {
|
|
2650
|
+
const decision = await context().check(permission, {
|
|
2413
2651
|
entityType: row.entity_type,
|
|
2414
2652
|
entityId: row.entity_id,
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
try {
|
|
2419
|
-
ws.send(JSON.stringify(frame));
|
|
2653
|
+
});
|
|
2654
|
+
if (!decision.allowed)
|
|
2655
|
+
return 'skip';
|
|
2420
2656
|
}
|
|
2421
2657
|
catch {
|
|
2422
|
-
//
|
|
2423
|
-
//
|
|
2424
|
-
|
|
2658
|
+
// A check that cannot answer is a check that refuses. The alternative —
|
|
2659
|
+
// treating an evaluator failure as an allow — turns an outage in the
|
|
2660
|
+
// permission path into a disclosure, which is the one failure mode this
|
|
2661
|
+
// surface must not have.
|
|
2662
|
+
return 'skip';
|
|
2663
|
+
}
|
|
2664
|
+
return {
|
|
2665
|
+
kind: 'change',
|
|
2666
|
+
id: row.id,
|
|
2667
|
+
type: row.type,
|
|
2668
|
+
entityType: row.entity_type,
|
|
2669
|
+
entityId: row.entity_id,
|
|
2670
|
+
at: row.occurred_at,
|
|
2671
|
+
};
|
|
2672
|
+
};
|
|
2673
|
+
rows: for (const row of announceable) {
|
|
2674
|
+
for (let attempt = 0; attempt < LIVE_DECIDE_ATTEMPTS; attempt++) {
|
|
2675
|
+
const decidedAt = new Date().toISOString();
|
|
2676
|
+
const e = epochAt(decidedAt);
|
|
2677
|
+
const verdict = await decide(row, e, decidedAt);
|
|
2678
|
+
// Every gate as close to the send as it can be, with no await between these
|
|
2679
|
+
// reads and the send below — so nothing can land after them and before it.
|
|
2680
|
+
// The store wrote, or something the decision relied on lapsed, while it was being
|
|
2681
|
+
// taken (Codex #2077 r4, r5): decide again, from nothing remembered.
|
|
2682
|
+
if (!holds(e, new Date().toISOString()))
|
|
2683
|
+
continue;
|
|
2684
|
+
if (verdict === 'skip')
|
|
2685
|
+
continue rows;
|
|
2686
|
+
if (verdict === 'revoked') {
|
|
2687
|
+
try {
|
|
2688
|
+
ws.close(LIVE_CLOSE.revoked, 'the subscriber may no longer watch this root');
|
|
2689
|
+
}
|
|
2690
|
+
catch {
|
|
2691
|
+
// Already gone.
|
|
2692
|
+
}
|
|
2693
|
+
break rows;
|
|
2694
|
+
}
|
|
2695
|
+
// The clock is read again here too, after every await above.
|
|
2696
|
+
if (sessionEnded()) {
|
|
2697
|
+
closeSessionEnded(ws);
|
|
2698
|
+
break rows;
|
|
2699
|
+
}
|
|
2700
|
+
try {
|
|
2701
|
+
ws.send(JSON.stringify(verdict));
|
|
2702
|
+
}
|
|
2703
|
+
catch {
|
|
2704
|
+
// The socket went away between `getWebSockets()` and here. Stop writing to
|
|
2705
|
+
// this one and move on; the runtime will deliver `webSocketClose`.
|
|
2706
|
+
break rows;
|
|
2707
|
+
}
|
|
2708
|
+
continue rows;
|
|
2425
2709
|
}
|
|
2710
|
+
// Still moving after every attempt: this row is not announced, and the client's
|
|
2711
|
+
// poll — the floor under every push — picks it up.
|
|
2426
2712
|
}
|
|
2427
2713
|
}
|
|
2428
2714
|
}
|
|
@@ -2858,8 +3144,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2858
3144
|
return this.queue.enqueue(() => {
|
|
2859
3145
|
if (systemSwitchedOff(this.switchSql(), moduleId))
|
|
2860
3146
|
return false;
|
|
2861
|
-
this.
|
|
2862
|
-
VALUES (?, ?, ?, ?)`, `system:${moduleId}`, relation, object, expiresAt);
|
|
3147
|
+
writeExplicitTupleIn(this.switchSql(), `system:${moduleId}`, relation, object, { kind: 'replace', expiresAt });
|
|
2863
3148
|
return true;
|
|
2864
3149
|
});
|
|
2865
3150
|
}
|
|
@@ -3248,6 +3533,70 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3248
3533
|
await this.ensureMigrations();
|
|
3249
3534
|
return readCapabilityPage(doSpineSql(this.sql), filter);
|
|
3250
3535
|
}
|
|
3536
|
+
/**
|
|
3537
|
+
* One pass of a purge horizon's schedule on this scope (#119): the oldest entities due for
|
|
3538
|
+
* purge, each deleted by the module's own `trashed: 'purges'` operation, one call and one
|
|
3539
|
+
* transaction per entity, as `system:<moduleId>`.
|
|
3540
|
+
*
|
|
3541
|
+
* The caller names only WHICH schedule. Everything that decides what is purged is this
|
|
3542
|
+
* object's: the module and the entity come from the registered schedule, the cutoff from this
|
|
3543
|
+
* object's clock and the entity's declared horizon, the ids from its own bin — and each purge
|
|
3544
|
+
* re-checks the cutoff inside its transaction (`refuseTrashedTarget`). Purge authority lives
|
|
3545
|
+
* here and nowhere else: `invoke` takes no purge argument, so a stub holding this namespace
|
|
3546
|
+
* cannot hand the system principal its purge-only key, and calling this early, often or from
|
|
3547
|
+
* outside the sweep can only purge what is genuinely due.
|
|
3548
|
+
*
|
|
3549
|
+
* Pinned like every system-door call (#1834): a missed pin answers `SystemDoorMoved` before
|
|
3550
|
+
* anything is read, and the coordinator's door gates again.
|
|
3551
|
+
*/
|
|
3552
|
+
async runPurgeSweep(operation, tenantId,
|
|
3553
|
+
/**
|
|
3554
|
+
* The node the purges' checks and events are stamped with. This object records no scope id of
|
|
3555
|
+
* its own, so it cannot hold this one to anything — and it routes nothing: the sweep runs on
|
|
3556
|
+
* the scope that received the call, whatever this says. A wrong one fails closed, since the
|
|
3557
|
+
* module's grant is seated on this scope's node and not on the one named.
|
|
3558
|
+
*/
|
|
3559
|
+
scopeId, systemDoorInstance) {
|
|
3560
|
+
await this.ensureMigrations();
|
|
3561
|
+
const schedule = this.purgeSchedules.get(operation);
|
|
3562
|
+
if (!schedule)
|
|
3563
|
+
throw toRpcError(substratError('not_found', `no purge schedule runs ${operation} on this scope`));
|
|
3564
|
+
try {
|
|
3565
|
+
this.assertSystemDoor(schedule.moduleId, systemDoorInstance);
|
|
3566
|
+
}
|
|
3567
|
+
catch (err) {
|
|
3568
|
+
if (err instanceof SystemDoorMovedError)
|
|
3569
|
+
return SYSTEM_DOOR_MOVED;
|
|
3570
|
+
throw toRpcError(err);
|
|
3571
|
+
}
|
|
3572
|
+
await this.pruneContinuationPositions();
|
|
3573
|
+
// #119: the gate the coordinator applies before any schedule fires, applied here from what
|
|
3574
|
+
// this scope records of it (`purgeGateFacts`). A foreign tenant throws.
|
|
3575
|
+
const held = purgeHeldBy(this.purgeGateFacts(schedule.moduleId, tenantId));
|
|
3576
|
+
if (held !== null)
|
|
3577
|
+
return heldPurgePass(held);
|
|
3578
|
+
const due = purgeDueOf(doSpineSql(this.sql), this.statePlans, this.operationTarget, operation, schedule.entityType, new Date().toISOString(), PURGE_BATCH);
|
|
3579
|
+
return runPurgePass(operation, due, async (entityId) => {
|
|
3580
|
+
await this.#invokeOrThrow(operation, { [due.idFrom]: entityId }, schedule.moduleId, tenantId, scopeId, undefined, undefined, schedule.moduleId, { invocationId: ulid() }, undefined, undefined, undefined, systemDoorInstance, true);
|
|
3581
|
+
}, (write) => write(doSpineSql(this.sql)));
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* #119: the purge gate's facts (`PurgeGateFacts`), from this scope's own storage: the module's
|
|
3585
|
+
* switch, the lifecycle the platform delivered (#1713), the copy classification (#2009) and the
|
|
3586
|
+
* tenant receipt (`tenantVerdict` — `unknown` passes, as at every door). A directory-backed
|
|
3587
|
+
* deployment records no receipt and delivers neither a lifecycle nor a copy classification here,
|
|
3588
|
+
* so on one those read clear: this object enforces the kill switch, and the coordinator's gate
|
|
3589
|
+
* is the authority for tenant, lifecycle, copy and the rewind hold.
|
|
3590
|
+
*/
|
|
3591
|
+
purgeGateFacts(moduleId, tenantId) {
|
|
3592
|
+
const now = new Date().toISOString();
|
|
3593
|
+
return {
|
|
3594
|
+
switched: systemScheduleState(this.switchSql(), moduleId, now),
|
|
3595
|
+
lifecycle: lifecycleRefusal(readLifecycle(this.switchSql())),
|
|
3596
|
+
copy: this.isCopy(),
|
|
3597
|
+
foreignTenant: this.tenantVerdict(tenantId).verdict === 'foreign',
|
|
3598
|
+
};
|
|
3599
|
+
}
|
|
3251
3600
|
/**
|
|
3252
3601
|
* The last time a schedule's operation ran on this scope (#383), or null.
|
|
3253
3602
|
*
|
|
@@ -3588,18 +3937,55 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3588
3937
|
this.migrationPromise = this.applyPendingMigrations();
|
|
3589
3938
|
return this.migrationPromise;
|
|
3590
3939
|
}
|
|
3940
|
+
/** Every registered module's derivation plans — what `repairDerivedObjects` and its checks read. */
|
|
3941
|
+
derivedPlans() {
|
|
3942
|
+
return { state: this.statePlans, lists: this.listPlans, search: this.searchPlans };
|
|
3943
|
+
}
|
|
3944
|
+
/** A multi-statement script on this DO's own handle, one statement per exec. */
|
|
3945
|
+
runScript(ddl) {
|
|
3946
|
+
for (const stmt of executableSqlStatements(ddl))
|
|
3947
|
+
this.sql.exec(stmt);
|
|
3948
|
+
}
|
|
3949
|
+
/**
|
|
3950
|
+
* #2090: the repair a pass with nothing pending runs, in a transaction of its own. A lost
|
|
3951
|
+
* state column fails the scope closed like a failed migration, named by the journaled
|
|
3952
|
+
* migration that added the column — the pure host's `repairDerived`.
|
|
3953
|
+
*/
|
|
3954
|
+
async repairDerived() {
|
|
3955
|
+
try {
|
|
3956
|
+
await this.revision.transaction(async () => {
|
|
3957
|
+
this.revisionSuspended = true;
|
|
3958
|
+
try {
|
|
3959
|
+
repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
|
|
3960
|
+
after: 'an applied migration',
|
|
3961
|
+
});
|
|
3962
|
+
}
|
|
3963
|
+
finally {
|
|
3964
|
+
this.revisionSuspended = false;
|
|
3965
|
+
}
|
|
3966
|
+
});
|
|
3967
|
+
}
|
|
3968
|
+
catch (err) {
|
|
3969
|
+
const version = err instanceof StateColumnLost ? err.migration : 'kernel@derived-objects';
|
|
3970
|
+
this.lastFailure = { version, error: err.message };
|
|
3971
|
+
throw migrationFailedError(version, err.message);
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3591
3974
|
/** Resolves true if this call applied at least one migration. */
|
|
3592
3975
|
async applyPendingMigrations() {
|
|
3593
|
-
const pending =
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
}
|
|
3599
|
-
}
|
|
3976
|
+
const { pending, diverged } = await planMigrations(this.modules.values(), this.applied);
|
|
3977
|
+
if (diverged) {
|
|
3978
|
+
// Recorded as a failed migration is, so the coordinator projects it the same way.
|
|
3979
|
+
this.lastFailure = diverged;
|
|
3980
|
+
throw migrationFailedError(diverged.version, diverged.error);
|
|
3600
3981
|
}
|
|
3601
|
-
if (pending.length === 0)
|
|
3982
|
+
if (pending.length === 0) {
|
|
3983
|
+
// #2090: once per pass — so once per wake, and on every `retryMigrations` — check and
|
|
3984
|
+
// repair what the kernel derived onto the tables. Their migrations are journaled and will
|
|
3985
|
+
// never run again, so this is what reaches a scope stripped before the pass checked.
|
|
3986
|
+
await this.queue.enqueue(async () => this.repairDerived());
|
|
3602
3987
|
return false;
|
|
3988
|
+
}
|
|
3603
3989
|
this.migrationRuns += 1;
|
|
3604
3990
|
await this.queue.enqueue(async () => {
|
|
3605
3991
|
// #286: bookmark the instant before an UPGRADE migrates live data — the
|
|
@@ -3622,34 +4008,53 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3622
4008
|
// (#278) remains the fallback rewind point.
|
|
3623
4009
|
}
|
|
3624
4010
|
}
|
|
3625
|
-
for (const { moduleId, migration } of pending) {
|
|
4011
|
+
for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
|
|
3626
4012
|
const key = `${moduleId}@${migration.version}`;
|
|
3627
4013
|
if (this.applied.has(key))
|
|
3628
4014
|
continue;
|
|
4015
|
+
let recorded = digest;
|
|
3629
4016
|
try {
|
|
3630
4017
|
await this.revision.transaction(async () => {
|
|
3631
4018
|
const already = this.sql
|
|
3632
|
-
.exec('SELECT
|
|
4019
|
+
.exec('SELECT sql_digest FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
|
|
3633
4020
|
.toArray()[0];
|
|
3634
|
-
if (
|
|
4021
|
+
if (already) {
|
|
4022
|
+
// Applied since this pass read the journal: held to the same digest rule.
|
|
4023
|
+
const error = migrationDivergence(already.sql_digest, digest, authored);
|
|
4024
|
+
if (error)
|
|
4025
|
+
throw new Error(error);
|
|
4026
|
+
recorded = already.sql_digest;
|
|
4027
|
+
}
|
|
4028
|
+
else {
|
|
3635
4029
|
const started = performance.now();
|
|
3636
4030
|
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
|
-
|
|
4031
|
+
// #1898, #2066: a migration runs on this DO's own handle, not `ctx.sql`, so the
|
|
4032
|
+
// spine rules a migration is held to are applied here.
|
|
4033
|
+
assertMigrationSql(migration.sql, { key, digest, authored });
|
|
4034
|
+
// #2068: the ownership ledger is the kernel's — no migration may name it, not even to
|
|
4035
|
+
// read it (the spine rules above allow reads).
|
|
4036
|
+
assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
|
|
3640
4037
|
// #1722: not counted per statement, so `total_changes()` measures the migration
|
|
3641
4038
|
// alone. The journal row below is a write, and advances the revision once.
|
|
4039
|
+
// #2068: run one statement at a time, keeping the table set either side of each.
|
|
4040
|
+
let steps;
|
|
3642
4041
|
this.revisionSuspended = true;
|
|
3643
4042
|
try {
|
|
3644
|
-
|
|
4043
|
+
steps = runMigrationStatements(doSpineSql(this.sql), migration.sql, (stmt) => {
|
|
3645
4044
|
this.sql.exec(stmt);
|
|
3646
|
-
}
|
|
4045
|
+
});
|
|
4046
|
+
// #2090: the state columns this migration must have left, and after the last of
|
|
4047
|
+
// the pass, the triggers and indexes a create-copy-rename rebuild dropped.
|
|
4048
|
+
const last = i === pending.length - 1;
|
|
4049
|
+
afterMigration(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), key, last);
|
|
3647
4050
|
}
|
|
3648
4051
|
finally {
|
|
3649
4052
|
this.revisionSuspended = false;
|
|
3650
4053
|
}
|
|
3651
4054
|
const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
|
|
3652
|
-
|
|
4055
|
+
// After the count: `rows_changed` is the migration's own, never the kernel's bookkeeping.
|
|
4056
|
+
recordOwnershipSteps(doSpineSql(this.sql), moduleId, steps, new Date().toISOString());
|
|
4057
|
+
this.sql.exec('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed, sql_digest) VALUES (?, ?, ?, ?, ?, ?)', moduleId, migration.version, new Date().toISOString(), Math.max(0, Math.round(performance.now() - started)), after - before, digest);
|
|
3653
4058
|
}
|
|
3654
4059
|
});
|
|
3655
4060
|
}
|
|
@@ -3663,9 +4068,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3663
4068
|
// It is not an unhandled rejection: every caller awaits the memoised promise, and
|
|
3664
4069
|
// the coordinator records the failure it receives (#1898 review).
|
|
3665
4070
|
this.lastFailure = { version: key, error: err.message };
|
|
3666
|
-
throw
|
|
4071
|
+
throw migrationFailedError(key, err.message);
|
|
3667
4072
|
}
|
|
3668
|
-
this.applied.
|
|
4073
|
+
this.applied.set(key, recorded);
|
|
3669
4074
|
}
|
|
3670
4075
|
});
|
|
3671
4076
|
return true;
|
|
@@ -3904,6 +4309,30 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3904
4309
|
attempts = attempts + 1, settled_at = ?
|
|
3905
4310
|
WHERE id = ? AND status = 'pending'`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
|
|
3906
4311
|
}
|
|
4312
|
+
/**
|
|
4313
|
+
* A settle that writes an outcome event (#2102): the kernel's one settle — the same
|
|
4314
|
+
* compare-and-set as the verb above — and the event in its transaction, written only when
|
|
4315
|
+
* this settle moved the row out of `pending`. Then the event reaches this scope's consumers,
|
|
4316
|
+
* as any committed write's does.
|
|
4317
|
+
*
|
|
4318
|
+
* A verb of its own rather than an argument on the one above, so a settle carrying no event
|
|
4319
|
+
* keeps reaching a DO class older than this change; only the new kind's settle needs it.
|
|
4320
|
+
*/
|
|
4321
|
+
async settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome) {
|
|
4322
|
+
await this.ensureMigrations();
|
|
4323
|
+
return this.queue.enqueue(async () => {
|
|
4324
|
+
const liveSince = this.liveHighWaterMark();
|
|
4325
|
+
this.revision.transactionSync(() => settlePlatformRequestIn(this.switchSql(), id, outcome, {
|
|
4326
|
+
tenantId,
|
|
4327
|
+
scopeId,
|
|
4328
|
+
now: new Date().toISOString(),
|
|
4329
|
+
mintEventId: (ms) => this.mintEventId(ms),
|
|
4330
|
+
version: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
4331
|
+
}));
|
|
4332
|
+
// #1525: null — a settle is the platform's call, not one a vertical's caller made.
|
|
4333
|
+
await this.settleCommitted(tenantId, scopeId, liveSince, null);
|
|
4334
|
+
});
|
|
4335
|
+
}
|
|
3907
4336
|
/**
|
|
3908
4337
|
* Turn one connector delivery into a `connector:<provider>` platform intent (#574
|
|
3909
4338
|
* phase 3) — the CP-less host's substitute for running the handler it cannot run.
|
|
@@ -3997,7 +4426,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3997
4426
|
/** Every table in this scope's DB, with row counts; spine/internal tables flagged. */
|
|
3998
4427
|
introspectTables() {
|
|
3999
4428
|
const names = this.sql
|
|
4000
|
-
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
|
4429
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*' ORDER BY name`)
|
|
4001
4430
|
.toArray().map((r) => r.name);
|
|
4002
4431
|
return names.map((name) => ({
|
|
4003
4432
|
name,
|
|
@@ -4021,7 +4450,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4021
4450
|
}
|
|
4022
4451
|
tablePage(table, limit, offset) {
|
|
4023
4452
|
const known = new Set(this.sql
|
|
4024
|
-
.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
|
4453
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*'`)
|
|
4025
4454
|
.toArray().map((r) => r.name));
|
|
4026
4455
|
if (!known.has(table))
|
|
4027
4456
|
throw substratError('not_found', `unknown table '${table}'`);
|
|
@@ -4059,6 +4488,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4059
4488
|
}
|
|
4060
4489
|
async readOnlyQuery(sql) {
|
|
4061
4490
|
const stmt = assertReadOnlyQuery(sql);
|
|
4491
|
+
assertNoWorkerdStorage(stmt);
|
|
4062
4492
|
let result;
|
|
4063
4493
|
const rollback = new Error('read-only console rollback');
|
|
4064
4494
|
try {
|
|
@@ -4136,7 +4566,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4136
4566
|
// carries the rows, never the index over them.
|
|
4137
4567
|
const defs = this.sql
|
|
4138
4568
|
.exec(`SELECT name, sql FROM sqlite_master
|
|
4139
|
-
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND sql IS NOT NULL
|
|
4569
|
+
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' AND sql IS NOT NULL
|
|
4140
4570
|
ORDER BY name`)
|
|
4141
4571
|
.toArray().filter(({ name }) => !isSearchIndexTable(name));
|
|
4142
4572
|
return defs.map(({ name, sql }) => {
|
|
@@ -4199,6 +4629,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4199
4629
|
// #1763: rows written before these fields keep NULL, meaning unrecorded.
|
|
4200
4630
|
'ALTER TABLE _substrat_migrations ADD COLUMN duration_ms INTEGER',
|
|
4201
4631
|
'ALTER TABLE _substrat_migrations ADD COLUMN rows_changed INTEGER',
|
|
4632
|
+
// #2066: the rows already there get the legacy mark below, never a digest.
|
|
4633
|
+
'ALTER TABLE _substrat_migrations ADD COLUMN sql_digest TEXT',
|
|
4202
4634
|
'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
|
|
4203
4635
|
// #1632: legacy runs retain an unknown subject; no content-based backfill.
|
|
4204
4636
|
'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
|
|
@@ -4255,6 +4687,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4255
4687
|
// every legacy row — a past run's id cannot be recovered afterwards, exactly
|
|
4256
4688
|
// as the other #1525 columns above argued.
|
|
4257
4689
|
'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
|
|
4690
|
+
// #2096: a purge horizon's lap in progress, on a scope DO built before the column. NULL is
|
|
4691
|
+
// the start of a lap — the honest reading of every row already there.
|
|
4692
|
+
'ALTER TABLE _substrat_schedule_state ADD COLUMN purge_cursor TEXT',
|
|
4258
4693
|
// #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
|
|
4259
4694
|
// see `COPY_ORIGIN_DDL`).
|
|
4260
4695
|
'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
|
|
@@ -4280,6 +4715,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4280
4715
|
// boot. `lint:spine-ddl` compares KERNEL_DDL's indexes only, so this one is held to
|
|
4281
4716
|
// both adapters by the query-plan test rather than by that gate.
|
|
4282
4717
|
this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
|
|
4718
|
+
this.sql.exec(GRANT_CHILDREN_INDEX_DDL);
|
|
4719
|
+
// #2066: what they ran was never measured. KERNEL_DDL's fence keeps any other NULL out.
|
|
4720
|
+
this.sql.exec(MIGRATION_DIGEST_MARK_LEGACY);
|
|
4283
4721
|
this.ensureScheduleStateKind();
|
|
4284
4722
|
this.ensureRefusalsAdmitGuards();
|
|
4285
4723
|
}
|
|
@@ -4320,7 +4758,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4320
4758
|
// synchronous, which is the one case the sync API is for (it commits at the
|
|
4321
4759
|
// first await, and there is none). It also has to be sync because the caller is.
|
|
4322
4760
|
this.revision.transactionSync(() => {
|
|
4323
|
-
for (const stmt of
|
|
4761
|
+
for (const stmt of executableSqlStatements(script))
|
|
4324
4762
|
this.sql.exec(stmt);
|
|
4325
4763
|
});
|
|
4326
4764
|
}
|
|
@@ -4397,7 +4835,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4397
4835
|
this.sql.exec('PRAGMA defer_foreign_keys = ON');
|
|
4398
4836
|
// Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
|
|
4399
4837
|
const existing = this.sql
|
|
4400
|
-
|
|
4838
|
+
// Nor workerd's own `_cf_*` (the live reaper's alarm keeps `_cf_METADATA`), which
|
|
4839
|
+
// it refuses to drop.
|
|
4840
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'`)
|
|
4401
4841
|
.toArray();
|
|
4402
4842
|
// Search index tables are left alone here and rebuilt below (#827): dropping a
|
|
4403
4843
|
// shadow table directly is an error, and `sqlite_master` order would reach one
|
|
@@ -4412,9 +4852,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4412
4852
|
// decide how this DO's permission checks match. Every `_substrat_*` table is built
|
|
4413
4853
|
// from KERNEL_DDL instead, and the dump contributes only rows, by column name
|
|
4414
4854
|
// (`spineRowsInsert`), a missing column taking the kernel's default.
|
|
4855
|
+
// Comment-blanked (#2068): a dump records `sqlite_master.sql` verbatim, comments included,
|
|
4856
|
+
// and replaying it raw would store them again — and a later DROP COLUMN of the last
|
|
4857
|
+
// column fails on workerd. The validated statement is otherwise executed exactly as is.
|
|
4415
4858
|
for (const t of replayable)
|
|
4416
4859
|
if (!isSpineTable(t.name))
|
|
4417
|
-
this.sql.exec(t.ddl);
|
|
4860
|
+
this.sql.exec(blankSqlComments(t.ddl));
|
|
4418
4861
|
// KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
|
|
4419
4862
|
// WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
|
|
4420
4863
|
// `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
|
|
@@ -4424,11 +4867,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4424
4867
|
// restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
|
|
4425
4868
|
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
4426
4869
|
// leaves to it.
|
|
4427
|
-
for (const stmt of
|
|
4870
|
+
for (const stmt of executableSqlStatements(KERNEL_DDL))
|
|
4428
4871
|
this.sql.exec(stmt);
|
|
4429
4872
|
this.applySpineColumnAdditions();
|
|
4430
4873
|
const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
|
|
4431
4874
|
assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
|
|
4875
|
+
assertJournalDumpCoherent(replayable);
|
|
4432
4876
|
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
4433
4877
|
// untyped column the checker never reads.
|
|
4434
4878
|
for (const t of replayable) {
|
|
@@ -4479,6 +4923,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4479
4923
|
if (destScopeId) {
|
|
4480
4924
|
this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
|
|
4481
4925
|
}
|
|
4926
|
+
// #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
|
|
4927
|
+
// sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows
|
|
4928
|
+
// that are gone. Repaired, and the search index rebuilt, AFTER the rows — which may
|
|
4929
|
+
// legitimately arrive archived or trashed — as the dump's own journal says they were
|
|
4930
|
+
// derived (#2090). Inside the replay's transaction: a dump whose journal owes a table a
|
|
4931
|
+
// state column it does not carry rolls the whole load back. A plan whose table this dump
|
|
4932
|
+
// did not carry is skipped: a restore must not invent one.
|
|
4933
|
+
repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
|
|
4934
|
+
after: 'the restored dump',
|
|
4935
|
+
absentTable: 'skip',
|
|
4936
|
+
});
|
|
4482
4937
|
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
4483
4938
|
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
4484
4939
|
// the dump's grants never commit live without the switch that should cover them.
|
|
@@ -4491,37 +4946,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4491
4946
|
}
|
|
4492
4947
|
});
|
|
4493
4948
|
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
4949
|
// #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
|
|
4526
4950
|
// as on a wake. A copy's own events then sort above every copied one, which is what
|
|
4527
4951
|
// `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
|
|
@@ -4536,12 +4960,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4536
4960
|
}
|
|
4537
4961
|
// The frontier arrived with the dump — refresh the in-memory applied set so a
|
|
4538
4962
|
// 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
|
-
}
|
|
4963
|
+
this.applied = readAppliedMigrations(this.sql);
|
|
4545
4964
|
// …and forget that this INSTANCE ever ran a migration pass (#1589). Refreshing
|
|
4546
4965
|
// the set above is not enough on its own: `ensureMigrations` memoises its
|
|
4547
4966
|
// promise, so a warm DO answers "already migrated" from the cache and never
|
|
@@ -4565,6 +4984,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4565
4984
|
// is complete applies nothing, so there is nothing to report either way.
|
|
4566
4985
|
this.migrationPromise = undefined;
|
|
4567
4986
|
this.lastFailure = null;
|
|
4987
|
+
// The KV key never travels in a SQL dump. A load into this same DO would
|
|
4988
|
+
// otherwise retain its old epoch, so explicitly invalidate all old tokens.
|
|
4989
|
+
await this.ctx.storage.delete('continuation:keys');
|
|
4990
|
+
const oldPositions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
|
|
4991
|
+
for (const name of oldPositions.keys())
|
|
4992
|
+
await this.ctx.storage.delete(name);
|
|
4568
4993
|
return switched;
|
|
4569
4994
|
}
|
|
4570
4995
|
/**
|
|
@@ -4801,9 +5226,41 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4801
5226
|
* outranks their Article 17 right.
|
|
4802
5227
|
*/
|
|
4803
5228
|
async redactSubject(subjectId) {
|
|
5229
|
+
// #2068: the module half reaches the scope's own tables, so they have to exist.
|
|
5230
|
+
await this.ensureMigrations();
|
|
4804
5231
|
// One instant for the whole erasure — the intent tombstones must not disagree with
|
|
4805
5232
|
// each other about when a person was erased.
|
|
4806
5233
|
const at = new Date().toISOString();
|
|
5234
|
+
// ONE transaction (#2068): a module's `onSubjectErased` hook that throws rolls the
|
|
5235
|
+
// whole redaction back, the spine half with it, and the coordinator — which destroys
|
|
5236
|
+
// the key only after this returns — never reaches the key.
|
|
5237
|
+
//
|
|
5238
|
+
// Its refusal is answered as DATA (`toWireFailure`, #113): workerd delivers a throw across
|
|
5239
|
+
// this boundary as its message alone, and a hook's `forbidden` (it reached past its own
|
|
5240
|
+
// tables) is a different answer from an `internal`. In this method's own reply rather than
|
|
5241
|
+
// a `…Reply` sibling, so every coordinator — old ones too — keeps calling one verb: an old
|
|
5242
|
+
// one reads a failure as a reply missing its counts and refuses before the key, which is
|
|
5243
|
+
// the safe reading.
|
|
5244
|
+
try {
|
|
5245
|
+
return this.revision.transactionSync(() => this.redactSubjectInTransaction(subjectId, at));
|
|
5246
|
+
}
|
|
5247
|
+
catch (err) {
|
|
5248
|
+
return { failure: toWireFailure(err) };
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5251
|
+
/** The body of `redactSubject`, inside its transaction. */
|
|
5252
|
+
redactSubjectInTransaction(subjectId, at) {
|
|
5253
|
+
// The module half first (#2068): the declared entities, then each hook, with the search
|
|
5254
|
+
// indexes over them under FTS5 secure-delete. The spine half follows in the same transaction.
|
|
5255
|
+
const vertical = eraseSubjectFromModules({
|
|
5256
|
+
sql: doSpineSql(this.sql),
|
|
5257
|
+
plans: this.erasurePlans,
|
|
5258
|
+
searchPlans: this.searchPlans.values(),
|
|
5259
|
+
statefulTables: statefulTablesOf(this.statePlans),
|
|
5260
|
+
subjectId,
|
|
5261
|
+
at,
|
|
5262
|
+
migrationSqlOf: (moduleId, version) => this.modules.get(moduleId)?.migrations.find((m) => m.version === version)?.sql,
|
|
5263
|
+
});
|
|
4807
5264
|
const doomed = this.sql
|
|
4808
5265
|
.exec(`SELECT id FROM _substrat_outbox
|
|
4809
5266
|
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`, subjectId)
|
|
@@ -4824,6 +5281,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4824
5281
|
// The free-text copies (#1632), and the tombstoned intents the coordinator hands to
|
|
4825
5282
|
// the directory half. Last, so those ids include every intent tombstoned above.
|
|
4826
5283
|
...redactSubjectScopeText(sql, subjectId, at),
|
|
5284
|
+
vertical,
|
|
4827
5285
|
};
|
|
4828
5286
|
}
|
|
4829
5287
|
/**
|
|
@@ -5065,7 +5523,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5065
5523
|
*/
|
|
5066
5524
|
peerSubject,
|
|
5067
5525
|
/** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
|
|
5068
|
-
delivery
|
|
5526
|
+
delivery,
|
|
5527
|
+
/** #119: keys this context's checks refuse whatever the subject holds (`purgeOnlyKeysOf`). */
|
|
5528
|
+
withheld) {
|
|
5069
5529
|
const checker = this.checker;
|
|
5070
5530
|
const relations = this.relations;
|
|
5071
5531
|
const searchPlans = this.searchPlans;
|
|
@@ -5126,6 +5586,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5126
5586
|
// #1642: parsed before the system actor's early return, which never reaches
|
|
5127
5587
|
// the checker — a cast key would otherwise become that path's proof relation.
|
|
5128
5588
|
const permission = assertPermissionKey(unparsed);
|
|
5589
|
+
if (withheld?.has(permission)) {
|
|
5590
|
+
return { allowed: false, checked: permission, node: { tenantId, scopeId } };
|
|
5591
|
+
}
|
|
5129
5592
|
if (systemActor) {
|
|
5130
5593
|
return {
|
|
5131
5594
|
allowed: true,
|
|
@@ -5206,10 +5669,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5206
5669
|
// #1672: a capability's own id stands in so the type holds — it is not a person, and
|
|
5207
5670
|
// the event actor says what it is instead. Every other door passes its own value.
|
|
5208
5671
|
principal: capabilityId ? capabilityId : principal,
|
|
5672
|
+
pageContinuation: (list, query) => visibleContinuation(this.continuationStore(impersonation?.mode !== 'read-only' && !this.isCopy()), { scopeId, principal: `${subject.kind}:${subject.id}`, operation: operation ?? 'kernel', list, query }, Date.now, () => ctxRef.log.info('legacy filtered-list cursor accepted', { list }), impersonation?.mode !== 'read-only' && !this.isCopy()),
|
|
5209
5673
|
sql: guardSecrets(doScopedSql(sql, statefulTablesOf(statePlans),
|
|
5210
|
-
// #119: after runtime DDL,
|
|
5211
|
-
|
|
5212
|
-
|
|
5674
|
+
// #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
|
|
5675
|
+
// derived onto any table is repaired — and a lost state column fails the operation.
|
|
5676
|
+
derivesAnything(this.derivedPlans())
|
|
5677
|
+
? () => afterRuntimeDdl(doSpineSql(sql), (ddl) => this.runScript(ddl), this.derivedPlans())
|
|
5213
5678
|
: undefined), minted),
|
|
5214
5679
|
now: () => at,
|
|
5215
5680
|
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
@@ -5268,6 +5733,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5268
5733
|
return entityVersionOf(sql.exec(q.sql, ...q.params).toArray());
|
|
5269
5734
|
},
|
|
5270
5735
|
check: runCheck,
|
|
5736
|
+
grantedEntities: async (unparsed, entityType, options) => {
|
|
5737
|
+
const permission = assertPermissionKey(unparsed);
|
|
5738
|
+
return grantedEntitiesForContext(checker, subject, permission, { tenantId, scopeId }, entityType, options, runCheck, withheld, Boolean(systemActor));
|
|
5739
|
+
},
|
|
5271
5740
|
canAssign: runCanAssign,
|
|
5272
5741
|
// #827. Mirror of the pure adapter: the plan comes from registration, the
|
|
5273
5742
|
// rows from this DO's own SQLite, and the index is maintained by triggers
|
|
@@ -5303,15 +5772,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5303
5772
|
view: uncheckedView('ctx.page', entityType, params.view),
|
|
5304
5773
|
});
|
|
5305
5774
|
const rows = sql.exec(q.sql, ...q.params).toArray();
|
|
5306
|
-
|
|
5307
|
-
const
|
|
5308
|
-
const page = { entries: rows, nextCursor };
|
|
5775
|
+
// `pageOf`'s rule, and each row's own cursor when asked (#2073).
|
|
5776
|
+
const mint = (row) => cursorOf(row, q.sortColumn, plan.idColumn, q.order, q.view);
|
|
5309
5777
|
if (!params.total)
|
|
5310
|
-
return
|
|
5778
|
+
return pageOf(rows, limit, mint, params.rowCursors);
|
|
5311
5779
|
const counted = sql
|
|
5312
5780
|
.exec(q.countSql, ...q.countParams)
|
|
5313
5781
|
.toArray();
|
|
5314
|
-
return
|
|
5782
|
+
return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
|
|
5315
5783
|
},
|
|
5316
5784
|
/**
|
|
5317
5785
|
* Delegate a permission this caller holds onto one entity — see the
|
|
@@ -5329,7 +5797,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5329
5797
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
5330
5798
|
'the caller does not hold it there (a grant delegates, it never elevates)');
|
|
5331
5799
|
}
|
|
5332
|
-
|
|
5800
|
+
// #2071: an explicit grant, so it clears a tombstone `revoke` left.
|
|
5801
|
+
writeExplicitTupleIn(this.switchSql(), `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, { kind: 'delegated' });
|
|
5333
5802
|
},
|
|
5334
5803
|
/**
|
|
5335
5804
|
* Deliberately NOT the #1856 grammar check `grant` and `link` make: a revoke writes
|
|
@@ -5343,7 +5812,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5343
5812
|
throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
5344
5813
|
'the caller does not hold it there');
|
|
5345
5814
|
}
|
|
5346
|
-
|
|
5815
|
+
// K-21 (#2071): a tombstone, never a delete — a declared shape's top-up must be
|
|
5816
|
+
// able to tell a key taken back from one never held.
|
|
5817
|
+
const r = delegatedRevokeSql(principal, permission, `${entity.entityType}:${entity.entityId}`, at);
|
|
5818
|
+
sql.exec(r.sql, ...r.params);
|
|
5347
5819
|
},
|
|
5348
5820
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
5349
5821
|
// #1672: mint / revoke / list, written once in the kernel — the pure adapter hands
|
|
@@ -5369,6 +5841,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5369
5841
|
now: at,
|
|
5370
5842
|
emit: (event) => writeEvent(event, 'kernel'),
|
|
5371
5843
|
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
5844
|
+
isUnreachableParent: (entity) => isUnreachableParent(doSpineSql(sql), statePlans, entity),
|
|
5372
5845
|
}),
|
|
5373
5846
|
// #119: archive and trash — the pure adapter's wiring, over the raw spine seam.
|
|
5374
5847
|
...createEntityStateVerbs({
|
|
@@ -5385,6 +5858,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5385
5858
|
searchPlans,
|
|
5386
5859
|
statePlans,
|
|
5387
5860
|
check: runCheck,
|
|
5861
|
+
continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
|
|
5388
5862
|
}),
|
|
5389
5863
|
entitlement: async (key) => {
|
|
5390
5864
|
const held = await entitlementReader().listEntitlements(tenantId);
|
|
@@ -5427,6 +5901,29 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5427
5901
|
.toArray()[0];
|
|
5428
5902
|
return row?.value === 'local' ? 'local' : 'control-plane';
|
|
5429
5903
|
}
|
|
5904
|
+
/**
|
|
5905
|
+
* Whether a check here reads the directory over RPC (#938, Codex #2077 r5): the same
|
|
5906
|
+
* choice `controlPlaneReader` makes per call, asked each time a live fan-out pass opens an epoch.
|
|
5907
|
+
*/
|
|
5908
|
+
permissionSourceIsRemote() {
|
|
5909
|
+
return this.permissionSource() !== 'local' && Boolean(this.env.CONTROL_PLANE);
|
|
5910
|
+
}
|
|
5911
|
+
/**
|
|
5912
|
+
* The earliest `expires_at` after `now` in any local store a check or the parent walk
|
|
5913
|
+
* reads (#938, Codex #2077 r5), or null when nothing here lapses. Until then, no grant,
|
|
5914
|
+
* tenant tuple, parent edge or entitlement this scope holds can stop authorizing without
|
|
5915
|
+
* a write. Revoked rows are not excluded: an earlier bound only costs a re-check.
|
|
5916
|
+
*/
|
|
5917
|
+
authorityLapsesAfter(now) {
|
|
5918
|
+
const row = this.sql
|
|
5919
|
+
.exec(`SELECT MIN(e) AS until FROM (
|
|
5920
|
+
SELECT MIN(expires_at) AS e FROM _substrat_tuples WHERE expires_at > ?
|
|
5921
|
+
UNION ALL SELECT MIN(expires_at) FROM _substrat_tenant_tuples WHERE expires_at > ?
|
|
5922
|
+
UNION ALL SELECT MIN(expires_at) FROM _substrat_entitlements WHERE expires_at > ?
|
|
5923
|
+
)`, now, now, now)
|
|
5924
|
+
.toArray()[0];
|
|
5925
|
+
return row?.until ?? null;
|
|
5926
|
+
}
|
|
5430
5927
|
/**
|
|
5431
5928
|
* The checker's tenant-tuple/role reader. Chosen **per call** (the source can
|
|
5432
5929
|
* flip at runtime, and the checker holds this wrapper for the DO's lifetime):
|