@substrat-run/adapter-cloudflare 0.122.0 → 0.124.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 +8 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +24 -7
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +176 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +458 -26
- package/dist/host.js.map +1 -1
- package/dist/rewind-refusal.d.ts +11 -0
- package/dist/rewind-refusal.d.ts.map +1 -0
- package/dist/rewind-refusal.js +13 -0
- package/dist/rewind-refusal.js.map +1 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +366 -69
- package/dist/scope-do.js.map +1 -1
- package/package.json +6 -6
package/dist/scope-do.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { REWIND_REFUSED } from './rewind-refusal.js';
|
|
1
2
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
3
|
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
3
|
-
import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, 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, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
|
|
4
|
+
import { ulid, DO_SQL_LIMITS, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, 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, 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, jobRunListLimit, } from '@substrat-run/kernel';
|
|
4
5
|
import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
|
|
5
6
|
import { OperationQueue } from './serialization.js';
|
|
6
7
|
import { doScopedSql, doSpineSql } from './sql.js';
|
|
7
|
-
import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
8
|
+
import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
8
9
|
import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
|
|
9
10
|
/**
|
|
10
11
|
* The key marking a scope DO whose storage was destroyed (`destroyStorage`).
|
|
@@ -900,22 +901,37 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
900
901
|
return { consumes, cursors };
|
|
901
902
|
}
|
|
902
903
|
/**
|
|
903
|
-
* #1705 PR 2: does this DO hold a scope PROVISIONED here for `tenantId`?
|
|
904
|
+
* #1705 PR 2 / #1738: does this DO hold a scope PROVISIONED here for `tenantId`?
|
|
904
905
|
*
|
|
905
906
|
* A CP-less deployment has no directory to ask whether it serves a scope. What it does
|
|
906
|
-
* have is what provisioning wrote
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
*
|
|
907
|
+
* have is what provisioning wrote. A scope this deployment never provisioned has none of
|
|
908
|
+
* it: its DO is empty, and a cross-vertical verb answering from it is a wrong answer rather
|
|
909
|
+
* than a failure: a watermark of "never read", or a delivery journaled into a scope that is
|
|
910
|
+
* not the install.
|
|
911
|
+
*
|
|
912
|
+
* The answer is the `provisioned_for` RECEIPT `applyProjection` writes (#1738): the tenant
|
|
913
|
+
* this scope was provisioned for, stated rather than inferred. A receipt decides alone, so
|
|
914
|
+
* a scope holding a receipt for ANOTHER tenant is refused whatever role rows sit in it (K-3's
|
|
915
|
+
* pair check), and a stray role row can no longer make a scope serve a tenant it was never
|
|
916
|
+
* provisioned for.
|
|
917
|
+
*
|
|
918
|
+
* A scope with NO receipt was provisioned before it existed, or has just been restored (a
|
|
919
|
+
* dump's receipt is dropped on import: it describes the scope the dump came from). Only
|
|
920
|
+
* then is the old inference used: `provisionScopeLocal` projected role definitions under
|
|
921
|
+
* the tenant, so a `_substrat_roles` row for it says the same thing. The next projection
|
|
922
|
+
* (reconcile, or the restore's repair) writes the receipt, after which the inference is
|
|
923
|
+
* never consulted again.
|
|
913
924
|
*
|
|
914
925
|
* Read without migrating, so asking about a foreign scope leaves its DO as empty as it found it.
|
|
915
926
|
*/
|
|
916
927
|
async servesTenant(tenantId) {
|
|
917
|
-
const
|
|
918
|
-
if (
|
|
928
|
+
const has = (table) => this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, table).toArray().length > 0;
|
|
929
|
+
if (has('_substrat_meta')) {
|
|
930
|
+
const receipt = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
|
|
931
|
+
if (receipt)
|
|
932
|
+
return receipt.value === tenantId;
|
|
933
|
+
}
|
|
934
|
+
if (!has('_substrat_roles'))
|
|
919
935
|
return false;
|
|
920
936
|
return this.sql.exec('SELECT 1 FROM _substrat_roles WHERE tenant_id = ? LIMIT 1', tenantId).toArray().length > 0;
|
|
921
937
|
}
|
|
@@ -1174,26 +1190,34 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1174
1190
|
async rewindToBookmark(bookmark, opts) {
|
|
1175
1191
|
const storage = this.ctx.storage;
|
|
1176
1192
|
if (typeof storage.onNextSessionRestoreBookmark !== 'function') {
|
|
1177
|
-
throw new Error(
|
|
1193
|
+
throw new Error(`${REWIND_REFUSED}point-in-time rewind is not available on this host (PITR is production-plane only)`);
|
|
1178
1194
|
}
|
|
1179
1195
|
const row = this.sql
|
|
1180
1196
|
.exec('SELECT taken_at FROM _substrat_migration_bookmarks WHERE bookmark = ?', bookmark)
|
|
1181
1197
|
.toArray()[0];
|
|
1182
1198
|
if (!opts?.force) {
|
|
1183
1199
|
if (!row) {
|
|
1184
|
-
throw new Error(
|
|
1200
|
+
throw new Error(`${REWIND_REFUSED}unknown bookmark — not one this scope recorded before a migration ` +
|
|
1201
|
+
'(force admits any bookmark Cloudflare holds)');
|
|
1185
1202
|
}
|
|
1186
1203
|
const ageMs = Date.now() - Date.parse(row.taken_at);
|
|
1187
1204
|
if (ageMs > 24 * 60 * 60 * 1000) {
|
|
1188
|
-
throw new Error(
|
|
1205
|
+
throw new Error(`${REWIND_REFUSED}bookmark is ${Math.round(ageMs / 3_600_000)}h old — rewinding discards EVERY write since; ` +
|
|
1189
1206
|
`use the backup restore path (#278), or force if the loss is intended`);
|
|
1190
1207
|
}
|
|
1191
1208
|
}
|
|
1209
|
+
// #1819: arming from BEFORE the call, so a probe that lands while it is in flight answers
|
|
1210
|
+
// armed. Left set if the call throws: that is not a refusal, and it may have armed.
|
|
1211
|
+
this.rewindArming = true;
|
|
1192
1212
|
const confirmed = await storage.onNextSessionRestoreBookmark(bookmark);
|
|
1213
|
+
// #1819: from here, everything this instance writes is discarded at the restart.
|
|
1214
|
+
this.rewindArmed = true;
|
|
1193
1215
|
// Answer first, then restart to complete the restore — an immediate abort
|
|
1194
1216
|
// would take the RPC response down with it.
|
|
1195
1217
|
setTimeout(() => this.ctx.abort(), 100);
|
|
1196
|
-
|
|
1218
|
+
// #1819: which instance is doomed, so the host can tell a switch move made here from one
|
|
1219
|
+
// made on the restored storage after the restart.
|
|
1220
|
+
return { rewindingTo: confirmed ?? bookmark, instance: this.instanceId };
|
|
1197
1221
|
}
|
|
1198
1222
|
/**
|
|
1199
1223
|
* The scope's live `connection:<id>` grant tuples (#726 gap 1) — the read half of
|
|
@@ -1249,6 +1273,21 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1249
1273
|
this.sql.exec(seat.sql, ...seat.params);
|
|
1250
1274
|
});
|
|
1251
1275
|
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Provisioning's seat of many tuples as ONE unit, then the directory's recorded-off
|
|
1278
|
+
* modules switched off in it (#1742) — the CP-full mirror of what `applyProjection` does
|
|
1279
|
+
* for a CP-less provision. One tuple per `seatTuple` call left a window between the seat
|
|
1280
|
+
* and the re-assert in which a sweep could run the grants just seated.
|
|
1281
|
+
*/
|
|
1282
|
+
async seatTuples(tuples, switchOff) {
|
|
1283
|
+
return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
|
|
1284
|
+
for (const t of tuples) {
|
|
1285
|
+
const seat = seatScopeTuple(t.subject, t.relation, t.object, t.expires_at);
|
|
1286
|
+
this.sql.exec(seat.sql, ...seat.params);
|
|
1287
|
+
}
|
|
1288
|
+
return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
1289
|
+
}));
|
|
1290
|
+
}
|
|
1252
1291
|
/**
|
|
1253
1292
|
* The dispatch capability's admission (#726 remedy B): this delivery may read the
|
|
1254
1293
|
* attachments of the entity its own spine row names, and no others.
|
|
@@ -1508,6 +1547,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1508
1547
|
// that exports something, and never for a read-only session, which commits nothing.
|
|
1509
1548
|
const exportTypes = impersonation?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
|
|
1510
1549
|
const exportMark = exportTypes.length > 0 ? this.outboxMark() : null;
|
|
1550
|
+
// #1746: the same mark, when the caller asked what this call emitted. The callback
|
|
1551
|
+
// itself never runs here — its presence is the request, and the report travels
|
|
1552
|
+
// back in the envelope for the coordinator to deliver.
|
|
1553
|
+
const emittedMark = invokeOptions?.onEmitted !== undefined && impersonation?.mode !== 'read-only'
|
|
1554
|
+
? (exportMark ?? this.outboxMark())
|
|
1555
|
+
: null;
|
|
1511
1556
|
let result;
|
|
1512
1557
|
let committedVersion = null;
|
|
1513
1558
|
// #116: set when this invocation was answered from a recording rather
|
|
@@ -1641,6 +1686,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1641
1686
|
// own retry backstop, which is what that backstop is for.
|
|
1642
1687
|
// #1525: still inside the queued body that set it, so these deliveries are this
|
|
1643
1688
|
// call's own work — the same tail its consumers' emits are stamped in.
|
|
1689
|
+
// #1746: read BEFORE `settleCommitted` drains the consumers, so the rows above the
|
|
1690
|
+
// mark are this operation's own emits. A replay committed nothing.
|
|
1691
|
+
let emitted;
|
|
1692
|
+
if (emittedMark !== null && !replayed) {
|
|
1693
|
+
const q = emittedSinceQuery(emittedMark, EMITTED_REPORT_CAP);
|
|
1694
|
+
emitted = emittedReportOf(this.sql.exec(q.sql, ...q.params).toArray());
|
|
1695
|
+
}
|
|
1644
1696
|
if (!replayed)
|
|
1645
1697
|
await this.settleCommitted(tenantId, scopeId, liveSince, this.invocationId);
|
|
1646
1698
|
// After the tail, so an exported type a consumer emitted in it counts too.
|
|
@@ -1651,6 +1703,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1651
1703
|
// #1705 PR 2: the coordinator fires `onExportedEvents` from this. Omitted at 0, so
|
|
1652
1704
|
// the envelope of a deployment that exports nothing is byte-for-byte what it was.
|
|
1653
1705
|
...(exported > 0 ? { exported } : {}),
|
|
1706
|
+
...(emitted ? { emitted } : {}),
|
|
1654
1707
|
...(impersonation ? { impersonation: { honoured: true } } : {}),
|
|
1655
1708
|
// #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
|
|
1656
1709
|
...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
|
|
@@ -2262,7 +2315,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2262
2315
|
* as one `transactionSync`, so its reads and writes are one unit.
|
|
2263
2316
|
*/
|
|
2264
2317
|
async switchSystemSchedules(moduleId, scopeId, to, at) {
|
|
2265
|
-
|
|
2318
|
+
const outcome = await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
|
|
2319
|
+
// #1819: the instance that applied it, which the rewind hold's release rule reads.
|
|
2320
|
+
return { ...outcome, instance: this.instanceId };
|
|
2266
2321
|
}
|
|
2267
2322
|
/**
|
|
2268
2323
|
* The replay lever on this scope (#1705 PR 3): the kernel's `moveImportCursor`, queued with
|
|
@@ -2307,6 +2362,185 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2307
2362
|
async systemGrantsStatus() {
|
|
2308
2363
|
return systemGrantsStatus(this.switchSql(), new Date().toISOString());
|
|
2309
2364
|
}
|
|
2365
|
+
// -- the rewind hold (#1819) ------------------------------------------------
|
|
2366
|
+
// Two halves. On EVERY scope instance: its identity, and whether it has armed a rewind, so
|
|
2367
|
+
// the host can tell storage the rewind will discard from storage it restored. On ONE object
|
|
2368
|
+
// per deployment, the one named `SWITCH_HOLDS_NAME` and never a scope's own: the claims
|
|
2369
|
+
// themselves, because a rewind replaces the rewound scope's whole storage. The claims table
|
|
2370
|
+
// is created on first use, so no scope ever carries it.
|
|
2371
|
+
/** This instance, and nothing before or after it: a restart is a new id. */
|
|
2372
|
+
instanceId = crypto.randomUUID();
|
|
2373
|
+
/** Whether this instance armed a rewind: then its writes are discarded at the restart. */
|
|
2374
|
+
rewindArmed = false;
|
|
2375
|
+
/** Whether this instance began arming one: set before the restore call, and never cleared. */
|
|
2376
|
+
rewindArming = false;
|
|
2377
|
+
/**
|
|
2378
|
+
* Which instance is serving, and whether it armed a rewind or began to. For an ambiguous
|
|
2379
|
+
* rewind throw: arming counts as armed, because the restore call may yet land.
|
|
2380
|
+
*/
|
|
2381
|
+
rewindProbe() {
|
|
2382
|
+
return { instance: this.instanceId, armed: this.rewindArmed || this.rewindArming };
|
|
2383
|
+
}
|
|
2384
|
+
/** Whether this instance has created the claims table; it never goes away once it exists. */
|
|
2385
|
+
switchHoldsReady = false;
|
|
2386
|
+
switchHoldsTable() {
|
|
2387
|
+
if (this.switchHoldsReady)
|
|
2388
|
+
return;
|
|
2389
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_holds (
|
|
2390
|
+
scope_id TEXT NOT NULL,
|
|
2391
|
+
module_id TEXT NOT NULL,
|
|
2392
|
+
claim_id TEXT NOT NULL,
|
|
2393
|
+
state TEXT NOT NULL,
|
|
2394
|
+
doomed TEXT,
|
|
2395
|
+
held_at TEXT NOT NULL,
|
|
2396
|
+
PRIMARY KEY (scope_id, module_id, claim_id)
|
|
2397
|
+
)`);
|
|
2398
|
+
// #1839: the ON tombstones, and the one counter that orders them against a rewind's reads.
|
|
2399
|
+
// This object's storage is never rewound, so the counter only ever goes up.
|
|
2400
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_hold_ons (
|
|
2401
|
+
scope_id TEXT NOT NULL,
|
|
2402
|
+
module_id TEXT NOT NULL,
|
|
2403
|
+
seq INTEGER NOT NULL,
|
|
2404
|
+
PRIMARY KEY (scope_id, module_id)
|
|
2405
|
+
)`);
|
|
2406
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_hold_seq (
|
|
2407
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
2408
|
+
seq INTEGER NOT NULL
|
|
2409
|
+
)`);
|
|
2410
|
+
this.switchHoldsReady = true;
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* #1839: where the ON counter stands now. A rewind reads this BEFORE it reads the scope's status,
|
|
2414
|
+
* and hands it to `switchHoldClaim`: an ON tombstoned after it may have moved after that read.
|
|
2415
|
+
*/
|
|
2416
|
+
switchHoldToken() {
|
|
2417
|
+
this.switchHoldsTable();
|
|
2418
|
+
const row = this.sql.exec('SELECT seq FROM _substrat_switch_hold_seq WHERE id = 1').toArray()[0];
|
|
2419
|
+
return row?.seq ?? 0;
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* #1839: an operator's ON has moved: release the claims it read before the move (S0) on this
|
|
2423
|
+
* module, and tombstone the module with the next count, in one transaction. Called only AFTER the
|
|
2424
|
+
* move, so a rewind whose token predates the tombstone may have read the switch before the ON;
|
|
2425
|
+
* `switchHoldClaim` then refuses that stale read's row. A row it inserted before this ran is
|
|
2426
|
+
* either one of the S0 claims (released here) or a claim created after S0, which is left alone.
|
|
2427
|
+
*/
|
|
2428
|
+
switchHoldOn(scopeId, moduleId, claimIds) {
|
|
2429
|
+
this.switchHoldsTable();
|
|
2430
|
+
this.ctx.storage.transactionSync(() => {
|
|
2431
|
+
const seq = this.sql
|
|
2432
|
+
.exec(`INSERT INTO _substrat_switch_hold_seq (id, seq) VALUES (1, 1)
|
|
2433
|
+
ON CONFLICT (id) DO UPDATE SET seq = seq + 1 RETURNING seq`)
|
|
2434
|
+
.toArray()[0].seq;
|
|
2435
|
+
this.sql.exec(`INSERT INTO _substrat_switch_hold_ons (scope_id, module_id, seq) VALUES (?, ?, ?)
|
|
2436
|
+
ON CONFLICT (scope_id, module_id) DO UPDATE SET seq = excluded.seq`, scopeId, moduleId, seq);
|
|
2437
|
+
for (const claimId of claimIds) {
|
|
2438
|
+
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND module_id = ? AND claim_id = ?', scopeId, moduleId, claimId);
|
|
2439
|
+
}
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* One rewind's claim on these modules, `pending` until the rewind has armed. Only the rewind
|
|
2444
|
+
* that owns the claim calls this, and only before it arms. Each new row is stamped with THIS
|
|
2445
|
+
* object's clock, and `switchHoldYoungestMs` measures against the same clock (#1839). A row
|
|
2446
|
+
* already there keeps its stamp.
|
|
2447
|
+
*
|
|
2448
|
+
* `token` is `switchHoldToken` as the rewind read it before the status read these modules came
|
|
2449
|
+
* from. A module tombstoned by an ON after that is skipped: the read may predate the ON, and
|
|
2450
|
+
* the ON is the operator's newer word (#1839).
|
|
2451
|
+
*/
|
|
2452
|
+
switchHoldClaim(scopeId, moduleIds, claimId, token) {
|
|
2453
|
+
this.switchHoldsTable();
|
|
2454
|
+
const at = new Date().toISOString();
|
|
2455
|
+
this.ctx.storage.transactionSync(() => {
|
|
2456
|
+
for (const moduleId of moduleIds) {
|
|
2457
|
+
this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
|
|
2458
|
+
SELECT ?, ?, ?, 'pending', NULL, ?
|
|
2459
|
+
WHERE NOT EXISTS (
|
|
2460
|
+
SELECT 1 FROM _substrat_switch_hold_ons WHERE scope_id = ? AND module_id = ? AND seq > ?
|
|
2461
|
+
)`, scopeId, moduleId, claimId, at, scopeId, moduleId, token);
|
|
2462
|
+
}
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
/**
|
|
2466
|
+
* #1839: an OFF joins other rewinds' claims. The row takes its claim's state and doomed instance
|
|
2467
|
+
* from the claim's own rows, in this transaction, so it can never be staler than the claim; it
|
|
2468
|
+
* is stamped with this object's clock. A claim with no rows left is not joined: this cannot tell
|
|
2469
|
+
* a claim its rewind dropped (a refusal) from one an ON emptied, and a row created for a dropped
|
|
2470
|
+
* one would hold the module with no rewind behind it.
|
|
2471
|
+
*/
|
|
2472
|
+
switchHoldJoin(scopeId, moduleId, claimIds) {
|
|
2473
|
+
this.switchHoldsTable();
|
|
2474
|
+
const at = new Date().toISOString();
|
|
2475
|
+
this.ctx.storage.transactionSync(() => {
|
|
2476
|
+
for (const claimId of claimIds) {
|
|
2477
|
+
this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
|
|
2478
|
+
SELECT scope_id, ?, claim_id, state, doomed, ? FROM _substrat_switch_holds
|
|
2479
|
+
WHERE scope_id = ? AND claim_id = ? LIMIT 1`, moduleId, at, scopeId, claimId);
|
|
2480
|
+
}
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* #1839: how long ago, on this object's clock, the youngest row of one claim was stamped; null
|
|
2485
|
+
* once the claim has no rows. The rewind waits on this, so its age and the stamp are read on
|
|
2486
|
+
* one clock and no two clocks are ever compared.
|
|
2487
|
+
*/
|
|
2488
|
+
switchHoldYoungestMs(scopeId, claimId) {
|
|
2489
|
+
this.switchHoldsTable();
|
|
2490
|
+
const row = this.sql
|
|
2491
|
+
.exec('SELECT MAX(held_at) AS youngest FROM _substrat_switch_holds WHERE scope_id = ? AND claim_id = ?', scopeId, claimId)
|
|
2492
|
+
.toArray()[0];
|
|
2493
|
+
return row.youngest ? Date.now() - Date.parse(row.youngest) : null;
|
|
2494
|
+
}
|
|
2495
|
+
/** The rewind armed (or may have): its claim is `armed`, naming the instance it doomed. */
|
|
2496
|
+
switchHoldArm(scopeId, claimId, doomed) {
|
|
2497
|
+
this.switchHoldsTable();
|
|
2498
|
+
this.sql.exec(`UPDATE _substrat_switch_holds SET state = 'armed', doomed = ? WHERE scope_id = ? AND claim_id = ?`, doomed, scopeId, claimId);
|
|
2499
|
+
}
|
|
2500
|
+
/** A definite refusal: this rewind's own claim goes, and no other. */
|
|
2501
|
+
switchHoldDrop(scopeId, claimId) {
|
|
2502
|
+
this.switchHoldsTable();
|
|
2503
|
+
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND claim_id = ?', scopeId, claimId);
|
|
2504
|
+
}
|
|
2505
|
+
/** Every held (scope, module) in the deployment, one read for a whole sweep pass. */
|
|
2506
|
+
switchHoldsAll() {
|
|
2507
|
+
this.switchHoldsTable();
|
|
2508
|
+
return this.sql
|
|
2509
|
+
.exec('SELECT DISTINCT scope_id, module_id FROM _substrat_switch_holds')
|
|
2510
|
+
.toArray()
|
|
2511
|
+
.map((r) => ({ scopeId: r.scope_id, moduleId: r.module_id }));
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
2514
|
+
* Every claim row on one scope, for a switch move to read before it moves: the release rule
|
|
2515
|
+
* reads the module's own rows, and an OFF (#1839) the scope's claims it would join.
|
|
2516
|
+
*/
|
|
2517
|
+
switchHoldClaims(scopeId) {
|
|
2518
|
+
this.switchHoldsTable();
|
|
2519
|
+
return this.sql
|
|
2520
|
+
.exec('SELECT claim_id, module_id, state, doomed, held_at FROM _substrat_switch_holds WHERE scope_id = ?', scopeId)
|
|
2521
|
+
.toArray()
|
|
2522
|
+
.map((r) => ({
|
|
2523
|
+
claimId: r.claim_id,
|
|
2524
|
+
moduleId: r.module_id,
|
|
2525
|
+
state: r.state,
|
|
2526
|
+
doomed: r.doomed ?? null,
|
|
2527
|
+
heldAt: r.held_at,
|
|
2528
|
+
}));
|
|
2529
|
+
}
|
|
2530
|
+
/** Release these claims on one module, or every claim on the scope when `claimIds` is null. */
|
|
2531
|
+
switchHoldRelease(scopeId, moduleId, claimIds) {
|
|
2532
|
+
this.switchHoldsTable();
|
|
2533
|
+
this.ctx.storage.transactionSync(() => {
|
|
2534
|
+
if (moduleId === null || claimIds === null) {
|
|
2535
|
+
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ?', scopeId);
|
|
2536
|
+
this.sql.exec('DELETE FROM _substrat_switch_hold_ons WHERE scope_id = ?', scopeId);
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
for (const claimId of claimIds) {
|
|
2540
|
+
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND module_id = ? AND claim_id = ?', scopeId, moduleId, claimId);
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2310
2544
|
/**
|
|
2311
2545
|
* Where every peer this scope holds or has held grants for stands (#1706) — the
|
|
2312
2546
|
* kernel's `peerGrantsStatus`, over this DO's own storage, and the same plain
|
|
@@ -2429,12 +2663,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2429
2663
|
* `kind` is LAST because this is a positional RPC — see the interface's own note
|
|
2430
2664
|
* in `host.ts`. An old DO drops a trailing argument; a leading one it binds, and
|
|
2431
2665
|
* every value after it lands one column to the left, silently.
|
|
2666
|
+
*
|
|
2667
|
+
* `invocationId` (#1525) is appended after `kind` for the same reason and defaulted
|
|
2668
|
+
* to null, so a coordinator that sends none — every freshness verdict, and every
|
|
2669
|
+
* caller predating this argument — records the honest "no call was carried".
|
|
2432
2670
|
*/
|
|
2433
|
-
async recordScheduleRun(unit, at, status, kind) {
|
|
2434
|
-
this.sql.exec(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
|
|
2435
|
-
VALUES (?, ?, ?, ?)
|
|
2671
|
+
async recordScheduleRun(unit, at, status, kind, invocationId) {
|
|
2672
|
+
this.sql.exec(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status, invocation_id)
|
|
2673
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2436
2674
|
ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
|
|
2437
|
-
last_status = excluded.last_status
|
|
2675
|
+
last_status = excluded.last_status,
|
|
2676
|
+
invocation_id = excluded.invocation_id`, kind, unit, at, status, invocationId ?? null);
|
|
2438
2677
|
}
|
|
2439
2678
|
// -- the resumable-run driver's store (#1577) -----------------------------
|
|
2440
2679
|
//
|
|
@@ -3092,6 +3331,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3092
3331
|
// #1237: the invocation column on a DO created before it. Nullable, and the
|
|
3093
3332
|
// null is honestly "none was carried" for every legacy row.
|
|
3094
3333
|
'ALTER TABLE _substrat_outbox ADD COLUMN invocation_id TEXT',
|
|
3334
|
+
// #1525: the invocation a fired schedule ran in, on a scope DO created before
|
|
3335
|
+
// the column. Nullable, and the null is honestly "no call was carried" for
|
|
3336
|
+
// every legacy row — a past run's id cannot be recovered afterwards, exactly
|
|
3337
|
+
// as the other #1525 columns above argued.
|
|
3338
|
+
'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
|
|
3095
3339
|
]) {
|
|
3096
3340
|
try {
|
|
3097
3341
|
this.sql.exec(alter);
|
|
@@ -3142,7 +3386,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3142
3386
|
this.sql.exec(stmt);
|
|
3143
3387
|
});
|
|
3144
3388
|
}
|
|
3145
|
-
async importDump(tables, destScopeId
|
|
3389
|
+
async importDump(tables, destScopeId,
|
|
3390
|
+
/** The directory's recorded-off modules (#1742), switched off on `destScopeId` right after
|
|
3391
|
+
* the replay re-points the grants, in the same event: a dump from before the switch was
|
|
3392
|
+
* pulled carries the grants live and no marker. Needs `destScopeId`, the scope restored. */
|
|
3393
|
+
switchOff) {
|
|
3146
3394
|
// The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
|
|
3147
3395
|
//
|
|
3148
3396
|
// Two distinct FK hazards, and both need the deferral — the second is why it cannot
|
|
@@ -3161,7 +3409,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3161
3409
|
// Deferral holds every check until commit, by which point the old rows are gone and
|
|
3162
3410
|
// the new ones are all in. It also covers what a topological sort cannot express: FK
|
|
3163
3411
|
// cycles, and self-referencing rows within a single table.
|
|
3164
|
-
|
|
3412
|
+
// A dump written before indexes were excluded may still carry them; skipped
|
|
3413
|
+
// rather than failing a restore over data about to be recomputed.
|
|
3414
|
+
const replayable = tables.filter((t) => !isSearchIndexTable(t.name));
|
|
3415
|
+
// The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
|
|
3416
|
+
// in the string it is given, so a `ddl` with anything appended to its CREATE
|
|
3417
|
+
// TABLE executed that too — with entirely plain identifiers, which is why no
|
|
3418
|
+
// amount of name checking reaches it. There is no prepare step here to compile
|
|
3419
|
+
// only the first statement, so the text itself has to be the one statement.
|
|
3420
|
+
// Pure input validation, so it runs before the first DROP: a refused dump touches nothing.
|
|
3421
|
+
assertReplayableDump(replayable, { maxColumns: DO_SQL_LIMITS.columns });
|
|
3422
|
+
const switched = await this.ctx.storage.transaction(async () => {
|
|
3165
3423
|
this.sql.exec('PRAGMA defer_foreign_keys = ON');
|
|
3166
3424
|
// Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
|
|
3167
3425
|
const existing = this.sql
|
|
@@ -3175,15 +3433,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3175
3433
|
continue;
|
|
3176
3434
|
this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
3177
3435
|
}
|
|
3178
|
-
// A dump written before indexes were excluded may still carry them; skipped
|
|
3179
|
-
// rather than failing a restore over data about to be recomputed.
|
|
3180
|
-
const replayable = tables.filter((t) => !isSearchIndexTable(t.name));
|
|
3181
|
-
// The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
|
|
3182
|
-
// in the string it is given, so a `ddl` with anything appended to its CREATE
|
|
3183
|
-
// TABLE executed that too — with entirely plain identifiers, which is why no
|
|
3184
|
-
// amount of name checking reaches it. There is no prepare step here to compile
|
|
3185
|
-
// only the first statement, so the text itself has to be the one statement.
|
|
3186
|
-
assertReplayableDump(replayable);
|
|
3187
3436
|
for (const t of replayable)
|
|
3188
3437
|
this.sql.exec(t.ddl);
|
|
3189
3438
|
for (const t of replayable) {
|
|
@@ -3195,25 +3444,39 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3195
3444
|
for (const row of t.rows)
|
|
3196
3445
|
this.sql.exec(insert, ...row);
|
|
3197
3446
|
}
|
|
3447
|
+
// Re-assert the kernel spine (#321). A dump captured from a WORLD that stores some
|
|
3448
|
+
// `_substrat_*` tables ELSEWHERE carries only a subset — an `@substrat-run/adapter-
|
|
3449
|
+
// sqlite` scope file, for instance, keeps `_substrat_roles`/`_substrat_tenant_tuples`
|
|
3450
|
+
// in its DIRECTORY database, so its per-scope dump omits them. Replaying such a dump
|
|
3451
|
+
// verbatim would leave this DO missing those spine tables, and the very next
|
|
3452
|
+
// permission check would raise a bare `no such table: _substrat_roles`. KERNEL_DDL is
|
|
3453
|
+
// all IF NOT EXISTS, so this recreates only what the dump did not carry (empty), and
|
|
3454
|
+
// never disturbs a table the dump DID bring. Roles land empty here and are re-projected
|
|
3455
|
+
// by the restore's repair leg (host.projectRolesLocal) — the spine's job is only to
|
|
3456
|
+
// exist so the checker can read it.
|
|
3457
|
+
for (const stmt of splitSqlStatements(KERNEL_DDL))
|
|
3458
|
+
this.sql.exec(stmt);
|
|
3459
|
+
// …and the additive columns, for the same reason KERNEL_DDL is re-asserted: a
|
|
3460
|
+
// dump captured before a column existed replays DDL WITHOUT it, and IF NOT
|
|
3461
|
+
// EXISTS cannot widen a table the dump did bring. Without this, the next emit
|
|
3462
|
+
// in this instance fails with `no such column` until a cold start re-runs the
|
|
3463
|
+
// constructor's pass.
|
|
3464
|
+
this.applySpineColumnAdditions();
|
|
3465
|
+
// #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
|
|
3466
|
+
// not this one. Dropped, so a restore never carries a receipt over; the repair
|
|
3467
|
+
// projection that follows writes this scope's own.
|
|
3468
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
|
|
3469
|
+
// Re-point the restored grants at THIS scope (after the spine exists, so a dump
|
|
3470
|
+
// that carried no tuples table still finds one here).
|
|
3471
|
+
if (destScopeId)
|
|
3472
|
+
this.rewriteScopeTuples(destScopeId);
|
|
3473
|
+
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
3474
|
+
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
3475
|
+
// the dump's grants never commit live without the switch that should cover them.
|
|
3476
|
+
return destScopeId && switchOff
|
|
3477
|
+
? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
|
|
3478
|
+
: [];
|
|
3198
3479
|
});
|
|
3199
|
-
// Re-assert the kernel spine (#321). A dump captured from a WORLD that stores some
|
|
3200
|
-
// `_substrat_*` tables ELSEWHERE carries only a subset — an `@substrat-run/adapter-
|
|
3201
|
-
// sqlite` scope file, for instance, keeps `_substrat_roles`/`_substrat_tenant_tuples`
|
|
3202
|
-
// in its DIRECTORY database, so its per-scope dump omits them. Replaying such a dump
|
|
3203
|
-
// verbatim would leave this DO missing those spine tables, and the very next
|
|
3204
|
-
// permission check would raise a bare `no such table: _substrat_roles`. KERNEL_DDL is
|
|
3205
|
-
// all IF NOT EXISTS, so this recreates only what the dump did not carry (empty), and
|
|
3206
|
-
// never disturbs a table the dump DID bring. Roles land empty here and are re-projected
|
|
3207
|
-
// by the restore's repair leg (host.projectRolesLocal) — the spine's job is only to
|
|
3208
|
-
// exist so the checker can read it.
|
|
3209
|
-
for (const stmt of splitSqlStatements(KERNEL_DDL))
|
|
3210
|
-
this.sql.exec(stmt);
|
|
3211
|
-
// …and the additive columns, for the same reason KERNEL_DDL is re-asserted: a
|
|
3212
|
-
// dump captured before a column existed replays DDL WITHOUT it, and IF NOT
|
|
3213
|
-
// EXISTS cannot widen a table the dump did bring. Without this, the next emit
|
|
3214
|
-
// in this instance fails with `no such column` until a cold start re-runs the
|
|
3215
|
-
// constructor's pass.
|
|
3216
|
-
this.applySpineColumnAdditions();
|
|
3217
3480
|
// Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
|
|
3218
3481
|
// create, so it also repairs an index a dump left stale, and the triggers it
|
|
3219
3482
|
// recreates are what keep the restored scope in step from here. Skipped for a plan
|
|
@@ -3228,10 +3491,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3228
3491
|
for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
|
|
3229
3492
|
this.sql.exec(stmt);
|
|
3230
3493
|
}
|
|
3231
|
-
// Re-point the restored grants at THIS scope (after the spine exists, so a dump
|
|
3232
|
-
// that carried no tuples table still finds one here).
|
|
3233
|
-
if (destScopeId)
|
|
3234
|
-
this.rewriteScopeTuples(destScopeId);
|
|
3235
3494
|
// The frontier arrived with the dump — refresh the in-memory applied set so a
|
|
3236
3495
|
// later migrate() builds on the imported state, not the provisioning state.
|
|
3237
3496
|
this.applied.clear();
|
|
@@ -3263,6 +3522,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3263
3522
|
// is complete applies nothing, so there is nothing to report either way.
|
|
3264
3523
|
this.migrationPromise = undefined;
|
|
3265
3524
|
this.lastFailure = null;
|
|
3525
|
+
return switched;
|
|
3266
3526
|
}
|
|
3267
3527
|
/**
|
|
3268
3528
|
* Re-point scope-level tuples at the scope they now live in.
|
|
@@ -3666,6 +3926,19 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3666
3926
|
principal: capabilityId ? capabilityId : principal,
|
|
3667
3927
|
sql: guardSecrets(doScopedSql(sql), minted),
|
|
3668
3928
|
now: () => at,
|
|
3929
|
+
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
3930
|
+
// and its fields. A consumer runs under the system override, so it logs as `system`.
|
|
3931
|
+
// The invocation id is read per call, as the DO holds it for the queued body's length.
|
|
3932
|
+
log: moduleLog({
|
|
3933
|
+
tenantId,
|
|
3934
|
+
scopeId,
|
|
3935
|
+
operation: operation ?? null,
|
|
3936
|
+
invocationId: () => this.invocationId,
|
|
3937
|
+
principalKind: systemActor ? 'system' : subject.kind,
|
|
3938
|
+
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
3939
|
+
// log's text is not JSON.
|
|
3940
|
+
redact: (text) => redactSecretText(text, minted),
|
|
3941
|
+
}),
|
|
3669
3942
|
emit: (event) => {
|
|
3670
3943
|
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
3671
3944
|
const parsed = domainEventInput.parse(event);
|
|
@@ -3989,32 +4262,50 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3989
4262
|
* (a role-only re-projection like the restore repair) leaves projected keys
|
|
3990
4263
|
* untouched; passing a list — even `[]` — full-replaces them, which is how a
|
|
3991
4264
|
* revoked connection stops being sealable to. */
|
|
3992
|
-
connectionKeys
|
|
4265
|
+
connectionKeys,
|
|
4266
|
+
/** The directory's recorded-off modules (#1742), switched off in THIS unit right after the
|
|
4267
|
+
* seat, so no sweep can run the grants the seat just re-created. `scopeId` is the scope
|
|
4268
|
+
* this projection provisions (the object of `scopeTuples`), never another. */
|
|
4269
|
+
switchOff) {
|
|
3993
4270
|
if (await this.isReaped())
|
|
3994
|
-
return;
|
|
4271
|
+
return [];
|
|
3995
4272
|
// A projection that arrives after this scope was reaped is dropped, not
|
|
3996
4273
|
// applied: writing it would recreate storage the platform deliberately
|
|
3997
4274
|
// destroyed. Silent rather than throwing — the fan-out is a best-effort
|
|
3998
4275
|
// convergence over many scopes and one dead sibling must not fail the others,
|
|
3999
4276
|
// and a reaped scope converging to "nothing" IS convergence.
|
|
4000
|
-
|
|
4277
|
+
// The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
|
|
4278
|
+
// serializes but does not roll back, and a role insert that throws part-way must not leave
|
|
4279
|
+
// the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
|
|
4280
|
+
return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
|
|
4281
|
+
// #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
|
|
4282
|
+
// a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
|
|
4283
|
+
// moves, so a misdirected projection can never re-point a scope or leave its roles behind.
|
|
4284
|
+
// A restore does not trip this: `importDump` drops the dump's receipt, so the repair
|
|
4285
|
+
// projection that follows finds none and writes the destination's own.
|
|
4286
|
+
const held = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
|
|
4287
|
+
if (held && held.value !== tenantId) {
|
|
4288
|
+
throw substratError('conflict', `applyProjection refused: this scope was provisioned for tenant ${held.value}, and a projection for tenant ${tenantId} would re-point it`);
|
|
4289
|
+
}
|
|
4290
|
+
// Written before any guard below can return early, so every projection leaves it.
|
|
4291
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('provisioned_for', ?)`, tenantId);
|
|
4001
4292
|
this.sql.exec(`DELETE FROM _substrat_roles WHERE tenant_id = ?`, tenantId);
|
|
4002
4293
|
for (const r of roles) {
|
|
4003
4294
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, revoked_at)
|
|
4004
|
-
|
|
4295
|
+
VALUES (?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source);
|
|
4005
4296
|
}
|
|
4006
4297
|
this.sql.exec(`DELETE FROM _substrat_tenant_tuples WHERE tenant_id = ?`, tenantId);
|
|
4007
4298
|
for (const t of tuples) {
|
|
4008
4299
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
|
|
4009
|
-
|
|
4010
|
-
|
|
4300
|
+
(tenant_id, subject, relation, object, expires_at, revoked_at)
|
|
4301
|
+
VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
|
|
4011
4302
|
}
|
|
4012
4303
|
if (entitlements !== undefined) {
|
|
4013
4304
|
this.sql.exec(`DELETE FROM _substrat_entitlements WHERE tenant_id = ?`, tenantId);
|
|
4014
4305
|
for (const e of entitlements) {
|
|
4015
4306
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_entitlements
|
|
4016
|
-
|
|
4017
|
-
|
|
4307
|
+
(tenant_id, entitlement_key, expires_at, quota, plan)
|
|
4308
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
|
|
4018
4309
|
}
|
|
4019
4310
|
// #304: once a scope has been projected WITH entitlements even once, its gate
|
|
4020
4311
|
// switches from trust-upstream to strict fail-closed (a missing/expired key
|
|
@@ -4030,8 +4321,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4030
4321
|
this.sql.exec(`DELETE FROM _substrat_identity_links WHERE tenant_id = ?`, tenantId);
|
|
4031
4322
|
for (const i of identities) {
|
|
4032
4323
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_identity_links
|
|
4033
|
-
|
|
4034
|
-
|
|
4324
|
+
(tenant_id, provider, external_id, principal_id, scope_id)
|
|
4325
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
|
|
4035
4326
|
}
|
|
4036
4327
|
}
|
|
4037
4328
|
// #687: connection sealing keys ride the same snapshot. Full replace, so a
|
|
@@ -4041,8 +4332,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4041
4332
|
this.sql.exec(`DELETE FROM _substrat_connection_keys WHERE tenant_id = ?`, tenantId);
|
|
4042
4333
|
for (const k of connectionKeys) {
|
|
4043
4334
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_connection_keys
|
|
4044
|
-
|
|
4045
|
-
|
|
4335
|
+
(tenant_id, connection_id, provider, key_id, public_key)
|
|
4336
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
|
|
4046
4337
|
}
|
|
4047
4338
|
}
|
|
4048
4339
|
// #332: scope-level grants (the owner's role tuple at provision) are written in the
|
|
@@ -4072,9 +4363,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4072
4363
|
if (!st.lockout_reseat)
|
|
4073
4364
|
continue;
|
|
4074
4365
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
|
|
4075
|
-
|
|
4366
|
+
VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
|
|
4076
4367
|
}
|
|
4077
4368
|
}
|
|
4369
|
+
// #1742: the directory's recorded-off modules go back off HERE, after the seat and in
|
|
4370
|
+
// its unit. A wiped scope's seat has just re-created their `system:` grants (#1659), and
|
|
4371
|
+
// a re-assert arriving later from the control plane left a window in which this
|
|
4372
|
+
// deployment's own sweeper could fire them. Nothing runs between these two lines.
|
|
4373
|
+
const switched = switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
4078
4374
|
// #332: only switch on strict local enforcement when SOMEONE actually holds a role.
|
|
4079
4375
|
// A projection that leaves role definitions but no effective principal→role grant would
|
|
4080
4376
|
// make every check fail closed — a scope serving nothing but denials, unfixable from
|
|
@@ -4083,9 +4379,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4083
4379
|
// regardless of this flag, so the owner grant is written above in the same unit — this
|
|
4084
4380
|
// guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
|
|
4085
4381
|
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
|
|
4086
|
-
return;
|
|
4382
|
+
return switched;
|
|
4087
4383
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
|
|
4088
|
-
|
|
4384
|
+
return switched;
|
|
4385
|
+
}));
|
|
4089
4386
|
}
|
|
4090
4387
|
/**
|
|
4091
4388
|
* Resolve an external identity from this scope's projected links (#406) — the
|