@substrat-run/adapter-sqlite 0.135.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/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import Database from 'better-sqlite3';
4
- import { accessLogEntry, adminLogEntry, delegatedReadParams, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, callsOfManifestJson, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
- import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, switchSystemSchedules, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, switchPeer, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, scopesSwitchedOffFor, switchedOffModulesOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, systemSwitchRecordsOf, systemSwitchesTableExists, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, isAttachmentTextRun, reconcileAttachmentText, recordAttachmentText, searchAttachments, 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, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_DDL, refusalInsert, refusedTransitionOf, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, } from '@substrat-run/kernel';
4
+ import { accessLogEntry, adminLogEntry, delegatedReadParams, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, ROUTE_SCOPE_HOSTNAMES_MAX, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, callsOfManifestJson, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
+ import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, switchSystemSchedules, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, switchPeer, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, scopesSwitchedOffFor, switchedOffModulesOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, systemSwitchRecordsOf, systemSwitchesTableExists, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, isAttachmentTextRun, reconcileAttachmentText, recordAttachmentText, searchAttachments, 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, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_DDL, refusalInsert, refusedTransitionOf, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, } from '@substrat-run/kernel';
6
6
  import { attributedHost } from '@substrat-run/kernel';
7
- import { LEGACY_SCOPE_ROWS_BACKFILL, assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, spineColumnAdditions } from '@substrat-run/kernel';
7
+ import { INERT_SCOPE_REASON, isPrimaryScopeRow } from '@substrat-run/kernel';
8
+ import { LEGACY_SCOPE_ROWS_BACKFILL, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
8
9
  import { ScopeActor } from './actor.js';
9
10
  import { createTupleChecker } from './checker.js';
10
11
  /**
@@ -208,6 +209,9 @@ const KERNEL_DDL = `
208
209
  -- an exchange trades that secret for. Spine (kernel-written), shared with the DO adapter
209
210
  -- from @substrat-run/kernel so the two cannot part company; the column comments are there.
210
211
  ${CAPABILITY_DDL}
212
+ -- #1686: where a copied scope's data came from, and the event id its own events start above.
213
+ -- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
214
+ ${COPY_ORIGIN_DDL}
211
215
  -- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
212
216
  -- families of row that the kind COLUMN — not the spelling of a key — tells apart.
213
217
  -- Spine (kernel-written), never a module migration. Shared with the DO adapter from
@@ -418,11 +422,12 @@ function modelUsageEntryOf(r) {
418
422
  });
419
423
  }
420
424
  /**
421
- * The policy an undecodable event's executor delivery is journaled under (#1636): one
422
- * attempt, so the first failure is the dead letter. A decode is pure — retrying it cannot
423
- * succeed — and the Cloudflare coordinator records the same row with no next attempt.
425
+ * The policy a delivery that can never succeed is journaled under: one attempt, so the first
426
+ * is the dead letter, and the Cloudflare coordinator records the same row with no next
427
+ * attempt. An undecodable event (#1636) — a decode is pure, so a retry cannot succeed — and an
428
+ * inert scope's delivery (#2005) — a scope does not become primary, so a retry decides the same.
424
429
  */
425
- const UNDECODABLE_RETRY = { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 };
430
+ const TERMINAL_RETRY = { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 };
426
431
  /**
427
432
  * The dispatch capability (#726 remedy B): a connector delivery may read the attachments
428
433
  * of the entity the DELIVERED EVENT names, and nothing else.
@@ -572,6 +577,8 @@ export class SqliteScopeHost {
572
577
  versionId;
573
578
  /** #1746: where `ctx.log` lines go — the console unless a caller (a test) says otherwise. */
574
579
  logSink;
580
+ /** #1901: where async work's invocation lines go. */
581
+ invocationLineSink;
575
582
  // The mint for event ids (#956) is NOT here: it lives on `ScopeRuntime`, one per
576
583
  // scope, seeded from that scope's persisted maximum (#1335). Its own monotonic
577
584
  // floor, because the timestamp it stamps comes from `this.clock` — a scripted
@@ -586,6 +593,7 @@ export class SqliteScopeHost {
586
593
  this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
587
594
  this.versionId = options.versionId ?? null;
588
595
  this.logSink = options.logSink ?? consoleLogSink;
596
+ this.invocationLineSink = options.invocationLineSink ?? consoleInvocationLineSink;
589
597
  this.dir = options.dir;
590
598
  mkdirSync(this.dir, { recursive: true });
591
599
  this.directory = new Database(join(this.dir, '_directory.sqlite'));
@@ -2246,6 +2254,10 @@ export class SqliteScopeHost {
2246
2254
  // #1575: attachment text is not in a dump, so a load left it as it was. Drop the
2247
2255
  // text of attachments the dump did not bring back, and queue extraction for those
2248
2256
  // it brought back without text — the bytes decide what that run finds.
2257
+ const switchSql = switchSqlOf(db);
2258
+ // #1686: nothing the source queued runs in a copy; a return leaves it all queued. BEFORE
2259
+ // the extraction queue below, which is this scope's own work, not the source's.
2260
+ settleCopiedWork(switchSql, scopeId, dump.scopeId, this.clock());
2249
2261
  reconcileAttachmentText(spineSql(db), ulid, this.clock());
2250
2262
  // Re-point scope-level grants at the scope they now live in. They are written as
2251
2263
  // `object = scope:<scopeId>`, so a fork, a restore into a different scope, or #286's
@@ -2257,7 +2269,7 @@ export class SqliteScopeHost {
2257
2269
  // (subject, relation, object) is the primary key, so a moved row can meet one the dump
2258
2270
  // already holds here; which one survives is that function's rule too (#1882), judged
2259
2271
  // at this host's clock as the checker judges expiry.
2260
- repointScopeGrants(switchSqlOf(db), scopeId, { scopeId: dump.scopeId, exact: dump.exact }, this.clock());
2272
+ repointScopeGrants(switchSql, scopeId, { scopeId: dump.scopeId, exact: dump.exact }, this.clock());
2261
2273
  // #1742: inside the replay's transaction, so a failure here rolls the whole load back and
2262
2274
  // the dump's grants never commit without the switch that should cover them.
2263
2275
  afterLoad?.(rt);
@@ -2266,7 +2278,20 @@ export class SqliteScopeHost {
2266
2278
  // load was a SAVEPOINT inside it, and that invoke's rollback undid the restore after
2267
2279
  // this verb had audited it.
2268
2280
  await rt.actor.turn(() => {
2269
- load(dump.tables);
2281
+ // #1686: a copy into another scope id loads no capability rows; a same-scope restore keeps them.
2282
+ load(capabilitiesForLoad(dump.tables, scopeId, dump.scopeId));
2283
+ // #1335 / #1686: ids resume above the outbox the dump brought, as on a reopen, so a copy's
2284
+ // own events sort above every copied one (`emittedHere()`). A top id that is no ULID leaves
2285
+ // the floor where it was.
2286
+ const top = db.prepare('SELECT MAX(id) AS id FROM _substrat_outbox').get()?.id;
2287
+ if (top) {
2288
+ try {
2289
+ rt.mintEventId.seedFrom(top);
2290
+ }
2291
+ catch {
2292
+ // Not a ULID: nothing this mint could have written, so there is no floor to keep.
2293
+ }
2294
+ }
2270
2295
  // The frontier came in with the dump — refresh the cached applied-migration set so
2271
2296
  // a later bind/migrate builds on the loaded state, not the previous one.
2272
2297
  rt.appliedMigrations.clear();
@@ -2602,12 +2627,29 @@ export class SqliteScopeHost {
2602
2627
  const deadLetter = rt.db.prepare(`INSERT OR IGNORE INTO _substrat_deliveries
2603
2628
  (event_id, consumer_module, delivered_at, error, invocation_id)
2604
2629
  VALUES (?, ?, ?, ?, NULL)`);
2630
+ // #1901: one line per (event, importing module), as for the scope's own consumers. The
2631
+ // handler runs as the producer's peer, so that is who the line says it ran as.
2632
+ const lines = asyncLinePass(this.invocationLineSink);
2633
+ const unitOf = (moduleId, eventId, eventType) => ({
2634
+ kind: 'consumer',
2635
+ tenantId: rt.tenantId,
2636
+ scopeId: rt.scopeId,
2637
+ invocationId: asyncInvocationId(null),
2638
+ operation: moduleId,
2639
+ eventType,
2640
+ eventId,
2641
+ attempt: 1,
2642
+ startedAt: Date.now(),
2643
+ principalKind: subject.kind,
2644
+ versionId: this.versionId,
2645
+ });
2605
2646
  // Named and not carried: a dead letter per importing module, so the operator who reads
2606
2647
  // this scope's dead letters sees what the producer sent and why it did not arrive.
2607
2648
  for (const w of batch.withheld) {
2608
2649
  record.run(w.id, source.scopeId, source.vertical, w.type, w.schemaVersion, w.occurredAt, w.entity.entityType, w.entity.entityId, 0, w.reason, at);
2609
2650
  for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
2610
2651
  deadLetter.run(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
2652
+ lines.write({ ...unitOf(moduleId, w.id, w.type), outcome: 'dead-lettered' });
2611
2653
  }
2612
2654
  result.withheld += 1;
2613
2655
  }
@@ -2623,14 +2665,17 @@ export class SqliteScopeHost {
2623
2665
  // this scope's code changed between the two, so it is refused by version (K-39)
2624
2666
  // exactly as the producer would have refused it.
2625
2667
  deadLetter.run(e.id, imp.moduleId, at, withheldNote('version', source.vertical));
2668
+ lines.write({ ...unitOf(imp.moduleId, e.id, e.type), outcome: 'dead-lettered' });
2626
2669
  result.deadLettered += 1;
2627
2670
  continue;
2628
2671
  }
2629
2672
  const { hops: _hops, ...fact } = e;
2630
2673
  const event = structuredClone({ ...fact, source });
2674
+ const unit = unitOf(imp.moduleId, e.id, e.type);
2631
2675
  // What the handler emits was emitted BECAUSE of the producer's event (#1237). The id
2632
2676
  // resolves through `_substrat_imports`, which the row above has just written.
2633
2677
  rt.causedBy = e.id;
2678
+ rt.unitInvocationId = unit.invocationId;
2634
2679
  rt.db.exec('BEGIN IMMEDIATE');
2635
2680
  try {
2636
2681
  // As the producer's principal, admitted above: the handler's checks are real ones,
@@ -2643,6 +2688,7 @@ export class SqliteScopeHost {
2643
2688
  .run(e.id, imp.moduleId, new Date().toISOString());
2644
2689
  rt.db.exec('COMMIT');
2645
2690
  result.delivered += 1;
2691
+ lines.write({ ...unit, outcome: 'delivered' });
2646
2692
  }
2647
2693
  catch (err) {
2648
2694
  rt.db.exec('ROLLBACK');
@@ -2650,9 +2696,11 @@ export class SqliteScopeHost {
2650
2696
  // behind it still arrive.
2651
2697
  deadLetter.run(e.id, imp.moduleId, new Date().toISOString(), String(err));
2652
2698
  result.deadLettered += 1;
2699
+ lines.write({ ...unit, outcome: 'dead-lettered', error: err });
2653
2700
  }
2654
2701
  finally {
2655
2702
  rt.causedBy = null;
2703
+ rt.unitInvocationId = null;
2656
2704
  }
2657
2705
  }
2658
2706
  if (!ran)
@@ -2662,6 +2710,7 @@ export class SqliteScopeHost {
2662
2710
  // rows above absorb it.
2663
2711
  rt.db.prepare(IMPORT_CURSOR_ADVANCE_SQL).run(source.scopeId, source.vertical, batch.next, at);
2664
2712
  result.cursor = batch.next;
2713
+ lines.end();
2665
2714
  // What the handlers emitted reaches this scope's own consumers and executors in the same
2666
2715
  // tail, under the ordinary cascade cap.
2667
2716
  await this.dispatch(rt, null);
@@ -3061,6 +3110,7 @@ export class SqliteScopeHost {
3061
3110
  // ended); the freshness evaluator's are `kind = 'freshness'` — see checkFreshness
3062
3111
  // for what the same two columns mean there.
3063
3112
  const now = Date.parse(nowIso);
3113
+ const lines = asyncLinePass(this.invocationLineSink);
3064
3114
  for (const schedule of mod.schedules) {
3065
3115
  const lastRunAt = gate.lastRuns.get(schedule.operation) ?? null;
3066
3116
  const lastRun = lastRunAt ? Date.parse(lastRunAt) : null;
@@ -3077,19 +3127,39 @@ export class SqliteScopeHost {
3077
3127
  // produces either way carries it, and so does the row below.
3078
3128
  const invocationId = ulid();
3079
3129
  let status = 'ok';
3130
+ // #1901: the run's line, under the call's own id — the one its `ctx.log` lines carry.
3131
+ const startedAt = Date.now();
3132
+ let emitted;
3133
+ let failure;
3080
3134
  try {
3081
3135
  const stub = await this.getSystemScope(moduleId, tenantId, scopeId);
3082
- await stub.invoke(schedule.operation, schedule.input, { invocationId });
3136
+ await stub.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
3083
3137
  report.fired += 1;
3084
3138
  }
3085
3139
  catch (err) {
3086
3140
  status = 'failed';
3141
+ failure = { error: err };
3087
3142
  report.failed += 1;
3088
3143
  report.errors.push({
3089
3144
  operation: schedule.operation,
3090
3145
  error: err instanceof Error ? err.message : String(err),
3091
3146
  });
3092
3147
  }
3148
+ lines.write({
3149
+ kind: 'schedule',
3150
+ tenantId,
3151
+ scopeId,
3152
+ invocationId,
3153
+ operation: schedule.operation,
3154
+ startedAt,
3155
+ outcome: status,
3156
+ ...failure,
3157
+ // Against the host's clock, which is what decided the run was due.
3158
+ dueAt: lastRun === null ? null : new Date(dueAt).toISOString(),
3159
+ latenessMs: lastRun === null ? null : now - dueAt,
3160
+ ...(emitted ? { emitted } : {}),
3161
+ versionId: this.versionId,
3162
+ });
3093
3163
  // On the scope actor (#1678): the cadence row joined whatever transaction an invoke
3094
3164
  // held open, so its rollback forgot the run and the next pass fired it AGAIN.
3095
3165
  await rt.actor.turn(() => rt.db
@@ -3101,6 +3171,7 @@ export class SqliteScopeHost {
3101
3171
  .run(schedule.operation, new Date(now).toISOString(), status, invocationId));
3102
3172
  report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
3103
3173
  }
3174
+ lines.end();
3104
3175
  return report;
3105
3176
  }
3106
3177
  /** The stub body, shared by the principal, connection, system, impersonation and capability doors. */
@@ -3496,7 +3567,26 @@ export class SqliteScopeHost {
3496
3567
  retrying: 0,
3497
3568
  deadLettered: 0,
3498
3569
  };
3570
+ if (this.executors.size === 0)
3571
+ return report;
3572
+ const lines = asyncLinePass(this.invocationLineSink);
3573
+ try {
3574
+ await this.dispatchExecutorsPass(rt, invocationId, report, lines);
3575
+ }
3576
+ finally {
3577
+ lines.end();
3578
+ }
3579
+ return report;
3580
+ }
3581
+ async dispatchExecutorsPass(rt, invocationId, report, lines) {
3499
3582
  const now = new Date().toISOString();
3583
+ // #2005: a non-primary scope (a fork, a snapshot, a preview of either kind) causes no
3584
+ // outbound effects. Every executor is host code acting with platform authority — a
3585
+ // connector with the tenant's credential, a plain executor with `HostAdmin` — so its
3586
+ // deliveries are journaled terminal with the reason and the handler never runs.
3587
+ // Asked on the first due event, once per pass: most passes have none.
3588
+ let inert;
3589
+ const isInert = () => (inert ??= !this.isPrimaryInDirectory(rt.scopeId));
3500
3590
  for (const [id, executor] of this.executors) {
3501
3591
  const deliveryId = `executor:${id}`;
3502
3592
  // Due = never attempted, or retrying and past its next attempt time.
@@ -3505,13 +3595,26 @@ export class SqliteScopeHost {
3505
3595
  .prepare(`SELECT o.* FROM _substrat_outbox o
3506
3596
  LEFT JOIN _substrat_deliveries d
3507
3597
  ON d.event_id = o.id AND d.consumer_module = ?
3508
- WHERE o.type = ?
3598
+ WHERE o.type = ? AND ${emittedHere('o.')}
3509
3599
  AND (d.event_id IS NULL
3510
3600
  OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?))
3511
3601
  ORDER BY o.id`)
3512
3602
  .all(deliveryId, executor.eventType, now);
3513
3603
  for (const row of rows) {
3514
3604
  report.attempted += 1;
3605
+ // #1901: this attempt's line. Its number is the one the journal records, which
3606
+ // `recordExecutorDelivery` answers — the Durable Object's RPC does the same.
3607
+ const unit = {
3608
+ kind: 'consumer',
3609
+ tenantId: rt.tenantId,
3610
+ scopeId: rt.scopeId,
3611
+ invocationId: asyncInvocationId(invocationId),
3612
+ operation: deliveryId,
3613
+ eventType: executor.eventType,
3614
+ eventId: row.id,
3615
+ startedAt: Date.now(),
3616
+ versionId: this.versionId,
3617
+ };
3515
3618
  let event;
3516
3619
  try {
3517
3620
  event = domainEventOf(row);
@@ -3523,8 +3626,16 @@ export class SqliteScopeHost {
3523
3626
  // on the FIRST failure, unlike a handler's: the decode is pure, of text already
3524
3627
  // in hand, so a retry cannot succeed — and only the decode is caught here, so a
3525
3628
  // handler's transient failure keeps its backoff below.
3526
- this.recordExecutorDelivery(rt, row.id, deliveryId, String(err), UNDECODABLE_RETRY, invocationId);
3629
+ const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, String(err), TERMINAL_RETRY, invocationId);
3527
3630
  report.deadLettered += 1;
3631
+ lines.write({ ...unit, attempt: attempts, outcome: 'dead-lettered' });
3632
+ continue;
3633
+ }
3634
+ if (isInert()) {
3635
+ const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, INERT_SCOPE_REASON, TERMINAL_RETRY, invocationId);
3636
+ report.inert = (report.inert ?? 0) + 1;
3637
+ // Its own outcome, never `delivered`: no handler ran (#2005).
3638
+ lines.write({ ...unit, attempt: attempts, outcome: 'inert' });
3528
3639
  continue;
3529
3640
  }
3530
3641
  this.causedBy = event.id;
@@ -3537,26 +3648,27 @@ export class SqliteScopeHost {
3537
3648
  else {
3538
3649
  await executor.handler(this.admin, event);
3539
3650
  }
3540
- this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry, invocationId);
3651
+ const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry, invocationId);
3541
3652
  report.delivered += 1;
3653
+ lines.write({ ...unit, attempt: attempts, outcome: 'delivered' });
3542
3654
  }
3543
3655
  catch (err) {
3544
- const dead = this.recordExecutorDelivery(rt, row.id, deliveryId, err instanceof Error ? (err.stack ?? err.message) : String(err), executor.retry, invocationId);
3656
+ const { dead, attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, err instanceof Error ? (err.stack ?? err.message) : String(err), executor.retry, invocationId);
3545
3657
  if (dead)
3546
3658
  report.deadLettered += 1;
3547
3659
  else
3548
3660
  report.retrying += 1;
3661
+ lines.write({ ...unit, attempt: attempts, outcome: dead ? 'dead-lettered' : 'retrying', error: err });
3549
3662
  }
3550
3663
  finally {
3551
3664
  this.causedBy = null;
3552
3665
  }
3553
3666
  }
3554
3667
  }
3555
- return report;
3556
3668
  }
3557
3669
  /**
3558
- * Journal one executor attempt. Returns true when this attempt was the last one
3559
- * — i.e. the delivery is now dead-lettered.
3670
+ * Journal one executor attempt. Answers which attempt it was (#1901), and `dead` when
3671
+ * it was the last one — i.e. the delivery is now dead-lettered.
3560
3672
  *
3561
3673
  * Written AFTER the handler ran, so a crash mid-effect retries rather than
3562
3674
  * silently marking success. Claiming first would make delivery at-most-once and
@@ -3589,7 +3701,11 @@ export class SqliteScopeHost {
3589
3701
  .run(eventId, deliveryId, new Date().toISOString(), error, attempts, nextAttemptAt,
3590
3702
  // #1525: the call this attempt ran in, as the caller named it.
3591
3703
  invocationId);
3592
- return error !== null && exhausted;
3704
+ return { dead: error !== null && exhausted, attempts };
3705
+ }
3706
+ /** Whether the directory says `scopeId` is the real install (`isPrimaryScopeRow`, #2005). */
3707
+ isPrimaryInDirectory(scopeId) {
3708
+ return isPrimaryScopeRow(this.directory.prepare('SELECT kind, forked_from FROM scopes WHERE scope_id = ?').get(scopeId));
3593
3709
  }
3594
3710
  async drainDue(tenantId, scopeId) {
3595
3711
  const rt = this.runtime(tenantId, scopeId);
@@ -3929,13 +4045,23 @@ export class SqliteScopeHost {
3929
4045
  * own emit is delivered in the same tail, so the whole cascade is one call's work.
3930
4046
  */
3931
4047
  invocationId) {
4048
+ // #1901: one line per delivery, capped per drain.
4049
+ const lines = asyncLinePass(this.invocationLineSink);
4050
+ try {
4051
+ await this.dispatchRounds(rt, invocationId, lines);
4052
+ }
4053
+ finally {
4054
+ lines.end();
4055
+ }
4056
+ }
4057
+ async dispatchRounds(rt, invocationId, lines) {
3932
4058
  for (let round = 0; round < 50; round++) {
3933
4059
  let deliveredAny = false;
3934
4060
  for (const mod of this.modules.values()) {
3935
4061
  for (const consumer of mod.consumers) {
3936
4062
  const rows = rt.db
3937
4063
  .prepare(`SELECT * FROM _substrat_outbox o
3938
- WHERE o.type = ?
4064
+ WHERE o.type = ? AND ${emittedHere('o.')}
3939
4065
  AND NOT EXISTS (
3940
4066
  SELECT 1 FROM _substrat_deliveries d
3941
4067
  WHERE d.event_id = o.id AND d.consumer_module = ?
@@ -3943,6 +4069,20 @@ export class SqliteScopeHost {
3943
4069
  ORDER BY o.id`)
3944
4070
  .all(consumer.eventType, mod.id);
3945
4071
  for (const row of rows) {
4072
+ // #1901: this delivery's line — the call's id when it runs in one, else its own.
4073
+ const unit = {
4074
+ kind: 'consumer',
4075
+ tenantId: rt.tenantId,
4076
+ scopeId: rt.scopeId,
4077
+ invocationId: asyncInvocationId(invocationId),
4078
+ operation: mod.id,
4079
+ eventType: consumer.eventType,
4080
+ eventId: row.id,
4081
+ // A module consumer is tried once: a throw dead-letters it (v0).
4082
+ attempt: 1,
4083
+ startedAt: Date.now(),
4084
+ versionId: this.versionId,
4085
+ };
3946
4086
  let event;
3947
4087
  try {
3948
4088
  event = domainEventOf(row);
@@ -3959,6 +4099,8 @@ export class SqliteScopeHost {
3959
4099
  (event_id, consumer_module, delivered_at, error, invocation_id)
3960
4100
  VALUES (?, ?, ?, ?, ?)`)
3961
4101
  .run(row.id, mod.id, new Date().toISOString(), String(err), invocationId);
4102
+ // Never reached the handler, so nothing threw in it: a warning, not an error.
4103
+ lines.write({ ...unit, outcome: 'dead-lettered' });
3962
4104
  continue;
3963
4105
  }
3964
4106
  const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
@@ -3969,6 +4111,8 @@ export class SqliteScopeHost {
3969
4111
  // consumers never did, which is exactly where a backwards walk stopped.
3970
4112
  // On the runtime rather than the host — see `ScopeRuntime.causedBy`.
3971
4113
  rt.causedBy = event.id;
4114
+ // #1901: the handler's `ctx.log` lines join this delivery's line.
4115
+ rt.unitInvocationId = unit.invocationId;
3972
4116
  rt.db.exec('BEGIN IMMEDIATE');
3973
4117
  try {
3974
4118
  await consumer.handler(ctx, event);
@@ -3979,6 +4123,7 @@ export class SqliteScopeHost {
3979
4123
  .run(event.id, mod.id, new Date().toISOString(), invocationId);
3980
4124
  rt.db.exec('COMMIT');
3981
4125
  deliveredAny = true;
4126
+ lines.write({ ...unit, outcome: 'delivered' });
3982
4127
  }
3983
4128
  catch (err) {
3984
4129
  rt.db.exec('ROLLBACK');
@@ -3989,8 +4134,10 @@ export class SqliteScopeHost {
3989
4134
  (event_id, consumer_module, delivered_at, error, invocation_id)
3990
4135
  VALUES (?, ?, ?, ?, ?)`)
3991
4136
  .run(event.id, mod.id, new Date().toISOString(), String(err), invocationId);
4137
+ lines.write({ ...unit, outcome: 'dead-lettered', error: err });
3992
4138
  }
3993
4139
  finally {
4140
+ rt.unitInvocationId = null;
3994
4141
  // Cleared on BOTH paths. Left set, the id would leak onto every later
3995
4142
  // emit in this scope — an operation's own event stamped as caused by
3996
4143
  // whatever happened to be delivered last, which is worse than no cause
@@ -5270,7 +5417,16 @@ export class SqliteScopeHost {
5270
5417
  COALESCE(s.serving_ref, vv.deployment_ref) AS deployment_ref,
5271
5418
  CASE WHEN s.serving_ref IS NOT NULL
5272
5419
  THEN json_extract(sv.manifest_json, '$.outbound')
5273
- ELSE json_extract(vv.manifest_json, '$.outbound') END AS outbound_json
5420
+ ELSE json_extract(vv.manifest_json, '$.outbound') END AS outbound_json,
5421
+ s.kind, s.forked_from,
5422
+ -- #2005: the scope's own addresses, bounded as the Cloudflare read bounds them.
5423
+ (SELECT json_group_array(sh.hostname) FROM (
5424
+ -- Pinned like the Cloudflare read: a range on the scope, never every
5425
+ -- active hostname through hostnames_status.
5426
+ SELECT hostname FROM hostnames INDEXED BY hostnames_scope
5427
+ WHERE scope_id = s.scope_id AND status = 'active'
5428
+ ORDER BY hostname LIMIT ${ROUTE_SCOPE_HOSTNAMES_MAX}
5429
+ ) sh) AS scope_hostnames_json
5274
5430
  FROM hostnames h
5275
5431
  JOIN scopes s ON s.scope_id = h.scope_id AND s.tenant_id = h.tenant_id
5276
5432
  JOIN tenants t ON t.tenant_id = s.tenant_id
@@ -5302,6 +5458,9 @@ export class SqliteScopeHost {
5302
5458
  surface: r.surface,
5303
5459
  region: r.region,
5304
5460
  outboundHosts,
5461
+ // #2005: the egress worker holds a fork or a preview to no third-party egress.
5462
+ primary: isPrimaryScopeRow(r),
5463
+ hostnames: JSON.parse(r.scope_hostnames_json),
5305
5464
  });
5306
5465
  },
5307
5466
  registerVertical: async (actor, input) => {
@@ -5828,6 +5987,15 @@ export class SqliteScopeHost {
5828
5987
  .run(servingRef, scopeId);
5829
5988
  this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref }, { servingRef, ...(ack.exportBreak ? { acknowledged: ack } : {}) });
5830
5989
  },
5990
+ recordKeptCopyResolution: async (actor, tenantId, scopeId, r) => {
5991
+ const scope = this.directory
5992
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
5993
+ .get(scopeId);
5994
+ if (!scope || scope.tenant_id !== tenantId) {
5995
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
5996
+ }
5997
+ this.recordAdmin(actor, 'resolveKeptCopy', { tenantId, scopeId }, { script: r.script, keptAt: r.keptAt, revision: r.revisionBefore }, { action: r.action, liveScript: r.liveScript, revision: r.revisionAfter });
5998
+ },
5831
5999
  setScopeExpiresAt: async (actor, tenantId, scopeId, expiresAt) => {
5832
6000
  const scope = this.directory
5833
6001
  .prepare('SELECT tenant_id, expires_at FROM scopes WHERE scope_id = ?')
@@ -5846,7 +6014,7 @@ export class SqliteScopeHost {
5846
6014
  // — which is append-only — holding an event the scope never had. The mark that
5847
6015
  // follows needs no such care: it stamps only rows that exist and are undrained.
5848
6016
  const db = this.scopeReadDbFor(tenantId, scopeId);
5849
- const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`);
6017
+ const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`);
5850
6018
  // The kernel's read, shared with the DO: the envelope and the columns lifted beside
5851
6019
  // it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
5852
6020
  // rather than stalling the scope on it (#1636).
@@ -7270,6 +7438,11 @@ export class SqliteScopeHost {
7270
7438
  const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
7271
7439
  this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
7272
7440
  },
7441
+ /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
7442
+ recordCopyMark: async (actor, entry) => {
7443
+ const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
7444
+ this.recordAdmin(actor, action === 'mark' ? 'markScopeCopy' : 'clearScopeCopyMark', { tenantId, scopeId }, null, after);
7445
+ },
7273
7446
  accessLog: async (actor, filter) => {
7274
7447
  const where = [];
7275
7448
  const params = [];
@@ -8155,7 +8328,7 @@ export class SqliteScopeHost {
8155
8328
  tenantId: rt.tenantId,
8156
8329
  scopeId: rt.scopeId,
8157
8330
  operation: operation ?? null,
8158
- invocationId: () => rt.invocationId,
8331
+ invocationId: () => rt.invocationId ?? rt.unitInvocationId,
8159
8332
  // A consumer runs under the system override, so it logs as `system`.
8160
8333
  principalKind: overrideActor ? 'system' : subject.kind,
8161
8334
  // The string-safe redaction: `redactSecrets` parses its serialization back, and a
@@ -8243,7 +8416,7 @@ export class SqliteScopeHost {
8243
8416
  // as `pageOf`, applied here because the cursor is a COLUMN's value (plus
8244
8417
  // the tie-break) rather than a field the caller could name.
8245
8418
  const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
8246
- const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn);
8419
+ const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
8247
8420
  const page = { entries: rows, nextCursor };
8248
8421
  if (!params.total)
8249
8422
  return page;
@@ -8653,6 +8826,9 @@ export class SqliteScopeHost {
8653
8826
  // executes, the table always already has `kind` in its key, never `invocation_id`.
8654
8827
  this.ensureColumn(db, '_substrat_schedule_state', 'invocation_id', 'invocation_id TEXT');
8655
8828
  this.ensureColumn(db, '_substrat_job_runs', 'subject_id', 'subject_id TEXT');
8829
+ // #2009: the copy classification on a scope DB built before it (NULL reads as a copy; see
8830
+ // `COPY_ORIGIN_DDL`).
8831
+ this.ensureColumn(db, '_substrat_copy_origin', 'is_copy', 'is_copy INTEGER');
8656
8832
  // #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
8657
8833
  // that is never pruned. No index leads with invocation_id, so without this one SQLite
8658
8834
  // walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a
@@ -8700,6 +8876,7 @@ export class SqliteScopeHost {
8700
8876
  mintEventId,
8701
8877
  causedBy: null,
8702
8878
  invocationId: null,
8879
+ unitInvocationId: null,
8703
8880
  };
8704
8881
  this.scopes.set(key, created);
8705
8882
  this.scopesById.set(scopeId, created);