@substrat-run/adapter-cloudflare 0.122.1 → 0.125.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 +462 -28
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +7 -9
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +7 -7
- package/dist/live-reads.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 +393 -70
- 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
|
-
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';
|
|
3
|
+
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, 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';
|
|
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`).
|
|
@@ -555,8 +556,20 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
555
556
|
lastFailure = null;
|
|
556
557
|
/** Passes of `applyPendingMigrations` that had work to do — see `migrationAttemptsOnInstance`. */
|
|
557
558
|
migrationRuns = 0;
|
|
559
|
+
/**
|
|
560
|
+
* TEST-ONLY (#1860): no production code reads this. An exact `'ping'` is answered
|
|
561
|
+
* by `setWebSocketAutoResponse` without reaching `webSocketMessage` at all, and
|
|
562
|
+
* this count is how a test proves that from outside the object — read through
|
|
563
|
+
* `runInDurableObject` — rather than assuming the runtime's documented behaviour.
|
|
564
|
+
*/
|
|
565
|
+
webSocketMessagesHandled = 0;
|
|
558
566
|
constructor(ctx, env) {
|
|
559
567
|
super(ctx, env);
|
|
568
|
+
// #1860: the client's 45s keep-alive (demos/ticket0/app/src/feed.ts) answered at
|
|
569
|
+
// the runtime level, before this object wakes for it. Without this, every idle
|
|
570
|
+
// subscriber's ping still reaches `webSocketMessage` and pins the DO in memory —
|
|
571
|
+
// exactly the hibernation cost `acceptWebSocket` (below) exists to avoid.
|
|
572
|
+
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
|
|
560
573
|
this.sql = ctx.storage.sql;
|
|
561
574
|
for (const stmt of splitSqlStatements(KERNEL_DDL)) {
|
|
562
575
|
this.sql.exec(stmt);
|
|
@@ -900,22 +913,37 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
900
913
|
return { consumes, cursors };
|
|
901
914
|
}
|
|
902
915
|
/**
|
|
903
|
-
* #1705 PR 2: does this DO hold a scope PROVISIONED here for `tenantId`?
|
|
916
|
+
* #1705 PR 2 / #1738: does this DO hold a scope PROVISIONED here for `tenantId`?
|
|
904
917
|
*
|
|
905
918
|
* 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
|
-
*
|
|
919
|
+
* have is what provisioning wrote. A scope this deployment never provisioned has none of
|
|
920
|
+
* it: its DO is empty, and a cross-vertical verb answering from it is a wrong answer rather
|
|
921
|
+
* than a failure: a watermark of "never read", or a delivery journaled into a scope that is
|
|
922
|
+
* not the install.
|
|
923
|
+
*
|
|
924
|
+
* The answer is the `provisioned_for` RECEIPT `applyProjection` writes (#1738): the tenant
|
|
925
|
+
* this scope was provisioned for, stated rather than inferred. A receipt decides alone, so
|
|
926
|
+
* a scope holding a receipt for ANOTHER tenant is refused whatever role rows sit in it (K-3's
|
|
927
|
+
* pair check), and a stray role row can no longer make a scope serve a tenant it was never
|
|
928
|
+
* provisioned for.
|
|
929
|
+
*
|
|
930
|
+
* A scope with NO receipt was provisioned before it existed, or has just been restored (a
|
|
931
|
+
* dump's receipt is dropped on import: it describes the scope the dump came from). Only
|
|
932
|
+
* then is the old inference used: `provisionScopeLocal` projected role definitions under
|
|
933
|
+
* the tenant, so a `_substrat_roles` row for it says the same thing. The next projection
|
|
934
|
+
* (reconcile, or the restore's repair) writes the receipt, after which the inference is
|
|
935
|
+
* never consulted again.
|
|
913
936
|
*
|
|
914
937
|
* Read without migrating, so asking about a foreign scope leaves its DO as empty as it found it.
|
|
915
938
|
*/
|
|
916
939
|
async servesTenant(tenantId) {
|
|
917
|
-
const
|
|
918
|
-
if (
|
|
940
|
+
const has = (table) => this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, table).toArray().length > 0;
|
|
941
|
+
if (has('_substrat_meta')) {
|
|
942
|
+
const receipt = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
|
|
943
|
+
if (receipt)
|
|
944
|
+
return receipt.value === tenantId;
|
|
945
|
+
}
|
|
946
|
+
if (!has('_substrat_roles'))
|
|
919
947
|
return false;
|
|
920
948
|
return this.sql.exec('SELECT 1 FROM _substrat_roles WHERE tenant_id = ? LIMIT 1', tenantId).toArray().length > 0;
|
|
921
949
|
}
|
|
@@ -1174,26 +1202,34 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1174
1202
|
async rewindToBookmark(bookmark, opts) {
|
|
1175
1203
|
const storage = this.ctx.storage;
|
|
1176
1204
|
if (typeof storage.onNextSessionRestoreBookmark !== 'function') {
|
|
1177
|
-
throw new Error(
|
|
1205
|
+
throw new Error(`${REWIND_REFUSED}point-in-time rewind is not available on this host (PITR is production-plane only)`);
|
|
1178
1206
|
}
|
|
1179
1207
|
const row = this.sql
|
|
1180
1208
|
.exec('SELECT taken_at FROM _substrat_migration_bookmarks WHERE bookmark = ?', bookmark)
|
|
1181
1209
|
.toArray()[0];
|
|
1182
1210
|
if (!opts?.force) {
|
|
1183
1211
|
if (!row) {
|
|
1184
|
-
throw new Error(
|
|
1212
|
+
throw new Error(`${REWIND_REFUSED}unknown bookmark — not one this scope recorded before a migration ` +
|
|
1213
|
+
'(force admits any bookmark Cloudflare holds)');
|
|
1185
1214
|
}
|
|
1186
1215
|
const ageMs = Date.now() - Date.parse(row.taken_at);
|
|
1187
1216
|
if (ageMs > 24 * 60 * 60 * 1000) {
|
|
1188
|
-
throw new Error(
|
|
1217
|
+
throw new Error(`${REWIND_REFUSED}bookmark is ${Math.round(ageMs / 3_600_000)}h old — rewinding discards EVERY write since; ` +
|
|
1189
1218
|
`use the backup restore path (#278), or force if the loss is intended`);
|
|
1190
1219
|
}
|
|
1191
1220
|
}
|
|
1221
|
+
// #1819: arming from BEFORE the call, so a probe that lands while it is in flight answers
|
|
1222
|
+
// armed. Left set if the call throws: that is not a refusal, and it may have armed.
|
|
1223
|
+
this.rewindArming = true;
|
|
1192
1224
|
const confirmed = await storage.onNextSessionRestoreBookmark(bookmark);
|
|
1225
|
+
// #1819: from here, everything this instance writes is discarded at the restart.
|
|
1226
|
+
this.rewindArmed = true;
|
|
1193
1227
|
// Answer first, then restart to complete the restore — an immediate abort
|
|
1194
1228
|
// would take the RPC response down with it.
|
|
1195
1229
|
setTimeout(() => this.ctx.abort(), 100);
|
|
1196
|
-
|
|
1230
|
+
// #1819: which instance is doomed, so the host can tell a switch move made here from one
|
|
1231
|
+
// made on the restored storage after the restart.
|
|
1232
|
+
return { rewindingTo: confirmed ?? bookmark, instance: this.instanceId };
|
|
1197
1233
|
}
|
|
1198
1234
|
/**
|
|
1199
1235
|
* The scope's live `connection:<id>` grant tuples (#726 gap 1) — the read half of
|
|
@@ -1249,6 +1285,21 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1249
1285
|
this.sql.exec(seat.sql, ...seat.params);
|
|
1250
1286
|
});
|
|
1251
1287
|
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Provisioning's seat of many tuples as ONE unit, then the directory's recorded-off
|
|
1290
|
+
* modules switched off in it (#1742) — the CP-full mirror of what `applyProjection` does
|
|
1291
|
+
* for a CP-less provision. One tuple per `seatTuple` call left a window between the seat
|
|
1292
|
+
* and the re-assert in which a sweep could run the grants just seated.
|
|
1293
|
+
*/
|
|
1294
|
+
async seatTuples(tuples, switchOff) {
|
|
1295
|
+
return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
|
|
1296
|
+
for (const t of tuples) {
|
|
1297
|
+
const seat = seatScopeTuple(t.subject, t.relation, t.object, t.expires_at);
|
|
1298
|
+
this.sql.exec(seat.sql, ...seat.params);
|
|
1299
|
+
}
|
|
1300
|
+
return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
1301
|
+
}));
|
|
1302
|
+
}
|
|
1252
1303
|
/**
|
|
1253
1304
|
* The dispatch capability's admission (#726 remedy B): this delivery may read the
|
|
1254
1305
|
* attachments of the entity its own spine row names, and no others.
|
|
@@ -1508,6 +1559,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1508
1559
|
// that exports something, and never for a read-only session, which commits nothing.
|
|
1509
1560
|
const exportTypes = impersonation?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
|
|
1510
1561
|
const exportMark = exportTypes.length > 0 ? this.outboxMark() : null;
|
|
1562
|
+
// #1746: the same mark, when the caller asked what this call emitted. The callback
|
|
1563
|
+
// itself never runs here — its presence is the request, and the report travels
|
|
1564
|
+
// back in the envelope for the coordinator to deliver.
|
|
1565
|
+
const emittedMark = invokeOptions?.onEmitted !== undefined && impersonation?.mode !== 'read-only'
|
|
1566
|
+
? (exportMark ?? this.outboxMark())
|
|
1567
|
+
: null;
|
|
1511
1568
|
let result;
|
|
1512
1569
|
let committedVersion = null;
|
|
1513
1570
|
// #116: set when this invocation was answered from a recording rather
|
|
@@ -1641,6 +1698,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1641
1698
|
// own retry backstop, which is what that backstop is for.
|
|
1642
1699
|
// #1525: still inside the queued body that set it, so these deliveries are this
|
|
1643
1700
|
// call's own work — the same tail its consumers' emits are stamped in.
|
|
1701
|
+
// #1746: read BEFORE `settleCommitted` drains the consumers, so the rows above the
|
|
1702
|
+
// mark are this operation's own emits. A replay committed nothing.
|
|
1703
|
+
let emitted;
|
|
1704
|
+
if (emittedMark !== null && !replayed) {
|
|
1705
|
+
const q = emittedSinceQuery(emittedMark, EMITTED_REPORT_CAP);
|
|
1706
|
+
emitted = emittedReportOf(this.sql.exec(q.sql, ...q.params).toArray());
|
|
1707
|
+
}
|
|
1644
1708
|
if (!replayed)
|
|
1645
1709
|
await this.settleCommitted(tenantId, scopeId, liveSince, this.invocationId);
|
|
1646
1710
|
// After the tail, so an exported type a consumer emitted in it counts too.
|
|
@@ -1651,6 +1715,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1651
1715
|
// #1705 PR 2: the coordinator fires `onExportedEvents` from this. Omitted at 0, so
|
|
1652
1716
|
// the envelope of a deployment that exports nothing is byte-for-byte what it was.
|
|
1653
1717
|
...(exported > 0 ? { exported } : {}),
|
|
1718
|
+
...(emitted ? { emitted } : {}),
|
|
1654
1719
|
...(impersonation ? { impersonation: { honoured: true } } : {}),
|
|
1655
1720
|
// #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
|
|
1656
1721
|
...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
|
|
@@ -1774,8 +1839,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1774
1839
|
* Deliberately NOT a place to let a client narrow or widen what it receives: a
|
|
1775
1840
|
* filter the client chooses is a filter the client can choose wrongly, and the
|
|
1776
1841
|
* only filter that matters here is the one it does not control.
|
|
1842
|
+
*
|
|
1843
|
+
* #1860: an exact `'ping'` is now answered by `setWebSocketAutoResponse` in the
|
|
1844
|
+
* constructor, at the runtime level, without waking this object — so this branch no
|
|
1845
|
+
* longer runs for it. Kept as the documented fallback for a client on an older
|
|
1846
|
+
* build, or a hop where the runtime's auto-response is unavailable.
|
|
1777
1847
|
*/
|
|
1778
1848
|
webSocketMessage(ws, message) {
|
|
1849
|
+
this.webSocketMessagesHandled++;
|
|
1779
1850
|
if (message === 'ping')
|
|
1780
1851
|
ws.send('pong');
|
|
1781
1852
|
}
|
|
@@ -2262,7 +2333,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2262
2333
|
* as one `transactionSync`, so its reads and writes are one unit.
|
|
2263
2334
|
*/
|
|
2264
2335
|
async switchSystemSchedules(moduleId, scopeId, to, at) {
|
|
2265
|
-
|
|
2336
|
+
const outcome = await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
|
|
2337
|
+
// #1819: the instance that applied it, which the rewind hold's release rule reads.
|
|
2338
|
+
return { ...outcome, instance: this.instanceId };
|
|
2266
2339
|
}
|
|
2267
2340
|
/**
|
|
2268
2341
|
* The replay lever on this scope (#1705 PR 3): the kernel's `moveImportCursor`, queued with
|
|
@@ -2307,6 +2380,185 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2307
2380
|
async systemGrantsStatus() {
|
|
2308
2381
|
return systemGrantsStatus(this.switchSql(), new Date().toISOString());
|
|
2309
2382
|
}
|
|
2383
|
+
// -- the rewind hold (#1819) ------------------------------------------------
|
|
2384
|
+
// Two halves. On EVERY scope instance: its identity, and whether it has armed a rewind, so
|
|
2385
|
+
// the host can tell storage the rewind will discard from storage it restored. On ONE object
|
|
2386
|
+
// per deployment, the one named `SWITCH_HOLDS_NAME` and never a scope's own: the claims
|
|
2387
|
+
// themselves, because a rewind replaces the rewound scope's whole storage. The claims table
|
|
2388
|
+
// is created on first use, so no scope ever carries it.
|
|
2389
|
+
/** This instance, and nothing before or after it: a restart is a new id. */
|
|
2390
|
+
instanceId = crypto.randomUUID();
|
|
2391
|
+
/** Whether this instance armed a rewind: then its writes are discarded at the restart. */
|
|
2392
|
+
rewindArmed = false;
|
|
2393
|
+
/** Whether this instance began arming one: set before the restore call, and never cleared. */
|
|
2394
|
+
rewindArming = false;
|
|
2395
|
+
/**
|
|
2396
|
+
* Which instance is serving, and whether it armed a rewind or began to. For an ambiguous
|
|
2397
|
+
* rewind throw: arming counts as armed, because the restore call may yet land.
|
|
2398
|
+
*/
|
|
2399
|
+
rewindProbe() {
|
|
2400
|
+
return { instance: this.instanceId, armed: this.rewindArmed || this.rewindArming };
|
|
2401
|
+
}
|
|
2402
|
+
/** Whether this instance has created the claims table; it never goes away once it exists. */
|
|
2403
|
+
switchHoldsReady = false;
|
|
2404
|
+
switchHoldsTable() {
|
|
2405
|
+
if (this.switchHoldsReady)
|
|
2406
|
+
return;
|
|
2407
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_holds (
|
|
2408
|
+
scope_id TEXT NOT NULL,
|
|
2409
|
+
module_id TEXT NOT NULL,
|
|
2410
|
+
claim_id TEXT NOT NULL,
|
|
2411
|
+
state TEXT NOT NULL,
|
|
2412
|
+
doomed TEXT,
|
|
2413
|
+
held_at TEXT NOT NULL,
|
|
2414
|
+
PRIMARY KEY (scope_id, module_id, claim_id)
|
|
2415
|
+
)`);
|
|
2416
|
+
// #1839: the ON tombstones, and the one counter that orders them against a rewind's reads.
|
|
2417
|
+
// This object's storage is never rewound, so the counter only ever goes up.
|
|
2418
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_hold_ons (
|
|
2419
|
+
scope_id TEXT NOT NULL,
|
|
2420
|
+
module_id TEXT NOT NULL,
|
|
2421
|
+
seq INTEGER NOT NULL,
|
|
2422
|
+
PRIMARY KEY (scope_id, module_id)
|
|
2423
|
+
)`);
|
|
2424
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_hold_seq (
|
|
2425
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
2426
|
+
seq INTEGER NOT NULL
|
|
2427
|
+
)`);
|
|
2428
|
+
this.switchHoldsReady = true;
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* #1839: where the ON counter stands now. A rewind reads this BEFORE it reads the scope's status,
|
|
2432
|
+
* and hands it to `switchHoldClaim`: an ON tombstoned after it may have moved after that read.
|
|
2433
|
+
*/
|
|
2434
|
+
switchHoldToken() {
|
|
2435
|
+
this.switchHoldsTable();
|
|
2436
|
+
const row = this.sql.exec('SELECT seq FROM _substrat_switch_hold_seq WHERE id = 1').toArray()[0];
|
|
2437
|
+
return row?.seq ?? 0;
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* #1839: an operator's ON has moved: release the claims it read before the move (S0) on this
|
|
2441
|
+
* module, and tombstone the module with the next count, in one transaction. Called only AFTER the
|
|
2442
|
+
* move, so a rewind whose token predates the tombstone may have read the switch before the ON;
|
|
2443
|
+
* `switchHoldClaim` then refuses that stale read's row. A row it inserted before this ran is
|
|
2444
|
+
* either one of the S0 claims (released here) or a claim created after S0, which is left alone.
|
|
2445
|
+
*/
|
|
2446
|
+
switchHoldOn(scopeId, moduleId, claimIds) {
|
|
2447
|
+
this.switchHoldsTable();
|
|
2448
|
+
this.ctx.storage.transactionSync(() => {
|
|
2449
|
+
const seq = this.sql
|
|
2450
|
+
.exec(`INSERT INTO _substrat_switch_hold_seq (id, seq) VALUES (1, 1)
|
|
2451
|
+
ON CONFLICT (id) DO UPDATE SET seq = seq + 1 RETURNING seq`)
|
|
2452
|
+
.toArray()[0].seq;
|
|
2453
|
+
this.sql.exec(`INSERT INTO _substrat_switch_hold_ons (scope_id, module_id, seq) VALUES (?, ?, ?)
|
|
2454
|
+
ON CONFLICT (scope_id, module_id) DO UPDATE SET seq = excluded.seq`, scopeId, moduleId, seq);
|
|
2455
|
+
for (const claimId of claimIds) {
|
|
2456
|
+
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND module_id = ? AND claim_id = ?', scopeId, moduleId, claimId);
|
|
2457
|
+
}
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* One rewind's claim on these modules, `pending` until the rewind has armed. Only the rewind
|
|
2462
|
+
* that owns the claim calls this, and only before it arms. Each new row is stamped with THIS
|
|
2463
|
+
* object's clock, and `switchHoldYoungestMs` measures against the same clock (#1839). A row
|
|
2464
|
+
* already there keeps its stamp.
|
|
2465
|
+
*
|
|
2466
|
+
* `token` is `switchHoldToken` as the rewind read it before the status read these modules came
|
|
2467
|
+
* from. A module tombstoned by an ON after that is skipped: the read may predate the ON, and
|
|
2468
|
+
* the ON is the operator's newer word (#1839).
|
|
2469
|
+
*/
|
|
2470
|
+
switchHoldClaim(scopeId, moduleIds, claimId, token) {
|
|
2471
|
+
this.switchHoldsTable();
|
|
2472
|
+
const at = new Date().toISOString();
|
|
2473
|
+
this.ctx.storage.transactionSync(() => {
|
|
2474
|
+
for (const moduleId of moduleIds) {
|
|
2475
|
+
this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
|
|
2476
|
+
SELECT ?, ?, ?, 'pending', NULL, ?
|
|
2477
|
+
WHERE NOT EXISTS (
|
|
2478
|
+
SELECT 1 FROM _substrat_switch_hold_ons WHERE scope_id = ? AND module_id = ? AND seq > ?
|
|
2479
|
+
)`, scopeId, moduleId, claimId, at, scopeId, moduleId, token);
|
|
2480
|
+
}
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* #1839: an OFF joins other rewinds' claims. The row takes its claim's state and doomed instance
|
|
2485
|
+
* from the claim's own rows, in this transaction, so it can never be staler than the claim; it
|
|
2486
|
+
* is stamped with this object's clock. A claim with no rows left is not joined: this cannot tell
|
|
2487
|
+
* a claim its rewind dropped (a refusal) from one an ON emptied, and a row created for a dropped
|
|
2488
|
+
* one would hold the module with no rewind behind it.
|
|
2489
|
+
*/
|
|
2490
|
+
switchHoldJoin(scopeId, moduleId, claimIds) {
|
|
2491
|
+
this.switchHoldsTable();
|
|
2492
|
+
const at = new Date().toISOString();
|
|
2493
|
+
this.ctx.storage.transactionSync(() => {
|
|
2494
|
+
for (const claimId of claimIds) {
|
|
2495
|
+
this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
|
|
2496
|
+
SELECT scope_id, ?, claim_id, state, doomed, ? FROM _substrat_switch_holds
|
|
2497
|
+
WHERE scope_id = ? AND claim_id = ? LIMIT 1`, moduleId, at, scopeId, claimId);
|
|
2498
|
+
}
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
/**
|
|
2502
|
+
* #1839: how long ago, on this object's clock, the youngest row of one claim was stamped; null
|
|
2503
|
+
* once the claim has no rows. The rewind waits on this, so its age and the stamp are read on
|
|
2504
|
+
* one clock and no two clocks are ever compared.
|
|
2505
|
+
*/
|
|
2506
|
+
switchHoldYoungestMs(scopeId, claimId) {
|
|
2507
|
+
this.switchHoldsTable();
|
|
2508
|
+
const row = this.sql
|
|
2509
|
+
.exec('SELECT MAX(held_at) AS youngest FROM _substrat_switch_holds WHERE scope_id = ? AND claim_id = ?', scopeId, claimId)
|
|
2510
|
+
.toArray()[0];
|
|
2511
|
+
return row.youngest ? Date.now() - Date.parse(row.youngest) : null;
|
|
2512
|
+
}
|
|
2513
|
+
/** The rewind armed (or may have): its claim is `armed`, naming the instance it doomed. */
|
|
2514
|
+
switchHoldArm(scopeId, claimId, doomed) {
|
|
2515
|
+
this.switchHoldsTable();
|
|
2516
|
+
this.sql.exec(`UPDATE _substrat_switch_holds SET state = 'armed', doomed = ? WHERE scope_id = ? AND claim_id = ?`, doomed, scopeId, claimId);
|
|
2517
|
+
}
|
|
2518
|
+
/** A definite refusal: this rewind's own claim goes, and no other. */
|
|
2519
|
+
switchHoldDrop(scopeId, claimId) {
|
|
2520
|
+
this.switchHoldsTable();
|
|
2521
|
+
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND claim_id = ?', scopeId, claimId);
|
|
2522
|
+
}
|
|
2523
|
+
/** Every held (scope, module) in the deployment, one read for a whole sweep pass. */
|
|
2524
|
+
switchHoldsAll() {
|
|
2525
|
+
this.switchHoldsTable();
|
|
2526
|
+
return this.sql
|
|
2527
|
+
.exec('SELECT DISTINCT scope_id, module_id FROM _substrat_switch_holds')
|
|
2528
|
+
.toArray()
|
|
2529
|
+
.map((r) => ({ scopeId: r.scope_id, moduleId: r.module_id }));
|
|
2530
|
+
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Every claim row on one scope, for a switch move to read before it moves: the release rule
|
|
2533
|
+
* reads the module's own rows, and an OFF (#1839) the scope's claims it would join.
|
|
2534
|
+
*/
|
|
2535
|
+
switchHoldClaims(scopeId) {
|
|
2536
|
+
this.switchHoldsTable();
|
|
2537
|
+
return this.sql
|
|
2538
|
+
.exec('SELECT claim_id, module_id, state, doomed, held_at FROM _substrat_switch_holds WHERE scope_id = ?', scopeId)
|
|
2539
|
+
.toArray()
|
|
2540
|
+
.map((r) => ({
|
|
2541
|
+
claimId: r.claim_id,
|
|
2542
|
+
moduleId: r.module_id,
|
|
2543
|
+
state: r.state,
|
|
2544
|
+
doomed: r.doomed ?? null,
|
|
2545
|
+
heldAt: r.held_at,
|
|
2546
|
+
}));
|
|
2547
|
+
}
|
|
2548
|
+
/** Release these claims on one module, or every claim on the scope when `claimIds` is null. */
|
|
2549
|
+
switchHoldRelease(scopeId, moduleId, claimIds) {
|
|
2550
|
+
this.switchHoldsTable();
|
|
2551
|
+
this.ctx.storage.transactionSync(() => {
|
|
2552
|
+
if (moduleId === null || claimIds === null) {
|
|
2553
|
+
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ?', scopeId);
|
|
2554
|
+
this.sql.exec('DELETE FROM _substrat_switch_hold_ons WHERE scope_id = ?', scopeId);
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
for (const claimId of claimIds) {
|
|
2558
|
+
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND module_id = ? AND claim_id = ?', scopeId, moduleId, claimId);
|
|
2559
|
+
}
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2310
2562
|
/**
|
|
2311
2563
|
* Where every peer this scope holds or has held grants for stands (#1706) — the
|
|
2312
2564
|
* kernel's `peerGrantsStatus`, over this DO's own storage, and the same plain
|
|
@@ -2429,12 +2681,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2429
2681
|
* `kind` is LAST because this is a positional RPC — see the interface's own note
|
|
2430
2682
|
* in `host.ts`. An old DO drops a trailing argument; a leading one it binds, and
|
|
2431
2683
|
* every value after it lands one column to the left, silently.
|
|
2684
|
+
*
|
|
2685
|
+
* `invocationId` (#1525) is appended after `kind` for the same reason and defaulted
|
|
2686
|
+
* to null, so a coordinator that sends none — every freshness verdict, and every
|
|
2687
|
+
* caller predating this argument — records the honest "no call was carried".
|
|
2432
2688
|
*/
|
|
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 (?, ?, ?, ?)
|
|
2689
|
+
async recordScheduleRun(unit, at, status, kind, invocationId) {
|
|
2690
|
+
this.sql.exec(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status, invocation_id)
|
|
2691
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2436
2692
|
ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
|
|
2437
|
-
last_status = excluded.last_status
|
|
2693
|
+
last_status = excluded.last_status,
|
|
2694
|
+
invocation_id = excluded.invocation_id`, kind, unit, at, status, invocationId ?? null);
|
|
2438
2695
|
}
|
|
2439
2696
|
// -- the resumable-run driver's store (#1577) -----------------------------
|
|
2440
2697
|
//
|
|
@@ -3092,6 +3349,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3092
3349
|
// #1237: the invocation column on a DO created before it. Nullable, and the
|
|
3093
3350
|
// null is honestly "none was carried" for every legacy row.
|
|
3094
3351
|
'ALTER TABLE _substrat_outbox ADD COLUMN invocation_id TEXT',
|
|
3352
|
+
// #1525: the invocation a fired schedule ran in, on a scope DO created before
|
|
3353
|
+
// the column. Nullable, and the null is honestly "no call was carried" for
|
|
3354
|
+
// every legacy row — a past run's id cannot be recovered afterwards, exactly
|
|
3355
|
+
// as the other #1525 columns above argued.
|
|
3356
|
+
'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
|
|
3095
3357
|
]) {
|
|
3096
3358
|
try {
|
|
3097
3359
|
this.sql.exec(alter);
|
|
@@ -3142,7 +3404,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3142
3404
|
this.sql.exec(stmt);
|
|
3143
3405
|
});
|
|
3144
3406
|
}
|
|
3145
|
-
async importDump(tables, destScopeId
|
|
3407
|
+
async importDump(tables, destScopeId,
|
|
3408
|
+
/** The directory's recorded-off modules (#1742), switched off on `destScopeId` right after
|
|
3409
|
+
* the replay re-points the grants, in the same event: a dump from before the switch was
|
|
3410
|
+
* pulled carries the grants live and no marker. Needs `destScopeId`, the scope restored. */
|
|
3411
|
+
switchOff) {
|
|
3146
3412
|
// The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
|
|
3147
3413
|
//
|
|
3148
3414
|
// Two distinct FK hazards, and both need the deferral — the second is why it cannot
|
|
@@ -3161,7 +3427,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3161
3427
|
// Deferral holds every check until commit, by which point the old rows are gone and
|
|
3162
3428
|
// the new ones are all in. It also covers what a topological sort cannot express: FK
|
|
3163
3429
|
// cycles, and self-referencing rows within a single table.
|
|
3164
|
-
|
|
3430
|
+
// A dump written before indexes were excluded may still carry them; skipped
|
|
3431
|
+
// rather than failing a restore over data about to be recomputed.
|
|
3432
|
+
const replayable = tables.filter((t) => !isSearchIndexTable(t.name));
|
|
3433
|
+
// The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
|
|
3434
|
+
// in the string it is given, so a `ddl` with anything appended to its CREATE
|
|
3435
|
+
// TABLE executed that too — with entirely plain identifiers, which is why no
|
|
3436
|
+
// amount of name checking reaches it. There is no prepare step here to compile
|
|
3437
|
+
// only the first statement, so the text itself has to be the one statement.
|
|
3438
|
+
// Pure input validation, so it runs before the first DROP: a refused dump touches nothing.
|
|
3439
|
+
assertReplayableDump(replayable, { maxColumns: DO_SQL_LIMITS.columns });
|
|
3440
|
+
const switched = await this.ctx.storage.transaction(async () => {
|
|
3165
3441
|
this.sql.exec('PRAGMA defer_foreign_keys = ON');
|
|
3166
3442
|
// Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
|
|
3167
3443
|
const existing = this.sql
|
|
@@ -3175,15 +3451,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3175
3451
|
continue;
|
|
3176
3452
|
this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
3177
3453
|
}
|
|
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
3454
|
for (const t of replayable)
|
|
3188
3455
|
this.sql.exec(t.ddl);
|
|
3189
3456
|
for (const t of replayable) {
|
|
@@ -3195,25 +3462,39 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3195
3462
|
for (const row of t.rows)
|
|
3196
3463
|
this.sql.exec(insert, ...row);
|
|
3197
3464
|
}
|
|
3465
|
+
// Re-assert the kernel spine (#321). A dump captured from a WORLD that stores some
|
|
3466
|
+
// `_substrat_*` tables ELSEWHERE carries only a subset — an `@substrat-run/adapter-
|
|
3467
|
+
// sqlite` scope file, for instance, keeps `_substrat_roles`/`_substrat_tenant_tuples`
|
|
3468
|
+
// in its DIRECTORY database, so its per-scope dump omits them. Replaying such a dump
|
|
3469
|
+
// verbatim would leave this DO missing those spine tables, and the very next
|
|
3470
|
+
// permission check would raise a bare `no such table: _substrat_roles`. KERNEL_DDL is
|
|
3471
|
+
// all IF NOT EXISTS, so this recreates only what the dump did not carry (empty), and
|
|
3472
|
+
// never disturbs a table the dump DID bring. Roles land empty here and are re-projected
|
|
3473
|
+
// by the restore's repair leg (host.projectRolesLocal) — the spine's job is only to
|
|
3474
|
+
// exist so the checker can read it.
|
|
3475
|
+
for (const stmt of splitSqlStatements(KERNEL_DDL))
|
|
3476
|
+
this.sql.exec(stmt);
|
|
3477
|
+
// …and the additive columns, for the same reason KERNEL_DDL is re-asserted: a
|
|
3478
|
+
// dump captured before a column existed replays DDL WITHOUT it, and IF NOT
|
|
3479
|
+
// EXISTS cannot widen a table the dump did bring. Without this, the next emit
|
|
3480
|
+
// in this instance fails with `no such column` until a cold start re-runs the
|
|
3481
|
+
// constructor's pass.
|
|
3482
|
+
this.applySpineColumnAdditions();
|
|
3483
|
+
// #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
|
|
3484
|
+
// not this one. Dropped, so a restore never carries a receipt over; the repair
|
|
3485
|
+
// projection that follows writes this scope's own.
|
|
3486
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
|
|
3487
|
+
// Re-point the restored grants at THIS scope (after the spine exists, so a dump
|
|
3488
|
+
// that carried no tuples table still finds one here).
|
|
3489
|
+
if (destScopeId)
|
|
3490
|
+
this.rewriteScopeTuples(destScopeId);
|
|
3491
|
+
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
3492
|
+
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
3493
|
+
// the dump's grants never commit live without the switch that should cover them.
|
|
3494
|
+
return destScopeId && switchOff
|
|
3495
|
+
? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
|
|
3496
|
+
: [];
|
|
3198
3497
|
});
|
|
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
3498
|
// Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
|
|
3218
3499
|
// create, so it also repairs an index a dump left stale, and the triggers it
|
|
3219
3500
|
// recreates are what keep the restored scope in step from here. Skipped for a plan
|
|
@@ -3228,10 +3509,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3228
3509
|
for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
|
|
3229
3510
|
this.sql.exec(stmt);
|
|
3230
3511
|
}
|
|
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
3512
|
// The frontier arrived with the dump — refresh the in-memory applied set so a
|
|
3236
3513
|
// later migrate() builds on the imported state, not the provisioning state.
|
|
3237
3514
|
this.applied.clear();
|
|
@@ -3263,6 +3540,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3263
3540
|
// is complete applies nothing, so there is nothing to report either way.
|
|
3264
3541
|
this.migrationPromise = undefined;
|
|
3265
3542
|
this.lastFailure = null;
|
|
3543
|
+
return switched;
|
|
3266
3544
|
}
|
|
3267
3545
|
/**
|
|
3268
3546
|
* Re-point scope-level tuples at the scope they now live in.
|
|
@@ -3666,6 +3944,19 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3666
3944
|
principal: capabilityId ? capabilityId : principal,
|
|
3667
3945
|
sql: guardSecrets(doScopedSql(sql), minted),
|
|
3668
3946
|
now: () => at,
|
|
3947
|
+
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
3948
|
+
// and its fields. A consumer runs under the system override, so it logs as `system`.
|
|
3949
|
+
// The invocation id is read per call, as the DO holds it for the queued body's length.
|
|
3950
|
+
log: moduleLog({
|
|
3951
|
+
tenantId,
|
|
3952
|
+
scopeId,
|
|
3953
|
+
operation: operation ?? null,
|
|
3954
|
+
invocationId: () => this.invocationId,
|
|
3955
|
+
principalKind: systemActor ? 'system' : subject.kind,
|
|
3956
|
+
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
3957
|
+
// log's text is not JSON.
|
|
3958
|
+
redact: (text) => redactSecretText(text, minted),
|
|
3959
|
+
}),
|
|
3669
3960
|
emit: (event) => {
|
|
3670
3961
|
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
3671
3962
|
const parsed = domainEventInput.parse(event);
|
|
@@ -3797,6 +4088,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3797
4088
|
*/
|
|
3798
4089
|
grant: async (principal, permission, entity) => {
|
|
3799
4090
|
assertImpersonationWrites(impersonation, 'ctx.grant');
|
|
4091
|
+
entityObjectRef(entity, 'ctx.grant'); // #1856: a tuple the walk can read back
|
|
3800
4092
|
const held = await runCheck(permission, entity);
|
|
3801
4093
|
if (!held.allowed) {
|
|
3802
4094
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
@@ -3804,6 +4096,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3804
4096
|
}
|
|
3805
4097
|
sql.exec(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
|
|
3806
4098
|
},
|
|
4099
|
+
/**
|
|
4100
|
+
* Deliberately NOT the #1856 grammar check `grant` and `link` make: a revoke writes
|
|
4101
|
+
* nothing the walk must read back, and a grant stored before that check existed must
|
|
4102
|
+
* stay removable.
|
|
4103
|
+
*/
|
|
3807
4104
|
revoke: async (principal, permission, entity) => {
|
|
3808
4105
|
assertImpersonationWrites(impersonation, 'ctx.revoke');
|
|
3809
4106
|
const held = await runCheck(permission, entity);
|
|
@@ -3832,6 +4129,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3832
4129
|
}),
|
|
3833
4130
|
link: (child, parent) => {
|
|
3834
4131
|
assertImpersonationWrites(impersonation, 'ctx.link');
|
|
4132
|
+
entityObjectRef(child, 'ctx.link'); // #1856: an edge the walk can read back
|
|
4133
|
+
entityObjectRef(parent, 'ctx.link');
|
|
3835
4134
|
const allowed = relations.get(child.entityType);
|
|
3836
4135
|
if (!allowed?.has(parent.entityType)) {
|
|
3837
4136
|
throw new Error(`undeclared entity relation: ${child.entityType} → ${parent.entityType} ` +
|
|
@@ -3989,32 +4288,50 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3989
4288
|
* (a role-only re-projection like the restore repair) leaves projected keys
|
|
3990
4289
|
* untouched; passing a list — even `[]` — full-replaces them, which is how a
|
|
3991
4290
|
* revoked connection stops being sealable to. */
|
|
3992
|
-
connectionKeys
|
|
4291
|
+
connectionKeys,
|
|
4292
|
+
/** The directory's recorded-off modules (#1742), switched off in THIS unit right after the
|
|
4293
|
+
* seat, so no sweep can run the grants the seat just re-created. `scopeId` is the scope
|
|
4294
|
+
* this projection provisions (the object of `scopeTuples`), never another. */
|
|
4295
|
+
switchOff) {
|
|
3993
4296
|
if (await this.isReaped())
|
|
3994
|
-
return;
|
|
4297
|
+
return [];
|
|
3995
4298
|
// A projection that arrives after this scope was reaped is dropped, not
|
|
3996
4299
|
// applied: writing it would recreate storage the platform deliberately
|
|
3997
4300
|
// destroyed. Silent rather than throwing — the fan-out is a best-effort
|
|
3998
4301
|
// convergence over many scopes and one dead sibling must not fail the others,
|
|
3999
4302
|
// and a reaped scope converging to "nothing" IS convergence.
|
|
4000
|
-
|
|
4303
|
+
// The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
|
|
4304
|
+
// serializes but does not roll back, and a role insert that throws part-way must not leave
|
|
4305
|
+
// the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
|
|
4306
|
+
return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
|
|
4307
|
+
// #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
|
|
4308
|
+
// a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
|
|
4309
|
+
// moves, so a misdirected projection can never re-point a scope or leave its roles behind.
|
|
4310
|
+
// A restore does not trip this: `importDump` drops the dump's receipt, so the repair
|
|
4311
|
+
// projection that follows finds none and writes the destination's own.
|
|
4312
|
+
const held = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
|
|
4313
|
+
if (held && held.value !== tenantId) {
|
|
4314
|
+
throw substratError('conflict', `applyProjection refused: this scope was provisioned for tenant ${held.value}, and a projection for tenant ${tenantId} would re-point it`);
|
|
4315
|
+
}
|
|
4316
|
+
// Written before any guard below can return early, so every projection leaves it.
|
|
4317
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('provisioned_for', ?)`, tenantId);
|
|
4001
4318
|
this.sql.exec(`DELETE FROM _substrat_roles WHERE tenant_id = ?`, tenantId);
|
|
4002
4319
|
for (const r of roles) {
|
|
4003
4320
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, revoked_at)
|
|
4004
|
-
|
|
4321
|
+
VALUES (?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source);
|
|
4005
4322
|
}
|
|
4006
4323
|
this.sql.exec(`DELETE FROM _substrat_tenant_tuples WHERE tenant_id = ?`, tenantId);
|
|
4007
4324
|
for (const t of tuples) {
|
|
4008
4325
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
|
|
4009
|
-
|
|
4010
|
-
|
|
4326
|
+
(tenant_id, subject, relation, object, expires_at, revoked_at)
|
|
4327
|
+
VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
|
|
4011
4328
|
}
|
|
4012
4329
|
if (entitlements !== undefined) {
|
|
4013
4330
|
this.sql.exec(`DELETE FROM _substrat_entitlements WHERE tenant_id = ?`, tenantId);
|
|
4014
4331
|
for (const e of entitlements) {
|
|
4015
4332
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_entitlements
|
|
4016
|
-
|
|
4017
|
-
|
|
4333
|
+
(tenant_id, entitlement_key, expires_at, quota, plan)
|
|
4334
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
|
|
4018
4335
|
}
|
|
4019
4336
|
// #304: once a scope has been projected WITH entitlements even once, its gate
|
|
4020
4337
|
// switches from trust-upstream to strict fail-closed (a missing/expired key
|
|
@@ -4030,8 +4347,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4030
4347
|
this.sql.exec(`DELETE FROM _substrat_identity_links WHERE tenant_id = ?`, tenantId);
|
|
4031
4348
|
for (const i of identities) {
|
|
4032
4349
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_identity_links
|
|
4033
|
-
|
|
4034
|
-
|
|
4350
|
+
(tenant_id, provider, external_id, principal_id, scope_id)
|
|
4351
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
|
|
4035
4352
|
}
|
|
4036
4353
|
}
|
|
4037
4354
|
// #687: connection sealing keys ride the same snapshot. Full replace, so a
|
|
@@ -4041,8 +4358,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4041
4358
|
this.sql.exec(`DELETE FROM _substrat_connection_keys WHERE tenant_id = ?`, tenantId);
|
|
4042
4359
|
for (const k of connectionKeys) {
|
|
4043
4360
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_connection_keys
|
|
4044
|
-
|
|
4045
|
-
|
|
4361
|
+
(tenant_id, connection_id, provider, key_id, public_key)
|
|
4362
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
|
|
4046
4363
|
}
|
|
4047
4364
|
}
|
|
4048
4365
|
// #332: scope-level grants (the owner's role tuple at provision) are written in the
|
|
@@ -4072,9 +4389,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4072
4389
|
if (!st.lockout_reseat)
|
|
4073
4390
|
continue;
|
|
4074
4391
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
|
|
4075
|
-
|
|
4392
|
+
VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
|
|
4076
4393
|
}
|
|
4077
4394
|
}
|
|
4395
|
+
// #1742: the directory's recorded-off modules go back off HERE, after the seat and in
|
|
4396
|
+
// its unit. A wiped scope's seat has just re-created their `system:` grants (#1659), and
|
|
4397
|
+
// a re-assert arriving later from the control plane left a window in which this
|
|
4398
|
+
// deployment's own sweeper could fire them. Nothing runs between these two lines.
|
|
4399
|
+
const switched = switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
4078
4400
|
// #332: only switch on strict local enforcement when SOMEONE actually holds a role.
|
|
4079
4401
|
// A projection that leaves role definitions but no effective principal→role grant would
|
|
4080
4402
|
// make every check fail closed — a scope serving nothing but denials, unfixable from
|
|
@@ -4083,9 +4405,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4083
4405
|
// regardless of this flag, so the owner grant is written above in the same unit — this
|
|
4084
4406
|
// guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
|
|
4085
4407
|
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
|
|
4086
|
-
return;
|
|
4408
|
+
return switched;
|
|
4087
4409
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
|
|
4088
|
-
|
|
4410
|
+
return switched;
|
|
4411
|
+
}));
|
|
4089
4412
|
}
|
|
4090
4413
|
/**
|
|
4091
4414
|
* Resolve an external identity from this scope's projected links (#406) — the
|