@substrat-run/adapter-sqlite 0.139.0 → 0.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +6 -0
- package/dist/checker.js.map +1 -1
- package/dist/index.d.ts +80 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1006 -265
- package/dist/index.js.map +1 -1
- package/dist/vertical-broker.d.ts +7 -2
- package/dist/vertical-broker.d.ts.map +1 -1
- package/dist/vertical-broker.js +54 -5
- package/dist/vertical-broker.js.map +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, 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, operationSeriesCount, 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, SCOPE_GATE_REASONS, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, searchStateWhere, uncheckedView, addStatePlans, entityStateMigrations,
|
|
6
|
-
import { attributedView } from '@substrat-run/kernel';
|
|
4
|
+
import { accessLogEntry, adminLogEntry, delegatedReadParams, operationSeriesCount, 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, isSubstratError, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, 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, pageOf, countedPageOf, SCOPE_GATE_REASONS, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, entityStateMigrations, assertNoCallerPurge, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, registerTrashTargets, runPurgePass, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, 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, FINDINGS_DDL, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink, listConnectLinks as listConnectLinkRows, readConnectLink, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, 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, eraseSubjectFromModules, moduleRowsErased, moduleErasurePlan, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_STEP_RECORD_SQL, JOB_LEASE_EXPIRED_NOTE, assertLeaseMs, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, executableSqlStatements, blankSqlComments, recordWriteSuperseded, switchFencesOf, switchSupersededMessage, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, moveSwitch, reassertActionOf, reassertEntry, reassertOffRow, recordSwitchedOff, recordSwitchedOn, restoreSwitchRecord, scopesSwitchedOffFor, switchActionOf, switchAuditSubject, recordAuditOutcome, auditWarningOf, switchNotFoundMessage, switchRecordsOf, switchedOffOf, switchesBackfillSqlOf, switchesTableExists, tenantHeldOf, tenantHoldsGrant, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, 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, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSteps, planMigrations, isSearchIndexTable, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, queueAttachmentTextBackfill, startAttachmentTextBackfill, attachmentTextBackfillJob, reconcileAttachmentText, recordAttachmentText, searchAttachments, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, 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, JOB_RUN_DUE_AT, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, readOperationSeries, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, } from '@substrat-run/kernel';
|
|
6
|
+
import { attributedView, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, shapeTopUpBatch, topUpEntityGrantShapes, settlePlatformRequestIn, } from '@substrat-run/kernel';
|
|
7
|
+
import { ADMIN_LOG_INDEXES_SQL, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, readAuditedOperations, unknownOutcomeOf, } from '@substrat-run/kernel';
|
|
7
8
|
import { INERT_SCOPE_REASON, isPrimaryScopeRow } from '@substrat-run/kernel';
|
|
8
|
-
import {
|
|
9
|
+
import { COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, scopeScriptCopyOf, } from '@substrat-run/kernel';
|
|
10
|
+
import { GRANT_CHILDREN_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
|
|
11
|
+
import { LEGACY_SCOPE_ROWS_BACKFILL, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
|
|
9
12
|
import { ScopeActor } from './actor.js';
|
|
10
13
|
import { createTupleChecker, directoryTenantReader } from './checker.js';
|
|
11
14
|
/**
|
|
@@ -244,9 +247,15 @@ const KERNEL_DDL = `
|
|
|
244
247
|
applied_at TEXT NOT NULL,
|
|
245
248
|
duration_ms INTEGER,
|
|
246
249
|
rows_changed INTEGER,
|
|
250
|
+
-- #2066: SHA-256 of the SQL that ran (kernel migrationDigest). NULL on a row written
|
|
251
|
+
-- before the column: unrecorded, accepted, never backfilled.
|
|
252
|
+
sql_digest TEXT,
|
|
247
253
|
PRIMARY KEY (module_id, version)
|
|
248
254
|
);
|
|
255
|
+
-- #2066: no new journal row without its digest (the kernel's comment says why).
|
|
256
|
+
${MIGRATION_DIGEST_FENCE_DDL}
|
|
249
257
|
${SWITCH_FENCES_DDL}
|
|
258
|
+
${TABLE_OWNERS_DDL}
|
|
250
259
|
CREATE TABLE IF NOT EXISTS _substrat_tuples (
|
|
251
260
|
subject TEXT NOT NULL,
|
|
252
261
|
relation TEXT NOT NULL,
|
|
@@ -258,6 +267,7 @@ const KERNEL_DDL = `
|
|
|
258
267
|
revoked_at TEXT,
|
|
259
268
|
PRIMARY KEY (subject, relation, object)
|
|
260
269
|
);
|
|
270
|
+
${GRANT_CHILDREN_INDEX_DDL};
|
|
261
271
|
CREATE TABLE IF NOT EXISTS _substrat_deliveries (
|
|
262
272
|
event_id TEXT NOT NULL,
|
|
263
273
|
consumer_module TEXT NOT NULL,
|
|
@@ -470,6 +480,66 @@ const admitByDelivery = (rt, eventId, row) => {
|
|
|
470
480
|
`the attachments of the entity its event names, and no others`);
|
|
471
481
|
}
|
|
472
482
|
};
|
|
483
|
+
/**
|
|
484
|
+
* #119: this thread's claim on each host directory it has open, by real path — the OS lock and
|
|
485
|
+
* how many live hosts on this thread share it. Several hosts on ONE thread may share a directory
|
|
486
|
+
* (that is how the pure adapter models several verticals on one platform directory, #1705): the
|
|
487
|
+
* guarantees that read the directory and a scope's file together do so with no `await` in between,
|
|
488
|
+
* and one thread's JS runs one task at a time, so nothing it runs can interleave there. A writer on
|
|
489
|
+
* another thread or in ANOTHER process could. This map is module state, so each worker thread has
|
|
490
|
+
* its own: a second thread opens its own `_host.lock` connection, and SQLite refuses it exactly as
|
|
491
|
+
* it refuses another process.
|
|
492
|
+
*/
|
|
493
|
+
const PROCESS_HOST_DIRS = new Map();
|
|
494
|
+
/**
|
|
495
|
+
* #119: claim a host directory for this process, for one live `SqliteScopeHost`. The first host in
|
|
496
|
+
* the process takes an exclusive lock on `_host.lock` (a SQLite file: `locking_mode = EXCLUSIVE`
|
|
497
|
+
* plus a write holds SQLite's file lock until the connection closes); later hosts in the process
|
|
498
|
+
* share it, and the last `release()` drops it. The operating system drops it when the process
|
|
499
|
+
* exits, however it exits, so it never goes stale. No busy timeout: another process's claim is
|
|
500
|
+
* refused at once — `conflict`, reason `host_dir_in_use`, naming the directory — never queued.
|
|
501
|
+
*/
|
|
502
|
+
function claimHostDir(dir) {
|
|
503
|
+
const key = realpathSync(dir);
|
|
504
|
+
let claim = PROCESS_HOST_DIRS.get(key);
|
|
505
|
+
if (!claim) {
|
|
506
|
+
const lock = new Database(join(key, '_host.lock'), { timeout: 0 });
|
|
507
|
+
try {
|
|
508
|
+
lock.pragma('locking_mode = EXCLUSIVE');
|
|
509
|
+
// No journal file beside the directory: the one write only takes the lock.
|
|
510
|
+
lock.pragma('journal_mode = MEMORY');
|
|
511
|
+
lock.exec('CREATE TABLE IF NOT EXISTS owner (id INTEGER PRIMARY KEY CHECK (id = 1), pid INTEGER NOT NULL, at TEXT NOT NULL)');
|
|
512
|
+
lock.prepare('INSERT OR REPLACE INTO owner (id, pid, at) VALUES (1, ?, ?)').run(process.pid, new Date().toISOString());
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
lock.close();
|
|
516
|
+
const code = err.code;
|
|
517
|
+
if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') {
|
|
518
|
+
throw substratError('conflict', `host directory already in use by another process: ${key} — one process owns a host directory`, {
|
|
519
|
+
reason: 'host_dir_in_use',
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
throw err;
|
|
523
|
+
}
|
|
524
|
+
claim = { lock, hosts: 0 };
|
|
525
|
+
PROCESS_HOST_DIRS.set(key, claim);
|
|
526
|
+
}
|
|
527
|
+
claim.hosts += 1;
|
|
528
|
+
const held = claim;
|
|
529
|
+
let released = false;
|
|
530
|
+
return {
|
|
531
|
+
release: () => {
|
|
532
|
+
if (released)
|
|
533
|
+
return;
|
|
534
|
+
released = true;
|
|
535
|
+
held.hosts -= 1;
|
|
536
|
+
if (held.hosts > 0)
|
|
537
|
+
return;
|
|
538
|
+
PROCESS_HOST_DIRS.delete(key);
|
|
539
|
+
held.lock.close();
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
473
543
|
export class SqliteScopeHost {
|
|
474
544
|
admin;
|
|
475
545
|
/**
|
|
@@ -499,8 +569,39 @@ export class SqliteScopeHost {
|
|
|
499
569
|
*/
|
|
500
570
|
liveReads;
|
|
501
571
|
dir;
|
|
572
|
+
/** #119: this host's share of its process's claim on `dir` (`claimHostDir`), released by `close()`. */
|
|
573
|
+
owner;
|
|
502
574
|
checker;
|
|
503
575
|
directory;
|
|
576
|
+
/** A private directory seam: module SQL and scope/directory exports never reach it. */
|
|
577
|
+
continuationStore(scopeId, writable) {
|
|
578
|
+
const pruneExpired = () => this.directory.prepare('DELETE FROM private_continuation_positions WHERE expires_at <= ?').run(Date.parse(this.clock()));
|
|
579
|
+
return {
|
|
580
|
+
keys: async () => {
|
|
581
|
+
const row = this.directory.prepare('SELECT keyring FROM private_continuation_keys WHERE scope_id = ?').get(scopeId);
|
|
582
|
+
return row ? JSON.parse(row.keyring) : null;
|
|
583
|
+
},
|
|
584
|
+
setKeys: async (keys) => {
|
|
585
|
+
this.directory.prepare(`INSERT INTO private_continuation_keys (scope_id, keyring) VALUES (?, ?)
|
|
586
|
+
ON CONFLICT(scope_id) DO UPDATE SET keyring = excluded.keyring`).run(scopeId, JSON.stringify(keys));
|
|
587
|
+
},
|
|
588
|
+
position: async (id, expiresAt) => {
|
|
589
|
+
if (writable)
|
|
590
|
+
pruneExpired();
|
|
591
|
+
const row = this.directory.prepare(`SELECT ciphertext FROM private_continuation_positions
|
|
592
|
+
WHERE scope_id = ? AND expires_at = ? AND locator = ?`).get(scopeId, expiresAt, id);
|
|
593
|
+
return row ? { sealed: JSON.parse(row.ciphertext), expiresAt } : null;
|
|
594
|
+
},
|
|
595
|
+
setPosition: async (id, position) => {
|
|
596
|
+
pruneExpired();
|
|
597
|
+
this.directory.prepare(`INSERT INTO private_continuation_positions (scope_id, expires_at, locator, ciphertext)
|
|
598
|
+
VALUES (?, ?, ?, ?)`).run(scopeId, position.expiresAt, id, JSON.stringify(position.sealed));
|
|
599
|
+
this.directory.prepare(`DELETE FROM private_continuation_positions WHERE rowid IN (
|
|
600
|
+
SELECT rowid FROM private_continuation_positions WHERE scope_id = ?
|
|
601
|
+
ORDER BY rowid DESC LIMIT -1 OFFSET ?)`).run(scopeId, CONTINUATION_POSITION_CAP);
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
}
|
|
504
605
|
scopes = new Map();
|
|
505
606
|
scopesById = new Map();
|
|
506
607
|
/** The runtimes holding an open `reader` (#1624), least recently read first — `READER_CAP`'s LRU. */
|
|
@@ -526,6 +627,8 @@ export class SqliteScopeHost {
|
|
|
526
627
|
listPlans = new Map();
|
|
527
628
|
/** #119: entity type → its archive/trash plan, from every registered module. */
|
|
528
629
|
statePlans = new Map();
|
|
630
|
+
/** #2068: each registered module's erasure, in registration order — what `shredSubject` runs. */
|
|
631
|
+
erasurePlans = [];
|
|
529
632
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
530
633
|
attachmentTargets = new Map();
|
|
531
634
|
/** operation name → who binds it: the owning module, its entitlementKey and its declared
|
|
@@ -544,6 +647,11 @@ export class SqliteScopeHost {
|
|
|
544
647
|
* a bare `defineOperation` carries no declaration to read one off.
|
|
545
648
|
*/
|
|
546
649
|
operationConcurrency = new Map();
|
|
650
|
+
/**
|
|
651
|
+
* #119: name → the entity the operation addresses by id, its declared key and whether it
|
|
652
|
+
* reaches a trashed one — what the host's trash refusal reads. Populated by `registerModule`.
|
|
653
|
+
*/
|
|
654
|
+
operationTarget = new Map();
|
|
547
655
|
/**
|
|
548
656
|
* #116: the operations that declared `idempotency: false` — a set of refusals,
|
|
549
657
|
* because that is what the declaration is. Every name NOT in here honours an
|
|
@@ -603,24 +711,43 @@ export class SqliteScopeHost {
|
|
|
603
711
|
this.versionId = options.versionId ?? null;
|
|
604
712
|
this.logSink = options.logSink ?? consoleLogSink;
|
|
605
713
|
this.invocationLineSink = options.invocationLineSink ?? consoleInvocationLineSink;
|
|
714
|
+
// Every option is read above and here, BEFORE the directory is claimed: an option that throws
|
|
715
|
+
// (a getter, a bad value) then fails the constructor with nothing yet held.
|
|
716
|
+
const customChecker = options.checker;
|
|
606
717
|
this.dir = options.dir;
|
|
607
718
|
mkdirSync(this.dir, { recursive: true });
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
this.
|
|
612
|
-
this.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
719
|
+
// #119: from the claim to the end of the constructor is ONE guard. A constructor that throws
|
|
720
|
+
// leaves no host for anyone to close, so whatever it opened must be released here, and the
|
|
721
|
+
// only way to keep that true as lines are added is that every line after the claim is inside
|
|
722
|
+
// this `try`. Do not add work after it; add it inside, before `this.owner`.
|
|
723
|
+
const owner = claimHostDir(this.dir);
|
|
724
|
+
let directory;
|
|
725
|
+
try {
|
|
726
|
+
directory = new Database(join(this.dir, '_directory.sqlite'));
|
|
727
|
+
this.directory = directory;
|
|
728
|
+
this.directory.pragma('journal_mode = WAL');
|
|
729
|
+
this.ensureDirectorySchema();
|
|
730
|
+
this.loadRoles();
|
|
731
|
+
this.checker =
|
|
732
|
+
customChecker ??
|
|
733
|
+
createTupleChecker({
|
|
734
|
+
directory: this.directory,
|
|
735
|
+
scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
|
|
736
|
+
getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
|
|
737
|
+
// #956: the evaluator judges `expires_at` against the host's clock, so a
|
|
738
|
+
// scripted one can actually expire a grant. A caller-supplied `checker`
|
|
739
|
+
// keeps its own time source — this only binds the built-in one.
|
|
740
|
+
clock: () => this.clock(),
|
|
741
|
+
});
|
|
742
|
+
this.admin = this.buildAdmin();
|
|
743
|
+
this.owner = owner;
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
// A host that never finished opening owns nothing: the next one may open the directory.
|
|
747
|
+
directory?.close();
|
|
748
|
+
owner.release();
|
|
749
|
+
throw err;
|
|
750
|
+
}
|
|
624
751
|
}
|
|
625
752
|
/**
|
|
626
753
|
* The directory schema, applied on every open, and inside a restore's transaction onto an
|
|
@@ -648,7 +775,7 @@ export class SqliteScopeHost {
|
|
|
648
775
|
* recorded answer to #969; `docs/architecture/kernel-design.md` §8 says why.
|
|
649
776
|
*/
|
|
650
777
|
applyDirectorySchema() {
|
|
651
|
-
this.directory
|
|
778
|
+
execSqlStatements(this.directory, `
|
|
652
779
|
-- The tenant registry (control-plane.md §4.1). Before this a tenant was an
|
|
653
780
|
-- FK string on scope rows; now it is a real record with a lifecycle status.
|
|
654
781
|
CREATE TABLE IF NOT EXISTS tenants (
|
|
@@ -684,6 +811,10 @@ export class SqliteScopeHost {
|
|
|
684
811
|
status TEXT NOT NULL DEFAULT 'active',
|
|
685
812
|
schema_version TEXT NOT NULL DEFAULT '0',
|
|
686
813
|
vertical_version_id TEXT,
|
|
814
|
+
erasure_epoch INTEGER,
|
|
815
|
+
-- #1722: set once a reap has started draining this scope's script copies, and never
|
|
816
|
+
-- cleared: only deleting the row ends it. While set, no copy move is recorded or bound.
|
|
817
|
+
reap_claimed_at TEXT,
|
|
687
818
|
provisioned_version_id TEXT,
|
|
688
819
|
-- Last FAILED migration attempt (§5.3). All null / 0 = healthy. Written on
|
|
689
820
|
-- the failure path so a scope that fails closed stops rendering as active;
|
|
@@ -707,6 +838,40 @@ export class SqliteScopeHost {
|
|
|
707
838
|
archived_at TEXT,
|
|
708
839
|
created_at TEXT NOT NULL
|
|
709
840
|
);
|
|
841
|
+
-- #1722: additive copy inventory; historical audit backfill is a separate act.
|
|
842
|
+
CREATE TABLE IF NOT EXISTS scope_script_copies (
|
|
843
|
+
tenant_id TEXT NOT NULL,
|
|
844
|
+
scope_id TEXT NOT NULL,
|
|
845
|
+
script_ref TEXT NOT NULL,
|
|
846
|
+
move_id TEXT NOT NULL,
|
|
847
|
+
state TEXT NOT NULL,
|
|
848
|
+
load_stamp TEXT,
|
|
849
|
+
revision TEXT,
|
|
850
|
+
last_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
851
|
+
-- 'source' | 'destination'. A pending entry belongs to its move until lease_until; a
|
|
852
|
+
-- sweep that claims it after that sets lease_owner (kernel scope-copy-ledger.ts).
|
|
853
|
+
role TEXT,
|
|
854
|
+
lease_until TEXT,
|
|
855
|
+
lease_owner TEXT,
|
|
856
|
+
PRIMARY KEY (tenant_id, scope_id, script_ref, move_id)
|
|
857
|
+
);
|
|
858
|
+
CREATE INDEX IF NOT EXISTS scope_script_copies_state ON scope_script_copies (state, scope_id);
|
|
859
|
+
-- Private host state. Module SQL opens only the scope file, never this directory;
|
|
860
|
+
-- neither table is included in directory exports or scope dumps (#2074).
|
|
861
|
+
CREATE TABLE IF NOT EXISTS private_continuation_keys (
|
|
862
|
+
scope_id TEXT PRIMARY KEY,
|
|
863
|
+
keyring TEXT NOT NULL
|
|
864
|
+
);
|
|
865
|
+
CREATE TABLE IF NOT EXISTS private_continuation_positions (
|
|
866
|
+
scope_id TEXT NOT NULL,
|
|
867
|
+
expires_at INTEGER NOT NULL,
|
|
868
|
+
locator TEXT NOT NULL,
|
|
869
|
+
ciphertext TEXT NOT NULL,
|
|
870
|
+
PRIMARY KEY (scope_id, expires_at, locator)
|
|
871
|
+
);
|
|
872
|
+
CREATE INDEX IF NOT EXISTS private_continuation_positions_expiry
|
|
873
|
+
ON private_continuation_positions (expires_at);
|
|
874
|
+
${PEER_BINDINGS_DDL}
|
|
710
875
|
-- The hostname map (K-26). A single environment-wide router resolves against
|
|
711
876
|
-- this before dispatching to the vertical's worker.
|
|
712
877
|
--
|
|
@@ -1007,6 +1172,9 @@ export class SqliteScopeHost {
|
|
|
1007
1172
|
updated_at TEXT NOT NULL,
|
|
1008
1173
|
PRIMARY KEY (connection_id, state_key)
|
|
1009
1174
|
);
|
|
1175
|
+
-- A vertical's mailed connect links (connections.md §3.5.4) — kernel-owned DDL, so
|
|
1176
|
+
-- both adapters build the same table.
|
|
1177
|
+
${CONNECT_LINKS_DDL}
|
|
1010
1178
|
CREATE TABLE IF NOT EXISTS _substrat_identities (
|
|
1011
1179
|
provider TEXT NOT NULL,
|
|
1012
1180
|
external_id TEXT NOT NULL,
|
|
@@ -1094,14 +1262,9 @@ export class SqliteScopeHost {
|
|
|
1094
1262
|
on_behalf_of TEXT,
|
|
1095
1263
|
at TEXT NOT NULL
|
|
1096
1264
|
);
|
|
1097
|
-
--
|
|
1098
|
-
--
|
|
1099
|
-
|
|
1100
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_tenant ON _substrat_admin_log (tenant_id, id);
|
|
1101
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_scope ON _substrat_admin_log (scope_id, id);
|
|
1102
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_actor ON _substrat_admin_log (actor, id);
|
|
1103
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_action ON _substrat_admin_log (action, id);
|
|
1104
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_at ON _substrat_admin_log (at);
|
|
1265
|
+
-- Every admin-log index, from the kernel's one list (#2064), which the legacy
|
|
1266
|
+
-- rebuild below runs again after its rename.
|
|
1267
|
+
${ADMIN_LOG_INDEXES_SQL}
|
|
1105
1268
|
-- Operational failures (#559): what the platform could NOT do. Unlike the
|
|
1106
1269
|
-- never-swept admin log above, this is retention-bounded telemetry, pruned
|
|
1107
1270
|
-- on write (OPS_FAILURE_RETENTION_DAYS). reference carries the upstream
|
|
@@ -1402,6 +1565,11 @@ export class SqliteScopeHost {
|
|
|
1402
1565
|
if (this.modules.has(manifest.id)) {
|
|
1403
1566
|
throw new Error(`module already registered: ${manifest.id}`);
|
|
1404
1567
|
}
|
|
1568
|
+
// #119: refused before anything is recorded, so a module the trash rules refuse leaves nothing.
|
|
1569
|
+
const trashTargets = registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
|
|
1570
|
+
// #2068: refused here, before anything is recorded, when the module claims an erasure it
|
|
1571
|
+
// cannot deliver (a hook with no reach declared, a `custom` entity with no hook).
|
|
1572
|
+
const erasure = moduleErasurePlan(registration);
|
|
1405
1573
|
const migrations = registration.migrations ?? [];
|
|
1406
1574
|
const seen = new Set();
|
|
1407
1575
|
for (const m of migrations) {
|
|
@@ -1492,11 +1660,15 @@ export class SqliteScopeHost {
|
|
|
1492
1660
|
// The order the kernel writes once (#1677): authored, then search, then list indexes.
|
|
1493
1661
|
// The two derived sets are still computed above, for the duplicate-version refusals.
|
|
1494
1662
|
migrations: moduleMigrations(registration),
|
|
1663
|
+
steps: migrationSteps(registration),
|
|
1495
1664
|
consumers,
|
|
1496
1665
|
schedules: manifest.schedules ?? [],
|
|
1497
1666
|
freshness: manifest.freshness ?? [],
|
|
1498
1667
|
peers: manifest.peers ?? [],
|
|
1668
|
+
purgeOnlyKeys: purgeOnlyKeysOf(manifest.schedules ?? []),
|
|
1499
1669
|
});
|
|
1670
|
+
if (erasure)
|
|
1671
|
+
this.erasurePlans.push(erasure);
|
|
1500
1672
|
for (const rel of manifest.entityRelations ?? []) {
|
|
1501
1673
|
const parents = this.relations.get(rel.entityType) ?? new Set();
|
|
1502
1674
|
parents.add(rel.parentType);
|
|
@@ -1583,6 +1755,9 @@ export class SqliteScopeHost {
|
|
|
1583
1755
|
if (declaredOptOuts.includes(name) && this.operations.has(name)) {
|
|
1584
1756
|
this.operationIdempotencyOptOut.add(name);
|
|
1585
1757
|
}
|
|
1758
|
+
const target = trashTargets.get(name);
|
|
1759
|
+
if (target && this.operations.has(name))
|
|
1760
|
+
this.operationTarget.set(name, target);
|
|
1586
1761
|
}
|
|
1587
1762
|
}
|
|
1588
1763
|
defineOperation(name, handler) {
|
|
@@ -1961,7 +2136,7 @@ export class SqliteScopeHost {
|
|
|
1961
2136
|
const actingAs = (operation) => authority.kind === 'capability-session'
|
|
1962
2137
|
? {
|
|
1963
2138
|
kind: 'capability',
|
|
1964
|
-
id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation),
|
|
2139
|
+
id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation, true),
|
|
1965
2140
|
}
|
|
1966
2141
|
: authority;
|
|
1967
2142
|
const node = { tenantId: rt.tenantId, scopeId: rt.scopeId };
|
|
@@ -2246,13 +2421,16 @@ export class SqliteScopeHost {
|
|
|
2246
2421
|
// A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
|
|
2247
2422
|
// not keep at all; they are skipped by name, and any other unknown spine table is refused.
|
|
2248
2423
|
const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
|
|
2424
|
+
// Comment-blanked (#2068), as the Durable Object replays it: the dump's DDL is
|
|
2425
|
+
// `sqlite_master.sql` verbatim, and `prepare` still compiles exactly one statement.
|
|
2249
2426
|
for (const t of loadable)
|
|
2250
2427
|
if (!isSpineTable(t.name))
|
|
2251
|
-
db.prepare(t.ddl).run();
|
|
2252
|
-
db
|
|
2428
|
+
db.prepare(blankSqlComments(t.ddl)).run();
|
|
2429
|
+
execSqlStatements(db, KERNEL_DDL);
|
|
2253
2430
|
this.ensureSpineColumns(db);
|
|
2254
2431
|
const columnsOf = (name) => builtColumnsOf(db, name);
|
|
2255
2432
|
assertSpineTablesBuilt(loadable.map((t) => t.name), columnsOf);
|
|
2433
|
+
assertJournalDumpCoherent(loadable);
|
|
2256
2434
|
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
2257
2435
|
// untyped column the checker never reads.
|
|
2258
2436
|
for (const t of loadable) {
|
|
@@ -2268,28 +2446,15 @@ export class SqliteScopeHost {
|
|
|
2268
2446
|
for (const row of t.rows)
|
|
2269
2447
|
stmt.run(...row);
|
|
2270
2448
|
}
|
|
2271
|
-
//
|
|
2272
|
-
//
|
|
2273
|
-
//
|
|
2274
|
-
//
|
|
2275
|
-
//
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
}
|
|
2281
|
-
// #119: the guard triggers went with the dropped table. Put back AFTER the rows, which
|
|
2282
|
-
// may legitimately arrive archived or trashed.
|
|
2283
|
-
for (const plan of this.statePlans.values()) {
|
|
2284
|
-
if (present.has(plan.table))
|
|
2285
|
-
db.exec(entityStateTriggerDdl(plan));
|
|
2286
|
-
}
|
|
2287
|
-
// #811 / #119: the derived list indexes went with it too, and a load never put them back —
|
|
2288
|
-
// an archivable entity's partial indexes are part of what the kernel checks after DDL.
|
|
2289
|
-
for (const plan of this.listPlans.values()) {
|
|
2290
|
-
if (present.has(plan.table))
|
|
2291
|
-
db.exec(listIndexDdl(plan));
|
|
2292
|
-
}
|
|
2449
|
+
// #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
|
|
2450
|
+
// sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows that are
|
|
2451
|
+
// gone. Put back, and the search index rebuilt, AFTER the rows — which may legitimately
|
|
2452
|
+
// arrive archived or trashed — as the dump's own journal says they were derived (#2090).
|
|
2453
|
+
// A plan whose table this dump did not carry is skipped: a restore must not invent one.
|
|
2454
|
+
repairDerivedObjects(spineSql(db), (ddl) => execSqlStatements(db, ddl), this.derivedPlans(), {
|
|
2455
|
+
after: 'the restored dump',
|
|
2456
|
+
absentTable: 'skip',
|
|
2457
|
+
});
|
|
2293
2458
|
// #1575: attachment text is not in a dump, so a load left it as it was. Drop the
|
|
2294
2459
|
// text of attachments the dump did not bring back, and queue extraction for those
|
|
2295
2460
|
// it brought back without text — the bytes decide what that run finds.
|
|
@@ -2333,10 +2498,11 @@ export class SqliteScopeHost {
|
|
|
2333
2498
|
}
|
|
2334
2499
|
// The frontier came in with the dump — refresh the cached applied-migration set so
|
|
2335
2500
|
// a later bind/migrate builds on the loaded state, not the previous one.
|
|
2336
|
-
rt.appliedMigrations
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2501
|
+
rt.appliedMigrations = readAppliedMigrations(db);
|
|
2502
|
+
// The directory is a separate database, but the actor serializes the load
|
|
2503
|
+
// and the next invocation. Old locators must be gone before that next turn.
|
|
2504
|
+
this.directory.prepare('DELETE FROM private_continuation_positions WHERE scope_id = ?').run(scopeId);
|
|
2505
|
+
this.directory.prepare('DELETE FROM private_continuation_keys WHERE scope_id = ?').run(scopeId);
|
|
2340
2506
|
});
|
|
2341
2507
|
}
|
|
2342
2508
|
async restoreScope(actor, tenantId, scopeId, dump, opts) {
|
|
@@ -2399,8 +2565,13 @@ export class SqliteScopeHost {
|
|
|
2399
2565
|
// orphaned bytes with no record (the §9 hazard).
|
|
2400
2566
|
this.directory.prepare('DELETE FROM hostnames WHERE scope_id = ?').run(scopeId);
|
|
2401
2567
|
rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
|
|
2402
|
-
|
|
2403
|
-
|
|
2568
|
+
this.directory.transaction(() => {
|
|
2569
|
+
this.directory.prepare('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?').run(scopeId);
|
|
2570
|
+
forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
2571
|
+
this.directory.prepare('DELETE FROM private_continuation_positions WHERE scope_id = ?').run(scopeId);
|
|
2572
|
+
this.directory.prepare('DELETE FROM private_continuation_keys WHERE scope_id = ?').run(scopeId);
|
|
2573
|
+
this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
|
|
2574
|
+
})();
|
|
2404
2575
|
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
2405
2576
|
forkedFrom: rec.forkedFrom,
|
|
2406
2577
|
forkedAt: rec.forkedAt,
|
|
@@ -2540,19 +2711,27 @@ export class SqliteScopeHost {
|
|
|
2540
2711
|
// Same lifecycle gates as the principal door, and reached the same way: a
|
|
2541
2712
|
// suspended tenant refuses a support session exactly as it refuses a user.
|
|
2542
2713
|
const scope = this.directory
|
|
2543
|
-
.prepare('SELECT tenant_id
|
|
2714
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
2544
2715
|
.get(scopeId);
|
|
2545
2716
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
2546
2717
|
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2547
2718
|
}
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2719
|
+
const refusal = this.scopeWorkRefusal(tenantId, scopeId);
|
|
2720
|
+
if (refusal)
|
|
2721
|
+
throw substratError('conflict', refusal, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2551
2722
|
const rt = this.runtime(tenantId, scopeId);
|
|
2552
2723
|
await this.applyPendingMigrations(rt);
|
|
2553
2724
|
return this.buildStub(tenantId, scopeId, rt, asPrincipal(record.principal), options, record.id);
|
|
2554
2725
|
}
|
|
2555
2726
|
async getSystemScope(moduleId, tenantId, scopeId) {
|
|
2727
|
+
return this.openSystemScope(moduleId, tenantId, scopeId, false);
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* The system door's stub. `purge` (#119) is set only by `runPurgeSweep`, which never hands the
|
|
2731
|
+
* stub on: it is the one stub whose invokes may use the module's purge-only keys, and only on
|
|
2732
|
+
* the purge operation. Every stub any caller can get is built with it off.
|
|
2733
|
+
*/
|
|
2734
|
+
async openSystemScope(moduleId, tenantId, scopeId, purge) {
|
|
2556
2735
|
// The scheduler's door (#383) — mirror of getConnectorScope. The module must be
|
|
2557
2736
|
// registered on this host (a schedule can only invoke its own vertical's ops),
|
|
2558
2737
|
// and the scope must be active. Authority is then an ordinary check against
|
|
@@ -2561,17 +2740,17 @@ export class SqliteScopeHost {
|
|
|
2561
2740
|
throw substratError('not_found', `module not registered on this host: ${moduleId}`);
|
|
2562
2741
|
}
|
|
2563
2742
|
const scope = this.directory
|
|
2564
|
-
.prepare('SELECT tenant_id
|
|
2743
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
2565
2744
|
.get(scopeId);
|
|
2566
2745
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
2567
2746
|
throw substratError('not_found', `unknown scope: ${scopeId}`);
|
|
2568
2747
|
}
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2748
|
+
const refusal = this.scopeWorkRefusal(tenantId, scopeId);
|
|
2749
|
+
if (refusal)
|
|
2750
|
+
throw substratError('conflict', refusal, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2572
2751
|
const rt = this.runtime(tenantId, scopeId);
|
|
2573
2752
|
await this.applyPendingMigrations(rt);
|
|
2574
|
-
return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
|
|
2753
|
+
return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId }, undefined, undefined, purge);
|
|
2575
2754
|
}
|
|
2576
2755
|
/**
|
|
2577
2756
|
* #1705: the producer's release, decided by THIS deployment's own declarations. The admin
|
|
@@ -2833,6 +3012,20 @@ export class SqliteScopeHost {
|
|
|
2833
3012
|
await this.applyPendingMigrations(rt);
|
|
2834
3013
|
return rt;
|
|
2835
3014
|
}
|
|
3015
|
+
/** The directory's current hold, read again when background work is about to run. */
|
|
3016
|
+
scopeWorkRefusal(tenantId, scopeId, allowProvisioning = false) {
|
|
3017
|
+
const row = this.directory
|
|
3018
|
+
.prepare(`SELECT s.status AS scope, t.status AS tenant
|
|
3019
|
+
FROM scopes s JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
3020
|
+
WHERE s.scope_id = ? AND s.tenant_id = ?`)
|
|
3021
|
+
.get(scopeId, tenantId);
|
|
3022
|
+
if (!row)
|
|
3023
|
+
return `unknown scope for tenant: (${tenantId}, ${scopeId})`;
|
|
3024
|
+
// A connector can serve provisioning work; suspension still holds it.
|
|
3025
|
+
if (allowProvisioning && row.scope === 'provisioning' && row.tenant === 'active')
|
|
3026
|
+
return null;
|
|
3027
|
+
return lifecycleRefusal(row, { tenantId, scopeId });
|
|
3028
|
+
}
|
|
2836
3029
|
/**
|
|
2837
3030
|
* The exchange (#1672) — a secret traded for a session, or for the principal a `become`
|
|
2838
3031
|
* capability yields. One serialized scope task and one transaction, so the use it takes
|
|
@@ -3029,10 +3222,7 @@ export class SqliteScopeHost {
|
|
|
3029
3222
|
const report = { checks: [] };
|
|
3030
3223
|
if (!this.modules.has(moduleId))
|
|
3031
3224
|
return report;
|
|
3032
|
-
|
|
3033
|
-
.prepare('SELECT status FROM scopes WHERE scope_id = ? AND tenant_id = ?')
|
|
3034
|
-
.get(scopeId, tenantId);
|
|
3035
|
-
if (!scope || scope.status !== 'active')
|
|
3225
|
+
if (this.scopeWorkRefusal(tenantId, scopeId))
|
|
3036
3226
|
return report;
|
|
3037
3227
|
const rt = this.runtime(tenantId, scopeId);
|
|
3038
3228
|
await this.applyPendingMigrations(rt);
|
|
@@ -3109,10 +3299,7 @@ export class SqliteScopeHost {
|
|
|
3109
3299
|
// not this driver's problem to raise — it simply has nothing due (the sweep
|
|
3110
3300
|
// already enumerated `active` scopes; this guards a race where one archived
|
|
3111
3301
|
// between enumeration and here).
|
|
3112
|
-
|
|
3113
|
-
.prepare('SELECT status FROM scopes WHERE scope_id = ? AND tenant_id = ?')
|
|
3114
|
-
.get(scopeId, tenantId);
|
|
3115
|
-
if (!scope || scope.status !== 'active')
|
|
3302
|
+
if (this.scopeWorkRefusal(tenantId, scopeId))
|
|
3116
3303
|
return report;
|
|
3117
3304
|
const rt = this.runtime(tenantId, scopeId);
|
|
3118
3305
|
await this.applyPendingMigrations(rt);
|
|
@@ -3186,19 +3373,42 @@ export class SqliteScopeHost {
|
|
|
3186
3373
|
const startedAt = Date.now();
|
|
3187
3374
|
let emitted;
|
|
3188
3375
|
let failure;
|
|
3376
|
+
// #119, #2096: more of a purge horizon's lap is due — the schedule stays due, so the next pass continues it.
|
|
3377
|
+
let stillDue = false;
|
|
3189
3378
|
try {
|
|
3190
|
-
|
|
3191
|
-
|
|
3379
|
+
if (schedule.purge) {
|
|
3380
|
+
// #119: a purge horizon's schedule runs its operation once per due entity, never once.
|
|
3381
|
+
const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation);
|
|
3382
|
+
stillDue = purgeStillDue(pass);
|
|
3383
|
+
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
3384
|
+
report.errors.push(...outcome.errors);
|
|
3385
|
+
if (outcome.failure)
|
|
3386
|
+
throw outcome.failure;
|
|
3387
|
+
}
|
|
3388
|
+
else {
|
|
3389
|
+
const stub = await this.getSystemScope(moduleId, tenantId, scopeId);
|
|
3390
|
+
await stub.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
|
|
3391
|
+
}
|
|
3192
3392
|
report.fired += 1;
|
|
3193
3393
|
}
|
|
3194
3394
|
catch (err) {
|
|
3395
|
+
// The system door can meet a suspension after the cadence gate. A refused
|
|
3396
|
+
// fire did not run, so leave its row due for the first live pass.
|
|
3397
|
+
if (isSubstratError(err) && err.code === 'conflict' && err.extensions.reason === SCOPE_GATE_REASONS.notActive) {
|
|
3398
|
+
report.lifecycleHeld = true;
|
|
3399
|
+
report.skipped += 1;
|
|
3400
|
+
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
3401
|
+
continue;
|
|
3402
|
+
}
|
|
3195
3403
|
status = 'failed';
|
|
3196
3404
|
failure = { error: err };
|
|
3197
3405
|
report.failed += 1;
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3406
|
+
if (!schedule.purge) {
|
|
3407
|
+
report.errors.push({
|
|
3408
|
+
operation: schedule.operation,
|
|
3409
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3202
3412
|
}
|
|
3203
3413
|
lines.write({
|
|
3204
3414
|
kind: 'schedule',
|
|
@@ -3215,6 +3425,9 @@ export class SqliteScopeHost {
|
|
|
3215
3425
|
...(emitted ? { emitted } : {}),
|
|
3216
3426
|
versionId: this.versionId,
|
|
3217
3427
|
});
|
|
3428
|
+
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
3429
|
+
if (stillDue)
|
|
3430
|
+
continue;
|
|
3218
3431
|
// On the scope actor (#1678): the cadence row joined whatever transaction an invoke
|
|
3219
3432
|
// held open, so its rollback forgot the run and the next pass fired it AGAIN.
|
|
3220
3433
|
await rt.actor.turn(() => rt.db
|
|
@@ -3224,11 +3437,65 @@ export class SqliteScopeHost {
|
|
|
3224
3437
|
last_status = excluded.last_status,
|
|
3225
3438
|
invocation_id = excluded.invocation_id`)
|
|
3226
3439
|
.run(schedule.operation, new Date(now).toISOString(), status, invocationId));
|
|
3227
|
-
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
3228
3440
|
}
|
|
3229
3441
|
lines.end();
|
|
3230
3442
|
return report;
|
|
3231
3443
|
}
|
|
3444
|
+
/**
|
|
3445
|
+
* One pass of a purge horizon's schedule on one scope (#119) — the Durable Object's
|
|
3446
|
+
* `runPurgeSweep`, mirrored. The caller names only which schedule: the module and the entity come
|
|
3447
|
+
* from the registered schedule, the cutoff from this host's clock and the declared horizon, the
|
|
3448
|
+
* ids from the scope's own bin, read in a turn. Then the purge operation is invoked once per id
|
|
3449
|
+
* through a purge stub (`openSystemScope`) that never leaves this method, each in its own
|
|
3450
|
+
* transaction and under the cutoff the host re-checks inside it (`refuseTrashedTarget`).
|
|
3451
|
+
*/
|
|
3452
|
+
async runPurgeSweep(moduleId, tenantId, scopeId, operation) {
|
|
3453
|
+
const entityType = this.modules
|
|
3454
|
+
.get(moduleId)
|
|
3455
|
+
?.schedules.find((s) => s.operation === operation && s.purge)?.purge?.entityType;
|
|
3456
|
+
if (entityType === undefined)
|
|
3457
|
+
throw substratError('not_found', `no purge schedule of ${moduleId} runs ${operation}`);
|
|
3458
|
+
// The pair first, from the directory, before the scope's storage is opened for it.
|
|
3459
|
+
const owner = this.directory.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
3460
|
+
if (!owner || owner.tenant_id !== tenantId)
|
|
3461
|
+
throw substratError('not_found', `unknown scope: ${scopeId}`);
|
|
3462
|
+
this.directory.prepare('DELETE FROM private_continuation_positions WHERE expires_at <= ?')
|
|
3463
|
+
.run(Date.parse(this.clock()));
|
|
3464
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
3465
|
+
await this.applyPendingMigrations(rt);
|
|
3466
|
+
const now = this.clock();
|
|
3467
|
+
// #119: the gate the schedule run applies, applied by the sweep itself (`purgeHeldBy`), and
|
|
3468
|
+
// again inside each purge's transaction. A held scope selects nothing.
|
|
3469
|
+
const due = await rt.actor.turn(() => {
|
|
3470
|
+
const held = purgeHeldBy(this.purgeGateFacts(rt, moduleId, tenantId, scopeId));
|
|
3471
|
+
return held !== null
|
|
3472
|
+
? { held }
|
|
3473
|
+
: purgeDueOf(spineSql(rt.db), this.statePlans, this.operationTarget, operation, entityType, now);
|
|
3474
|
+
});
|
|
3475
|
+
if ('held' in due)
|
|
3476
|
+
return heldPurgePass(due.held);
|
|
3477
|
+
const stub = await this.openSystemScope(moduleId, tenantId, scopeId, true);
|
|
3478
|
+
return runPurgePass(operation, due, async (entityId) => {
|
|
3479
|
+
await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: ulid() });
|
|
3480
|
+
}, (write) => rt.actor.turn(() => write(spineSql(rt.db))));
|
|
3481
|
+
}
|
|
3482
|
+
/**
|
|
3483
|
+
* #119: the purge gate's facts (`PurgeGateFacts`) — the module's switch from the scope's own
|
|
3484
|
+
* storage, and the lifecycle, the primacy and the tenant from this host's directory, which is
|
|
3485
|
+
* their authority here.
|
|
3486
|
+
*/
|
|
3487
|
+
purgeGateFacts(rt, moduleId, tenantId, scopeId) {
|
|
3488
|
+
const row = this.directory
|
|
3489
|
+
.prepare(`SELECT s.tenant_id AS tenant_id, s.status AS scope, t.status AS tenant
|
|
3490
|
+
FROM scopes s LEFT JOIN tenants t ON t.tenant_id = s.tenant_id WHERE s.scope_id = ?`)
|
|
3491
|
+
.get(scopeId);
|
|
3492
|
+
return {
|
|
3493
|
+
switched: systemScheduleState(switchSqlOf(rt.db), moduleId, this.clock()),
|
|
3494
|
+
lifecycle: row ? lifecycleRefusal({ scope: row.scope, tenant: row.tenant ?? 'active' }) : null,
|
|
3495
|
+
copy: !this.isPrimaryInDirectory(scopeId),
|
|
3496
|
+
foreignTenant: row?.tenant_id !== tenantId,
|
|
3497
|
+
};
|
|
3498
|
+
}
|
|
3232
3499
|
/** The stub body, shared by the principal, connection, system, impersonation and capability doors. */
|
|
3233
3500
|
buildStub(tenantId, scopeId, rt,
|
|
3234
3501
|
/**
|
|
@@ -3244,7 +3511,9 @@ export class SqliteScopeHost {
|
|
|
3244
3511
|
* about them with no override branch anywhere — and this is what every
|
|
3245
3512
|
* record additionally keeps, plus the bound a read-only session is held to.
|
|
3246
3513
|
*/
|
|
3247
|
-
sessionId
|
|
3514
|
+
sessionId,
|
|
3515
|
+
/** #119: a purge stub — `openSystemScope`'s, built only by `runPurgeSweep`, never handed on. */
|
|
3516
|
+
purge = false) {
|
|
3248
3517
|
const operations = this.operations;
|
|
3249
3518
|
return {
|
|
3250
3519
|
tenantId,
|
|
@@ -3258,6 +3527,15 @@ export class SqliteScopeHost {
|
|
|
3258
3527
|
: authority.kind,
|
|
3259
3528
|
invoke: async (operation, input, invokeOptions) => {
|
|
3260
3529
|
const handler = operations.get(operation);
|
|
3530
|
+
// #119: purge authority is never an invoke option — only a purge stub, which
|
|
3531
|
+
// `runPurgeSweep` builds and keeps, purges — and an options object naming a cutoff is
|
|
3532
|
+
// refused outright.
|
|
3533
|
+
try {
|
|
3534
|
+
assertNoCallerPurge(invokeOptions);
|
|
3535
|
+
}
|
|
3536
|
+
catch (err) {
|
|
3537
|
+
return Promise.reject(err);
|
|
3538
|
+
}
|
|
3261
3539
|
// `not_found`, not a bare throw (#113) — the same code `adapter-cloudflare`
|
|
3262
3540
|
// gives it, so a demo and a hosted vertical answer 404 for the same reason.
|
|
3263
3541
|
if (!handler)
|
|
@@ -3369,7 +3647,15 @@ export class SqliteScopeHost {
|
|
|
3369
3647
|
const minted = [];
|
|
3370
3648
|
// Fresh per operation: the context carries the K-34 authorization accumulator,
|
|
3371
3649
|
// which must not leak across operations (invokes are serialized per scope).
|
|
3372
|
-
|
|
3650
|
+
// #119: the keys the module's system principal holds only for its purge schedules are
|
|
3651
|
+
// withheld from every call but the purge sweep's own invoke of the purge operation.
|
|
3652
|
+
const target = this.operationTarget.get(operation);
|
|
3653
|
+
// Through the system door only: the sweep's mark on any other subject's call means nothing.
|
|
3654
|
+
const purging = purge && subject.kind === 'system';
|
|
3655
|
+
const withheld = subject.kind === 'system'
|
|
3656
|
+
? withheldKeysFor(this.modules.get(subject.id)?.purgeOnlyKeys, target, purging)
|
|
3657
|
+
: undefined;
|
|
3658
|
+
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted, undefined, undefined, withheld);
|
|
3373
3659
|
const clonedInput = structuredClone(input);
|
|
3374
3660
|
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
3375
3661
|
// declaration. BEFORE `BEGIN`, so a malformed call never opens a
|
|
@@ -3436,11 +3722,28 @@ export class SqliteScopeHost {
|
|
|
3436
3722
|
// so the only loss is effort on a path that was already failing.
|
|
3437
3723
|
const ref = guarded ? this.concurrencyRef(operation, guarded, parsed) : undefined;
|
|
3438
3724
|
const seen = ref && invokeOptions?.ifMatch !== undefined ? this.versionAt(rt, ref) : undefined;
|
|
3725
|
+
// #119: an operation on a TRASHED entity it did not declare it reaches is refused
|
|
3726
|
+
// here, before the guards and the handler — the declared key first, so a caller
|
|
3727
|
+
// without it meets the same `forbidden` an active entity gives (`entity-trash.ts`).
|
|
3728
|
+
await refuseTrashedTarget({ sql: spineSql(rt.db), plans: this.statePlans, check: ctx.check }, operation, target, parsed, purging ? { now: this.clock(), gate: this.purgeGateFacts(rt, subject.id, tenantId, scopeId) } : undefined);
|
|
3439
3729
|
// Manifest guards (K-17): pre-conditions, inside the operation's own
|
|
3440
3730
|
// transaction, before the handler. A throw here blocks the operation
|
|
3441
3731
|
// and rolls back exactly like a handler throw — fail closed.
|
|
3442
3732
|
await this.runGuards(operation, ctx, parsed);
|
|
3443
3733
|
result = await handler(ctx, parsed);
|
|
3734
|
+
// #119: the purge gate's AUTHORITATIVE read. Lifecycle, primacy and tenant live in the
|
|
3735
|
+
// directory, which this transaction does not cover and whose writers (a suspend, a
|
|
3736
|
+
// tenant status, a reclassification) do not take the scope's actor — so a hold could
|
|
3737
|
+
// commit while the handler above was awaiting. From here to `COMMIT` nothing awaits:
|
|
3738
|
+
// better-sqlite3 is synchronous and this process's JS single-threaded, so nothing in
|
|
3739
|
+
// this process — another host on the same directory included — can write between this
|
|
3740
|
+
// read and the commit of the delete; and this process owns the directory
|
|
3741
|
+
// (`claimHostDir`), so no other process can either.
|
|
3742
|
+
if (purging) {
|
|
3743
|
+
const held = purgeHeldBy(this.purgeGateFacts(rt, subject.id, tenantId, scopeId));
|
|
3744
|
+
if (held !== null)
|
|
3745
|
+
throw substratError('conflict', `${operation}: ${held}`, { reason: 'purge_held' });
|
|
3746
|
+
}
|
|
3444
3747
|
if (ref && invokeOptions?.ifMatch !== undefined) {
|
|
3445
3748
|
assertIfMatch(ref, invokeOptions.ifMatch, seen ?? null);
|
|
3446
3749
|
}
|
|
@@ -3593,6 +3896,7 @@ export class SqliteScopeHost {
|
|
|
3593
3896
|
for (const rt of [...this.scopes.values()])
|
|
3594
3897
|
this.closeRuntime(rt);
|
|
3595
3898
|
this.directory.close();
|
|
3899
|
+
this.owner.release();
|
|
3596
3900
|
}
|
|
3597
3901
|
// -------------------------------------------------------------------------
|
|
3598
3902
|
// Event dispatch (testrun spec §9.2.3): at-least-once, kernel-journaled,
|
|
@@ -3659,6 +3963,12 @@ export class SqliteScopeHost {
|
|
|
3659
3963
|
return report;
|
|
3660
3964
|
}
|
|
3661
3965
|
async dispatchExecutorsPass(rt, invocationId, report, lines, outcomes) {
|
|
3966
|
+
// A request that started while live may commit after a suspension. Its outbox
|
|
3967
|
+
// stays due without an attempt, just as a scheduled retry does.
|
|
3968
|
+
if (this.scopeWorkRefusal(rt.tenantId, rt.scopeId)) {
|
|
3969
|
+
report.lifecycleHeld = true;
|
|
3970
|
+
return;
|
|
3971
|
+
}
|
|
3662
3972
|
const now = new Date().toISOString();
|
|
3663
3973
|
const scope = this.executorScope(rt);
|
|
3664
3974
|
// #2005: a non-primary scope (a fork, a snapshot, a preview of either kind) causes no
|
|
@@ -3682,6 +3992,10 @@ export class SqliteScopeHost {
|
|
|
3682
3992
|
ORDER BY o.id`)
|
|
3683
3993
|
.all(deliveryId, executor.eventType, now);
|
|
3684
3994
|
for (const row of rows) {
|
|
3995
|
+
if (this.scopeWorkRefusal(rt.tenantId, rt.scopeId)) {
|
|
3996
|
+
report.lifecycleHeld = true;
|
|
3997
|
+
return;
|
|
3998
|
+
}
|
|
3685
3999
|
report.attempted += 1;
|
|
3686
4000
|
// #1901: this attempt's line. Its number is the one the journal records, which
|
|
3687
4001
|
// `recordExecutorDelivery` answers — the Durable Object's RPC does the same.
|
|
@@ -3805,6 +4119,9 @@ export class SqliteScopeHost {
|
|
|
3805
4119
|
return isPrimaryScopeRow(this.directory.prepare('SELECT kind, forked_from FROM scopes WHERE scope_id = ?').get(scopeId));
|
|
3806
4120
|
}
|
|
3807
4121
|
async drainDue(tenantId, scopeId) {
|
|
4122
|
+
if (this.scopeWorkRefusal(tenantId, scopeId)) {
|
|
4123
|
+
return { attempted: 0, delivered: 0, retrying: 0, deadLettered: 0, lifecycleHeld: true };
|
|
4124
|
+
}
|
|
3808
4125
|
const rt = this.runtime(tenantId, scopeId);
|
|
3809
4126
|
await this.applyPendingMigrations(rt);
|
|
3810
4127
|
// #1525: null, and honestly so — a sweep is not a call. An attempt this pass makes
|
|
@@ -3989,13 +4306,11 @@ export class SqliteScopeHost {
|
|
|
3989
4306
|
this.attachmentExtractors, this.attachmentTextBounds);
|
|
3990
4307
|
}
|
|
3991
4308
|
async startJobRun(tenantId, scopeId, input) {
|
|
3992
|
-
const rt = this.
|
|
3993
|
-
await this.applyPendingMigrations(rt);
|
|
4309
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
3994
4310
|
return jobRunOf(await startJobRun(this.jobStore(rt), input, ulid, this.clock));
|
|
3995
4311
|
}
|
|
3996
4312
|
async runDueJobs(tenantId, scopeId, options) {
|
|
3997
|
-
const rt = this.
|
|
3998
|
-
await this.applyPendingMigrations(rt);
|
|
4313
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
3999
4314
|
// #1575: the kernel's extraction jobs are this host's own, bound to this scope — no
|
|
4000
4315
|
// deployment registers them, and none can shadow them. The backfill starts here, on
|
|
4001
4316
|
// the drive, so a scope's first request never pays for attachments that predate it.
|
|
@@ -4027,6 +4342,9 @@ export class SqliteScopeHost {
|
|
|
4027
4342
|
// directory, so a node control plane can drain routed intents exactly as the
|
|
4028
4343
|
// Cloudflare one does; the context build is the same one `dispatchExecutors` hands
|
|
4029
4344
|
// an in-process connector.
|
|
4345
|
+
const refusal = this.scopeWorkRefusal(tenantId, scopeId, true);
|
|
4346
|
+
if (refusal)
|
|
4347
|
+
throw substratError('conflict', refusal, { reason: SCOPE_GATE_REASONS.notActive });
|
|
4030
4348
|
const rt = this.runtime(tenantId, scopeId);
|
|
4031
4349
|
// `false`: this path deliberately does NOT enqueue, so nothing is held and
|
|
4032
4350
|
// the connection's reads take an ordinary serialized turn. Built on a view bound to
|
|
@@ -4101,17 +4419,17 @@ export class SqliteScopeHost {
|
|
|
4101
4419
|
// Nothing pending FOR THIS HOST → noop, and deliberately no state write: a
|
|
4102
4420
|
// host that does not run the scope's modules must never clear (or overwrite)
|
|
4103
4421
|
// a failure recorded by the deployment that does.
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
}
|
|
4110
|
-
}
|
|
4422
|
+
// A scope that applied different SQL (#2066) is not a noop: the pass below fails it.
|
|
4423
|
+
const plan = await planMigrations(this.modules.values(), rt.appliedMigrations);
|
|
4424
|
+
const pending = plan.pending.length > 0 || plan.diverged !== undefined;
|
|
4425
|
+
// #2090: with nothing pending, the pass still checks and repairs what the kernel derived —
|
|
4426
|
+
// the one way a scope stripped before that check existed is ever put right.
|
|
4111
4427
|
if (!pending)
|
|
4112
|
-
|
|
4428
|
+
rt.derivedRepaired = false;
|
|
4113
4429
|
try {
|
|
4114
|
-
await this.applyPendingMigrations(rt);
|
|
4430
|
+
await this.applyPendingMigrations(rt, plan);
|
|
4431
|
+
if (!pending)
|
|
4432
|
+
return { status: 'noop' };
|
|
4115
4433
|
return { status: 'migrated', schemaVersion: String(rt.appliedMigrations.size) };
|
|
4116
4434
|
}
|
|
4117
4435
|
catch {
|
|
@@ -4169,21 +4487,22 @@ export class SqliteScopeHost {
|
|
|
4169
4487
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
4170
4488
|
const rt = this.runtime(tenantId, scopeId);
|
|
4171
4489
|
await this.applyPendingMigrations(rt);
|
|
4172
|
-
await rt.actor.enqueue(() => {
|
|
4173
|
-
//
|
|
4174
|
-
//
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4490
|
+
await rt.actor.enqueue(async () => {
|
|
4491
|
+
// The kernel's one settle (#2102): compare-and-set on `pending` (#1600 review), and the
|
|
4492
|
+
// outcome event written in the same transaction, only by the settle that moved the row.
|
|
4493
|
+
rt.db.transaction(() => settlePlatformRequestIn(switchSqlOf(rt.db), id, outcome, {
|
|
4494
|
+
tenantId: rt.tenantId,
|
|
4495
|
+
scopeId: rt.scopeId,
|
|
4496
|
+
now: new Date().toISOString(),
|
|
4497
|
+
mintEventId: (ms) => rt.mintEventId(ms),
|
|
4498
|
+
version: this.versionId,
|
|
4499
|
+
}))();
|
|
4500
|
+
// What the event announces reaches this scope's consumers and executors in the same
|
|
4501
|
+
// tail, as an import's events do. A settle that wrote none has nothing to deliver.
|
|
4502
|
+
if (outcome.event) {
|
|
4503
|
+
await this.dispatch(rt, null);
|
|
4504
|
+
await this.dispatchExecutors(rt, null);
|
|
4505
|
+
}
|
|
4187
4506
|
});
|
|
4188
4507
|
}
|
|
4189
4508
|
async dispatch(rt,
|
|
@@ -4568,6 +4887,65 @@ export class SqliteScopeHost {
|
|
|
4568
4887
|
auditedFindings(actor, run) {
|
|
4569
4888
|
return this.directory.transaction(() => run(redactionSqlOf(this.directory), (a) => this.recordAdmin(actor, a.action, a.target, a.before, a.after)))();
|
|
4570
4889
|
}
|
|
4890
|
+
/**
|
|
4891
|
+
* A connect-link move and the audit row it hands back, in ONE directory transaction
|
|
4892
|
+
* (connections.md §3.5.4): a consume whose audit write failed must not leave the link spent
|
|
4893
|
+
* with no connection and no record.
|
|
4894
|
+
*/
|
|
4895
|
+
auditedConnectLink(actor, run) {
|
|
4896
|
+
return this.directory.transaction(() => run(redactionSqlOf(this.directory), (a) => this.recordAdmin(actor, a.action, a.target, a.before, a.after)))();
|
|
4897
|
+
}
|
|
4898
|
+
/**
|
|
4899
|
+
* #559: one ops-failure row with its issue and finding, in one transaction (a savepoint when
|
|
4900
|
+
* the caller holds one: #2064's settle writes it beside the `unknown` row it reports).
|
|
4901
|
+
*/
|
|
4902
|
+
writeOpsFailure(entry) {
|
|
4903
|
+
const at = new Date().toISOString();
|
|
4904
|
+
const fingerprint = opsFailureFingerprint(entry);
|
|
4905
|
+
// #1748: the evidence, its issue and its finding in ONE transaction — a detector that
|
|
4906
|
+
// throws takes the evidence with it, so the caller's retry observes it rather than finding
|
|
4907
|
+
// the row already there and the observation lost.
|
|
4908
|
+
this.directory.transaction(() => {
|
|
4909
|
+
this.directory
|
|
4910
|
+
.prepare(`INSERT INTO _substrat_ops_failures
|
|
4911
|
+
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
4912
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
4913
|
+
.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
|
|
4914
|
+
// Bounded here, not trusted from the catch site: one runaway upstream
|
|
4915
|
+
// body must not become a runaway directory row (#559).
|
|
4916
|
+
entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
|
|
4917
|
+
// Prune-on-write (#559): retention lives here, not in a cron — every insert
|
|
4918
|
+
// pays for its own housekeeping, so the table stays bounded even where no
|
|
4919
|
+
// scheduled pass runs (this adapter has none).
|
|
4920
|
+
const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
4921
|
+
this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
|
|
4922
|
+
// The issues materialization (#1233): the group's counters live on their
|
|
4923
|
+
// own row, bumped in the same call, because the evidence self-prunes above
|
|
4924
|
+
// and a count must survive its own exemplars. A fresh arrival regresses a
|
|
4925
|
+
// resolved issue; an ignored one stays ignored — that is what ignoring means.
|
|
4926
|
+
this.directory
|
|
4927
|
+
.prepare(`INSERT INTO _substrat_issues
|
|
4928
|
+
(fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
|
|
4929
|
+
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
4930
|
+
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
4931
|
+
seen_count = seen_count + 1,
|
|
4932
|
+
last_seen = excluded.last_seen,
|
|
4933
|
+
last_message = excluded.last_message,
|
|
4934
|
+
last_tenant_id = excluded.last_tenant_id,
|
|
4935
|
+
last_owner_kind = excluded.last_owner_kind,
|
|
4936
|
+
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
4937
|
+
last_version = COALESCE(excluded.last_version, last_version),
|
|
4938
|
+
origin = COALESCE(excluded.origin, origin),
|
|
4939
|
+
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
|
|
4940
|
+
.run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.tenantId ?? null, issueExemplarOwner(entry.tenantId ?? null), entry.vertical ?? null, entry.version ?? null);
|
|
4941
|
+
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
4942
|
+
this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
|
|
4943
|
+
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
4944
|
+
const finding = findingOfOpsFailure(entry);
|
|
4945
|
+
if (finding)
|
|
4946
|
+
observeFinding(redactionSqlOf(this.directory), finding, at);
|
|
4947
|
+
})();
|
|
4948
|
+
}
|
|
4571
4949
|
recordAdmin(actor, action, target, before, after,
|
|
4572
4950
|
/** When the row says it happened; the wall clock unless the write took its time elsewhere (#1184). */
|
|
4573
4951
|
at = new Date().toISOString()) {
|
|
@@ -4813,6 +5191,9 @@ export class SqliteScopeHost {
|
|
|
4813
5191
|
else {
|
|
4814
5192
|
this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
|
|
4815
5193
|
}
|
|
5194
|
+
if (to === 'archived' || to === 'reaped') {
|
|
5195
|
+
this.directory.prepare('UPDATE peer_bindings SET invalidated = 1 WHERE tenant_id = ? AND target_scope_id = ?').run(tenantId, scopeId);
|
|
5196
|
+
}
|
|
4816
5197
|
if (to === 'reaped')
|
|
4817
5198
|
forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
4818
5199
|
// The audit target carries the scope's vertical (control-plane.md §4.4:
|
|
@@ -4870,10 +5251,7 @@ export class SqliteScopeHost {
|
|
|
4870
5251
|
if (!node.scopeId)
|
|
4871
5252
|
throw new Error('scope tuple requires node.scopeId');
|
|
4872
5253
|
const rt = this.runtime(node.tenantId, node.scopeId);
|
|
4873
|
-
rt.db
|
|
4874
|
-
.prepare(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
|
|
4875
|
-
VALUES (?, ?, ?, ?)`)
|
|
4876
|
-
.run(subject, relation, object, expiresAt ?? null);
|
|
5254
|
+
writeExplicitTupleIn(switchSqlOf(rt.db), subject, relation, object, { kind: 'replace', expiresAt: expiresAt ?? null });
|
|
4877
5255
|
};
|
|
4878
5256
|
const writeGrant = (subject, permission, node, entity, expiresAt) => {
|
|
4879
5257
|
if (entity) {
|
|
@@ -4915,12 +5293,19 @@ export class SqliteScopeHost {
|
|
|
4915
5293
|
// AUDIT FIRST (#1666 review), exactly as the Cloudflare adapter: the intent row before
|
|
4916
5294
|
// anything moves, the outcome row after, on every attempt. The directory and the
|
|
4917
5295
|
// scope's file are separate databases, so a crash between them leaves an intent with
|
|
4918
|
-
// no recorded outcome — never a moved switch with no audit row.
|
|
5296
|
+
// no recorded outcome — never a moved switch with no audit row. #2089: every outcome row
|
|
5297
|
+
// goes through the kernel's `recordAuditOutcome`, exactly as the Cloudflare adapter — one
|
|
5298
|
+
// that cannot be written is logged, never swallowed, and the settle closes its intent.
|
|
4919
5299
|
const operationId = ulid();
|
|
4920
5300
|
const action = switchActionOf(kind, to);
|
|
4921
5301
|
const target = { tenantId, scopeId };
|
|
4922
5302
|
const base = { operationId, ...switchAuditSubject(kind, key, to) };
|
|
4923
5303
|
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
|
|
5304
|
+
const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, action, target, null, { ...base, ...row }), {
|
|
5305
|
+
flow: `${kind}-switch`,
|
|
5306
|
+
operationId,
|
|
5307
|
+
phase: row.phase,
|
|
5308
|
+
}, (message, fields) => console.error(message, fields));
|
|
4924
5309
|
// The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
|
|
4925
5310
|
// ways, and undone if the move throws or holds nothing — see `recordSwitchedOn` and
|
|
4926
5311
|
// (#1823) `recordSwitchedOff` for why that order is the safe one.
|
|
@@ -4934,18 +5319,13 @@ export class SqliteScopeHost {
|
|
|
4934
5319
|
}
|
|
4935
5320
|
catch (err) {
|
|
4936
5321
|
// Nothing has moved: fail the call here, audited — the Cloudflare adapter's posture.
|
|
4937
|
-
|
|
4938
|
-
this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) });
|
|
4939
|
-
}
|
|
4940
|
-
catch {
|
|
4941
|
-
// Best effort: the original error is what the caller must see.
|
|
4942
|
-
}
|
|
5322
|
+
await recordOutcome({ phase: 'failed', error: errorOf(err) });
|
|
4943
5323
|
throw err;
|
|
4944
5324
|
}
|
|
4945
5325
|
// #2045: a newer call on this subject has recorded its position already: this one writes
|
|
4946
5326
|
// nothing, here or in the scope, and says so.
|
|
4947
5327
|
if (recordWriteSuperseded(prior, record)) {
|
|
4948
|
-
|
|
5328
|
+
await recordOutcome({ phase: 'refused', superseded: true });
|
|
4949
5329
|
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
4950
5330
|
}
|
|
4951
5331
|
/** The record's undo, after the scope's move threw or held nothing: retried once, never swallowed. */
|
|
@@ -4984,23 +5364,13 @@ export class SqliteScopeHost {
|
|
|
4984
5364
|
}
|
|
4985
5365
|
catch (err) {
|
|
4986
5366
|
const recordError = undoRecord();
|
|
4987
|
-
|
|
4988
|
-
this.recordAdmin(actor, action, target, null, {
|
|
4989
|
-
...base,
|
|
4990
|
-
phase: 'failed',
|
|
4991
|
-
error: errorOf(err),
|
|
4992
|
-
...(recordError ? { recordError } : {}),
|
|
4993
|
-
});
|
|
4994
|
-
}
|
|
4995
|
-
catch {
|
|
4996
|
-
// Best effort: the original error is what the caller must see.
|
|
4997
|
-
}
|
|
5367
|
+
await recordOutcome({ phase: 'failed', error: errorOf(err), ...(recordError ? { recordError } : {}) });
|
|
4998
5368
|
throw err;
|
|
4999
5369
|
}
|
|
5000
5370
|
// #2045: the scope has applied a newer call on this subject, so this move wrote nothing. The
|
|
5001
5371
|
// record is that newer call's too (its write overwrote this one's), so nothing is undone.
|
|
5002
5372
|
if (outcome.superseded) {
|
|
5003
|
-
|
|
5373
|
+
await recordOutcome({ phase: 'refused', superseded: true });
|
|
5004
5374
|
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
5005
5375
|
}
|
|
5006
5376
|
// A call that held nothing moved nothing (the subject has no authority reaching the
|
|
@@ -5008,8 +5378,7 @@ export class SqliteScopeHost {
|
|
|
5008
5378
|
// switch the subject off the day it is installed; left `on`, the next reconcile of a
|
|
5009
5379
|
// wiped scope would leave it running.
|
|
5010
5380
|
const recordError = outcome.held ? null : undoRecord();
|
|
5011
|
-
|
|
5012
|
-
...base,
|
|
5381
|
+
const unrecorded = await recordOutcome({
|
|
5013
5382
|
phase: outcome.held ? 'applied' : 'refused',
|
|
5014
5383
|
changed: outcome.changed,
|
|
5015
5384
|
permissions: outcome.permissions,
|
|
@@ -5019,30 +5388,32 @@ export class SqliteScopeHost {
|
|
|
5019
5388
|
throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
|
|
5020
5389
|
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
5021
5390
|
}
|
|
5022
|
-
return { operationId, outcome };
|
|
5391
|
+
return { operationId, outcome, ...(unrecorded !== null ? { auditWarning: auditWarningOf('the switch', unrecorded) } : {}) };
|
|
5023
5392
|
};
|
|
5024
5393
|
/** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
|
|
5025
5394
|
const switchSystem = async (actor, raw, to) => {
|
|
5026
5395
|
const input = systemSwitch.parse(raw);
|
|
5027
|
-
const { operationId, outcome } = await switchSubject(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
5396
|
+
const { operationId, outcome, auditWarning } = await switchSubject(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
5028
5397
|
return {
|
|
5029
5398
|
operationId,
|
|
5030
5399
|
moduleId: input.moduleId,
|
|
5031
5400
|
schedules: to,
|
|
5032
5401
|
changed: outcome.changed,
|
|
5033
5402
|
permissions: outcome.permissions,
|
|
5403
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
5034
5404
|
};
|
|
5035
5405
|
};
|
|
5036
5406
|
/** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
|
|
5037
5407
|
const switchPeerAt = async (actor, raw, to) => {
|
|
5038
5408
|
const input = peerSwitch.parse(raw);
|
|
5039
|
-
const { operationId, outcome } = await switchSubject(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
5409
|
+
const { operationId, outcome, auditWarning } = await switchSubject(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
5040
5410
|
return {
|
|
5041
5411
|
operationId,
|
|
5042
5412
|
vertical: input.vertical,
|
|
5043
5413
|
calls: to,
|
|
5044
5414
|
changed: outcome.changed,
|
|
5045
5415
|
permissions: outcome.permissions,
|
|
5416
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
5046
5417
|
};
|
|
5047
5418
|
};
|
|
5048
5419
|
/**
|
|
@@ -5151,6 +5522,8 @@ export class SqliteScopeHost {
|
|
|
5151
5522
|
return result;
|
|
5152
5523
|
};
|
|
5153
5524
|
return {
|
|
5525
|
+
// #2069: a view of the view this admin was built over — the person added, its event kept.
|
|
5526
|
+
attributed: (onBehalfOf) => this.attributed(onBehalfOf).admin,
|
|
5154
5527
|
// #603: fixed at construction — a host built without a box can never store a
|
|
5155
5528
|
// credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
5156
5529
|
canStoreSecrets: isSecretBoxConfigured(this.secretBox),
|
|
@@ -5245,6 +5618,42 @@ export class SqliteScopeHost {
|
|
|
5245
5618
|
await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
|
|
5246
5619
|
this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
5247
5620
|
},
|
|
5621
|
+
grantEntityShape: async (actor, grant) => {
|
|
5622
|
+
const { tenantId, scopeId } = grant.node;
|
|
5623
|
+
this.assertScope(tenantId, scopeId);
|
|
5624
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
5625
|
+
// One unit on the scope actor (#1678): the marker and its keys land together or not at all.
|
|
5626
|
+
await rt.actor.turn(() => rt.db.transaction(() => grantEntityShapeIn(switchSqlOf(rt.db), grant.principalId, grant.entity, grant.permissions))());
|
|
5627
|
+
this.recordAdmin(actor, 'grantEntityShape', { tenantId, scopeId }, null, grant);
|
|
5628
|
+
},
|
|
5629
|
+
reconcileEntityGrantShapes: async (actor, node, shapes, opts) => {
|
|
5630
|
+
const limit = shapeTopUpBatch(opts?.batch);
|
|
5631
|
+
const { tenantId, scopeId } = node;
|
|
5632
|
+
this.assertScope(tenantId, scopeId);
|
|
5633
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
5634
|
+
let toppedUp = 0;
|
|
5635
|
+
let retired = 0;
|
|
5636
|
+
// #2071: one bounded transaction per pass, so a large scope never holds one long; a pass
|
|
5637
|
+
// that did not use its whole budget found everything.
|
|
5638
|
+
for (let done = false; !done;) {
|
|
5639
|
+
const pass = await rt.actor.turn(() => rt.db.transaction(() => topUpEntityGrantShapes(switchSqlOf(rt.db), {
|
|
5640
|
+
tenantId,
|
|
5641
|
+
scopeId,
|
|
5642
|
+
shapes,
|
|
5643
|
+
now: new Date().toISOString(),
|
|
5644
|
+
limit,
|
|
5645
|
+
mintEventId: (ms) => rt.mintEventId(ms),
|
|
5646
|
+
version: this.versionId,
|
|
5647
|
+
}))());
|
|
5648
|
+
toppedUp += pass.toppedUp;
|
|
5649
|
+
retired += pass.retired;
|
|
5650
|
+
done = pass.done;
|
|
5651
|
+
}
|
|
5652
|
+
if (toppedUp > 0 || retired > 0) {
|
|
5653
|
+
this.recordAdmin(actor, 'reconcileEntityGrantShapes', { tenantId, scopeId }, null, { shapes, toppedUp, retired });
|
|
5654
|
+
}
|
|
5655
|
+
return { toppedUp, retired };
|
|
5656
|
+
},
|
|
5248
5657
|
grantToConnection: async (actor, raw) => {
|
|
5249
5658
|
const grant = connectionGrant.parse(raw);
|
|
5250
5659
|
const check = () => {
|
|
@@ -5569,6 +5978,55 @@ export class SqliteScopeHost {
|
|
|
5569
5978
|
forkedFrom: r.forked_from ?? null,
|
|
5570
5979
|
})), tenantId, vertical);
|
|
5571
5980
|
},
|
|
5981
|
+
resolvePeerInstance: async (tenantId, callerScopeId, vertical) => {
|
|
5982
|
+
const binding = this.directory.prepare('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').get(tenantId, callerScopeId, vertical);
|
|
5983
|
+
const rows = this.directory.prepare(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
|
|
5984
|
+
WHERE tenant_id = ? AND vertical = ?`).all(tenantId, vertical);
|
|
5985
|
+
return resolvePeerInstanceFrom(rows.map((r) => ({
|
|
5986
|
+
id: r.scope_id,
|
|
5987
|
+
tenantId: r.tenant_id,
|
|
5988
|
+
vertical: r.vertical,
|
|
5989
|
+
status: r.status,
|
|
5990
|
+
kind: r.kind ?? '',
|
|
5991
|
+
forkedFrom: r.forked_from,
|
|
5992
|
+
})), tenantId, vertical, binding?.target_scope_id ?? null, binding?.invalidated === 1);
|
|
5993
|
+
},
|
|
5994
|
+
peerBinding: async (actor, tenantId, callerScopeId, vertical) => {
|
|
5995
|
+
const row = this.directory.prepare('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').get(tenantId, callerScopeId, vertical);
|
|
5996
|
+
this.recordAccess(actor, 'peerBinding', { tenantId, scopeId: callerScopeId }, { vertical }, row ? 1 : 0);
|
|
5997
|
+
return row ? { tenantId, callerScopeId, vertical, targetScopeId: row.target_scope_id, invalidated: row.invalidated === 1 } : undefined;
|
|
5998
|
+
},
|
|
5999
|
+
peerBindings: async (actor, tenantId, callerScopeId) => {
|
|
6000
|
+
const rows = this.directory.prepare('SELECT vertical, target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? ORDER BY vertical').all(tenantId, callerScopeId);
|
|
6001
|
+
this.recordAccess(actor, 'peerBindings', { tenantId, scopeId: callerScopeId }, null, rows.length);
|
|
6002
|
+
return rows.map((r) => ({ tenantId, callerScopeId, vertical: r.vertical, targetScopeId: r.target_scope_id, invalidated: r.invalidated === 1 }));
|
|
6003
|
+
},
|
|
6004
|
+
setPeerBinding: async (_actor, tenantId, callerScopeId, vertical, targetScopeId) => this.directory.transaction(() => {
|
|
6005
|
+
const caller = this.directory.prepare('SELECT tenant_id, status, kind, forked_from FROM scopes WHERE scope_id = ?').get(callerScopeId);
|
|
6006
|
+
if (!caller || caller.tenant_id !== tenantId || caller.status !== 'active' || !isPrimaryScopeRow(caller)) {
|
|
6007
|
+
throw substratError('not_found', 'calling app is not a live primary scope in this tenant');
|
|
6008
|
+
}
|
|
6009
|
+
if (targetScopeId !== null) {
|
|
6010
|
+
const target = this.directory.prepare('SELECT tenant_id, vertical, status, kind, forked_from FROM scopes WHERE scope_id = ?').get(targetScopeId);
|
|
6011
|
+
if (!target || target.tenant_id !== tenantId || target.vertical !== vertical || target.status !== 'active' || !isPrimaryScopeRow(target)) {
|
|
6012
|
+
throw substratError('not_found', 'target is not a live primary instance of this vertical in this tenant');
|
|
6013
|
+
}
|
|
6014
|
+
}
|
|
6015
|
+
const key = [tenantId, callerScopeId, vertical];
|
|
6016
|
+
const before = this.directory.prepare('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').get(...key);
|
|
6017
|
+
const previous = before?.target_scope_id;
|
|
6018
|
+
if ((previous ?? null) === targetScopeId && before?.invalidated !== 1)
|
|
6019
|
+
return { changed: false, previous: previous ?? null };
|
|
6020
|
+
if (targetScopeId === null) {
|
|
6021
|
+
this.directory.prepare('DELETE FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').run(...key);
|
|
6022
|
+
}
|
|
6023
|
+
else {
|
|
6024
|
+
this.directory.prepare(`INSERT INTO peer_bindings (tenant_id, caller_scope_id, vertical, target_scope_id)
|
|
6025
|
+
VALUES (?, ?, ?, ?) ON CONFLICT (tenant_id, caller_scope_id, vertical)
|
|
6026
|
+
DO UPDATE SET target_scope_id = excluded.target_scope_id, invalidated = 0`).run(...key, targetScopeId);
|
|
6027
|
+
}
|
|
6028
|
+
return { changed: true, previous: previous ?? null };
|
|
6029
|
+
})(),
|
|
5572
6030
|
resolveHostname: async (raw) => {
|
|
5573
6031
|
// The router's per-request read. No actor, not logged — same carve-out as
|
|
5574
6032
|
// resolveIdentity (K-24): this is a machine path, not a staff read.
|
|
@@ -5649,6 +6107,9 @@ export class SqliteScopeHost {
|
|
|
5649
6107
|
installSpec.provisions = parsed.provisions;
|
|
5650
6108
|
if (parsed.sendsEmail)
|
|
5651
6109
|
installSpec.sendsEmail = parsed.sendsEmail;
|
|
6110
|
+
// Where the scope lifecycle is held (#1713) — read by the lifecycle delivery's targets.
|
|
6111
|
+
if (parsed.lifecycle)
|
|
6112
|
+
installSpec.lifecycle = parsed.lifecycle;
|
|
5652
6113
|
if (parsed.surfaces)
|
|
5653
6114
|
installSpec.surfaces = parsed.surfaces;
|
|
5654
6115
|
const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
|
|
@@ -6041,6 +6502,11 @@ export class SqliteScopeHost {
|
|
|
6041
6502
|
if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId) {
|
|
6042
6503
|
throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
|
|
6043
6504
|
}
|
|
6505
|
+
const epoch = this.directory.prepare('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?')
|
|
6506
|
+
.get(tenantId, scopeId);
|
|
6507
|
+
if (opts?.expectedErasureEpoch !== undefined && epoch?.erasure_epoch !== opts.expectedErasureEpoch) {
|
|
6508
|
+
throw substratError('precondition_failed', 'scope erasure changed; reload the scope and retry');
|
|
6509
|
+
}
|
|
6044
6510
|
const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
|
|
6045
6511
|
// #1756: an export an app in this tenant imports, dropped or re-versioned by what this
|
|
6046
6512
|
// scope would run. Before the snapshot, so a refused bind leaves nothing behind.
|
|
@@ -6058,12 +6524,7 @@ export class SqliteScopeHost {
|
|
|
6058
6524
|
await this.snapshotScope(actor, tenantId, scopeId);
|
|
6059
6525
|
}
|
|
6060
6526
|
}
|
|
6061
|
-
|
|
6062
|
-
? this.directory.prepare('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ?')
|
|
6063
|
-
.run(versionId, v.verticalSlug, scopeId)
|
|
6064
|
-
: this.directory.prepare('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ? AND vertical_version_id IS ?')
|
|
6065
|
-
.run(versionId, v.verticalSlug, scopeId, opts.expectedVersionId);
|
|
6066
|
-
if (update.changes === 0) {
|
|
6527
|
+
if (!this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, v.verticalSlug], tenantId, scopeId, opts)) {
|
|
6067
6528
|
throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
|
|
6068
6529
|
}
|
|
6069
6530
|
this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
|
|
@@ -6074,6 +6535,88 @@ export class SqliteScopeHost {
|
|
|
6074
6535
|
...(ack.exportBreak ? { acknowledged: ack } : {}),
|
|
6075
6536
|
});
|
|
6076
6537
|
},
|
|
6538
|
+
recordScopeScriptCopy: async (_actor, tenantId, scopeId, scriptRef, moveId, opts) => {
|
|
6539
|
+
if (!scriptRef || !moveId)
|
|
6540
|
+
throw substratError('conflict', 'copy script and move must name a real script');
|
|
6541
|
+
const result = this.directory.prepare(`INSERT INTO scope_script_copies (tenant_id, scope_id, script_ref, move_id, state, role, load_stamp, lease_until)
|
|
6542
|
+
SELECT tenant_id, scope_id, ?, ?, 'pending', ?, ?, ? FROM scopes
|
|
6543
|
+
WHERE tenant_id = ? AND scope_id = ? AND reap_claimed_at IS NULL
|
|
6544
|
+
ON CONFLICT (tenant_id, scope_id, script_ref, move_id) DO NOTHING`).run(scriptRef, moveId, opts?.role ?? null, opts?.loadStamp ?? null, new Date(Date.now() + (opts?.leaseMs ?? SCOPE_COPY_LEASE_MS)).toISOString(), tenantId, scopeId);
|
|
6545
|
+
if (result.changes > 0)
|
|
6546
|
+
return;
|
|
6547
|
+
const scope = this.directory.prepare('SELECT reap_claimed_at FROM scopes WHERE tenant_id = ? AND scope_id = ?')
|
|
6548
|
+
.get(tenantId, scopeId);
|
|
6549
|
+
if (!scope)
|
|
6550
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
6551
|
+
if (scope.reap_claimed_at !== null)
|
|
6552
|
+
throw substratError('precondition_failed', `scope ${scopeId} is being reaped`);
|
|
6553
|
+
},
|
|
6554
|
+
claimExpiredScopeScriptCopies: async (_actor, input) => {
|
|
6555
|
+
const limit = assertRowLimit('limit', input.limit);
|
|
6556
|
+
return this.directory.transaction(() => {
|
|
6557
|
+
const due = this.directory.prepare(COPY_EXPIRED_SQL).all(input.now, limit);
|
|
6558
|
+
const claimed = [];
|
|
6559
|
+
for (const r of due) {
|
|
6560
|
+
if (this.directory.prepare(COPY_CLAIM_SQL).run(input.owner, input.leaseUntil, r.tenant_id, r.scope_id, r.script_ref, r.move_id, input.now).changes === 0)
|
|
6561
|
+
continue;
|
|
6562
|
+
claimed.push(this.directory.prepare(`SELECT ${SCOPE_SCRIPT_COPY_COLUMNS} FROM scope_script_copies
|
|
6563
|
+
WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ?`).get(r.tenant_id, r.scope_id, r.script_ref, r.move_id));
|
|
6564
|
+
}
|
|
6565
|
+
return claimed.map(scopeScriptCopyOf);
|
|
6566
|
+
})();
|
|
6567
|
+
},
|
|
6568
|
+
beginScopeScriptReap: async (_actor, tenantId, scopeId) => {
|
|
6569
|
+
const result = this.directory.prepare(`UPDATE scopes SET reap_claimed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
6570
|
+
WHERE tenant_id = ? AND scope_id = ? AND reap_claimed_at IS NULL
|
|
6571
|
+
AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS move
|
|
6572
|
+
WHERE move.tenant_id = scopes.tenant_id AND move.scope_id = scopes.scope_id
|
|
6573
|
+
AND move.state = 'pending')`).run(tenantId, scopeId);
|
|
6574
|
+
if (result.changes > 0)
|
|
6575
|
+
return;
|
|
6576
|
+
const scope = this.directory.prepare('SELECT reap_claimed_at FROM scopes WHERE tenant_id = ? AND scope_id = ?')
|
|
6577
|
+
.get(tenantId, scopeId);
|
|
6578
|
+
if (!scope)
|
|
6579
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
6580
|
+
if (scope.reap_claimed_at !== null)
|
|
6581
|
+
return; // a reaper resumes its earlier claim
|
|
6582
|
+
throw substratError('precondition_failed', `scope ${scopeId} has a copy move in flight; retry reap after it settles`);
|
|
6583
|
+
},
|
|
6584
|
+
settleScopeScriptCopy: async (_actor, tenantId, scopeId, scriptRef, moveId, state, marker, opts) => this.directory.prepare(`UPDATE scope_script_copies SET state = ?, load_stamp = ?, revision = ?,
|
|
6585
|
+
last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
6586
|
+
WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ?
|
|
6587
|
+
AND (state <> 'done' OR ? = 'done')
|
|
6588
|
+
${opts?.claimedBy === undefined ? '' : "AND state = 'pending' AND lease_owner = ?"}`).run(state, marker?.loadStamp ?? null, marker?.revision ?? null, tenantId, scopeId, scriptRef, moveId, state, ...(opts?.claimedBy === undefined ? [] : [opts.claimedBy])).changes > 0,
|
|
6589
|
+
touchScopeScriptCopy: async (_actor, tenantId, scopeId, scriptRef, moveId) => {
|
|
6590
|
+
this.directory.prepare(`UPDATE scope_script_copies SET last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
6591
|
+
WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ? AND state = 'eligible'`).run(tenantId, scopeId, scriptRef, moveId);
|
|
6592
|
+
},
|
|
6593
|
+
listScopeScriptCopies: async (_actor, filter) => {
|
|
6594
|
+
const where = [];
|
|
6595
|
+
const args = [];
|
|
6596
|
+
if (filter.tenantId) {
|
|
6597
|
+
where.push('tenant_id = ?');
|
|
6598
|
+
args.push(filter.tenantId);
|
|
6599
|
+
}
|
|
6600
|
+
if (filter.scopeId) {
|
|
6601
|
+
where.push('scope_id = ?');
|
|
6602
|
+
args.push(filter.scopeId);
|
|
6603
|
+
}
|
|
6604
|
+
if (filter.state) {
|
|
6605
|
+
where.push('state = ?');
|
|
6606
|
+
args.push(filter.state);
|
|
6607
|
+
}
|
|
6608
|
+
if (filter.after) {
|
|
6609
|
+
if (!filter.tenantId || !filter.scopeId)
|
|
6610
|
+
throw substratError('validation_failed', 'copy cursor requires tenant and scope');
|
|
6611
|
+
where.push('(script_ref > ? OR (script_ref = ? AND move_id > ?))');
|
|
6612
|
+
args.push(filter.after.scriptRef, filter.after.scriptRef, filter.after.moveId);
|
|
6613
|
+
}
|
|
6614
|
+
const limit = assertRowLimit('limit', filter.limit ?? 100);
|
|
6615
|
+
const order = filter.scopeId ? 'script_ref, move_id' : 'last_attempt_at, tenant_id, scope_id, script_ref';
|
|
6616
|
+
const rows = this.directory.prepare(`SELECT ${SCOPE_SCRIPT_COPY_COLUMNS} FROM scope_script_copies ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
6617
|
+
ORDER BY ${order} LIMIT ?`).all(...args, limit);
|
|
6618
|
+
return rows.map(scopeScriptCopyOf);
|
|
6619
|
+
},
|
|
6077
6620
|
/**
|
|
6078
6621
|
* Record that this scope's provision has now run against `versionId` (#1172).
|
|
6079
6622
|
* Written only after a provision or reconcile succeeded — a receipt, never a hope.
|
|
@@ -6151,9 +6694,9 @@ export class SqliteScopeHost {
|
|
|
6151
6694
|
if (breaks.length > 0)
|
|
6152
6695
|
throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
|
|
6153
6696
|
}
|
|
6154
|
-
this.
|
|
6155
|
-
|
|
6156
|
-
|
|
6697
|
+
if (!this.moveScopeRow('serving_ref = ?', [servingRef], tenantId, scopeId, opts)) {
|
|
6698
|
+
throw substratError('precondition_failed', 'scope erasure or reap changed; reload and retry');
|
|
6699
|
+
}
|
|
6157
6700
|
this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref }, { servingRef, ...(ack.exportBreak ? { acknowledged: ack } : {}) });
|
|
6158
6701
|
},
|
|
6159
6702
|
recordKeptCopyResolution: async (actor, tenantId, scopeId, r) => {
|
|
@@ -6258,10 +6801,20 @@ export class SqliteScopeHost {
|
|
|
6258
6801
|
this.assertScopeReachable(tenantId, scopeId);
|
|
6259
6802
|
const rec = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
6260
6803
|
const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
|
|
6804
|
+
// #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs
|
|
6805
|
+
// and closes the lever's rows exactly as it does the kill switches'.
|
|
6261
6806
|
const replayId = ulid();
|
|
6262
6807
|
const target = { tenantId, scopeId };
|
|
6263
|
-
const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
6808
|
+
const base = { operationId: replayId, replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
6264
6809
|
this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
|
|
6810
|
+
// #2114: every outcome row goes through the kernel's `recordAuditOutcome`, exactly as the
|
|
6811
|
+
// Cloudflare adapter and the kill switches (#2089): logged when it cannot be written, never
|
|
6812
|
+
// swallowed, and never in place of the move's own error or result.
|
|
6813
|
+
const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, ...row }), {
|
|
6814
|
+
flow: 'import-cursor',
|
|
6815
|
+
operationId: replayId,
|
|
6816
|
+
phase: row.phase,
|
|
6817
|
+
}, (message, fields) => console.error(message, fields));
|
|
6265
6818
|
let moved;
|
|
6266
6819
|
try {
|
|
6267
6820
|
const rt = this.runtime(tenantId, scopeId);
|
|
@@ -6271,26 +6824,16 @@ export class SqliteScopeHost {
|
|
|
6271
6824
|
moved = await rt.actor.turn(() => rt.db.transaction(() => moveImportCursor(switchSqlOf(rt.db), { move, source, replayId, now, imports: this.crossVertical.consumes() }))());
|
|
6272
6825
|
}
|
|
6273
6826
|
catch (err) {
|
|
6274
|
-
|
|
6275
|
-
this.recordAdmin(actor, 'moveImportCursor', target, null, {
|
|
6276
|
-
...base,
|
|
6277
|
-
phase: 'failed',
|
|
6278
|
-
error: err instanceof Error ? err.message : String(err),
|
|
6279
|
-
});
|
|
6280
|
-
}
|
|
6281
|
-
catch {
|
|
6282
|
-
// Best effort: the original error is what the caller must see.
|
|
6283
|
-
}
|
|
6827
|
+
await recordOutcome({ phase: 'failed', error: err instanceof Error ? err.message : String(err) });
|
|
6284
6828
|
throw err;
|
|
6285
6829
|
}
|
|
6286
|
-
|
|
6287
|
-
...base,
|
|
6830
|
+
const unrecorded = await recordOutcome({
|
|
6288
6831
|
phase: 'applied',
|
|
6289
6832
|
previous: moved.previous,
|
|
6290
6833
|
cursor: moved.cursor,
|
|
6291
6834
|
archived: moved.archived,
|
|
6292
6835
|
});
|
|
6293
|
-
return moved;
|
|
6836
|
+
return unrecorded === null ? moved : { ...moved, auditWarning: auditWarningOf('the move', unrecorded) };
|
|
6294
6837
|
},
|
|
6295
6838
|
redrainEvents: async (actor, tenantId, scopeId, input) => {
|
|
6296
6839
|
// Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
|
|
@@ -6870,7 +7413,8 @@ export class SqliteScopeHost {
|
|
|
6870
7413
|
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND sql IS NOT NULL
|
|
6871
7414
|
ORDER BY name`)
|
|
6872
7415
|
.all();
|
|
6873
|
-
const
|
|
7416
|
+
const privateTables = new Set(['private_continuation_keys', 'private_continuation_positions']);
|
|
7417
|
+
const tables = defs.filter(({ name }) => !privateTables.has(name)).map(({ name, sql }) => {
|
|
6874
7418
|
const stmt = this.directory.prepare(`SELECT * FROM "${name}"`).raw(true);
|
|
6875
7419
|
const rows = stmt.all();
|
|
6876
7420
|
const columns = stmt.columns().map((c) => c.name);
|
|
@@ -6888,7 +7432,11 @@ export class SqliteScopeHost {
|
|
|
6888
7432
|
// as identifiers, and its `ddl`, never executed here since #1912, is still held to one
|
|
6889
7433
|
// CREATE TABLE. Judged as a whole before the first DROP, so a refused dump leaves the
|
|
6890
7434
|
// directory untouched.
|
|
6891
|
-
|
|
7435
|
+
// Older backup producers and local migration fixtures may include these
|
|
7436
|
+
// tables. Ignore them entirely: neither their DDL nor their rows may set
|
|
7437
|
+
// a restored key epoch or make an old hidden locator valid again.
|
|
7438
|
+
const publicTables = dump.tables.filter((table) => !['private_continuation_keys', 'private_continuation_positions'].includes(table.name.toLowerCase()));
|
|
7439
|
+
assertReplayableDump(publicTables);
|
|
6892
7440
|
// One transaction, foreign keys deferred to commit: the dump is ordered by table NAME
|
|
6893
7441
|
// (which says nothing about references — a `tenants` row can name another as
|
|
6894
7442
|
// `provisioned_by_tenant`), and the DROPs themselves delete rows a populated child would
|
|
@@ -6911,7 +7459,7 @@ export class SqliteScopeHost {
|
|
|
6911
7459
|
this.buildDirectorySchema();
|
|
6912
7460
|
// Refuses a table this code does not build, adds unknown columns bare, loads the rows
|
|
6913
7461
|
// by name, then the legacy scope and #1674 backfills, all inside this transaction.
|
|
6914
|
-
loadDirectoryDump(
|
|
7462
|
+
loadDirectoryDump(publicTables, {
|
|
6915
7463
|
columnsOf: (name) => builtColumnsOf(this.directory, name),
|
|
6916
7464
|
exec: (sql) => this.directory.exec(sql),
|
|
6917
7465
|
now: () => this.clock(),
|
|
@@ -7028,6 +7576,10 @@ export class SqliteScopeHost {
|
|
|
7028
7576
|
// and transaction facts remain". A consumer's timeline still shows that something
|
|
7029
7577
|
// happened, to what, and when; it no longer shows who or what was said.
|
|
7030
7578
|
const db = this.scopeDbFor(tenantId, scopeId);
|
|
7579
|
+
// #2068: the module half reaches the scope's own tables, so they have to exist — the
|
|
7580
|
+
// same migrations an invoke would apply first.
|
|
7581
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
7582
|
+
await this.applyPendingMigrations(rt);
|
|
7031
7583
|
// One instant for the whole erasure, read before the first write: the intent
|
|
7032
7584
|
// tombstones below and the key's own tombstone should not disagree about when a
|
|
7033
7585
|
// person was erased.
|
|
@@ -7035,8 +7587,24 @@ export class SqliteScopeHost {
|
|
|
7035
7587
|
// Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
|
|
7036
7588
|
// invoke held its transaction open, they joined it, and its rollback put the
|
|
7037
7589
|
// person's PII back after this verb had destroyed the key and receipted the erasure.
|
|
7590
|
+
//
|
|
7591
|
+
// And in ONE transaction (#2068): a module's `onSubjectErased` hook that throws rolls
|
|
7592
|
+
// the whole scope side back, the spine redaction with it, and the erasure throws
|
|
7593
|
+
// before the key below is touched — nothing receipts an erasure that did not happen.
|
|
7038
7594
|
const scopeSql = redactionSqlOf(db);
|
|
7039
|
-
const { redacted, intentsRedacted, jobRunsRedacted, text } = await
|
|
7595
|
+
const { redacted, intentsRedacted, jobRunsRedacted, text, vertical } = await rt.actor.turn(() => db.transaction(() => ({
|
|
7596
|
+
// The module half first (#2068): the declared entities, then each hook, with the
|
|
7597
|
+
// search indexes over them under FTS5 secure-delete. The spine half follows in the same
|
|
7598
|
+
// transaction, so the order between them decides nothing but the reading order.
|
|
7599
|
+
vertical: eraseSubjectFromModules({
|
|
7600
|
+
sql: spineSql(db),
|
|
7601
|
+
plans: this.erasurePlans,
|
|
7602
|
+
searchPlans: this.searchPlans.values(),
|
|
7603
|
+
statefulTables: statefulTablesOf(this.statePlans),
|
|
7604
|
+
subjectId,
|
|
7605
|
+
at,
|
|
7606
|
+
migrationSqlOf: (moduleId, version) => this.modules.get(moduleId)?.migrations.find((m) => m.version === version)?.sql,
|
|
7607
|
+
}),
|
|
7040
7608
|
redacted: db
|
|
7041
7609
|
.prepare(`UPDATE _substrat_outbox SET payload = NULL
|
|
7042
7610
|
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
|
|
@@ -7059,7 +7627,7 @@ export class SqliteScopeHost {
|
|
|
7059
7627
|
// The free-text copies (#1632), and the tombstoned intents the directory half
|
|
7060
7628
|
// follows. Last, so those ids include every intent tombstoned above.
|
|
7061
7629
|
text: redactSubjectScopeText(scopeSql, subjectId, at),
|
|
7062
|
-
}));
|
|
7630
|
+
}))());
|
|
7063
7631
|
const { idempotencyResults, intentIds } = text;
|
|
7064
7632
|
// The directory's failure text (#1632) — a drain failure quoting one of those
|
|
7065
7633
|
// intents, an issue's exemplar, a sweep record's error. Before the key, for the
|
|
@@ -7071,6 +7639,7 @@ export class SqliteScopeHost {
|
|
|
7071
7639
|
eventsRedacted: redacted.changes,
|
|
7072
7640
|
intentsRedacted,
|
|
7073
7641
|
jobRunsRedacted,
|
|
7642
|
+
...vertical,
|
|
7074
7643
|
keyDestroyed: existed,
|
|
7075
7644
|
tombstoned: true,
|
|
7076
7645
|
});
|
|
@@ -7080,9 +7649,34 @@ export class SqliteScopeHost {
|
|
|
7080
7649
|
this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
7081
7650
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
7082
7651
|
// an intent payload is a whole event's worth of it.
|
|
7083
|
-
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted + idempotencyResults);
|
|
7652
|
+
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted + idempotencyResults + moduleRowsErased(vertical));
|
|
7084
7653
|
return receipt;
|
|
7085
7654
|
},
|
|
7655
|
+
scopeErasureEpoch: async (_actor, tenantId, scopeId) => {
|
|
7656
|
+
// NULL on every row from before the column (#1722): the same 0 every CAS compares against.
|
|
7657
|
+
const row = this.directory.prepare('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?')
|
|
7658
|
+
.get(tenantId, scopeId);
|
|
7659
|
+
if (!row)
|
|
7660
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
7661
|
+
return row.erasure_epoch;
|
|
7662
|
+
},
|
|
7663
|
+
finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected) => {
|
|
7664
|
+
if (redactions.length === 0)
|
|
7665
|
+
throw substratError('conflict', 'subject erasure has no confirmed scope redaction');
|
|
7666
|
+
const claimed = this.directory.prepare(`UPDATE scopes SET erasure_epoch = COALESCE(erasure_epoch, 0) + 1
|
|
7667
|
+
WHERE tenant_id = ? AND scope_id = ? AND vertical_version_id IS ?
|
|
7668
|
+
AND serving_ref IS ? AND COALESCE(erasure_epoch, 0) = ?
|
|
7669
|
+
AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS copy
|
|
7670
|
+
WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id
|
|
7671
|
+
AND copy.state = 'pending')
|
|
7672
|
+
AND (SELECT COUNT(*) FROM scope_script_copies AS copy
|
|
7673
|
+
WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id) = ?`).run(tenantId, scopeId, expected.versionId, expected.servingRef, expected.epoch, expected.copyCount);
|
|
7674
|
+
if (claimed.changes === 0)
|
|
7675
|
+
throw substratError('precondition_failed', 'scope route or copy inventory changed during subject erasure; retry after it settles');
|
|
7676
|
+
// The pure adapter has one co-located scope store. Its existing atomic shred is
|
|
7677
|
+
// the final local redaction and key destruction, after remote copies confirmed.
|
|
7678
|
+
return this.admin.shredSubject(actor, tenantId, scopeId, subjectId);
|
|
7679
|
+
},
|
|
7086
7680
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
7087
7681
|
beginImpersonation: async (actor, input) => {
|
|
7088
7682
|
// Fail closed on the scope before minting anything, exactly as the
|
|
@@ -7151,6 +7745,7 @@ export class SqliteScopeHost {
|
|
|
7151
7745
|
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
7152
7746
|
'_substrat_membership_fences', // #1184: the latest removal, per principal
|
|
7153
7747
|
'_substrat_peer_switches', // #2029: the peer switch's record, per scope
|
|
7748
|
+
'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
|
|
7154
7749
|
'_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
|
|
7155
7750
|
'_substrat_findings', // #1748: the tenant's findings
|
|
7156
7751
|
'_substrat_finding_rules', // #1748: the tenant's suppress rules
|
|
@@ -7469,7 +8064,7 @@ export class SqliteScopeHost {
|
|
|
7469
8064
|
.prepare(`UPDATE _substrat_connections
|
|
7470
8065
|
SET status = 'active', expires_at = ?, last_error = NULL, last_error_at = NULL
|
|
7471
8066
|
WHERE id = ?`)
|
|
7472
|
-
.run(expiresAt
|
|
8067
|
+
.run(expiresAt === undefined ? row.expires_at : expiresAt, id);
|
|
7473
8068
|
// The event, never the token. "Rotated at T" is the auditable fact — plus WHO
|
|
7474
8069
|
// authorized it when the rotation was a tenant admin's act (§3.5.1's attribution,
|
|
7475
8070
|
// rotate-side): the principal, never laundered into the actor column.
|
|
@@ -7477,7 +8072,7 @@ export class SqliteScopeHost {
|
|
|
7477
8072
|
id,
|
|
7478
8073
|
provider: row.provider,
|
|
7479
8074
|
rotatedAt: now,
|
|
7480
|
-
expiresAt: expiresAt
|
|
8075
|
+
expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
|
|
7481
8076
|
...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
|
|
7482
8077
|
});
|
|
7483
8078
|
},
|
|
@@ -7508,6 +8103,51 @@ export class SqliteScopeHost {
|
|
|
7508
8103
|
.run(now, id);
|
|
7509
8104
|
this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
|
|
7510
8105
|
},
|
|
8106
|
+
// -- a vertical's mailed connect links (connections.md §3.5.4) --------------
|
|
8107
|
+
// The statements are the kernel's (`connect-links.ts`), shared with the hosted
|
|
8108
|
+
// adapter; what is here is the clock, the parse at the edge, and the transaction:
|
|
8109
|
+
// each move commits with the audit row it hands back, or not at all.
|
|
8110
|
+
mintConnectLink: async (actor, raw) => {
|
|
8111
|
+
const input = mintConnectLinkInput.parse(raw);
|
|
8112
|
+
return this.auditedConnectLink(actor, (sql, audit) => insertConnectLink(sql, {
|
|
8113
|
+
id: ulid(),
|
|
8114
|
+
tenantId: input.tenantId,
|
|
8115
|
+
scopeId: input.scopeId,
|
|
8116
|
+
vertical: input.vertical,
|
|
8117
|
+
provider: input.provider,
|
|
8118
|
+
createdBy: input.createdBy,
|
|
8119
|
+
subjectRef: input.subjectRef ?? null,
|
|
8120
|
+
returnUrl: input.returnUrl ?? null,
|
|
8121
|
+
createdAt: this.clock(),
|
|
8122
|
+
expiresAt: input.expiresAt,
|
|
8123
|
+
}, audit));
|
|
8124
|
+
},
|
|
8125
|
+
getConnectLink: async (actor, raw) => {
|
|
8126
|
+
const key = connectLinkKey.parse(raw);
|
|
8127
|
+
const link = readConnectLink(redactionSqlOf(this.directory), key);
|
|
8128
|
+
this.recordAccess(actor, 'getConnectLink', key, { id: key.id }, link ? 1 : 0);
|
|
8129
|
+
return link;
|
|
8130
|
+
},
|
|
8131
|
+
listConnectLinks: async (actor, raw) => {
|
|
8132
|
+
const filter = connectLinkFilter.parse(raw);
|
|
8133
|
+
const links = listConnectLinkRows(redactionSqlOf(this.directory), filter, this.clock());
|
|
8134
|
+
this.recordAccess(actor, 'listConnectLinks', filter, filter, links.length);
|
|
8135
|
+
return links;
|
|
8136
|
+
},
|
|
8137
|
+
revokeConnectLink: async (actor, raw) => {
|
|
8138
|
+
const key = connectLinkKey.parse(raw);
|
|
8139
|
+
// Idempotent, and a no-op is not audited (the statement hands back no row for one).
|
|
8140
|
+
return this.auditedConnectLink(actor, (sql, audit) => revokeConnectLinkRow(sql, key, audit))?.link;
|
|
8141
|
+
},
|
|
8142
|
+
consumeConnectLink: async (actor, raw) => {
|
|
8143
|
+
const input = consumeConnectLinkInput.parse(raw);
|
|
8144
|
+
return this.auditedConnectLink(actor, (sql, audit) => consumeConnectLinkRow(sql, input, this.clock(), audit));
|
|
8145
|
+
},
|
|
8146
|
+
restoreConnectLink: async (actor, raw) => {
|
|
8147
|
+
const key = connectLinkKey.parse(raw);
|
|
8148
|
+
const link = this.auditedConnectLink(actor, (sql, audit) => restoreConnectLinkRow(sql, key, this.clock(), audit));
|
|
8149
|
+
return link !== undefined;
|
|
8150
|
+
},
|
|
7511
8151
|
openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
|
|
7512
8152
|
const rows = this.directory
|
|
7513
8153
|
.prepare(`SELECT * FROM _substrat_connections
|
|
@@ -7660,6 +8300,10 @@ export class SqliteScopeHost {
|
|
|
7660
8300
|
const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
|
|
7661
8301
|
this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
|
|
7662
8302
|
},
|
|
8303
|
+
recordPeerBindingChange: async (actor, entry) => {
|
|
8304
|
+
const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
|
|
8305
|
+
this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
|
|
8306
|
+
},
|
|
7663
8307
|
/** #2005: one change to a scope's copy marker, written around the vertical's own change. */
|
|
7664
8308
|
recordCopyMark: async (actor, entry) => {
|
|
7665
8309
|
const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
|
|
@@ -7800,52 +8444,24 @@ export class SqliteScopeHost {
|
|
|
7800
8444
|
}));
|
|
7801
8445
|
},
|
|
7802
8446
|
recordOpsFailure: async (entry) => {
|
|
7803
|
-
|
|
7804
|
-
const fingerprint = opsFailureFingerprint(entry);
|
|
7805
|
-
// #1748: the evidence, its issue and its finding in ONE transaction — a detector that
|
|
7806
|
-
// throws takes the evidence with it, so the caller's retry observes it rather than finding
|
|
7807
|
-
// the row already there and the observation lost.
|
|
7808
|
-
this.directory.transaction(() => {
|
|
7809
|
-
this.directory
|
|
7810
|
-
.prepare(`INSERT INTO _substrat_ops_failures
|
|
7811
|
-
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
7812
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
7813
|
-
.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
|
|
7814
|
-
// Bounded here, not trusted from the catch site: one runaway upstream
|
|
7815
|
-
// body must not become a runaway directory row (#559).
|
|
7816
|
-
entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
|
|
7817
|
-
// Prune-on-write (#559): retention lives here, not in a cron — every insert
|
|
7818
|
-
// pays for its own housekeeping, so the table stays bounded even where no
|
|
7819
|
-
// scheduled pass runs (this adapter has none).
|
|
7820
|
-
const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
7821
|
-
this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
|
|
7822
|
-
// The issues materialization (#1233): the group's counters live on their
|
|
7823
|
-
// own row, bumped in the same call, because the evidence self-prunes above
|
|
7824
|
-
// and a count must survive its own exemplars. A fresh arrival regresses a
|
|
7825
|
-
// resolved issue; an ignored one stays ignored — that is what ignoring means.
|
|
7826
|
-
this.directory
|
|
7827
|
-
.prepare(`INSERT INTO _substrat_issues
|
|
7828
|
-
(fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
|
|
7829
|
-
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
7830
|
-
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
7831
|
-
seen_count = seen_count + 1,
|
|
7832
|
-
last_seen = excluded.last_seen,
|
|
7833
|
-
last_message = excluded.last_message,
|
|
7834
|
-
last_tenant_id = excluded.last_tenant_id,
|
|
7835
|
-
last_owner_kind = excluded.last_owner_kind,
|
|
7836
|
-
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
7837
|
-
last_version = COALESCE(excluded.last_version, last_version),
|
|
7838
|
-
origin = COALESCE(excluded.origin, origin),
|
|
7839
|
-
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
|
|
7840
|
-
.run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.tenantId ?? null, issueExemplarOwner(entry.tenantId ?? null), entry.vertical ?? null, entry.version ?? null);
|
|
7841
|
-
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
7842
|
-
this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
|
|
7843
|
-
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
7844
|
-
const finding = findingOfOpsFailure(entry);
|
|
7845
|
-
if (finding)
|
|
7846
|
-
observeFinding(redactionSqlOf(this.directory), finding, at);
|
|
7847
|
-
})();
|
|
8447
|
+
this.writeOpsFailure(entry);
|
|
7848
8448
|
},
|
|
8449
|
+
/** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
|
|
8450
|
+
auditedOperations: async (actor, refs) => {
|
|
8451
|
+
const rows = readAuditedOperations((sql, params) => this.directory.prepare(sql).all(...params), refs);
|
|
8452
|
+
this.recordAccess(actor, 'auditedOperations', {}, { operations: refs.length }, rows.length);
|
|
8453
|
+
return rows;
|
|
8454
|
+
},
|
|
8455
|
+
/** #2064: settle an intent with no outcome, in one transaction — see `audit-outcome.ts`. */
|
|
8456
|
+
settleUnrecordedOutcome: async (actor, input) => this.directory.transaction(() => {
|
|
8457
|
+
const row = this.directory.prepare(SETTLE_INTENT_SQL).get(input.intentId);
|
|
8458
|
+
const outcome = unknownOutcomeOf(row, input.intentId, input.error);
|
|
8459
|
+
if (this.directory.prepare(SETTLE_OUTCOME_SQL).get(...settleOutcomeParamsOf(outcome.operation)))
|
|
8460
|
+
return false;
|
|
8461
|
+
this.recordAdmin(actor, outcome.action, outcome.target, null, outcome.after);
|
|
8462
|
+
this.writeOpsFailure({ ...outcome.failure, actor });
|
|
8463
|
+
return true;
|
|
8464
|
+
})(),
|
|
7849
8465
|
listOpsFailures: async (actor, filter) => {
|
|
7850
8466
|
const where = [];
|
|
7851
8467
|
const params = [];
|
|
@@ -8125,13 +8741,43 @@ export class SqliteScopeHost {
|
|
|
8125
8741
|
* directory. `PRAGMA table_info` is available in the pure adapter (the DO adapter
|
|
8126
8742
|
* has to attempt-and-tolerate instead — see its `ensureDirectoryColumns`).
|
|
8127
8743
|
*/
|
|
8744
|
+
/**
|
|
8745
|
+
* Move a scope's binding or route (#1722), conditionally: on the binding the caller read, on
|
|
8746
|
+
* the erasure epoch its carry read (a NULL epoch, from before the column, is 0) with no reap
|
|
8747
|
+
* claimed, and on its copy move still owning a live lease. The move's confirmation commits in
|
|
8748
|
+
* the same transaction as the row. False when any condition failed and nothing was written.
|
|
8749
|
+
*/
|
|
8750
|
+
moveScopeRow(set, setArgs, tenantId, scopeId, cond) {
|
|
8751
|
+
const now = new Date().toISOString();
|
|
8752
|
+
return this.directory.transaction(() => {
|
|
8753
|
+
const moved = this.directory.prepare(`UPDATE scopes SET ${set} WHERE scope_id = ?
|
|
8754
|
+
${cond?.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
|
|
8755
|
+
${cond?.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
|
|
8756
|
+
${cond?.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}`).run(...setArgs, scopeId, ...(cond?.expectedVersionId === undefined ? [] : [cond.expectedVersionId]), ...(cond?.expectedErasureEpoch === undefined ? [] : [cond.expectedErasureEpoch]), ...(cond?.confirmMove === undefined ? [] : copyMoveLiveParams(cond.confirmMove, now)));
|
|
8757
|
+
if (moved.changes === 0)
|
|
8758
|
+
return false;
|
|
8759
|
+
if (cond?.confirmMove) {
|
|
8760
|
+
this.directory.prepare(COPY_MOVE_CONFIRM_SQL).run(...copyMoveConfirmParams(cond.confirmMove, tenantId, scopeId, now));
|
|
8761
|
+
}
|
|
8762
|
+
return true;
|
|
8763
|
+
})();
|
|
8764
|
+
}
|
|
8128
8765
|
ensureColumn(db, table, column, ddl) {
|
|
8129
8766
|
// Without case, as SQLite resolves a column name: a restore may have added this column
|
|
8130
8767
|
// already, untyped and spelled as a newer kernel's dump spelled it (#1883).
|
|
8131
8768
|
const existing = db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name.toLowerCase() === column.toLowerCase());
|
|
8132
8769
|
if (existing)
|
|
8133
8770
|
return false;
|
|
8134
|
-
|
|
8771
|
+
try {
|
|
8772
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
8773
|
+
}
|
|
8774
|
+
catch (err) {
|
|
8775
|
+
// A second opener of the same file can add it between the PRAGMA and here (#2066 review):
|
|
8776
|
+
// the column is there, which is all this wanted. The DO's ALTER pass tolerates it the same way.
|
|
8777
|
+
if (!/duplicate column name/i.test(err.message))
|
|
8778
|
+
throw err;
|
|
8779
|
+
return false;
|
|
8780
|
+
}
|
|
8135
8781
|
return true;
|
|
8136
8782
|
}
|
|
8137
8783
|
/**
|
|
@@ -8166,7 +8812,7 @@ export class SqliteScopeHost {
|
|
|
8166
8812
|
// behind an empty table `CREATE TABLE IF NOT EXISTS` put back. `db.transaction`
|
|
8167
8813
|
// nests as a SAVEPOINT, which is what makes this safe on the `loadDump` path too,
|
|
8168
8814
|
// where the whole replay is already inside one.
|
|
8169
|
-
db.transaction(() => db
|
|
8815
|
+
db.transaction(() => execSqlStatements(db, script))();
|
|
8170
8816
|
}
|
|
8171
8817
|
/**
|
|
8172
8818
|
* #1573: run a create-copy-drop-rename against the directory as ONE transaction.
|
|
@@ -8186,7 +8832,7 @@ export class SqliteScopeHost {
|
|
|
8186
8832
|
* capture one.
|
|
8187
8833
|
*/
|
|
8188
8834
|
rebuildAtomically(script) {
|
|
8189
|
-
this.directory.transaction(() => this.directory
|
|
8835
|
+
this.directory.transaction(() => execSqlStatements(this.directory, script))();
|
|
8190
8836
|
}
|
|
8191
8837
|
/**
|
|
8192
8838
|
* Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
|
|
@@ -8233,7 +8879,8 @@ export class SqliteScopeHost {
|
|
|
8233
8879
|
* constraint in place, so this is the same create-copy-drop-rename the identity key
|
|
8234
8880
|
* uses, detected the same way — from `sqlite_master.sql`, which works on DO SQLite
|
|
8235
8881
|
* too. Rows are copied verbatim: the log stays append-only in content, this only
|
|
8236
|
-
* widens what a future row may say.
|
|
8882
|
+
* widens what a future row may say. The DROP takes every index on the table with it, so
|
|
8883
|
+
* the same transaction rebuilds them from the kernel's list (#2064).
|
|
8237
8884
|
*/
|
|
8238
8885
|
ensureAdminLogTenantNullable() {
|
|
8239
8886
|
const row = this.directory
|
|
@@ -8260,6 +8907,7 @@ export class SqliteScopeHost {
|
|
|
8260
8907
|
FROM _substrat_admin_log;
|
|
8261
8908
|
DROP TABLE _substrat_admin_log;
|
|
8262
8909
|
ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
|
|
8910
|
+
${ADMIN_LOG_INDEXES_SQL}
|
|
8263
8911
|
`);
|
|
8264
8912
|
}
|
|
8265
8913
|
/**
|
|
@@ -8300,6 +8948,8 @@ export class SqliteScopeHost {
|
|
|
8300
8948
|
// #1172: which version this scope's provision hook last ran against. Null on every
|
|
8301
8949
|
// existing row, which reads as "unknown, reconcile once" rather than "up to date".
|
|
8302
8950
|
this.ensureColumn(this.directory, 'scopes', 'provisioned_version_id', 'provisioned_version_id TEXT');
|
|
8951
|
+
this.ensureColumn(this.directory, 'scopes', 'erasure_epoch', 'erasure_epoch INTEGER');
|
|
8952
|
+
this.ensureColumn(this.directory, 'scopes', 'reap_claimed_at', 'reap_claimed_at TEXT');
|
|
8303
8953
|
// §4.8's grace-window timestamp on tenants (mirrors scopes' archived_at).
|
|
8304
8954
|
this.ensureColumn(this.directory, 'tenants', 'deleting_at', 'deleting_at TEXT');
|
|
8305
8955
|
this.ensureColumn(this.directory, 'tenants', 'provisioned_by_tenant', 'provisioned_by_tenant TEXT REFERENCES tenants(tenant_id)');
|
|
@@ -8460,7 +9110,12 @@ export class SqliteScopeHost {
|
|
|
8460
9110
|
*/
|
|
8461
9111
|
at = this.clock(),
|
|
8462
9112
|
/** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
|
|
8463
|
-
delivery
|
|
9113
|
+
delivery,
|
|
9114
|
+
/**
|
|
9115
|
+
* #119: keys this context's checks refuse whatever the subject holds — a module's
|
|
9116
|
+
* purge-only keys, outside the purge sweep's own invoke (`purgeOnlyKeysOf`).
|
|
9117
|
+
*/
|
|
9118
|
+
withheld) {
|
|
8464
9119
|
// For a connection, system, capability or peer-vertical subject this carries THAT id so
|
|
8465
9120
|
// the type holds — it is not a person, and the event actor below says what it is instead.
|
|
8466
9121
|
const principal = subject.id;
|
|
@@ -8527,6 +9182,9 @@ export class SqliteScopeHost {
|
|
|
8527
9182
|
// #1642: parsed before the override actor's early return, which never reaches
|
|
8528
9183
|
// the checker — a cast key would otherwise become that path's proof relation.
|
|
8529
9184
|
const permission = assertPermissionKey(unparsed);
|
|
9185
|
+
if (withheld?.has(permission)) {
|
|
9186
|
+
return { allowed: false, checked: permission, node: { tenantId: rt.tenantId, scopeId: rt.scopeId } };
|
|
9187
|
+
}
|
|
8530
9188
|
if (overrideActor) {
|
|
8531
9189
|
return {
|
|
8532
9190
|
allowed: true,
|
|
@@ -8602,10 +9260,12 @@ export class SqliteScopeHost {
|
|
|
8602
9260
|
tenantId: rt.tenantId,
|
|
8603
9261
|
scopeId: rt.scopeId,
|
|
8604
9262
|
principal,
|
|
9263
|
+
pageContinuation: (list, query) => visibleContinuation(this.continuationStore(rt.scopeId, impersonation?.mode !== 'read-only' && this.isPrimaryInDirectory(rt.scopeId)), { scopeId: rt.scopeId, principal: `${subject.kind}:${subject.id}`, operation: operation ?? 'kernel', list, query }, () => Date.parse(this.clock()), () => ctxRef.log.info('legacy filtered-list cursor accepted', { list }), impersonation?.mode !== 'read-only' && this.isPrimaryInDirectory(rt.scopeId)),
|
|
8605
9264
|
sql: guardSecrets(guardSqlLimits(scopedSql(rt.db, true, statefulTablesOf(statePlans),
|
|
8606
|
-
// #119: after runtime DDL,
|
|
8607
|
-
|
|
8608
|
-
|
|
9265
|
+
// #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
|
|
9266
|
+
// derived onto any table is repaired — and a lost state column fails the operation.
|
|
9267
|
+
derivesAnything(this.derivedPlans())
|
|
9268
|
+
? () => afterRuntimeDdl(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans())
|
|
8609
9269
|
: undefined)), minted),
|
|
8610
9270
|
now: () => at,
|
|
8611
9271
|
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
@@ -8665,6 +9325,10 @@ export class SqliteScopeHost {
|
|
|
8665
9325
|
return entityVersionOf(rt.db.prepare(q.sql).all(...q.params));
|
|
8666
9326
|
},
|
|
8667
9327
|
check: runCheck,
|
|
9328
|
+
grantedEntities: async (unparsed, entityType, options) => {
|
|
9329
|
+
const permission = assertPermissionKey(unparsed);
|
|
9330
|
+
return grantedEntitiesForContext(checker, subject, permission, { tenantId: rt.tenantId, scopeId: rt.scopeId }, entityType, options, runCheck, withheld, Boolean(overrideActor));
|
|
9331
|
+
},
|
|
8668
9332
|
canAssign: runCanAssign,
|
|
8669
9333
|
/**
|
|
8670
9334
|
* #827. The plan is registration state, the index is scope state: an entity
|
|
@@ -8703,16 +9367,14 @@ export class SqliteScopeHost {
|
|
|
8703
9367
|
view: uncheckedView('ctx.page', entityType, params.view),
|
|
8704
9368
|
});
|
|
8705
9369
|
const rows = rt.db.prepare(q.sql).all(...q.params);
|
|
8706
|
-
//
|
|
8707
|
-
//
|
|
8708
|
-
//
|
|
8709
|
-
const
|
|
8710
|
-
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order, q.view);
|
|
8711
|
-
const page = { entries: rows, nextCursor };
|
|
9370
|
+
// `pageOf`'s rule — a FULL page may have more, a short one is the end — with the
|
|
9371
|
+
// cursor minted from the row's COLUMN (plus the tie-break), and each row's own
|
|
9372
|
+
// cursor when asked (#2073: `pageVisible` may stop at a visible row mid-page).
|
|
9373
|
+
const mint = (row) => cursorOf(row, q.sortColumn, plan.idColumn, q.order, q.view);
|
|
8712
9374
|
if (!params.total)
|
|
8713
|
-
return
|
|
9375
|
+
return pageOf(rows, limit, mint, params.rowCursors);
|
|
8714
9376
|
const counted = rt.db.prepare(q.countSql).all(...q.countParams);
|
|
8715
|
-
return
|
|
9377
|
+
return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
|
|
8716
9378
|
},
|
|
8717
9379
|
/**
|
|
8718
9380
|
* Narrow a permission this caller already holds onto one entity (#K-sharing).
|
|
@@ -8741,10 +9403,8 @@ export class SqliteScopeHost {
|
|
|
8741
9403
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
8742
9404
|
'the caller does not hold it there (a grant delegates, it never elevates)');
|
|
8743
9405
|
}
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
VALUES (?, ?, ?)`)
|
|
8747
|
-
.run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
|
|
9406
|
+
// #2071: an explicit grant, so it clears a tombstone `revoke` left.
|
|
9407
|
+
writeExplicitTupleIn(switchSqlOf(rt.db), `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, { kind: 'delegated' });
|
|
8748
9408
|
},
|
|
8749
9409
|
/**
|
|
8750
9410
|
* Withdraw a grant this caller could have made. Same guardrails, same reason — but NOT
|
|
@@ -8758,9 +9418,10 @@ export class SqliteScopeHost {
|
|
|
8758
9418
|
throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
8759
9419
|
'the caller does not hold it there');
|
|
8760
9420
|
}
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
9421
|
+
// K-21 (#2071): a tombstone, never a delete — a declared shape's top-up must be able
|
|
9422
|
+
// to tell a key taken back from one never held.
|
|
9423
|
+
const r = delegatedRevokeSql(principal, permission, `${entity.entityType}:${entity.entityId}`, at);
|
|
9424
|
+
rt.db.prepare(r.sql).run(...r.params);
|
|
8764
9425
|
},
|
|
8765
9426
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
8766
9427
|
// #1672: mint / revoke / list, written once in the kernel. The raw spine seam (this
|
|
@@ -8785,6 +9446,7 @@ export class SqliteScopeHost {
|
|
|
8785
9446
|
now: at,
|
|
8786
9447
|
emit: (event) => writeEvent(event, 'kernel'),
|
|
8787
9448
|
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
9449
|
+
isUnreachableParent: (entity) => isUnreachableParent(spineSql(rt.db), statePlans, entity),
|
|
8788
9450
|
}),
|
|
8789
9451
|
// #119: archive and trash, written once in the kernel. The raw seam, because the guarded
|
|
8790
9452
|
// `ctx.sql` refuses the very columns these write; the operation's own check, so the
|
|
@@ -8803,6 +9465,7 @@ export class SqliteScopeHost {
|
|
|
8803
9465
|
searchPlans,
|
|
8804
9466
|
statePlans,
|
|
8805
9467
|
check: runCheck,
|
|
9468
|
+
continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
|
|
8806
9469
|
}),
|
|
8807
9470
|
// #304: the request-time entitlement read. The pure adapter is single-process, so the
|
|
8808
9471
|
// directory is local — no projection needed; it reads `_substrat_entitlements` straight,
|
|
@@ -8856,56 +9519,109 @@ export class SqliteScopeHost {
|
|
|
8856
9519
|
kernelEmitters.set(ctxRef, (event) => writeEvent(event, 'kernel'));
|
|
8857
9520
|
return ctxRef;
|
|
8858
9521
|
}
|
|
8859
|
-
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
9522
|
+
/** Every registered module's derivation plans — what `repairDerivedObjects` and its checks read. */
|
|
9523
|
+
derivedPlans() {
|
|
9524
|
+
return { state: this.statePlans, lists: this.listPlans, search: this.searchPlans };
|
|
9525
|
+
}
|
|
9526
|
+
/**
|
|
9527
|
+
* #2090: a pass with nothing pending, once per runtime — check and repair what the kernel
|
|
9528
|
+
* derived onto the scope's tables, in a transaction of its own. Its migrations are journaled
|
|
9529
|
+
* and will never run again, so this is what reaches a scope stripped before the migration
|
|
9530
|
+
* pass checked. A lost state column fails the scope closed like a failed migration, named by
|
|
9531
|
+
* the journaled migration that added the column; every later call tries again and fails again.
|
|
9532
|
+
*/
|
|
9533
|
+
async repairDerived(rt) {
|
|
9534
|
+
let failure;
|
|
9535
|
+
await rt.actor.enqueue(() => {
|
|
9536
|
+
rt.db.exec('BEGIN IMMEDIATE');
|
|
9537
|
+
try {
|
|
9538
|
+
repairDerivedObjects(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans(), {
|
|
9539
|
+
after: 'an applied migration',
|
|
9540
|
+
});
|
|
9541
|
+
rt.db.exec('COMMIT');
|
|
9542
|
+
}
|
|
9543
|
+
catch (err) {
|
|
9544
|
+
rt.db.exec('ROLLBACK');
|
|
9545
|
+
failure = { version: err instanceof StateColumnLost ? err.migration : 'kernel@derived-objects', error: err.message };
|
|
8866
9546
|
}
|
|
9547
|
+
});
|
|
9548
|
+
if (failure) {
|
|
9549
|
+
this.recordMigrationState(rt, failure);
|
|
9550
|
+
throw migrationFailedError(failure.version, failure.error);
|
|
9551
|
+
}
|
|
9552
|
+
rt.derivedRepaired = true;
|
|
9553
|
+
}
|
|
9554
|
+
async applyPendingMigrations(rt, plan) {
|
|
9555
|
+
const { pending, diverged } = plan ?? (await planMigrations(this.modules.values(), rt.appliedMigrations));
|
|
9556
|
+
if (diverged) {
|
|
9557
|
+
this.recordMigrationState(rt, diverged);
|
|
9558
|
+
throw migrationFailedError(diverged.version, diverged.error);
|
|
8867
9559
|
}
|
|
8868
9560
|
// Nothing pending → nothing to record. A scope provisioned before any module
|
|
8869
|
-
// registers legitimately sits at schema_version '0'.
|
|
8870
|
-
|
|
9561
|
+
// registers legitimately sits at schema_version '0'. Still, once per runtime, what the
|
|
9562
|
+
// kernel derived onto the tables is checked and repaired (#2090).
|
|
9563
|
+
if (pending.length === 0) {
|
|
9564
|
+
if (!rt.derivedRepaired)
|
|
9565
|
+
await this.repairDerived(rt);
|
|
8871
9566
|
return;
|
|
9567
|
+
}
|
|
8872
9568
|
// The failing `module@version` and its cause, captured structurally rather than
|
|
8873
9569
|
// re-parsed out of the thrown message — the directory record has to name both.
|
|
8874
9570
|
let failure;
|
|
8875
9571
|
try {
|
|
8876
9572
|
await rt.actor.enqueue(() => {
|
|
8877
|
-
for (const { moduleId, migration } of pending) {
|
|
9573
|
+
for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
|
|
8878
9574
|
const key = `${moduleId}@${migration.version}`;
|
|
8879
9575
|
if (rt.appliedMigrations.has(key))
|
|
8880
9576
|
continue;
|
|
8881
9577
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
9578
|
+
let recorded = digest;
|
|
8882
9579
|
try {
|
|
8883
9580
|
const already = rt.db
|
|
8884
|
-
.prepare('SELECT
|
|
9581
|
+
.prepare('SELECT sql_digest FROM _substrat_migrations WHERE module_id = ? AND version = ?')
|
|
8885
9582
|
.get(moduleId, migration.version);
|
|
8886
|
-
if (
|
|
9583
|
+
if (already) {
|
|
9584
|
+
// Applied since this pass read the journal: held to the same digest rule.
|
|
9585
|
+
const error = migrationDivergence(already.sql_digest, digest, authored);
|
|
9586
|
+
if (error)
|
|
9587
|
+
throw new Error(error);
|
|
9588
|
+
recorded = already.sql_digest;
|
|
9589
|
+
}
|
|
9590
|
+
else {
|
|
8887
9591
|
const started = performance.now();
|
|
8888
9592
|
const before = rt.db.prepare('SELECT total_changes() AS n').get().n;
|
|
8889
|
-
// #1898: a migration runs on the scope's own handle, not `ctx.sql`, so the
|
|
8890
|
-
// spine
|
|
8891
|
-
|
|
8892
|
-
|
|
9593
|
+
// #1898, #2066: a migration runs on the scope's own handle, not `ctx.sql`, so the
|
|
9594
|
+
// spine rules a migration is held to are applied here.
|
|
9595
|
+
assertMigrationSql(migration.sql, { key, digest, authored });
|
|
9596
|
+
// #2068: the ownership ledger is the kernel's — no migration may name it, not even to
|
|
9597
|
+
// read it (the spine rules above allow reads).
|
|
9598
|
+
assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
|
|
9599
|
+
// #2068: one statement at a time, keeping the table set either side of each — the
|
|
9600
|
+
// same splitter the Durable Object runs, so both hosts execute identical statements.
|
|
9601
|
+
const steps = runMigrationStatements(spineSql(rt.db), migration.sql, (stmt) => rt.db.exec(stmt));
|
|
8893
9602
|
assertTablesWithinColumnLimit(rt.db);
|
|
9603
|
+
// #2090: the state columns this migration must have left, and after the last of the
|
|
9604
|
+
// pass, the triggers and indexes a create-copy-rename rebuild dropped.
|
|
9605
|
+
const last = i === pending.length - 1;
|
|
9606
|
+
afterMigration(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans(), key, last);
|
|
8894
9607
|
const after = rt.db.prepare('SELECT total_changes() AS n').get().n;
|
|
9608
|
+
// After the count: `rows_changed` is the migration's own, never the kernel's bookkeeping.
|
|
9609
|
+
recordOwnershipSteps(spineSql(rt.db), moduleId, steps, this.clock());
|
|
8895
9610
|
rt.db
|
|
8896
|
-
.prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed) VALUES (?, ?, ?, ?, ?)')
|
|
8897
|
-
.run(moduleId, migration.version, this.clock(), Math.max(0, Math.round(performance.now() - started)), after - before);
|
|
9611
|
+
.prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed, sql_digest) VALUES (?, ?, ?, ?, ?, ?)')
|
|
9612
|
+
.run(moduleId, migration.version, this.clock(), Math.max(0, Math.round(performance.now() - started)), after - before, digest);
|
|
8898
9613
|
}
|
|
8899
9614
|
rt.db.exec('COMMIT');
|
|
8900
9615
|
}
|
|
8901
9616
|
catch (err) {
|
|
8902
9617
|
rt.db.exec('ROLLBACK');
|
|
8903
9618
|
failure = { version: key, error: err.message };
|
|
8904
|
-
throw
|
|
9619
|
+
throw migrationFailedError(key, err.message);
|
|
8905
9620
|
}
|
|
8906
|
-
rt.appliedMigrations.
|
|
9621
|
+
rt.appliedMigrations.set(key, recorded);
|
|
8907
9622
|
}
|
|
8908
9623
|
});
|
|
9624
|
+
rt.derivedRepaired = true;
|
|
8909
9625
|
}
|
|
8910
9626
|
finally {
|
|
8911
9627
|
// `finally`, not the success path: a scope that failed closed is exactly the
|
|
@@ -9077,9 +9793,16 @@ export class SqliteScopeHost {
|
|
|
9077
9793
|
// #1763: old journal rows keep NULL, meaning their cost was never recorded.
|
|
9078
9794
|
this.ensureColumn(db, '_substrat_migrations', 'duration_ms', 'duration_ms INTEGER');
|
|
9079
9795
|
this.ensureColumn(db, '_substrat_migrations', 'rows_changed', 'rows_changed INTEGER');
|
|
9796
|
+
// #2066: the rows already there get the legacy mark, never a digest — what they ran was never
|
|
9797
|
+
// measured. KERNEL_DDL's fence keeps any other NULL out, so the mark finds only those.
|
|
9798
|
+
this.ensureColumn(db, '_substrat_migrations', 'sql_digest', 'sql_digest TEXT');
|
|
9799
|
+
db.exec(MIGRATION_DIGEST_MARK_LEGACY);
|
|
9080
9800
|
// KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before K-21 keeps the
|
|
9081
9801
|
// old shape — ALTER the tombstone in.
|
|
9082
9802
|
this.ensureColumn(db, '_substrat_tuples', 'revoked_at', 'revoked_at TEXT');
|
|
9803
|
+
// #2126: NULL keeps every existing capability's attachment behavior unchanged.
|
|
9804
|
+
this.ensureColumn(db, '_substrat_capabilities', 'attachments', 'attachments TEXT');
|
|
9805
|
+
db.exec(GRANT_CHILDREN_INDEX_DDL);
|
|
9083
9806
|
// Executor retry state (#100), same reasoning: scopes provisioned before it
|
|
9084
9807
|
// already have the table. Defaults read as "terminal", which is exactly right
|
|
9085
9808
|
// for the rows already there — every one of them is a completed delivery or a
|
|
@@ -9136,6 +9859,9 @@ export class SqliteScopeHost {
|
|
|
9136
9859
|
// from the kernel's (now widened) DDL when it runs — so by the time this ALTER
|
|
9137
9860
|
// executes, the table always already has `kind` in its key, never `invocation_id`.
|
|
9138
9861
|
this.ensureColumn(db, '_substrat_schedule_state', 'invocation_id', 'invocation_id TEXT');
|
|
9862
|
+
// #2096: a purge horizon's lap in progress, on a scope DB built before the column. NULL is the
|
|
9863
|
+
// start of a lap — the honest reading of every row already there. After the #1288 rebuild, as above.
|
|
9864
|
+
this.ensureColumn(db, '_substrat_schedule_state', 'purge_cursor', 'purge_cursor TEXT');
|
|
9139
9865
|
this.ensureColumn(db, '_substrat_job_runs', 'subject_id', 'subject_id TEXT');
|
|
9140
9866
|
// #2034: the lease, on a scope DB built before it. NULL = nobody holds the run.
|
|
9141
9867
|
this.ensureColumn(db, '_substrat_job_runs', 'lease_owner', 'lease_owner TEXT');
|
|
@@ -9171,9 +9897,9 @@ export class SqliteScopeHost {
|
|
|
9171
9897
|
// WAL is also what the read connection rests on (#1624): a reader sees the last
|
|
9172
9898
|
// committed snapshot and never blocks, or is blocked by, the writer.
|
|
9173
9899
|
db.pragma('journal_mode = WAL');
|
|
9174
|
-
db
|
|
9900
|
+
execSqlStatements(db, KERNEL_DDL);
|
|
9175
9901
|
this.ensureSpineColumns(db);
|
|
9176
|
-
const appliedMigrations =
|
|
9902
|
+
const appliedMigrations = readAppliedMigrations(db);
|
|
9177
9903
|
// #1335: the floor this scope's ids have to clear is the highest id already in
|
|
9178
9904
|
// its outbox, not wherever the wall clock happens to be. Read once, here, because
|
|
9179
9905
|
// this is the one place a scope's runtime is built — a reopened host, a revived
|
|
@@ -9191,6 +9917,7 @@ export class SqliteScopeHost {
|
|
|
9191
9917
|
reader: null,
|
|
9192
9918
|
actor: new ScopeActor(),
|
|
9193
9919
|
appliedMigrations,
|
|
9920
|
+
derivedRepaired: false,
|
|
9194
9921
|
mintEventId,
|
|
9195
9922
|
invocationId: null,
|
|
9196
9923
|
};
|
|
@@ -9199,6 +9926,11 @@ export class SqliteScopeHost {
|
|
|
9199
9926
|
return created;
|
|
9200
9927
|
}
|
|
9201
9928
|
}
|
|
9929
|
+
/** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
|
|
9930
|
+
function readAppliedMigrations(db) {
|
|
9931
|
+
const rows = db.prepare('SELECT module_id, version, sql_digest FROM _substrat_migrations').all();
|
|
9932
|
+
return new Map(rows.map((r) => [`${r.module_id}@${r.version}`, r.sql_digest]));
|
|
9933
|
+
}
|
|
9202
9934
|
/** The platform spine (`_substrat_*`) and SQLite internals — the UI groups these apart. */
|
|
9203
9935
|
function isSystemTable(name) {
|
|
9204
9936
|
return name.startsWith('_substrat') || name.startsWith('sqlite_');
|
|
@@ -9228,6 +9960,15 @@ function redactionSqlOf(db) {
|
|
|
9228
9960
|
* `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
|
|
9229
9961
|
* spine writes use `rt.db` directly and never pass through here.
|
|
9230
9962
|
*/
|
|
9963
|
+
/**
|
|
9964
|
+
* Run a multi-statement DDL blob one statement at a time, as its comment-blanked text (#2068) —
|
|
9965
|
+
* the same statements and the same text the Durable Object executes, so both hosts store the
|
|
9966
|
+
* same DDL in `sqlite_master` and a later `ALTER TABLE … DROP COLUMN` rewrites the same thing.
|
|
9967
|
+
*/
|
|
9968
|
+
function execSqlStatements(db, sql) {
|
|
9969
|
+
for (const statement of executableSqlStatements(sql))
|
|
9970
|
+
db.exec(statement);
|
|
9971
|
+
}
|
|
9231
9972
|
/**
|
|
9232
9973
|
* The kernel's OWN spine access (#1672) — the same seam as `scopedSql` without `guardSpine`,
|
|
9233
9974
|
* because these are the kernel's writes to `_substrat_capabilities`, which module code may
|