@substrat-run/adapter-sqlite 0.138.0 → 0.140.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 +106 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1005 -304
- 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 +6 -6
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_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, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, PEER_SWITCHES_DDL, SWITCH_OWED_DDL, SWITCH_FENCES_DDL, recordWriteSuperseded, switchFencesOf, switchSupersededMessage, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, moveSwitch, reassertActionOf, reassertEntry, reassertOffRow, recordSwitchedOff, recordSwitchedOn, restoreSwitchRecord, scopesSwitchedOffFor, switchActionOf, switchAuditSubject, 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, tenantCoverage, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations,
|
|
6
|
-
import {
|
|
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, 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 { GRANT_CHILDREN_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
|
|
10
|
+
import { LEGACY_SCOPE_ROWS_BACKFILL, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
|
|
9
11
|
import { ScopeActor } from './actor.js';
|
|
10
12
|
import { createTupleChecker, directoryTenantReader } from './checker.js';
|
|
11
13
|
/**
|
|
@@ -213,6 +215,9 @@ const KERNEL_DDL = `
|
|
|
213
215
|
-- #1686: where a copied scope's data came from, and the event id its own events start above.
|
|
214
216
|
-- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
|
|
215
217
|
${COPY_ORIGIN_DDL}
|
|
218
|
+
-- #119: the kernel's authorization for one archive/trash move, read by the derived update
|
|
219
|
+
-- trigger. Shared with the other adapter from @substrat-run/kernel; the comments are there.
|
|
220
|
+
${ENTITY_STATE_MOVES_DDL}
|
|
216
221
|
-- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
|
|
217
222
|
-- families of row that the kind COLUMN — not the spelling of a key — tells apart.
|
|
218
223
|
-- Spine (kernel-written), never a module migration. Shared with the DO adapter from
|
|
@@ -241,9 +246,15 @@ const KERNEL_DDL = `
|
|
|
241
246
|
applied_at TEXT NOT NULL,
|
|
242
247
|
duration_ms INTEGER,
|
|
243
248
|
rows_changed INTEGER,
|
|
249
|
+
-- #2066: SHA-256 of the SQL that ran (kernel migrationDigest). NULL on a row written
|
|
250
|
+
-- before the column: unrecorded, accepted, never backfilled.
|
|
251
|
+
sql_digest TEXT,
|
|
244
252
|
PRIMARY KEY (module_id, version)
|
|
245
253
|
);
|
|
254
|
+
-- #2066: no new journal row without its digest (the kernel's comment says why).
|
|
255
|
+
${MIGRATION_DIGEST_FENCE_DDL}
|
|
246
256
|
${SWITCH_FENCES_DDL}
|
|
257
|
+
${TABLE_OWNERS_DDL}
|
|
247
258
|
CREATE TABLE IF NOT EXISTS _substrat_tuples (
|
|
248
259
|
subject TEXT NOT NULL,
|
|
249
260
|
relation TEXT NOT NULL,
|
|
@@ -255,6 +266,7 @@ const KERNEL_DDL = `
|
|
|
255
266
|
revoked_at TEXT,
|
|
256
267
|
PRIMARY KEY (subject, relation, object)
|
|
257
268
|
);
|
|
269
|
+
${GRANT_CHILDREN_INDEX_DDL};
|
|
258
270
|
CREATE TABLE IF NOT EXISTS _substrat_deliveries (
|
|
259
271
|
event_id TEXT NOT NULL,
|
|
260
272
|
consumer_module TEXT NOT NULL,
|
|
@@ -467,6 +479,66 @@ const admitByDelivery = (rt, eventId, row) => {
|
|
|
467
479
|
`the attachments of the entity its event names, and no others`);
|
|
468
480
|
}
|
|
469
481
|
};
|
|
482
|
+
/**
|
|
483
|
+
* #119: this thread's claim on each host directory it has open, by real path — the OS lock and
|
|
484
|
+
* how many live hosts on this thread share it. Several hosts on ONE thread may share a directory
|
|
485
|
+
* (that is how the pure adapter models several verticals on one platform directory, #1705): the
|
|
486
|
+
* guarantees that read the directory and a scope's file together do so with no `await` in between,
|
|
487
|
+
* and one thread's JS runs one task at a time, so nothing it runs can interleave there. A writer on
|
|
488
|
+
* another thread or in ANOTHER process could. This map is module state, so each worker thread has
|
|
489
|
+
* its own: a second thread opens its own `_host.lock` connection, and SQLite refuses it exactly as
|
|
490
|
+
* it refuses another process.
|
|
491
|
+
*/
|
|
492
|
+
const PROCESS_HOST_DIRS = new Map();
|
|
493
|
+
/**
|
|
494
|
+
* #119: claim a host directory for this process, for one live `SqliteScopeHost`. The first host in
|
|
495
|
+
* the process takes an exclusive lock on `_host.lock` (a SQLite file: `locking_mode = EXCLUSIVE`
|
|
496
|
+
* plus a write holds SQLite's file lock until the connection closes); later hosts in the process
|
|
497
|
+
* share it, and the last `release()` drops it. The operating system drops it when the process
|
|
498
|
+
* exits, however it exits, so it never goes stale. No busy timeout: another process's claim is
|
|
499
|
+
* refused at once — `conflict`, reason `host_dir_in_use`, naming the directory — never queued.
|
|
500
|
+
*/
|
|
501
|
+
function claimHostDir(dir) {
|
|
502
|
+
const key = realpathSync(dir);
|
|
503
|
+
let claim = PROCESS_HOST_DIRS.get(key);
|
|
504
|
+
if (!claim) {
|
|
505
|
+
const lock = new Database(join(key, '_host.lock'), { timeout: 0 });
|
|
506
|
+
try {
|
|
507
|
+
lock.pragma('locking_mode = EXCLUSIVE');
|
|
508
|
+
// No journal file beside the directory: the one write only takes the lock.
|
|
509
|
+
lock.pragma('journal_mode = MEMORY');
|
|
510
|
+
lock.exec('CREATE TABLE IF NOT EXISTS owner (id INTEGER PRIMARY KEY CHECK (id = 1), pid INTEGER NOT NULL, at TEXT NOT NULL)');
|
|
511
|
+
lock.prepare('INSERT OR REPLACE INTO owner (id, pid, at) VALUES (1, ?, ?)').run(process.pid, new Date().toISOString());
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
lock.close();
|
|
515
|
+
const code = err.code;
|
|
516
|
+
if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') {
|
|
517
|
+
throw substratError('conflict', `host directory already in use by another process: ${key} — one process owns a host directory`, {
|
|
518
|
+
reason: 'host_dir_in_use',
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
throw err;
|
|
522
|
+
}
|
|
523
|
+
claim = { lock, hosts: 0 };
|
|
524
|
+
PROCESS_HOST_DIRS.set(key, claim);
|
|
525
|
+
}
|
|
526
|
+
claim.hosts += 1;
|
|
527
|
+
const held = claim;
|
|
528
|
+
let released = false;
|
|
529
|
+
return {
|
|
530
|
+
release: () => {
|
|
531
|
+
if (released)
|
|
532
|
+
return;
|
|
533
|
+
released = true;
|
|
534
|
+
held.hosts -= 1;
|
|
535
|
+
if (held.hosts > 0)
|
|
536
|
+
return;
|
|
537
|
+
PROCESS_HOST_DIRS.delete(key);
|
|
538
|
+
held.lock.close();
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
470
542
|
export class SqliteScopeHost {
|
|
471
543
|
admin;
|
|
472
544
|
/**
|
|
@@ -496,8 +568,39 @@ export class SqliteScopeHost {
|
|
|
496
568
|
*/
|
|
497
569
|
liveReads;
|
|
498
570
|
dir;
|
|
571
|
+
/** #119: this host's share of its process's claim on `dir` (`claimHostDir`), released by `close()`. */
|
|
572
|
+
owner;
|
|
499
573
|
checker;
|
|
500
574
|
directory;
|
|
575
|
+
/** A private directory seam: module SQL and scope/directory exports never reach it. */
|
|
576
|
+
continuationStore(scopeId, writable) {
|
|
577
|
+
const pruneExpired = () => this.directory.prepare('DELETE FROM private_continuation_positions WHERE expires_at <= ?').run(Date.parse(this.clock()));
|
|
578
|
+
return {
|
|
579
|
+
keys: async () => {
|
|
580
|
+
const row = this.directory.prepare('SELECT keyring FROM private_continuation_keys WHERE scope_id = ?').get(scopeId);
|
|
581
|
+
return row ? JSON.parse(row.keyring) : null;
|
|
582
|
+
},
|
|
583
|
+
setKeys: async (keys) => {
|
|
584
|
+
this.directory.prepare(`INSERT INTO private_continuation_keys (scope_id, keyring) VALUES (?, ?)
|
|
585
|
+
ON CONFLICT(scope_id) DO UPDATE SET keyring = excluded.keyring`).run(scopeId, JSON.stringify(keys));
|
|
586
|
+
},
|
|
587
|
+
position: async (id, expiresAt) => {
|
|
588
|
+
if (writable)
|
|
589
|
+
pruneExpired();
|
|
590
|
+
const row = this.directory.prepare(`SELECT ciphertext FROM private_continuation_positions
|
|
591
|
+
WHERE scope_id = ? AND expires_at = ? AND locator = ?`).get(scopeId, expiresAt, id);
|
|
592
|
+
return row ? { sealed: JSON.parse(row.ciphertext), expiresAt } : null;
|
|
593
|
+
},
|
|
594
|
+
setPosition: async (id, position) => {
|
|
595
|
+
pruneExpired();
|
|
596
|
+
this.directory.prepare(`INSERT INTO private_continuation_positions (scope_id, expires_at, locator, ciphertext)
|
|
597
|
+
VALUES (?, ?, ?, ?)`).run(scopeId, position.expiresAt, id, JSON.stringify(position.sealed));
|
|
598
|
+
this.directory.prepare(`DELETE FROM private_continuation_positions WHERE rowid IN (
|
|
599
|
+
SELECT rowid FROM private_continuation_positions WHERE scope_id = ?
|
|
600
|
+
ORDER BY rowid DESC LIMIT -1 OFFSET ?)`).run(scopeId, CONTINUATION_POSITION_CAP);
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
501
604
|
scopes = new Map();
|
|
502
605
|
scopesById = new Map();
|
|
503
606
|
/** The runtimes holding an open `reader` (#1624), least recently read first — `READER_CAP`'s LRU. */
|
|
@@ -521,6 +624,10 @@ export class SqliteScopeHost {
|
|
|
521
624
|
searchPlans = new Map();
|
|
522
625
|
/** #811: the paged lists modules declare, by entity type. Same one-owner rule as search. */
|
|
523
626
|
listPlans = new Map();
|
|
627
|
+
/** #119: entity type → its archive/trash plan, from every registered module. */
|
|
628
|
+
statePlans = new Map();
|
|
629
|
+
/** #2068: each registered module's erasure, in registration order — what `shredSubject` runs. */
|
|
630
|
+
erasurePlans = [];
|
|
524
631
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
525
632
|
attachmentTargets = new Map();
|
|
526
633
|
/** operation name → who binds it: the owning module, its entitlementKey and its declared
|
|
@@ -539,6 +646,11 @@ export class SqliteScopeHost {
|
|
|
539
646
|
* a bare `defineOperation` carries no declaration to read one off.
|
|
540
647
|
*/
|
|
541
648
|
operationConcurrency = new Map();
|
|
649
|
+
/**
|
|
650
|
+
* #119: name → the entity the operation addresses by id, its declared key and whether it
|
|
651
|
+
* reaches a trashed one — what the host's trash refusal reads. Populated by `registerModule`.
|
|
652
|
+
*/
|
|
653
|
+
operationTarget = new Map();
|
|
542
654
|
/**
|
|
543
655
|
* #116: the operations that declared `idempotency: false` — a set of refusals,
|
|
544
656
|
* because that is what the declaration is. Every name NOT in here honours an
|
|
@@ -557,10 +669,10 @@ export class SqliteScopeHost {
|
|
|
557
669
|
*/
|
|
558
670
|
jobs = new Map();
|
|
559
671
|
/**
|
|
560
|
-
* The event
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
672
|
+
* The event an executor is effecting, stamped onto the admin rows it writes (K-22). Like
|
|
673
|
+
* `onBehalfOf`, only ever set on a VIEW (`causedByView`, or `attributed` with a
|
|
674
|
+
* `causedBy`), never on the host: a field set around the handler's `await` stamped every
|
|
675
|
+
* admin call the host served meanwhile — a staff call included — with that event (#2055).
|
|
564
676
|
*/
|
|
565
677
|
causedBy = null;
|
|
566
678
|
/**
|
|
@@ -573,6 +685,7 @@ export class SqliteScopeHost {
|
|
|
573
685
|
secretBox;
|
|
574
686
|
/** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
|
|
575
687
|
attachmentExtractors;
|
|
688
|
+
attachmentTextBounds;
|
|
576
689
|
fetchImpl;
|
|
577
690
|
connectorCalls;
|
|
578
691
|
clock;
|
|
@@ -590,30 +703,50 @@ export class SqliteScopeHost {
|
|
|
590
703
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
591
704
|
assertAttachmentExtractors(options.attachmentExtractors ?? []);
|
|
592
705
|
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
706
|
+
this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
|
|
593
707
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
594
708
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
595
709
|
this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
|
|
596
710
|
this.versionId = options.versionId ?? null;
|
|
597
711
|
this.logSink = options.logSink ?? consoleLogSink;
|
|
598
712
|
this.invocationLineSink = options.invocationLineSink ?? consoleInvocationLineSink;
|
|
713
|
+
// Every option is read above and here, BEFORE the directory is claimed: an option that throws
|
|
714
|
+
// (a getter, a bad value) then fails the constructor with nothing yet held.
|
|
715
|
+
const customChecker = options.checker;
|
|
599
716
|
this.dir = options.dir;
|
|
600
717
|
mkdirSync(this.dir, { recursive: true });
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
this.
|
|
605
|
-
this.
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
718
|
+
// #119: from the claim to the end of the constructor is ONE guard. A constructor that throws
|
|
719
|
+
// leaves no host for anyone to close, so whatever it opened must be released here, and the
|
|
720
|
+
// only way to keep that true as lines are added is that every line after the claim is inside
|
|
721
|
+
// this `try`. Do not add work after it; add it inside, before `this.owner`.
|
|
722
|
+
const owner = claimHostDir(this.dir);
|
|
723
|
+
let directory;
|
|
724
|
+
try {
|
|
725
|
+
directory = new Database(join(this.dir, '_directory.sqlite'));
|
|
726
|
+
this.directory = directory;
|
|
727
|
+
this.directory.pragma('journal_mode = WAL');
|
|
728
|
+
this.ensureDirectorySchema();
|
|
729
|
+
this.loadRoles();
|
|
730
|
+
this.checker =
|
|
731
|
+
customChecker ??
|
|
732
|
+
createTupleChecker({
|
|
733
|
+
directory: this.directory,
|
|
734
|
+
scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
|
|
735
|
+
getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
|
|
736
|
+
// #956: the evaluator judges `expires_at` against the host's clock, so a
|
|
737
|
+
// scripted one can actually expire a grant. A caller-supplied `checker`
|
|
738
|
+
// keeps its own time source — this only binds the built-in one.
|
|
739
|
+
clock: () => this.clock(),
|
|
740
|
+
});
|
|
741
|
+
this.admin = this.buildAdmin();
|
|
742
|
+
this.owner = owner;
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
// A host that never finished opening owns nothing: the next one may open the directory.
|
|
746
|
+
directory?.close();
|
|
747
|
+
owner.release();
|
|
748
|
+
throw err;
|
|
749
|
+
}
|
|
617
750
|
}
|
|
618
751
|
/**
|
|
619
752
|
* The directory schema, applied on every open, and inside a restore's transaction onto an
|
|
@@ -641,7 +774,7 @@ export class SqliteScopeHost {
|
|
|
641
774
|
* recorded answer to #969; `docs/architecture/kernel-design.md` §8 says why.
|
|
642
775
|
*/
|
|
643
776
|
applyDirectorySchema() {
|
|
644
|
-
this.directory
|
|
777
|
+
execSqlStatements(this.directory, `
|
|
645
778
|
-- The tenant registry (control-plane.md §4.1). Before this a tenant was an
|
|
646
779
|
-- FK string on scope rows; now it is a real record with a lifecycle status.
|
|
647
780
|
CREATE TABLE IF NOT EXISTS tenants (
|
|
@@ -700,6 +833,22 @@ export class SqliteScopeHost {
|
|
|
700
833
|
archived_at TEXT,
|
|
701
834
|
created_at TEXT NOT NULL
|
|
702
835
|
);
|
|
836
|
+
-- Private host state. Module SQL opens only the scope file, never this directory;
|
|
837
|
+
-- neither table is included in directory exports or scope dumps (#2074).
|
|
838
|
+
CREATE TABLE IF NOT EXISTS private_continuation_keys (
|
|
839
|
+
scope_id TEXT PRIMARY KEY,
|
|
840
|
+
keyring TEXT NOT NULL
|
|
841
|
+
);
|
|
842
|
+
CREATE TABLE IF NOT EXISTS private_continuation_positions (
|
|
843
|
+
scope_id TEXT NOT NULL,
|
|
844
|
+
expires_at INTEGER NOT NULL,
|
|
845
|
+
locator TEXT NOT NULL,
|
|
846
|
+
ciphertext TEXT NOT NULL,
|
|
847
|
+
PRIMARY KEY (scope_id, expires_at, locator)
|
|
848
|
+
);
|
|
849
|
+
CREATE INDEX IF NOT EXISTS private_continuation_positions_expiry
|
|
850
|
+
ON private_continuation_positions (expires_at);
|
|
851
|
+
${PEER_BINDINGS_DDL}
|
|
703
852
|
-- The hostname map (K-26). A single environment-wide router resolves against
|
|
704
853
|
-- this before dispatching to the vertical's worker.
|
|
705
854
|
--
|
|
@@ -1000,6 +1149,9 @@ export class SqliteScopeHost {
|
|
|
1000
1149
|
updated_at TEXT NOT NULL,
|
|
1001
1150
|
PRIMARY KEY (connection_id, state_key)
|
|
1002
1151
|
);
|
|
1152
|
+
-- A vertical's mailed connect links (connections.md §3.5.4) — kernel-owned DDL, so
|
|
1153
|
+
-- both adapters build the same table.
|
|
1154
|
+
${CONNECT_LINKS_DDL}
|
|
1003
1155
|
CREATE TABLE IF NOT EXISTS _substrat_identities (
|
|
1004
1156
|
provider TEXT NOT NULL,
|
|
1005
1157
|
external_id TEXT NOT NULL,
|
|
@@ -1087,14 +1239,9 @@ export class SqliteScopeHost {
|
|
|
1087
1239
|
on_behalf_of TEXT,
|
|
1088
1240
|
at TEXT NOT NULL
|
|
1089
1241
|
);
|
|
1090
|
-
--
|
|
1091
|
-
--
|
|
1092
|
-
|
|
1093
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_tenant ON _substrat_admin_log (tenant_id, id);
|
|
1094
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_scope ON _substrat_admin_log (scope_id, id);
|
|
1095
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_actor ON _substrat_admin_log (actor, id);
|
|
1096
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_action ON _substrat_admin_log (action, id);
|
|
1097
|
-
CREATE INDEX IF NOT EXISTS _substrat_admin_log_at ON _substrat_admin_log (at);
|
|
1242
|
+
-- Every admin-log index, from the kernel's one list (#2064), which the legacy
|
|
1243
|
+
-- rebuild below runs again after its rename.
|
|
1244
|
+
${ADMIN_LOG_INDEXES_SQL}
|
|
1098
1245
|
-- Operational failures (#559): what the platform could NOT do. Unlike the
|
|
1099
1246
|
-- never-swept admin log above, this is retention-bounded telemetry, pruned
|
|
1100
1247
|
-- on write (OPS_FAILURE_RETENTION_DAYS). reference carries the upstream
|
|
@@ -1146,6 +1293,8 @@ export class SqliteScopeHost {
|
|
|
1146
1293
|
resolved_at TEXT
|
|
1147
1294
|
);
|
|
1148
1295
|
CREATE INDEX IF NOT EXISTS _substrat_issues_seen ON _substrat_issues (last_seen);
|
|
1296
|
+
-- #1748: tenant findings and their suppress rules — kernel-owned DDL (findings.ts).
|
|
1297
|
+
${FINDINGS_DDL}
|
|
1149
1298
|
-- The durable sweep record (#1232): one row per unit outcome per pass —
|
|
1150
1299
|
-- a connection swept/skipped/failed, a schedule fired/skipped/failed. What
|
|
1151
1300
|
-- makes "when was this last swept" answerable after the log line rolls off.
|
|
@@ -1393,6 +1542,11 @@ export class SqliteScopeHost {
|
|
|
1393
1542
|
if (this.modules.has(manifest.id)) {
|
|
1394
1543
|
throw new Error(`module already registered: ${manifest.id}`);
|
|
1395
1544
|
}
|
|
1545
|
+
// #119: refused before anything is recorded, so a module the trash rules refuse leaves nothing.
|
|
1546
|
+
const trashTargets = registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
|
|
1547
|
+
// #2068: refused here, before anything is recorded, when the module claims an erasure it
|
|
1548
|
+
// cannot deliver (a hook with no reach declared, a `custom` entity with no hook).
|
|
1549
|
+
const erasure = moduleErasurePlan(registration);
|
|
1396
1550
|
const migrations = registration.migrations ?? [];
|
|
1397
1551
|
const seen = new Set();
|
|
1398
1552
|
for (const m of migrations) {
|
|
@@ -1458,14 +1612,17 @@ export class SqliteScopeHost {
|
|
|
1458
1612
|
// #811: the list indexes this module's `lists` declare — same placement and
|
|
1459
1613
|
// the same reason as the search indexes above: appended AFTER the module's
|
|
1460
1614
|
// own migrations, so `CREATE INDEX` names a table that exists.
|
|
1461
|
-
|
|
1462
|
-
|
|
1615
|
+
// #119: the archive/trash columns, and the plans the verbs and the reads consult.
|
|
1616
|
+
const stateMigrations = entityStateMigrations(manifest.id, manifest.entityStates);
|
|
1617
|
+
const listMigrations = listIndexMigrations(manifest.id, manifest.lists, manifest.entityStates);
|
|
1618
|
+
for (const m of [...stateMigrations, ...listMigrations]) {
|
|
1463
1619
|
if (seen.has(m.version)) {
|
|
1464
1620
|
throw new Error(`duplicate migration version in ${manifest.id}: ${m.version}`);
|
|
1465
1621
|
}
|
|
1466
1622
|
seen.add(m.version);
|
|
1467
1623
|
}
|
|
1468
|
-
|
|
1624
|
+
addStatePlans(this.statePlans, manifest.id, manifest.entityStates, manifest.permissions);
|
|
1625
|
+
for (const plan of listIndexPlans(manifest.id, manifest.lists, manifest.entityStates)) {
|
|
1469
1626
|
const existing = this.listPlans.get(plan.entityType);
|
|
1470
1627
|
if (existing) {
|
|
1471
1628
|
throw new Error(`list: '${plan.entityType}' declares a paged list in both '${existing.moduleId}' and ` +
|
|
@@ -1480,11 +1637,15 @@ export class SqliteScopeHost {
|
|
|
1480
1637
|
// The order the kernel writes once (#1677): authored, then search, then list indexes.
|
|
1481
1638
|
// The two derived sets are still computed above, for the duplicate-version refusals.
|
|
1482
1639
|
migrations: moduleMigrations(registration),
|
|
1640
|
+
steps: migrationSteps(registration),
|
|
1483
1641
|
consumers,
|
|
1484
1642
|
schedules: manifest.schedules ?? [],
|
|
1485
1643
|
freshness: manifest.freshness ?? [],
|
|
1486
1644
|
peers: manifest.peers ?? [],
|
|
1645
|
+
purgeOnlyKeys: purgeOnlyKeysOf(manifest.schedules ?? []),
|
|
1487
1646
|
});
|
|
1647
|
+
if (erasure)
|
|
1648
|
+
this.erasurePlans.push(erasure);
|
|
1488
1649
|
for (const rel of manifest.entityRelations ?? []) {
|
|
1489
1650
|
const parents = this.relations.get(rel.entityType) ?? new Set();
|
|
1490
1651
|
parents.add(rel.parentType);
|
|
@@ -1571,6 +1732,9 @@ export class SqliteScopeHost {
|
|
|
1571
1732
|
if (declaredOptOuts.includes(name) && this.operations.has(name)) {
|
|
1572
1733
|
this.operationIdempotencyOptOut.add(name);
|
|
1573
1734
|
}
|
|
1735
|
+
const target = trashTargets.get(name);
|
|
1736
|
+
if (target && this.operations.has(name))
|
|
1737
|
+
this.operationTarget.set(name, target);
|
|
1574
1738
|
}
|
|
1575
1739
|
}
|
|
1576
1740
|
defineOperation(name, handler) {
|
|
@@ -2234,13 +2398,16 @@ export class SqliteScopeHost {
|
|
|
2234
2398
|
// A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
|
|
2235
2399
|
// not keep at all; they are skipped by name, and any other unknown spine table is refused.
|
|
2236
2400
|
const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
|
|
2401
|
+
// Comment-blanked (#2068), as the Durable Object replays it: the dump's DDL is
|
|
2402
|
+
// `sqlite_master.sql` verbatim, and `prepare` still compiles exactly one statement.
|
|
2237
2403
|
for (const t of loadable)
|
|
2238
2404
|
if (!isSpineTable(t.name))
|
|
2239
|
-
db.prepare(t.ddl).run();
|
|
2240
|
-
db
|
|
2405
|
+
db.prepare(blankSqlComments(t.ddl)).run();
|
|
2406
|
+
execSqlStatements(db, KERNEL_DDL);
|
|
2241
2407
|
this.ensureSpineColumns(db);
|
|
2242
2408
|
const columnsOf = (name) => builtColumnsOf(db, name);
|
|
2243
2409
|
assertSpineTablesBuilt(loadable.map((t) => t.name), columnsOf);
|
|
2410
|
+
assertJournalDumpCoherent(loadable);
|
|
2244
2411
|
// A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
|
|
2245
2412
|
// untyped column the checker never reads.
|
|
2246
2413
|
for (const t of loadable) {
|
|
@@ -2256,16 +2423,15 @@ export class SqliteScopeHost {
|
|
|
2256
2423
|
for (const row of t.rows)
|
|
2257
2424
|
stmt.run(...row);
|
|
2258
2425
|
}
|
|
2259
|
-
//
|
|
2260
|
-
//
|
|
2261
|
-
//
|
|
2262
|
-
//
|
|
2263
|
-
//
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
}
|
|
2426
|
+
// #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
|
|
2427
|
+
// sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows that are
|
|
2428
|
+
// gone. Put back, and the search index rebuilt, AFTER the rows — which may legitimately
|
|
2429
|
+
// arrive archived or trashed — as the dump's own journal says they were derived (#2090).
|
|
2430
|
+
// A plan whose table this dump did not carry is skipped: a restore must not invent one.
|
|
2431
|
+
repairDerivedObjects(spineSql(db), (ddl) => execSqlStatements(db, ddl), this.derivedPlans(), {
|
|
2432
|
+
after: 'the restored dump',
|
|
2433
|
+
absentTable: 'skip',
|
|
2434
|
+
});
|
|
2269
2435
|
// #1575: attachment text is not in a dump, so a load left it as it was. Drop the
|
|
2270
2436
|
// text of attachments the dump did not bring back, and queue extraction for those
|
|
2271
2437
|
// it brought back without text — the bytes decide what that run finds.
|
|
@@ -2309,10 +2475,11 @@ export class SqliteScopeHost {
|
|
|
2309
2475
|
}
|
|
2310
2476
|
// The frontier came in with the dump — refresh the cached applied-migration set so
|
|
2311
2477
|
// a later bind/migrate builds on the loaded state, not the previous one.
|
|
2312
|
-
rt.appliedMigrations
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2478
|
+
rt.appliedMigrations = readAppliedMigrations(db);
|
|
2479
|
+
// The directory is a separate database, but the actor serializes the load
|
|
2480
|
+
// and the next invocation. Old locators must be gone before that next turn.
|
|
2481
|
+
this.directory.prepare('DELETE FROM private_continuation_positions WHERE scope_id = ?').run(scopeId);
|
|
2482
|
+
this.directory.prepare('DELETE FROM private_continuation_keys WHERE scope_id = ?').run(scopeId);
|
|
2316
2483
|
});
|
|
2317
2484
|
}
|
|
2318
2485
|
async restoreScope(actor, tenantId, scopeId, dump, opts) {
|
|
@@ -2375,8 +2542,13 @@ export class SqliteScopeHost {
|
|
|
2375
2542
|
// orphaned bytes with no record (the §9 hazard).
|
|
2376
2543
|
this.directory.prepare('DELETE FROM hostnames WHERE scope_id = ?').run(scopeId);
|
|
2377
2544
|
rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
|
|
2378
|
-
|
|
2379
|
-
|
|
2545
|
+
this.directory.transaction(() => {
|
|
2546
|
+
this.directory.prepare('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?').run(scopeId);
|
|
2547
|
+
forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
2548
|
+
this.directory.prepare('DELETE FROM private_continuation_positions WHERE scope_id = ?').run(scopeId);
|
|
2549
|
+
this.directory.prepare('DELETE FROM private_continuation_keys WHERE scope_id = ?').run(scopeId);
|
|
2550
|
+
this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
|
|
2551
|
+
})();
|
|
2380
2552
|
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
2381
2553
|
forkedFrom: rec.forkedFrom,
|
|
2382
2554
|
forkedAt: rec.forkedAt,
|
|
@@ -2529,6 +2701,14 @@ export class SqliteScopeHost {
|
|
|
2529
2701
|
return this.buildStub(tenantId, scopeId, rt, asPrincipal(record.principal), options, record.id);
|
|
2530
2702
|
}
|
|
2531
2703
|
async getSystemScope(moduleId, tenantId, scopeId) {
|
|
2704
|
+
return this.openSystemScope(moduleId, tenantId, scopeId, false);
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* The system door's stub. `purge` (#119) is set only by `runPurgeSweep`, which never hands the
|
|
2708
|
+
* stub on: it is the one stub whose invokes may use the module's purge-only keys, and only on
|
|
2709
|
+
* the purge operation. Every stub any caller can get is built with it off.
|
|
2710
|
+
*/
|
|
2711
|
+
async openSystemScope(moduleId, tenantId, scopeId, purge) {
|
|
2532
2712
|
// The scheduler's door (#383) — mirror of getConnectorScope. The module must be
|
|
2533
2713
|
// registered on this host (a schedule can only invoke its own vertical's ops),
|
|
2534
2714
|
// and the scope must be active. Authority is then an ordinary check against
|
|
@@ -2547,7 +2727,7 @@ export class SqliteScopeHost {
|
|
|
2547
2727
|
}
|
|
2548
2728
|
const rt = this.runtime(tenantId, scopeId);
|
|
2549
2729
|
await this.applyPendingMigrations(rt);
|
|
2550
|
-
return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
|
|
2730
|
+
return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId }, undefined, undefined, purge);
|
|
2551
2731
|
}
|
|
2552
2732
|
/**
|
|
2553
2733
|
* #1705: the producer's release, decided by THIS deployment's own declarations. The admin
|
|
@@ -2685,16 +2865,14 @@ export class SqliteScopeHost {
|
|
|
2685
2865
|
const { hops: _hops, ...fact } = e;
|
|
2686
2866
|
const event = structuredClone({ ...fact, source });
|
|
2687
2867
|
const unit = unitOf(imp.moduleId, e.id, e.type);
|
|
2688
|
-
// What the handler emits was emitted BECAUSE of the producer's event (#1237). The id
|
|
2689
|
-
// resolves through `_substrat_imports`, which the row above has just written.
|
|
2690
|
-
rt.causedBy = e.id;
|
|
2691
|
-
rt.unitInvocationId = unit.invocationId;
|
|
2692
2868
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
2693
2869
|
try {
|
|
2694
2870
|
// As the producer's principal, admitted above: the handler's checks are real ones,
|
|
2695
2871
|
// against the grants this vertical's `peers` gave it, and its emits carry the actor
|
|
2696
|
-
// `{ vertical, scope }` and the authorization they passed (K-34).
|
|
2697
|
-
|
|
2872
|
+
// `{ vertical, scope }` and the authorization they passed (K-34). What it emits was
|
|
2873
|
+
// emitted BECAUSE of the producer's event (#1237); the id resolves through
|
|
2874
|
+
// `_substrat_imports`, which the row above has just written.
|
|
2875
|
+
await imp.handler(this.deliveryContext(rt, subject, undefined, { causedBy: e.id, invocationId: unit.invocationId }), event);
|
|
2698
2876
|
rt.db
|
|
2699
2877
|
.prepare(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
|
|
2700
2878
|
VALUES (?, ?, ?, NULL)`)
|
|
@@ -2711,10 +2889,6 @@ export class SqliteScopeHost {
|
|
|
2711
2889
|
result.deadLettered += 1;
|
|
2712
2890
|
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
2713
2891
|
}
|
|
2714
|
-
finally {
|
|
2715
|
-
rt.causedBy = null;
|
|
2716
|
-
rt.unitInvocationId = null;
|
|
2717
|
-
}
|
|
2718
2892
|
}
|
|
2719
2893
|
if (!ran)
|
|
2720
2894
|
result.duplicates += 1;
|
|
@@ -2925,13 +3099,35 @@ export class SqliteScopeHost {
|
|
|
2925
3099
|
const target = principalId.parse(assignee);
|
|
2926
3100
|
return rt.actor.turn(async () => {
|
|
2927
3101
|
const bound = await this.assignmentBound(subject, tenantId, scopeId, roleKey);
|
|
2928
|
-
if (bound.covered)
|
|
2929
|
-
rt.db
|
|
2930
|
-
VALUES (?, ?, ?, NULL)`).run(`principal:${target}`, `role:${roleKey}`, `scope:${scopeId}`);
|
|
2931
|
-
}
|
|
3102
|
+
if (bound.covered)
|
|
3103
|
+
applyScopeRoleChange(switchSqlOf(rt.db), scopeId, target, { revoke: [], grant: roleKey }, this.clock());
|
|
2932
3104
|
return bound;
|
|
2933
3105
|
});
|
|
2934
3106
|
}
|
|
3107
|
+
async listScopeRoleHolders(tenantId, scopeId, principal) {
|
|
3108
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
3109
|
+
const who = principal === undefined ? undefined : principalId.parse(principal);
|
|
3110
|
+
return rt.actor.enqueue(() => scopeRoleHolders(switchSqlOf(rt.db), scopeId, this.clock(), who));
|
|
3111
|
+
}
|
|
3112
|
+
async changeScopeRoleBounded(tenantId, scopeId, caller, principal, from, to) {
|
|
3113
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
3114
|
+
const target = principalId.parse(principal);
|
|
3115
|
+
const answer = await rt.actor.turn(() => changeScopeRole(switchSqlOf(rt.db), scopeId, target, from, to, this.clock(), this.roleBound(caller, tenantId, scopeId), (run) => rt.db.transaction(run)()));
|
|
3116
|
+
if (answer === 'not-held')
|
|
3117
|
+
throw substratError('conflict', `${target} does not hold '${from}' at this scope`);
|
|
3118
|
+
if (answer === 'unknown-to')
|
|
3119
|
+
throw unknownRoleError(to);
|
|
3120
|
+
return answer;
|
|
3121
|
+
}
|
|
3122
|
+
async revokeScopeRolesBounded(tenantId, scopeId, caller, principal) {
|
|
3123
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
3124
|
+
return rt.actor.turn(() => revokeScopeRoles(switchSqlOf(rt.db), scopeId, principalId.parse(principal), this.clock(), this.roleBound(caller, tenantId, scopeId), (run) => rt.db.transaction(run)()));
|
|
3125
|
+
}
|
|
3126
|
+
/** The caller's bound per role at the scope, `null` for a role this tenant does not define (#1150). */
|
|
3127
|
+
roleBound(caller, tenantId, scopeId) {
|
|
3128
|
+
const subject = asPrincipal(principalId.parse(caller));
|
|
3129
|
+
return async (roleKey) => this.roles.has(`${tenantId}/${roleKey}`) ? this.assignmentBound(subject, tenantId, scopeId, roleKey) : null;
|
|
3130
|
+
}
|
|
2935
3131
|
/**
|
|
2936
3132
|
* §5.1's assignment bound, resolved against the SAME role table the checker expands
|
|
2937
3133
|
* — not a vertical's compile-time `ROLES` array. A tenant's projected role is what
|
|
@@ -3146,19 +3342,34 @@ export class SqliteScopeHost {
|
|
|
3146
3342
|
const startedAt = Date.now();
|
|
3147
3343
|
let emitted;
|
|
3148
3344
|
let failure;
|
|
3345
|
+
// #119, #2096: more of a purge horizon's lap is due — the schedule stays due, so the next pass continues it.
|
|
3346
|
+
let stillDue = false;
|
|
3149
3347
|
try {
|
|
3150
|
-
|
|
3151
|
-
|
|
3348
|
+
if (schedule.purge) {
|
|
3349
|
+
// #119: a purge horizon's schedule runs its operation once per due entity, never once.
|
|
3350
|
+
const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation);
|
|
3351
|
+
stillDue = purgeStillDue(pass);
|
|
3352
|
+
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
3353
|
+
report.errors.push(...outcome.errors);
|
|
3354
|
+
if (outcome.failure)
|
|
3355
|
+
throw outcome.failure;
|
|
3356
|
+
}
|
|
3357
|
+
else {
|
|
3358
|
+
const stub = await this.getSystemScope(moduleId, tenantId, scopeId);
|
|
3359
|
+
await stub.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
|
|
3360
|
+
}
|
|
3152
3361
|
report.fired += 1;
|
|
3153
3362
|
}
|
|
3154
3363
|
catch (err) {
|
|
3155
3364
|
status = 'failed';
|
|
3156
3365
|
failure = { error: err };
|
|
3157
3366
|
report.failed += 1;
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3367
|
+
if (!schedule.purge) {
|
|
3368
|
+
report.errors.push({
|
|
3369
|
+
operation: schedule.operation,
|
|
3370
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3371
|
+
});
|
|
3372
|
+
}
|
|
3162
3373
|
}
|
|
3163
3374
|
lines.write({
|
|
3164
3375
|
kind: 'schedule',
|
|
@@ -3175,6 +3386,9 @@ export class SqliteScopeHost {
|
|
|
3175
3386
|
...(emitted ? { emitted } : {}),
|
|
3176
3387
|
versionId: this.versionId,
|
|
3177
3388
|
});
|
|
3389
|
+
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
3390
|
+
if (stillDue)
|
|
3391
|
+
continue;
|
|
3178
3392
|
// On the scope actor (#1678): the cadence row joined whatever transaction an invoke
|
|
3179
3393
|
// held open, so its rollback forgot the run and the next pass fired it AGAIN.
|
|
3180
3394
|
await rt.actor.turn(() => rt.db
|
|
@@ -3184,11 +3398,65 @@ export class SqliteScopeHost {
|
|
|
3184
3398
|
last_status = excluded.last_status,
|
|
3185
3399
|
invocation_id = excluded.invocation_id`)
|
|
3186
3400
|
.run(schedule.operation, new Date(now).toISOString(), status, invocationId));
|
|
3187
|
-
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
3188
3401
|
}
|
|
3189
3402
|
lines.end();
|
|
3190
3403
|
return report;
|
|
3191
3404
|
}
|
|
3405
|
+
/**
|
|
3406
|
+
* One pass of a purge horizon's schedule on one scope (#119) — the Durable Object's
|
|
3407
|
+
* `runPurgeSweep`, mirrored. The caller names only which schedule: the module and the entity come
|
|
3408
|
+
* from the registered schedule, the cutoff from this host's clock and the declared horizon, the
|
|
3409
|
+
* ids from the scope's own bin, read in a turn. Then the purge operation is invoked once per id
|
|
3410
|
+
* through a purge stub (`openSystemScope`) that never leaves this method, each in its own
|
|
3411
|
+
* transaction and under the cutoff the host re-checks inside it (`refuseTrashedTarget`).
|
|
3412
|
+
*/
|
|
3413
|
+
async runPurgeSweep(moduleId, tenantId, scopeId, operation) {
|
|
3414
|
+
const entityType = this.modules
|
|
3415
|
+
.get(moduleId)
|
|
3416
|
+
?.schedules.find((s) => s.operation === operation && s.purge)?.purge?.entityType;
|
|
3417
|
+
if (entityType === undefined)
|
|
3418
|
+
throw substratError('not_found', `no purge schedule of ${moduleId} runs ${operation}`);
|
|
3419
|
+
// The pair first, from the directory, before the scope's storage is opened for it.
|
|
3420
|
+
const owner = this.directory.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
3421
|
+
if (!owner || owner.tenant_id !== tenantId)
|
|
3422
|
+
throw substratError('not_found', `unknown scope: ${scopeId}`);
|
|
3423
|
+
this.directory.prepare('DELETE FROM private_continuation_positions WHERE expires_at <= ?')
|
|
3424
|
+
.run(Date.parse(this.clock()));
|
|
3425
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
3426
|
+
await this.applyPendingMigrations(rt);
|
|
3427
|
+
const now = this.clock();
|
|
3428
|
+
// #119: the gate the schedule run applies, applied by the sweep itself (`purgeHeldBy`), and
|
|
3429
|
+
// again inside each purge's transaction. A held scope selects nothing.
|
|
3430
|
+
const due = await rt.actor.turn(() => {
|
|
3431
|
+
const held = purgeHeldBy(this.purgeGateFacts(rt, moduleId, tenantId, scopeId));
|
|
3432
|
+
return held !== null
|
|
3433
|
+
? { held }
|
|
3434
|
+
: purgeDueOf(spineSql(rt.db), this.statePlans, this.operationTarget, operation, entityType, now);
|
|
3435
|
+
});
|
|
3436
|
+
if ('held' in due)
|
|
3437
|
+
return heldPurgePass(due.held);
|
|
3438
|
+
const stub = await this.openSystemScope(moduleId, tenantId, scopeId, true);
|
|
3439
|
+
return runPurgePass(operation, due, async (entityId) => {
|
|
3440
|
+
await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: ulid() });
|
|
3441
|
+
}, (write) => rt.actor.turn(() => write(spineSql(rt.db))));
|
|
3442
|
+
}
|
|
3443
|
+
/**
|
|
3444
|
+
* #119: the purge gate's facts (`PurgeGateFacts`) — the module's switch from the scope's own
|
|
3445
|
+
* storage, and the lifecycle, the primacy and the tenant from this host's directory, which is
|
|
3446
|
+
* their authority here.
|
|
3447
|
+
*/
|
|
3448
|
+
purgeGateFacts(rt, moduleId, tenantId, scopeId) {
|
|
3449
|
+
const row = this.directory
|
|
3450
|
+
.prepare(`SELECT s.tenant_id AS tenant_id, s.status AS scope, t.status AS tenant
|
|
3451
|
+
FROM scopes s LEFT JOIN tenants t ON t.tenant_id = s.tenant_id WHERE s.scope_id = ?`)
|
|
3452
|
+
.get(scopeId);
|
|
3453
|
+
return {
|
|
3454
|
+
switched: systemScheduleState(switchSqlOf(rt.db), moduleId, this.clock()),
|
|
3455
|
+
lifecycle: row ? lifecycleRefusal({ scope: row.scope, tenant: row.tenant ?? 'active' }) : null,
|
|
3456
|
+
copy: !this.isPrimaryInDirectory(scopeId),
|
|
3457
|
+
foreignTenant: row?.tenant_id !== tenantId,
|
|
3458
|
+
};
|
|
3459
|
+
}
|
|
3192
3460
|
/** The stub body, shared by the principal, connection, system, impersonation and capability doors. */
|
|
3193
3461
|
buildStub(tenantId, scopeId, rt,
|
|
3194
3462
|
/**
|
|
@@ -3204,7 +3472,9 @@ export class SqliteScopeHost {
|
|
|
3204
3472
|
* about them with no override branch anywhere — and this is what every
|
|
3205
3473
|
* record additionally keeps, plus the bound a read-only session is held to.
|
|
3206
3474
|
*/
|
|
3207
|
-
sessionId
|
|
3475
|
+
sessionId,
|
|
3476
|
+
/** #119: a purge stub — `openSystemScope`'s, built only by `runPurgeSweep`, never handed on. */
|
|
3477
|
+
purge = false) {
|
|
3208
3478
|
const operations = this.operations;
|
|
3209
3479
|
return {
|
|
3210
3480
|
tenantId,
|
|
@@ -3218,6 +3488,15 @@ export class SqliteScopeHost {
|
|
|
3218
3488
|
: authority.kind,
|
|
3219
3489
|
invoke: async (operation, input, invokeOptions) => {
|
|
3220
3490
|
const handler = operations.get(operation);
|
|
3491
|
+
// #119: purge authority is never an invoke option — only a purge stub, which
|
|
3492
|
+
// `runPurgeSweep` builds and keeps, purges — and an options object naming a cutoff is
|
|
3493
|
+
// refused outright.
|
|
3494
|
+
try {
|
|
3495
|
+
assertNoCallerPurge(invokeOptions);
|
|
3496
|
+
}
|
|
3497
|
+
catch (err) {
|
|
3498
|
+
return Promise.reject(err);
|
|
3499
|
+
}
|
|
3221
3500
|
// `not_found`, not a bare throw (#113) — the same code `adapter-cloudflare`
|
|
3222
3501
|
// gives it, so a demo and a hosted vertical answer 404 for the same reason.
|
|
3223
3502
|
if (!handler)
|
|
@@ -3329,7 +3608,15 @@ export class SqliteScopeHost {
|
|
|
3329
3608
|
const minted = [];
|
|
3330
3609
|
// Fresh per operation: the context carries the K-34 authorization accumulator,
|
|
3331
3610
|
// which must not leak across operations (invokes are serialized per scope).
|
|
3332
|
-
|
|
3611
|
+
// #119: the keys the module's system principal holds only for its purge schedules are
|
|
3612
|
+
// withheld from every call but the purge sweep's own invoke of the purge operation.
|
|
3613
|
+
const target = this.operationTarget.get(operation);
|
|
3614
|
+
// Through the system door only: the sweep's mark on any other subject's call means nothing.
|
|
3615
|
+
const purging = purge && subject.kind === 'system';
|
|
3616
|
+
const withheld = subject.kind === 'system'
|
|
3617
|
+
? withheldKeysFor(this.modules.get(subject.id)?.purgeOnlyKeys, target, purging)
|
|
3618
|
+
: undefined;
|
|
3619
|
+
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted, undefined, undefined, withheld);
|
|
3333
3620
|
const clonedInput = structuredClone(input);
|
|
3334
3621
|
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
3335
3622
|
// declaration. BEFORE `BEGIN`, so a malformed call never opens a
|
|
@@ -3396,11 +3683,28 @@ export class SqliteScopeHost {
|
|
|
3396
3683
|
// so the only loss is effort on a path that was already failing.
|
|
3397
3684
|
const ref = guarded ? this.concurrencyRef(operation, guarded, parsed) : undefined;
|
|
3398
3685
|
const seen = ref && invokeOptions?.ifMatch !== undefined ? this.versionAt(rt, ref) : undefined;
|
|
3686
|
+
// #119: an operation on a TRASHED entity it did not declare it reaches is refused
|
|
3687
|
+
// here, before the guards and the handler — the declared key first, so a caller
|
|
3688
|
+
// without it meets the same `forbidden` an active entity gives (`entity-trash.ts`).
|
|
3689
|
+
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);
|
|
3399
3690
|
// Manifest guards (K-17): pre-conditions, inside the operation's own
|
|
3400
3691
|
// transaction, before the handler. A throw here blocks the operation
|
|
3401
3692
|
// and rolls back exactly like a handler throw — fail closed.
|
|
3402
3693
|
await this.runGuards(operation, ctx, parsed);
|
|
3403
3694
|
result = await handler(ctx, parsed);
|
|
3695
|
+
// #119: the purge gate's AUTHORITATIVE read. Lifecycle, primacy and tenant live in the
|
|
3696
|
+
// directory, which this transaction does not cover and whose writers (a suspend, a
|
|
3697
|
+
// tenant status, a reclassification) do not take the scope's actor — so a hold could
|
|
3698
|
+
// commit while the handler above was awaiting. From here to `COMMIT` nothing awaits:
|
|
3699
|
+
// better-sqlite3 is synchronous and this process's JS single-threaded, so nothing in
|
|
3700
|
+
// this process — another host on the same directory included — can write between this
|
|
3701
|
+
// read and the commit of the delete; and this process owns the directory
|
|
3702
|
+
// (`claimHostDir`), so no other process can either.
|
|
3703
|
+
if (purging) {
|
|
3704
|
+
const held = purgeHeldBy(this.purgeGateFacts(rt, subject.id, tenantId, scopeId));
|
|
3705
|
+
if (held !== null)
|
|
3706
|
+
throw substratError('conflict', `${operation}: ${held}`, { reason: 'purge_held' });
|
|
3707
|
+
}
|
|
3404
3708
|
if (ref && invokeOptions?.ifMatch !== undefined) {
|
|
3405
3709
|
assertIfMatch(ref, invokeOptions.ifMatch, seen ?? null);
|
|
3406
3710
|
}
|
|
@@ -3553,6 +3857,7 @@ export class SqliteScopeHost {
|
|
|
3553
3857
|
for (const rt of [...this.scopes.values()])
|
|
3554
3858
|
this.closeRuntime(rt);
|
|
3555
3859
|
this.directory.close();
|
|
3860
|
+
this.owner.release();
|
|
3556
3861
|
}
|
|
3557
3862
|
// -------------------------------------------------------------------------
|
|
3558
3863
|
// Event dispatch (testrun spec §9.2.3): at-least-once, kernel-journaled,
|
|
@@ -3683,16 +3988,17 @@ export class SqliteScopeHost {
|
|
|
3683
3988
|
lines.write({ ...unit, attempt: attempts, outcome: 'inert' });
|
|
3684
3989
|
continue;
|
|
3685
3990
|
}
|
|
3686
|
-
|
|
3991
|
+
// #2055: the handler writes through a view bound to its event, never through the host.
|
|
3992
|
+
const caused = this.causedByView(event.id);
|
|
3687
3993
|
try {
|
|
3688
3994
|
let result;
|
|
3689
3995
|
if (executor.kind === 'connector') {
|
|
3690
3996
|
// `true`: dispatchExecutors is only ever reached from inside
|
|
3691
3997
|
// `rt.actor.enqueue` (invoke's post-commit tail, or drainDue).
|
|
3692
|
-
await executor.handler(
|
|
3998
|
+
await executor.handler(caused.connectorContext(rt, executor.timeoutMs, true, event.id), event);
|
|
3693
3999
|
}
|
|
3694
4000
|
else {
|
|
3695
|
-
result = await executor.handler(
|
|
4001
|
+
result = await executor.handler(caused.admin, event, scope);
|
|
3696
4002
|
}
|
|
3697
4003
|
// #1184: a refusal is the handler's own terminal decision — journaled with its
|
|
3698
4004
|
// reason, never retried, and listed by `executorDeadLetters` beside an exhausted one.
|
|
@@ -3719,9 +4025,6 @@ export class SqliteScopeHost {
|
|
|
3719
4025
|
outcomeOf(dead ? 'dead-lettered' : 'retrying', err);
|
|
3720
4026
|
lines.write({ ...unit, attempt: attempts, outcome: dead ? 'dead-lettered' : 'retrying', error: err });
|
|
3721
4027
|
}
|
|
3722
|
-
finally {
|
|
3723
|
-
this.causedBy = null;
|
|
3724
|
-
}
|
|
3725
4028
|
}
|
|
3726
4029
|
}
|
|
3727
4030
|
}
|
|
@@ -3948,7 +4251,7 @@ export class SqliteScopeHost {
|
|
|
3948
4251
|
write: (attachmentId, outcome) => rt.actor.enqueue(() => recordAttachmentText(spineSql(rt.db), attachmentId, outcome, this.clock())),
|
|
3949
4252
|
},
|
|
3950
4253
|
// K-43: the host's parsers, handed in — this adapter imports none.
|
|
3951
|
-
this.attachmentExtractors);
|
|
4254
|
+
this.attachmentExtractors, this.attachmentTextBounds);
|
|
3952
4255
|
}
|
|
3953
4256
|
async startJobRun(tenantId, scopeId, input) {
|
|
3954
4257
|
const rt = this.runtime(tenantId, scopeId);
|
|
@@ -3958,12 +4261,19 @@ export class SqliteScopeHost {
|
|
|
3958
4261
|
async runDueJobs(tenantId, scopeId, options) {
|
|
3959
4262
|
const rt = this.runtime(tenantId, scopeId);
|
|
3960
4263
|
await this.applyPendingMigrations(rt);
|
|
3961
|
-
// #1575: the kernel's extraction
|
|
3962
|
-
// deployment registers
|
|
3963
|
-
|
|
4264
|
+
// #1575: the kernel's extraction jobs are this host's own, bound to this scope — no
|
|
4265
|
+
// deployment registers them, and none can shadow them. The backfill starts here, on
|
|
4266
|
+
// the drive, so a scope's first request never pays for attachments that predate it.
|
|
4267
|
+
await rt.actor.enqueue(() => startAttachmentTextBackfill(spineSql(rt.db), ulid(), this.clock()));
|
|
4268
|
+
const kernelJobs = {
|
|
4269
|
+
[ATTACHMENT_TEXT_JOB]: this.attachmentTextHandler(rt),
|
|
4270
|
+
[ATTACHMENT_TEXT_BACKFILL_JOB]: attachmentTextBackfillJob({
|
|
4271
|
+
queueBatch: (after) => rt.actor.enqueue(() => rt.db.transaction(() => queueAttachmentTextBackfill(spineSql(rt.db), after, ulid, this.clock()))()),
|
|
4272
|
+
}),
|
|
4273
|
+
};
|
|
3964
4274
|
return runDueJobRuns({
|
|
3965
4275
|
store: this.jobStore(rt),
|
|
3966
|
-
handlerFor: (run) =>
|
|
4276
|
+
handlerFor: (run) => kernelJobFor(run, kernelJobs) ?? this.jobs.get(`${run.module_id}/${run.job}`),
|
|
3967
4277
|
now: this.clock,
|
|
3968
4278
|
openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
|
|
3969
4279
|
maxPasses: options?.maxPasses,
|
|
@@ -3983,15 +4293,10 @@ export class SqliteScopeHost {
|
|
|
3983
4293
|
// Cloudflare one does; the context build is the same one `dispatchExecutors` hands
|
|
3984
4294
|
// an in-process connector.
|
|
3985
4295
|
const rt = this.runtime(tenantId, scopeId);
|
|
3986
|
-
this
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
await handler(this.connectorContext(rt, options?.timeoutMs ?? 30_000, false, event.id), event);
|
|
3991
|
-
}
|
|
3992
|
-
finally {
|
|
3993
|
-
this.causedBy = null;
|
|
3994
|
-
}
|
|
4296
|
+
// `false`: this path deliberately does NOT enqueue, so nothing is held and
|
|
4297
|
+
// the connection's reads take an ordinary serialized turn. Built on a view bound to
|
|
4298
|
+
// the event, so its admin rows carry it and nothing else the host serves does (#2055).
|
|
4299
|
+
await handler(this.causedByView(event.id).connectorContext(rt, options?.timeoutMs ?? 30_000, false, event.id), event);
|
|
3995
4300
|
}
|
|
3996
4301
|
/**
|
|
3997
4302
|
* The scope's own answer to "what may this connection do here" (#726 gap 1) — read
|
|
@@ -4061,17 +4366,17 @@ export class SqliteScopeHost {
|
|
|
4061
4366
|
// Nothing pending FOR THIS HOST → noop, and deliberately no state write: a
|
|
4062
4367
|
// host that does not run the scope's modules must never clear (or overwrite)
|
|
4063
4368
|
// a failure recorded by the deployment that does.
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
}
|
|
4070
|
-
}
|
|
4369
|
+
// A scope that applied different SQL (#2066) is not a noop: the pass below fails it.
|
|
4370
|
+
const plan = await planMigrations(this.modules.values(), rt.appliedMigrations);
|
|
4371
|
+
const pending = plan.pending.length > 0 || plan.diverged !== undefined;
|
|
4372
|
+
// #2090: with nothing pending, the pass still checks and repairs what the kernel derived —
|
|
4373
|
+
// the one way a scope stripped before that check existed is ever put right.
|
|
4071
4374
|
if (!pending)
|
|
4072
|
-
|
|
4375
|
+
rt.derivedRepaired = false;
|
|
4073
4376
|
try {
|
|
4074
|
-
await this.applyPendingMigrations(rt);
|
|
4377
|
+
await this.applyPendingMigrations(rt, plan);
|
|
4378
|
+
if (!pending)
|
|
4379
|
+
return { status: 'noop' };
|
|
4075
4380
|
return { status: 'migrated', schemaVersion: String(rt.appliedMigrations.size) };
|
|
4076
4381
|
}
|
|
4077
4382
|
catch {
|
|
@@ -4129,21 +4434,22 @@ export class SqliteScopeHost {
|
|
|
4129
4434
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
4130
4435
|
const rt = this.runtime(tenantId, scopeId);
|
|
4131
4436
|
await this.applyPendingMigrations(rt);
|
|
4132
|
-
await rt.actor.enqueue(() => {
|
|
4133
|
-
//
|
|
4134
|
-
//
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4437
|
+
await rt.actor.enqueue(async () => {
|
|
4438
|
+
// The kernel's one settle (#2102): compare-and-set on `pending` (#1600 review), and the
|
|
4439
|
+
// outcome event written in the same transaction, only by the settle that moved the row.
|
|
4440
|
+
rt.db.transaction(() => settlePlatformRequestIn(switchSqlOf(rt.db), id, outcome, {
|
|
4441
|
+
tenantId: rt.tenantId,
|
|
4442
|
+
scopeId: rt.scopeId,
|
|
4443
|
+
now: new Date().toISOString(),
|
|
4444
|
+
mintEventId: (ms) => rt.mintEventId(ms),
|
|
4445
|
+
version: this.versionId,
|
|
4446
|
+
}))();
|
|
4447
|
+
// What the event announces reaches this scope's consumers and executors in the same
|
|
4448
|
+
// tail, as an import's events do. A settle that wrote none has nothing to deliver.
|
|
4449
|
+
if (outcome.event) {
|
|
4450
|
+
await this.dispatch(rt, null);
|
|
4451
|
+
await this.dispatchExecutors(rt, null);
|
|
4452
|
+
}
|
|
4147
4453
|
});
|
|
4148
4454
|
}
|
|
4149
4455
|
async dispatch(rt,
|
|
@@ -4211,16 +4517,12 @@ export class SqliteScopeHost {
|
|
|
4211
4517
|
lines.write({ ...unit, outcome: 'dead-lettered' });
|
|
4212
4518
|
continue;
|
|
4213
4519
|
}
|
|
4214
|
-
|
|
4215
|
-
|
|
4520
|
+
// #1237: anything this consumer emits was emitted BECAUSE of this event — where a
|
|
4521
|
+
// backwards walk used to stop. #1901: its `ctx.log` lines join this delivery's line.
|
|
4522
|
+
const ctx = this.deliveryContext(rt, asPrincipal(this.systemPrincipal), { system: mod.id }, {
|
|
4523
|
+
causedBy: event.id,
|
|
4524
|
+
invocationId: unit.invocationId,
|
|
4216
4525
|
});
|
|
4217
|
-
// #1237: anything this consumer emits was emitted BECAUSE of this event.
|
|
4218
|
-
// `dispatchExecutors` already did this for the admin log; the module
|
|
4219
|
-
// consumers never did, which is exactly where a backwards walk stopped.
|
|
4220
|
-
// On the runtime rather than the host — see `ScopeRuntime.causedBy`.
|
|
4221
|
-
rt.causedBy = event.id;
|
|
4222
|
-
// #1901: the handler's `ctx.log` lines join this delivery's line.
|
|
4223
|
-
rt.unitInvocationId = unit.invocationId;
|
|
4224
4526
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
4225
4527
|
try {
|
|
4226
4528
|
await consumer.handler(ctx, event);
|
|
@@ -4244,14 +4546,6 @@ export class SqliteScopeHost {
|
|
|
4244
4546
|
.run(event.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4245
4547
|
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
4246
4548
|
}
|
|
4247
|
-
finally {
|
|
4248
|
-
rt.unitInvocationId = null;
|
|
4249
|
-
// Cleared on BOTH paths. Left set, the id would leak onto every later
|
|
4250
|
-
// emit in this scope — an operation's own event stamped as caused by
|
|
4251
|
-
// whatever happened to be delivered last, which is worse than no cause
|
|
4252
|
-
// at all, because it reads as a recorded fact.
|
|
4253
|
-
rt.causedBy = null;
|
|
4254
|
-
}
|
|
4255
4549
|
}
|
|
4256
4550
|
}
|
|
4257
4551
|
}
|
|
@@ -4532,6 +4826,73 @@ export class SqliteScopeHost {
|
|
|
4532
4826
|
throw new Error(`connection not found: ${id}`);
|
|
4533
4827
|
return row;
|
|
4534
4828
|
}
|
|
4829
|
+
/**
|
|
4830
|
+
* A findings mutation (#1748) and the audit rows it hands back, in ONE directory transaction:
|
|
4831
|
+
* an audit write that fails rolls the mutation back, so no verdict, rule or stale resolution
|
|
4832
|
+
* stands without its row.
|
|
4833
|
+
*/
|
|
4834
|
+
auditedFindings(actor, run) {
|
|
4835
|
+
return this.directory.transaction(() => run(redactionSqlOf(this.directory), (a) => this.recordAdmin(actor, a.action, a.target, a.before, a.after)))();
|
|
4836
|
+
}
|
|
4837
|
+
/**
|
|
4838
|
+
* A connect-link move and the audit row it hands back, in ONE directory transaction
|
|
4839
|
+
* (connections.md §3.5.4): a consume whose audit write failed must not leave the link spent
|
|
4840
|
+
* with no connection and no record.
|
|
4841
|
+
*/
|
|
4842
|
+
auditedConnectLink(actor, run) {
|
|
4843
|
+
return this.directory.transaction(() => run(redactionSqlOf(this.directory), (a) => this.recordAdmin(actor, a.action, a.target, a.before, a.after)))();
|
|
4844
|
+
}
|
|
4845
|
+
/**
|
|
4846
|
+
* #559: one ops-failure row with its issue and finding, in one transaction (a savepoint when
|
|
4847
|
+
* the caller holds one: #2064's settle writes it beside the `unknown` row it reports).
|
|
4848
|
+
*/
|
|
4849
|
+
writeOpsFailure(entry) {
|
|
4850
|
+
const at = new Date().toISOString();
|
|
4851
|
+
const fingerprint = opsFailureFingerprint(entry);
|
|
4852
|
+
// #1748: the evidence, its issue and its finding in ONE transaction — a detector that
|
|
4853
|
+
// throws takes the evidence with it, so the caller's retry observes it rather than finding
|
|
4854
|
+
// the row already there and the observation lost.
|
|
4855
|
+
this.directory.transaction(() => {
|
|
4856
|
+
this.directory
|
|
4857
|
+
.prepare(`INSERT INTO _substrat_ops_failures
|
|
4858
|
+
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
4859
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
4860
|
+
.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
|
|
4861
|
+
// Bounded here, not trusted from the catch site: one runaway upstream
|
|
4862
|
+
// body must not become a runaway directory row (#559).
|
|
4863
|
+
entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
|
|
4864
|
+
// Prune-on-write (#559): retention lives here, not in a cron — every insert
|
|
4865
|
+
// pays for its own housekeeping, so the table stays bounded even where no
|
|
4866
|
+
// scheduled pass runs (this adapter has none).
|
|
4867
|
+
const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
4868
|
+
this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
|
|
4869
|
+
// The issues materialization (#1233): the group's counters live on their
|
|
4870
|
+
// own row, bumped in the same call, because the evidence self-prunes above
|
|
4871
|
+
// and a count must survive its own exemplars. A fresh arrival regresses a
|
|
4872
|
+
// resolved issue; an ignored one stays ignored — that is what ignoring means.
|
|
4873
|
+
this.directory
|
|
4874
|
+
.prepare(`INSERT INTO _substrat_issues
|
|
4875
|
+
(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)
|
|
4876
|
+
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
4877
|
+
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
4878
|
+
seen_count = seen_count + 1,
|
|
4879
|
+
last_seen = excluded.last_seen,
|
|
4880
|
+
last_message = excluded.last_message,
|
|
4881
|
+
last_tenant_id = excluded.last_tenant_id,
|
|
4882
|
+
last_owner_kind = excluded.last_owner_kind,
|
|
4883
|
+
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
4884
|
+
last_version = COALESCE(excluded.last_version, last_version),
|
|
4885
|
+
origin = COALESCE(excluded.origin, origin),
|
|
4886
|
+
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
|
|
4887
|
+
.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);
|
|
4888
|
+
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
4889
|
+
this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
|
|
4890
|
+
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
4891
|
+
const finding = findingOfOpsFailure(entry);
|
|
4892
|
+
if (finding)
|
|
4893
|
+
observeFinding(redactionSqlOf(this.directory), finding, at);
|
|
4894
|
+
})();
|
|
4895
|
+
}
|
|
4535
4896
|
recordAdmin(actor, action, target, before, after,
|
|
4536
4897
|
/** When the row says it happened; the wall clock unless the write took its time elsewhere (#1184). */
|
|
4537
4898
|
at = new Date().toISOString()) {
|
|
@@ -4539,7 +4900,9 @@ export class SqliteScopeHost {
|
|
|
4539
4900
|
.prepare(`INSERT INTO _substrat_admin_log
|
|
4540
4901
|
(id, actor, action, tenant_id, scope_id, vertical, before, after, caused_by, on_behalf_of, at)
|
|
4541
4902
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
4542
|
-
.run(ulid(), actor, action, target.tenantId ?? null, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after),
|
|
4903
|
+
.run(ulid(), actor, action, target.tenantId ?? null, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after),
|
|
4904
|
+
// #2055: like `onBehalfOf`, set only on a view, never on the host itself.
|
|
4905
|
+
this.causedBy,
|
|
4543
4906
|
// #977: set only on an attributed view (`attributed`), never on the host itself.
|
|
4544
4907
|
this.onBehalfOf === null ? null : JSON.stringify(this.onBehalfOf), at);
|
|
4545
4908
|
}
|
|
@@ -4562,8 +4925,12 @@ export class SqliteScopeHost {
|
|
|
4562
4925
|
.all(tenantId).map((r) => ({ key: r.entitlement_key, expired: r.expires_at !== null && r.expires_at <= now }));
|
|
4563
4926
|
}
|
|
4564
4927
|
/** #977: this host, with every admin row it writes naming who the actor acted for. */
|
|
4565
|
-
attributed(onBehalfOf) {
|
|
4566
|
-
return
|
|
4928
|
+
attributed(onBehalfOf, options) {
|
|
4929
|
+
return attributedView(this, { onBehalfOf, causedBy: options?.causedBy }, this.buildAdmin);
|
|
4930
|
+
}
|
|
4931
|
+
/** #2055: this host, with every admin row it writes naming `eventId` as its cause. */
|
|
4932
|
+
causedByView(eventId) {
|
|
4933
|
+
return attributedView(this, { causedBy: eventId }, this.buildAdmin);
|
|
4567
4934
|
}
|
|
4568
4935
|
buildAdmin() {
|
|
4569
4936
|
// #977: an admin whose audit rows say on whose behalf it acts. Built over a VIEW of this
|
|
@@ -4771,6 +5138,9 @@ export class SqliteScopeHost {
|
|
|
4771
5138
|
else {
|
|
4772
5139
|
this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
|
|
4773
5140
|
}
|
|
5141
|
+
if (to === 'archived' || to === 'reaped') {
|
|
5142
|
+
this.directory.prepare('UPDATE peer_bindings SET invalidated = 1 WHERE tenant_id = ? AND target_scope_id = ?').run(tenantId, scopeId);
|
|
5143
|
+
}
|
|
4774
5144
|
if (to === 'reaped')
|
|
4775
5145
|
forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
4776
5146
|
// The audit target carries the scope's vertical (control-plane.md §4.4:
|
|
@@ -4808,6 +5178,13 @@ export class SqliteScopeHost {
|
|
|
4808
5178
|
const { tenantId } = assignment.node;
|
|
4809
5179
|
return revokeAndFence(tenantId, assignment.principalId, `role:${assignment.roleKey}`, `tenant:${tenantId}`, (at) => this.recordAdmin(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null, at));
|
|
4810
5180
|
};
|
|
5181
|
+
// An org membership written or tombstoned with its audit row — `addMember` and `removeMember`,
|
|
5182
|
+
// and the membership executor's unit (#2047), one spelling each. Leaving raises the fence too.
|
|
5183
|
+
const joinOrg = (actor, tenantId, principal, orgId, expiresAt) => {
|
|
5184
|
+
writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt);
|
|
5185
|
+
this.recordAdmin(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt));
|
|
5186
|
+
};
|
|
5187
|
+
const leaveOrgAndFence = (actor, tenantId, principal, orgId) => revokeAndFence(tenantId, principal, 'member', `org:${orgId}`, (at) => this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null, at));
|
|
4811
5188
|
// The tenant-level reads the checker makes, synchronous, for the bound inside a unit.
|
|
4812
5189
|
const directoryReader = directoryTenantReader({
|
|
4813
5190
|
directory: this.directory,
|
|
@@ -4821,10 +5198,7 @@ export class SqliteScopeHost {
|
|
|
4821
5198
|
if (!node.scopeId)
|
|
4822
5199
|
throw new Error('scope tuple requires node.scopeId');
|
|
4823
5200
|
const rt = this.runtime(node.tenantId, node.scopeId);
|
|
4824
|
-
rt.db
|
|
4825
|
-
.prepare(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
|
|
4826
|
-
VALUES (?, ?, ?, ?)`)
|
|
4827
|
-
.run(subject, relation, object, expiresAt ?? null);
|
|
5201
|
+
writeExplicitTupleIn(switchSqlOf(rt.db), subject, relation, object, { kind: 'replace', expiresAt: expiresAt ?? null });
|
|
4828
5202
|
};
|
|
4829
5203
|
const writeGrant = (subject, permission, node, entity, expiresAt) => {
|
|
4830
5204
|
if (entity) {
|
|
@@ -4866,12 +5240,19 @@ export class SqliteScopeHost {
|
|
|
4866
5240
|
// AUDIT FIRST (#1666 review), exactly as the Cloudflare adapter: the intent row before
|
|
4867
5241
|
// anything moves, the outcome row after, on every attempt. The directory and the
|
|
4868
5242
|
// scope's file are separate databases, so a crash between them leaves an intent with
|
|
4869
|
-
// no recorded outcome — never a moved switch with no audit row.
|
|
5243
|
+
// no recorded outcome — never a moved switch with no audit row. #2089: every outcome row
|
|
5244
|
+
// goes through the kernel's `recordAuditOutcome`, exactly as the Cloudflare adapter — one
|
|
5245
|
+
// that cannot be written is logged, never swallowed, and the settle closes its intent.
|
|
4870
5246
|
const operationId = ulid();
|
|
4871
5247
|
const action = switchActionOf(kind, to);
|
|
4872
5248
|
const target = { tenantId, scopeId };
|
|
4873
5249
|
const base = { operationId, ...switchAuditSubject(kind, key, to) };
|
|
4874
5250
|
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
|
|
5251
|
+
const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, action, target, null, { ...base, ...row }), {
|
|
5252
|
+
flow: `${kind}-switch`,
|
|
5253
|
+
operationId,
|
|
5254
|
+
phase: row.phase,
|
|
5255
|
+
}, (message, fields) => console.error(message, fields));
|
|
4875
5256
|
// The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
|
|
4876
5257
|
// ways, and undone if the move throws or holds nothing — see `recordSwitchedOn` and
|
|
4877
5258
|
// (#1823) `recordSwitchedOff` for why that order is the safe one.
|
|
@@ -4885,18 +5266,13 @@ export class SqliteScopeHost {
|
|
|
4885
5266
|
}
|
|
4886
5267
|
catch (err) {
|
|
4887
5268
|
// Nothing has moved: fail the call here, audited — the Cloudflare adapter's posture.
|
|
4888
|
-
|
|
4889
|
-
this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) });
|
|
4890
|
-
}
|
|
4891
|
-
catch {
|
|
4892
|
-
// Best effort: the original error is what the caller must see.
|
|
4893
|
-
}
|
|
5269
|
+
await recordOutcome({ phase: 'failed', error: errorOf(err) });
|
|
4894
5270
|
throw err;
|
|
4895
5271
|
}
|
|
4896
5272
|
// #2045: a newer call on this subject has recorded its position already: this one writes
|
|
4897
5273
|
// nothing, here or in the scope, and says so.
|
|
4898
5274
|
if (recordWriteSuperseded(prior, record)) {
|
|
4899
|
-
|
|
5275
|
+
await recordOutcome({ phase: 'refused', superseded: true });
|
|
4900
5276
|
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
4901
5277
|
}
|
|
4902
5278
|
/** The record's undo, after the scope's move threw or held nothing: retried once, never swallowed. */
|
|
@@ -4935,23 +5311,13 @@ export class SqliteScopeHost {
|
|
|
4935
5311
|
}
|
|
4936
5312
|
catch (err) {
|
|
4937
5313
|
const recordError = undoRecord();
|
|
4938
|
-
|
|
4939
|
-
this.recordAdmin(actor, action, target, null, {
|
|
4940
|
-
...base,
|
|
4941
|
-
phase: 'failed',
|
|
4942
|
-
error: errorOf(err),
|
|
4943
|
-
...(recordError ? { recordError } : {}),
|
|
4944
|
-
});
|
|
4945
|
-
}
|
|
4946
|
-
catch {
|
|
4947
|
-
// Best effort: the original error is what the caller must see.
|
|
4948
|
-
}
|
|
5314
|
+
await recordOutcome({ phase: 'failed', error: errorOf(err), ...(recordError ? { recordError } : {}) });
|
|
4949
5315
|
throw err;
|
|
4950
5316
|
}
|
|
4951
5317
|
// #2045: the scope has applied a newer call on this subject, so this move wrote nothing. The
|
|
4952
5318
|
// record is that newer call's too (its write overwrote this one's), so nothing is undone.
|
|
4953
5319
|
if (outcome.superseded) {
|
|
4954
|
-
|
|
5320
|
+
await recordOutcome({ phase: 'refused', superseded: true });
|
|
4955
5321
|
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
4956
5322
|
}
|
|
4957
5323
|
// A call that held nothing moved nothing (the subject has no authority reaching the
|
|
@@ -4959,8 +5325,7 @@ export class SqliteScopeHost {
|
|
|
4959
5325
|
// switch the subject off the day it is installed; left `on`, the next reconcile of a
|
|
4960
5326
|
// wiped scope would leave it running.
|
|
4961
5327
|
const recordError = outcome.held ? null : undoRecord();
|
|
4962
|
-
|
|
4963
|
-
...base,
|
|
5328
|
+
const unrecorded = await recordOutcome({
|
|
4964
5329
|
phase: outcome.held ? 'applied' : 'refused',
|
|
4965
5330
|
changed: outcome.changed,
|
|
4966
5331
|
permissions: outcome.permissions,
|
|
@@ -4970,30 +5335,32 @@ export class SqliteScopeHost {
|
|
|
4970
5335
|
throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
|
|
4971
5336
|
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
4972
5337
|
}
|
|
4973
|
-
return { operationId, outcome };
|
|
5338
|
+
return { operationId, outcome, ...(unrecorded !== null ? { auditWarning: auditWarningOf('the switch', unrecorded) } : {}) };
|
|
4974
5339
|
};
|
|
4975
5340
|
/** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
|
|
4976
5341
|
const switchSystem = async (actor, raw, to) => {
|
|
4977
5342
|
const input = systemSwitch.parse(raw);
|
|
4978
|
-
const { operationId, outcome } = await switchSubject(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
5343
|
+
const { operationId, outcome, auditWarning } = await switchSubject(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
4979
5344
|
return {
|
|
4980
5345
|
operationId,
|
|
4981
5346
|
moduleId: input.moduleId,
|
|
4982
5347
|
schedules: to,
|
|
4983
5348
|
changed: outcome.changed,
|
|
4984
5349
|
permissions: outcome.permissions,
|
|
5350
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
4985
5351
|
};
|
|
4986
5352
|
};
|
|
4987
5353
|
/** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
|
|
4988
5354
|
const switchPeerAt = async (actor, raw, to) => {
|
|
4989
5355
|
const input = peerSwitch.parse(raw);
|
|
4990
|
-
const { operationId, outcome } = await switchSubject(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
5356
|
+
const { operationId, outcome, auditWarning } = await switchSubject(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
4991
5357
|
return {
|
|
4992
5358
|
operationId,
|
|
4993
5359
|
vertical: input.vertical,
|
|
4994
5360
|
calls: to,
|
|
4995
5361
|
changed: outcome.changed,
|
|
4996
5362
|
permissions: outcome.permissions,
|
|
5363
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
4997
5364
|
};
|
|
4998
5365
|
};
|
|
4999
5366
|
/**
|
|
@@ -5102,6 +5469,8 @@ export class SqliteScopeHost {
|
|
|
5102
5469
|
return result;
|
|
5103
5470
|
};
|
|
5104
5471
|
return {
|
|
5472
|
+
// #2069: a view of the view this admin was built over — the person added, its event kept.
|
|
5473
|
+
attributed: (onBehalfOf) => this.attributed(onBehalfOf).admin,
|
|
5105
5474
|
// #603: fixed at construction — a host built without a box can never store a
|
|
5106
5475
|
// credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
5107
5476
|
canStoreSecrets: isSecretBoxConfigured(this.secretBox),
|
|
@@ -5196,6 +5565,42 @@ export class SqliteScopeHost {
|
|
|
5196
5565
|
await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
|
|
5197
5566
|
this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
5198
5567
|
},
|
|
5568
|
+
grantEntityShape: async (actor, grant) => {
|
|
5569
|
+
const { tenantId, scopeId } = grant.node;
|
|
5570
|
+
this.assertScope(tenantId, scopeId);
|
|
5571
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
5572
|
+
// One unit on the scope actor (#1678): the marker and its keys land together or not at all.
|
|
5573
|
+
await rt.actor.turn(() => rt.db.transaction(() => grantEntityShapeIn(switchSqlOf(rt.db), grant.principalId, grant.entity, grant.permissions))());
|
|
5574
|
+
this.recordAdmin(actor, 'grantEntityShape', { tenantId, scopeId }, null, grant);
|
|
5575
|
+
},
|
|
5576
|
+
reconcileEntityGrantShapes: async (actor, node, shapes, opts) => {
|
|
5577
|
+
const limit = shapeTopUpBatch(opts?.batch);
|
|
5578
|
+
const { tenantId, scopeId } = node;
|
|
5579
|
+
this.assertScope(tenantId, scopeId);
|
|
5580
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
5581
|
+
let toppedUp = 0;
|
|
5582
|
+
let retired = 0;
|
|
5583
|
+
// #2071: one bounded transaction per pass, so a large scope never holds one long; a pass
|
|
5584
|
+
// that did not use its whole budget found everything.
|
|
5585
|
+
for (let done = false; !done;) {
|
|
5586
|
+
const pass = await rt.actor.turn(() => rt.db.transaction(() => topUpEntityGrantShapes(switchSqlOf(rt.db), {
|
|
5587
|
+
tenantId,
|
|
5588
|
+
scopeId,
|
|
5589
|
+
shapes,
|
|
5590
|
+
now: new Date().toISOString(),
|
|
5591
|
+
limit,
|
|
5592
|
+
mintEventId: (ms) => rt.mintEventId(ms),
|
|
5593
|
+
version: this.versionId,
|
|
5594
|
+
}))());
|
|
5595
|
+
toppedUp += pass.toppedUp;
|
|
5596
|
+
retired += pass.retired;
|
|
5597
|
+
done = pass.done;
|
|
5598
|
+
}
|
|
5599
|
+
if (toppedUp > 0 || retired > 0) {
|
|
5600
|
+
this.recordAdmin(actor, 'reconcileEntityGrantShapes', { tenantId, scopeId }, null, { shapes, toppedUp, retired });
|
|
5601
|
+
}
|
|
5602
|
+
return { toppedUp, retired };
|
|
5603
|
+
},
|
|
5199
5604
|
grantToConnection: async (actor, raw) => {
|
|
5200
5605
|
const grant = connectionGrant.parse(raw);
|
|
5201
5606
|
const check = () => {
|
|
@@ -5520,6 +5925,55 @@ export class SqliteScopeHost {
|
|
|
5520
5925
|
forkedFrom: r.forked_from ?? null,
|
|
5521
5926
|
})), tenantId, vertical);
|
|
5522
5927
|
},
|
|
5928
|
+
resolvePeerInstance: async (tenantId, callerScopeId, vertical) => {
|
|
5929
|
+
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);
|
|
5930
|
+
const rows = this.directory.prepare(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
|
|
5931
|
+
WHERE tenant_id = ? AND vertical = ?`).all(tenantId, vertical);
|
|
5932
|
+
return resolvePeerInstanceFrom(rows.map((r) => ({
|
|
5933
|
+
id: r.scope_id,
|
|
5934
|
+
tenantId: r.tenant_id,
|
|
5935
|
+
vertical: r.vertical,
|
|
5936
|
+
status: r.status,
|
|
5937
|
+
kind: r.kind ?? '',
|
|
5938
|
+
forkedFrom: r.forked_from,
|
|
5939
|
+
})), tenantId, vertical, binding?.target_scope_id ?? null, binding?.invalidated === 1);
|
|
5940
|
+
},
|
|
5941
|
+
peerBinding: async (actor, tenantId, callerScopeId, vertical) => {
|
|
5942
|
+
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);
|
|
5943
|
+
this.recordAccess(actor, 'peerBinding', { tenantId, scopeId: callerScopeId }, { vertical }, row ? 1 : 0);
|
|
5944
|
+
return row ? { tenantId, callerScopeId, vertical, targetScopeId: row.target_scope_id, invalidated: row.invalidated === 1 } : undefined;
|
|
5945
|
+
},
|
|
5946
|
+
peerBindings: async (actor, tenantId, callerScopeId) => {
|
|
5947
|
+
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);
|
|
5948
|
+
this.recordAccess(actor, 'peerBindings', { tenantId, scopeId: callerScopeId }, null, rows.length);
|
|
5949
|
+
return rows.map((r) => ({ tenantId, callerScopeId, vertical: r.vertical, targetScopeId: r.target_scope_id, invalidated: r.invalidated === 1 }));
|
|
5950
|
+
},
|
|
5951
|
+
setPeerBinding: async (_actor, tenantId, callerScopeId, vertical, targetScopeId) => this.directory.transaction(() => {
|
|
5952
|
+
const caller = this.directory.prepare('SELECT tenant_id, status, kind, forked_from FROM scopes WHERE scope_id = ?').get(callerScopeId);
|
|
5953
|
+
if (!caller || caller.tenant_id !== tenantId || caller.status !== 'active' || !isPrimaryScopeRow(caller)) {
|
|
5954
|
+
throw substratError('not_found', 'calling app is not a live primary scope in this tenant');
|
|
5955
|
+
}
|
|
5956
|
+
if (targetScopeId !== null) {
|
|
5957
|
+
const target = this.directory.prepare('SELECT tenant_id, vertical, status, kind, forked_from FROM scopes WHERE scope_id = ?').get(targetScopeId);
|
|
5958
|
+
if (!target || target.tenant_id !== tenantId || target.vertical !== vertical || target.status !== 'active' || !isPrimaryScopeRow(target)) {
|
|
5959
|
+
throw substratError('not_found', 'target is not a live primary instance of this vertical in this tenant');
|
|
5960
|
+
}
|
|
5961
|
+
}
|
|
5962
|
+
const key = [tenantId, callerScopeId, vertical];
|
|
5963
|
+
const before = this.directory.prepare('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').get(...key);
|
|
5964
|
+
const previous = before?.target_scope_id;
|
|
5965
|
+
if ((previous ?? null) === targetScopeId && before?.invalidated !== 1)
|
|
5966
|
+
return { changed: false, previous: previous ?? null };
|
|
5967
|
+
if (targetScopeId === null) {
|
|
5968
|
+
this.directory.prepare('DELETE FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').run(...key);
|
|
5969
|
+
}
|
|
5970
|
+
else {
|
|
5971
|
+
this.directory.prepare(`INSERT INTO peer_bindings (tenant_id, caller_scope_id, vertical, target_scope_id)
|
|
5972
|
+
VALUES (?, ?, ?, ?) ON CONFLICT (tenant_id, caller_scope_id, vertical)
|
|
5973
|
+
DO UPDATE SET target_scope_id = excluded.target_scope_id, invalidated = 0`).run(...key, targetScopeId);
|
|
5974
|
+
}
|
|
5975
|
+
return { changed: true, previous: previous ?? null };
|
|
5976
|
+
})(),
|
|
5523
5977
|
resolveHostname: async (raw) => {
|
|
5524
5978
|
// The router's per-request read. No actor, not logged — same carve-out as
|
|
5525
5979
|
// resolveIdentity (K-24): this is a machine path, not a staff read.
|
|
@@ -5600,6 +6054,9 @@ export class SqliteScopeHost {
|
|
|
5600
6054
|
installSpec.provisions = parsed.provisions;
|
|
5601
6055
|
if (parsed.sendsEmail)
|
|
5602
6056
|
installSpec.sendsEmail = parsed.sendsEmail;
|
|
6057
|
+
// Where the scope lifecycle is held (#1713) — read by the lifecycle delivery's targets.
|
|
6058
|
+
if (parsed.lifecycle)
|
|
6059
|
+
installSpec.lifecycle = parsed.lifecycle;
|
|
5603
6060
|
if (parsed.surfaces)
|
|
5604
6061
|
installSpec.surfaces = parsed.surfaces;
|
|
5605
6062
|
const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
|
|
@@ -6209,10 +6666,20 @@ export class SqliteScopeHost {
|
|
|
6209
6666
|
this.assertScopeReachable(tenantId, scopeId);
|
|
6210
6667
|
const rec = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
6211
6668
|
const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
|
|
6669
|
+
// #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs
|
|
6670
|
+
// and closes the lever's rows exactly as it does the kill switches'.
|
|
6212
6671
|
const replayId = ulid();
|
|
6213
6672
|
const target = { tenantId, scopeId };
|
|
6214
|
-
const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
6673
|
+
const base = { operationId: replayId, replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
6215
6674
|
this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
|
|
6675
|
+
// #2114: every outcome row goes through the kernel's `recordAuditOutcome`, exactly as the
|
|
6676
|
+
// Cloudflare adapter and the kill switches (#2089): logged when it cannot be written, never
|
|
6677
|
+
// swallowed, and never in place of the move's own error or result.
|
|
6678
|
+
const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, ...row }), {
|
|
6679
|
+
flow: 'import-cursor',
|
|
6680
|
+
operationId: replayId,
|
|
6681
|
+
phase: row.phase,
|
|
6682
|
+
}, (message, fields) => console.error(message, fields));
|
|
6216
6683
|
let moved;
|
|
6217
6684
|
try {
|
|
6218
6685
|
const rt = this.runtime(tenantId, scopeId);
|
|
@@ -6222,26 +6689,16 @@ export class SqliteScopeHost {
|
|
|
6222
6689
|
moved = await rt.actor.turn(() => rt.db.transaction(() => moveImportCursor(switchSqlOf(rt.db), { move, source, replayId, now, imports: this.crossVertical.consumes() }))());
|
|
6223
6690
|
}
|
|
6224
6691
|
catch (err) {
|
|
6225
|
-
|
|
6226
|
-
this.recordAdmin(actor, 'moveImportCursor', target, null, {
|
|
6227
|
-
...base,
|
|
6228
|
-
phase: 'failed',
|
|
6229
|
-
error: err instanceof Error ? err.message : String(err),
|
|
6230
|
-
});
|
|
6231
|
-
}
|
|
6232
|
-
catch {
|
|
6233
|
-
// Best effort: the original error is what the caller must see.
|
|
6234
|
-
}
|
|
6692
|
+
await recordOutcome({ phase: 'failed', error: err instanceof Error ? err.message : String(err) });
|
|
6235
6693
|
throw err;
|
|
6236
6694
|
}
|
|
6237
|
-
|
|
6238
|
-
...base,
|
|
6695
|
+
const unrecorded = await recordOutcome({
|
|
6239
6696
|
phase: 'applied',
|
|
6240
6697
|
previous: moved.previous,
|
|
6241
6698
|
cursor: moved.cursor,
|
|
6242
6699
|
archived: moved.archived,
|
|
6243
6700
|
});
|
|
6244
|
-
return moved;
|
|
6701
|
+
return unrecorded === null ? moved : { ...moved, auditWarning: auditWarningOf('the move', unrecorded) };
|
|
6245
6702
|
},
|
|
6246
6703
|
redrainEvents: async (actor, tenantId, scopeId, input) => {
|
|
6247
6704
|
// Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
|
|
@@ -6444,14 +6901,13 @@ export class SqliteScopeHost {
|
|
|
6444
6901
|
this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, o ? 1 : 0);
|
|
6445
6902
|
return o;
|
|
6446
6903
|
},
|
|
6447
|
-
addMember: async (actor, tenantId, principal, orgId) => {
|
|
6904
|
+
addMember: async (actor, tenantId, principal, orgId, opts) => {
|
|
6448
6905
|
requireOrg(tenantId, orgId);
|
|
6449
6906
|
// INSERT OR REPLACE, so re-adding a revoked member clears the tombstone —
|
|
6450
6907
|
// they are a member again. The add/revoke history is not lost: it lives in
|
|
6451
6908
|
// the append-only admin log, which is where "what happened" belongs. The
|
|
6452
6909
|
// tuple carries "what is true now" plus enough to explain a live proof.
|
|
6453
|
-
|
|
6454
|
-
this.recordAdmin(actor, 'addMember', { tenantId }, null, { principal, orgId });
|
|
6910
|
+
joinOrg(actor, tenantId, principal, orgId, opts?.expiresAt ?? null);
|
|
6455
6911
|
},
|
|
6456
6912
|
applyMembership: async (actor, change) => {
|
|
6457
6913
|
// ONE directory transaction, synchronous throughout (#1184): the fence (an add's), the
|
|
@@ -6473,10 +6929,24 @@ export class SqliteScopeHost {
|
|
|
6473
6929
|
const bound = tenantCoverage(directoryReader, tenantId, boundedBy, role.permissions);
|
|
6474
6930
|
if (!bound.covered)
|
|
6475
6931
|
return { applied: false, missing: bound.missing };
|
|
6476
|
-
|
|
6477
|
-
|
|
6932
|
+
// #2047: the org, bounded by `boundedBy`'s own live membership of it.
|
|
6933
|
+
const { orgId } = change;
|
|
6934
|
+
const org = orgId ? { orgId, ...orgChangeBound(directoryReader, tenantId, { ...change, orgId }) } : undefined;
|
|
6935
|
+
if (org) {
|
|
6936
|
+
if (!readOrg(tenantId, org.orgId))
|
|
6937
|
+
return { applied: false, unknownOrg: org.orgId };
|
|
6938
|
+
if (!org.bounded)
|
|
6939
|
+
return { applied: false, notMember: org.orgId };
|
|
6940
|
+
}
|
|
6941
|
+
if (change.op === 'remove') {
|
|
6942
|
+
const roleTaken = revokeTenantRoleAndFence(actor, assignment);
|
|
6943
|
+
const orgLeft = !!org && leaveOrgAndFence(actor, tenantId, principal, org.orgId);
|
|
6944
|
+
return { applied: true, changed: roleTaken || orgLeft };
|
|
6945
|
+
}
|
|
6478
6946
|
writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`);
|
|
6479
6947
|
this.recordAdmin(actor, 'assignRole', { tenantId, scopeId: null }, null, assignment);
|
|
6948
|
+
if (org?.bounded)
|
|
6949
|
+
joinOrg(actor, tenantId, principal, org.orgId, org.expiresAt);
|
|
6480
6950
|
return { applied: true };
|
|
6481
6951
|
})();
|
|
6482
6952
|
},
|
|
@@ -6485,12 +6955,12 @@ export class SqliteScopeHost {
|
|
|
6485
6955
|
// Tombstone (K-21), never DELETE: a repeat revoke neither moves the timestamp nor writes
|
|
6486
6956
|
// a second audit row. One transaction with the removal fence (#1184), which a no-op
|
|
6487
6957
|
// raises too.
|
|
6488
|
-
this.directory.transaction(() =>
|
|
6958
|
+
this.directory.transaction(() => leaveOrgAndFence(actor, tenantId, principal, orgId))();
|
|
6489
6959
|
},
|
|
6490
6960
|
listMembers: async (actor, tenantId, orgId, options) => {
|
|
6491
6961
|
requireOrg(tenantId, orgId);
|
|
6492
6962
|
const rows = this.directory
|
|
6493
|
-
.prepare(`SELECT subject, revoked_at FROM _substrat_tenant_tuples
|
|
6963
|
+
.prepare(`SELECT subject, revoked_at, expires_at FROM _substrat_tenant_tuples
|
|
6494
6964
|
WHERE tenant_id = ? AND relation = 'member' AND object = ?
|
|
6495
6965
|
${options?.includeRevoked ? '' : 'AND revoked_at IS NULL'}
|
|
6496
6966
|
ORDER BY subject`)
|
|
@@ -6500,6 +6970,7 @@ export class SqliteScopeHost {
|
|
|
6500
6970
|
principal: r.subject.slice('principal:'.length),
|
|
6501
6971
|
orgId,
|
|
6502
6972
|
revokedAt: r.revoked_at,
|
|
6973
|
+
expiresAt: r.expires_at,
|
|
6503
6974
|
}));
|
|
6504
6975
|
},
|
|
6505
6976
|
createTenant: async (actor, input) => {
|
|
@@ -6807,7 +7278,8 @@ export class SqliteScopeHost {
|
|
|
6807
7278
|
WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND sql IS NOT NULL
|
|
6808
7279
|
ORDER BY name`)
|
|
6809
7280
|
.all();
|
|
6810
|
-
const
|
|
7281
|
+
const privateTables = new Set(['private_continuation_keys', 'private_continuation_positions']);
|
|
7282
|
+
const tables = defs.filter(({ name }) => !privateTables.has(name)).map(({ name, sql }) => {
|
|
6811
7283
|
const stmt = this.directory.prepare(`SELECT * FROM "${name}"`).raw(true);
|
|
6812
7284
|
const rows = stmt.all();
|
|
6813
7285
|
const columns = stmt.columns().map((c) => c.name);
|
|
@@ -6825,7 +7297,11 @@ export class SqliteScopeHost {
|
|
|
6825
7297
|
// as identifiers, and its `ddl`, never executed here since #1912, is still held to one
|
|
6826
7298
|
// CREATE TABLE. Judged as a whole before the first DROP, so a refused dump leaves the
|
|
6827
7299
|
// directory untouched.
|
|
6828
|
-
|
|
7300
|
+
// Older backup producers and local migration fixtures may include these
|
|
7301
|
+
// tables. Ignore them entirely: neither their DDL nor their rows may set
|
|
7302
|
+
// a restored key epoch or make an old hidden locator valid again.
|
|
7303
|
+
const publicTables = dump.tables.filter((table) => !['private_continuation_keys', 'private_continuation_positions'].includes(table.name.toLowerCase()));
|
|
7304
|
+
assertReplayableDump(publicTables);
|
|
6829
7305
|
// One transaction, foreign keys deferred to commit: the dump is ordered by table NAME
|
|
6830
7306
|
// (which says nothing about references — a `tenants` row can name another as
|
|
6831
7307
|
// `provisioned_by_tenant`), and the DROPs themselves delete rows a populated child would
|
|
@@ -6848,7 +7324,7 @@ export class SqliteScopeHost {
|
|
|
6848
7324
|
this.buildDirectorySchema();
|
|
6849
7325
|
// Refuses a table this code does not build, adds unknown columns bare, loads the rows
|
|
6850
7326
|
// by name, then the legacy scope and #1674 backfills, all inside this transaction.
|
|
6851
|
-
loadDirectoryDump(
|
|
7327
|
+
loadDirectoryDump(publicTables, {
|
|
6852
7328
|
columnsOf: (name) => builtColumnsOf(this.directory, name),
|
|
6853
7329
|
exec: (sql) => this.directory.exec(sql),
|
|
6854
7330
|
now: () => this.clock(),
|
|
@@ -6965,6 +7441,10 @@ export class SqliteScopeHost {
|
|
|
6965
7441
|
// and transaction facts remain". A consumer's timeline still shows that something
|
|
6966
7442
|
// happened, to what, and when; it no longer shows who or what was said.
|
|
6967
7443
|
const db = this.scopeDbFor(tenantId, scopeId);
|
|
7444
|
+
// #2068: the module half reaches the scope's own tables, so they have to exist — the
|
|
7445
|
+
// same migrations an invoke would apply first.
|
|
7446
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
7447
|
+
await this.applyPendingMigrations(rt);
|
|
6968
7448
|
// One instant for the whole erasure, read before the first write: the intent
|
|
6969
7449
|
// tombstones below and the key's own tombstone should not disagree about when a
|
|
6970
7450
|
// person was erased.
|
|
@@ -6972,8 +7452,24 @@ export class SqliteScopeHost {
|
|
|
6972
7452
|
// Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
|
|
6973
7453
|
// invoke held its transaction open, they joined it, and its rollback put the
|
|
6974
7454
|
// person's PII back after this verb had destroyed the key and receipted the erasure.
|
|
7455
|
+
//
|
|
7456
|
+
// And in ONE transaction (#2068): a module's `onSubjectErased` hook that throws rolls
|
|
7457
|
+
// the whole scope side back, the spine redaction with it, and the erasure throws
|
|
7458
|
+
// before the key below is touched — nothing receipts an erasure that did not happen.
|
|
6975
7459
|
const scopeSql = redactionSqlOf(db);
|
|
6976
|
-
const { redacted, intentsRedacted, jobRunsRedacted, text } = await
|
|
7460
|
+
const { redacted, intentsRedacted, jobRunsRedacted, text, vertical } = await rt.actor.turn(() => db.transaction(() => ({
|
|
7461
|
+
// The module half first (#2068): the declared entities, then each hook, with the
|
|
7462
|
+
// search indexes over them under FTS5 secure-delete. The spine half follows in the same
|
|
7463
|
+
// transaction, so the order between them decides nothing but the reading order.
|
|
7464
|
+
vertical: eraseSubjectFromModules({
|
|
7465
|
+
sql: spineSql(db),
|
|
7466
|
+
plans: this.erasurePlans,
|
|
7467
|
+
searchPlans: this.searchPlans.values(),
|
|
7468
|
+
statefulTables: statefulTablesOf(this.statePlans),
|
|
7469
|
+
subjectId,
|
|
7470
|
+
at,
|
|
7471
|
+
migrationSqlOf: (moduleId, version) => this.modules.get(moduleId)?.migrations.find((m) => m.version === version)?.sql,
|
|
7472
|
+
}),
|
|
6977
7473
|
redacted: db
|
|
6978
7474
|
.prepare(`UPDATE _substrat_outbox SET payload = NULL
|
|
6979
7475
|
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
|
|
@@ -6996,7 +7492,7 @@ export class SqliteScopeHost {
|
|
|
6996
7492
|
// The free-text copies (#1632), and the tombstoned intents the directory half
|
|
6997
7493
|
// follows. Last, so those ids include every intent tombstoned above.
|
|
6998
7494
|
text: redactSubjectScopeText(scopeSql, subjectId, at),
|
|
6999
|
-
}));
|
|
7495
|
+
}))());
|
|
7000
7496
|
const { idempotencyResults, intentIds } = text;
|
|
7001
7497
|
// The directory's failure text (#1632) — a drain failure quoting one of those
|
|
7002
7498
|
// intents, an issue's exemplar, a sweep record's error. Before the key, for the
|
|
@@ -7008,6 +7504,7 @@ export class SqliteScopeHost {
|
|
|
7008
7504
|
eventsRedacted: redacted.changes,
|
|
7009
7505
|
intentsRedacted,
|
|
7010
7506
|
jobRunsRedacted,
|
|
7507
|
+
...vertical,
|
|
7011
7508
|
keyDestroyed: existed,
|
|
7012
7509
|
tombstoned: true,
|
|
7013
7510
|
});
|
|
@@ -7017,7 +7514,7 @@ export class SqliteScopeHost {
|
|
|
7017
7514
|
this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
7018
7515
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
7019
7516
|
// an intent payload is a whole event's worth of it.
|
|
7020
|
-
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted + idempotencyResults);
|
|
7517
|
+
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted + idempotencyResults + moduleRowsErased(vertical));
|
|
7021
7518
|
return receipt;
|
|
7022
7519
|
},
|
|
7023
7520
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -7088,7 +7585,10 @@ export class SqliteScopeHost {
|
|
|
7088
7585
|
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
7089
7586
|
'_substrat_membership_fences', // #1184: the latest removal, per principal
|
|
7090
7587
|
'_substrat_peer_switches', // #2029: the peer switch's record, per scope
|
|
7588
|
+
'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
|
|
7091
7589
|
'_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
|
|
7590
|
+
'_substrat_findings', // #1748: the tenant's findings
|
|
7591
|
+
'_substrat_finding_rules', // #1748: the tenant's suppress rules
|
|
7092
7592
|
];
|
|
7093
7593
|
const clear = this.directory.transaction(() => {
|
|
7094
7594
|
for (const table of tables) {
|
|
@@ -7404,7 +7904,7 @@ export class SqliteScopeHost {
|
|
|
7404
7904
|
.prepare(`UPDATE _substrat_connections
|
|
7405
7905
|
SET status = 'active', expires_at = ?, last_error = NULL, last_error_at = NULL
|
|
7406
7906
|
WHERE id = ?`)
|
|
7407
|
-
.run(expiresAt
|
|
7907
|
+
.run(expiresAt === undefined ? row.expires_at : expiresAt, id);
|
|
7408
7908
|
// The event, never the token. "Rotated at T" is the auditable fact — plus WHO
|
|
7409
7909
|
// authorized it when the rotation was a tenant admin's act (§3.5.1's attribution,
|
|
7410
7910
|
// rotate-side): the principal, never laundered into the actor column.
|
|
@@ -7412,7 +7912,7 @@ export class SqliteScopeHost {
|
|
|
7412
7912
|
id,
|
|
7413
7913
|
provider: row.provider,
|
|
7414
7914
|
rotatedAt: now,
|
|
7415
|
-
expiresAt: expiresAt
|
|
7915
|
+
expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
|
|
7416
7916
|
...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
|
|
7417
7917
|
});
|
|
7418
7918
|
},
|
|
@@ -7443,6 +7943,51 @@ export class SqliteScopeHost {
|
|
|
7443
7943
|
.run(now, id);
|
|
7444
7944
|
this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
|
|
7445
7945
|
},
|
|
7946
|
+
// -- a vertical's mailed connect links (connections.md §3.5.4) --------------
|
|
7947
|
+
// The statements are the kernel's (`connect-links.ts`), shared with the hosted
|
|
7948
|
+
// adapter; what is here is the clock, the parse at the edge, and the transaction:
|
|
7949
|
+
// each move commits with the audit row it hands back, or not at all.
|
|
7950
|
+
mintConnectLink: async (actor, raw) => {
|
|
7951
|
+
const input = mintConnectLinkInput.parse(raw);
|
|
7952
|
+
return this.auditedConnectLink(actor, (sql, audit) => insertConnectLink(sql, {
|
|
7953
|
+
id: ulid(),
|
|
7954
|
+
tenantId: input.tenantId,
|
|
7955
|
+
scopeId: input.scopeId,
|
|
7956
|
+
vertical: input.vertical,
|
|
7957
|
+
provider: input.provider,
|
|
7958
|
+
createdBy: input.createdBy,
|
|
7959
|
+
subjectRef: input.subjectRef ?? null,
|
|
7960
|
+
returnUrl: input.returnUrl ?? null,
|
|
7961
|
+
createdAt: this.clock(),
|
|
7962
|
+
expiresAt: input.expiresAt,
|
|
7963
|
+
}, audit));
|
|
7964
|
+
},
|
|
7965
|
+
getConnectLink: async (actor, raw) => {
|
|
7966
|
+
const key = connectLinkKey.parse(raw);
|
|
7967
|
+
const link = readConnectLink(redactionSqlOf(this.directory), key);
|
|
7968
|
+
this.recordAccess(actor, 'getConnectLink', key, { id: key.id }, link ? 1 : 0);
|
|
7969
|
+
return link;
|
|
7970
|
+
},
|
|
7971
|
+
listConnectLinks: async (actor, raw) => {
|
|
7972
|
+
const filter = connectLinkFilter.parse(raw);
|
|
7973
|
+
const links = listConnectLinkRows(redactionSqlOf(this.directory), filter, this.clock());
|
|
7974
|
+
this.recordAccess(actor, 'listConnectLinks', filter, filter, links.length);
|
|
7975
|
+
return links;
|
|
7976
|
+
},
|
|
7977
|
+
revokeConnectLink: async (actor, raw) => {
|
|
7978
|
+
const key = connectLinkKey.parse(raw);
|
|
7979
|
+
// Idempotent, and a no-op is not audited (the statement hands back no row for one).
|
|
7980
|
+
return this.auditedConnectLink(actor, (sql, audit) => revokeConnectLinkRow(sql, key, audit))?.link;
|
|
7981
|
+
},
|
|
7982
|
+
consumeConnectLink: async (actor, raw) => {
|
|
7983
|
+
const input = consumeConnectLinkInput.parse(raw);
|
|
7984
|
+
return this.auditedConnectLink(actor, (sql, audit) => consumeConnectLinkRow(sql, input, this.clock(), audit));
|
|
7985
|
+
},
|
|
7986
|
+
restoreConnectLink: async (actor, raw) => {
|
|
7987
|
+
const key = connectLinkKey.parse(raw);
|
|
7988
|
+
const link = this.auditedConnectLink(actor, (sql, audit) => restoreConnectLinkRow(sql, key, this.clock(), audit));
|
|
7989
|
+
return link !== undefined;
|
|
7990
|
+
},
|
|
7446
7991
|
openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
|
|
7447
7992
|
const rows = this.directory
|
|
7448
7993
|
.prepare(`SELECT * FROM _substrat_connections
|
|
@@ -7590,6 +8135,15 @@ export class SqliteScopeHost {
|
|
|
7590
8135
|
const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
|
|
7591
8136
|
this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
|
|
7592
8137
|
},
|
|
8138
|
+
/** #1150: one phase of a dashboard member change, written around the vertical's own. */
|
|
8139
|
+
recordMemberChange: async (actor, entry) => {
|
|
8140
|
+
const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
|
|
8141
|
+
this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
|
|
8142
|
+
},
|
|
8143
|
+
recordPeerBindingChange: async (actor, entry) => {
|
|
8144
|
+
const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
|
|
8145
|
+
this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
|
|
8146
|
+
},
|
|
7593
8147
|
/** #2005: one change to a scope's copy marker, written around the vertical's own change. */
|
|
7594
8148
|
recordCopyMark: async (actor, entry) => {
|
|
7595
8149
|
const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
|
|
@@ -7730,43 +8284,24 @@ export class SqliteScopeHost {
|
|
|
7730
8284
|
}));
|
|
7731
8285
|
},
|
|
7732
8286
|
recordOpsFailure: async (entry) => {
|
|
7733
|
-
|
|
7734
|
-
const fingerprint = opsFailureFingerprint(entry);
|
|
7735
|
-
this.directory
|
|
7736
|
-
.prepare(`INSERT INTO _substrat_ops_failures
|
|
7737
|
-
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
7738
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
7739
|
-
.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
|
|
7740
|
-
// Bounded here, not trusted from the catch site: one runaway upstream
|
|
7741
|
-
// body must not become a runaway directory row (#559).
|
|
7742
|
-
entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
|
|
7743
|
-
// Prune-on-write (#559): retention lives here, not in a cron — every insert
|
|
7744
|
-
// pays for its own housekeeping, so the table stays bounded even where no
|
|
7745
|
-
// scheduled pass runs (this adapter has none).
|
|
7746
|
-
const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
7747
|
-
this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
|
|
7748
|
-
// The issues materialization (#1233): the group's counters live on their
|
|
7749
|
-
// own row, bumped in the same call, because the evidence self-prunes above
|
|
7750
|
-
// and a count must survive its own exemplars. A fresh arrival regresses a
|
|
7751
|
-
// resolved issue; an ignored one stays ignored — that is what ignoring means.
|
|
7752
|
-
this.directory
|
|
7753
|
-
.prepare(`INSERT INTO _substrat_issues
|
|
7754
|
-
(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)
|
|
7755
|
-
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
7756
|
-
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
7757
|
-
seen_count = seen_count + 1,
|
|
7758
|
-
last_seen = excluded.last_seen,
|
|
7759
|
-
last_message = excluded.last_message,
|
|
7760
|
-
last_tenant_id = excluded.last_tenant_id,
|
|
7761
|
-
last_owner_kind = excluded.last_owner_kind,
|
|
7762
|
-
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
7763
|
-
last_version = COALESCE(excluded.last_version, last_version),
|
|
7764
|
-
origin = COALESCE(excluded.origin, origin),
|
|
7765
|
-
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
|
|
7766
|
-
.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);
|
|
7767
|
-
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
7768
|
-
this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
|
|
8287
|
+
this.writeOpsFailure(entry);
|
|
7769
8288
|
},
|
|
8289
|
+
/** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
|
|
8290
|
+
auditedOperations: async (actor, refs) => {
|
|
8291
|
+
const rows = readAuditedOperations((sql, params) => this.directory.prepare(sql).all(...params), refs);
|
|
8292
|
+
this.recordAccess(actor, 'auditedOperations', {}, { operations: refs.length }, rows.length);
|
|
8293
|
+
return rows;
|
|
8294
|
+
},
|
|
8295
|
+
/** #2064: settle an intent with no outcome, in one transaction — see `audit-outcome.ts`. */
|
|
8296
|
+
settleUnrecordedOutcome: async (actor, input) => this.directory.transaction(() => {
|
|
8297
|
+
const row = this.directory.prepare(SETTLE_INTENT_SQL).get(input.intentId);
|
|
8298
|
+
const outcome = unknownOutcomeOf(row, input.intentId, input.error);
|
|
8299
|
+
if (this.directory.prepare(SETTLE_OUTCOME_SQL).get(...settleOutcomeParamsOf(outcome.operation)))
|
|
8300
|
+
return false;
|
|
8301
|
+
this.recordAdmin(actor, outcome.action, outcome.target, null, outcome.after);
|
|
8302
|
+
this.writeOpsFailure({ ...outcome.failure, actor });
|
|
8303
|
+
return true;
|
|
8304
|
+
})(),
|
|
7770
8305
|
listOpsFailures: async (actor, filter) => {
|
|
7771
8306
|
const where = [];
|
|
7772
8307
|
const params = [];
|
|
@@ -7847,19 +8382,30 @@ export class SqliteScopeHost {
|
|
|
7847
8382
|
}));
|
|
7848
8383
|
},
|
|
7849
8384
|
recordSweepRun: async (entry) => {
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
8385
|
+
const at = entry.at ?? new Date().toISOString();
|
|
8386
|
+
// #1748: the row and its finding in ONE transaction. A replayed drain the unique index
|
|
8387
|
+
// ignores observes nothing, which is right only because the first arrival's observation
|
|
8388
|
+
// committed with it, or did not commit at all.
|
|
8389
|
+
this.directory.transaction(() => {
|
|
8390
|
+
const { changes } = this.directory
|
|
8391
|
+
.prepare(`INSERT OR IGNORE INTO _substrat_sweep_runs
|
|
8392
|
+
(id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
|
|
8393
|
+
connection_id, error, elapsed_ms, request_id, event_type, observed_at, platform_requests, at)
|
|
8394
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
8395
|
+
.run(ulid(), entry.kind, entry.unit, entry.outcome, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.operation ?? null, entry.connectionId ?? null,
|
|
8396
|
+
// Bounded here, not trusted from the sweep: one runaway provider body
|
|
8397
|
+
// must not become a runaway directory row (the #559 rule).
|
|
8398
|
+
entry.error == null ? null : entry.error.slice(0, 2000), entry.elapsedMs ?? null, entry.requestId ?? null, entry.eventType ?? null, entry.observedAt ?? null, entry.platformRequests == null ? null : JSON.stringify(entry.platformRequests), at);
|
|
8399
|
+
// Prune-on-write, like ops failures and for the same reason: the table stays
|
|
8400
|
+
// bounded even on a deployment whose scheduled pass is broken.
|
|
8401
|
+
const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
|
|
8402
|
+
this.directory.prepare('DELETE FROM _substrat_sweep_runs WHERE at < ?').run(horizon);
|
|
8403
|
+
// #1748: a failed schedule (Invariant) or a stale freshness verdict (Drift). Only when the
|
|
8404
|
+
// row was written: a replayed drain the unique index ignored is not a second occurrence.
|
|
8405
|
+
const finding = changes > 0 ? findingOfSweepRun(entry) : null;
|
|
8406
|
+
if (finding)
|
|
8407
|
+
observeFinding(redactionSqlOf(this.directory), finding, at);
|
|
8408
|
+
})();
|
|
7863
8409
|
},
|
|
7864
8410
|
listSweepRuns: async (actor, filter) => {
|
|
7865
8411
|
const where = [];
|
|
@@ -7929,6 +8475,8 @@ export class SqliteScopeHost {
|
|
|
7929
8475
|
}
|
|
7930
8476
|
return pruned;
|
|
7931
8477
|
},
|
|
8478
|
+
// #1748: one transaction, so a stale resolution never lands without its audit row.
|
|
8479
|
+
pruneFindings: async (actor, limit) => this.auditedFindings(actor, (sql, audit) => pruneFindings(sql, Date.now(), limit, audit)),
|
|
7932
8480
|
listIssues: async (actor, filter) => {
|
|
7933
8481
|
const where = [];
|
|
7934
8482
|
const params = [];
|
|
@@ -7973,6 +8521,25 @@ export class SqliteScopeHost {
|
|
|
7973
8521
|
this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: existing.status }, { fingerprint, status: after.status });
|
|
7974
8522
|
return after;
|
|
7975
8523
|
},
|
|
8524
|
+
listFindings: async (actor, filter) => {
|
|
8525
|
+
const rows = listFindings(redactionSqlOf(this.directory), filter);
|
|
8526
|
+
this.recordAccess(actor, 'listFindings', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
8527
|
+
return rows;
|
|
8528
|
+
},
|
|
8529
|
+
// #1748: every findings mutation runs in ONE transaction with its audit row (`audited`).
|
|
8530
|
+
setFindingStatus: async (actor, tenantId, id, status) => this.auditedFindings(actor, (sql, audit) => setFindingStatus(sql, tenantId, id, status, new Date().toISOString(), audit))
|
|
8531
|
+
?.after,
|
|
8532
|
+
createFindingRule: async (actor, tenantId, input) => {
|
|
8533
|
+
const { rule, suppressed } = this.auditedFindings(actor, (sql, audit) => createFindingRule(sql, tenantId, input, actor, new Date().toISOString(), audit));
|
|
8534
|
+
return { rule, suppressed: suppressed.length };
|
|
8535
|
+
},
|
|
8536
|
+
revokeFindingRule: async (actor, tenantId, ruleId) => this.auditedFindings(actor, (sql, audit) => revokeFindingRule(sql, tenantId, ruleId, new Date().toISOString(), audit))
|
|
8537
|
+
?.after,
|
|
8538
|
+
listFindingRules: async (actor, tenantId, filter) => {
|
|
8539
|
+
const rows = listFindingRules(redactionSqlOf(this.directory), tenantId, filter?.active ? new Date().toISOString() : undefined, filter?.limit);
|
|
8540
|
+
this.recordAccess(actor, 'listFindingRules', { tenantId }, filter, rows.length);
|
|
8541
|
+
return rows;
|
|
8542
|
+
},
|
|
7976
8543
|
recordModelUsage: async (input) => {
|
|
7977
8544
|
const l = input.line;
|
|
7978
8545
|
// INSERT OR IGNORE on the UNIQUE request id: the drain may replay a settled-then-
|
|
@@ -8020,7 +8587,16 @@ export class SqliteScopeHost {
|
|
|
8020
8587
|
const existing = db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name.toLowerCase() === column.toLowerCase());
|
|
8021
8588
|
if (existing)
|
|
8022
8589
|
return false;
|
|
8023
|
-
|
|
8590
|
+
try {
|
|
8591
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
8592
|
+
}
|
|
8593
|
+
catch (err) {
|
|
8594
|
+
// A second opener of the same file can add it between the PRAGMA and here (#2066 review):
|
|
8595
|
+
// the column is there, which is all this wanted. The DO's ALTER pass tolerates it the same way.
|
|
8596
|
+
if (!/duplicate column name/i.test(err.message))
|
|
8597
|
+
throw err;
|
|
8598
|
+
return false;
|
|
8599
|
+
}
|
|
8024
8600
|
return true;
|
|
8025
8601
|
}
|
|
8026
8602
|
/**
|
|
@@ -8055,7 +8631,7 @@ export class SqliteScopeHost {
|
|
|
8055
8631
|
// behind an empty table `CREATE TABLE IF NOT EXISTS` put back. `db.transaction`
|
|
8056
8632
|
// nests as a SAVEPOINT, which is what makes this safe on the `loadDump` path too,
|
|
8057
8633
|
// where the whole replay is already inside one.
|
|
8058
|
-
db.transaction(() => db
|
|
8634
|
+
db.transaction(() => execSqlStatements(db, script))();
|
|
8059
8635
|
}
|
|
8060
8636
|
/**
|
|
8061
8637
|
* #1573: run a create-copy-drop-rename against the directory as ONE transaction.
|
|
@@ -8075,7 +8651,7 @@ export class SqliteScopeHost {
|
|
|
8075
8651
|
* capture one.
|
|
8076
8652
|
*/
|
|
8077
8653
|
rebuildAtomically(script) {
|
|
8078
|
-
this.directory.transaction(() => this.directory
|
|
8654
|
+
this.directory.transaction(() => execSqlStatements(this.directory, script))();
|
|
8079
8655
|
}
|
|
8080
8656
|
/**
|
|
8081
8657
|
* Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
|
|
@@ -8122,7 +8698,8 @@ export class SqliteScopeHost {
|
|
|
8122
8698
|
* constraint in place, so this is the same create-copy-drop-rename the identity key
|
|
8123
8699
|
* uses, detected the same way — from `sqlite_master.sql`, which works on DO SQLite
|
|
8124
8700
|
* too. Rows are copied verbatim: the log stays append-only in content, this only
|
|
8125
|
-
* widens what a future row may say.
|
|
8701
|
+
* widens what a future row may say. The DROP takes every index on the table with it, so
|
|
8702
|
+
* the same transaction rebuilds them from the kernel's list (#2064).
|
|
8126
8703
|
*/
|
|
8127
8704
|
ensureAdminLogTenantNullable() {
|
|
8128
8705
|
const row = this.directory
|
|
@@ -8149,6 +8726,7 @@ export class SqliteScopeHost {
|
|
|
8149
8726
|
FROM _substrat_admin_log;
|
|
8150
8727
|
DROP TABLE _substrat_admin_log;
|
|
8151
8728
|
ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
|
|
8729
|
+
${ADMIN_LOG_INDEXES_SQL}
|
|
8152
8730
|
`);
|
|
8153
8731
|
}
|
|
8154
8732
|
/**
|
|
@@ -8314,6 +8892,10 @@ export class SqliteScopeHost {
|
|
|
8314
8892
|
* can never disagree about who acted (#97). `overrideActor` remains for the
|
|
8315
8893
|
* system-actor path, where the acting module is the honest answer.
|
|
8316
8894
|
*/
|
|
8895
|
+
/** The context a consumer or an import runs in: no operation, and the delivery it is for (#2055). */
|
|
8896
|
+
deliveryContext(rt, subject, overrideActor, delivery) {
|
|
8897
|
+
return this.operationContext(rt, subject, overrideActor, undefined, undefined, undefined, [], undefined, delivery);
|
|
8898
|
+
}
|
|
8317
8899
|
operationContext(rt, subject, overrideActor,
|
|
8318
8900
|
/** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
|
|
8319
8901
|
signals,
|
|
@@ -8343,7 +8925,14 @@ export class SqliteScopeHost {
|
|
|
8343
8925
|
* instant its row write used, so the `capability.exercised` event's `occurredAt` and the
|
|
8344
8926
|
* row's `last_used_at` are the same value — a second clock read could disagree.
|
|
8345
8927
|
*/
|
|
8346
|
-
at = this.clock()
|
|
8928
|
+
at = this.clock(),
|
|
8929
|
+
/** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
|
|
8930
|
+
delivery,
|
|
8931
|
+
/**
|
|
8932
|
+
* #119: keys this context's checks refuse whatever the subject holds — a module's
|
|
8933
|
+
* purge-only keys, outside the purge sweep's own invoke (`purgeOnlyKeysOf`).
|
|
8934
|
+
*/
|
|
8935
|
+
withheld) {
|
|
8347
8936
|
// For a connection, system, capability or peer-vertical subject this carries THAT id so
|
|
8348
8937
|
// the type holds — it is not a person, and the event actor below says what it is instead.
|
|
8349
8938
|
const principal = subject.id;
|
|
@@ -8351,6 +8940,7 @@ export class SqliteScopeHost {
|
|
|
8351
8940
|
const relations = this.relations;
|
|
8352
8941
|
const searchPlans = this.searchPlans;
|
|
8353
8942
|
const listPlans = this.listPlans;
|
|
8943
|
+
const statePlans = this.statePlans;
|
|
8354
8944
|
// K-34: the checks that passed in THIS operation. The context is created per invoke
|
|
8355
8945
|
// (see buildStub), so this accumulates one operation's authorizations; `emit` snapshots
|
|
8356
8946
|
// whatever has passed up to that point. A system/override actor is unconditionally
|
|
@@ -8409,6 +8999,9 @@ export class SqliteScopeHost {
|
|
|
8409
8999
|
// #1642: parsed before the override actor's early return, which never reaches
|
|
8410
9000
|
// the checker — a cast key would otherwise become that path's proof relation.
|
|
8411
9001
|
const permission = assertPermissionKey(unparsed);
|
|
9002
|
+
if (withheld?.has(permission)) {
|
|
9003
|
+
return { allowed: false, checked: permission, node: { tenantId: rt.tenantId, scopeId: rt.scopeId } };
|
|
9004
|
+
}
|
|
8412
9005
|
if (overrideActor) {
|
|
8413
9006
|
return {
|
|
8414
9007
|
allowed: true,
|
|
@@ -8472,11 +9065,10 @@ export class SqliteScopeHost {
|
|
|
8472
9065
|
// about the process, so it never rides `DomainEvent` for module code to
|
|
8473
9066
|
// branch on; it exists for the observability joins the column serves.
|
|
8474
9067
|
this.versionId,
|
|
8475
|
-
// #1237:
|
|
8476
|
-
// the runtime
|
|
8477
|
-
//
|
|
8478
|
-
|
|
8479
|
-
rt.causedBy,
|
|
9068
|
+
// #1237: the delivery this context was built for, if any — passed in, never read
|
|
9069
|
+
// off the runtime or the host (#2055). A fact about the surrounding dispatch,
|
|
9070
|
+
// never envelope data module code could set or branch on.
|
|
9071
|
+
delivery?.causedBy ?? null,
|
|
8480
9072
|
// #1237: a fact about the surrounding CALL, like the version above — never
|
|
8481
9073
|
// envelope data module code could set or branch on.
|
|
8482
9074
|
rt.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
|
|
@@ -8485,7 +9077,13 @@ export class SqliteScopeHost {
|
|
|
8485
9077
|
tenantId: rt.tenantId,
|
|
8486
9078
|
scopeId: rt.scopeId,
|
|
8487
9079
|
principal,
|
|
8488
|
-
|
|
9080
|
+
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)),
|
|
9081
|
+
sql: guardSecrets(guardSqlLimits(scopedSql(rt.db, true, statefulTablesOf(statePlans),
|
|
9082
|
+
// #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
|
|
9083
|
+
// derived onto any table is repaired — and a lost state column fails the operation.
|
|
9084
|
+
derivesAnything(this.derivedPlans())
|
|
9085
|
+
? () => afterRuntimeDdl(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans())
|
|
9086
|
+
: undefined)), minted),
|
|
8489
9087
|
now: () => at,
|
|
8490
9088
|
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
8491
9089
|
// and its fields. The invocation id is read per call — it is set for the duration of
|
|
@@ -8494,7 +9092,7 @@ export class SqliteScopeHost {
|
|
|
8494
9092
|
tenantId: rt.tenantId,
|
|
8495
9093
|
scopeId: rt.scopeId,
|
|
8496
9094
|
operation: operation ?? null,
|
|
8497
|
-
invocationId: () => rt.invocationId ??
|
|
9095
|
+
invocationId: () => rt.invocationId ?? delivery?.invocationId ?? null,
|
|
8498
9096
|
// A consumer runs under the system override, so it logs as `system`.
|
|
8499
9097
|
principalKind: overrideActor ? 'system' : subject.kind,
|
|
8500
9098
|
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
@@ -8544,6 +9142,10 @@ export class SqliteScopeHost {
|
|
|
8544
9142
|
return entityVersionOf(rt.db.prepare(q.sql).all(...q.params));
|
|
8545
9143
|
},
|
|
8546
9144
|
check: runCheck,
|
|
9145
|
+
grantedEntities: async (unparsed, entityType, options) => {
|
|
9146
|
+
const permission = assertPermissionKey(unparsed);
|
|
9147
|
+
return grantedEntitiesForContext(checker, subject, permission, { tenantId: rt.tenantId, scopeId: rt.scopeId }, entityType, options, runCheck, withheld, Boolean(overrideActor));
|
|
9148
|
+
},
|
|
8547
9149
|
canAssign: runCanAssign,
|
|
8548
9150
|
/**
|
|
8549
9151
|
* #827. The plan is registration state, the index is scope state: an entity
|
|
@@ -8555,7 +9157,9 @@ export class SqliteScopeHost {
|
|
|
8555
9157
|
const plan = searchPlans.get(entityType);
|
|
8556
9158
|
if (!plan)
|
|
8557
9159
|
throw new NotSearchable(entityType);
|
|
8558
|
-
const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit)
|
|
9160
|
+
const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit),
|
|
9161
|
+
// #119: active rows unless the archive is asked for.
|
|
9162
|
+
searchStateWhere(statePlans, entityType, options?.view));
|
|
8559
9163
|
return rt.db.prepare(q.sql).all(...q.params).map((row) => ({ entityType, id: row.id, rank: row.rank }));
|
|
8560
9164
|
},
|
|
8561
9165
|
/**
|
|
@@ -8576,18 +9180,18 @@ export class SqliteScopeHost {
|
|
|
8576
9180
|
order: params.order,
|
|
8577
9181
|
cursor: params.cursor,
|
|
8578
9182
|
filters: params.filters,
|
|
9183
|
+
// #119: the bin is the checked reader's, never this one's.
|
|
9184
|
+
view: uncheckedView('ctx.page', entityType, params.view),
|
|
8579
9185
|
});
|
|
8580
9186
|
const rows = rt.db.prepare(q.sql).all(...q.params);
|
|
8581
|
-
//
|
|
8582
|
-
//
|
|
8583
|
-
//
|
|
8584
|
-
const
|
|
8585
|
-
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
|
|
8586
|
-
const page = { entries: rows, nextCursor };
|
|
9187
|
+
// `pageOf`'s rule — a FULL page may have more, a short one is the end — with the
|
|
9188
|
+
// cursor minted from the row's COLUMN (plus the tie-break), and each row's own
|
|
9189
|
+
// cursor when asked (#2073: `pageVisible` may stop at a visible row mid-page).
|
|
9190
|
+
const mint = (row) => cursorOf(row, q.sortColumn, plan.idColumn, q.order, q.view);
|
|
8587
9191
|
if (!params.total)
|
|
8588
|
-
return
|
|
9192
|
+
return pageOf(rows, limit, mint, params.rowCursors);
|
|
8589
9193
|
const counted = rt.db.prepare(q.countSql).all(...q.countParams);
|
|
8590
|
-
return
|
|
9194
|
+
return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
|
|
8591
9195
|
},
|
|
8592
9196
|
/**
|
|
8593
9197
|
* Narrow a permission this caller already holds onto one entity (#K-sharing).
|
|
@@ -8616,10 +9220,8 @@ export class SqliteScopeHost {
|
|
|
8616
9220
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
8617
9221
|
'the caller does not hold it there (a grant delegates, it never elevates)');
|
|
8618
9222
|
}
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
VALUES (?, ?, ?)`)
|
|
8622
|
-
.run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
|
|
9223
|
+
// #2071: an explicit grant, so it clears a tombstone `revoke` left.
|
|
9224
|
+
writeExplicitTupleIn(switchSqlOf(rt.db), `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, { kind: 'delegated' });
|
|
8623
9225
|
},
|
|
8624
9226
|
/**
|
|
8625
9227
|
* Withdraw a grant this caller could have made. Same guardrails, same reason — but NOT
|
|
@@ -8633,9 +9235,10 @@ export class SqliteScopeHost {
|
|
|
8633
9235
|
throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
8634
9236
|
'the caller does not hold it there');
|
|
8635
9237
|
}
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
9238
|
+
// K-21 (#2071): a tombstone, never a delete — a declared shape's top-up must be able
|
|
9239
|
+
// to tell a key taken back from one never held.
|
|
9240
|
+
const r = delegatedRevokeSql(principal, permission, `${entity.entityType}:${entity.entityId}`, at);
|
|
9241
|
+
rt.db.prepare(r.sql).run(...r.params);
|
|
8639
9242
|
},
|
|
8640
9243
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
8641
9244
|
// #1672: mint / revoke / list, written once in the kernel. The raw spine seam (this
|
|
@@ -8660,6 +9263,26 @@ export class SqliteScopeHost {
|
|
|
8660
9263
|
now: at,
|
|
8661
9264
|
emit: (event) => writeEvent(event, 'kernel'),
|
|
8662
9265
|
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
9266
|
+
isUnreachableParent: (entity) => isUnreachableParent(spineSql(rt.db), statePlans, entity),
|
|
9267
|
+
}),
|
|
9268
|
+
// #119: archive and trash, written once in the kernel. The raw seam, because the guarded
|
|
9269
|
+
// `ctx.sql` refuses the very columns these write; the operation's own check, so the
|
|
9270
|
+
// declared key's pass is one of its authorizations.
|
|
9271
|
+
...createEntityStateVerbs({
|
|
9272
|
+
sql: spineSql(rt.db),
|
|
9273
|
+
plans: statePlans,
|
|
9274
|
+
now: at,
|
|
9275
|
+
check: runCheck,
|
|
9276
|
+
emit: (event) => writeEvent(event, 'kernel'),
|
|
9277
|
+
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
9278
|
+
}),
|
|
9279
|
+
...createTrashedReads({
|
|
9280
|
+
query: (q, params) => rt.db.prepare(q).all(...params),
|
|
9281
|
+
listPlans,
|
|
9282
|
+
searchPlans,
|
|
9283
|
+
statePlans,
|
|
9284
|
+
check: runCheck,
|
|
9285
|
+
continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
|
|
8663
9286
|
}),
|
|
8664
9287
|
// #304: the request-time entitlement read. The pure adapter is single-process, so the
|
|
8665
9288
|
// directory is local — no projection needed; it reads `_substrat_entitlements` straight,
|
|
@@ -8713,56 +9336,109 @@ export class SqliteScopeHost {
|
|
|
8713
9336
|
kernelEmitters.set(ctxRef, (event) => writeEvent(event, 'kernel'));
|
|
8714
9337
|
return ctxRef;
|
|
8715
9338
|
}
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
9339
|
+
/** Every registered module's derivation plans — what `repairDerivedObjects` and its checks read. */
|
|
9340
|
+
derivedPlans() {
|
|
9341
|
+
return { state: this.statePlans, lists: this.listPlans, search: this.searchPlans };
|
|
9342
|
+
}
|
|
9343
|
+
/**
|
|
9344
|
+
* #2090: a pass with nothing pending, once per runtime — check and repair what the kernel
|
|
9345
|
+
* derived onto the scope's tables, in a transaction of its own. Its migrations are journaled
|
|
9346
|
+
* and will never run again, so this is what reaches a scope stripped before the migration
|
|
9347
|
+
* pass checked. A lost state column fails the scope closed like a failed migration, named by
|
|
9348
|
+
* the journaled migration that added the column; every later call tries again and fails again.
|
|
9349
|
+
*/
|
|
9350
|
+
async repairDerived(rt) {
|
|
9351
|
+
let failure;
|
|
9352
|
+
await rt.actor.enqueue(() => {
|
|
9353
|
+
rt.db.exec('BEGIN IMMEDIATE');
|
|
9354
|
+
try {
|
|
9355
|
+
repairDerivedObjects(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans(), {
|
|
9356
|
+
after: 'an applied migration',
|
|
9357
|
+
});
|
|
9358
|
+
rt.db.exec('COMMIT');
|
|
8723
9359
|
}
|
|
9360
|
+
catch (err) {
|
|
9361
|
+
rt.db.exec('ROLLBACK');
|
|
9362
|
+
failure = { version: err instanceof StateColumnLost ? err.migration : 'kernel@derived-objects', error: err.message };
|
|
9363
|
+
}
|
|
9364
|
+
});
|
|
9365
|
+
if (failure) {
|
|
9366
|
+
this.recordMigrationState(rt, failure);
|
|
9367
|
+
throw migrationFailedError(failure.version, failure.error);
|
|
9368
|
+
}
|
|
9369
|
+
rt.derivedRepaired = true;
|
|
9370
|
+
}
|
|
9371
|
+
async applyPendingMigrations(rt, plan) {
|
|
9372
|
+
const { pending, diverged } = plan ?? (await planMigrations(this.modules.values(), rt.appliedMigrations));
|
|
9373
|
+
if (diverged) {
|
|
9374
|
+
this.recordMigrationState(rt, diverged);
|
|
9375
|
+
throw migrationFailedError(diverged.version, diverged.error);
|
|
8724
9376
|
}
|
|
8725
9377
|
// Nothing pending → nothing to record. A scope provisioned before any module
|
|
8726
|
-
// registers legitimately sits at schema_version '0'.
|
|
8727
|
-
|
|
9378
|
+
// registers legitimately sits at schema_version '0'. Still, once per runtime, what the
|
|
9379
|
+
// kernel derived onto the tables is checked and repaired (#2090).
|
|
9380
|
+
if (pending.length === 0) {
|
|
9381
|
+
if (!rt.derivedRepaired)
|
|
9382
|
+
await this.repairDerived(rt);
|
|
8728
9383
|
return;
|
|
9384
|
+
}
|
|
8729
9385
|
// The failing `module@version` and its cause, captured structurally rather than
|
|
8730
9386
|
// re-parsed out of the thrown message — the directory record has to name both.
|
|
8731
9387
|
let failure;
|
|
8732
9388
|
try {
|
|
8733
9389
|
await rt.actor.enqueue(() => {
|
|
8734
|
-
for (const { moduleId, migration } of pending) {
|
|
9390
|
+
for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
|
|
8735
9391
|
const key = `${moduleId}@${migration.version}`;
|
|
8736
9392
|
if (rt.appliedMigrations.has(key))
|
|
8737
9393
|
continue;
|
|
8738
9394
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
9395
|
+
let recorded = digest;
|
|
8739
9396
|
try {
|
|
8740
9397
|
const already = rt.db
|
|
8741
|
-
.prepare('SELECT
|
|
9398
|
+
.prepare('SELECT sql_digest FROM _substrat_migrations WHERE module_id = ? AND version = ?')
|
|
8742
9399
|
.get(moduleId, migration.version);
|
|
8743
|
-
if (
|
|
9400
|
+
if (already) {
|
|
9401
|
+
// Applied since this pass read the journal: held to the same digest rule.
|
|
9402
|
+
const error = migrationDivergence(already.sql_digest, digest, authored);
|
|
9403
|
+
if (error)
|
|
9404
|
+
throw new Error(error);
|
|
9405
|
+
recorded = already.sql_digest;
|
|
9406
|
+
}
|
|
9407
|
+
else {
|
|
8744
9408
|
const started = performance.now();
|
|
8745
9409
|
const before = rt.db.prepare('SELECT total_changes() AS n').get().n;
|
|
8746
|
-
// #1898: a migration runs on the scope's own handle, not `ctx.sql`, so the
|
|
8747
|
-
// spine
|
|
8748
|
-
|
|
8749
|
-
|
|
9410
|
+
// #1898, #2066: a migration runs on the scope's own handle, not `ctx.sql`, so the
|
|
9411
|
+
// spine rules a migration is held to are applied here.
|
|
9412
|
+
assertMigrationSql(migration.sql, { key, digest, authored });
|
|
9413
|
+
// #2068: the ownership ledger is the kernel's — no migration may name it, not even to
|
|
9414
|
+
// read it (the spine rules above allow reads).
|
|
9415
|
+
assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
|
|
9416
|
+
// #2068: one statement at a time, keeping the table set either side of each — the
|
|
9417
|
+
// same splitter the Durable Object runs, so both hosts execute identical statements.
|
|
9418
|
+
const steps = runMigrationStatements(spineSql(rt.db), migration.sql, (stmt) => rt.db.exec(stmt));
|
|
8750
9419
|
assertTablesWithinColumnLimit(rt.db);
|
|
9420
|
+
// #2090: the state columns this migration must have left, and after the last of the
|
|
9421
|
+
// pass, the triggers and indexes a create-copy-rename rebuild dropped.
|
|
9422
|
+
const last = i === pending.length - 1;
|
|
9423
|
+
afterMigration(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans(), key, last);
|
|
8751
9424
|
const after = rt.db.prepare('SELECT total_changes() AS n').get().n;
|
|
9425
|
+
// After the count: `rows_changed` is the migration's own, never the kernel's bookkeeping.
|
|
9426
|
+
recordOwnershipSteps(spineSql(rt.db), moduleId, steps, this.clock());
|
|
8752
9427
|
rt.db
|
|
8753
|
-
.prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed) VALUES (?, ?, ?, ?, ?)')
|
|
8754
|
-
.run(moduleId, migration.version, this.clock(), Math.max(0, Math.round(performance.now() - started)), after - before);
|
|
9428
|
+
.prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed, sql_digest) VALUES (?, ?, ?, ?, ?, ?)')
|
|
9429
|
+
.run(moduleId, migration.version, this.clock(), Math.max(0, Math.round(performance.now() - started)), after - before, digest);
|
|
8755
9430
|
}
|
|
8756
9431
|
rt.db.exec('COMMIT');
|
|
8757
9432
|
}
|
|
8758
9433
|
catch (err) {
|
|
8759
9434
|
rt.db.exec('ROLLBACK');
|
|
8760
9435
|
failure = { version: key, error: err.message };
|
|
8761
|
-
throw
|
|
9436
|
+
throw migrationFailedError(key, err.message);
|
|
8762
9437
|
}
|
|
8763
|
-
rt.appliedMigrations.
|
|
9438
|
+
rt.appliedMigrations.set(key, recorded);
|
|
8764
9439
|
}
|
|
8765
9440
|
});
|
|
9441
|
+
rt.derivedRepaired = true;
|
|
8766
9442
|
}
|
|
8767
9443
|
finally {
|
|
8768
9444
|
// `finally`, not the success path: a scope that failed closed is exactly the
|
|
@@ -8934,9 +9610,14 @@ export class SqliteScopeHost {
|
|
|
8934
9610
|
// #1763: old journal rows keep NULL, meaning their cost was never recorded.
|
|
8935
9611
|
this.ensureColumn(db, '_substrat_migrations', 'duration_ms', 'duration_ms INTEGER');
|
|
8936
9612
|
this.ensureColumn(db, '_substrat_migrations', 'rows_changed', 'rows_changed INTEGER');
|
|
9613
|
+
// #2066: the rows already there get the legacy mark, never a digest — what they ran was never
|
|
9614
|
+
// measured. KERNEL_DDL's fence keeps any other NULL out, so the mark finds only those.
|
|
9615
|
+
this.ensureColumn(db, '_substrat_migrations', 'sql_digest', 'sql_digest TEXT');
|
|
9616
|
+
db.exec(MIGRATION_DIGEST_MARK_LEGACY);
|
|
8937
9617
|
// KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before K-21 keeps the
|
|
8938
9618
|
// old shape — ALTER the tombstone in.
|
|
8939
9619
|
this.ensureColumn(db, '_substrat_tuples', 'revoked_at', 'revoked_at TEXT');
|
|
9620
|
+
db.exec(GRANT_CHILDREN_INDEX_DDL);
|
|
8940
9621
|
// Executor retry state (#100), same reasoning: scopes provisioned before it
|
|
8941
9622
|
// already have the table. Defaults read as "terminal", which is exactly right
|
|
8942
9623
|
// for the rows already there — every one of them is a completed delivery or a
|
|
@@ -8993,6 +9674,9 @@ export class SqliteScopeHost {
|
|
|
8993
9674
|
// from the kernel's (now widened) DDL when it runs — so by the time this ALTER
|
|
8994
9675
|
// executes, the table always already has `kind` in its key, never `invocation_id`.
|
|
8995
9676
|
this.ensureColumn(db, '_substrat_schedule_state', 'invocation_id', 'invocation_id TEXT');
|
|
9677
|
+
// #2096: a purge horizon's lap in progress, on a scope DB built before the column. NULL is the
|
|
9678
|
+
// start of a lap — the honest reading of every row already there. After the #1288 rebuild, as above.
|
|
9679
|
+
this.ensureColumn(db, '_substrat_schedule_state', 'purge_cursor', 'purge_cursor TEXT');
|
|
8996
9680
|
this.ensureColumn(db, '_substrat_job_runs', 'subject_id', 'subject_id TEXT');
|
|
8997
9681
|
// #2034: the lease, on a scope DB built before it. NULL = nobody holds the run.
|
|
8998
9682
|
this.ensureColumn(db, '_substrat_job_runs', 'lease_owner', 'lease_owner TEXT');
|
|
@@ -9028,9 +9712,9 @@ export class SqliteScopeHost {
|
|
|
9028
9712
|
// WAL is also what the read connection rests on (#1624): a reader sees the last
|
|
9029
9713
|
// committed snapshot and never blocks, or is blocked by, the writer.
|
|
9030
9714
|
db.pragma('journal_mode = WAL');
|
|
9031
|
-
db
|
|
9715
|
+
execSqlStatements(db, KERNEL_DDL);
|
|
9032
9716
|
this.ensureSpineColumns(db);
|
|
9033
|
-
const appliedMigrations =
|
|
9717
|
+
const appliedMigrations = readAppliedMigrations(db);
|
|
9034
9718
|
// #1335: the floor this scope's ids have to clear is the highest id already in
|
|
9035
9719
|
// its outbox, not wherever the wall clock happens to be. Read once, here, because
|
|
9036
9720
|
// this is the one place a scope's runtime is built — a reopened host, a revived
|
|
@@ -9048,16 +9732,20 @@ export class SqliteScopeHost {
|
|
|
9048
9732
|
reader: null,
|
|
9049
9733
|
actor: new ScopeActor(),
|
|
9050
9734
|
appliedMigrations,
|
|
9735
|
+
derivedRepaired: false,
|
|
9051
9736
|
mintEventId,
|
|
9052
|
-
causedBy: null,
|
|
9053
9737
|
invocationId: null,
|
|
9054
|
-
unitInvocationId: null,
|
|
9055
9738
|
};
|
|
9056
9739
|
this.scopes.set(key, created);
|
|
9057
9740
|
this.scopesById.set(scopeId, created);
|
|
9058
9741
|
return created;
|
|
9059
9742
|
}
|
|
9060
9743
|
}
|
|
9744
|
+
/** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
|
|
9745
|
+
function readAppliedMigrations(db) {
|
|
9746
|
+
const rows = db.prepare('SELECT module_id, version, sql_digest FROM _substrat_migrations').all();
|
|
9747
|
+
return new Map(rows.map((r) => [`${r.module_id}@${r.version}`, r.sql_digest]));
|
|
9748
|
+
}
|
|
9061
9749
|
/** The platform spine (`_substrat_*`) and SQLite internals — the UI groups these apart. */
|
|
9062
9750
|
function isSystemTable(name) {
|
|
9063
9751
|
return name.startsWith('_substrat') || name.startsWith('sqlite_');
|
|
@@ -9087,6 +9775,15 @@ function redactionSqlOf(db) {
|
|
|
9087
9775
|
* `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
|
|
9088
9776
|
* spine writes use `rt.db` directly and never pass through here.
|
|
9089
9777
|
*/
|
|
9778
|
+
/**
|
|
9779
|
+
* Run a multi-statement DDL blob one statement at a time, as its comment-blanked text (#2068) —
|
|
9780
|
+
* the same statements and the same text the Durable Object executes, so both hosts store the
|
|
9781
|
+
* same DDL in `sqlite_master` and a later `ALTER TABLE … DROP COLUMN` rewrites the same thing.
|
|
9782
|
+
*/
|
|
9783
|
+
function execSqlStatements(db, sql) {
|
|
9784
|
+
for (const statement of executableSqlStatements(sql))
|
|
9785
|
+
db.exec(statement);
|
|
9786
|
+
}
|
|
9090
9787
|
/**
|
|
9091
9788
|
* The kernel's OWN spine access (#1672) — the same seam as `scopedSql` without `guardSpine`,
|
|
9092
9789
|
* because these are the kernel's writes to `_substrat_capabilities`, which module code may
|
|
@@ -9116,7 +9813,11 @@ function assertTablesWithinColumnLimit(db) {
|
|
|
9116
9813
|
if (wide)
|
|
9117
9814
|
throw new Error(tooManyTableColumns(wide.name, wide.width));
|
|
9118
9815
|
}
|
|
9119
|
-
function scopedSql(db, judgeWidth = false
|
|
9816
|
+
function scopedSql(db, judgeWidth = false,
|
|
9817
|
+
/** #119: the module tables carrying archive/trash columns — `guardSpine` refuses positional writes to them. */
|
|
9818
|
+
statefulTables,
|
|
9819
|
+
/** #119: the kernel's integrity check, run after any runtime DDL — see `guardSpine`. */
|
|
9820
|
+
afterDdl) {
|
|
9120
9821
|
// #1811: a Durable Object refuses a result set over its column cap, `exec` of a SELECT included.
|
|
9121
9822
|
// The driver knows the width, so it is read off the prepared statement rather than parsed out
|
|
9122
9823
|
// of the text (a `SELECT *` is as wide as the tables under it). Module SQL only, like the
|
|
@@ -9143,7 +9844,7 @@ function scopedSql(db, judgeWidth = false) {
|
|
|
9143
9844
|
assertTablesWithinColumnLimit(db);
|
|
9144
9845
|
return { changes: info.changes };
|
|
9145
9846
|
},
|
|
9146
|
-
});
|
|
9847
|
+
}, statefulTables, afterDdl);
|
|
9147
9848
|
}
|
|
9148
9849
|
const schemaVersion = (db) => db.pragma('schema_version', { simple: true });
|
|
9149
9850
|
//# sourceMappingURL=index.js.map
|