@substrat-run/adapter-sqlite 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/index.d.ts +16 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +235 -28
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
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, redactSecrets, redactSecretText, moduleLog, consoleLogSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, 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, 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 {
|
|
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
|
|
422
|
-
*
|
|
423
|
-
*
|
|
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
|
|
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.
|
|
@@ -516,7 +521,8 @@ export class SqliteScopeHost {
|
|
|
516
521
|
listPlans = new Map();
|
|
517
522
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
518
523
|
attachmentTargets = new Map();
|
|
519
|
-
/** operation name →
|
|
524
|
+
/** operation name → who binds it: the owning module, its entitlementKey and its declared
|
|
525
|
+
* schedules — the §4.3 gate's input, resolved per invoke by `requiredEntitlementFor` (#1654). */
|
|
520
526
|
operationEntitlement = new Map();
|
|
521
527
|
/**
|
|
522
528
|
* #893: name → the declared input schema, parsed before guards and handler.
|
|
@@ -571,6 +577,8 @@ export class SqliteScopeHost {
|
|
|
571
577
|
versionId;
|
|
572
578
|
/** #1746: where `ctx.log` lines go — the console unless a caller (a test) says otherwise. */
|
|
573
579
|
logSink;
|
|
580
|
+
/** #1901: where async work's invocation lines go. */
|
|
581
|
+
invocationLineSink;
|
|
574
582
|
// The mint for event ids (#956) is NOT here: it lives on `ScopeRuntime`, one per
|
|
575
583
|
// scope, seeded from that scope's persisted maximum (#1335). Its own monotonic
|
|
576
584
|
// floor, because the timestamp it stamps comes from `this.clock` — a scripted
|
|
@@ -585,6 +593,7 @@ export class SqliteScopeHost {
|
|
|
585
593
|
this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
|
|
586
594
|
this.versionId = options.versionId ?? null;
|
|
587
595
|
this.logSink = options.logSink ?? consoleLogSink;
|
|
596
|
+
this.invocationLineSink = options.invocationLineSink ?? consoleInvocationLineSink;
|
|
588
597
|
this.dir = options.dir;
|
|
589
598
|
mkdirSync(this.dir, { recursive: true });
|
|
590
599
|
this.directory = new Database(join(this.dir, '_directory.sqlite'));
|
|
@@ -1530,11 +1539,16 @@ export class SqliteScopeHost {
|
|
|
1530
1539
|
`${[...unboundOptOuts].sort().join(', ')} — an opt-out on nothing reads as an ` +
|
|
1531
1540
|
'exclusion someone decided, of an operation that does not exist');
|
|
1532
1541
|
}
|
|
1542
|
+
const entitlementBinding = {
|
|
1543
|
+
moduleId: manifest.id,
|
|
1544
|
+
entitlementKey: manifest.entitlementKey,
|
|
1545
|
+
scheduledOperations: new Set((manifest.schedules ?? []).map((sch) => sch.operation)),
|
|
1546
|
+
};
|
|
1533
1547
|
for (const [name, handler] of Object.entries(registration.operations ?? {})) {
|
|
1534
1548
|
this.defineOperation(name, handler);
|
|
1535
1549
|
// Record which SKU flag gates this operation (§4.3). Bare defineOperation
|
|
1536
1550
|
// bindings (tests, glue) carry no manifest and stay ungated.
|
|
1537
|
-
this.operationEntitlement.set(name,
|
|
1551
|
+
this.operationEntitlement.set(name, entitlementBinding);
|
|
1538
1552
|
// #893: withdrawal removes the binding, so the schema follows the handler
|
|
1539
1553
|
// rather than the name — a withdrawn operation has nothing to parse for.
|
|
1540
1554
|
const schema = declaredInputs[name];
|
|
@@ -2240,6 +2254,10 @@ export class SqliteScopeHost {
|
|
|
2240
2254
|
// #1575: attachment text is not in a dump, so a load left it as it was. Drop the
|
|
2241
2255
|
// text of attachments the dump did not bring back, and queue extraction for those
|
|
2242
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());
|
|
2243
2261
|
reconcileAttachmentText(spineSql(db), ulid, this.clock());
|
|
2244
2262
|
// Re-point scope-level grants at the scope they now live in. They are written as
|
|
2245
2263
|
// `object = scope:<scopeId>`, so a fork, a restore into a different scope, or #286's
|
|
@@ -2251,7 +2269,7 @@ export class SqliteScopeHost {
|
|
|
2251
2269
|
// (subject, relation, object) is the primary key, so a moved row can meet one the dump
|
|
2252
2270
|
// already holds here; which one survives is that function's rule too (#1882), judged
|
|
2253
2271
|
// at this host's clock as the checker judges expiry.
|
|
2254
|
-
repointScopeGrants(
|
|
2272
|
+
repointScopeGrants(switchSql, scopeId, { scopeId: dump.scopeId, exact: dump.exact }, this.clock());
|
|
2255
2273
|
// #1742: inside the replay's transaction, so a failure here rolls the whole load back and
|
|
2256
2274
|
// the dump's grants never commit without the switch that should cover them.
|
|
2257
2275
|
afterLoad?.(rt);
|
|
@@ -2260,7 +2278,20 @@ export class SqliteScopeHost {
|
|
|
2260
2278
|
// load was a SAVEPOINT inside it, and that invoke's rollback undid the restore after
|
|
2261
2279
|
// this verb had audited it.
|
|
2262
2280
|
await rt.actor.turn(() => {
|
|
2263
|
-
|
|
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
|
+
}
|
|
2264
2295
|
// The frontier came in with the dump — refresh the cached applied-migration set so
|
|
2265
2296
|
// a later bind/migrate builds on the loaded state, not the previous one.
|
|
2266
2297
|
rt.appliedMigrations.clear();
|
|
@@ -2596,12 +2627,29 @@ export class SqliteScopeHost {
|
|
|
2596
2627
|
const deadLetter = rt.db.prepare(`INSERT OR IGNORE INTO _substrat_deliveries
|
|
2597
2628
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
2598
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
|
+
});
|
|
2599
2646
|
// Named and not carried: a dead letter per importing module, so the operator who reads
|
|
2600
2647
|
// this scope's dead letters sees what the producer sent and why it did not arrive.
|
|
2601
2648
|
for (const w of batch.withheld) {
|
|
2602
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);
|
|
2603
2650
|
for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
|
|
2604
2651
|
deadLetter.run(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
|
|
2652
|
+
lines.write({ ...unitOf(moduleId, w.id, w.type), outcome: 'dead-lettered' });
|
|
2605
2653
|
}
|
|
2606
2654
|
result.withheld += 1;
|
|
2607
2655
|
}
|
|
@@ -2617,14 +2665,17 @@ export class SqliteScopeHost {
|
|
|
2617
2665
|
// this scope's code changed between the two, so it is refused by version (K-39)
|
|
2618
2666
|
// exactly as the producer would have refused it.
|
|
2619
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' });
|
|
2620
2669
|
result.deadLettered += 1;
|
|
2621
2670
|
continue;
|
|
2622
2671
|
}
|
|
2623
2672
|
const { hops: _hops, ...fact } = e;
|
|
2624
2673
|
const event = structuredClone({ ...fact, source });
|
|
2674
|
+
const unit = unitOf(imp.moduleId, e.id, e.type);
|
|
2625
2675
|
// What the handler emits was emitted BECAUSE of the producer's event (#1237). The id
|
|
2626
2676
|
// resolves through `_substrat_imports`, which the row above has just written.
|
|
2627
2677
|
rt.causedBy = e.id;
|
|
2678
|
+
rt.unitInvocationId = unit.invocationId;
|
|
2628
2679
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
2629
2680
|
try {
|
|
2630
2681
|
// As the producer's principal, admitted above: the handler's checks are real ones,
|
|
@@ -2637,6 +2688,7 @@ export class SqliteScopeHost {
|
|
|
2637
2688
|
.run(e.id, imp.moduleId, new Date().toISOString());
|
|
2638
2689
|
rt.db.exec('COMMIT');
|
|
2639
2690
|
result.delivered += 1;
|
|
2691
|
+
lines.write({ ...unit, outcome: 'delivered' });
|
|
2640
2692
|
}
|
|
2641
2693
|
catch (err) {
|
|
2642
2694
|
rt.db.exec('ROLLBACK');
|
|
@@ -2644,9 +2696,11 @@ export class SqliteScopeHost {
|
|
|
2644
2696
|
// behind it still arrive.
|
|
2645
2697
|
deadLetter.run(e.id, imp.moduleId, new Date().toISOString(), String(err));
|
|
2646
2698
|
result.deadLettered += 1;
|
|
2699
|
+
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
2647
2700
|
}
|
|
2648
2701
|
finally {
|
|
2649
2702
|
rt.causedBy = null;
|
|
2703
|
+
rt.unitInvocationId = null;
|
|
2650
2704
|
}
|
|
2651
2705
|
}
|
|
2652
2706
|
if (!ran)
|
|
@@ -2656,6 +2710,7 @@ export class SqliteScopeHost {
|
|
|
2656
2710
|
// rows above absorb it.
|
|
2657
2711
|
rt.db.prepare(IMPORT_CURSOR_ADVANCE_SQL).run(source.scopeId, source.vertical, batch.next, at);
|
|
2658
2712
|
result.cursor = batch.next;
|
|
2713
|
+
lines.end();
|
|
2659
2714
|
// What the handlers emitted reaches this scope's own consumers and executors in the same
|
|
2660
2715
|
// tail, under the ordinary cascade cap.
|
|
2661
2716
|
await this.dispatch(rt, null);
|
|
@@ -3055,6 +3110,7 @@ export class SqliteScopeHost {
|
|
|
3055
3110
|
// ended); the freshness evaluator's are `kind = 'freshness'` — see checkFreshness
|
|
3056
3111
|
// for what the same two columns mean there.
|
|
3057
3112
|
const now = Date.parse(nowIso);
|
|
3113
|
+
const lines = asyncLinePass(this.invocationLineSink);
|
|
3058
3114
|
for (const schedule of mod.schedules) {
|
|
3059
3115
|
const lastRunAt = gate.lastRuns.get(schedule.operation) ?? null;
|
|
3060
3116
|
const lastRun = lastRunAt ? Date.parse(lastRunAt) : null;
|
|
@@ -3071,19 +3127,39 @@ export class SqliteScopeHost {
|
|
|
3071
3127
|
// produces either way carries it, and so does the row below.
|
|
3072
3128
|
const invocationId = ulid();
|
|
3073
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;
|
|
3074
3134
|
try {
|
|
3075
3135
|
const stub = await this.getSystemScope(moduleId, tenantId, scopeId);
|
|
3076
|
-
await stub.invoke(schedule.operation, schedule.input, { invocationId });
|
|
3136
|
+
await stub.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
|
|
3077
3137
|
report.fired += 1;
|
|
3078
3138
|
}
|
|
3079
3139
|
catch (err) {
|
|
3080
3140
|
status = 'failed';
|
|
3141
|
+
failure = { error: err };
|
|
3081
3142
|
report.failed += 1;
|
|
3082
3143
|
report.errors.push({
|
|
3083
3144
|
operation: schedule.operation,
|
|
3084
3145
|
error: err instanceof Error ? err.message : String(err),
|
|
3085
3146
|
});
|
|
3086
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
|
+
});
|
|
3087
3163
|
// On the scope actor (#1678): the cadence row joined whatever transaction an invoke
|
|
3088
3164
|
// held open, so its rollback forgot the run and the next pass fired it AGAIN.
|
|
3089
3165
|
await rt.actor.turn(() => rt.db
|
|
@@ -3095,6 +3171,7 @@ export class SqliteScopeHost {
|
|
|
3095
3171
|
.run(schedule.operation, new Date(now).toISOString(), status, invocationId));
|
|
3096
3172
|
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
3097
3173
|
}
|
|
3174
|
+
lines.end();
|
|
3098
3175
|
return report;
|
|
3099
3176
|
}
|
|
3100
3177
|
/** The stub body, shared by the principal, connection, system, impersonation and capability doors. */
|
|
@@ -3172,7 +3249,13 @@ export class SqliteScopeHost {
|
|
|
3172
3249
|
// only if the tenant holds its SKU flag. Checked per invoke — the simple,
|
|
3173
3250
|
// uncached path (K-OQ5); a DO-cached variant is a later benchmark call.
|
|
3174
3251
|
// Fails closed the same way withdrawal does: the operation is unavailable.
|
|
3175
|
-
|
|
3252
|
+
//
|
|
3253
|
+
// EXCEPTION (#1654): a module's own declared schedule, through the system door,
|
|
3254
|
+
// demands no SKU — its `system:<moduleId>` grant is the switch, as it already is
|
|
3255
|
+
// for permissions (#383). The kernel's `requiredEntitlementFor` holds the rule and
|
|
3256
|
+
// why, and the Cloudflare coordinator runs the same predicate. Every other door
|
|
3257
|
+
// (principal, connection, capability, peer, impersonation) is gated as before.
|
|
3258
|
+
const requiredKey = requiredEntitlementFor(operation, this.operationEntitlement.get(operation), authority.kind === 'system' ? authority.id : undefined);
|
|
3176
3259
|
if (requiredKey && !this.tenantHoldsEntitlement(tenantId, requiredKey)) {
|
|
3177
3260
|
return Promise.reject(substratError('not_found', entitlementDenial(operation, requiredKey, this.heldEntitlements(tenantId))));
|
|
3178
3261
|
}
|
|
@@ -3484,7 +3567,26 @@ export class SqliteScopeHost {
|
|
|
3484
3567
|
retrying: 0,
|
|
3485
3568
|
deadLettered: 0,
|
|
3486
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) {
|
|
3487
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));
|
|
3488
3590
|
for (const [id, executor] of this.executors) {
|
|
3489
3591
|
const deliveryId = `executor:${id}`;
|
|
3490
3592
|
// Due = never attempted, or retrying and past its next attempt time.
|
|
@@ -3493,13 +3595,26 @@ export class SqliteScopeHost {
|
|
|
3493
3595
|
.prepare(`SELECT o.* FROM _substrat_outbox o
|
|
3494
3596
|
LEFT JOIN _substrat_deliveries d
|
|
3495
3597
|
ON d.event_id = o.id AND d.consumer_module = ?
|
|
3496
|
-
WHERE o.type = ?
|
|
3598
|
+
WHERE o.type = ? AND ${emittedHere('o.')}
|
|
3497
3599
|
AND (d.event_id IS NULL
|
|
3498
3600
|
OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?))
|
|
3499
3601
|
ORDER BY o.id`)
|
|
3500
3602
|
.all(deliveryId, executor.eventType, now);
|
|
3501
3603
|
for (const row of rows) {
|
|
3502
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
|
+
};
|
|
3503
3618
|
let event;
|
|
3504
3619
|
try {
|
|
3505
3620
|
event = domainEventOf(row);
|
|
@@ -3511,8 +3626,16 @@ export class SqliteScopeHost {
|
|
|
3511
3626
|
// on the FIRST failure, unlike a handler's: the decode is pure, of text already
|
|
3512
3627
|
// in hand, so a retry cannot succeed — and only the decode is caught here, so a
|
|
3513
3628
|
// handler's transient failure keeps its backoff below.
|
|
3514
|
-
this.recordExecutorDelivery(rt, row.id, deliveryId, String(err),
|
|
3629
|
+
const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, String(err), TERMINAL_RETRY, invocationId);
|
|
3515
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' });
|
|
3516
3639
|
continue;
|
|
3517
3640
|
}
|
|
3518
3641
|
this.causedBy = event.id;
|
|
@@ -3525,26 +3648,27 @@ export class SqliteScopeHost {
|
|
|
3525
3648
|
else {
|
|
3526
3649
|
await executor.handler(this.admin, event);
|
|
3527
3650
|
}
|
|
3528
|
-
this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry, invocationId);
|
|
3651
|
+
const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry, invocationId);
|
|
3529
3652
|
report.delivered += 1;
|
|
3653
|
+
lines.write({ ...unit, attempt: attempts, outcome: 'delivered' });
|
|
3530
3654
|
}
|
|
3531
3655
|
catch (err) {
|
|
3532
|
-
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);
|
|
3533
3657
|
if (dead)
|
|
3534
3658
|
report.deadLettered += 1;
|
|
3535
3659
|
else
|
|
3536
3660
|
report.retrying += 1;
|
|
3661
|
+
lines.write({ ...unit, attempt: attempts, outcome: dead ? 'dead-lettered' : 'retrying', error: err });
|
|
3537
3662
|
}
|
|
3538
3663
|
finally {
|
|
3539
3664
|
this.causedBy = null;
|
|
3540
3665
|
}
|
|
3541
3666
|
}
|
|
3542
3667
|
}
|
|
3543
|
-
return report;
|
|
3544
3668
|
}
|
|
3545
3669
|
/**
|
|
3546
|
-
* Journal one executor attempt.
|
|
3547
|
-
* — 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.
|
|
3548
3672
|
*
|
|
3549
3673
|
* Written AFTER the handler ran, so a crash mid-effect retries rather than
|
|
3550
3674
|
* silently marking success. Claiming first would make delivery at-most-once and
|
|
@@ -3577,7 +3701,11 @@ export class SqliteScopeHost {
|
|
|
3577
3701
|
.run(eventId, deliveryId, new Date().toISOString(), error, attempts, nextAttemptAt,
|
|
3578
3702
|
// #1525: the call this attempt ran in, as the caller named it.
|
|
3579
3703
|
invocationId);
|
|
3580
|
-
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));
|
|
3581
3709
|
}
|
|
3582
3710
|
async drainDue(tenantId, scopeId) {
|
|
3583
3711
|
const rt = this.runtime(tenantId, scopeId);
|
|
@@ -3917,13 +4045,23 @@ export class SqliteScopeHost {
|
|
|
3917
4045
|
* own emit is delivered in the same tail, so the whole cascade is one call's work.
|
|
3918
4046
|
*/
|
|
3919
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) {
|
|
3920
4058
|
for (let round = 0; round < 50; round++) {
|
|
3921
4059
|
let deliveredAny = false;
|
|
3922
4060
|
for (const mod of this.modules.values()) {
|
|
3923
4061
|
for (const consumer of mod.consumers) {
|
|
3924
4062
|
const rows = rt.db
|
|
3925
4063
|
.prepare(`SELECT * FROM _substrat_outbox o
|
|
3926
|
-
WHERE o.type = ?
|
|
4064
|
+
WHERE o.type = ? AND ${emittedHere('o.')}
|
|
3927
4065
|
AND NOT EXISTS (
|
|
3928
4066
|
SELECT 1 FROM _substrat_deliveries d
|
|
3929
4067
|
WHERE d.event_id = o.id AND d.consumer_module = ?
|
|
@@ -3931,6 +4069,20 @@ export class SqliteScopeHost {
|
|
|
3931
4069
|
ORDER BY o.id`)
|
|
3932
4070
|
.all(consumer.eventType, mod.id);
|
|
3933
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
|
+
};
|
|
3934
4086
|
let event;
|
|
3935
4087
|
try {
|
|
3936
4088
|
event = domainEventOf(row);
|
|
@@ -3947,6 +4099,8 @@ export class SqliteScopeHost {
|
|
|
3947
4099
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3948
4100
|
VALUES (?, ?, ?, ?, ?)`)
|
|
3949
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' });
|
|
3950
4104
|
continue;
|
|
3951
4105
|
}
|
|
3952
4106
|
const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
|
|
@@ -3957,6 +4111,8 @@ export class SqliteScopeHost {
|
|
|
3957
4111
|
// consumers never did, which is exactly where a backwards walk stopped.
|
|
3958
4112
|
// On the runtime rather than the host — see `ScopeRuntime.causedBy`.
|
|
3959
4113
|
rt.causedBy = event.id;
|
|
4114
|
+
// #1901: the handler's `ctx.log` lines join this delivery's line.
|
|
4115
|
+
rt.unitInvocationId = unit.invocationId;
|
|
3960
4116
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
3961
4117
|
try {
|
|
3962
4118
|
await consumer.handler(ctx, event);
|
|
@@ -3967,6 +4123,7 @@ export class SqliteScopeHost {
|
|
|
3967
4123
|
.run(event.id, mod.id, new Date().toISOString(), invocationId);
|
|
3968
4124
|
rt.db.exec('COMMIT');
|
|
3969
4125
|
deliveredAny = true;
|
|
4126
|
+
lines.write({ ...unit, outcome: 'delivered' });
|
|
3970
4127
|
}
|
|
3971
4128
|
catch (err) {
|
|
3972
4129
|
rt.db.exec('ROLLBACK');
|
|
@@ -3977,8 +4134,10 @@ export class SqliteScopeHost {
|
|
|
3977
4134
|
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3978
4135
|
VALUES (?, ?, ?, ?, ?)`)
|
|
3979
4136
|
.run(event.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4137
|
+
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
3980
4138
|
}
|
|
3981
4139
|
finally {
|
|
4140
|
+
rt.unitInvocationId = null;
|
|
3982
4141
|
// Cleared on BOTH paths. Left set, the id would leak onto every later
|
|
3983
4142
|
// emit in this scope — an operation's own event stamped as caused by
|
|
3984
4143
|
// whatever happened to be delivered last, which is worse than no cause
|
|
@@ -5081,6 +5240,15 @@ export class SqliteScopeHost {
|
|
|
5081
5240
|
revoked: true,
|
|
5082
5241
|
});
|
|
5083
5242
|
},
|
|
5243
|
+
// #1686 — the operator's read of the directory `ctx.capabilities.list` reads. The
|
|
5244
|
+
// denial log's discipline: `scopeReadDbFor` is the K-3 cross-check, the read is the
|
|
5245
|
+
// kernel's one (`readCapabilityPage`: no `token_hash` column in it), and the read leaves a K-24 row.
|
|
5246
|
+
listCapabilities: async (actor, tenantId, scopeId, filter) => {
|
|
5247
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
5248
|
+
const page = readCapabilityPage(spineSql(db), filter);
|
|
5249
|
+
this.recordAccess(actor, 'listCapabilities', { tenantId, scopeId }, filter ?? null, page.entries.length);
|
|
5250
|
+
return page;
|
|
5251
|
+
},
|
|
5084
5252
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
5085
5253
|
// The org must exist in the node's tenant. A grant to a phantom org is
|
|
5086
5254
|
// worse than an error: it looks applied, resolves for nobody, and shows up
|
|
@@ -5249,7 +5417,16 @@ export class SqliteScopeHost {
|
|
|
5249
5417
|
COALESCE(s.serving_ref, vv.deployment_ref) AS deployment_ref,
|
|
5250
5418
|
CASE WHEN s.serving_ref IS NOT NULL
|
|
5251
5419
|
THEN json_extract(sv.manifest_json, '$.outbound')
|
|
5252
|
-
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
|
|
5253
5430
|
FROM hostnames h
|
|
5254
5431
|
JOIN scopes s ON s.scope_id = h.scope_id AND s.tenant_id = h.tenant_id
|
|
5255
5432
|
JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
@@ -5281,6 +5458,9 @@ export class SqliteScopeHost {
|
|
|
5281
5458
|
surface: r.surface,
|
|
5282
5459
|
region: r.region,
|
|
5283
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),
|
|
5284
5464
|
});
|
|
5285
5465
|
},
|
|
5286
5466
|
registerVertical: async (actor, input) => {
|
|
@@ -5521,7 +5701,7 @@ export class SqliteScopeHost {
|
|
|
5521
5701
|
if (!v)
|
|
5522
5702
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
5523
5703
|
if (v.admission === 'admitted') {
|
|
5524
|
-
throw
|
|
5704
|
+
throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
|
|
5525
5705
|
}
|
|
5526
5706
|
if (v.admission === 'rejected')
|
|
5527
5707
|
return; // idempotent
|
|
@@ -5550,7 +5730,7 @@ export class SqliteScopeHost {
|
|
|
5550
5730
|
if (!incoming)
|
|
5551
5731
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
5552
5732
|
if (incoming.verticalSlug !== verticalSlug) {
|
|
5553
|
-
throw
|
|
5733
|
+
throw substratError('conflict', `version ${versionId} belongs to '${incoming.verticalSlug}'`);
|
|
5554
5734
|
}
|
|
5555
5735
|
if (incoming.admission !== 'admitted') {
|
|
5556
5736
|
throw substratError('conflict', `version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
|
|
@@ -5807,6 +5987,15 @@ export class SqliteScopeHost {
|
|
|
5807
5987
|
.run(servingRef, scopeId);
|
|
5808
5988
|
this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref }, { servingRef, ...(ack.exportBreak ? { acknowledged: ack } : {}) });
|
|
5809
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
|
+
},
|
|
5810
5999
|
setScopeExpiresAt: async (actor, tenantId, scopeId, expiresAt) => {
|
|
5811
6000
|
const scope = this.directory
|
|
5812
6001
|
.prepare('SELECT tenant_id, expires_at FROM scopes WHERE scope_id = ?')
|
|
@@ -5825,7 +6014,7 @@ export class SqliteScopeHost {
|
|
|
5825
6014
|
// — which is append-only — holding an event the scope never had. The mark that
|
|
5826
6015
|
// follows needs no such care: it stamps only rows that exist and are undrained.
|
|
5827
6016
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
5828
|
-
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 ?`);
|
|
5829
6018
|
// The kernel's read, shared with the DO: the envelope and the columns lifted beside
|
|
5830
6019
|
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
5831
6020
|
// rather than stalling the scope on it (#1636).
|
|
@@ -6420,6 +6609,15 @@ export class SqliteScopeHost {
|
|
|
6420
6609
|
drained: Number(w.drained ?? 0),
|
|
6421
6610
|
};
|
|
6422
6611
|
},
|
|
6612
|
+
// #1745: the refusal log, read like the denial log above — same K-3 cross-check,
|
|
6613
|
+
// same K-24 access row.
|
|
6614
|
+
listRefusals: async (actor, tenantId, scopeId, filter) => {
|
|
6615
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
6616
|
+
const q = refusalListQuery(filter);
|
|
6617
|
+
const rows = db.prepare(q.sql).all(...q.params).map(mapRefusalRow);
|
|
6618
|
+
this.recordAccess(actor, 'listRefusals', { tenantId, scopeId }, filter ?? null, rows.length);
|
|
6619
|
+
return rows;
|
|
6620
|
+
},
|
|
6423
6621
|
exportScope: async (actor, tenantId, scopeId) => {
|
|
6424
6622
|
// K-3: cross-check the pair before opening anything (same as the introspection reads).
|
|
6425
6623
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
@@ -7240,6 +7438,11 @@ export class SqliteScopeHost {
|
|
|
7240
7438
|
const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
|
|
7241
7439
|
this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
|
|
7242
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
|
+
},
|
|
7243
7446
|
accessLog: async (actor, filter) => {
|
|
7244
7447
|
const where = [];
|
|
7245
7448
|
const params = [];
|
|
@@ -8125,7 +8328,7 @@ export class SqliteScopeHost {
|
|
|
8125
8328
|
tenantId: rt.tenantId,
|
|
8126
8329
|
scopeId: rt.scopeId,
|
|
8127
8330
|
operation: operation ?? null,
|
|
8128
|
-
invocationId: () => rt.invocationId,
|
|
8331
|
+
invocationId: () => rt.invocationId ?? rt.unitInvocationId,
|
|
8129
8332
|
// A consumer runs under the system override, so it logs as `system`.
|
|
8130
8333
|
principalKind: overrideActor ? 'system' : subject.kind,
|
|
8131
8334
|
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
@@ -8213,7 +8416,7 @@ export class SqliteScopeHost {
|
|
|
8213
8416
|
// as `pageOf`, applied here because the cursor is a COLUMN's value (plus
|
|
8214
8417
|
// the tie-break) rather than a field the caller could name.
|
|
8215
8418
|
const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
|
|
8216
|
-
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);
|
|
8217
8420
|
const page = { entries: rows, nextCursor };
|
|
8218
8421
|
if (!params.total)
|
|
8219
8422
|
return page;
|
|
@@ -8623,6 +8826,9 @@ export class SqliteScopeHost {
|
|
|
8623
8826
|
// executes, the table always already has `kind` in its key, never `invocation_id`.
|
|
8624
8827
|
this.ensureColumn(db, '_substrat_schedule_state', 'invocation_id', 'invocation_id TEXT');
|
|
8625
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');
|
|
8626
8832
|
// #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
|
|
8627
8833
|
// that is never pruned. No index leads with invocation_id, so without this one SQLite
|
|
8628
8834
|
// walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a
|
|
@@ -8670,6 +8876,7 @@ export class SqliteScopeHost {
|
|
|
8670
8876
|
mintEventId,
|
|
8671
8877
|
causedBy: null,
|
|
8672
8878
|
invocationId: null,
|
|
8879
|
+
unitInvocationId: null,
|
|
8673
8880
|
};
|
|
8674
8881
|
this.scopes.set(key, created);
|
|
8675
8882
|
this.scopesById.set(scopeId, created);
|