@substrat-run/adapter-cloudflare 0.134.0 → 0.136.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/scope-do.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { REWIND_REFUSED } from './rewind-refusal.js';
2
2
  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, 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_DDL, refusalInsert, refusedTransitionOf, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
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, errorCodeOf, 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, REFUSALS_DDL, refusalInsert, refusedTransitionOf, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
5
5
  import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
6
6
  import { OperationQueue } from './serialization.js';
7
7
  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, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
8
+ 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
9
  import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
10
- import { assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpineTable, repointScopeGrants, spineColumnAdditions } from '@substrat-run/kernel';
10
+ 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
11
  /**
12
12
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
13
13
  * Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
@@ -327,6 +327,9 @@ const KERNEL_DDL = `
327
327
  -- an exchange trades that secret for. Shared with the pure adapter from
328
328
  -- @substrat-run/kernel so the two cannot part company; the column comments are there.
329
329
  ${CAPABILITY_DDL}
330
+ -- #1686: where a copied scope's data came from, and the event id its own events start above.
331
+ -- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
332
+ ${COPY_ORIGIN_DDL}
330
333
  `;
331
334
  /**
332
335
  * Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
@@ -362,6 +365,137 @@ function kernelEmit(ctx, event) {
362
365
  throw new Error('kernelEmit: not an operation context this host built');
363
366
  write(event);
364
367
  }
368
+ /**
369
+ * The scope DO's write revision (#1722): the one SQL handle this object writes through, and the
370
+ * one way it opens a transaction, so a writer or a transaction added later cannot slip past it.
371
+ * A carry's conditional restore compares the revision, so every change to the store has to move
372
+ * it: an UPDATE in place (a drain receipt, a redrain) as surely as an emitted event (Codex #2008
373
+ * r2). `suspended` is the wake's idempotent DDL, a load's drop-and-replay and a migration's own
374
+ * statements, which advance it themselves.
375
+ *
376
+ * **Atomic with the write (Codex #2008 r4).** The bump runs synchronously, right BEFORE the first
377
+ * write that needs it, on the same connection: inside a `transactionSync` or `transaction` it
378
+ * commits or rolls back with that transaction's writes, and outside one it joins the same
379
+ * coalesced batch. A bump that fails throws before the write runs, so no write lands under the old
380
+ * revision. Nothing is deferred to a later commit.
381
+ *
382
+ * **Once per run, not per statement** (a bump per statement cost 28.7–32.4% on a write-heavy
383
+ * operation, `write-revision-cost.test.ts`). After a bump, later writes skip it while they are
384
+ * covered by it: until the run ends (a microtask clears the flag at its first await or its end)
385
+ * and until a transaction boundary. Entering a transaction clears it, so each committing
386
+ * transaction holds a bump of its own; leaving one clears it too, so after a rollback (which took
387
+ * the bump with it) or a commit the next write bumps again. Clearing more often only over-counts,
388
+ * and over-counting only refuses a restore that could have landed.
389
+ */
390
+ class WriteRevision {
391
+ raw;
392
+ storage;
393
+ suspended;
394
+ refusal;
395
+ /** Whether the current run, in the current transaction scope, already holds a bump. */
396
+ covered = false;
397
+ /** Inside `bookkeeping`: the one path whose writes advance no revision. */
398
+ keeping = false;
399
+ /** A statement's text is almost always a constant, so its answer is remembered (bounded). */
400
+ writes = new Map();
401
+ sql;
402
+ constructor(raw, storage, suspended,
403
+ /** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
404
+ refusal) {
405
+ this.raw = raw;
406
+ this.storage = storage;
407
+ this.suspended = suspended;
408
+ this.refusal = refusal;
409
+ const exec = (query, ...bindings) => {
410
+ if (this.keeping && this.isWrite(query)) {
411
+ // Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
412
+ // else: any other write here would be one the revision never saw, which is the hole this
413
+ // class exists to close.
414
+ if (!isCopyMarkInsert(query) && !isLifecycleWrite(query)) {
415
+ throw substratError('internal', `the bookkeeping path takes only the copy-marker insert and the lifecycle delivery (#1722, #1713), not: ${query.slice(0, 80)}`);
416
+ }
417
+ return raw.exec(query, ...bindings);
418
+ }
419
+ if (!this.suspended() && this.isWrite(query)) {
420
+ const refused = this.refusal();
421
+ if (refused)
422
+ throw substratError('conflict', refused);
423
+ if (!this.covered)
424
+ this.bump();
425
+ }
426
+ return raw.exec(query, ...bindings);
427
+ };
428
+ this.sql = new Proxy(raw, {
429
+ get(target, prop) {
430
+ if (prop === 'exec')
431
+ return exec;
432
+ const value = Reflect.get(target, prop, target);
433
+ return typeof value === 'function' ? value.bind(target) : value;
434
+ },
435
+ });
436
+ }
437
+ /**
438
+ * Store bookkeeping that is not the scope's data: marking the store a copy (#2005, Codex #2008
439
+ * r10). That only ever restricts what the store may run and changes no data, so it advances no
440
+ * write revision: a backfill that marks a carry's source between its export and its wipe must
441
+ * not read as a write the carry did not copy (that would keep the copy for nothing). Enforced,
442
+ * not trusted: any write in here but the marker insert (`isCopyMarkInsert`) or the lifecycle
443
+ * delivery (`isLifecycleWrite`, #1713) throws. Clearing a
444
+ * marker is NOT bookkeeping (r11): it loosens the store, so it is a write a carry fences on.
445
+ */
446
+ bookkeeping(run) {
447
+ const was = this.keeping;
448
+ this.keeping = true;
449
+ try {
450
+ return run();
451
+ }
452
+ finally {
453
+ this.keeping = was;
454
+ }
455
+ }
456
+ /** `storage.transactionSync`, at a transaction boundary. */
457
+ transactionSync(run) {
458
+ this.covered = false;
459
+ try {
460
+ return this.storage.transactionSync(run);
461
+ }
462
+ finally {
463
+ this.covered = false;
464
+ }
465
+ }
466
+ /** `storage.transaction`, at a transaction boundary. */
467
+ async transaction(run) {
468
+ this.covered = false;
469
+ try {
470
+ return await this.storage.transaction(run);
471
+ }
472
+ finally {
473
+ this.covered = false;
474
+ }
475
+ }
476
+ bump() {
477
+ // No catch: a bump that cannot be written fails the write it was for.
478
+ this.raw.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, '1')
479
+ ON CONFLICT (key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)`, WRITE_REVISION_KEY);
480
+ this.covered = true;
481
+ queueMicrotask(() => {
482
+ this.covered = false;
483
+ });
484
+ }
485
+ isWrite(query) {
486
+ let known = this.writes.get(query);
487
+ if (known === undefined) {
488
+ known = isWriteStatement(query);
489
+ if (this.writes.size >= 512)
490
+ this.writes.clear();
491
+ this.writes.set(query, known);
492
+ }
493
+ return known;
494
+ }
495
+ }
496
+ /** What a write into a wiped copy answers (#1722): the scope's data lives in another script now. */
497
+ const CARRIED_AWAY_WRITE_REFUSAL = 'this copy of the scope was carried to another script and wiped (#1722); it takes no writes — ' +
498
+ 'a request that reaches it was routed before the move';
365
499
  function toRpcError(err) {
366
500
  if (err instanceof Error) {
367
501
  return err.constructor === Error ? err : new Error(err.message);
@@ -584,6 +718,21 @@ export function defineScopeDO(modules, bareOps) {
584
718
  * `runInDurableObject` — rather than assuming the runtime's documented behaviour.
585
719
  */
586
720
  webSocketMessagesHandled = 0;
721
+ /**
722
+ * #1722: while true, a write does not advance the write revision. True through the wake's
723
+ * own idempotent DDL (which every wake re-runs, and which changes nothing) and through a
724
+ * load's drop-and-replay, which sets the revision itself once the store is rebuilt.
725
+ */
726
+ revisionSuspended = true;
727
+ /**
728
+ * #1722 (Codex #2008 r7): whether this store is a copy a carry wiped (it holds the
729
+ * `carried_away` tombstone). Such a store takes no write: a stale request still routed here
730
+ * after the wipe would land a write that belongs to no live copy. A load, a rollback's
731
+ * restore, ends it. Read on every wake and after every load.
732
+ */
733
+ carriedAwayCopy = false;
734
+ /** #1722: the write revision, and the one way this object opens a transaction. */
735
+ revision;
587
736
  constructor(ctx, env) {
588
737
  super(ctx, env);
589
738
  // #1860: the client's 45s keep-alive (demos/ticket0/app/src/feed.ts) answered at
@@ -591,11 +740,18 @@ export function defineScopeDO(modules, bareOps) {
591
740
  // subscriber's ping still reaches `webSocketMessage` and pins the DO in memory —
592
741
  // exactly the hibernation cost `acceptWebSocket` (below) exists to avoid.
593
742
  ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
594
- this.sql = ctx.storage.sql;
743
+ // #1722: the ONE handle this object writes through and the one way it opens a transaction,
744
+ // so every write advances the write revision a carry's restore is fenced on, inside the
745
+ // transaction that commits it. Nothing else here may take the raw handle or open a
746
+ // transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
747
+ this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
748
+ this.sql = this.revision.sql;
595
749
  for (const stmt of splitSqlStatements(KERNEL_DDL)) {
596
750
  this.sql.exec(stmt);
597
751
  }
598
752
  this.applySpineColumnAdditions();
753
+ this.revisionSuspended = false;
754
+ this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
599
755
  for (const registration of modules)
600
756
  this.registerModule(registration);
601
757
  for (const [name, handler] of Object.entries(bareOps))
@@ -860,7 +1016,7 @@ export function defineScopeDO(modules, bareOps) {
860
1016
  */
861
1017
  undrainedEventsRead(limit) {
862
1018
  return readUndrainedOutbox((offset, count) => this.sql
863
- .exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`, count, offset)
1019
+ .exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`, count, offset)
864
1020
  .toArray(), limit);
865
1021
  }
866
1022
  /**
@@ -1024,10 +1180,27 @@ export function defineScopeDO(modules, bareOps) {
1024
1180
  (event_id, consumer_module, delivered_at, error, invocation_id)
1025
1181
  VALUES (?, ?, ?, ?, NULL)`, id, moduleId, when, error);
