@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.
@@ -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>>): new (ctx: DurableObjectState, env: ScopeDoEnv) => DurableObject<ScopeDoEnv>;
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
@@ -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;AA8DnD,OAAO,EA2DL,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;AA0B9B;;;;;;;;;;;;;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;AAwkB5E;;;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,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAAC,UAAU,CAAC,CA00O7E"}
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 { 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 { 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
- /** Check and grant in one serialized scope task; a refusal never writes a tuple. */
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(() => changeScopeRole(this.switchSql(), scopeId, principal, from, to, new Date().toISOString(), this.roleBound(caller, tenantId, scopeId), (run) => this.revision.transactionSync(run)));
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
- // The derived search index (#827) is excluded: its shadow tables cannot be
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
- // #1722: the load stamp and the write revision describe THIS store, so they never leave
4959
- // in a dump (`exportDumpStamped` hands the stamp over beside one); every load writes its own.
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, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, } = {}) {
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 = capabilitiesForLoad(tables.filter((t) => !isSearchIndexTable(t.name.toLowerCase())), destScopeId, sourceScopeId);
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
- refuseCondemnedInDump(doSpineSql(this.sql), replayable);
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
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'`)
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 { refused: false, switchedOff: await this.importDump(tables, destScopeId, opts) };
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} — ` +