@substrat-run/adapter-cloudflare 0.153.0 → 0.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/control-plane-do.d.ts +3 -1
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +7 -2
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +75 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +153 -8
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +10 -2
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +428 -30
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/scope-do.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
-
import { type SwitchSql, type ModuleRegistration, type OperationHandler } from '@substrat-run/kernel';
|
|
2
|
+
import { type DeclaredPermissions, type SwitchSql, type ModuleRegistration, type OperationHandler } from '@substrat-run/kernel';
|
|
3
3
|
import { type DoReply } from './do-reply.js';
|
|
4
4
|
import { splitSqlStatements } from '@substrat-run/kernel';
|
|
5
5
|
/**
|
|
@@ -81,5 +81,13 @@ export type CapabilityAttachmentReply<T> = DoReply<T>;
|
|
|
81
81
|
export declare function switchSqlOver(sql: SqlStorage): SwitchSql;
|
|
82
82
|
/** The splitter both adapters run migrations through — the kernel's, re-exported for this package's callers. */
|
|
83
83
|
export { splitSqlStatements };
|
|
84
|
-
export declare function defineScopeDO(modules: ModuleRegistration[], bareOps: Record<string, OperationHandler<never, unknown
|
|
84
|
+
export declare function defineScopeDO(modules: ModuleRegistration[], bareOps: Record<string, OperationHandler<never, unknown>>,
|
|
85
|
+
/**
|
|
86
|
+
* #2430: `permissions` is the vertical's `definePermissions(...)` value, the one `substrat push`
|
|
87
|
+
* and `lint:permissions` read. Given, a module in `modules` that its list leaves out is refused
|
|
88
|
+
* when the class is defined, as `registerModule` refuses one on either host.
|
|
89
|
+
*/
|
|
90
|
+
options?: {
|
|
91
|
+
permissions?: DeclaredPermissions;
|
|
92
|
+
}): new (ctx: DurableObjectState, env: ScopeDoEnv) => DurableObject<ScopeDoEnv>;
|
|
85
93
|
//# sourceMappingURL=scope-do.d.ts.map
|
package/dist/scope-do.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAgEnD,OAAO,EAwDL,KAAK,mBAAmB,EASxB,KAAK,SAAS,EAsBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAkHtB,MAAM,sBAAsB,CAAC;AAoD9B,OAAO,EAAW,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAGtD,OAAO,EAwBL,kBAAkB,EAgFnB,MAAM,sBAAsB,CAAC;AA2B9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AAoFD,oFAAoF;AACpF,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAmrB5E;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;AAmBtD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,gHAAgH;AAChH,OAAO,EAAE,kBAAkB,EAAE,CAAC;AA2B9B,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;AACzD;;;;GAIG;AACH,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,mBAAmB,CAAA;CAAO,GAClD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAAC,UAAU,CAAC,CAqoP7E"}
|
package/dist/scope-do.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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, principalBecomeCapabilityInput, 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, 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, migrationSetDivergence, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, 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';
|
|
4
|
+
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, principalBecomeCapabilityInput, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_DUMP_PAGE_MAX_BYTES, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, pageOf, countedPageOf, 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, 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, assertModuleDeclared, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, 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, migrationSetDivergence, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, 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
6
|
import { isUpgradeRequest, readSubscription, LIVE_DECIDE_ATTEMPTS, LIVE_FANOUT_LIMIT, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, LIVE_CLOSE, LIVE_SOCKETS_PER_PRINCIPAL, decodeLiveWithin, liveInstant, liveRefusal, liveRefusalStatus, } from './live-reads.js';
|
|
7
7
|
import { replyOf } from './do-reply.js';
|
|
8
8
|
import { OperationQueue } from './serialization.js';
|
|
@@ -11,7 +11,8 @@ import { actorOf, admitPeer, boundToPeerDeclarations, collectPeers, switchPeer,
|
|
|
11
11
|
import { COPY_RESTORE_FENCE_LAPSED, copyRestoreFenceLapsed, orphanSweepVersionRefusal } from '@substrat-run/kernel';
|
|
12
12
|
import { createDoTupleChecker, createLocalControlPlaneReader, scopeTupleReader } from './checker.js';
|
|
13
13
|
import { settlePlatformRequestIn } from '@substrat-run/kernel';
|
|
14
|
-
import {
|
|
14
|
+
import { loadDropsRowsOf } from '@substrat-run/kernel';
|
|
15
|
+
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, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
|
|
15
16
|
import { GRANT_CHILDREN_INDEX_DDL, SHAPE_MARKER_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
|
|
16
17
|
/** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
|
|
17
18
|
function readAppliedMigrations(sql) {
|
|
@@ -415,6 +416,68 @@ function kernelEmit(ctx, event) {
|
|
|
415
416
|
throw new Error('kernelEmit: not an operation context this host built');
|
|
416
417
|
write(event);
|
|
417
418
|
}
|
|
419
|
+
/** #2450: a commit or a page naming a stage this store no longer holds. */
|
|
420
|
+
const STAGE_GONE = 'the staged load is not here any more (another carry replaced it, or it was already loaded); nothing was loaded';
|
|
421
|
+
/**
|
|
422
|
+
* A staged load's own tables (#2450): `_substrat_stage` (which load, and the next page it takes),
|
|
423
|
+
* `_substrat_stage_tables` (each staged table's name, DDL and columns) and one
|
|
424
|
+
* `_substrat_stage_rows_<n>` per staged table. They hold a carry's pages until the load commits
|
|
425
|
+
* from them, so they are this store's, never its data: no dump carries them, and a load's drops
|
|
426
|
+
* leave them to the commit that reads them.
|
|
427
|
+
*/
|
|
428
|
+
const STAGE_GLOB = '_substrat_stage*';
|
|
429
|
+
const STAGE_TABLES_DDL = [
|
|
430
|
+
`CREATE TABLE _substrat_stage (stage_id TEXT NOT NULL, next_seq INTEGER NOT NULL)`,
|
|
431
|
+
`CREATE TABLE _substrat_stage_tables (ord INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, ddl TEXT NOT NULL, columns TEXT NOT NULL)`,
|
|
432
|
+
];
|
|
433
|
+
const stageRowsTable = (ord) => `_substrat_stage_rows_${ord}`;
|
|
434
|
+
/** How many staged rows a commit reads at a time. */
|
|
435
|
+
const STAGE_READ_BATCH = 500;
|
|
436
|
+
/** How many rows a dump page reads from a table at a time; each batch is read to its end. */
|
|
437
|
+
const DUMP_READ_BATCH = 500;
|
|
438
|
+
/** The names under which SQLite answers a table's rowid, unless a column takes one. */
|
|
439
|
+
const ROWID_NAMES = new Set(['rowid', '_rowid_', 'oid']);
|
|
440
|
+
/**
|
|
441
|
+
* The rows of `name` a dump carries, as a WHERE clause and its bindings. #1722: the load stamp and
|
|
442
|
+
* the write revision describe THIS store, so they never leave in a dump (`exportDumpStamped` hands
|
|
443
|
+
* the stamp over beside one); every load writes its own. #2350: the orphan sweep's licence is this
|
|
444
|
+
* store's own, so its rows never leave either.
|
|
445
|
+
*/
|
|
446
|
+
function dumpRowFilter(name) {
|
|
447
|
+
if (name === '_substrat_meta') {
|
|
448
|
+
return { where: `key NOT IN (${STORE_LOCAL_META_KEYS.map(() => '?').join(', ')})`, bindings: [...STORE_LOCAL_META_KEYS] };
|
|
449
|
+
}
|
|
450
|
+
if (name === ATTACHMENT_SWEEP_LICENCE_TABLE)
|
|
451
|
+
return { where: '0', bindings: [] };
|
|
452
|
+
return { where: '1', bindings: [] };
|
|
453
|
+
}
|
|
454
|
+
/** An estimate of a row's size on the wire, for sizing a dump page (#2450). Over, never under. */
|
|
455
|
+
function rowBytes(row) {
|
|
456
|
+
let n = 0;
|
|
457
|
+
for (const cell of row) {
|
|
458
|
+
if (typeof cell === 'string')
|
|
459
|
+
n += cell.length * 3 + 2;
|
|
460
|
+
else if (cell instanceof ArrayBuffer || ArrayBuffer.isView(cell))
|
|
461
|
+
n += cell.byteLength * 4 + 2;
|
|
462
|
+
else
|
|
463
|
+
n += 24;
|
|
464
|
+
}
|
|
465
|
+
return n;
|
|
466
|
+
}
|
|
467
|
+
const encodeDumpCursor = (at) => JSON.stringify([at.table, at.after]);
|
|
468
|
+
function decodeDumpCursor(cursor) {
|
|
469
|
+
let parsed;
|
|
470
|
+
try {
|
|
471
|
+
parsed = JSON.parse(cursor);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
parsed = null;
|
|
475
|
+
}
|
|
476
|
+
if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'string' || !Number.isSafeInteger(parsed[1])) {
|
|
477
|
+
throw substratError('validation_failed', 'not a dump page cursor');
|
|
478
|
+
}
|
|
479
|
+
return { table: parsed[0], after: parsed[1] };
|
|
480
|
+
}
|
|
418
481
|
/**
|
|
419
482
|
* The scope DO's write revision (#1722): the one SQL handle this object writes through, and the
|
|
420
483
|
* one way it opens a transaction, so a writer or a transaction added later cannot slip past it.
|
|
@@ -678,7 +741,21 @@ function closeSessionEnded(ws) {
|
|
|
678
741
|
// Already gone.
|
|
679
742
|
}
|
|
680
743
|
}
|
|
681
|
-
export function defineScopeDO(modules, bareOps
|
|
744
|
+
export function defineScopeDO(modules, bareOps,
|
|
745
|
+
/**
|
|
746
|
+
* #2430: `permissions` is the vertical's `definePermissions(...)` value, the one `substrat push`
|
|
747
|
+
* and `lint:permissions` read. Given, a module in `modules` that its list leaves out is refused
|
|
748
|
+
* when the class is defined, as `registerModule` refuses one on either host.
|
|
749
|
+
*/
|
|
750
|
+
options = {}) {
|
|
751
|
+
// At definition, not at construction: a worker whose module list drifted fails at its first
|
|
752
|
+
// import, rather than at the first scope it wakes.
|
|
753
|
+
for (const registration of modules)
|
|
754
|
+
assertModuleDeclared(options.permissions, registration.manifest.id);
|
|
755
|
+
// What the declared surface reserves holds in every instance too, whatever registers under its ids.
|
|
756
|
+
const declaredReserved = new Set();
|
|
757
|
+
for (const m of options.permissions?.modules ?? [])
|
|
758
|
+
noteReservedKeys(declaredReserved, m.manifest);
|
|
682
759
|
return class ScopeDO extends DurableObject {
|
|
683
760
|
sql;
|
|
684
761
|
continuationOrder = 0;
|
|
@@ -735,6 +812,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
735
812
|
/** #2387: the operations held to a `ctx.check` — the pure adapter's set, from the same kernel read. */
|
|
736
813
|
operationCheckHeld = new Set();
|
|
737
814
|
modules = new Map();
|
|
815
|
+
/**
|
|
816
|
+
* #2430: what a registered module, or a module the class's declared `permissions` lists, declares
|
|
817
|
+
* `holder: 'system'` — the role and grant doors' refusals read it.
|
|
818
|
+
*/
|
|
819
|
+
reservedKeys = new Set(declaredReserved);
|
|
738
820
|
/** #1705: what this deployment exports to other verticals and imports from them. */
|
|
739
821
|
crossVertical = new CrossVerticalRegistry();
|
|
740
822
|
/**
|
|
@@ -913,6 +995,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
913
995
|
handler,
|
|
914
996
|
})),
|
|
915
997
|
});
|
|
998
|
+
noteReservedKeys(this.reservedKeys, manifest);
|
|
916
999
|
if (erasure)
|
|
917
1000
|
this.erasurePlans.push(erasure);
|
|
918
1001
|
for (const [name, handler] of Object.entries(registration.predicates ?? {})) {
|
|
@@ -3533,10 +3616,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3533
3616
|
await this.ensureMigrations();
|
|
3534
3617
|
return this.queue.enqueue(() => this.assignmentBound({ kind: 'principal', id: principal }, tenantId, atTenant ? null : scopeId, roleKey));
|
|
3535
3618
|
}
|
|
3536
|
-
/**
|
|
3619
|
+
/**
|
|
3620
|
+
* Check and grant in one serialized scope task; a refusal never writes a tuple. `refused` (#2430)
|
|
3621
|
+
* is a role carrying a key reserved to a system principal, answered as data so its text survives
|
|
3622
|
+
* the hop and the coordinator types it.
|
|
3623
|
+
*/
|
|
3537
3624
|
async assignScopeRoleBoundedFor(tenantId, scopeId, caller, assignee, roleKey) {
|
|
3538
3625
|
await this.ensureMigrations();
|
|
3539
3626
|
return this.queue.enqueue(async () => {
|
|
3627
|
+
const refused = await this.reservedRoleRefusalFor(tenantId, roleKey, 'person');
|
|
3628
|
+
if (refused)
|
|
3629
|
+
return { refused };
|
|
3540
3630
|
const bound = await this.assignmentBound({ kind: 'principal', id: caller }, tenantId, scopeId, roleKey);
|
|
3541
3631
|
if (bound?.covered) {
|
|
3542
3632
|
applyScopeRoleChange(this.switchSql(), scopeId, assignee, { revoke: [], grant: roleKey }, new Date().toISOString());
|
|
@@ -3555,7 +3645,70 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3555
3645
|
*/
|
|
3556
3646
|
async changeScopeRoleBoundedFor(tenantId, scopeId, caller, principal, from, to) {
|
|
3557
3647
|
await this.ensureMigrations();
|
|
3558
|
-
return this.queue.enqueue(
|
|
3648
|
+
return this.queue.enqueue(async () => {
|
|
3649
|
+
// #2430: the role moved TO is conferred by a person; the one moved from is only taken back.
|
|
3650
|
+
const refused = await this.reservedRoleRefusalFor(tenantId, to, 'person');
|
|
3651
|
+
if (refused)
|
|
3652
|
+
return { refused };
|
|
3653
|
+
return changeScopeRole(this.switchSql(), scopeId, principal, from, to, new Date().toISOString(), this.roleBound(caller, tenantId, scopeId), (run) => this.revision.transactionSync(run));
|
|
3654
|
+
});
|
|
3655
|
+
}
|
|
3656
|
+
/**
|
|
3657
|
+
* #2430: why `roleKey` may not be conferred through `door` here, or null — the tenant's role as
|
|
3658
|
+
* the checker expands it (`controlPlaneReader`), against the keys this class's modules reserve.
|
|
3659
|
+
* A role the tenant does not define is left to the door's own answer for it.
|
|
3660
|
+
*/
|
|
3661
|
+
async reservedRoleRefusalFor(tenantId, roleKey, door) {
|
|
3662
|
+
if (this.reservedKeys.size === 0)
|
|
3663
|
+
return null;
|
|
3664
|
+
const role = await this.controlPlaneReader().getRole(tenantId, roleKey);
|
|
3665
|
+
return role ? reservedRoleRefusal(role, this.reservedKeys, door) : null;
|
|
3666
|
+
}
|
|
3667
|
+
/**
|
|
3668
|
+
* #2430: `reservedRoleRefusalFor` for a caller that names no tenant — `assignScopeRole` — read
|
|
3669
|
+
* against the tenant this scope's provisioning receipt names.
|
|
3670
|
+
*
|
|
3671
|
+
* A scope from before receipts (#1738) names its tenants only through the role rows it was
|
|
3672
|
+
* projected (`tenantVerdict`'s inference), and each of those tenants is judged through the
|
|
3673
|
+
* same reader the checker expands the role with (`controlPlaneReader`): the scope's own rows
|
|
3674
|
+
* once it reads locally, the control plane's definition otherwise. So a stale or partial
|
|
3675
|
+
* projection is never what decides for a checker that does not read it. A scope with no role
|
|
3676
|
+
* row names no tenant: reading locally it expands nothing, so nothing is refused, but a checker
|
|
3677
|
+
* reading the control plane would expand whichever tenant's role a later call names, which this
|
|
3678
|
+
* door cannot know, so it refuses.
|
|
3679
|
+
*/
|
|
3680
|
+
async reservedRoleRefusalHere(roleKey, door) {
|
|
3681
|
+
if (this.reservedKeys.size === 0)
|
|
3682
|
+
return null;
|
|
3683
|
+
const tenantId = this.provisionedFor();
|
|
3684
|
+
if (tenantId)
|
|
3685
|
+
return this.reservedRoleRefusalFor(tenantId, roleKey, door);
|
|
3686
|
+
const tenants = this.hasTable('_substrat_roles')
|
|
3687
|
+
? this.sql.exec('SELECT DISTINCT tenant_id FROM _substrat_roles').toArray().map((r) => r.tenant_id)
|
|
3688
|
+
: [];
|
|
3689
|
+
for (const projected of tenants) {
|
|
3690
|
+
const refusal = await this.reservedRoleRefusalFor(projected, roleKey, door);
|
|
3691
|
+
if (refusal)
|
|
3692
|
+
return refusal;
|
|
3693
|
+
}
|
|
3694
|
+
if (tenants.length === 0 && this.permissionSourceIsRemote()) {
|
|
3695
|
+
return (`role '${roleKey}' cannot be judged here: this scope has no provisioning receipt and no projected role, and its ` +
|
|
3696
|
+
"checks read roles from the control plane, so which tenant's definition a check expands is unknown and a key " +
|
|
3697
|
+
'reserved to a system principal cannot be ruled out. Provision the scope first.');
|
|
3698
|
+
}
|
|
3699
|
+
return null;
|
|
3700
|
+
}
|
|
3701
|
+
/** #2430: why a raw grant of `permissions` is refused against this class's reservations, or null. */
|
|
3702
|
+
async reservedGrantRefusalFor(permissions) {
|
|
3703
|
+
return reservedGrantRefusal(permissions, this.reservedKeys);
|
|
3704
|
+
}
|
|
3705
|
+
/** #2430: why a reconcile of declared `shapes` is refused against this class's reservations, or null. */
|
|
3706
|
+
async reservedShapeRefusalFor(shapes) {
|
|
3707
|
+
return reservedShapeRefusal(shapes, this.reservedKeys);
|
|
3708
|
+
}
|
|
3709
|
+
/** #2430: which of `permissions` this class's modules reserve to a system principal. */
|
|
3710
|
+
async reservedAmong(permissions) {
|
|
3711
|
+
return permissions.filter((p) => this.reservedKeys.has(p));
|
|
3559
3712
|
}
|
|
3560
3713
|
/** The kernel's `revokeScopeRoles` in one serialized scope task (#1150). */
|
|
3561
3714
|
async revokeScopeRolesBoundedFor(tenantId, scopeId, caller, principal) {
|
|
@@ -4943,31 +5096,99 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4943
5096
|
* with the scope's identity after the K-3 check (host.ts admin.exportScope).
|
|
4944
5097
|
*/
|
|
4945
5098
|
exportDump() {
|
|
4946
|
-
|
|
4947
|
-
// replayed — this host answers "object name reserved for internal use" — and
|
|
4948
|
-
// it is recomputable, so `importDump` rebuilds it from the loaded rows. A dump
|
|
4949
|
-
// carries the rows, never the index over them.
|
|
4950
|
-
const defs = this.sql
|
|
4951
|
-
.exec(`SELECT name, sql FROM sqlite_master
|
|
4952
|
-
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' AND sql IS NOT NULL
|
|
4953
|
-
ORDER BY name`)
|
|
4954
|
-
.toArray().filter(({ name }) => !isSearchIndexTable(name));
|
|
4955
|
-
return defs.map(({ name, sql }) => {
|
|
5099
|
+
return this.dumpTableDefs().map(({ name, sql }) => {
|
|
4956
5100
|
// Raw positional rows, cells as-is (blobs kept as bytes, not nulled like a UI
|
|
4957
5101
|
// read) so the dump reloads faithfully. The name is from the live schema.
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
const cursor = name === '_substrat_meta'
|
|
4961
|
-
? this.sql.exec(`SELECT * FROM "${name}" WHERE key NOT IN (${STORE_LOCAL_META_KEYS.map(() => '?').join(', ')})`, ...STORE_LOCAL_META_KEYS)
|
|
4962
|
-
: // #2350: the orphan sweep's licence is this store's own, so its rows never leave in a dump.
|
|
4963
|
-
name === ATTACHMENT_SWEEP_LICENCE_TABLE
|
|
4964
|
-
? this.sql.exec(`SELECT * FROM "${name}" WHERE 0`)
|
|
4965
|
-
: this.sql.exec(`SELECT * FROM "${name}"`);
|
|
5102
|
+
const { where, bindings } = dumpRowFilter(name);
|
|
5103
|
+
const cursor = this.sql.exec(`SELECT * FROM "${name}" WHERE ${where}`, ...bindings);
|
|
4966
5104
|
const columns = cursor.columnNames;
|
|
4967
5105
|
const rows = Array.from(cursor.raw(), (row) => row);
|
|
4968
5106
|
return { name, ddl: sql, columns, rows };
|
|
4969
5107
|
});
|
|
4970
5108
|
}
|
|
5109
|
+
/**
|
|
5110
|
+
* The tables a dump carries, by name. The derived search index (#827) is excluded: its shadow
|
|
5111
|
+
* tables cannot be replayed — this host answers "object name reserved for internal use" — and
|
|
5112
|
+
* it is recomputable, so `importDump` rebuilds it from the loaded rows. A dump carries the rows,
|
|
5113
|
+
* never the index over them. A staged load's own tables (#2450) are this store's, not its data.
|
|
5114
|
+
*/
|
|
5115
|
+
dumpTableDefs() {
|
|
5116
|
+
return this.sql
|
|
5117
|
+
.exec(`SELECT name, sql FROM sqlite_master
|
|
5118
|
+
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'
|
|
5119
|
+
AND name NOT GLOB '${STAGE_GLOB}' AND sql IS NOT NULL
|
|
5120
|
+
ORDER BY name`)
|
|
5121
|
+
.toArray().filter(({ name }) => !isSearchIndexTable(name));
|
|
5122
|
+
}
|
|
5123
|
+
/**
|
|
5124
|
+
* One page of `exportDumpStamped` (#2450): the tables in the same order, each table's rows in
|
|
5125
|
+
* rowid order, until about `maxBytes` of cells are read (a single larger row rides alone). Every
|
|
5126
|
+
* table is in some page, empty or not. Each page is read in one turn, so it is consistent on its
|
|
5127
|
+
* own; the pages are one consistent read only while `revision` stays the same, which the reader
|
|
5128
|
+
* compares. With `stamp`, the first page (`cursor` null) mints the load stamp, as
|
|
5129
|
+
* `exportDumpStamped` does; without it the export writes nothing, as `exportDump`.
|
|
5130
|
+
*/
|
|
5131
|
+
exportDumpPage(cursor, opts = {}) {
|
|
5132
|
+
// Answered as data, so a refusal's code survives the hop (#113).
|
|
5133
|
+
return replyOf(() => this.dumpPage(cursor, opts));
|
|
5134
|
+
}
|
|
5135
|
+
dumpPage(cursor, { stamp = false, maxBytes = SCOPE_DUMP_PAGE_MAX_BYTES }) {
|
|
5136
|
+
let loadStamp = this.loadStamp();
|
|
5137
|
+
if (stamp && cursor === null && !loadStamp && !this.carriedAwayCopy) {
|
|
5138
|
+
loadStamp = ulid();
|
|
5139
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, loadStamp);
|
|
5140
|
+
}
|
|
5141
|
+
const at = cursor === null ? null : decodeDumpCursor(cursor);
|
|
5142
|
+
const tables = [];
|
|
5143
|
+
let bytes = 0;
|
|
5144
|
+
let next = null;
|
|
5145
|
+
for (const { name, sql } of this.dumpTableDefs()) {
|
|
5146
|
+
// Tables are read in name order: those before the cursor's are done. A cursor naming a
|
|
5147
|
+
// table that is gone resumes at the next name.
|
|
5148
|
+
if (at && name < at.table)
|
|
5149
|
+
continue;
|
|
5150
|
+
const after = at && name === at.table ? at.after : null;
|
|
5151
|
+
const { where, bindings } = dumpRowFilter(name);
|
|
5152
|
+
// Read to its end (it holds no row), as every cursor here is: see the batches below.
|
|
5153
|
+
const probe = this.sql.exec(`SELECT * FROM "${name}" WHERE 0`);
|
|
5154
|
+
probe.toArray();
|
|
5155
|
+
const columns = probe.columnNames;
|
|
5156
|
+
// A table without a rowid, or with a column that takes the name, is read by offset.
|
|
5157
|
+
const byRowid = !/\bWITHOUT\s+ROWID\b/i.test(sql) && !columns.some((c) => ROWID_NAMES.has(c.toLowerCase()));
|
|
5158
|
+
// In batches, each read to its end: a cursor left part-read keeps its table locked, and
|
|
5159
|
+
// the next statement that drops or alters it fails with SQLITE_LOCKED.
|
|
5160
|
+
const batch = (from) => Array.from(!byRowid
|
|
5161
|
+
? this.sql.exec(`SELECT * FROM "${name}" WHERE ${where} LIMIT ? OFFSET ?`, ...bindings, DUMP_READ_BATCH, from ?? 0).raw()
|
|
5162
|
+
: from === null
|
|
5163
|
+
? this.sql.exec(`SELECT rowid, * FROM "${name}" WHERE ${where} ORDER BY rowid LIMIT ?`, ...bindings, DUMP_READ_BATCH).raw()
|
|
5164
|
+
: this.sql
|
|
5165
|
+
.exec(`SELECT rowid, * FROM "${name}" WHERE (${where}) AND rowid > ? ORDER BY rowid LIMIT ?`, ...bindings, from, DUMP_READ_BATCH)
|
|
5166
|
+
.raw(), (raw) => raw);
|
|
5167
|
+
const rows = [];
|
|
5168
|
+
let last = after;
|
|
5169
|
+
let full = false;
|
|
5170
|
+
for (;;) {
|
|
5171
|
+
const read = batch(last);
|
|
5172
|
+
for (const cells of read) {
|
|
5173
|
+
rows.push(byRowid ? cells.slice(1) : cells);
|
|
5174
|
+
last = byRowid ? Number(cells[0]) : (last ?? 0) + 1;
|
|
5175
|
+
bytes += rowBytes(rows[rows.length - 1]);
|
|
5176
|
+
if (bytes >= maxBytes) {
|
|
5177
|
+
full = true;
|
|
5178
|
+
break;
|
|
5179
|
+
}
|
|
5180
|
+
}
|
|
5181
|
+
if (full || read.length < DUMP_READ_BATCH)
|
|
5182
|
+
break;
|
|
5183
|
+
}
|
|
5184
|
+
tables.push({ name, ddl: sql, columns, rows });
|
|
5185
|
+
if (full) {
|
|
5186
|
+
next = encodeDumpCursor({ table: name, after: last ?? 0 });
|
|
5187
|
+
break;
|
|
5188
|
+
}
|
|
5189
|
+
}
|
|
5190
|
+
return { tables, next, loadStamp, revision: this.metaValue(WRITE_REVISION_KEY) };
|
|
5191
|
+
}
|
|
4971
5192
|
/**
|
|
4972
5193
|
* `exportDump`, with the store's load stamp read in the same call (#1722): the stamp a
|
|
4973
5194
|
* carry's fenced wipe of this copy later expects. A store no load has stamped gets one now,
|
|
@@ -5159,7 +5380,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5159
5380
|
this.sql.exec(stmt);
|
|
5160
5381
|
});
|
|
5161
5382
|
}
|
|
5162
|
-
async importDump(tables, destScopeId,
|
|
5383
|
+
async importDump(tables, destScopeId, opts = {}) {
|
|
5384
|
+
return this.loadTables(tables.map((t) => ({ name: t.name, ddl: t.ddl, columns: t.columns, rows: () => t.rows })), destScopeId, opts);
|
|
5385
|
+
}
|
|
5386
|
+
/**
|
|
5387
|
+
* The load `importDump` runs, over tables whose rows are read lazily (#2450): from the dump's
|
|
5388
|
+
* arrays, or from a staged load's tables (`commitStagedDump`), so a load never needs every row
|
|
5389
|
+
* in memory at once. `stageId` names the staged load the rows come from: checked inside the
|
|
5390
|
+
* load's transaction, and its tables dropped in it once the rows are in.
|
|
5391
|
+
*/
|
|
5392
|
+
async loadTables(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, } = {}, stageId) {
|
|
5163
5393
|
// The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
|
|
5164
5394
|
//
|
|
5165
5395
|
// Two distinct FK hazards, and both need the deferral — the second is why it cannot
|
|
@@ -5182,7 +5412,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5182
5412
|
// rather than failing a restore over data about to be recomputed.
|
|
5183
5413
|
// Matched without case, as SQLite resolves a table name (#1883 review).
|
|
5184
5414
|
// #1686: a copy into another scope id loads no capability rows; a same-scope restore keeps them.
|
|
5185
|
-
const replayable =
|
|
5415
|
+
const replayable = tables
|
|
5416
|
+
.filter((t) => !isSearchIndexTable(t.name.toLowerCase()))
|
|
5417
|
+
.map((t) => (loadDropsRowsOf(t.name, destScopeId, sourceScopeId) ? { ...t, rows: () => [] } : t));
|
|
5186
5418
|
// The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
|
|
5187
5419
|
// in the string it is given, so a `ddl` with anything appended to its CREATE
|
|
5188
5420
|
// TABLE executed that too — with entirely plain identifiers, which is why no
|
|
@@ -5202,6 +5434,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5202
5434
|
fenceRefusals.add(lapsed);
|
|
5203
5435
|
throw lapsed;
|
|
5204
5436
|
}
|
|
5437
|
+
// #2450: the staged load is still the one this commit names: a later carry's first page
|
|
5438
|
+
// replaces a stage, and a load that already committed dropped its own.
|
|
5439
|
+
if (stageId !== undefined && this.stageHead()?.stageId !== stageId) {
|
|
5440
|
+
throw substratError('precondition_failed', STAGE_GONE);
|
|
5441
|
+
}
|
|
5205
5442
|
const before = this.loadMarker();
|
|
5206
5443
|
// #1713: the lifecycle the platform delivered here, read before the drops take it.
|
|
5207
5444
|
const lifecycleBefore = readLifecycle(this.switchSql());
|
|
@@ -5210,7 +5447,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5210
5447
|
// must agree with the receipt held, and is refused before the first drop if it does not.
|
|
5211
5448
|
// #2314: a row for an id the orphan sweep condemned here is refused, as the insert refuses it.
|
|
5212
5449
|
// Judged on `replayable`, the very tables whose rows the replay below inserts.
|
|
5213
|
-
|
|
5450
|
+
// Only the ids are read (#2450): a staged load holds no table whole, this one included.
|
|
5451
|
+
refuseCondemnedInDump(doSpineSql(this.sql), replayable
|
|
5452
|
+
.filter((t) => t.name.toLowerCase() === '_substrat_attachments')
|
|
5453
|
+
.map((t) => {
|
|
5454
|
+
const at = t.columns.indexOf('id');
|
|
5455
|
+
return { name: t.name, columns: ['id'], rows: at < 0 ? [] : Array.from(t.rows(), (row) => [row[at]]) };
|
|
5456
|
+
}));
|
|
5214
5457
|
const receiptBefore = this.provisionedFor();
|
|
5215
5458
|
if (provisionedFor !== undefined && receiptBefore !== null && receiptBefore !== provisionedFor) {
|
|
5216
5459
|
const refused = substratError('conflict', tenantReceiptRefusal(receiptBefore, provisionedFor));
|
|
@@ -5244,7 +5487,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5244
5487
|
const existing = this.sql
|
|
5245
5488
|
// Nor workerd's own `_cf_*` (the live reaper's alarm keeps `_cf_METADATA`), which
|
|
5246
5489
|
// it refuses to drop.
|
|
5247
|
-
|
|
5490
|
+
// Nor a staged load's own tables (#2450): this commit reads its rows from them below.
|
|
5491
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'
|
|
5492
|
+
AND name NOT GLOB '${STAGE_GLOB}'`)
|
|
5248
5493
|
.toArray();
|
|
5249
5494
|
// Search index tables are left alone here and rebuilt below (#827): dropping a
|
|
5250
5495
|
// shadow table directly is an error, and `sqlite_master` order would reach one
|
|
@@ -5289,9 +5534,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5289
5534
|
}
|
|
5290
5535
|
for (const t of replayable) {
|
|
5291
5536
|
const insert = dumpRowsInsert(t, columnsOf);
|
|
5292
|
-
for (const row of t.rows)
|
|
5537
|
+
for (const row of t.rows())
|
|
5293
5538
|
this.sql.exec(insert, ...row);
|
|
5294
5539
|
}
|
|
5540
|
+
// #2450: the staged rows are in, so the stage goes, with the load: a load refused or
|
|
5541
|
+
// rolled back after this keeps it, for a retry of the same commit.
|
|
5542
|
+
if (stageId !== undefined)
|
|
5543
|
+
this.dropStage();
|
|
5295
5544
|
// #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
|
|
5296
5545
|
// not this one, so it is never carried over. #2016: what stays is THIS store's own —
|
|
5297
5546
|
// the platform's word when the load carries it (a copy into a fresh scope records its
|
|
@@ -5409,8 +5658,149 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
5409
5658
|
* (`changed`, 412) and "this store is a kept copy" (`kept`, 409) from a failure.
|
|
5410
5659
|
*/
|
|
5411
5660
|
async importDumpChecked(tables, destScopeId, opts) {
|
|
5661
|
+
return this.checkedLoad(() => this.importDump(tables, destScopeId, opts));
|
|
5662
|
+
}
|
|
5663
|
+
/**
|
|
5664
|
+
* Stage one page of a dump for a later `commitStagedDump` (#2450). The rows go into this
|
|
5665
|
+
* store's own staging tables, untyped, so every cell is kept as it arrived; nothing of the
|
|
5666
|
+
* scope's data is touched, and the write revision does not move, so the marker a carry read
|
|
5667
|
+
* here before staging still holds at the commit. Page `0` of a new stage replaces any
|
|
5668
|
+
* other; the following pages must arrive in order. A page already staged (page `0` included)
|
|
5669
|
+
* answers `staged: false` and changes nothing, so a retried page is harmless.
|
|
5670
|
+
*/
|
|
5671
|
+
stageDumpPage(stageId, seq, tables) {
|
|
5672
|
+
// Answered as data, so a refusal's code survives the hop (#113).
|
|
5673
|
+
return replyOf(() => this.stagePage(stageId, seq, tables));
|
|
5674
|
+
}
|
|
5675
|
+
stagePage(stageId, seq, tables) {
|
|
5676
|
+
if (!Number.isSafeInteger(seq) || seq < 0)
|
|
5677
|
+
throw substratError('validation_failed', 'a stage page is numbered from 0');
|
|
5678
|
+
// The identifiers reach SQL below as text, so they are held to the replay rules first.
|
|
5679
|
+
assertReplayableDump(tables, { maxColumns: DO_SQL_LIMITS.columns });
|
|
5680
|
+
this.revisionSuspended = true;
|
|
5412
5681
|
try {
|
|
5413
|
-
return
|
|
5682
|
+
return this.revision.transactionSync(() => {
|
|
5683
|
+
// A page 0 that already opened this stage is a retry, answered below as one.
|
|
5684
|
+
if (seq === 0 && this.stageHead()?.stageId !== stageId) {
|
|
5685
|
+
this.dropStage();
|
|
5686
|
+
for (const ddl of STAGE_TABLES_DDL)
|
|
5687
|
+
this.sql.exec(ddl);
|
|
5688
|
+
this.sql.exec(`INSERT INTO _substrat_stage (stage_id, next_seq) VALUES (?, 0)`, stageId);
|
|
5689
|
+
}
|
|
5690
|
+
const head = this.stageHead();
|
|
5691
|
+
if (head?.stageId !== stageId)
|
|
5692
|
+
throw substratError('precondition_failed', STAGE_GONE);
|
|
5693
|
+
if (seq < head.nextSeq)
|
|
5694
|
+
return { staged: false };
|
|
5695
|
+
if (seq > head.nextSeq) {
|
|
5696
|
+
throw substratError('precondition_failed', `stage page ${seq} arrived before page ${head.nextSeq}`);
|
|
5697
|
+
}
|
|
5698
|
+
// #2314: a page carrying an attachment row this store condemned is refused here, at the
|
|
5699
|
+
// page, as the commit refuses it again before its first drop (`loadTables`).
|
|
5700
|
+
refuseCondemnedInDump(doSpineSql(this.sql), tables);
|
|
5701
|
+
for (const t of tables) {
|
|
5702
|
+
const columns = JSON.stringify(t.columns);
|
|
5703
|
+
let held = this.sql
|
|
5704
|
+
.exec(`SELECT ord, ddl, columns FROM _substrat_stage_tables WHERE name = ?`, t.name)
|
|
5705
|
+
.toArray()[0];
|
|
5706
|
+
if (!held) {
|
|
5707
|
+
const ord = Number(this.sql.exec(`SELECT COUNT(*) AS n FROM _substrat_stage_tables`).toArray()[0].n);
|
|
5708
|
+
this.sql.exec(`INSERT INTO _substrat_stage_tables (ord, name, ddl, columns) VALUES (?, ?, ?, ?)`, ord, t.name, t.ddl, columns);
|
|
5709
|
+
// Untyped columns: no affinity, so a cell is stored exactly as it came.
|
|
5710
|
+
this.sql.exec(`CREATE TABLE "${stageRowsTable(ord)}" (${t.columns.map((_, i) => `c${i}`).join(', ')})`);
|
|
5711
|
+
held = { ord, ddl: t.ddl, columns };
|
|
5712
|
+
}
|
|
5713
|
+
else if (held.ddl !== t.ddl || held.columns !== columns) {
|
|
5714
|
+
throw substratError('precondition_failed', `table ${t.name} changed between dump pages; nothing more was staged`);
|
|
5715
|
+
}
|
|
5716
|
+
const insert = `INSERT INTO "${stageRowsTable(held.ord)}" VALUES (${t.columns.map(() => '?').join(', ')})`;
|
|
5717
|
+
for (const row of t.rows) {
|
|
5718
|
+
if (row.length !== t.columns.length) {
|
|
5719
|
+
throw substratError('validation_failed', `a row of ${t.name} has ${row.length} cells for ${t.columns.length} columns`);
|
|
5720
|
+
}
|
|
5721
|
+
this.sql.exec(insert, ...row);
|
|
5722
|
+
}
|
|
5723
|
+
}
|
|
5724
|
+
this.sql.exec(`UPDATE _substrat_stage SET next_seq = ?`, seq + 1);
|
|
5725
|
+
return { staged: true };
|
|
5726
|
+
});
|
|
5727
|
+
}
|
|
5728
|
+
finally {
|
|
5729
|
+
this.revisionSuspended = false;
|
|
5730
|
+
}
|
|
5731
|
+
}
|
|
5732
|
+
/**
|
|
5733
|
+
* Load the dump staged under `stageId` (#2450): `importDump`, its rows read from the stage a
|
|
5734
|
+
* batch at a time rather than handed over whole, in the same one transaction with the same
|
|
5735
|
+
* fences and refusals. The stage is dropped with the load; a refused load keeps it.
|
|
5736
|
+
*/
|
|
5737
|
+
async commitStagedDumpChecked(stageId, destScopeId, opts) {
|
|
5738
|
+
let count = 0;
|
|
5739
|
+
const out = await this.checkedLoad(() => {
|
|
5740
|
+
const head = this.stageHead();
|
|
5741
|
+
if (head?.stageId !== stageId)
|
|
5742
|
+
throw substratError('precondition_failed', STAGE_GONE);
|
|
5743
|
+
const staged = this.sql
|
|
5744
|
+
.exec(`SELECT ord, name, ddl, columns FROM _substrat_stage_tables ORDER BY name`)
|
|
5745
|
+
.toArray();
|
|
5746
|
+
const tables = staged.map((t) => ({
|
|
5747
|
+
name: t.name,
|
|
5748
|
+
ddl: t.ddl,
|
|
5749
|
+
columns: JSON.parse(t.columns),
|
|
5750
|
+
rows: () => this.stagedRows(t.ord),
|
|
5751
|
+
}));
|
|
5752
|
+
count = tables.length;
|
|
5753
|
+
return this.loadTables(tables, destScopeId, opts, stageId);
|
|
5754
|
+
});
|
|
5755
|
+
return out.refused === false ? { ...out, tables: count } : out;
|
|
5756
|
+
}
|
|
5757
|
+
/** Drop a staged load (#2450), if it is still `stageId`'s. A carry that gave up calls it. */
|
|
5758
|
+
discardStagedDump(stageId) {
|
|
5759
|
+
if (this.stageHead()?.stageId !== stageId)
|
|
5760
|
+
return { discarded: false };
|
|
5761
|
+
this.revisionSuspended = true;
|
|
5762
|
+
try {
|
|
5763
|
+
this.revision.transactionSync(() => this.dropStage());
|
|
5764
|
+
}
|
|
5765
|
+
finally {
|
|
5766
|
+
this.revisionSuspended = false;
|
|
5767
|
+
}
|
|
5768
|
+
return { discarded: true };
|
|
5769
|
+
}
|
|
5770
|
+
/** One staged table's rows, in the order they were staged, a batch at a time. */
|
|
5771
|
+
*stagedRows(ord) {
|
|
5772
|
+
let after = 0;
|
|
5773
|
+
for (;;) {
|
|
5774
|
+
const batch = Array.from(this.sql
|
|
5775
|
+
.exec(`SELECT rowid, * FROM "${stageRowsTable(ord)}" WHERE rowid > ? ORDER BY rowid LIMIT ?`, after, STAGE_READ_BATCH)
|
|
5776
|
+
.raw(), (row) => row);
|
|
5777
|
+
for (const row of batch)
|
|
5778
|
+
yield row.slice(1);
|
|
5779
|
+
if (batch.length < STAGE_READ_BATCH)
|
|
5780
|
+
return;
|
|
5781
|
+
after = Number(batch[batch.length - 1][0]);
|
|
5782
|
+
}
|
|
5783
|
+
}
|
|
5784
|
+
/** The staged load this store holds (#2450), or null. */
|
|
5785
|
+
stageHead() {
|
|
5786
|
+
const present = this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_substrat_stage'`).toArray();
|
|
5787
|
+
if (present.length === 0)
|
|
5788
|
+
return null;
|
|
5789
|
+
const row = this.sql.exec(`SELECT stage_id, next_seq FROM _substrat_stage`).toArray()[0];
|
|
5790
|
+
return row ? { stageId: row.stage_id, nextSeq: Number(row.next_seq) } : null;
|
|
5791
|
+
}
|
|
5792
|
+
/** Drop every staging table (#2450). Inside the caller's transaction. */
|
|
5793
|
+
dropStage() {
|
|
5794
|
+
const names = this.sql
|
|
5795
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name GLOB '${STAGE_GLOB}'`)
|
|
5796
|
+
.toArray();
|
|
5797
|
+
for (const { name } of names)
|
|
5798
|
+
this.sql.exec(`DROP TABLE "${name}"`);
|
|
5799
|
+
}
|
|
5800
|
+
/** A load's refusals answered as values (#1722): across the RPC a throw carries only its message. */
|
|
5801
|
+
async checkedLoad(run) {
|
|
5802
|
+
try {
|
|
5803
|
+
return { refused: false, switchedOff: await run() };
|
|
5414
5804
|
}
|
|
5415
5805
|
catch (e) {
|
|
5416
5806
|
if (e instanceof Error && fenceRefusals.has(e))
|
|
@@ -6031,6 +6421,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
6031
6421
|
// #2387: every check this context makes is counted, the kernel's own verbs' included.
|
|
6032
6422
|
const runCheck = checks ? countChecks(decideUncounted, checks) : decideUncounted;
|
|
6033
6423
|
const runCanAssign = async (roleKey) => {
|
|
6424
|
+
// #2430: the answer a person's door would give — a role carrying a reserved key is refused.
|
|
6425
|
+
const refused = await this.reservedRoleRefusalFor(tenantId, roleKey, 'person');
|
|
6426
|
+
if (refused)
|
|
6427
|
+
throw substratError('forbidden', refused);
|
|
6034
6428
|
const bound = await this.assignmentBound(subject, tenantId, scopeId, roleKey);
|
|
6035
6429
|
if (!bound) {
|
|
6036
6430
|
throw unknownRoleError(roleKey);
|
|
@@ -6218,6 +6612,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
6218
6612
|
grant: async (principal, permission, entity) => {
|
|
6219
6613
|
assertImpersonationWrites(impersonation, 'ctx.grant');
|
|
6220
6614
|
entityObjectRef(entity, 'ctx.grant'); // #1856: a tuple the walk can read back
|
|
6615
|
+
// #2430: whoever the caller is — a system actor passes guardrail 2 by construction.
|
|
6616
|
+
const reserved = reservedGrantRefusal([permission], this.reservedKeys);
|
|
6617
|
+
if (reserved)
|
|
6618
|
+
throw substratError('forbidden', reserved);
|
|
6221
6619
|
const held = await runCheck(permission, entity);
|
|
6222
6620
|
if (!held.allowed) {
|
|
6223
6621
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|