1026
1182
  };
1183
+ // #1901: one line per (event, importing module), as for the scope's own consumers.
1184
+ // The handler runs as the producer's peer, so that is who the line says it ran as.
1185
+ const lines = asyncLinePass();
1186
+ const unitOf = (moduleId, eventId, eventType) => ({
1187
+ kind: 'consumer',
1188
+ tenantId,
1189
+ scopeId,
1190
+ invocationId: asyncInvocationId(null),
1191
+ operation: moduleId,
1192
+ eventType,
1193
+ eventId,
1194
+ attempt: 1,
1195
+ startedAt: Date.now(),
1196
+ principalKind: peerSubject.kind,
1197
+ versionId: this.env.SUBSTRAT_VERSION_ID ?? null,
1198
+ });
1027
1199
  for (const w of batch.withheld) {
1028
1200
  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
1201
  for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
1030
1202
  deadLetter(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
1203
+ lines.write({ ...unitOf(moduleId, w.id, w.type), outcome: 'dead-lettered' });
1031
1204
  }
1032
1205
  result.withheld += 1;
1033
1206
  }
@@ -1040,14 +1213,17 @@ export function defineScopeDO(modules, bareOps) {
1040
1213
  ran = true;
1041
1214
  if (imp.schemaVersion !== e.schemaVersion) {
1042
1215
  deadLetter(e.id, imp.moduleId, at, withheldNote('version', source.vertical));
1216
+ lines.write({ ...unitOf(imp.moduleId, e.id, e.type), outcome: 'dead-lettered' });
1043
1217
  result.deadLettered += 1;
1044
1218
  continue;
1045
1219
  }
1046
1220
  const { hops: _hops, ...fact } = e;
1047
1221
  const event = structuredClone({ ...fact, source });
1222
+ const unit = unitOf(imp.moduleId, e.id, e.type);
1048
1223
  this.causedBy = e.id;
1224
+ this.unitInvocationId = unit.invocationId;
1049
1225
  try {
1050
- await this.ctx.storage.transaction(async () => {
1226
+ await this.revision.transaction(async () => {
1051
1227
  // As the producer's principal: real checks against the grants this vertical's
1052
1228
  // `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
1053
1229
  await imp.handler(this.importContext(tenantId, scopeId, peerSubject), event);
@@ -1055,14 +1231,17 @@ export function defineScopeDO(modules, bareOps) {
1055
1231
  VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
1056
1232
  });
1057
1233
  result.delivered += 1;
1234
+ lines.write({ ...unit, outcome: 'delivered' });
1058
1235
  }
1059
1236
  catch (err) {
1060
1237
  // Dead-letter (v0), outside the rolled-back transaction.
1061
1238
  deadLetter(e.id, imp.moduleId, new Date().toISOString(), String(err));
1062
1239
  result.deadLettered += 1;
1240
+ lines.write({ ...unit, outcome: 'dead-lettered', error: err });
1063
1241
  }
1064
1242
  finally {
1065
1243
  this.causedBy = null;
1244
+ this.unitInvocationId = null;
1066
1245
  }
1067
1246
  }
1068
1247
  if (!ran)
@@ -1070,6 +1249,7 @@ export function defineScopeDO(modules, bareOps) {
1070
1249
  }
1071
1250
  this.sql.exec(IMPORT_CURSOR_ADVANCE_SQL, source.scopeId, source.vertical, batch.next, at);
1072
1251
  result.cursor = batch.next;
1252
+ lines.end();
1073
1253
  await this.settleCommitted(tenantId, scopeId, liveSince, null);
1074
1254
  return result;
1075
1255
  });
@@ -1320,7 +1500,7 @@ export function defineScopeDO(modules, bareOps) {
1320
1500
  * and the re-assert in which a sweep could run the grants just seated.
1321
1501
  */
1322
1502
  async seatTuples(tuples, switchOff) {
1323
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
1503
+ return this.queue.enqueue(() => this.revision.transactionSync(() => {
1324
1504
  for (const t of tuples) {
1325
1505
  const seat = seatScopeTuple(t.subject, t.relation, t.object, t.expires_at);
1326
1506
  this.sql.exec(seat.sql, ...seat.params);
@@ -1386,6 +1566,9 @@ export function defineScopeDO(modules, bareOps) {
1386
1566
  * where the control plane is unreachable by the sandbox contract and the projected
1387
1567
  * entitlements are the source of truth. A console-managed scope is gated on the
1388
1568
  * coordinator against the shared CP, so this is left undefined / a no-op there.
1569
+ * Also undefined for a module's own declared schedule through the system door
1570
+ * (#1654): the coordinator resolves the key with the kernel's `requiredEntitlementFor`,
1571
+ * which exempts exactly that invoke, so the DO enforces whatever it is handed.
1389
1572
  */
1390
1573
  requiredEntitlement,
1391
1574
  /**
@@ -1626,7 +1809,7 @@ export function defineScopeDO(modules, bareOps) {
1626
1809
  // commit together, or a throw (from either) rolls domain writes AND
1627
1810
  // emitted events back as one — verified across `await` in workerd.
1628
1811
  try {
1629
- await this.ctx.storage.transaction(async () => {
1812
+ await this.revision.transaction(async () => {
1630
1813
  const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
1631
1814
  // #116: a retry is answered from the recording, and nothing else runs
1632
1815
  // — not the guards, not the handler, not the permission check inside
@@ -2108,7 +2291,7 @@ export function defineScopeDO(modules, bareOps) {
2108
2291
  // the reason it is read there: that is where this call holds the DO to itself.
2109
2292
  const liveSince = this.liveHighWaterMark();
2110
2293
  try {
2111
- await this.ctx.storage.transaction(async () => {
2294
+ await this.revision.transaction(async () => {
2112
2295
  const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.upload');
2113
2296
  assertAllowed(await ctx.check(gate.write, parsed.entity));
2114
2297
  this.sql.exec(`INSERT INTO _substrat_attachments
@@ -2232,7 +2415,7 @@ export function defineScopeDO(modules, bareOps) {
2232
2415
  // #938: same mark-then-settle pair as the upload path above.
2233
2416
  const liveSince = this.liveHighWaterMark();
2234
2417
  try {
2235
- await this.ctx.storage.transaction(async () => {
2418
+ await this.revision.transaction(async () => {
2236
2419
  const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.remove');
2237
2420
  assertAllowed(await ctx.check(gate.write, record.entity));
2238
2421
  this.sql.exec('DELETE FROM _substrat_attachments WHERE id = ?', attachmentId);
@@ -2298,7 +2481,7 @@ export function defineScopeDO(modules, bareOps) {
2298
2481
  */
2299
2482
  async attachmentTextRecord(attachmentId, outcome) {
2300
2483
  await this.ensureMigrations();
2301
- return this.queue.enqueue(async () => this.ctx.storage.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
2484
+ return this.queue.enqueue(async () => this.revision.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
2302
2485
  }
2303
2486
  // -- attachments through a capability (#1686) -------------------------------
2304
2487
  // The coordinator hashed the session token; only the hash arrives. Each verb resolves it
@@ -2432,7 +2615,7 @@ export function defineScopeDO(modules, bareOps) {
2432
2615
  * as one `transactionSync`, so its reads and writes are one unit.
2433
2616
  */
2434
2617
  async switchSystemSchedules(moduleId, scopeId, to, at) {
2435
- const outcome = await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2618
+ const outcome = await this.queue.enqueue(() => this.revision.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2436
2619
  // #1819: the instance that applied it, which the rewind hold's release rule reads.
2437
2620
  return { ...outcome, instance: this.instanceId };
2438
2621
  }
@@ -2447,14 +2630,14 @@ export function defineScopeDO(modules, bareOps) {
2447
2630
  // The consumer's own running imports decide whether the edge exists: read here, in the
2448
2631
  // deployment whose handlers would run, never from the platform's request.
2449
2632
  const imports = this.crossVertical.consumes();
2450
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => moveImportCursor(this.switchSql(), { ...input, imports })));
2633
+ return this.queue.enqueue(() => this.revision.transactionSync(() => moveImportCursor(this.switchSql(), { ...input, imports })));
2451
2634
  }
2452
2635
  /**
2453
2636
  * Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
2454
2637
  * the schedule switch's statement with a `vertical:` subject. Queued, one transaction.
2455
2638
  */
2456
2639
  async switchPeer(vertical, scopeId, to, at) {
2457
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at })));
2640
+ return this.queue.enqueue(() => this.revision.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at })));
2458
2641
  }
2459
2642
  /**
2460
2643
  * Does peer `vertical` hold each key at this scope's node now (#1706) — the checker's own
@@ -2578,7 +2761,7 @@ export function defineScopeDO(modules, bareOps) {
2578
2761
  */
2579
2762
  switchHoldOn(scopeId, moduleId, claimIds) {
2580
2763
  this.switchHoldsTable();
2581
- this.ctx.storage.transactionSync(() => {
2764
+ this.revision.transactionSync(() => {
2582
2765
  const seq = this.sql
2583
2766
  .exec(`INSERT INTO _substrat_switch_hold_seq (id, seq) VALUES (1, 1)
2584
2767
  ON CONFLICT (id) DO UPDATE SET seq = seq + 1 RETURNING seq`)
@@ -2603,7 +2786,7 @@ export function defineScopeDO(modules, bareOps) {
2603
2786
  switchHoldClaim(scopeId, moduleIds, claimId, token) {
2604
2787
  this.switchHoldsTable();
2605
2788
  const at = new Date().toISOString();
2606
- this.ctx.storage.transactionSync(() => {
2789
+ this.revision.transactionSync(() => {
2607
2790
  for (const moduleId of moduleIds) {
2608
2791
  this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
2609
2792
  SELECT ?, ?, ?, 'pending', NULL, ?
@@ -2623,7 +2806,7 @@ export function defineScopeDO(modules, bareOps) {
2623
2806
  switchHoldJoin(scopeId, moduleId, claimIds) {
2624
2807
  this.switchHoldsTable();
2625
2808
  const at = new Date().toISOString();
2626
- this.ctx.storage.transactionSync(() => {
2809
+ this.revision.transactionSync(() => {
2627
2810
  for (const claimId of claimIds) {
2628
2811
  this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
2629
2812
  SELECT scope_id, ?, claim_id, state, doomed, ? FROM _substrat_switch_holds
@@ -2681,7 +2864,7 @@ export function defineScopeDO(modules, bareOps) {
2681
2864
  /** Release these claims on one module, or every claim on the scope when `claimIds` is null. */
2682
2865
  switchHoldRelease(scopeId, moduleId, claimIds) {
2683
2866
  this.switchHoldsTable();
2684
- this.ctx.storage.transactionSync(() => {
2867
+ this.revision.transactionSync(() => {
2685
2868
  if (moduleId === null || claimIds === null) {
2686
2869
  this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ?', scopeId);
2687
2870
  this.sql.exec('DELETE FROM _substrat_switch_hold_ons WHERE scope_id = ?', scopeId);
@@ -2718,7 +2901,7 @@ export function defineScopeDO(modules, bareOps) {
2718
2901
  // and the event's `occurredAt` are one fact, and two clock reads could disagree.
2719
2902
  const now = instant.parse(new Date().toISOString());
2720
2903
  let outcome = null;
2721
- await this.ctx.storage.transaction(async () => {
2904
+ await this.revision.transaction(async () => {
2722
2905
  outcome = await exchangeCapability({
2723
2906
  sql: doSpineSql(this.sql),
2724
2907
  now,
@@ -2748,7 +2931,16 @@ export function defineScopeDO(modules, bareOps) {
2748
2931
  */
2749
2932
  async revokeCapabilityAsPlatform(id, actor) {
2750
2933
  await this.ensureMigrations();
2751
- return await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => revokeCapabilityAsPlatform(doSpineSql(this.sql), id, actor, instant.parse(new Date().toISOString()))) ?? null);
2934
+ return await this.queue.enqueue(() => this.revision.transactionSync(() => revokeCapabilityAsPlatform(doSpineSql(this.sql), id, actor, instant.parse(new Date().toISOString()))) ?? null);
2935
+ }
2936
+ /**
2937
+ * The operator's read of this scope's capabilities (#1686). The kernel's one read
2938
+ * (`readCapabilityPage`): no `token_hash` selected, decoded to a record with no field to hold one.
2939
+ * Authorization and the K-3 cross-check happen on the coordinator first.
2940
+ */
2941
+ async listCapabilities(filter) {
2942
+ await this.ensureMigrations();
2943
+ return readCapabilityPage(doSpineSql(this.sql), filter);
2752
2944
  }
2753
2945
  /**
2754
2946
  * The last time a schedule's operation ran on this scope (#383), or null.
@@ -2861,7 +3053,7 @@ export function defineScopeDO(modules, bareOps) {
2861
3053
  // `jobRunInsert`: an `await` between them is an output-gate boundary, and the
2862
3054
  // point of doing this in one RPC is that there is no boundary to be evicted
2863
3055
  // at. Same reason `SCHEDULE_STATE_REBUILD` insists on it.
2864
- this.ctx.storage.transactionSync(() => {
3056
+ this.revision.transactionSync(() => {
2865
3057
  this.sql.exec(`INSERT INTO _substrat_job_runs
2866
3058
  (id, module_id, job, instance, payload, status, cursor, counters, attempts,
2867
3059
  last_error, started_at, updated_at, next_attempt_at, ended_at, subject_id)
@@ -2961,7 +3153,7 @@ export function defineScopeDO(modules, bareOps) {
2961
3153
  // `transactionSync` with both statements inline — NOT `await
2962
3154
  // this.jobRunPatch(...)` then the delete. The await is an output-gate
2963
3155
  // boundary, which is precisely the gap this method exists to close.
2964
- this.ctx.storage.transactionSync(() => {
3156
+ this.revision.transactionSync(() => {
2965
3157
  this.sql.exec(JOB_RUN_PATCH_SQL, patch.status, patch.cursor, patch.counters, patch.attempts, patch.lastError, patch.updatedAt, patch.nextAttemptAt, patch.endedAt, id);
2966
3158
  this.sql.exec('DELETE FROM _substrat_job_steps WHERE run_id = ?', id);
2967
3159
  });
@@ -3030,7 +3222,7 @@ export function defineScopeDO(modules, bareOps) {
3030
3222
  if (this.applied.has(key))
3031
3223
  continue;
3032
3224
  try {
3033
- await this.ctx.storage.transaction(async () => {
3225
+ await this.revision.transaction(async () => {
3034
3226
  const already = this.sql
3035
3227
  .exec('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
3036
3228
  .toArray()[0];
@@ -3040,8 +3232,16 @@ export function defineScopeDO(modules, bareOps) {
3040
3232
  // #1898: a migration runs on this DO's own handle, not `ctx.sql`, so the
3041
3233
  // spine guard's REFERENCES rule is applied here.
3042
3234
  assertNoSpineReference(migration.sql, `migration ${key}`);
3043
- for (const stmt of splitSqlStatements(migration.sql)) {
3044
- this.sql.exec(stmt);
3235
+ // #1722: not counted per statement, so `total_changes()` measures the migration
3236
+ // alone. The journal row below is a write, and advances the revision once.
3237
+ this.revisionSuspended = true;
3238
+ try {
3239
+ for (const stmt of splitSqlStatements(migration.sql)) {
3240
+ this.sql.exec(stmt);
3241
+ }
3242
+ }
3243
+ finally {
3244
+ this.revisionSuspended = false;
3045
3245
  }
3046
3246
  const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
3047
3247
  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);
@@ -3089,7 +3289,7 @@ export function defineScopeDO(modules, bareOps) {
3089
3289
  .exec(`SELECT o.* FROM _substrat_outbox o
3090
3290
  LEFT JOIN _substrat_deliveries d
3091
3291
  ON d.event_id = o.id AND d.consumer_module = ?
3092
- WHERE o.type = ?
3292
+ WHERE o.type = ? AND ${emittedHere('o.')}
3093
3293
  AND (d.event_id IS NULL
3094
3294
  OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?))
3095
3295
  ORDER BY o.id`, deliveryId, eventType, new Date().toISOString())
@@ -3106,6 +3306,69 @@ export function defineScopeDO(modules, bareOps) {
3106
3306
  }
3107
3307
  return { events, undecodable };
3108
3308
  }
3309
+ /**
3310
+ * Whether this scope is a copy (#2005) — the classification on its `_substrat_copy_origin` row
3311
+ * (#2009), which only the directory's word sets, not a load's events mark. A CP-less
3312
+ * coordinator has no directory to read a scope's kind from, so this is how it holds a copy's
3313
+ * executor deliveries inert; a coordinator with a directory asks that instead.
3314
+ */
3315
+ isCopy() {
3316
+ return this.sql.exec(IS_COPY_SQL).toArray().length > 0;
3317
+ }
3318
+ /** The lifecycle the platform last delivered to this scope (#1713), or null for none. */
3319
+ lifecycle() {
3320
+ return readLifecycle(this.switchSql());
3321
+ }
3322
+ /**
3323
+ * Store a lifecycle the platform delivered (#1713, `writeLifecycle`): kept only when it is not
3324
+ * older than the one held. Bookkeeping, like the copy marker: it changes what runs here, not
3325
+ * the scope's data, so it does not advance the write revision a carry fences on.
3326
+ */
3327
+ setLifecycle(next) {
3328
+ let out;
3329
+ this.revision.transactionSync(() => {
3330
+ out = this.revision.bookkeeping(() => writeLifecycle(this.switchSql(), next));
3331
+ });
3332
+ return out;
3333
+ }
3334
+ /** Mark this scope a copy (#2005, `markCopyOrigin`): the repair of a copy that predates the
3335
+ * marker. Answers whether this call stamped it; a store that already reads as a copy is left
3336
+ * as it is, and a load's events mark is never moved (#2009). */
3337
+ markCopy() {
3338
+ let marked = false;
3339
+ this.revision.transactionSync(() => {
3340
+ marked = this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), new Date().toISOString()));
3341
+ });
3342
+ return marked;
3343
+ }
3344
+ /**
3345
+ * Clear a mistaken copy classification (#2005, `clearCopyMarker`); a load's events mark stays,
3346
+ * so copied work still never runs here (#2009). A
3347
+ * clear is a write like any other (Codex #2008 r11), never bookkeeping: it lets the store run
3348
+ * work a copy holds inert, so it advances the write revision, and a carry that exported
3349
+ * before it cannot wipe the repaired store. That carry's wipe keeps it instead.
3350
+ */
3351
+ clearCopyMark(
3352
+ /** The store the caller means (Codex #2008 r12–r13): the platform's reconcile of the store a
3353
+ * carry landed names that carry's load stamp and the revision it read, so a store another
3354
+ * load has replaced since (a governed restore, whose classification is its own) is refused,
3355
+ * compared here, in the clear's own transaction. Absent for staff's correction. */
3356
+ expect) {
3357
+ return this.revision.transactionSync(() => {
3358
+ const from = this.metaValue(WRITE_REVISION_KEY);
3359
+ if (expect && (this.metaValue(LOAD_STAMP_KEY) !== expect.loadStamp || from !== expect.revision)) {
3360
+ return 'changed';
3361
+ }
3362
+ const outcome = clearCopyMarker(this.switchSql());
3363
+ // The clear's own revisions, so a carry's refused wipe can tell that this clear, and
3364
+ // nothing else, is what changed here since its export (`COPY_MARK_CLEARED_KEY`).
3365
+ if (outcome === 'cleared') {
3366
+ this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)
3367
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`, COPY_MARK_CLEARED_KEY, JSON.stringify({ from, to: this.metaValue(WRITE_REVISION_KEY) }));
3368
+ }
3369
+ return outcome;
3370
+ });
3371
+ }
3109
3372
  /**
3110
3373
  * The same read as a bare list of events, for a coordinator deployed before
3111
3374
  * `pendingExecutorDeliveries` (#1636). An undecodable row is left out rather than
@@ -3350,7 +3613,7 @@ export function defineScopeDO(modules, bareOps) {
3350
3613
  let result;
3351
3614
  const rollback = new Error('read-only console rollback');
3352
3615
  try {
3353
- await this.ctx.storage.transaction(async () => {
3616
+ await this.revision.transaction(async () => {
3354
3617
  const cursor = this.sql.exec(stmt);
3355
3618
  const columns = cursor.columnNames;
3356
3619
  const rows = [];
@@ -3382,6 +3645,14 @@ export function defineScopeDO(modules, bareOps) {
3382
3645
  const q = denialListQuery(filter);
3383
3646
  return this.sql.exec(q.sql, ...q.params).toArray().map(mapDenialRow);
3384
3647
  }
3648
+ /**
3649
+ * #1745: the refusal log — every lifecycle move refused and failed with, recorded after
3650
+ * the rollback. Authorization and the K-3 cross-check happen on the coordinator first.
3651
+ */
3652
+ listRefusals(filter) {
3653
+ const q = refusalListQuery(filter);
3654
+ return this.sql.exec(q.sql, ...q.params).toArray().map(mapRefusalRow);
3655
+ }
3385
3656
  /**
3386
3657
  * The same log bucketed per (actor, permission) — or per operation when the filter
3387
3658
  * says `groupBy: 'operation'` (#1456) — with the window's own facts.
@@ -3422,12 +3693,32 @@ export function defineScopeDO(modules, bareOps) {
3422
3693
  return defs.map(({ name, sql }) => {
3423
3694
  // Raw positional rows, cells as-is (blobs kept as bytes, not nulled like a UI
3424
3695
  // read) so the dump reloads faithfully. The name is from the live schema.
3425
- const cursor = this.sql.exec(`SELECT * FROM "${name}"`);
3696
+ // #1722: the load stamp and the write revision describe THIS store, so they never leave
3697
+ // in a dump (`exportDumpStamped` hands the stamp over beside one); every load writes its own.
3698
+ const cursor = name === '_substrat_meta'
3699
+ ? this.sql.exec(`SELECT * FROM "${name}" WHERE key NOT IN (${STORE_LOCAL_META_KEYS.map(() => '?').join(', ')})`, ...STORE_LOCAL_META_KEYS)
3700
+ : this.sql.exec(`SELECT * FROM "${name}"`);
3426
3701
  const columns = cursor.columnNames;
3427
3702
  const rows = Array.from(cursor.raw(), (row) => row);
3428
3703
  return { name, ddl: sql, columns, rows };
3429
3704
  });
3430
3705
  }
3706
+ /**
3707
+ * `exportDump`, with the store's load stamp read in the same call (#1722): the stamp a
3708
+ * carry's fenced wipe of this copy later expects. A store no load has stamped gets one now,
3709
+ * so the stamp handed out is present exactly while nothing has been loaded here since. One
3710
+ * synchronous method, so no load can land between the dump and the stamp.
3711
+ */
3712
+ exportDumpStamped() {
3713
+ let stamp = this.loadStamp();
3714
+ // A wiped copy takes no write, a stamp included; the carry refuses its tombstone anyway.
3715
+ if (!stamp && !this.carriedAwayCopy) {
3716
+ stamp = ulid();
3717
+ this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, stamp);
3718
+ }
3719
+ // Read after the stamp's own write, so it is the revision the store holds as it is dumped.
3720
+ return { tables: this.exportDump(), loadStamp: stamp, revision: this.metaValue(WRITE_REVISION_KEY) };
3721
+ }
3431
3722
  /**
3432
3723
  * Load a dump into this (freshly-provisioned) scope — the write half of the fork
3433
3724
  * (host.ts importScope). Drop-then-replay: the provisioning schema is wiped and rebuilt,
@@ -3509,6 +3800,9 @@ export function defineScopeDO(modules, bareOps) {
3509
3800
  // every legacy row — a past run's id cannot be recovered afterwards, exactly
3510
3801
  // as the other #1525 columns above argued.
3511
3802
  'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
3803
+ // #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
3804
+ // see `COPY_ORIGIN_DDL`).
3805
+ 'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
3512
3806
  ]) {
3513
3807
  try {
3514
3808
  this.sql.exec(alter);
@@ -3554,12 +3848,12 @@ export function defineScopeDO(modules, bareOps) {
3554
3848
  // runtime forbids a manual BEGIN through `sql.exec`, and this body is wholly
3555
3849
  // synchronous, which is the one case the sync API is for (it commits at the
3556
3850
  // first await, and there is none). It also has to be sync because the caller is.
3557
- this.ctx.storage.transactionSync(() => {
3851
+ this.revision.transactionSync(() => {
3558
3852
  for (const stmt of splitSqlStatements(SCHEDULE_STATE_REBUILD))
3559
3853
  this.sql.exec(stmt);
3560
3854
  });
3561
3855
  }
3562
- async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, } = {}) {
3856
+ async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, } = {}) {
3563
3857
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
3564
3858
  //
3565
3859
  // Two distinct FK hazards, and both need the deferral — the second is why it cannot
@@ -3581,7 +3875,8 @@ export function defineScopeDO(modules, bareOps) {
3581
3875
  // A dump written before indexes were excluded may still carry them; skipped
3582
3876
  // rather than failing a restore over data about to be recomputed.
3583
3877
  // Matched without case, as SQLite resolves a table name (#1883 review).
3584
- const replayable = tables.filter((t) => !isSearchIndexTable(t.name.toLowerCase()));
3878
+ // #1686: a copy into another scope id loads no capability rows; a same-scope restore keeps them.
3879
+ const replayable = capabilitiesForLoad(tables.filter((t) => !isSearchIndexTable(t.name.toLowerCase())), destScopeId, sourceScopeId);
3585
3880
  // The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
3586
3881
  // in the string it is given, so a `ddl` with anything appended to its CREATE
3587
3882
  // TABLE executed that too — with entirely plain identifiers, which is why no
@@ -3593,74 +3888,123 @@ export function defineScopeDO(modules, bareOps) {
3593
3888
  if (exact && !sourceScopeId) {
3594
3889
  throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
3595
3890
  }
3596
- const switched = await this.ctx.storage.transaction(async () => {
3597
- this.sql.exec('PRAGMA defer_foreign_keys = ON');
3598
- // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
3599
- const existing = this.sql
3600
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*'`)
3601
- .toArray();
3602
- // Search index tables are left alone here and rebuilt below (#827): dropping a
3603
- // shadow table directly is an error, and `sqlite_master` order would reach one
3604
- // before its virtual table.
3605
- for (const { name } of existing) {
3606
- if (isSearchIndexTable(name.toLowerCase()))
3607
- continue;
3608
- this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
3891
+ const switched = await this.revision.transaction(async () => {
3892
+ const before = this.loadMarker();
3893
+ // #1713: the lifecycle the platform delivered here, read before the drops take it.
3894
+ const lifecycleBefore = readLifecycle(this.switchSql());
3895
+ // #1722 (Codex #2008 r7): a kept copy holds writes nothing else has. No load replaces it
3896
+ // except its own resolution, and that only at the revision the operator acted on.
3897
+ const kept = this.metaValue(KEPT_DIVERGENT_KEY);
3898
+ if (resolveKept) {
3899
+ if (!kept)
3900
+ throw substratError('precondition_failed', 'no kept copy here to resolve; nothing was loaded');
3901
+ if (before.revision !== resolveKept.revision ||
3902
+ (resolveKept.loadStamp !== undefined && before.loadStamp !== resolveKept.loadStamp)) {
3903
+ throw substratError('precondition_failed', 'the kept copy changed since it was read; nothing was loaded');
3904
+ }
3609
3905
  }
3610
- // A vertical's tables take the dump's own DDL. The spine never does (#1883): a dump
3611
- // that declared a column the checker compares on as COLLATE NOCASE would otherwise
3612
- // decide how this DO's permission checks match. Every `_substrat_*` table is built
3613
- // from KERNEL_DDL instead, and the dump contributes only rows, by column name
3614
- // (`spineRowsInsert`), a missing column taking the kernel's default.
3615
- for (const t of replayable)
3616
- if (!isSpineTable(t.name))
3617
- this.sql.exec(t.ddl);
3618
- // KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
3619
- // WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
3620
- // `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
3621
- // `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
3622
- // them, and without them the very next permission check would raise a bare `no such
3623
- // table: _substrat_roles`. Roles land empty here and are re-projected by the
3624
- // restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
3625
- // the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
3626
- // leaves to it.
3627
- for (const stmt of splitSqlStatements(KERNEL_DDL))
3628
- this.sql.exec(stmt);
3629
- this.applySpineColumnAdditions();
3630
- const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
3631
- assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
3632
- // A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
3633
- // untyped column the checker never reads.
3634
- for (const t of replayable) {
3635
- if (isSpineTable(t.name))
3636
- for (const alter of spineColumnAdditions(t, columnsOf(t.name)))
3637
- this.sql.exec(alter);
3906
+ else if (kept) {
3907
+ throw substratError('conflict', KEPT_COPY_REFUSAL);
3638
3908
  }
3639
- for (const t of replayable) {
3640
- const insert = dumpRowsInsert(t, columnsOf);
3641
- for (const row of t.rows)
3642
- this.sql.exec(insert, ...row);
3909
+ if (expect) {
3910
+ if (before.loadStamp !== expect.loadStamp || (expect.revision !== undefined && before.revision !== expect.revision)) {
3911
+ throw substratError('precondition_failed', 'scope store changed since it was read; nothing was loaded');
3912
+ }
3643
3913
  }
3644
- // #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
3645
- // not this one. Dropped, so a restore never carries a receipt over; the repair
3646
- // projection that follows writes this scope's own.
3647
- this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
3648
- // #1575: attachment text is not in a dump, so the load left it as it was. Drop the
3649
- // text of attachments the dump did not bring back, and queue extraction for those
3650
- // it brought back without text — the bytes decide what that run finds.
3651
- reconcileAttachmentText(doSpineSql(this.sql), ulid, new Date().toISOString());
3652
- // Re-point the restored grants at THIS scope (after the spine exists, so a dump
3653
- // that carried no tuples table still finds one here).
3654
- if (destScopeId) {
3655
- this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
3914
+ // #1722: the drops take `_substrat_meta` with them; the revision is written back below.
3915
+ this.revisionSuspended = true;
3916
+ try {
3917
+ this.sql.exec('PRAGMA defer_foreign_keys = ON');
3918
+ // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
3919
+ const existing = this.sql
3920
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*'`)
3921
+ .toArray();
3922
+ // Search index tables are left alone here and rebuilt below (#827): dropping a
3923
+ // shadow table directly is an error, and `sqlite_master` order would reach one
3924
+ // before its virtual table.
3925
+ for (const { name } of existing) {
3926
+ if (isSearchIndexTable(name.toLowerCase()))
3927
+ continue;
3928
+ this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
3929
+ }
3930
+ // A vertical's tables take the dump's own DDL. The spine never does (#1883): a dump
3931
+ // that declared a column the checker compares on as COLLATE NOCASE would otherwise
3932
+ // decide how this DO's permission checks match. Every `_substrat_*` table is built
3933
+ // from KERNEL_DDL instead, and the dump contributes only rows, by column name
3934
+ // (`spineRowsInsert`), a missing column taking the kernel's default.
3935
+ for (const t of replayable)
3936
+ if (!isSpineTable(t.name))
3937
+ this.sql.exec(t.ddl);
3938
+ // KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
3939
+ // WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
3940
+ // `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
3941
+ // `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
3942
+ // them, and without them the very next permission check would raise a bare `no such
3943
+ // table: _substrat_roles`. Roles land empty here and are re-projected by the
3944
+ // restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
3945
+ // the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
3946
+ // leaves to it.
3947
+ for (const stmt of splitSqlStatements(KERNEL_DDL))
3948
+ this.sql.exec(stmt);
3949
+ this.applySpineColumnAdditions();
3950
+ const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
3951
+ assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
3952
+ // A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
3953
+ // untyped column the checker never reads.
3954
+ for (const t of replayable) {
3955
+ if (isSpineTable(t.name))
3956
+ for (const alter of spineColumnAdditions(t, columnsOf(t.name)))
3957
+ this.sql.exec(alter);
3958
+ }
3959
+ for (const t of replayable) {
3960
+ const insert = dumpRowsInsert(t, columnsOf);
3961
+ for (const row of t.rows)
3962
+ this.sql.exec(insert, ...row);
3963
+ }
3964
+ // #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
3965
+ // not this one. Dropped, so a restore never carries a receipt over; the repair
3966
+ // projection that follows writes this scope's own.
3967
+ this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
3968
+ // #1722: whatever stamp an export read here no longer describes this store, so every load
3969
+ // replaces it: with the carry's own, or with none. And the write revision moves on from
3970
+ // where it stood before the drops, so it never goes back.
3971
+ if (loadStamp) {
3972
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, loadStamp);
3973
+ }
3974
+ else {
3975
+ this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, LOAD_STAMP_KEY);
3976
+ }
3977
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, WRITE_REVISION_KEY, String(Number(before.revision ?? 0) + 1));
3978
+ // #1575: attachment text is not in a dump, so the load left it as it was. Drop the
3979
+ // text of attachments the dump did not bring back, and queue extraction for those
3980
+ // it brought back without text — the bytes decide what that run finds.
3981
+ const now = new Date().toISOString();
3982
+ // #1686: nothing the source queued runs in a copy; a return leaves it all queued. BEFORE
3983
+ // the extraction queue below, which is this scope's own work, not the source's.
3984
+ settleCopiedWork(this.switchSql(), destScopeId, sourceScopeId, now);
3985
+ if (markCopy)
3986
+ markCopyOrigin(this.switchSql(), now);
3987
+ // #1713: a return keeps the newer of this store's lifecycle and the dump's, so a backup
3988
+ // from before a suspension does not lift it; a copy keeps only its own.
3989
+ settleLifecycleAfterLoad(this.switchSql(), lifecycleBefore, markCopy === true || isCopyLoad(destScopeId, sourceScopeId));
3990
+ reconcileAttachmentText(doSpineSql(this.sql), ulid, now);
3991
+ // Re-point the restored grants at THIS scope (after the spine exists, so a dump
3992
+ // that carried no tuples table still finds one here).
3993
+ if (destScopeId) {
3994
+ this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
3995
+ }
3996
+ // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
3997
+ // the spine and the re-point. A switch that throws rolls the whole restore back, so
3998
+ // the dump's grants never commit live without the switch that should cover them.
3999
+ return destScopeId && switchOff
4000
+ ? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
4001
+ : [];
4002
+ }
4003
+ finally {
4004
+ this.revisionSuspended = false;
3656
4005
  }
3657
- // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
3658
- // the spine and the re-point. A switch that throws rolls the whole restore back, so
3659
- // the dump's grants never commit live without the switch that should cover them.
3660
- return destScopeId && switchOff
3661
- ? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
3662
- : [];
3663
4006
  });
4007
+ this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
3664
4008
  // Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
3665
4009
  // create, so it also repairs an index a dump left stale, and the triggers it
3666
4010
  // recreates are what keep the restored scope in step from here. Skipped for a plan
@@ -3675,6 +4019,18 @@ export function defineScopeDO(modules, bareOps) {
3675
4019
  for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
3676
4020
  this.sql.exec(stmt);
3677
4021
  }
4022
+ // #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
4023
+ // as on a wake. A copy's own events then sort above every copied one, which is what
4024
+ // `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
4025
+ const top = this.sql.exec('SELECT MAX(id) AS id FROM _substrat_outbox').toArray()[0]?.id;
4026
+ if (top) {
4027
+ try {
4028
+ this.mintEventId.seedFrom(top);
4029
+ }
4030
+ catch {
4031
+ // Not a ULID: nothing this mint could have written, so there is no floor to keep.
4032
+ }
4033
+ }
3678
4034
  // The frontier arrived with the dump — refresh the in-memory applied set so a
3679
4035
  // later migrate() builds on the imported state, not the provisioning state.
3680
4036
  this.applied.clear();
@@ -3708,6 +4064,164 @@ export function defineScopeDO(modules, bareOps) {
3708
4064
  this.lastFailure = null;
3709
4065
  return switched;
3710
4066
  }
4067
+ /**
4068
+ * `importDump` with its two refusals answered as values (#1722): across this RPC a throw
4069
+ * carries only its message, and the host has to tell "the store moved since it was read"
4070
+ * (`changed`, 412) and "this store is a kept copy" (`kept`, 409) from a failure.
4071
+ */
4072
+ async importDumpChecked(tables, destScopeId, opts) {
4073
+ try {
4074
+ return { refused: false, switchedOff: await this.importDump(tables, destScopeId, opts) };
4075
+ }
4076
+ catch (e) {
4077
+ const code = errorCodeOf(e);
4078
+ if (code === 'precondition_failed')
4079
+ return { refused: 'changed' };
4080
+ if (code === 'conflict')
4081
+ return { refused: 'kept' };
4082
+ throw toRpcError(e);
4083
+ }
4084
+ }
4085
+ /** The kept-copy marker (#1722), or null when this store is not one. */
4086
+ keptCopy() {
4087
+ const raw = this.metaValue(KEPT_DIVERGENT_KEY);
4088
+ return raw ? JSON.parse(raw) : null;
4089
+ }
4090
+ /**
4091
+ * Release a kept copy that is the scope's live store after all (#1722, Codex #2008 r8): a carry
4092
+ * that protected its source when the route read elsewhere, while a rollback was about to bind
4093
+ * it. Clears the marker only, fenced on the write revision read; the data stays as it is.
4094
+ */
4095
+ releaseKeptCopy(revision,
4096
+ /** The directory says this scope is not primary (#2005): the released store is marked a copy. */
4097
+ markCopy,
4098
+ /** The load stamp read with `revision` (Codex #2008 r13): a store a load replaced is refused.
4099
+ * A kept copy refuses every load but its own resolution, so this holds by construction too. */
4100
+ loadStamp) {
4101
+ return this.revision.transactionSync(() => {
4102
+ if (!this.metaValue(KEPT_DIVERGENT_KEY))
4103
+ return { refused: 'not-kept' };
4104
+ if (this.metaValue(WRITE_REVISION_KEY) !== revision)
4105
+ return { refused: 'changed' };
4106
+ if (loadStamp !== undefined && this.metaValue(LOAD_STAMP_KEY) !== loadStamp)
4107
+ return { refused: 'changed' };
4108
+ this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, KEPT_DIVERGENT_KEY);
4109
+ if (markCopy)
4110
+ this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), new Date().toISOString()));
4111
+ return { released: true };
4112
+ });
4113
+ }
4114
+ /**
4115
+ * Discard a kept copy (#1722): the staff resolution that wipes it to the tombstone, only if it
4116
+ * is still one at the write revision the operator acted on. The marker goes with the wipe.
4117
+ */
4118
+ async discardKeptCopy(scopeId, revision, carriedAway,
4119
+ /** The directory says this scope is not primary (#2005): as `wipeCarried`. */
4120
+ markCopy,
4121
+ /** The load stamp read with `revision` (Codex #2008 r13), as `releaseKeptCopy`. */
4122
+ loadStamp) {
4123
+ if (!this.metaValue(KEPT_DIVERGENT_KEY))
4124
+ return { refused: 'not-kept' };
4125
+ try {
4126
+ await this.importDump(carriedAwayDump(carriedAway), scopeId, {
4127
+ sourceScopeId: scopeId,
4128
+ resolveKept: { revision, ...(loadStamp !== undefined ? { loadStamp } : {}) },
4129
+ markCopy: markCopy === true || this.isCopy(), // #2005: as `wipeCarried`
4130
+ });
4131
+ return { discarded: true };
4132
+ }
4133
+ catch (e) {
4134
+ if (errorCodeOf(e) === 'precondition_failed')
4135
+ return { refused: 'changed' };
4136
+ throw toRpcError(e);
4137
+ }
4138
+ }
4139
+ /** What a carry's restore into this store expects to find unchanged (#1722): see `LoadMarker`. */
4140
+ loadMarker() {
4141
+ return { loadStamp: this.metaValue(LOAD_STAMP_KEY), revision: this.metaValue(WRITE_REVISION_KEY) };
4142
+ }
4143
+ /** This store's load stamp (#1722), or null when no load has written one. */
4144
+ loadStamp() {
4145
+ return this.metaValue(LOAD_STAMP_KEY);
4146
+ }
4147
+ metaValue(key) {
4148
+ const row = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = ?`, key).toArray()[0];
4149
+ return row?.value ?? null;
4150
+ }
4151
+ /**
4152
+ * Wipe the copy a carry left in this script (#1722), only if nothing was loaded here since
4153
+ * the carry exported it. A rollback that restored into this DO in the meantime replaced or
4154
+ * cleared the stamp, and its restore stays. Non-terminal, unlike `destroyStorage`: the store is a load of
4155
+ * the `carriedAway` tombstone, so a later restore into it works as on any scope. False when
4156
+ * refused.
4157
+ */
4158
+ async wipeCarried(scopeId, expectLoadStamp, carriedAway, { expectRevision, protectIfChanged, markCopy, } = {}) {
4159
+ // A store marked now stays one: the copy-origin row goes on the tombstone (the wipe), or
4160
+ // on the copy kept (a refusal), and never counts as a data write (`bookkeeping`).
4161
+ const copy = markCopy === true || this.isCopy();
4162
+ try {
4163
+ await this.importDump(carriedAwayDump(carriedAway), scopeId, {
4164
+ sourceScopeId: scopeId,
4165
+ expect: { loadStamp: expectLoadStamp, ...(expectRevision !== undefined ? { revision: expectRevision } : {}) },
4166
+ // #2005: a copy wiped is still a copy. The drop-and-replay takes the copy-origin row
4167
+ // with everything else, so it is written again, or the tombstone would read as primary.
4168
+ markCopy: copy,
4169
+ });
4170
+ return true;
4171
+ }
4172
+ catch (e) {
4173
+ const code = errorCodeOf(e);
4174
+ // Only on the directory's word: the store's own marker, read before the refused load, may
4175
+ // have been cleared by staff since (Codex #2008 r12), and marking it again would undo that.
4176
+ if (markCopy === true) {
4177
+ this.revision.transactionSync(() => this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), carriedAway.at)));
4178
+ }
4179
+ if (code === 'conflict') {
4180
+ // Already a kept copy, and the scope has been carried away from it again (Codex #2008
4181
+ // r9). That is recorded on the marker, which is a write: it advances the revision, so a
4182
+ // release (staff or a carry's auto-release) that read the revision before this, when the
4183
+ // scope still routed here, fails its compare-and-set rather than clearing the marker of
4184
+ // a copy the scope has just left.
4185
+ this.revision.transactionSync(() => {
4186
+ const raw = this.metaValue(KEPT_DIVERGENT_KEY);
4187
+ if (!raw)
4188
+ return;
4189
+ const kept = { ...JSON.parse(raw), leftAgain: { to: carriedAway.to, at: carriedAway.at } };
4190
+ this.sql.exec(`UPDATE _substrat_meta SET value = ? WHERE key = ?`, JSON.stringify(kept), KEPT_DIVERGENT_KEY);
4191
+ });
4192
+ return false;
4193
+ }
4194
+ if (code !== 'precondition_failed')
4195
+ throw toRpcError(e);
4196
+ // Refused. When nothing was LOADED here since the export (the stamp is the one read) but
4197
+ // the store was WRITTEN (the revision moved), the copy holds a write the carry never
4198
+ // copied: protect it in the store itself, decided again inside its own transaction.
4199
+ this.revision.transactionSync(() => {
4200
+ const now = this.loadMarker();
4201
+ const writtenSince = expectRevision !== undefined && now.loadStamp === expectLoadStamp && now.revision !== expectRevision;
4202
+ const changedSince = now.loadStamp !== expectLoadStamp || (expectRevision !== undefined && now.revision !== expectRevision);
4203
+ // A wiped copy holds nothing to protect (and takes no write, the marker included).
4204
+ if (this.carriedAwayCopy || this.metaValue(KEPT_DIVERGENT_KEY))
4205
+ return;
4206
+ if (!writtenSince && !(protectIfChanged && changedSince))
4207
+ return;
4208
+ const kept = { carriedTo: carriedAway.to, keptAt: carriedAway.at, revision: now.revision };
4209
+ this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, KEPT_DIVERGENT_KEY, JSON.stringify(kept));
4210
+ // Codex #2008 r12: changed ONLY by a staff clear of the copy marker — the clear's own
4211
+ // revisions span exactly the export's and the store's now — so the data is what the carry
4212
+ // copied, and only the fresher classification is not. Recorded on the marker with the
4213
+ // revision this keep committed at (the same run: no second bump), which the platform's
4214
+ // discard is fenced on once it has brought the clear to where the scope runs.
4215
+ const cleared = this.metaValue(COPY_MARK_CLEARED_KEY);
4216
+ const span = cleared ? JSON.parse(cleared) : null;
4217
+ if (writtenSince && span && span.from === expectRevision && span.to === now.revision) {
4218
+ const settled = { ...kept, clearedOnly: { revision: this.metaValue(WRITE_REVISION_KEY) } };
4219
+ this.sql.exec(`UPDATE _substrat_meta SET value = ? WHERE key = ?`, JSON.stringify(settled), KEPT_DIVERGENT_KEY);
4220
+ }
4221
+ });
4222
+ return false;
4223
+ }
4224
+ }
3711
4225
  /**
3712
4226
  * Re-point scope-level tuples at the scope they now live in.
3713
4227
  *
@@ -3854,6 +4368,13 @@ export function defineScopeDO(modules, bareOps) {
3854
4368
  * later alarm-driven drain with a call it had nothing to do with.
3855
4369
  */
3856
4370
  invocationId = null;
4371
+ /**
4372
+ * #1901: the id a unit of async work with no call around it logs under — a consumer a
4373
+ * seed or an import delivered — set for its handler's duration and cleared after. Read
4374
+ * by `ctx.log` only, never the spine: an event a consumer emits outside a call still
4375
+ * records no invocation (#1525), while its log lines join the consumer's own line.
4376
+ */
4377
+ unitInvocationId = null;
3857
4378
  async dispatch(tenantId, scopeId,
3858
4379
  /**
3859
4380
  * #1525: the call this drain is running in, or null. Every round of the loop
@@ -3861,13 +4382,23 @@ export function defineScopeDO(modules, bareOps) {
3861
4382
  * whole cascade is one call's work.
3862
4383
  */
3863
4384
  invocationId) {
4385
+ // #1901: one line per delivery, capped per drain.
4386
+ const lines = asyncLinePass();
4387
+ try {
4388
+ await this.dispatchRounds(tenantId, scopeId, invocationId, lines);
4389
+ }
4390
+ finally {
4391
+ lines.end();
4392
+ }
4393
+ }
4394
+ async dispatchRounds(tenantId, scopeId, invocationId, lines) {
3864
4395
  for (let round = 0; round < 50; round++) {
3865
4396
  let deliveredAny = false;
3866
4397
  for (const mod of this.modules.values()) {
3867
4398
  for (const consumer of mod.consumers) {
3868
4399
  const rows = this.sql
3869
4400
  .exec(`SELECT * FROM _substrat_outbox o
3870
- WHERE o.type = ?
4401
+ WHERE o.type = ? AND ${emittedHere('o.')}
3871
4402
  AND NOT EXISTS (
3872
4403
  SELECT 1 FROM _substrat_deliveries d
3873
4404
  WHERE d.event_id = o.id AND d.consumer_module = ?
@@ -3875,6 +4406,20 @@ export function defineScopeDO(modules, bareOps) {
3875
4406
  ORDER BY o.id`, consumer.eventType, mod.id)
3876
4407
  .toArray();
3877
4408
  for (const row of rows) {
4409
+ // #1901: this delivery's line — the call's id when it runs in one, else its own.
4410
+ const unit = {
4411
+ kind: 'consumer',
4412
+ tenantId,
4413
+ scopeId,
4414
+ invocationId: asyncInvocationId(invocationId),
4415
+ operation: mod.id,
4416
+ eventType: consumer.eventType,
4417
+ eventId: row.id,
4418
+ // A module consumer is tried once: a throw dead-letters it (v0).
4419
+ attempt: 1,
4420
+ startedAt: Date.now(),
4421
+ versionId: this.env.SUBSTRAT_VERSION_ID ?? null,
4422
+ };
3878
4423
  let event;
3879
4424
  try {
3880
4425
  event = domainEventOf(row);
@@ -3888,14 +4433,18 @@ export function defineScopeDO(modules, bareOps) {
3888
4433
  this.sql.exec(`INSERT INTO _substrat_deliveries
3889
4434
  (event_id, consumer_module, delivered_at, error, invocation_id)
3890
4435
  VALUES (?, ?, ?, ?, ?)`, row.id, mod.id, new Date().toISOString(), String(err), invocationId);
4436
+ // Never reached the handler, so nothing threw in it: a warning, not an error.
4437
+ lines.write({ ...unit, outcome: 'dead-lettered' });
3891
4438
  continue;
3892
4439
  }
3893
4440
  // #1237: anything this consumer emits was emitted BECAUSE of this event
3894
4441
  // — the step a backwards walk used to stop dead at, since a consumer
3895
4442
  // emit records no operation either.
3896
4443
  this.causedBy = event.id;
4444
+ // #1901: the handler's `ctx.log` lines join this delivery's line.
4445
+ this.unitInvocationId = unit.invocationId;
3897
4446
  try {
3898
- await this.ctx.storage.transaction(async () => {
4447
+ await this.revision.transaction(async () => {
3899
4448
  const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, {
3900
4449
  system: mod.id,
3901
4450
  });
@@ -3905,6 +4454,7 @@ export function defineScopeDO(modules, bareOps) {
3905
4454
  VALUES (?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), invocationId);
3906
4455
  });
3907
4456
  deliveredAny = true;
4457
+ lines.write({ ...unit, outcome: 'delivered' });
3908
4458
  }
3909
4459
  catch (err) {
3910
4460
  // Dead-letter (v0): journal the failure so one poison event
@@ -3912,8 +4462,10 @@ export function defineScopeDO(modules, bareOps) {
3912
4462
  this.sql.exec(`INSERT INTO _substrat_deliveries
3913
4463
  (event_id, consumer_module, delivered_at, error, invocation_id)
3914
4464
  VALUES (?, ?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), String(err), invocationId);
4465
+ lines.write({ ...unit, outcome: 'dead-lettered', error: err });
3915
4466
  }
3916
4467
  finally {
4468
+ this.unitInvocationId = null;
3917
4469
  // Cleared on BOTH paths. Left set, the id leaks onto every later emit
3918
4470
  // this DO makes — an operation's own event stamped as caused by
3919
4471
  // whatever was delivered last, which is worse than recording nothing
@@ -4081,7 +4633,7 @@ export function defineScopeDO(modules, bareOps) {
4081
4633
  * owns that stack itself. It is exactly this asymmetry that makes `RunSub`
4082
4634
  * closure-shaped rather than an enter/rollback/release triple.
4083
4635
  */
4084
- const runSub = (_depth, fn) => this.ctx.storage.transaction(fn);
4636
+ const runSub = (_depth, fn) => this.revision.transaction(fn);
4085
4637
  // Lifted so `grant` reuses the SAME check the operation itself passes —
4086
4638
  // a delegation check that could differ from the operation's would be a
4087
4639
  // second opinion about what the caller holds.
@@ -4178,7 +4730,7 @@ export function defineScopeDO(modules, bareOps) {
4178
4730
  tenantId,
4179
4731
  scopeId,
4180
4732
  operation: operation ?? null,
4181
- invocationId: () => this.invocationId,
4733
+ invocationId: () => this.invocationId ?? this.unitInvocationId,
4182
4734
  principalKind: systemActor ? 'system' : subject.kind,
4183
4735
  // The string-safe redaction: `redactSecrets` parses its serialization back, and a
4184
4736
  // log's text is not JSON.
@@ -4259,7 +4811,7 @@ export function defineScopeDO(modules, bareOps) {
4259
4811
  });
4260
4812
  const rows = sql.exec(q.sql, ...q.params).toArray();
4261
4813
  const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
4262
- const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn);
4814
+ const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
4263
4815
  const page = { entries: rows, nextCursor };
4264
4816
  if (!params.total)
4265
4817
  return page;
@@ -4492,7 +5044,7 @@ export function defineScopeDO(modules, bareOps) {
4492
5044
  // The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
4493
5045
  // serializes but does not roll back, and a role insert that throws part-way must not leave
4494
5046
  // the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
4495
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
5047
+ return this.queue.enqueue(() => this.revision.transactionSync(() => {
4496
5048
  // #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
4497
5049
  // a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
4498
5050
  // moves, so a misdirected projection can never re-point a scope or leave its roles behind.