@substrat-run/adapter-cloudflare 0.135.0 → 0.137.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/checker.d.ts.map +1 -1
- package/dist/checker.js +3 -1
- package/dist/checker.js.map +1 -1
- package/dist/connector-calls.d.ts +11 -0
- package/dist/connector-calls.d.ts.map +1 -0
- package/dist/connector-calls.js +10 -0
- package/dist/connector-calls.js.map +1 -0
- package/dist/control-plane-do.d.ts +77 -3
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +250 -35
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +297 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +895 -132
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/live-reads.d.ts +5 -4
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +5 -4
- package/dist/live-reads.js.map +1 -1
- package/dist/r2.d.ts +2 -1
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js.map +1 -1
- package/dist/route-resolver.d.ts +7 -0
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +4 -0
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +2 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +896 -140
- package/dist/scope-do.js.map +1 -1
- package/dist/scope-sweeper-do.d.ts +32 -6
- package/dist/scope-sweeper-do.d.ts.map +1 -1
- package/dist/scope-sweeper-do.js +35 -5
- package/dist/scope-sweeper-do.js.map +1 -1
- package/dist/system-door.d.ts +21 -0
- package/dist/system-door.d.ts.map +1 -0
- package/dist/system-door.js +13 -0
- package/dist/system-door.js.map +1 -0
- package/package.json +10 -9
package/dist/scope-do.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { REWIND_REFUSED } from './rewind-refusal.js';
|
|
2
|
+
import { SYSTEM_DOOR_MOVED } from './system-door.js';
|
|
2
3
|
import { DurableObject } from 'cloudflare:workers';
|
|
3
|
-
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
4
|
-
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL,
|
|
4
|
+
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
+
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, } from '@substrat-run/kernel';
|
|
5
6
|
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';
|
|
6
7
|
import { OperationQueue } from './serialization.js';
|
|
7
8
|
import { doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
|
|
8
|
-
import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
9
|
+
import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
9
10
|
import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
|
|
10
|
-
import { assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpineTable, repointScopeGrants, spineColumnAdditions } from '@substrat-run/kernel';
|
|
11
|
+
import { CARRIED_AWAY_KEY, COPY_MARK_CLEARED_KEY, KEPT_COPY_REFUSAL, KEPT_DIVERGENT_KEY, LOAD_STAMP_KEY, STORE_LOCAL_META_KEYS, WRITE_REVISION_KEY, carriedAwayDump, isCopyMarkInsert, isWriteStatement, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
|
|
11
12
|
/**
|
|
12
13
|
* The key marking a scope DO whose storage was destroyed (`destroyStorage`).
|
|
13
14
|
* Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
|
|
@@ -15,6 +16,21 @@ import { assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpine
|
|
|
15
16
|
* without asking the directory.
|
|
16
17
|
*/
|
|
17
18
|
const REAPED_MARKER = '_substrat_reaped';
|
|
19
|
+
/**
|
|
20
|
+
* The `_substrat_meta` key of the tenant a scope was provisioned for (#1738): written by the first
|
|
21
|
+
* projection, never re-pointed, and what every door's pair check holds a request to (#2016).
|
|
22
|
+
*/
|
|
23
|
+
const PROVISIONED_FOR_KEY = 'provisioned_for';
|
|
24
|
+
/** #2016: the refusal of a write that would re-point a scope's tenant, in one wording. */
|
|
25
|
+
const tenantReceiptRefusal = (held, asked) => `refused: this scope was provisioned for tenant ${held}, not ${asked}`;
|
|
26
|
+
/** #2016: the spine tables whose rows say a scope holds state (`ScopeDO.holdsData`). */
|
|
27
|
+
const HOLDS_DATA_TABLES = ['_substrat_migrations', '_substrat_tuples', '_substrat_outbox', '_substrat_schedule_state'];
|
|
28
|
+
/** #2016: why `tenantId` is foreign to a scope (`held`, the receipt `tenantVerdict` read), in one wording. */
|
|
29
|
+
const foreignTenant = (held, tenantId) => held !== null
|
|
30
|
+
? tenantReceiptRefusal(held, tenantId)
|
|
31
|
+
: `refused: this scope holds role rows for another tenant and none for ${tenantId}`;
|
|
32
|
+
/** #2016: the load refusals `tenantReceiptRefusal` raised, told apart by identity, not by text. */
|
|
33
|
+
const tenantRefusals = new WeakSet();
|
|
18
34
|
/**
|
|
19
35
|
* The scope spine, as this adapter builds it — one of two hand-written copies (#969).
|
|
20
36
|
*
|
|
@@ -322,11 +338,15 @@ const KERNEL_DDL = `
|
|
|
322
338
|
-- #116: the request-dedupe table, kernel-owned so no vertical migrates for it.
|
|
323
339
|
${IDEMPOTENCY_DDL}
|
|
324
340
|
-- #1745: refused transitions, recorded after the rollback like a denial. Kernel-owned.
|
|
325
|
-
${
|
|
341
|
+
${REFUSALS_TABLE_DDL}
|
|
342
|
+
${REFUSALS_INDEX}
|
|
326
343
|
-- #1672: capabilities — authority carried by a secret (a link share), and the sessions
|
|
327
344
|
-- an exchange trades that secret for. Shared with the pure adapter from
|
|
328
345
|
-- @substrat-run/kernel so the two cannot part company; the column comments are there.
|
|
329
346
|
${CAPABILITY_DDL}
|
|
347
|
+
-- #1686: where a copied scope's data came from, and the event id its own events start above.
|
|
348
|
+
-- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
|
|
349
|
+
${COPY_ORIGIN_DDL}
|
|
330
350
|
`;
|
|
331
351
|
/**
|
|
332
352
|
* Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
|
|
@@ -362,6 +382,137 @@ function kernelEmit(ctx, event) {
|
|
|
362
382
|
throw new Error('kernelEmit: not an operation context this host built');
|
|
363
383
|
write(event);
|
|
364
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* The scope DO's write revision (#1722): the one SQL handle this object writes through, and the
|
|
387
|
+
* one way it opens a transaction, so a writer or a transaction added later cannot slip past it.
|
|
388
|
+
* A carry's conditional restore compares the revision, so every change to the store has to move
|
|
389
|
+
* it: an UPDATE in place (a drain receipt, a redrain) as surely as an emitted event (Codex #2008
|
|
390
|
+
* r2). `suspended` is the wake's idempotent DDL, a load's drop-and-replay and a migration's own
|
|
391
|
+
* statements, which advance it themselves.
|
|
392
|
+
*
|
|
393
|
+
* **Atomic with the write (Codex #2008 r4).** The bump runs synchronously, right BEFORE the first
|
|
394
|
+
* write that needs it, on the same connection: inside a `transactionSync` or `transaction` it
|
|
395
|
+
* commits or rolls back with that transaction's writes, and outside one it joins the same
|
|
396
|
+
* coalesced batch. A bump that fails throws before the write runs, so no write lands under the old
|
|
397
|
+
* revision. Nothing is deferred to a later commit.
|
|
398
|
+
*
|
|
399
|
+
* **Once per run, not per statement** (a bump per statement cost 28.7–32.4% on a write-heavy
|
|
400
|
+
* operation, `write-revision-cost.test.ts`). After a bump, later writes skip it while they are
|
|
401
|
+
* covered by it: until the run ends (a microtask clears the flag at its first await or its end)
|
|
402
|
+
* and until a transaction boundary. Entering a transaction clears it, so each committing
|
|
403
|
+
* transaction holds a bump of its own; leaving one clears it too, so after a rollback (which took
|
|
404
|
+
* the bump with it) or a commit the next write bumps again. Clearing more often only over-counts,
|
|
405
|
+
* and over-counting only refuses a restore that could have landed.
|
|
406
|
+
*/
|
|
407
|
+
class WriteRevision {
|
|
408
|
+
raw;
|
|
409
|
+
storage;
|
|
410
|
+
suspended;
|
|
411
|
+
refusal;
|
|
412
|
+
/** Whether the current run, in the current transaction scope, already holds a bump. */
|
|
413
|
+
covered = false;
|
|
414
|
+
/** Inside `bookkeeping`: the one path whose writes advance no revision. */
|
|
415
|
+
keeping = false;
|
|
416
|
+
/** A statement's text is almost always a constant, so its answer is remembered (bounded). */
|
|
417
|
+
writes = new Map();
|
|
418
|
+
sql;
|
|
419
|
+
constructor(raw, storage, suspended,
|
|
420
|
+
/** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
|
|
421
|
+
refusal) {
|
|
422
|
+
this.raw = raw;
|
|
423
|
+
this.storage = storage;
|
|
424
|
+
this.suspended = suspended;
|
|
425
|
+
this.refusal = refusal;
|
|
426
|
+
const exec = (query, ...bindings) => {
|
|
427
|
+
if (this.keeping && this.isWrite(query)) {
|
|
428
|
+
// Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
|
|
429
|
+
// else: any other write here would be one the revision never saw, which is the hole this
|
|
430
|
+
// class exists to close.
|
|
431
|
+
if (!isCopyMarkInsert(query) && !isLifecycleWrite(query)) {
|
|
432
|
+
throw substratError('internal', `the bookkeeping path takes only the copy-marker insert and the lifecycle delivery (#1722, #1713), not: ${query.slice(0, 80)}`);
|
|
433
|
+
}
|
|
434
|
+
return raw.exec(query, ...bindings);
|
|
435
|
+
}
|
|
436
|
+
if (!this.suspended() && this.isWrite(query)) {
|
|
437
|
+
const refused = this.refusal();
|
|
438
|
+
if (refused)
|
|
439
|
+
throw substratError('conflict', refused);
|
|
440
|
+
if (!this.covered)
|
|
441
|
+
this.bump();
|
|
442
|
+
}
|
|
443
|
+
return raw.exec(query, ...bindings);
|
|
444
|
+
};
|
|
445
|
+
this.sql = new Proxy(raw, {
|
|
446
|
+
get(target, prop) {
|
|
447
|
+
if (prop === 'exec')
|
|
448
|
+
return exec;
|
|
449
|
+
const value = Reflect.get(target, prop, target);
|
|
450
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Store bookkeeping that is not the scope's data: marking the store a copy (#2005, Codex #2008
|
|
456
|
+
* r10). That only ever restricts what the store may run and changes no data, so it advances no
|
|
457
|
+
* write revision: a backfill that marks a carry's source between its export and its wipe must
|
|
458
|
+
* not read as a write the carry did not copy (that would keep the copy for nothing). Enforced,
|
|
459
|
+
* not trusted: any write in here but the marker insert (`isCopyMarkInsert`) or the lifecycle
|
|
460
|
+
* delivery (`isLifecycleWrite`, #1713) throws. Clearing a
|
|
461
|
+
* marker is NOT bookkeeping (r11): it loosens the store, so it is a write a carry fences on.
|
|
462
|
+
*/
|
|
463
|
+
bookkeeping(run) {
|
|
464
|
+
const was = this.keeping;
|
|
465
|
+
this.keeping = true;
|
|
466
|
+
try {
|
|
467
|
+
return run();
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
this.keeping = was;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** `storage.transactionSync`, at a transaction boundary. */
|
|
474
|
+
transactionSync(run) {
|
|
475
|
+
this.covered = false;
|
|
476
|
+
try {
|
|
477
|
+
return this.storage.transactionSync(run);
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
this.covered = false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** `storage.transaction`, at a transaction boundary. */
|
|
484
|
+
async transaction(run) {
|
|
485
|
+
this.covered = false;
|
|
486
|
+
try {
|
|
487
|
+
return await this.storage.transaction(run);
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
this.covered = false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
bump() {
|
|
494
|
+
// No catch: a bump that cannot be written fails the write it was for.
|
|
495
|
+
this.raw.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, '1')
|
|
496
|
+
ON CONFLICT (key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)`, WRITE_REVISION_KEY);
|
|
497
|
+
this.covered = true;
|
|
498
|
+
queueMicrotask(() => {
|
|
499
|
+
this.covered = false;
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
isWrite(query) {
|
|
503
|
+
let known = this.writes.get(query);
|
|
504
|
+
if (known === undefined) {
|
|
505
|
+
known = isWriteStatement(query);
|
|
506
|
+
if (this.writes.size >= 512)
|
|
507
|
+
this.writes.clear();
|
|
508
|
+
this.writes.set(query, known);
|
|
509
|
+
}
|
|
510
|
+
return known;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** What a write into a wiped copy answers (#1722): the scope's data lives in another script now. */
|
|
514
|
+
const CARRIED_AWAY_WRITE_REFUSAL = 'this copy of the scope was carried to another script and wiped (#1722); it takes no writes — ' +
|
|
515
|
+
'a request that reaches it was routed before the move';
|
|
365
516
|
function toRpcError(err) {
|
|
366
517
|
if (err instanceof Error) {
|
|
367
518
|
return err.constructor === Error ? err : new Error(err.message);
|
|
@@ -510,6 +661,15 @@ export function splitSqlStatements(sql) {
|
|
|
510
661
|
out.push(cur.trim());
|
|
511
662
|
return out;
|
|
512
663
|
}
|
|
664
|
+
/**
|
|
665
|
+
* #1834: the pin missed. Thrown only by `assertSystemDoor`, and module-private, so no operation can
|
|
666
|
+
* throw one: the two RPCs a door call reaches turn it into the `SystemDoorMoved` ANSWER, by class
|
|
667
|
+
* identity, and an operation's own error, whatever its text, stays a failure.
|
|
668
|
+
*/
|
|
669
|
+
class SystemDoorMovedError extends Error {
|
|
670
|
+
}
|
|
671
|
+
/** #1834: the brand only `assertSystemDoor` sets, so nothing else can make a `SystemDoorPass`. */
|
|
672
|
+
const SYSTEM_DOOR_PASSED = Symbol('system door passed');
|
|
513
673
|
export function defineScopeDO(modules, bareOps) {
|
|
514
674
|
return class ScopeDO extends DurableObject {
|
|
515
675
|
sql;
|
|
@@ -584,6 +744,21 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
584
744
|
* `runInDurableObject` — rather than assuming the runtime's documented behaviour.
|
|
585
745
|
*/
|
|
586
746
|
webSocketMessagesHandled = 0;
|
|
747
|
+
/**
|
|
748
|
+
* #1722: while true, a write does not advance the write revision. True through the wake's
|
|
749
|
+
* own idempotent DDL (which every wake re-runs, and which changes nothing) and through a
|
|
750
|
+
* load's drop-and-replay, which sets the revision itself once the store is rebuilt.
|
|
751
|
+
*/
|
|
752
|
+
revisionSuspended = true;
|
|
753
|
+
/**
|
|
754
|
+
* #1722 (Codex #2008 r7): whether this store is a copy a carry wiped (it holds the
|
|
755
|
+
* `carried_away` tombstone). Such a store takes no write: a stale request still routed here
|
|
756
|
+
* after the wipe would land a write that belongs to no live copy. A load, a rollback's
|
|
757
|
+
* restore, ends it. Read on every wake and after every load.
|
|
758
|
+
*/
|
|
759
|
+
carriedAwayCopy = false;
|
|
760
|
+
/** #1722: the write revision, and the one way this object opens a transaction. */
|
|
761
|
+
revision;
|
|
587
762
|
constructor(ctx, env) {
|
|
588
763
|
super(ctx, env);
|
|
589
764
|
// #1860: the client's 45s keep-alive (demos/ticket0/app/src/feed.ts) answered at
|
|
@@ -591,11 +766,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
591
766
|
// subscriber's ping still reaches `webSocketMessage` and pins the DO in memory —
|
|
592
767
|
// exactly the hibernation cost `acceptWebSocket` (below) exists to avoid.
|
|
593
768
|
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
|
|
594
|
-
this
|
|
769
|
+
// #1722: the ONE handle this object writes through and the one way it opens a transaction,
|
|
770
|
+
// so every write advances the write revision a carry's restore is fenced on, inside the
|
|
771
|
+
// transaction that commits it. Nothing else here may take the raw handle or open a
|
|
772
|
+
// transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
|
|
773
|
+
this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
|
|
774
|
+
this.sql = this.revision.sql;
|
|
595
775
|
for (const stmt of splitSqlStatements(KERNEL_DDL)) {
|
|
596
776
|
this.sql.exec(stmt);
|
|
597
777
|
}
|
|
598
778
|
this.applySpineColumnAdditions();
|
|
779
|
+
this.revisionSuspended = false;
|
|
780
|
+
this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
|
|
599
781
|
for (const registration of modules)
|
|
600
782
|
this.registerModule(registration);
|
|
601
783
|
for (const [name, handler] of Object.entries(bareOps))
|
|
@@ -860,7 +1042,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
860
1042
|
*/
|
|
861
1043
|
undrainedEventsRead(limit) {
|
|
862
1044
|
return readUndrainedOutbox((offset, count) => this.sql
|
|
863
|
-
.exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`, count, offset)
|
|
1045
|
+
.exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`, count, offset)
|
|
864
1046
|
.toArray(), limit);
|
|
865
1047
|
}
|
|
866
1048
|
/**
|
|
@@ -950,25 +1132,76 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
950
1132
|
* pair check), and a stray role row can no longer make a scope serve a tenant it was never
|
|
951
1133
|
* provisioned for.
|
|
952
1134
|
*
|
|
953
|
-
* A scope with NO receipt was provisioned before it existed
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
* (reconcile, or the restore's repair) writes the receipt, after which
|
|
958
|
-
* never consulted again.
|
|
1135
|
+
* A scope with NO receipt was provisioned before it existed (a load never brings the dump's:
|
|
1136
|
+
* it describes the scope the dump came from, and the store keeps its own, #2016). Only then
|
|
1137
|
+
* is the old inference used: `provisionScopeLocal` projected role definitions under the
|
|
1138
|
+
* tenant, so a `_substrat_roles` row for it says the same thing. The next projection
|
|
1139
|
+
* (reconcile, or the restore's repair) or lifecycle delivery writes the receipt, after which
|
|
1140
|
+
* the inference is never consulted again.
|
|
959
1141
|
*
|
|
960
1142
|
* Read without migrating, so asking about a foreign scope leaves its DO as empty as it found it.
|
|
961
1143
|
*/
|
|
962
1144
|
async servesTenant(tenantId) {
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1145
|
+
const { verdict } = this.tenantVerdict(tenantId);
|
|
1146
|
+
return verdict === 'recorded' || verdict === 'inferred';
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* #2016: what one door into this scope needs from its storage on a CP-less host, in ONE call —
|
|
1150
|
+
* how `tenantId` reads against the scope (`tenantVerdict`), and the lifecycle the platform
|
|
1151
|
+
* delivered (#1713). The coordinator refuses a foreign pair before any guard, handler or store
|
|
1152
|
+
* lookup runs, and reports an `unknown` one it lets through. Read without migrating, like
|
|
1153
|
+
* `servesTenant`.
|
|
1154
|
+
*/
|
|
1155
|
+
admission(tenantId) {
|
|
1156
|
+
return { verdict: this.tenantVerdict(tenantId).verdict, lifecycle: readLifecycle(this.switchSql()) };
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* #1738 / #2016: this scope's tenant against `tenantId` — the one reading behind the served-here
|
|
1160
|
+
* gate, every door's pair check and the lifecycle delivery's back-fill.
|
|
1161
|
+
*
|
|
1162
|
+
* - `recorded`: the `provisioned_for` receipt names `tenantId`.
|
|
1163
|
+
* - `foreign`: the receipt names another tenant, or — on a scope provisioned before receipts
|
|
1164
|
+
* existed — the scope holds role rows and none of them is `tenantId`'s.
|
|
1165
|
+
* - `inferred`: no receipt, and a role row for `tenantId` (what `provisionScopeLocal` projected
|
|
1166
|
+
* before the receipt existed).
|
|
1167
|
+
* - `unknown`: no receipt and no role rows — never provisioned here, or loaded from a world
|
|
1168
|
+
* that keeps its roles elsewhere and not yet repaired. Nothing to hold the pair against.
|
|
1169
|
+
*
|
|
1170
|
+
* `held` is the receipt read, so a refusal can name it. Read without migrating, so asking about
|
|
1171
|
+
* a foreign scope leaves its DO as empty as it found it.
|
|
1172
|
+
*/
|
|
1173
|
+
tenantVerdict(tenantId) {
|
|
1174
|
+
const held = this.provisionedFor();
|
|
1175
|
+
if (held !== null)
|
|
1176
|
+
return { verdict: held === tenantId ? 'recorded' : 'foreign', held };
|
|
1177
|
+
if (!this.hasTable('_substrat_roles'))
|
|
1178
|
+
return { verdict: 'unknown', held };
|
|
1179
|
+
if (this.sql.exec('SELECT 1 FROM _substrat_roles WHERE tenant_id = ? LIMIT 1', tenantId).toArray().length > 0) {
|
|
1180
|
+
return { verdict: 'inferred', held };
|
|
968
1181
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1182
|
+
const anyRole = this.sql.exec('SELECT 1 FROM _substrat_roles LIMIT 1').toArray().length > 0;
|
|
1183
|
+
return { verdict: anyRole ? 'foreign' : 'unknown', held };
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* #2016: whether a scope with no receipt and no role rows holds state — rather than being a DO
|
|
1187
|
+
* nothing ever provisioned or loaded here. Only such a scope takes its tenant from a lifecycle
|
|
1188
|
+
* delivery: recording one in an empty DO would make `servesTenant` answer for a scope this
|
|
1189
|
+
* deployment does not hold.
|
|
1190
|
+
*
|
|
1191
|
+
* Every provision since #1738 writes the receipt itself (that IS the durable provision marker),
|
|
1192
|
+
* so this only ever judges a scope from before it, or a load from an older platform. A module
|
|
1193
|
+
* with no SQL migrations still leaves spine state, so any of it counts, not migrations alone:
|
|
1194
|
+
* applied migrations, scope tuples (its grants, `system:` ones included), events, schedule state.
|
|
1195
|
+
*/
|
|
1196
|
+
holdsData() {
|
|
1197
|
+
return HOLDS_DATA_TABLES.some((table) => this.hasTable(table) && this.sql.exec(`SELECT 1 FROM ${table} LIMIT 1`).toArray().length > 0);
|
|
1198
|
+
}
|
|
1199
|
+
/** #1738: the tenant this scope's `provisioned_for` receipt names, or null; read without migrating. */
|
|
1200
|
+
provisionedFor() {
|
|
1201
|
+
return this.hasTable('_substrat_meta') ? this.metaValue(PROVISIONED_FOR_KEY) : null;
|
|
1202
|
+
}
|
|
1203
|
+
hasTable(name) {
|
|
1204
|
+
return this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, name).toArray().length > 0;
|
|
972
1205
|
}
|
|
973
1206
|
/** #1705 PR 2: the outbox's insertion mark (`OUTBOX_MARK_SQL`). */
|
|
974
1207
|
outboxMark() {
|
|
@@ -1024,10 +1257,27 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1024
1257
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
1025
1258
|
VALUES (?, ?, ?, ?, NULL)`, id, moduleId, when, error);
|
|
1026
1259
|
};
|
|
1260
|
+
// #1901: one line per (event, importing module), as for the scope's own consumers.
|
|
1261
|
+
// The handler runs as the producer's peer, so that is who the line says it ran as.
|
|
1262
|
+
const lines = asyncLinePass();
|
|
1263
|
+
const unitOf = (moduleId, eventId, eventType) => ({
|
|
1264
|
+
kind: 'consumer',
|
|
1265
|
+
tenantId,
|
|
1266
|
+
scopeId,
|
|
1267
|
+
invocationId: asyncInvocationId(null),
|
|
1268
|
+
operation: moduleId,
|
|
1269
|
+
eventType,
|
|
1270
|
+
eventId,
|
|
1271
|
+
attempt: 1,
|
|
1272
|
+
startedAt: Date.now(),
|
|
1273
|
+
principalKind: peerSubject.kind,
|
|
1274
|
+
versionId: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
1275
|
+
});
|
|
1027
1276
|
for (const w of batch.withheld) {
|
|
1028
1277
|
this.sql.exec(IMPORT_RECORD_SQL, w.id, source.scopeId, source.vertical, w.type, w.schemaVersion, w.occurredAt, w.entity.entityType, w.entity.entityId, 0, w.reason, at);
|
|
1029
1278
|
for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
|
|
1030
1279
|
deadLetter(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
|
|
1280
|
+
lines.write({ ...unitOf(moduleId, w.id, w.type), outcome: 'dead-lettered' });
|
|
1031
1281
|
}
|
|
1032
1282
|
result.withheld += 1;
|
|
1033
1283
|
}
|
|
@@ -1040,14 +1290,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1040
1290
|
ran = true;
|
|
1041
1291
|
if (imp.schemaVersion !== e.schemaVersion) {
|
|
1042
1292
|
deadLetter(e.id, imp.moduleId, at, withheldNote('version', source.vertical));
|
|
1293
|
+
lines.write({ ...unitOf(imp.moduleId, e.id, e.type), outcome: 'dead-lettered' });
|
|
1043
1294
|
result.deadLettered += 1;
|
|
1044
1295
|
continue;
|
|
1045
1296
|
}
|
|
1046
1297
|
const { hops: _hops, ...fact } = e;
|
|
1047
1298
|
const event = structuredClone({ ...fact, source });
|
|
1299
|
+
const unit = unitOf(imp.moduleId, e.id, e.type);
|
|
1048
1300
|
this.causedBy = e.id;
|
|
1301
|
+
this.unitInvocationId = unit.invocationId;
|
|
1049
1302
|
try {
|
|
1050
|
-
await this.
|
|
1303
|
+
await this.revision.transaction(async () => {
|
|
1051
1304
|
// As the producer's principal: real checks against the grants this vertical's
|
|
1052
1305
|
// `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
|
|
1053
1306
|
await imp.handler(this.importContext(tenantId, scopeId, peerSubject), event);
|
|
@@ -1055,14 +1308,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1055
1308
|
VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
|
|
1056
1309
|
});
|
|
1057
1310
|
result.delivered += 1;
|
|
1311
|
+
lines.write({ ...unit, outcome: 'delivered' });
|
|
1058
1312
|
}
|
|
1059
1313
|
catch (err) {
|
|
1060
1314
|
// Dead-letter (v0), outside the rolled-back transaction.
|
|
1061
1315
|
deadLetter(e.id, imp.moduleId, new Date().toISOString(), String(err));
|
|
1062
1316
|
result.deadLettered += 1;
|
|
1317
|
+
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
1063
1318
|
}
|
|
1064
1319
|
finally {
|
|
1065
1320
|
this.causedBy = null;
|
|
1321
|
+
this.unitInvocationId = null;
|
|
1066
1322
|
}
|
|
1067
1323
|
}
|
|
1068
1324
|
if (!ran)
|
|
@@ -1070,6 +1326,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1070
1326
|
}
|
|
1071
1327
|
this.sql.exec(IMPORT_CURSOR_ADVANCE_SQL, source.scopeId, source.vertical, batch.next, at);
|
|
1072
1328
|
result.cursor = batch.next;
|
|
1329
|
+
lines.end();
|
|
1073
1330
|
await this.settleCommitted(tenantId, scopeId, liveSince, null);
|
|
1074
1331
|
return result;
|
|
1075
1332
|
});
|
|
@@ -1320,7 +1577,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1320
1577
|
* and the re-assert in which a sweep could run the grants just seated.
|
|
1321
1578
|
*/
|
|
1322
1579
|
async seatTuples(tuples, switchOff) {
|
|
1323
|
-
return this.queue.enqueue(() => this.
|
|
1580
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => {
|
|
1324
1581
|
for (const t of tuples) {
|
|
1325
1582
|
const seat = seatScopeTuple(t.subject, t.relation, t.object, t.expires_at);
|
|
1326
1583
|
this.sql.exec(seat.sql, ...seat.params);
|
|
@@ -1445,21 +1702,33 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1445
1702
|
* `vertical.honoured`: an old DO that ignored this argument would run the call as that
|
|
1446
1703
|
* placeholder, and the coordinator refuses a success without the acknowledgement.
|
|
1447
1704
|
*/
|
|
1448
|
-
verticalCaller
|
|
1705
|
+
verticalCaller,
|
|
1706
|
+
/**
|
|
1707
|
+
* #1834: set on a system-door call — the instance whose state read the door's gate
|
|
1708
|
+
* consulted the rewind hold against. On any other instance nothing runs and the answer is
|
|
1709
|
+
* `systemDoorMoved` instead, because a PITR restore always restarts this object, so another
|
|
1710
|
+
* instance may be rewound storage the gate never saw. The reply carries `systemDoor.honoured`.
|
|
1711
|
+
*/
|
|
1712
|
+
systemDoorInstance) {
|
|
1449
1713
|
if (!failureEnvelope) {
|
|
1450
1714
|
// Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
|
|
1451
1715
|
// ZodError, whose `message` is a getter) still arrives with its message.
|
|
1452
1716
|
try {
|
|
1453
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller);
|
|
1717
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
|
|
1454
1718
|
}
|
|
1455
1719
|
catch (err) {
|
|
1720
|
+
if (err instanceof SystemDoorMovedError)
|
|
1721
|
+
return { result: undefined, platformRequests: 0, ...SYSTEM_DOOR_MOVED };
|
|
1456
1722
|
throw toRpcError(err);
|
|
1457
1723
|
}
|
|
1458
1724
|
}
|
|
1459
1725
|
try {
|
|
1460
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller);
|
|
1726
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
|
|
1461
1727
|
}
|
|
1462
1728
|
catch (err) {
|
|
1729
|
+
// #1834: the pin missed — an answer, never a failure an operation could have produced.
|
|
1730
|
+
if (err instanceof SystemDoorMovedError)
|
|
1731
|
+
return { result: undefined, platformRequests: 0, ...SYSTEM_DOOR_MOVED };
|
|
1463
1732
|
// The ONE place the error keeps its structure: flattened here, rebuilt by the
|
|
1464
1733
|
// coordinator. `toRpcError` is not applied — that exists to make a throw
|
|
1465
1734
|
// survivable, and this is not a throw.
|
|
@@ -1473,7 +1742,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1473
1742
|
/** #1672: a capability session's hash. See `invoke` above. */
|
|
1474
1743
|
capabilitySession,
|
|
1475
1744
|
/** #1706: the calling vertical, as the platform named it. See `invoke` above. */
|
|
1476
|
-
verticalCaller
|
|
1745
|
+
verticalCaller,
|
|
1746
|
+
/** #1834: the instance the system door's gate read. See `invoke` above. */
|
|
1747
|
+
systemDoorInstance) {
|
|
1477
1748
|
await this.ensureMigrations();
|
|
1478
1749
|
const handler = this.operations.get(operation);
|
|
1479
1750
|
// `not_found`, not a bare throw (#113): every vertical hand-matched this message
|
|
@@ -1556,6 +1827,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1556
1827
|
// one call. Same placement as the SQLite adapter's actor task, for this reason.
|
|
1557
1828
|
this.invocationId = invokeOptions?.invocationId ?? null;
|
|
1558
1829
|
try {
|
|
1830
|
+
// #1834: before anything opens. This instance is the storage the call meets, for the
|
|
1831
|
+
// whole call: a restore restarts the object, so it cannot move under a running body.
|
|
1832
|
+
const systemDoor = this.assertSystemDoor(systemModuleId, systemDoorInstance);
|
|
1559
1833
|
// #1672: the capability session, resolved on EVERY call and here — inside the queued
|
|
1560
1834
|
// body, the one region where this call holds the DO to itself — so nothing can
|
|
1561
1835
|
// revoke between this read and the transaction. Refuses a stale session, a revoked
|
|
@@ -1629,8 +1903,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1629
1903
|
// commit together, or a throw (from either) rolls domain writes AND
|
|
1630
1904
|
// emitted events back as one — verified across `await` in workerd.
|
|
1631
1905
|
try {
|
|
1632
|
-
await this.
|
|
1633
|
-
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId,
|
|
1906
|
+
await this.revision.transaction(async () => {
|
|
1907
|
+
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
|
|
1634
1908
|
// #116: a retry is answered from the recording, and nothing else runs
|
|
1635
1909
|
// — not the guards, not the handler, not the permission check inside
|
|
1636
1910
|
// it. Keyed by SUBJECT, so a caller only ever reaches its own
|
|
@@ -1698,6 +1972,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1698
1972
|
impersonation: { honoured: true },
|
|
1699
1973
|
...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
|
|
1700
1974
|
...(verticalCaller !== undefined ? { vertical: { honoured: true } } : {}),
|
|
1975
|
+
...(systemDoorInstance !== undefined ? { systemDoor: { honoured: true } } : {}),
|
|
1701
1976
|
...(idempotencyKey !== undefined
|
|
1702
1977
|
? { idempotency: { keyHonoured: true, replayed } }
|
|
1703
1978
|
: {}),
|
|
@@ -1762,6 +2037,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1762
2037
|
...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
|
|
1763
2038
|
// #1706: the same, for the peer door.
|
|
1764
2039
|
...(verticalCaller !== undefined ? { vertical: { honoured: true } } : {}),
|
|
2040
|
+
// #1834: the same, for the system door's instance pin.
|
|
2041
|
+
...(systemDoorInstance !== undefined ? { systemDoor: { honoured: true } } : {}),
|
|
1765
2042
|
// The acknowledgement the coordinator's skew check reads (#116), on the
|
|
1766
2043
|
// same reasoning as `ifMatchChecked` below and with a sharper failure: a
|
|
1767
2044
|
// DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
|
|
@@ -2111,7 +2388,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2111
2388
|
// the reason it is read there: that is where this call holds the DO to itself.
|
|
2112
2389
|
const liveSince = this.liveHighWaterMark();
|
|
2113
2390
|
try {
|
|
2114
|
-
await this.
|
|
2391
|
+
await this.revision.transaction(async () => {
|
|
2115
2392
|
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.upload');
|
|
2116
2393
|
assertAllowed(await ctx.check(gate.write, parsed.entity));
|
|
2117
2394
|
this.sql.exec(`INSERT INTO _substrat_attachments
|
|
@@ -2203,8 +2480,19 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2203
2480
|
return record;
|
|
2204
2481
|
}
|
|
2205
2482
|
/** Authorize a job's read as its registered module, never as a person. */
|
|
2206
|
-
async systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId
|
|
2483
|
+
async systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId,
|
|
2484
|
+
/** #1834: the instance the system door's gate read; see `invoke`. */
|
|
2485
|
+
systemDoorInstance) {
|
|
2207
2486
|
await this.ensureMigrations();
|
|
2487
|
+
let systemDoor;
|
|
2488
|
+
try {
|
|
2489
|
+
systemDoor = this.assertSystemDoor(moduleId, systemDoorInstance);
|
|
2490
|
+
}
|
|
2491
|
+
catch (err) {
|
|
2492
|
+
if (err instanceof SystemDoorMovedError)
|
|
2493
|
+
return SYSTEM_DOOR_MOVED;
|
|
2494
|
+
throw toRpcError(err);
|
|
2495
|
+
}
|
|
2208
2496
|
if (!this.modules.has(moduleId)) {
|
|
2209
2497
|
throw toRpcError(substratError('not_found', `module not registered in this scope: ${moduleId}`));
|
|
2210
2498
|
}
|
|
@@ -2213,7 +2501,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2213
2501
|
return null;
|
|
2214
2502
|
const gate = this.attachmentGate(record.entity.entityType);
|
|
2215
2503
|
try {
|
|
2216
|
-
const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, undefined, undefined,
|
|
2504
|
+
const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, undefined, undefined, systemDoor, undefined, undefined, 'attachments.open');
|
|
2217
2505
|
assertAllowed(await ctx.check(gate.read, record.entity));
|
|
2218
2506
|
}
|
|
2219
2507
|
catch (err) {
|
|
@@ -2235,7 +2523,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2235
2523
|
// #938: same mark-then-settle pair as the upload path above.
|
|
2236
2524
|
const liveSince = this.liveHighWaterMark();
|
|
2237
2525
|
try {
|
|
2238
|
-
await this.
|
|
2526
|
+
await this.revision.transaction(async () => {
|
|
2239
2527
|
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.remove');
|
|
2240
2528
|
assertAllowed(await ctx.check(gate.write, record.entity));
|
|
2241
2529
|
this.sql.exec('DELETE FROM _substrat_attachments WHERE id = ?', attachmentId);
|
|
@@ -2301,7 +2589,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2301
2589
|
*/
|
|
2302
2590
|
async attachmentTextRecord(attachmentId, outcome) {
|
|
2303
2591
|
await this.ensureMigrations();
|
|
2304
|
-
return this.queue.enqueue(async () => this.
|
|
2592
|
+
return this.queue.enqueue(async () => this.revision.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
|
|
2305
2593
|
}
|
|
2306
2594
|
// -- attachments through a capability (#1686) -------------------------------
|
|
2307
2595
|
// The coordinator hashed the session token; only the hash arrives. Each verb resolves it
|
|
@@ -2405,6 +2693,41 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2405
2693
|
async systemScheduleState(moduleId) {
|
|
2406
2694
|
return systemScheduleState(this.switchSql(), moduleId, new Date().toISOString());
|
|
2407
2695
|
}
|
|
2696
|
+
/**
|
|
2697
|
+
* #1834: the system door's state read — `systemScheduleState`, and the instance that
|
|
2698
|
+
* answered it. The door consults the rewind hold after this read and pins every call it
|
|
2699
|
+
* then makes to this instance (`assertSystemDoor`).
|
|
2700
|
+
*/
|
|
2701
|
+
async systemDoorState(moduleId) {
|
|
2702
|
+
return { state: await this.systemScheduleState(moduleId), instance: this.instanceId };
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* #1834: the system door's check on this side, and the ONLY maker of the `SystemDoorPass`
|
|
2706
|
+
* the operation context needs to act as `system:<moduleId>`. A call acting as a module must
|
|
2707
|
+
* carry the instance the host's door gate read (refused without one: no door gated it), and
|
|
2708
|
+
* is refused on any other instance. A PITR restore always restarts this object, so a call
|
|
2709
|
+
* landing on a new instance may meet rewound storage whose module the gate never checked
|
|
2710
|
+
* against the rewind hold. Both are refused before anything opens; the moved one is the
|
|
2711
|
+
* module-private `SystemDoorMovedError`, which the RPC answers as `SystemDoorMoved`, so the door
|
|
2712
|
+
* gates again and retries. No module, no pass.
|
|
2713
|
+
*/
|
|
2714
|
+
assertSystemDoor(moduleId, expected) {
|
|
2715
|
+
if (moduleId === undefined)
|
|
2716
|
+
return undefined;
|
|
2717
|
+
// Strict, and deliberately so: no pin means no door gated this call, so it is refused rather
|
|
2718
|
+
// than run unchecked. The one innocent caller is a worker a deploy behind, still running host
|
|
2719
|
+
// code from before the pin, during a rolling deploy. Its refusal is transient: the host and this
|
|
2720
|
+
// object ship in the same bundle, so the window lasts as long as the rollout. Nothing ran, and
|
|
2721
|
+
// the reason says "not now" (`SYSTEM_DOOR_WAIT`) to whoever reads it: a schedule fires on the
|
|
2722
|
+
// next pass, and a job run retries on its next drive.
|
|
2723
|
+
if (expected === undefined) {
|
|
2724
|
+
throw substratError('forbidden', `a call acting as module '${moduleId}' reached this scope without passing the system door`, { reason: SYSTEM_DOOR_WAIT });
|
|
2725
|
+
}
|
|
2726
|
+
if (expected !== this.instanceId) {
|
|
2727
|
+
throw new SystemDoorMovedError("the scope restarted after the system door's gate read it; gate it again");
|
|
2728
|
+
}
|
|
2729
|
+
return { moduleId, [SYSTEM_DOOR_PASSED]: true };
|
|
2730
|
+
}
|
|
2408
2731
|
/**
|
|
2409
2732
|
* The pre-#1666 read, kept for ONE reason: a coordinator a deploy behind this DO still
|
|
2410
2733
|
* calls it. It answers the new question, not the old one — the old predicate counted any
|
|
@@ -2434,8 +2757,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2434
2757
|
* `switchSystemSchedules`, serialized on the queue with every other tuple write and run
|
|
2435
2758
|
* as one `transactionSync`, so its reads and writes are one unit.
|
|
2436
2759
|
*/
|
|
2437
|
-
async switchSystemSchedules(moduleId, scopeId, to, at) {
|
|
2438
|
-
const outcome = await this.queue.enqueue(() => this.
|
|
2760
|
+
async switchSystemSchedules(moduleId, scopeId, to, at, tenantHeld = false) {
|
|
2761
|
+
const outcome = await this.queue.enqueue(() => this.revision.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at, tenantHeld })));
|
|
2439
2762
|
// #1819: the instance that applied it, which the rewind hold's release rule reads.
|
|
2440
2763
|
return { ...outcome, instance: this.instanceId };
|
|
2441
2764
|
}
|
|
@@ -2450,14 +2773,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2450
2773
|
// The consumer's own running imports decide whether the edge exists: read here, in the
|
|
2451
2774
|
// deployment whose handlers would run, never from the platform's request.
|
|
2452
2775
|
const imports = this.crossVertical.consumes();
|
|
2453
|
-
return this.queue.enqueue(() => this.
|
|
2776
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => moveImportCursor(this.switchSql(), { ...input, imports })));
|
|
2454
2777
|
}
|
|
2455
2778
|
/**
|
|
2456
2779
|
* Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
|
|
2457
2780
|
* the schedule switch's statement with a `vertical:` subject. Queued, one transaction.
|
|
2458
2781
|
*/
|
|
2459
2782
|
async switchPeer(vertical, scopeId, to, at) {
|
|
2460
|
-
return this.queue.enqueue(() => this.
|
|
2783
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at })));
|
|
2461
2784
|
}
|
|
2462
2785
|
/**
|
|
2463
2786
|
* Does peer `vertical` hold each key at this scope's node now (#1706) — the checker's own
|
|
@@ -2581,7 +2904,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2581
2904
|
*/
|
|
2582
2905
|
switchHoldOn(scopeId, moduleId, claimIds) {
|
|
2583
2906
|
this.switchHoldsTable();
|
|
2584
|
-
this.
|
|
2907
|
+
this.revision.transactionSync(() => {
|
|
2585
2908
|
const seq = this.sql
|
|
2586
2909
|
.exec(`INSERT INTO _substrat_switch_hold_seq (id, seq) VALUES (1, 1)
|
|
2587
2910
|
ON CONFLICT (id) DO UPDATE SET seq = seq + 1 RETURNING seq`)
|
|
@@ -2606,7 +2929,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2606
2929
|
switchHoldClaim(scopeId, moduleIds, claimId, token) {
|
|
2607
2930
|
this.switchHoldsTable();
|
|
2608
2931
|
const at = new Date().toISOString();
|
|
2609
|
-
this.
|
|
2932
|
+
this.revision.transactionSync(() => {
|
|
2610
2933
|
for (const moduleId of moduleIds) {
|
|
2611
2934
|
this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
|
|
2612
2935
|
SELECT ?, ?, ?, 'pending', NULL, ?
|
|
@@ -2626,7 +2949,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2626
2949
|
switchHoldJoin(scopeId, moduleId, claimIds) {
|
|
2627
2950
|
this.switchHoldsTable();
|
|
2628
2951
|
const at = new Date().toISOString();
|
|
2629
|
-
this.
|
|
2952
|
+
this.revision.transactionSync(() => {
|
|
2630
2953
|
for (const claimId of claimIds) {
|
|
2631
2954
|
this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
|
|
2632
2955
|
SELECT scope_id, ?, claim_id, state, doomed, ? FROM _substrat_switch_holds
|
|
@@ -2684,7 +3007,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2684
3007
|
/** Release these claims on one module, or every claim on the scope when `claimIds` is null. */
|
|
2685
3008
|
switchHoldRelease(scopeId, moduleId, claimIds) {
|
|
2686
3009
|
this.switchHoldsTable();
|
|
2687
|
-
this.
|
|
3010
|
+
this.revision.transactionSync(() => {
|
|
2688
3011
|
if (moduleId === null || claimIds === null) {
|
|
2689
3012
|
this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ?', scopeId);
|
|
2690
3013
|
this.sql.exec('DELETE FROM _substrat_switch_hold_ons WHERE scope_id = ?', scopeId);
|
|
@@ -2721,7 +3044,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2721
3044
|
// and the event's `occurredAt` are one fact, and two clock reads could disagree.
|
|
2722
3045
|
const now = instant.parse(new Date().toISOString());
|
|
2723
3046
|
let outcome = null;
|
|
2724
|
-
await this.
|
|
3047
|
+
await this.revision.transaction(async () => {
|
|
2725
3048
|
outcome = await exchangeCapability({
|
|
2726
3049
|
sql: doSpineSql(this.sql),
|
|
2727
3050
|
now,
|
|
@@ -2751,7 +3074,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2751
3074
|
*/
|
|
2752
3075
|
async revokeCapabilityAsPlatform(id, actor) {
|
|
2753
3076
|
await this.ensureMigrations();
|
|
2754
|
-
return await this.queue.enqueue(() => this.
|
|
3077
|
+
return await this.queue.enqueue(() => this.revision.transactionSync(() => revokeCapabilityAsPlatform(doSpineSql(this.sql), id, actor, instant.parse(new Date().toISOString()))) ?? null);
|
|
2755
3078
|
}
|
|
2756
3079
|
/**
|
|
2757
3080
|
* The operator's read of this scope's capabilities (#1686). The kernel's one read
|
|
@@ -2873,7 +3196,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2873
3196
|
// `jobRunInsert`: an `await` between them is an output-gate boundary, and the
|
|
2874
3197
|
// point of doing this in one RPC is that there is no boundary to be evicted
|
|
2875
3198
|
// at. Same reason `SCHEDULE_STATE_REBUILD` insists on it.
|
|
2876
|
-
this.
|
|
3199
|
+
this.revision.transactionSync(() => {
|
|
2877
3200
|
this.sql.exec(`INSERT INTO _substrat_job_runs
|
|
2878
3201
|
(id, module_id, job, instance, payload, status, cursor, counters, attempts,
|
|
2879
3202
|
last_error, started_at, updated_at, next_attempt_at, ended_at, subject_id)
|
|
@@ -2913,6 +3236,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2913
3236
|
ORDER BY id LIMIT ?`, now, afterId ?? null, afterId ?? null, limit)
|
|
2914
3237
|
.toArray();
|
|
2915
3238
|
}
|
|
3239
|
+
/**
|
|
3240
|
+
* #1834: the drive's ONE snapshot of due runs — keys only, in the order they became due
|
|
3241
|
+
* (`JOB_RUN_DUE_AT`, then id). Spelled exactly as the pure adapter spells it. `jobRunsDue`
|
|
3242
|
+
* above stays for a coordinator a deploy behind, which still pages by id.
|
|
3243
|
+
*/
|
|
3244
|
+
async jobRunsDueKeys(now, max) {
|
|
3245
|
+
return this.sql
|
|
3246
|
+
.exec(`SELECT id, module_id, job FROM _substrat_job_runs
|
|
3247
|
+
WHERE status = 'running' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
3248
|
+
ORDER BY ${JOB_RUN_DUE_AT}, id LIMIT ?`, now, max)
|
|
3249
|
+
.toArray();
|
|
3250
|
+
}
|
|
2916
3251
|
/** The operator read, newest first. */
|
|
2917
3252
|
async jobRunList(filter) {
|
|
2918
3253
|
const where = [];
|
|
@@ -2973,7 +3308,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2973
3308
|
// `transactionSync` with both statements inline — NOT `await
|
|
2974
3309
|
// this.jobRunPatch(...)` then the delete. The await is an output-gate
|
|
2975
3310
|
// boundary, which is precisely the gap this method exists to close.
|
|
2976
|
-
this.
|
|
3311
|
+
this.revision.transactionSync(() => {
|
|
2977
3312
|
this.sql.exec(JOB_RUN_PATCH_SQL, patch.status, patch.cursor, patch.counters, patch.attempts, patch.lastError, patch.updatedAt, patch.nextAttemptAt, patch.endedAt, id);
|
|
2978
3313
|
this.sql.exec('DELETE FROM _substrat_job_steps WHERE run_id = ?', id);
|
|
2979
3314
|
});
|
|
@@ -2994,7 +3329,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2994
3329
|
throw new Error(`unknown guard predicate: '${guard.predicate}' — declared by ${guard.declaredBy} ` +
|
|
2995
3330
|
`before '${operation}'; no registered module contributes it (operation blocked)`);
|
|
2996
3331
|
}
|
|
2997
|
-
|
|
3332
|
+
try {
|
|
3333
|
+
await predicate.handler(ctx, guard.config, input);
|
|
3334
|
+
}
|
|
3335
|
+
catch (err) {
|
|
3336
|
+
// #1745: a guard refusing is recorded after the rollback, as a transition is —
|
|
3337
|
+
// marked here, where which guard threw is still known. Always rethrown.
|
|
3338
|
+
markGuardRefusal(err, guard.predicate, operation);
|
|
3339
|
+
throw err;
|
|
3340
|
+
}
|
|
2998
3341
|
}
|
|
2999
3342
|
}
|
|
3000
3343
|
// -- migrations (port of applyPendingMigrations) --------------------------
|
|
@@ -3042,7 +3385,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3042
3385
|
if (this.applied.has(key))
|
|
3043
3386
|
continue;
|
|
3044
3387
|
try {
|
|
3045
|
-
await this.
|
|
3388
|
+
await this.revision.transaction(async () => {
|
|
3046
3389
|
const already = this.sql
|
|
3047
3390
|
.exec('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
|
|
3048
3391
|
.toArray()[0];
|
|
@@ -3052,8 +3395,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3052
3395
|
// #1898: a migration runs on this DO's own handle, not `ctx.sql`, so the
|
|
3053
3396
|
// spine guard's REFERENCES rule is applied here.
|
|
3054
3397
|
assertNoSpineReference(migration.sql, `migration ${key}`);
|
|
3055
|
-
|
|
3056
|
-
|
|
3398
|
+
// #1722: not counted per statement, so `total_changes()` measures the migration
|
|
3399
|
+
// alone. The journal row below is a write, and advances the revision once.
|
|
3400
|
+
this.revisionSuspended = true;
|
|
3401
|
+
try {
|
|
3402
|
+
for (const stmt of splitSqlStatements(migration.sql)) {
|
|
3403
|
+
this.sql.exec(stmt);
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
finally {
|
|
3407
|
+
this.revisionSuspended = false;
|
|
3057
3408
|
}
|
|
3058
3409
|
const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
|
|
3059
3410
|
this.sql.exec('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed) VALUES (?, ?, ?, ?, ?)', moduleId, migration.version, new Date().toISOString(), Math.max(0, Math.round(performance.now() - started)), after - before);
|
|
@@ -3101,7 +3452,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3101
3452
|
.exec(`SELECT o.* FROM _substrat_outbox o
|
|
3102
3453
|
LEFT JOIN _substrat_deliveries d
|
|
3103
3454
|
ON d.event_id = o.id AND d.consumer_module = ?
|
|
3104
|
-
WHERE o.type = ?
|
|
3455
|
+
WHERE o.type = ? AND ${emittedHere('o.')}
|
|
3105
3456
|
AND (d.event_id IS NULL
|
|
3106
3457
|
OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?))
|
|
3107
3458
|
ORDER BY o.id`, deliveryId, eventType, new Date().toISOString())
|
|
@@ -3118,6 +3469,87 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3118
3469
|
}
|
|
3119
3470
|
return { events, undecodable };
|
|
3120
3471
|
}
|
|
3472
|
+
/**
|
|
3473
|
+
* Whether this scope is a copy (#2005) — the classification on its `_substrat_copy_origin` row
|
|
3474
|
+
* (#2009), which only the directory's word sets, not a load's events mark. A CP-less
|
|
3475
|
+
* coordinator has no directory to read a scope's kind from, so this is how it holds a copy's
|
|
3476
|
+
* executor deliveries inert; a coordinator with a directory asks that instead.
|
|
3477
|
+
*/
|
|
3478
|
+
isCopy() {
|
|
3479
|
+
return this.sql.exec(IS_COPY_SQL).toArray().length > 0;
|
|
3480
|
+
}
|
|
3481
|
+
/** The lifecycle the platform last delivered to this scope (#1713), or null for none. */
|
|
3482
|
+
lifecycle() {
|
|
3483
|
+
return readLifecycle(this.switchSql());
|
|
3484
|
+
}
|
|
3485
|
+
/**
|
|
3486
|
+
* Store a lifecycle the platform delivered (#1713, `writeLifecycle`): kept only when it is not
|
|
3487
|
+
* older than the one held. Bookkeeping, like the copy marker: it changes what runs here, not
|
|
3488
|
+
* the scope's data, so it does not advance the write revision a carry fences on.
|
|
3489
|
+
*/
|
|
3490
|
+
setLifecycle(next,
|
|
3491
|
+
/** #2016: the tenant the directory delivered this lifecycle for. A scope it is foreign to
|
|
3492
|
+
* (`tenantVerdict`) refuses the delivery untouched; one provisioned before the receipt
|
|
3493
|
+
* existed, whose role rows name this tenant, records it — the back-fill. */
|
|
3494
|
+
tenantId) {
|
|
3495
|
+
const { verdict, held } = tenantId === undefined ? { verdict: null, held: null } : this.tenantVerdict(tenantId);
|
|
3496
|
+
if (verdict === 'foreign') {
|
|
3497
|
+
return { refused: 'tenant', message: `lifecycle delivery ${foreignTenant(held, tenantId)}` };
|
|
3498
|
+
}
|
|
3499
|
+
// The back-fill: a scope provisioned before the receipt (its role rows agree), or one whose
|
|
3500
|
+
// tenant nothing recorded although it holds data (a load from a world that keeps its roles
|
|
3501
|
+
// elsewhere). The directory's word records it; a DO never provisioned here records nothing.
|
|
3502
|
+
const backfill = verdict === 'inferred' || (verdict === 'unknown' && this.holdsData());
|
|
3503
|
+
let out;
|
|
3504
|
+
this.revision.transactionSync(() => {
|
|
3505
|
+
// A write the carry fences on, not bookkeeping: it happens once per legacy scope, and
|
|
3506
|
+
// over-counting only refuses a restore that could have landed. A carried-away copy takes
|
|
3507
|
+
// no write at all, so it is left to the next projection.
|
|
3508
|
+
if (backfill && !this.carriedAwayCopy) {
|
|
3509
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, tenantId);
|
|
3510
|
+
}
|
|
3511
|
+
out = { ...this.revision.bookkeeping(() => writeLifecycle(this.switchSql(), next)), tenantRecorded: this.provisionedFor() !== null };
|
|
3512
|
+
});
|
|
3513
|
+
return out;
|
|
3514
|
+
}
|
|
3515
|
+
/** Mark this scope a copy (#2005, `markCopyOrigin`): the repair of a copy that predates the
|
|
3516
|
+
* marker. Answers whether this call stamped it; a store that already reads as a copy is left
|
|
3517
|
+
* as it is, and a load's events mark is never moved (#2009). */
|
|
3518
|
+
markCopy() {
|
|
3519
|
+
let marked = false;
|
|
3520
|
+
this.revision.transactionSync(() => {
|
|
3521
|
+
marked = this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), new Date().toISOString()));
|
|
3522
|
+
});
|
|
3523
|
+
return marked;
|
|
3524
|
+
}
|
|
3525
|
+
/**
|
|
3526
|
+
* Clear a mistaken copy classification (#2005, `clearCopyMarker`); a load's events mark stays,
|
|
3527
|
+
* so copied work still never runs here (#2009). A
|
|
3528
|
+
* clear is a write like any other (Codex #2008 r11), never bookkeeping: it lets the store run
|
|
3529
|
+
* work a copy holds inert, so it advances the write revision, and a carry that exported
|
|
3530
|
+
* before it cannot wipe the repaired store. That carry's wipe keeps it instead.
|
|
3531
|
+
*/
|
|
3532
|
+
clearCopyMark(
|
|
3533
|
+
/** The store the caller means (Codex #2008 r12–r13): the platform's reconcile of the store a
|
|
3534
|
+
* carry landed names that carry's load stamp and the revision it read, so a store another
|
|
3535
|
+
* load has replaced since (a governed restore, whose classification is its own) is refused,
|
|
3536
|
+
* compared here, in the clear's own transaction. Absent for staff's correction. */
|
|
3537
|
+
expect) {
|
|
3538
|
+
return this.revision.transactionSync(() => {
|
|
3539
|
+
const from = this.metaValue(WRITE_REVISION_KEY);
|
|
3540
|
+
if (expect && (this.metaValue(LOAD_STAMP_KEY) !== expect.loadStamp || from !== expect.revision)) {
|
|
3541
|
+
return 'changed';
|
|
3542
|
+
}
|
|
3543
|
+
const outcome = clearCopyMarker(this.switchSql());
|
|
3544
|
+
// The clear's own revisions, so a carry's refused wipe can tell that this clear, and
|
|
3545
|
+
// nothing else, is what changed here since its export (`COPY_MARK_CLEARED_KEY`).
|
|
3546
|
+
if (outcome === 'cleared') {
|
|
3547
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)
|
|
3548
|
+
ON CONFLICT (key) DO UPDATE SET value = excluded.value`, COPY_MARK_CLEARED_KEY, JSON.stringify({ from, to: this.metaValue(WRITE_REVISION_KEY) }));
|
|
3549
|
+
}
|
|
3550
|
+
return outcome;
|
|
3551
|
+
});
|
|
3552
|
+
}
|
|
3121
3553
|
/**
|
|
3122
3554
|
* The same read as a bare list of events, for a coordinator deployed before
|
|
3123
3555
|
* `pendingExecutorDeliveries` (#1636). An undecodable row is left out rather than
|
|
@@ -3362,7 +3794,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3362
3794
|
let result;
|
|
3363
3795
|
const rollback = new Error('read-only console rollback');
|
|
3364
3796
|
try {
|
|
3365
|
-
await this.
|
|
3797
|
+
await this.revision.transaction(async () => {
|
|
3366
3798
|
const cursor = this.sql.exec(stmt);
|
|
3367
3799
|
const columns = cursor.columnNames;
|
|
3368
3800
|
const rows = [];
|
|
@@ -3442,12 +3874,32 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3442
3874
|
return defs.map(({ name, sql }) => {
|
|
3443
3875
|
// Raw positional rows, cells as-is (blobs kept as bytes, not nulled like a UI
|
|
3444
3876
|
// read) so the dump reloads faithfully. The name is from the live schema.
|
|
3445
|
-
|
|
3877
|
+
// #1722: the load stamp and the write revision describe THIS store, so they never leave
|
|
3878
|
+
// in a dump (`exportDumpStamped` hands the stamp over beside one); every load writes its own.
|
|
3879
|
+
const cursor = name === '_substrat_meta'
|
|
3880
|
+
? this.sql.exec(`SELECT * FROM "${name}" WHERE key NOT IN (${STORE_LOCAL_META_KEYS.map(() => '?').join(', ')})`, ...STORE_LOCAL_META_KEYS)
|
|
3881
|
+
: this.sql.exec(`SELECT * FROM "${name}"`);
|
|
3446
3882
|
const columns = cursor.columnNames;
|
|
3447
3883
|
const rows = Array.from(cursor.raw(), (row) => row);
|
|
3448
3884
|
return { name, ddl: sql, columns, rows };
|
|
3449
3885
|
});
|
|
3450
3886
|
}
|
|
3887
|
+
/**
|
|
3888
|
+
* `exportDump`, with the store's load stamp read in the same call (#1722): the stamp a
|
|
3889
|
+
* carry's fenced wipe of this copy later expects. A store no load has stamped gets one now,
|
|
3890
|
+
* so the stamp handed out is present exactly while nothing has been loaded here since. One
|
|
3891
|
+
* synchronous method, so no load can land between the dump and the stamp.
|
|
3892
|
+
*/
|
|
3893
|
+
exportDumpStamped() {
|
|
3894
|
+
let stamp = this.loadStamp();
|
|
3895
|
+
// A wiped copy takes no write, a stamp included; the carry refuses its tombstone anyway.
|
|
3896
|
+
if (!stamp && !this.carriedAwayCopy) {
|
|
3897
|
+
stamp = ulid();
|
|
3898
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, stamp);
|
|
3899
|
+
}
|
|
3900
|
+
// Read after the stamp's own write, so it is the revision the store holds as it is dumped.
|
|
3901
|
+
return { tables: this.exportDump(), loadStamp: stamp, revision: this.metaValue(WRITE_REVISION_KEY) };
|
|
3902
|
+
}
|
|
3451
3903
|
/**
|
|
3452
3904
|
* Load a dump into this (freshly-provisioned) scope — the write half of the fork
|
|
3453
3905
|
* (host.ts importScope). Drop-then-replay: the provisioning schema is wiped and rebuilt,
|
|
@@ -3529,6 +3981,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3529
3981
|
// every legacy row — a past run's id cannot be recovered afterwards, exactly
|
|
3530
3982
|
// as the other #1525 columns above argued.
|
|
3531
3983
|
'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
|
|
3984
|
+
// #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
|
|
3985
|
+
// see `COPY_ORIGIN_DDL`).
|
|
3986
|
+
'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
|
|
3532
3987
|
]) {
|
|
3533
3988
|
try {
|
|
3534
3989
|
this.sql.exec(alter);
|
|
@@ -3552,6 +4007,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3552
4007
|
// both adapters by the query-plan test rather than by that gate.
|
|
3553
4008
|
this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
|
|
3554
4009
|
this.ensureScheduleStateKind();
|
|
4010
|
+
this.ensureRefusalsAdmitGuards();
|
|
4011
|
+
}
|
|
4012
|
+
/**
|
|
4013
|
+
* #1745: `_substrat_refusals`, rebuilt to admit a guard row (a nullable `from_state`, plus
|
|
4014
|
+
* `guard` and `reason`) on a scope DO whose table predates it.
|
|
4015
|
+
*/
|
|
4016
|
+
ensureRefusalsAdmitGuards() {
|
|
4017
|
+
this.rebuildIfStale('_substrat_refusals', refusalsAdmitGuards, REFUSALS_REBUILD);
|
|
3555
4018
|
}
|
|
3556
4019
|
/**
|
|
3557
4020
|
* #1288: `_substrat_schedule_state`, rebuilt with `kind` in its key on a scope DO
|
|
@@ -3563,23 +4026,31 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3563
4026
|
* the stored DDL is the one probe both adapters can make.
|
|
3564
4027
|
*/
|
|
3565
4028
|
ensureScheduleStateKind() {
|
|
4029
|
+
this.rebuildIfStale('_substrat_schedule_state', scheduleStateHasKind, SCHEDULE_STATE_REBUILD);
|
|
4030
|
+
}
|
|
4031
|
+
/**
|
|
4032
|
+
* Run a kernel create-copy-drop-rename `script` over `table` when the stored DDL fails
|
|
4033
|
+
* `isCurrent` — the one shape `ensureScheduleStateKind` and `ensureRefusalsAdmitGuards`
|
|
4034
|
+
* share, so the pure adapter's twin rebuilds byte-identically.
|
|
4035
|
+
*/
|
|
4036
|
+
rebuildIfStale(table, isCurrent, script) {
|
|
3566
4037
|
const row = this.sql
|
|
3567
|
-
.exec(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
4038
|
+
.exec(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`, table)
|
|
3568
4039
|
.toArray()[0];
|
|
3569
|
-
if (!row ||
|
|
4040
|
+
if (!row || isCurrent(row.sql))
|
|
3570
4041
|
return;
|
|
3571
|
-
// In a transaction, for the reason the kernel
|
|
4042
|
+
// In a transaction, for the reason the kernel constants spell out: create-copy-
|
|
3572
4043
|
// drop-rename has two intermediate states and both are unrecoverable on the next
|
|
3573
4044
|
// wake. `transactionSync`, not the async one every operation uses — the DO
|
|
3574
4045
|
// runtime forbids a manual BEGIN through `sql.exec`, and this body is wholly
|
|
3575
4046
|
// synchronous, which is the one case the sync API is for (it commits at the
|
|
3576
4047
|
// first await, and there is none). It also has to be sync because the caller is.
|
|
3577
|
-
this.
|
|
3578
|
-
for (const stmt of splitSqlStatements(
|
|
4048
|
+
this.revision.transactionSync(() => {
|
|
4049
|
+
for (const stmt of splitSqlStatements(script))
|
|
3579
4050
|
this.sql.exec(stmt);
|
|
3580
4051
|
});
|
|
3581
4052
|
}
|
|
3582
|
-
async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, } = {}) {
|
|
4053
|
+
async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, } = {}) {
|
|
3583
4054
|
// The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
|
|
3584
4055
|
//
|
|
3585
4056
|
// Two distinct FK hazards, and both need the deferral — the second is why it cannot
|
|
@@ -3601,7 +4072,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3601
4072
|
// A dump written before indexes were excluded may still carry them; skipped
|
|
3602
4073
|
// rather than failing a restore over data about to be recomputed.
|
|
3603
4074
|
// Matched without case, as SQLite resolves a table name (#1883 review).
|
|
3604
|
-
|
|
4075
|
+
// #1686: a copy into another scope id loads no capability rows; a same-scope restore keeps them.
|
|
4076
|
+
const replayable = capabilitiesForLoad(tables.filter((t) => !isSearchIndexTable(t.name.toLowerCase())), destScopeId, sourceScopeId);
|
|
3605
4077
|
// The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
|
|
3606
4078
|
// in the string it is given, so a `ddl` with anything appended to its CREATE
|
|
3607
4079
|
// TABLE executed that too — with entirely plain identifiers, which is why no
|
|
@@ -3613,74 +4085,138 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3613
4085
|
if (exact && !sourceScopeId) {
|
|
3614
4086
|
throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
|
|
3615
4087
|
}
|
|
3616
|
-
const switched = await this.
|
|
3617
|
-
|
|
3618
|
-
//
|
|
3619
|
-
const
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
//
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
4088
|
+
const switched = await this.revision.transaction(async () => {
|
|
4089
|
+
const before = this.loadMarker();
|
|
4090
|
+
// #1713: the lifecycle the platform delivered here, read before the drops take it.
|
|
4091
|
+
const lifecycleBefore = readLifecycle(this.switchSql());
|
|
4092
|
+
// #2016: this store's own tenant receipt, read before the drops take it. A load never
|
|
4093
|
+
// changes which tenant the scope belongs to: the platform's word for it (`provisionedFor`)
|
|
4094
|
+
// must agree with the receipt held, and is refused before the first drop if it does not.
|
|
4095
|
+
const receiptBefore = this.provisionedFor();
|
|
4096
|
+
if (provisionedFor !== undefined && receiptBefore !== null && receiptBefore !== provisionedFor) {
|
|
4097
|
+
const refused = substratError('conflict', tenantReceiptRefusal(receiptBefore, provisionedFor));
|
|
4098
|
+
tenantRefusals.add(refused);
|
|
4099
|
+
throw refused;
|
|
3629
4100
|
}
|
|
3630
|
-
//
|
|
3631
|
-
//
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
if (
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
// `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
|
|
3641
|
-
// `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
|
|
3642
|
-
// them, and without them the very next permission check would raise a bare `no such
|
|
3643
|
-
// table: _substrat_roles`. Roles land empty here and are re-projected by the
|
|
3644
|
-
// restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
|
|
3645
|
-
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
3646
|
-
// leaves to it.
|
|
3647
|
-
for (const stmt of splitSqlStatements(KERNEL_DDL))
|
|
3648
|
-
this.sql.exec(stmt);
|
|
3649
|
-
this.applySpineColumnAdditions();
|
|
3650
|
-
const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
|
|
3651
|
-
assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
|
|
3652
|
-
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
3653
|
-
// untyped column the checker never reads.
|
|
3654
|
-
for (const t of replayable) {
|
|
3655
|
-
if (isSpineTable(t.name))
|
|
3656
|
-
for (const alter of spineColumnAdditions(t, columnsOf(t.name)))
|
|
3657
|
-
this.sql.exec(alter);
|
|
4101
|
+
// #1722 (Codex #2008 r7): a kept copy holds writes nothing else has. No load replaces it
|
|
4102
|
+
// except its own resolution, and that only at the revision the operator acted on.
|
|
4103
|
+
const kept = this.metaValue(KEPT_DIVERGENT_KEY);
|
|
4104
|
+
if (resolveKept) {
|
|
4105
|
+
if (!kept)
|
|
4106
|
+
throw substratError('precondition_failed', 'no kept copy here to resolve; nothing was loaded');
|
|
4107
|
+
if (before.revision !== resolveKept.revision ||
|
|
4108
|
+
(resolveKept.loadStamp !== undefined && before.loadStamp !== resolveKept.loadStamp)) {
|
|
4109
|
+
throw substratError('precondition_failed', 'the kept copy changed since it was read; nothing was loaded');
|
|
4110
|
+
}
|
|
3658
4111
|
}
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
for (const row of t.rows)
|
|
3662
|
-
this.sql.exec(insert, ...row);
|
|
4112
|
+
else if (kept) {
|
|
4113
|
+
throw substratError('conflict', KEPT_COPY_REFUSAL);
|
|
3663
4114
|
}
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
//
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
4115
|
+
if (expect) {
|
|
4116
|
+
if (before.loadStamp !== expect.loadStamp || (expect.revision !== undefined && before.revision !== expect.revision)) {
|
|
4117
|
+
throw substratError('precondition_failed', 'scope store changed since it was read; nothing was loaded');
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
// #1722: the drops take `_substrat_meta` with them; the revision is written back below.
|
|
4121
|
+
this.revisionSuspended = true;
|
|
4122
|
+
try {
|
|
4123
|
+
this.sql.exec('PRAGMA defer_foreign_keys = ON');
|
|
4124
|
+
// Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
|
|
4125
|
+
const existing = this.sql
|
|
4126
|
+
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*'`)
|
|
4127
|
+
.toArray();
|
|
4128
|
+
// Search index tables are left alone here and rebuilt below (#827): dropping a
|
|
4129
|
+
// shadow table directly is an error, and `sqlite_master` order would reach one
|
|
4130
|
+
// before its virtual table.
|
|
4131
|
+
for (const { name } of existing) {
|
|
4132
|
+
if (isSearchIndexTable(name.toLowerCase()))
|
|
4133
|
+
continue;
|
|
4134
|
+
this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
4135
|
+
}
|
|
4136
|
+
// A vertical's tables take the dump's own DDL. The spine never does (#1883): a dump
|
|
4137
|
+
// that declared a column the checker compares on as COLLATE NOCASE would otherwise
|
|
4138
|
+
// decide how this DO's permission checks match. Every `_substrat_*` table is built
|
|
4139
|
+
// from KERNEL_DDL instead, and the dump contributes only rows, by column name
|
|
4140
|
+
// (`spineRowsInsert`), a missing column taking the kernel's default.
|
|
4141
|
+
for (const t of replayable)
|
|
4142
|
+
if (!isSpineTable(t.name))
|
|
4143
|
+
this.sql.exec(t.ddl);
|
|
4144
|
+
// KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
|
|
4145
|
+
// WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
|
|
4146
|
+
// `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
|
|
4147
|
+
// `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
|
|
4148
|
+
// them, and without them the very next permission check would raise a bare `no such
|
|
4149
|
+
// table: _substrat_roles`. Roles land empty here and are re-projected by the
|
|
4150
|
+
// restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
|
|
4151
|
+
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
4152
|
+
// leaves to it.
|
|
4153
|
+
for (const stmt of splitSqlStatements(KERNEL_DDL))
|
|
4154
|
+
this.sql.exec(stmt);
|
|
4155
|
+
this.applySpineColumnAdditions();
|
|
4156
|
+
const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
|
|
4157
|
+
assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
|
|
4158
|
+
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
4159
|
+
// untyped column the checker never reads.
|
|
4160
|
+
for (const t of replayable) {
|
|
4161
|
+
if (isSpineTable(t.name))
|
|
4162
|
+
for (const alter of spineColumnAdditions(t, columnsOf(t.name)))
|
|
4163
|
+
this.sql.exec(alter);
|
|
4164
|
+
}
|
|
4165
|
+
for (const t of replayable) {
|
|
4166
|
+
const insert = dumpRowsInsert(t, columnsOf);
|
|
4167
|
+
for (const row of t.rows)
|
|
4168
|
+
this.sql.exec(insert, ...row);
|
|
4169
|
+
}
|
|
4170
|
+
// #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
|
|
4171
|
+
// not this one, so it is never carried over. #2016: what stays is THIS store's own —
|
|
4172
|
+
// the platform's word when the load carries it (a copy into a fresh scope records its
|
|
4173
|
+
// own tenant here), else the receipt held before the drops. A store that held none
|
|
4174
|
+
// keeps none, and the repair projection that follows writes it.
|
|
4175
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, PROVISIONED_FOR_KEY);
|
|
4176
|
+
const receipt = provisionedFor ?? receiptBefore;
|
|
4177
|
+
if (receipt !== null) {
|
|
4178
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, receipt);
|
|
4179
|
+
}
|
|
4180
|
+
// #1722: whatever stamp an export read here no longer describes this store, so every load
|
|
4181
|
+
// replaces it: with the carry's own, or with none. And the write revision moves on from
|
|
4182
|
+
// where it stood before the drops, so it never goes back.
|
|
4183
|
+
if (loadStamp) {
|
|
4184
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, loadStamp);
|
|
4185
|
+
}
|
|
4186
|
+
else {
|
|
4187
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, LOAD_STAMP_KEY);
|
|
4188
|
+
}
|
|
4189
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, WRITE_REVISION_KEY, String(Number(before.revision ?? 0) + 1));
|
|
4190
|
+
// #1575: attachment text is not in a dump, so the load left it as it was. Drop the
|
|
4191
|
+
// text of attachments the dump did not bring back, and queue extraction for those
|
|
4192
|
+
// it brought back without text — the bytes decide what that run finds.
|
|
4193
|
+
const now = new Date().toISOString();
|
|
4194
|
+
// #1686: nothing the source queued runs in a copy; a return leaves it all queued. BEFORE
|
|
4195
|
+
// the extraction queue below, which is this scope's own work, not the source's.
|
|
4196
|
+
settleCopiedWork(this.switchSql(), destScopeId, sourceScopeId, now);
|
|
4197
|
+
if (markCopy)
|
|
4198
|
+
markCopyOrigin(this.switchSql(), now);
|
|
4199
|
+
// #1713: a return keeps the newer of this store's lifecycle and the dump's, so a backup
|
|
4200
|
+
// from before a suspension does not lift it; a copy keeps only its own.
|
|
4201
|
+
settleLifecycleAfterLoad(this.switchSql(), lifecycleBefore, markCopy === true || isCopyLoad(destScopeId, sourceScopeId));
|
|
4202
|
+
reconcileAttachmentText(doSpineSql(this.sql), ulid, now);
|
|
4203
|
+
// Re-point the restored grants at THIS scope (after the spine exists, so a dump
|
|
4204
|
+
// that carried no tuples table still finds one here).
|
|
4205
|
+
if (destScopeId) {
|
|
4206
|
+
this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
|
|
4207
|
+
}
|
|
4208
|
+
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
4209
|
+
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
4210
|
+
// the dump's grants never commit live without the switch that should cover them.
|
|
4211
|
+
return destScopeId && switchOff
|
|
4212
|
+
? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
|
|
4213
|
+
: [];
|
|
4214
|
+
}
|
|
4215
|
+
finally {
|
|
4216
|
+
this.revisionSuspended = false;
|
|
3676
4217
|
}
|
|
3677
|
-
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
3678
|
-
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
3679
|
-
// the dump's grants never commit live without the switch that should cover them.
|
|
3680
|
-
return destScopeId && switchOff
|
|
3681
|
-
? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
|
|
3682
|
-
: [];
|
|
3683
4218
|
});
|
|
4219
|
+
this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
|
|
3684
4220
|
// Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
|
|
3685
4221
|
// create, so it also repairs an index a dump left stale, and the triggers it
|
|
3686
4222
|
// recreates are what keep the restored scope in step from here. Skipped for a plan
|
|
@@ -3695,6 +4231,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3695
4231
|
for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
|
|
3696
4232
|
this.sql.exec(stmt);
|
|
3697
4233
|
}
|
|
4234
|
+
// #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
|
|
4235
|
+
// as on a wake. A copy's own events then sort above every copied one, which is what
|
|
4236
|
+
// `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
|
|
4237
|
+
const top = this.sql.exec('SELECT MAX(id) AS id FROM _substrat_outbox').toArray()[0]?.id;
|
|
4238
|
+
if (top) {
|
|
4239
|
+
try {
|
|
4240
|
+
this.mintEventId.seedFrom(top);
|
|
4241
|
+
}
|
|
4242
|
+
catch {
|
|
4243
|
+
// Not a ULID: nothing this mint could have written, so there is no floor to keep.
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
3698
4246
|
// The frontier arrived with the dump — refresh the in-memory applied set so a
|
|
3699
4247
|
// later migrate() builds on the imported state, not the provisioning state.
|
|
3700
4248
|
this.applied.clear();
|
|
@@ -3728,6 +4276,166 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3728
4276
|
this.lastFailure = null;
|
|
3729
4277
|
return switched;
|
|
3730
4278
|
}
|
|
4279
|
+
/**
|
|
4280
|
+
* `importDump` with its two refusals answered as values (#1722): across this RPC a throw
|
|
4281
|
+
* carries only its message, and the host has to tell "the store moved since it was read"
|
|
4282
|
+
* (`changed`, 412) and "this store is a kept copy" (`kept`, 409) from a failure.
|
|
4283
|
+
*/
|
|
4284
|
+
async importDumpChecked(tables, destScopeId, opts) {
|
|
4285
|
+
try {
|
|
4286
|
+
return { refused: false, switchedOff: await this.importDump(tables, destScopeId, opts) };
|
|
4287
|
+
}
|
|
4288
|
+
catch (e) {
|
|
4289
|
+
const code = errorCodeOf(e);
|
|
4290
|
+
if (code === 'precondition_failed')
|
|
4291
|
+
return { refused: 'changed' };
|
|
4292
|
+
if (e instanceof Error && tenantRefusals.has(e))
|
|
4293
|
+
return { refused: 'tenant', message: e.message };
|
|
4294
|
+
if (code === 'conflict')
|
|
4295
|
+
return { refused: 'kept' };
|
|
4296
|
+
throw toRpcError(e);
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
/** The kept-copy marker (#1722), or null when this store is not one. */
|
|
4300
|
+
keptCopy() {
|
|
4301
|
+
const raw = this.metaValue(KEPT_DIVERGENT_KEY);
|
|
4302
|
+
return raw ? JSON.parse(raw) : null;
|
|
4303
|
+
}
|
|
4304
|
+
/**
|
|
4305
|
+
* Release a kept copy that is the scope's live store after all (#1722, Codex #2008 r8): a carry
|
|
4306
|
+
* that protected its source when the route read elsewhere, while a rollback was about to bind
|
|
4307
|
+
* it. Clears the marker only, fenced on the write revision read; the data stays as it is.
|
|
4308
|
+
*/
|
|
4309
|
+
releaseKeptCopy(revision,
|
|
4310
|
+
/** The directory says this scope is not primary (#2005): the released store is marked a copy. */
|
|
4311
|
+
markCopy,
|
|
4312
|
+
/** The load stamp read with `revision` (Codex #2008 r13): a store a load replaced is refused.
|
|
4313
|
+
* A kept copy refuses every load but its own resolution, so this holds by construction too. */
|
|
4314
|
+
loadStamp) {
|
|
4315
|
+
return this.revision.transactionSync(() => {
|
|
4316
|
+
if (!this.metaValue(KEPT_DIVERGENT_KEY))
|
|
4317
|
+
return { refused: 'not-kept' };
|
|
4318
|
+
if (this.metaValue(WRITE_REVISION_KEY) !== revision)
|
|
4319
|
+
return { refused: 'changed' };
|
|
4320
|
+
if (loadStamp !== undefined && this.metaValue(LOAD_STAMP_KEY) !== loadStamp)
|
|
4321
|
+
return { refused: 'changed' };
|
|
4322
|
+
this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, KEPT_DIVERGENT_KEY);
|
|
4323
|
+
if (markCopy)
|
|
4324
|
+
this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), new Date().toISOString()));
|
|
4325
|
+
return { released: true };
|
|
4326
|
+
});
|
|
4327
|
+
}
|
|
4328
|
+
/**
|
|
4329
|
+
* Discard a kept copy (#1722): the staff resolution that wipes it to the tombstone, only if it
|
|
4330
|
+
* is still one at the write revision the operator acted on. The marker goes with the wipe.
|
|
4331
|
+
*/
|
|
4332
|
+
async discardKeptCopy(scopeId, revision, carriedAway,
|
|
4333
|
+
/** The directory says this scope is not primary (#2005): as `wipeCarried`. */
|
|
4334
|
+
markCopy,
|
|
4335
|
+
/** The load stamp read with `revision` (Codex #2008 r13), as `releaseKeptCopy`. */
|
|
4336
|
+
loadStamp) {
|
|
4337
|
+
if (!this.metaValue(KEPT_DIVERGENT_KEY))
|
|
4338
|
+
return { refused: 'not-kept' };
|
|
4339
|
+
try {
|
|
4340
|
+
await this.importDump(carriedAwayDump(carriedAway), scopeId, {
|
|
4341
|
+
sourceScopeId: scopeId,
|
|
4342
|
+
resolveKept: { revision, ...(loadStamp !== undefined ? { loadStamp } : {}) },
|
|
4343
|
+
markCopy: markCopy === true || this.isCopy(), // #2005: as `wipeCarried`
|
|
4344
|
+
});
|
|
4345
|
+
return { discarded: true };
|
|
4346
|
+
}
|
|
4347
|
+
catch (e) {
|
|
4348
|
+
if (errorCodeOf(e) === 'precondition_failed')
|
|
4349
|
+
return { refused: 'changed' };
|
|
4350
|
+
throw toRpcError(e);
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
/** What a carry's restore into this store expects to find unchanged (#1722): see `LoadMarker`. */
|
|
4354
|
+
loadMarker() {
|
|
4355
|
+
return { loadStamp: this.metaValue(LOAD_STAMP_KEY), revision: this.metaValue(WRITE_REVISION_KEY) };
|
|
4356
|
+
}
|
|
4357
|
+
/** This store's load stamp (#1722), or null when no load has written one. */
|
|
4358
|
+
loadStamp() {
|
|
4359
|
+
return this.metaValue(LOAD_STAMP_KEY);
|
|
4360
|
+
}
|
|
4361
|
+
metaValue(key) {
|
|
4362
|
+
const row = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = ?`, key).toArray()[0];
|
|
4363
|
+
return row?.value ?? null;
|
|
4364
|
+
}
|
|
4365
|
+
/**
|
|
4366
|
+
* Wipe the copy a carry left in this script (#1722), only if nothing was loaded here since
|
|
4367
|
+
* the carry exported it. A rollback that restored into this DO in the meantime replaced or
|
|
4368
|
+
* cleared the stamp, and its restore stays. Non-terminal, unlike `destroyStorage`: the store is a load of
|
|
4369
|
+
* the `carriedAway` tombstone, so a later restore into it works as on any scope. False when
|
|
4370
|
+
* refused.
|
|
4371
|
+
*/
|
|
4372
|
+
async wipeCarried(scopeId, expectLoadStamp, carriedAway, { expectRevision, protectIfChanged, markCopy, } = {}) {
|
|
4373
|
+
// A store marked now stays one: the copy-origin row goes on the tombstone (the wipe), or
|
|
4374
|
+
// on the copy kept (a refusal), and never counts as a data write (`bookkeeping`).
|
|
4375
|
+
const copy = markCopy === true || this.isCopy();
|
|
4376
|
+
try {
|
|
4377
|
+
await this.importDump(carriedAwayDump(carriedAway), scopeId, {
|
|
4378
|
+
sourceScopeId: scopeId,
|
|
4379
|
+
expect: { loadStamp: expectLoadStamp, ...(expectRevision !== undefined ? { revision: expectRevision } : {}) },
|
|
4380
|
+
// #2005: a copy wiped is still a copy. The drop-and-replay takes the copy-origin row
|
|
4381
|
+
// with everything else, so it is written again, or the tombstone would read as primary.
|
|
4382
|
+
markCopy: copy,
|
|
4383
|
+
});
|
|
4384
|
+
return true;
|
|
4385
|
+
}
|
|
4386
|
+
catch (e) {
|
|
4387
|
+
const code = errorCodeOf(e);
|
|
4388
|
+
// Only on the directory's word: the store's own marker, read before the refused load, may
|
|
4389
|
+
// have been cleared by staff since (Codex #2008 r12), and marking it again would undo that.
|
|
4390
|
+
if (markCopy === true) {
|
|
4391
|
+
this.revision.transactionSync(() => this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), carriedAway.at)));
|
|
4392
|
+
}
|
|
4393
|
+
if (code === 'conflict') {
|
|
4394
|
+
// Already a kept copy, and the scope has been carried away from it again (Codex #2008
|
|
4395
|
+
// r9). That is recorded on the marker, which is a write: it advances the revision, so a
|
|
4396
|
+
// release (staff or a carry's auto-release) that read the revision before this, when the
|
|
4397
|
+
// scope still routed here, fails its compare-and-set rather than clearing the marker of
|
|
4398
|
+
// a copy the scope has just left.
|
|
4399
|
+
this.revision.transactionSync(() => {
|
|
4400
|
+
const raw = this.metaValue(KEPT_DIVERGENT_KEY);
|
|
4401
|
+
if (!raw)
|
|
4402
|
+
return;
|
|
4403
|
+
const kept = { ...JSON.parse(raw), leftAgain: { to: carriedAway.to, at: carriedAway.at } };
|
|
4404
|
+
this.sql.exec(`UPDATE _substrat_meta SET value = ? WHERE key = ?`, JSON.stringify(kept), KEPT_DIVERGENT_KEY);
|
|
4405
|
+
});
|
|
4406
|
+
return false;
|
|
4407
|
+
}
|
|
4408
|
+
if (code !== 'precondition_failed')
|
|
4409
|
+
throw toRpcError(e);
|
|
4410
|
+
// Refused. When nothing was LOADED here since the export (the stamp is the one read) but
|
|
4411
|
+
// the store was WRITTEN (the revision moved), the copy holds a write the carry never
|
|
4412
|
+
// copied: protect it in the store itself, decided again inside its own transaction.
|
|
4413
|
+
this.revision.transactionSync(() => {
|
|
4414
|
+
const now = this.loadMarker();
|
|
4415
|
+
const writtenSince = expectRevision !== undefined && now.loadStamp === expectLoadStamp && now.revision !== expectRevision;
|
|
4416
|
+
const changedSince = now.loadStamp !== expectLoadStamp || (expectRevision !== undefined && now.revision !== expectRevision);
|
|
4417
|
+
// A wiped copy holds nothing to protect (and takes no write, the marker included).
|
|
4418
|
+
if (this.carriedAwayCopy || this.metaValue(KEPT_DIVERGENT_KEY))
|
|
4419
|
+
return;
|
|
4420
|
+
if (!writtenSince && !(protectIfChanged && changedSince))
|
|
4421
|
+
return;
|
|
4422
|
+
const kept = { carriedTo: carriedAway.to, keptAt: carriedAway.at, revision: now.revision };
|
|
4423
|
+
this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, KEPT_DIVERGENT_KEY, JSON.stringify(kept));
|
|
4424
|
+
// Codex #2008 r12: changed ONLY by a staff clear of the copy marker — the clear's own
|
|
4425
|
+
// revisions span exactly the export's and the store's now — so the data is what the carry
|
|
4426
|
+
// copied, and only the fresher classification is not. Recorded on the marker with the
|
|
4427
|
+
// revision this keep committed at (the same run: no second bump), which the platform's
|
|
4428
|
+
// discard is fenced on once it has brought the clear to where the scope runs.
|
|
4429
|
+
const cleared = this.metaValue(COPY_MARK_CLEARED_KEY);
|
|
4430
|
+
const span = cleared ? JSON.parse(cleared) : null;
|
|
4431
|
+
if (writtenSince && span && span.from === expectRevision && span.to === now.revision) {
|
|
4432
|
+
const settled = { ...kept, clearedOnly: { revision: this.metaValue(WRITE_REVISION_KEY) } };
|
|
4433
|
+
this.sql.exec(`UPDATE _substrat_meta SET value = ? WHERE key = ?`, JSON.stringify(settled), KEPT_DIVERGENT_KEY);
|
|
4434
|
+
}
|
|
4435
|
+
});
|
|
4436
|
+
return false;
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
3731
4439
|
/**
|
|
3732
4440
|
* Re-point scope-level tuples at the scope they now live in.
|
|
3733
4441
|
*
|
|
@@ -3874,6 +4582,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3874
4582
|
* later alarm-driven drain with a call it had nothing to do with.
|
|
3875
4583
|
*/
|
|
3876
4584
|
invocationId = null;
|
|
4585
|
+
/**
|
|
4586
|
+
* #1901: the id a unit of async work with no call around it logs under — a consumer a
|
|
4587
|
+
* seed or an import delivered — set for its handler's duration and cleared after. Read
|
|
4588
|
+
* by `ctx.log` only, never the spine: an event a consumer emits outside a call still
|
|
4589
|
+
* records no invocation (#1525), while its log lines join the consumer's own line.
|
|
4590
|
+
*/
|
|
4591
|
+
unitInvocationId = null;
|
|
3877
4592
|
async dispatch(tenantId, scopeId,
|
|
3878
4593
|
/**
|
|
3879
4594
|
* #1525: the call this drain is running in, or null. Every round of the loop
|
|
@@ -3881,13 +4596,23 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3881
4596
|
* whole cascade is one call's work.
|
|
3882
4597
|
*/
|
|
3883
4598
|
invocationId) {
|
|
4599
|
+
// #1901: one line per delivery, capped per drain.
|
|
4600
|
+
const lines = asyncLinePass();
|
|
4601
|
+
try {
|
|
4602
|
+
await this.dispatchRounds(tenantId, scopeId, invocationId, lines);
|
|
4603
|
+
}
|
|
4604
|
+
finally {
|
|
4605
|
+
lines.end();
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4608
|
+
async dispatchRounds(tenantId, scopeId, invocationId, lines) {
|
|
3884
4609
|
for (let round = 0; round < 50; round++) {
|
|
3885
4610
|
let deliveredAny = false;
|
|
3886
4611
|
for (const mod of this.modules.values()) {
|
|
3887
4612
|
for (const consumer of mod.consumers) {
|
|
3888
4613
|
const rows = this.sql
|
|
3889
4614
|
.exec(`SELECT * FROM _substrat_outbox o
|
|
3890
|
-
WHERE o.type = ?
|
|
4615
|
+
WHERE o.type = ? AND ${emittedHere('o.')}
|
|
3891
4616
|
AND NOT EXISTS (
|
|
3892
4617
|
SELECT 1 FROM _substrat_deliveries d
|
|
3893
4618
|
WHERE d.event_id = o.id AND d.consumer_module = ?
|
|
@@ -3895,6 +4620,20 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3895
4620
|
ORDER BY o.id`, consumer.eventType, mod.id)
|
|
3896
4621
|
.toArray();
|
|
3897
4622
|
for (const row of rows) {
|
|
4623
|
+
// #1901: this delivery's line — the call's id when it runs in one, else its own.
|
|
4624
|
+
const unit = {
|
|
4625
|
+
kind: 'consumer',
|
|
4626
|
+
tenantId,
|
|
4627
|
+
scopeId,
|
|
4628
|
+
invocationId: asyncInvocationId(invocationId),
|
|
4629
|
+
operation: mod.id,
|
|
4630
|
+
eventType: consumer.eventType,
|
|
4631
|
+
eventId: row.id,
|
|
4632
|
+
// A module consumer is tried once: a throw dead-letters it (v0).
|
|
4633
|
+
attempt: 1,
|
|
4634
|
+
startedAt: Date.now(),
|
|
4635
|
+
versionId: this.env.SUBSTRAT_VERSION_ID ?? null,
|
|
4636
|
+
};
|
|
3898
4637
|
let event;
|
|
3899
4638
|
try {
|
|
3900
4639
|
event = domainEventOf(row);
|
|
@@ -3908,14 +4647,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3908
4647
|
this.sql.exec(`INSERT INTO _substrat_deliveries
|
|
3909
4648
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3910
4649
|
VALUES (?, ?, ?, ?, ?)`, row.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4650
|
+
// Never reached the handler, so nothing threw in it: a warning, not an error.
|
|
4651
|
+
lines.write({ ...unit, outcome: 'dead-lettered' });
|
|
3911
4652
|
continue;
|
|
3912
4653
|
}
|
|
3913
4654
|
// #1237: anything this consumer emits was emitted BECAUSE of this event
|
|
3914
4655
|
// — the step a backwards walk used to stop dead at, since a consumer
|
|
3915
4656
|
// emit records no operation either.
|
|
3916
4657
|
this.causedBy = event.id;
|
|
4658
|
+
// #1901: the handler's `ctx.log` lines join this delivery's line.
|
|
4659
|
+
this.unitInvocationId = unit.invocationId;
|
|
3917
4660
|
try {
|
|
3918
|
-
await this.
|
|
4661
|
+
await this.revision.transaction(async () => {
|
|
3919
4662
|
const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, {
|
|
3920
4663
|
system: mod.id,
|
|
3921
4664
|
});
|
|
@@ -3925,6 +4668,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3925
4668
|
VALUES (?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), invocationId);
|
|
3926
4669
|
});
|
|
3927
4670
|
deliveredAny = true;
|
|
4671
|
+
lines.write({ ...unit, outcome: 'delivered' });
|
|
3928
4672
|
}
|
|
3929
4673
|
catch (err) {
|
|
3930
4674
|
// Dead-letter (v0): journal the failure so one poison event
|
|
@@ -3932,8 +4676,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3932
4676
|
this.sql.exec(`INSERT INTO _substrat_deliveries
|
|
3933
4677
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3934
4678
|
VALUES (?, ?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4679
|
+
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
3935
4680
|
}
|
|
3936
4681
|
finally {
|
|
4682
|
+
this.unitInvocationId = null;
|
|
3937
4683
|
// Cleared on BOTH paths. Left set, the id leaks onto every later emit
|
|
3938
4684
|
// this DO makes — an operation's own event stamped as caused by
|
|
3939
4685
|
// whatever was delivered last, which is worse than recording nothing
|
|
@@ -3953,9 +4699,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3953
4699
|
* survives — the whole point, since the denial is the write the operation could not make.
|
|
3954
4700
|
*/
|
|
3955
4701
|
/** K-42: the session a refused call ran under travels with the denial row. */
|
|
3956
|
-
/** #1745: a refused transition the operation failed with, written after its rollback. */
|
|
4702
|
+
/** #1745: a refused transition or refusing guard the operation failed with, written after its rollback. */
|
|
3957
4703
|
recordRefusal(subject, tenantId, scopeId, operation, err, invocationId, impersonation) {
|
|
3958
|
-
const refused =
|
|
4704
|
+
const refused = refusalOf(err);
|
|
3959
4705
|
if (!refused)
|
|
3960
4706
|
return;
|
|
3961
4707
|
const q = refusalInsert({
|
|
@@ -4006,7 +4752,12 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4006
4752
|
invocationId, new Date().toISOString());
|
|
4007
4753
|
}
|
|
4008
4754
|
// -- operation context (port of operationContext) -------------------------
|
|
4009
|
-
operationContext(principal, tenantId, scopeId, systemActor, connectionId,
|
|
4755
|
+
operationContext(principal, tenantId, scopeId, systemActor, connectionId,
|
|
4756
|
+
/**
|
|
4757
|
+
* #1834: the system subject, only as the door check hands it out (`assertSystemDoor`) — so
|
|
4758
|
+
* no path can act as `system:<moduleId>` without passing that check.
|
|
4759
|
+
*/
|
|
4760
|
+
systemDoor,
|
|
4010
4761
|
/** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
|
|
4011
4762
|
signals,
|
|
4012
4763
|
/**
|
|
@@ -4071,8 +4822,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4071
4822
|
? peerSubject
|
|
4072
4823
|
: capabilityId
|
|
4073
4824
|
? { kind: 'capability', id: capabilityId }
|
|
4074
|
-
:
|
|
4075
|
-
? { kind: 'system', id:
|
|
4825
|
+
: systemDoor
|
|
4826
|
+
? { kind: 'system', id: systemDoor.moduleId }
|
|
4076
4827
|
: connectionId
|
|
4077
4828
|
? { kind: 'connection', id: connectionId }
|
|
4078
4829
|
: { kind: 'principal', id: principal };
|
|
@@ -4101,7 +4852,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4101
4852
|
* owns that stack itself. It is exactly this asymmetry that makes `RunSub`
|
|
4102
4853
|
* closure-shaped rather than an enter/rollback/release triple.
|
|
4103
4854
|
*/
|
|
4104
|
-
const runSub = (_depth, fn) => this.
|
|
4855
|
+
const runSub = (_depth, fn) => this.revision.transaction(fn);
|
|
4105
4856
|
// Lifted so `grant` reuses the SAME check the operation itself passes —
|
|
4106
4857
|
// a delegation check that could differ from the operation's would be a
|
|
4107
4858
|
// second opinion about what the caller holds.
|
|
@@ -4198,7 +4949,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4198
4949
|
tenantId,
|
|
4199
4950
|
scopeId,
|
|
4200
4951
|
operation: operation ?? null,
|
|
4201
|
-
invocationId: () => this.invocationId,
|
|
4952
|
+
invocationId: () => this.invocationId ?? this.unitInvocationId,
|
|
4202
4953
|
principalKind: systemActor ? 'system' : subject.kind,
|
|
4203
4954
|
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
4204
4955
|
// log's text is not JSON.
|
|
@@ -4279,7 +5030,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4279
5030
|
});
|
|
4280
5031
|
const rows = sql.exec(q.sql, ...q.params).toArray();
|
|
4281
5032
|
const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
|
|
4282
|
-
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn);
|
|
5033
|
+
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
|
|
4283
5034
|
const page = { entries: rows, nextCursor };
|
|
4284
5035
|
if (!params.total)
|
|
4285
5036
|
return page;
|
|
@@ -4512,18 +5263,23 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
4512
5263
|
// The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
|
|
4513
5264
|
// serializes but does not roll back, and a role insert that throws part-way must not leave
|
|
4514
5265
|
// the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
|
|
4515
|
-
return this.queue.enqueue(() => this.
|
|
5266
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => {
|
|
4516
5267
|
// #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
|
|
4517
5268
|
// a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
|
|
4518
5269
|
// moves, so a misdirected projection can never re-point a scope or leave its roles behind.
|
|
4519
|
-
// A restore does not trip this: `importDump`
|
|
4520
|
-
// projection that follows finds
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
5270
|
+
// A restore does not trip this: `importDump` keeps the destination's own receipt and
|
|
5271
|
+
// never the dump's (#2016), so the repair projection that follows finds its own tenant.
|
|
5272
|
+
// #2016: a scope with no receipt yet takes its first one only where its role rows agree
|
|
5273
|
+
// (`tenantVerdict`, the same reading the lifecycle back-fill uses), so a projection for
|
|
5274
|
+
// another tenant cannot pin a legacy scope to it.
|
|
5275
|
+
const { verdict, held } = this.tenantVerdict(tenantId);
|
|
5276
|
+
if (verdict === 'foreign') {
|
|
5277
|
+
throw substratError('conflict', held !== null
|
|
5278
|
+
? `applyProjection refused: this scope was provisioned for tenant ${held}, and a projection for tenant ${tenantId} would re-point it`
|
|
5279
|
+
: `applyProjection ${foreignTenant(held, tenantId)}`);
|
|
4524
5280
|
}
|
|
4525
5281
|
// Written before any guard below can return early, so every projection leaves it.
|
|
4526
|
-
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (
|
|
5282
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, tenantId);
|
|
4527
5283
|
this.sql.exec(`DELETE FROM _substrat_roles WHERE tenant_id = ?`, tenantId);
|
|
4528
5284
|
for (const r of roles) {
|
|
4529
5285
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, revoked_at)
|