@substrat-run/adapter-sqlite 0.148.0 → 0.150.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +47 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +471 -142
- package/dist/index.js.map +1 -1
- package/dist/scope-store-runners.d.ts +4 -4
- package/dist/scope-store-runners.d.ts.map +1 -1
- package/dist/scope-store-runners.js +9 -9
- package/dist/scope-store-runners.js.map +1 -1
- package/dist/trace.d.ts +42 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +91 -0
- package/dist/trace.js.map +1 -0
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
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, assertNoBindingShapeBreak, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, isSubstratError, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, ROUTE_SCOPE_HOSTNAMES_MAX, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, principalBecomeCapabilityInput, 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, connectionBindingAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, UPLOAD_LINK_DDL, createAttachmentVerbs, inspectUploadLink, redeemUploadLinkInTransaction, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, ENTITY_UNBIND_OPERATION, allowedFor, unbindEntityHolderBounded, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, addUpdatedStampPlans, watchUpdatedStamps, entityStateMigrations, assertNoCallerPurge, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, 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, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, 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, peerCallerAt, collectPeers, peerSeats, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, peerVerticalSpellings, samePeerVertical, 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, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, switchRecordsOf, switchedOffOf, switchesBackfillSqlOf, switchesTableExists, tenantHeldOf, tenantHoldsGrant, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, VERSION_MIGRATIONS_DDL, VERTICAL_SCOPE_STORES_DDL, forgetVerticalScopeStores, indexVersionScopeStores,
|
|
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, assertNoBindingShapeBreak, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, isSubstratError, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, ROUTE_SCOPE_HOSTNAMES_MAX, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, principalBecomeCapabilityInput, 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, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, redrainEventsInput, REDRAIN_BATCH, errorCodeOf, } from '@substrat-run/contracts';
|
|
5
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, UPLOAD_LINK_DDL, createAttachmentVerbs, inspectUploadLink, redeemUploadLinkInTransaction, redeemOrReplayUploadLink, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, ENTITY_UNBIND_OPERATION, allowedFor, unbindEntityHolderBounded, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, addUpdatedStampPlans, watchUpdatedStamps, entityStateMigrations, assertNoCallerPurge, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, 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, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, 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, boundToPeerDeclarations, peerCallerAt, collectPeers, peerSeats, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, peerVerticalSpellings, samePeerVertical, 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, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, switchRecordsOf, switchedOffOf, switchesBackfillSqlOf, switchesTableExists, tenantHeldOf, tenantHoldsGrant, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, VERSION_MIGRATIONS_DDL, VERTICAL_SCOPE_STORES_DDL, forgetVerticalScopeStores, indexVersionScopeStores, driverDeclarationsOf, verticalScopeStoresOf, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, deriveVersionMigrationDigest, publishedMigrationDigest, migrationChangeRefusal, 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, migrationSetDivergence, migrationSteps, planMigrations, isSearchIndexTable, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, fenceAttachmentSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, sweepOrphanAttachments, missingAttachmentStore, SETTLED_COPY_STATES, unsettledCopyRefusal, 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, assertPromoteReviewCurrent, promoteRaceRefusal, rebindRaceRefusal, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, isPrimaryScope, PROVISIONED_VERSIONS_SQL, installsReconciledOf, strandJudgmentsOf, provisionedVersionsParams, boundShapesColumn, boundShapesOfColumn, installReconciled, 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, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf, } from '@substrat-run/kernel';
|
|
6
6
|
import { attributedView, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, shapeTopUpBatch, topUpEntityGrantShapes, settlePlatformRequestIn, } from '@substrat-run/kernel';
|
|
7
7
|
import { ADMIN_LOG_INDEXES_SQL, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, readAuditedOperations, unknownOutcomeOf, } from '@substrat-run/kernel';
|
|
8
|
-
import { INERT_SCOPE_REASON, isPrimaryScopeRow,
|
|
8
|
+
import { INERT_SCOPE_REASON, isPrimaryScopeRow, normalizeUploadMediaType } from '@substrat-run/kernel';
|
|
9
9
|
import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, scopeScriptCopyOf, } from '@substrat-run/kernel';
|
|
10
10
|
import { GRANT_CHILDREN_INDEX_DDL, SHAPE_MARKER_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
|
|
11
11
|
import { LEGACY_SCOPE_ROWS_BACKFILL, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
|
|
12
12
|
import { ScopeActor } from './actor.js';
|
|
13
13
|
import { createTupleChecker, directoryTenantReader } from './checker.js';
|
|
14
|
+
import { decidedByOf, entityString, forgetInvocationTrace, invocationTrace, tracedSql } from './trace.js';
|
|
14
15
|
export { scopeStoreRunnersFor } from './scope-store-runners.js';
|
|
15
16
|
/**
|
|
16
17
|
* A fault in the database itself (full, corrupt, I/O), as opposed to one statement failing.
|
|
@@ -322,11 +323,22 @@ const KERNEL_DDL = `
|
|
|
322
323
|
-- #1575: attachment text and its FTS5 index, shared from @substrat-run/kernel. After
|
|
323
324
|
-- _substrat_attachments, whose delete trigger it adds.
|
|
324
325
|
${ATTACHMENT_TEXT_DDL}
|
|
326
|
+
-- #2314: the orphan sweep's tombstones, shared from @substrat-run/kernel. An id here
|
|
327
|
+
-- never takes an attachment row again: the insert path refuses it.
|
|
328
|
+
${ATTACHMENT_CONDEMNED_DDL}
|
|
329
|
+
-- #2350: the orphan sweep's dry-run licence, shared from @substrat-run/kernel. Store-local:
|
|
330
|
+
-- never dumped, and every load drops it.
|
|
331
|
+
${ATTACHMENT_SWEEP_LICENCE_DDL}
|
|
325
332
|
-- #901: an entity's version is the ULID of the last event about it (a state move
|
|
326
333
|
-- excepted, #2191), so the newest id per (entity_type, entity_id) is the read. The id
|
|
327
334
|
-- column sits last so SQLite walks the matched range backwards instead of aggregating.
|
|
328
335
|
${OUTBOX_ENTITY_INDEX}
|
|
329
336
|
`;
|
|
337
|
+
/**
|
|
338
|
+
* The scope spine a fresh scope gets, for a tool that replays a vertical's migrations outside a
|
|
339
|
+
* host — `pnpm lint:migration-fence` (#115), whose reviewed spine migrations read and write it.
|
|
340
|
+
*/
|
|
341
|
+
export const SCOPE_SPINE_DDL = KERNEL_DDL;
|
|
330
342
|
/** Row → contract shape. Never reads the secrets table — that is the point of the split. */
|
|
331
343
|
const toConnection = (r) => connection.parse({
|
|
332
344
|
id: r.id,
|
|
@@ -698,11 +710,13 @@ export class SqliteScopeHost {
|
|
|
698
710
|
secretBox;
|
|
699
711
|
/** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
|
|
700
712
|
attachmentExtractors;
|
|
713
|
+
wrapBlobStore;
|
|
701
714
|
attachmentTextBounds;
|
|
702
715
|
fetchImpl;
|
|
703
716
|
connectorCalls;
|
|
704
717
|
clock;
|
|
705
718
|
versionId;
|
|
719
|
+
migrationsDigest;
|
|
706
720
|
/** #1746: where `ctx.log` lines go — the console unless a caller (a test) says otherwise. */
|
|
707
721
|
logSink;
|
|
708
722
|
/** #1901: where async work's invocation lines go. */
|
|
@@ -716,11 +730,13 @@ export class SqliteScopeHost {
|
|
|
716
730
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
717
731
|
assertAttachmentExtractors(options.attachmentExtractors ?? []);
|
|
718
732
|
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
733
|
+
this.wrapBlobStore = options.wrapBlobStore ?? ((store) => store);
|
|
719
734
|
this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
|
|
720
735
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
721
736
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
722
737
|
this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
|
|
723
738
|
this.versionId = options.versionId ?? null;
|
|
739
|
+
this.migrationsDigest = options.migrationsDigest;
|
|
724
740
|
this.scopeStoreVertical = options.scopeStores?.vertical;
|
|
725
741
|
this.scopeStoreRunners = scopeStoreRunnersOf(options.scopeStores);
|
|
726
742
|
this.scopeStoreLifecycle = scopeStoreLifecycleOf(this.scopeStoreRunners);
|
|
@@ -743,17 +759,17 @@ export class SqliteScopeHost {
|
|
|
743
759
|
this.directory.pragma('journal_mode = WAL');
|
|
744
760
|
this.ensureDirectorySchema();
|
|
745
761
|
this.loadRoles();
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
762
|
+
// #2263: whichever checker evaluates, a peer holds only what the running declaration names.
|
|
763
|
+
this.checker = boundToPeerDeclarations(customChecker ??
|
|
764
|
+
createTupleChecker({
|
|
765
|
+
directory: this.directory,
|
|
766
|
+
scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
|
|
767
|
+
getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
|
|
768
|
+
// #956: the evaluator judges `expires_at` against the host's clock, so a
|
|
769
|
+
// scripted one can actually expire a grant. A caller-supplied `checker`
|
|
770
|
+
// keeps its own time source — this only binds the built-in one.
|
|
771
|
+
clock: () => this.clock(),
|
|
772
|
+
}), () => this.peerDeclarations());
|
|
757
773
|
this.admin = this.buildAdmin();
|
|
758
774
|
this.owner = owner;
|
|
759
775
|
}
|
|
@@ -942,8 +958,8 @@ export class SqliteScopeHost {
|
|
|
942
958
|
created_at TEXT NOT NULL,
|
|
943
959
|
PRIMARY KEY (tenant_id, vertical, binding)
|
|
944
960
|
);
|
|
945
|
-
-- A store
|
|
946
|
-
${
|
|
961
|
+
-- A store driver's stored releases, rollouts and holds (D-63, #2300).
|
|
962
|
+
${STORE_DRIVER_RELEASES_DDL}
|
|
947
963
|
-- The vertical + version registry (#31). A scope binds to a VERSION, so
|
|
948
964
|
-- dev/staging/prod are the same vertical pinned differently, and a preview
|
|
949
965
|
-- deployment is a version nothing has been promoted to yet.
|
|
@@ -1010,6 +1026,9 @@ export class SqliteScopeHost {
|
|
|
1010
1026
|
migration_count INTEGER,
|
|
1011
1027
|
-- 1 once the SQL is out of manifest_json. NULL = stored before #1764, not yet backfilled.
|
|
1012
1028
|
migrations_split INTEGER,
|
|
1029
|
+
-- 1 once migration_digest is the one derived from the stored migrations (#2339).
|
|
1030
|
+
-- NULL = stored before, still the pushed digest; derived on its next read.
|
|
1031
|
+
migration_digest_derived INTEGER,
|
|
1013
1032
|
created_at TEXT NOT NULL,
|
|
1014
1033
|
UNIQUE (vertical_slug, version)
|
|
1015
1034
|
);
|
|
@@ -1414,13 +1433,27 @@ export class SqliteScopeHost {
|
|
|
1414
1433
|
* it inside its transaction onto an emptied directory, before loading any row.
|
|
1415
1434
|
*/
|
|
1416
1435
|
buildDirectorySchema() {
|
|
1436
|
+
// A directory built before the store driver rename keeps its rows under the old names (and one
|
|
1437
|
+
// that ran pre-rename code again, a rollback, holds both names), and
|
|
1438
|
+
// `applyDirectorySchema` would only create empty tables beside them.
|
|
1439
|
+
const tables = this.directory.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((t) => t.name);
|
|
1440
|
+
const hasRows = (table) => this.directory.prepare(`SELECT 1 FROM "${table}" LIMIT 1`).get() !== undefined;
|
|
1441
|
+
// Asked before the moves below, which drop a name with no rows.
|
|
1442
|
+
const bothNames = legacyStoreDriverConflict(tables, hasRows);
|
|
1443
|
+
if (bothNames)
|
|
1444
|
+
console.error(bothNames);
|
|
1445
|
+
for (const stmt of legacyStoreDriverRenames(tables, (table) => builtColumnsOf(this.directory, table), hasRows))
|
|
1446
|
+
this.directory.exec(stmt);
|
|
1417
1447
|
// #1764: `VERSION_MIGRATIONS_DDL` indexes these two columns, so a directory created
|
|
1418
1448
|
// before them needs them before `applyDirectorySchema` reaches that statement.
|
|
1419
1449
|
if (this.directory.prepare('PRAGMA table_info(vertical_versions)').all().length > 0) {
|
|
1420
1450
|
this.ensureColumn(this.directory, 'vertical_versions', 'migration_count', 'migration_count INTEGER');
|
|
1421
1451
|
this.ensureColumn(this.directory, 'vertical_versions', 'migrations_split', 'migrations_split INTEGER');
|
|
1452
|
+
this.ensureColumn(this.directory, 'vertical_versions', 'migration_digest_derived', 'migration_digest_derived INTEGER');
|
|
1422
1453
|
}
|
|
1423
1454
|
this.applyDirectorySchema();
|
|
1455
|
+
// #2322: a migration's declared moves and discards, on a table created before they were carried.
|
|
1456
|
+
this.ensureColumn(this.directory, 'vertical_version_migrations', 'declarations', 'declarations TEXT');
|
|
1424
1457
|
}
|
|
1425
1458
|
/**
|
|
1426
1459
|
* `applyDirectorySchema`, plus the backfill of the schedule switch's record (#1674) from
|
|
@@ -1682,6 +1715,7 @@ export class SqliteScopeHost {
|
|
|
1682
1715
|
}
|
|
1683
1716
|
// #1705: validated whole and kept only if it passes, so a refused module leaves nothing.
|
|
1684
1717
|
this.crossVertical.register(manifest, registration.imports);
|
|
1718
|
+
this.peerUnion = undefined; // #2263: the next check reads the new module set's declarations
|
|
1685
1719
|
this.modules.set(manifest.id, {
|
|
1686
1720
|
id: manifest.id,
|
|
1687
1721
|
// The order the kernel writes once (#1677): authored, then search, then list indexes.
|
|
@@ -2073,7 +2107,7 @@ export class SqliteScopeHost {
|
|
|
2073
2107
|
out.push(r);
|
|
2074
2108
|
}
|
|
2075
2109
|
};
|
|
2076
|
-
return {
|
|
2110
|
+
return this.wrapBlobStore({
|
|
2077
2111
|
put: async (key, body, opts) => {
|
|
2078
2112
|
const p = resolveKey(key);
|
|
2079
2113
|
mkdirSync(dirname(p), { recursive: true });
|
|
@@ -2105,7 +2139,7 @@ export class SqliteScopeHost {
|
|
|
2105
2139
|
walk(root, '', out);
|
|
2106
2140
|
return out.filter((k) => k.startsWith(prefix)).sort();
|
|
2107
2141
|
},
|
|
2108
|
-
};
|
|
2142
|
+
}, ref);
|
|
2109
2143
|
}
|
|
2110
2144
|
/** Resolve the blob store that carries a scope's attachments (#473): the platform-minted
|
|
2111
2145
|
* store for (tenant, the scope's vertical). Fails loudly when none was provisioned —
|
|
@@ -2148,30 +2182,100 @@ export class SqliteScopeHost {
|
|
|
2148
2182
|
const uploadLinks = this.uploadLinkPolicies.get(entityType);
|
|
2149
2183
|
return uploadLinks ? { write: gate.write, uploadLinks } : { write: gate.write };
|
|
2150
2184
|
}
|
|
2151
|
-
async inspectUploadLink(tenantId, scopeId, secret) {
|
|
2185
|
+
async inspectUploadLink(tenantId, scopeId, secret, target, idempotencyKey) {
|
|
2186
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2187
|
+
return rt.actor.enqueue(async () => (await inspectUploadLink({ sql: spineSql(rt.db), now: this.clock() }, secret, target, idempotencyKey)).view);
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* #2314: one orphan-sweep pass over a scope's attachment bytes (`sweepOrphanAttachments` in the
|
|
2191
|
+
* kernel has the guards). Only an active PRIMARY scope of `tenantId` is judged: a fork or a
|
|
2192
|
+
* preview holds another scope's rows, never this one's.
|
|
2193
|
+
*/
|
|
2194
|
+
async sweepOrphanAttachments(tenantId, scopeId, request = {}) {
|
|
2195
|
+
const row = this.directory
|
|
2196
|
+
.prepare('SELECT vertical, kind, forked_from FROM scopes WHERE scope_id = ? AND tenant_id = ?')
|
|
2197
|
+
.get(scopeId, tenantId);
|
|
2198
|
+
if (row && !isPrimaryScopeRow(row)) {
|
|
2199
|
+
throw substratError('conflict', `refused: scope ${scopeId} is a copy, and a copy may not judge orphan attachments`);
|
|
2200
|
+
}
|
|
2201
|
+
const copies = this.directory
|
|
2202
|
+
.prepare(`SELECT script_ref AS scriptRef, state FROM scope_script_copies
|
|
2203
|
+
WHERE tenant_id = ? AND scope_id = ? AND state NOT IN (${SETTLED_COPY_STATES.map(() => '?').join(', ')})`)
|
|
2204
|
+
.all(tenantId, scopeId, ...SETTLED_COPY_STATES);
|
|
2205
|
+
const unsettled = unsettledCopyRefusal(scopeId, copies);
|
|
2206
|
+
if (unsettled)
|
|
2207
|
+
throw unsettled;
|
|
2208
|
+
// Only an active scope: an archived or reaped one is not this pass's to judge.
|
|
2152
2209
|
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2153
|
-
|
|
2210
|
+
const now = this.clock();
|
|
2211
|
+
// #2350: a destructive pass needs the scope's own dry-run licence, asked before anything is
|
|
2212
|
+
// listed, so a pass with nothing to condemn is refused all the same.
|
|
2213
|
+
if (request.dryRun !== true)
|
|
2214
|
+
await rt.actor.enqueue(() => refuseUnlicensedOrphanSweep(spineSql(rt.db), now));
|
|
2215
|
+
// A scope with no blob store holds no bytes: answered as that, marked, after every guard above,
|
|
2216
|
+
// so the platform's pass tells it from a failure and from a store that held nothing.
|
|
2217
|
+
const stores = row?.vertical
|
|
2218
|
+
? this.directory.prepare('SELECT 1 FROM blob_stores WHERE tenant_id = ? AND vertical = ? LIMIT 1').get(tenantId, row.vertical)
|
|
2219
|
+
: undefined;
|
|
2220
|
+
if (!stores) {
|
|
2221
|
+
// A scope that holds an attachment row does keep files: its missing store is a misconfiguration.
|
|
2222
|
+
const holds = await rt.actor.enqueue(() => {
|
|
2223
|
+
const table = rt.db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_substrat_attachments'`).get();
|
|
2224
|
+
return table !== undefined && rt.db.prepare('SELECT 1 FROM _substrat_attachments LIMIT 1').get() !== undefined;
|
|
2225
|
+
});
|
|
2226
|
+
if (holds)
|
|
2227
|
+
throw missingAttachmentStore(tenantId, scopeId);
|
|
2228
|
+
return { listed: 0, candidates: 0, orphans: 0, deleted: 0, keys: [], dryRun: request.dryRun === true, bucket: 'none' };
|
|
2229
|
+
}
|
|
2230
|
+
const store = this.attachmentStore(tenantId, row?.vertical ?? null);
|
|
2231
|
+
return sweepOrphanAttachments(store, scopeId, {
|
|
2232
|
+
rowless: async (ids) => rt.actor.enqueue(() => rowlessAttachmentIds(spineSql(rt.db), ids)),
|
|
2233
|
+
condemn: async (ids, at) => rt.actor.enqueue(() => rt.db.transaction(() => condemnLicensedOrphanAttachments(spineSql(rt.db), ids, at))()),
|
|
2234
|
+
license: async (ids, at) => {
|
|
2235
|
+
await rt.actor.enqueue(() => rt.db.transaction(() => recordOrphanSweepLicence(spineSql(rt.db), ids, at))());
|
|
2236
|
+
},
|
|
2237
|
+
licensed: async () => rt.actor.enqueue(() => licensedOrphanIds(spineSql(rt.db))),
|
|
2238
|
+
consume: async (ids) => rt.actor.enqueue(() => rt.db.transaction(() => consumeOrphanSweepLicence(spineSql(rt.db), ids))()),
|
|
2239
|
+
}, {
|
|
2240
|
+
// The host's own clock, never a caller's: no request can step past the floor or the fence.
|
|
2241
|
+
now,
|
|
2242
|
+
...(request.dryRun ? { dryRun: true } : {}),
|
|
2243
|
+
...(request.limit !== undefined ? { limit: request.limit } : {}),
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* #2314: the orphan sweep's restore fence for a scope whose rows are about to be replaced. A
|
|
2248
|
+
* scope whose vertical has no blob store has no bytes, so nothing is fenced; a fence that cannot
|
|
2249
|
+
* be written throws, and the caller refuses the replacement.
|
|
2250
|
+
*/
|
|
2251
|
+
fenceOrphanSweep(tenantId, scopeId, vertical) {
|
|
2252
|
+
const held = vertical
|
|
2253
|
+
? this.directory.prepare('SELECT 1 FROM blob_stores WHERE tenant_id = ? AND vertical = ? LIMIT 1').get(tenantId, vertical)
|
|
2254
|
+
: undefined;
|
|
2255
|
+
if (!held)
|
|
2256
|
+
return Promise.resolve();
|
|
2257
|
+
return fenceAttachmentSweep(this.attachmentStore(tenantId, vertical), scopeId, this.clock());
|
|
2154
2258
|
}
|
|
2155
2259
|
async redeemUploadLink(tenantId, scopeId, secret, input) {
|
|
2156
2260
|
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2261
|
+
// The target first (the kernel judges it before the state), so a wrong path never reads as
|
|
2262
|
+
// gone; a replay (#2315) is answered from the inspection and never reaches the write.
|
|
2263
|
+
const inspect = () => rt.actor.enqueue(() => inspectUploadLink({ sql: spineSql(rt.db), now: this.clock() }, secret, input.entity, input.idempotencyKey));
|
|
2264
|
+
return redeemOrReplayUploadLink(inspect, input, async ({ view, mintedBy }) => {
|
|
2265
|
+
// The write is made AS the minter: the same gates `attachments()` puts in front of a person.
|
|
2266
|
+
await this.getScope(mintedBy, tenantId, scopeId);
|
|
2267
|
+
const scopeRow = this.directory
|
|
2268
|
+
.prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
|
|
2269
|
+
.get(scopeId);
|
|
2270
|
+
const files = this.buildAttachments(rt, asPrincipal(mintedBy), this.attachmentStore(tenantId, scopeRow?.vertical ?? null), { uploadLink: { secret, idempotencyKey: input.idempotencyKey } });
|
|
2271
|
+
return files.upload({
|
|
2272
|
+
entity: view.entity,
|
|
2273
|
+
filename: view.filename ?? input.filename,
|
|
2274
|
+
// Recorded as it is judged: the essence, never a parameter or a stray space.
|
|
2275
|
+
contentType: normalizeUploadMediaType(input.contentType),
|
|
2276
|
+
visibility: view.visibility,
|
|
2277
|
+
body: input.body,
|
|
2161
2278
|
});
|
|
2162
|
-
}
|
|
2163
|
-
// The write is made AS the minter: the same gates `attachments()` puts in front of a person.
|
|
2164
|
-
await this.getScope(mintedBy, tenantId, scopeId);
|
|
2165
|
-
const scopeRow = this.directory
|
|
2166
|
-
.prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
|
|
2167
|
-
.get(scopeId);
|
|
2168
|
-
const files = this.buildAttachments(rt, asPrincipal(mintedBy), this.attachmentStore(tenantId, scopeRow?.vertical ?? null), { uploadLinkSecret: secret });
|
|
2169
|
-
return files.upload({
|
|
2170
|
-
entity: view.entity,
|
|
2171
|
-
filename: view.filename ?? input.filename,
|
|
2172
|
-
contentType: input.contentType,
|
|
2173
|
-
visibility: view.visibility,
|
|
2174
|
-
body: input.body,
|
|
2175
2279
|
});
|
|
2176
2280
|
}
|
|
2177
2281
|
async getSystemAttachments(moduleId, tenantId, scopeId) {
|
|
@@ -2314,6 +2418,8 @@ export class SqliteScopeHost {
|
|
|
2314
2418
|
try {
|
|
2315
2419
|
return await guarded('attachments.upload', async (ctx) => {
|
|
2316
2420
|
assertAllowed(await ctx.check(gate.write, input.entity));
|
|
2421
|
+
// #2314: an id the orphan sweep condemned takes no row, in this same transaction.
|
|
2422
|
+
refuseCondemnedAttachment(spineSql(rt.db), record.id);
|
|
2317
2423
|
rt.db
|
|
2318
2424
|
.prepare(`INSERT INTO _substrat_attachments
|
|
2319
2425
|
(id, entity_type, entity_id, filename, content_type, size, sha256,
|
|
@@ -2332,8 +2438,8 @@ export class SqliteScopeHost {
|
|
|
2332
2438
|
enqueueAttachmentText(spineSql(rt.db), record.id, ulid(), this.clock());
|
|
2333
2439
|
// An upload-link redemption spends its link HERE, in the same transaction as the
|
|
2334
2440
|
// row it produced: a refusal anywhere above or below rolls the spend back.
|
|
2335
|
-
if (opts.
|
|
2336
|
-
await redeemUploadLinkInTransaction({ sql: spineSql(rt.db), now: this.clock(), emit: (event) => kernelEmit(ctx, event) }, opts.
|
|
2441
|
+
if (opts.uploadLink !== undefined) {
|
|
2442
|
+
await redeemUploadLinkInTransaction({ sql: spineSql(rt.db), now: this.clock(), emit: (event) => kernelEmit(ctx, event) }, opts.uploadLink.secret, record, opts.uploadLink.idempotencyKey);
|
|
2337
2443
|
}
|
|
2338
2444
|
return record;
|
|
2339
2445
|
});
|
|
@@ -2473,6 +2579,16 @@ export class SqliteScopeHost {
|
|
|
2473
2579
|
// rows are gone and the new ones are in. It also covers what a topological sort
|
|
2474
2580
|
// cannot express: FK cycles, and self-referencing rows within one table.
|
|
2475
2581
|
db.pragma('defer_foreign_keys = ON');
|
|
2582
|
+
// A dump written before indexes were excluded may still carry them; skip those
|
|
2583
|
+
// too rather than failing a restore over data that is about to be recomputed.
|
|
2584
|
+
// Matched without case, as SQLite resolves a table name (#1883 review).
|
|
2585
|
+
const replayable = dumped.filter((t) => !isSearchIndexTable(t.name.toLowerCase()));
|
|
2586
|
+
// A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
|
|
2587
|
+
// not keep at all; they are skipped by name, and any other unknown spine table is refused.
|
|
2588
|
+
const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
|
|
2589
|
+
// #2314: a row for an id the orphan sweep condemned here is refused, as the insert refuses it.
|
|
2590
|
+
// Judged on `loadable`, the very tables whose rows the replay below inserts, before any drop.
|
|
2591
|
+
refuseCondemnedInDump(spineSql(db), loadable);
|
|
2476
2592
|
// Drop the current schema: a vertical's tables are rebuilt from the dump's DDL, the
|
|
2477
2593
|
// spine from KERNEL_DDL (below). Only real tables (never `sqlite_*` internals, which
|
|
2478
2594
|
// are auto-managed and un-droppable).
|
|
@@ -2487,10 +2603,6 @@ export class SqliteScopeHost {
|
|
|
2487
2603
|
continue;
|
|
2488
2604
|
db.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
2489
2605
|
}
|
|
2490
|
-
// A dump written before indexes were excluded may still carry them; skip those
|
|
2491
|
-
// too rather than failing a restore over data that is about to be recomputed.
|
|
2492
|
-
// Matched without case, as SQLite resolves a table name (#1883 review).
|
|
2493
|
-
const replayable = dumped.filter((t) => !isSearchIndexTable(t.name.toLowerCase()));
|
|
2494
2606
|
// The dump is untrusted input (#1143): its names reach SQL as identifiers, and
|
|
2495
2607
|
// `db.exec` would run every statement its `ddl` contains, not just the CREATE
|
|
2496
2608
|
// TABLE. Judged as a whole before any of it executes — a check interleaved with
|
|
@@ -2502,9 +2614,6 @@ export class SqliteScopeHost {
|
|
|
2502
2614
|
// contributes only rows, by column name (`dumpRowsInsert`). Otherwise a dump could
|
|
2503
2615
|
// declare a column the checker compares on as COLLATE NOCASE. The column pass follows,
|
|
2504
2616
|
// for the one outbox index KERNEL_DDL leaves to it.
|
|
2505
|
-
// A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
|
|
2506
|
-
// not keep at all; they are skipped by name, and any other unknown spine table is refused.
|
|
2507
|
-
const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
|
|
2508
2617
|
// Comment-blanked (#2068), as the Durable Object replays it: the dump's DDL is
|
|
2509
2618
|
// `sqlite_master.sql` verbatim, and `prepare` still compiles exactly one statement.
|
|
2510
2619
|
for (const t of loadable)
|
|
@@ -2530,6 +2639,10 @@ export class SqliteScopeHost {
|
|
|
2530
2639
|
for (const row of t.rows)
|
|
2531
2640
|
stmt.run(...row);
|
|
2532
2641
|
}
|
|
2642
|
+
// #2350: the orphan sweep's licence describes this store's last dry run, never the dump's.
|
|
2643
|
+
// A dump does not carry it; a hand-made one that does is dropped here all the same, so a
|
|
2644
|
+
// loaded store's next pass is a dry run.
|
|
2645
|
+
clearOrphanSweepLicence(spineSql(db));
|
|
2533
2646
|
// #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
|
|
2534
2647
|
// sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows that are
|
|
2535
2648
|
// gone. Put back, and the search index rebuilt, AFTER the rows — which may legitimately
|
|
@@ -2595,6 +2708,8 @@ export class SqliteScopeHost {
|
|
|
2595
2708
|
if (!existing)
|
|
2596
2709
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2597
2710
|
this.refuseUnusableScopeStores(dump.stores, existing.vertical);
|
|
2711
|
+
// #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
|
|
2712
|
+
await this.fenceOrphanSweep(tenantId, scopeId, existing.vertical ?? null);
|
|
2598
2713
|
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
2599
2714
|
// the directory's recorded-off modules go back off in the load's own turn, as a
|
|
2600
2715
|
// provision's seat does — no schedule pass can run in between.
|
|
@@ -2828,7 +2943,7 @@ export class SqliteScopeHost {
|
|
|
2828
2943
|
}
|
|
2829
2944
|
/**
|
|
2830
2945
|
* A dump's stores this host cannot load are refused before any write (#2211, L8): a
|
|
2831
|
-
* binding that is not bound, a store with no
|
|
2946
|
+
* binding that is not bound, a store with no driver or a driver other than the bound runner's,
|
|
2832
2947
|
* any store at all for a destination with no vertical (no store object can be named for
|
|
2833
2948
|
* it, so loading would drop them), or a destination on a vertical other than the one this
|
|
2834
2949
|
* host is pinned to.
|
|
@@ -2848,11 +2963,11 @@ export class SqliteScopeHost {
|
|
|
2848
2963
|
if (!runner) {
|
|
2849
2964
|
throw substratError('not_found', `dump names scope store '${store.binding}', which is not bound`);
|
|
2850
2965
|
}
|
|
2851
|
-
if (!store.
|
|
2852
|
-
throw substratError('validation_failed', `dump's scope store '${store.binding}' names no
|
|
2966
|
+
if (!store.driver) {
|
|
2967
|
+
throw substratError('validation_failed', `dump's scope store '${store.binding}' names no driver`);
|
|
2853
2968
|
}
|
|
2854
|
-
if (store.
|
|
2855
|
-
throw substratError('conflict', `dump's scope store '${store.binding}' is
|
|
2969
|
+
if (store.driver !== runner.driver) {
|
|
2970
|
+
throw substratError('conflict', `dump's scope store '${store.binding}' is driver '${store.driver}', and this host binds driver '${runner.driver}'`);
|
|
2856
2971
|
}
|
|
2857
2972
|
}
|
|
2858
2973
|
}
|
|
@@ -2869,7 +2984,7 @@ export class SqliteScopeHost {
|
|
|
2869
2984
|
if (!runner)
|
|
2870
2985
|
throw substratError('not_found', `no scope store is bound for '${binding}'`);
|
|
2871
2986
|
const exported = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.export');
|
|
2872
|
-
stores.push({ binding,
|
|
2987
|
+
stores.push({ binding, driver: runner.driver, tables: exported.tables });
|
|
2873
2988
|
}
|
|
2874
2989
|
return stores;
|
|
2875
2990
|
}
|
|
@@ -3456,10 +3571,15 @@ export class SqliteScopeHost {
|
|
|
3456
3571
|
const store = this.attachmentStore(tenantId, scopeRow?.vertical ?? null);
|
|
3457
3572
|
return this.buildAttachments(rt, { kind: 'capability-session', hash }, store);
|
|
3458
3573
|
}
|
|
3459
|
-
/**
|
|
3574
|
+
/**
|
|
3575
|
+
* Every registered module's `peers`, as one declaration per peer vertical (#1706). Built once
|
|
3576
|
+
* per registered module set and dropped by `registerModule` (#2263): the checker reads it on
|
|
3577
|
+
* every check, so it must be a lookup, never a rebuild.
|
|
3578
|
+
*/
|
|
3460
3579
|
peerDeclarations() {
|
|
3461
|
-
return collectPeers(this.modules.values());
|
|
3580
|
+
return (this.peerUnion ??= collectPeers(this.modules.values()));
|
|
3462
3581
|
}
|
|
3582
|
+
peerUnion;
|
|
3463
3583
|
/**
|
|
3464
3584
|
* The peer door (#1706) — mirror of the connection, system and capability doors. The caller is
|
|
3465
3585
|
* the local broker's word (the platform's, on the pure host); the pair and lifecycle gate run
|
|
@@ -3833,7 +3953,8 @@ export class SqliteScopeHost {
|
|
|
3833
3953
|
try {
|
|
3834
3954
|
if (schedule.purge) {
|
|
3835
3955
|
// #119: a purge horizon's schedule runs its operation once per due entity, never once.
|
|
3836
|
-
|
|
3956
|
+
// #2192: under the run's own id, so the purges and their deliveries join this run's line.
|
|
3957
|
+
const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation, invocationId);
|
|
3837
3958
|
stillDue = purgeStillDue(pass);
|
|
3838
3959
|
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
3839
3960
|
report.errors.push(...outcome.errors);
|
|
@@ -3904,7 +4025,9 @@ export class SqliteScopeHost {
|
|
|
3904
4025
|
* through a purge stub (`openSystemScope`) that never leaves this method, each in its own
|
|
3905
4026
|
* transaction and under the cutoff the host re-checks inside it (`refuseTrashedTarget`).
|
|
3906
4027
|
*/
|
|
3907
|
-
async runPurgeSweep(moduleId, tenantId, scopeId, operation
|
|
4028
|
+
async runPurgeSweep(moduleId, tenantId, scopeId, operation,
|
|
4029
|
+
/** #2192: the schedule run this pass belongs to; every purge in it runs under that id. */
|
|
4030
|
+
runInvocationId) {
|
|
3908
4031
|
const entityType = this.modules
|
|
3909
4032
|
.get(moduleId)
|
|
3910
4033
|
?.schedules.find((s) => s.operation === operation && s.purge)?.purge?.entityType;
|
|
@@ -3931,7 +4054,7 @@ export class SqliteScopeHost {
|
|
|
3931
4054
|
return heldPurgePass(due.held);
|
|
3932
4055
|
const stub = await this.openSystemScope(moduleId, tenantId, scopeId, true);
|
|
3933
4056
|
return runPurgePass(operation, due, async (entityId) => {
|
|
3934
|
-
await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId:
|
|
4057
|
+
await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: runInvocationId });
|
|
3935
4058
|
}, (write) => rt.actor.turn(() => write(spineSql(rt.db))));
|
|
3936
4059
|
}
|
|
3937
4060
|
/**
|
|
@@ -4073,6 +4196,15 @@ export class SqliteScopeHost {
|
|
|
4073
4196
|
// `finally` so a later consumer carries none rather than the last caller's,
|
|
4074
4197
|
// which is the leak `causedBy` had to be moved off the host to avoid.
|
|
4075
4198
|
rt.invocationId = invokeOptions?.invocationId ?? null;
|
|
4199
|
+
// #2358: this call's trace, when `substrat dev` has installed a sink; undefined otherwise.
|
|
4200
|
+
const trace = invocationTrace(rt.invocationId);
|
|
4201
|
+
// #1672: the secrets this invocation mints — withheld from its idempotency recording,
|
|
4202
|
+
// and what the tripwire on its writes looks for. Declared here, outside the `try`, so the
|
|
4203
|
+
// trace's error step can withhold them from a message too.
|
|
4204
|
+
const minted = [];
|
|
4205
|
+
// #2358: the input error already recorded as a `validation` step, so the `error` step
|
|
4206
|
+
// below does not repeat its message.
|
|
4207
|
+
let refusedInput;
|
|
4076
4208
|
// The outbox's insertion mark, inside the actor task so nothing else writes between it
|
|
4077
4209
|
// and this invoke (`OUTBOX_MARK_SQL` says why rowid and not the id).
|
|
4078
4210
|
const exportMark = exportTypes.length > 0 ? Number(rt.db.prepare(OUTBOX_MARK_SQL).get().mark) : null;
|
|
@@ -4097,9 +4229,6 @@ export class SqliteScopeHost {
|
|
|
4097
4229
|
: authority.kind === 'vertical-peer'
|
|
4098
4230
|
? admitPeer(switchSqlOf(rt.db), this.peerDeclarations(), authority.caller, operation)
|
|
4099
4231
|
: authority;
|
|
4100
|
-
// #1672: the secrets this invocation mints — withheld from its idempotency recording,
|
|
4101
|
-
// and what the tripwire on its writes looks for.
|
|
4102
|
-
const minted = [];
|
|
4103
4232
|
// Fresh per operation: the context carries the K-34 authorization accumulator,
|
|
4104
4233
|
// which must not leak across operations (invokes are serialized per scope).
|
|
4105
4234
|
// #119: the keys the module's system principal holds only for its purge schedules are
|
|
@@ -4110,6 +4239,7 @@ export class SqliteScopeHost {
|
|
|
4110
4239
|
const withheld = subject.kind === 'system'
|
|
4111
4240
|
? withheldKeysFor(this.modules.get(subject.id)?.purgeOnlyKeys, target, purging)
|
|
4112
4241
|
: undefined;
|
|
4242
|
+
trace?.head({ operation, tenantId, scopeId, principalKind: subject.kind, principal: actorOf(subject) });
|
|
4113
4243
|
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted, undefined, undefined, withheld);
|
|
4114
4244
|
const clonedInput = structuredClone(input);
|
|
4115
4245
|
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
@@ -4121,9 +4251,21 @@ export class SqliteScopeHost {
|
|
|
4121
4251
|
// schedule. That is the point — parsing at the mount alone would leave
|
|
4122
4252
|
// the demos' own suites exercising an unparsed path, and the wire is
|
|
4123
4253
|
// where the untrusted input actually arrives.
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4254
|
+
let parsed;
|
|
4255
|
+
try {
|
|
4256
|
+
parsed = this.operationInput.has(operation)
|
|
4257
|
+
? this.operationInput.get(operation).parse(clonedInput)
|
|
4258
|
+
: clonedInput;
|
|
4259
|
+
}
|
|
4260
|
+
catch (err) {
|
|
4261
|
+
// #2358: the issues a refused input carries, with the dotted path of each.
|
|
4262
|
+
const issues = err?.issues;
|
|
4263
|
+
if (trace && Array.isArray(issues)) {
|
|
4264
|
+
trace.step({ type: 'validation', issues: issues.map((i) => ({ path: i.path.map(String).join('.'), message: i.message })) });
|
|
4265
|
+
refusedInput = err;
|
|
4266
|
+
}
|
|
4267
|
+
throw err;
|
|
4268
|
+
}
|
|
4127
4269
|
// #116: fingerprinted from the PARSED input, so a retry that omits an
|
|
4128
4270
|
// optional field the original sent at its default value is still the
|
|
4129
4271
|
// same request. Computed before `BEGIN` — it is a pure hash of what the
|
|
@@ -4271,7 +4413,22 @@ export class SqliteScopeHost {
|
|
|
4271
4413
|
}
|
|
4272
4414
|
return structuredClone(result);
|
|
4273
4415
|
}
|
|
4416
|
+
catch (err) {
|
|
4417
|
+
// #2358: the error that ended the call, with the secrets this call minted withheld.
|
|
4418
|
+
// A refused input was recorded above, issue by issue. Its error's message is those
|
|
4419
|
+
// issues as JSON, which can carry the values that failed, so it is not repeated here.
|
|
4420
|
+
trace?.step({
|
|
4421
|
+
type: 'error',
|
|
4422
|
+
code: errorCodeOf(err) ?? null,
|
|
4423
|
+
message: err === refusedInput
|
|
4424
|
+
? 'input failed validation'
|
|
4425
|
+
: redactSecretText(err instanceof Error ? err.message : String(err), minted),
|
|
4426
|
+
...(err !== refusedInput && err instanceof Error && err.stack ? { stack: redactSecretText(err.stack, minted) } : {}),
|
|
4427
|
+
});
|
|
4428
|
+
throw err;
|
|
4429
|
+
}
|
|
4274
4430
|
finally {
|
|
4431
|
+
forgetInvocationTrace(rt.invocationId);
|
|
4275
4432
|
// Cleared on BOTH paths, inside the task. The actor runs one task at a
|
|
4276
4433
|
// time, so a value left set here would be read by whichever task ran next
|
|
4277
4434
|
// and did not set its own — a connector drain, say — stamping its events
|
|
@@ -5531,10 +5688,15 @@ export class SqliteScopeHost {
|
|
|
5531
5688
|
calls: callsOfManifestJson(r.manifest_json),
|
|
5532
5689
|
createdAt: r.created_at,
|
|
5533
5690
|
});
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5691
|
+
// #2339: a version stored before the server derived migration digests is derived on its
|
|
5692
|
+
// first read, so no gate here ever compares the digest its push sent.
|
|
5693
|
+
const readVersion = async (id) => {
|
|
5694
|
+
const read = () => this.directory.prepare('SELECT * FROM vertical_versions WHERE id = ?').get(id);
|
|
5695
|
+
let r = read();
|
|
5696
|
+
if (r && r.migration_digest_derived === null) {
|
|
5697
|
+
await deriveVersionMigrationDigest(switchSqlOf(this.directory), id);
|
|
5698
|
+
r = read();
|
|
5699
|
+
}
|
|
5538
5700
|
return r ? mapVersion(r) : undefined;
|
|
5539
5701
|
};
|
|
5540
5702
|
const mapOrg = (r) => orgSchema.parse({
|
|
@@ -5600,8 +5762,8 @@ export class SqliteScopeHost {
|
|
|
5600
5762
|
});
|
|
5601
5763
|
// The (version, scope) pair a bind and its impact read both start from, and the refusals
|
|
5602
5764
|
// that come before any export-break question, in the order the bind makes them (#1756).
|
|
5603
|
-
const bindTarget = (tenantId, scopeId, versionId) => {
|
|
5604
|
-
const v = readVersion(versionId);
|
|
5765
|
+
const bindTarget = async (tenantId, scopeId, versionId) => {
|
|
5766
|
+
const v = await readVersion(versionId);
|
|
5605
5767
|
if (!v)
|
|
5606
5768
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
5607
5769
|
const scope = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
@@ -5982,7 +6144,7 @@ export class SqliteScopeHost {
|
|
|
5982
6144
|
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5983
6145
|
}
|
|
5984
6146
|
const rt = this.runtime(tenantId, scopeId);
|
|
5985
|
-
const states = await rt.actor.turn(() => peerGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
|
|
6147
|
+
const states = await rt.actor.turn(() => peerGrantsStatus(switchSqlOf(rt.db), new Date().toISOString(), this.peerDeclarations()));
|
|
5986
6148
|
const offPeers = new Set(states.filter((p) => p.calls === 'off').map((p) => p.vertical));
|
|
5987
6149
|
const explanations = offPeers.size > 0 ? lastSwitchedOffBy(tenantId, scopeId, 'revokeFromPeer', 'vertical', offPeers) : new Map();
|
|
5988
6150
|
const result = states.map((p) => ({
|
|
@@ -6638,20 +6800,26 @@ export class SqliteScopeHost {
|
|
|
6638
6800
|
// The SQL migrations are stored apart from the manifest (#1764), so no read of the
|
|
6639
6801
|
// version but the promote review's carries them.
|
|
6640
6802
|
const split = splitManifestMigrations(manifestJson ?? null, 'push');
|
|
6803
|
+
// #2339: the digest a promote compares is the one derived from what is stored, never the pushed one.
|
|
6804
|
+
const migrationDigest = await publishedMigrationDigest({
|
|
6805
|
+
id: audited.id, migrationDigest: audited.migrationDigest, manifestJson: manifestJson ?? null, migrations: split.migrations,
|
|
6806
|
+
});
|
|
6641
6807
|
this.directory.transaction(() => {
|
|
6642
6808
|
this.directory
|
|
6643
6809
|
.prepare(`INSERT INTO vertical_versions
|
|
6644
6810
|
(id, vertical_slug, version, manifest_digest, permission_digest,
|
|
6645
6811
|
migration_digest, deployment_ref, admission, admission_note, manifest_json,
|
|
6646
|
-
origin_json, migration_count, migrations_split, created_at)
|
|
6647
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`)
|
|
6648
|
-
.run(audited.id, audited.verticalSlug, audited.version, audited.manifestDigest, audited.permissionDigest,
|
|
6812
|
+
origin_json, migration_count, migrations_split, migration_digest_derived, created_at)
|
|
6813
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`)
|
|
6814
|
+
.run(audited.id, audited.verticalSlug, audited.version, audited.manifestDigest, audited.permissionDigest, migrationDigest, audited.deploymentRef, selfAdmits ? 'admitted' : 'pending', selfAdmits ? AUTO_ADMISSION_NOTE : null, split.manifestJson, origin ? JSON.stringify(origin) : null, split.migrations?.length ?? null, new Date().toISOString());
|
|
6649
6815
|
writeVersionMigrations(switchSqlOf(this.directory), audited.id, split.migrations);
|
|
6650
|
-
// #2286: the version's scope-store bindings, refusing one
|
|
6816
|
+
// #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
|
|
6651
6817
|
indexVersionScopeStores(switchSqlOf(this.directory), audited.verticalSlug, audited.id, split.manifestJson);
|
|
6652
6818
|
})();
|
|
6653
6819
|
this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
|
|
6654
6820
|
...audited,
|
|
6821
|
+
// The digest the registry holds, which differs from the pushed one only for an unheld version.
|
|
6822
|
+
migrationDigest,
|
|
6655
6823
|
...(origin ? { origin } : {}),
|
|
6656
6824
|
admission: selfAdmits ? 'admitted' : 'pending',
|
|
6657
6825
|
});
|
|
@@ -6666,14 +6834,20 @@ export class SqliteScopeHost {
|
|
|
6666
6834
|
const where = ['vertical_slug = ?'];
|
|
6667
6835
|
const params = [verticalSlug];
|
|
6668
6836
|
const tail = keysetTail(where, params, 'id', page);
|
|
6669
|
-
const
|
|
6670
|
-
|
|
6671
|
-
|
|
6837
|
+
const sql = `SELECT * FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`;
|
|
6838
|
+
let rows = this.directory.prepare(sql).all(...params);
|
|
6839
|
+
// #2339: a page holding versions whose digest is still the pushed one derives them first.
|
|
6840
|
+
const underived = rows.filter((r) => r.migration_digest_derived === null);
|
|
6841
|
+
if (underived.length > 0) {
|
|
6842
|
+
for (const r of underived)
|
|
6843
|
+
await deriveVersionMigrationDigest(switchSqlOf(this.directory), r.id);
|
|
6844
|
+
rows = this.directory.prepare(sql).all(...params);
|
|
6845
|
+
}
|
|
6672
6846
|
this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
|
|
6673
6847
|
return rows.map(mapVersion);
|
|
6674
6848
|
},
|
|
6675
6849
|
getVersion: async (actor, versionId, verticalSlug) => {
|
|
6676
|
-
const v = readVersion(versionId);
|
|
6850
|
+
const v = await readVersion(versionId);
|
|
6677
6851
|
// A version of another vertical reads as absent when the caller named one.
|
|
6678
6852
|
const hit = v && (verticalSlug === undefined || v.verticalSlug === verticalSlug) ? v : undefined;
|
|
6679
6853
|
this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
|
|
@@ -6690,7 +6864,7 @@ export class SqliteScopeHost {
|
|
|
6690
6864
|
const prod = this.directory
|
|
6691
6865
|
.prepare("SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = 'prod'")
|
|
6692
6866
|
.get(slug);
|
|
6693
|
-
const prodVersion = prod ? readVersion(prod.version_id) : undefined;
|
|
6867
|
+
const prodVersion = prod ? await readVersion(prod.version_id) : undefined;
|
|
6694
6868
|
if (prodVersion?.admissionNote === AUTO_ADMISSION_NOTE) {
|
|
6695
6869
|
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
6696
6870
|
`a staff admit must vouch for it before listing`);
|
|
@@ -6771,7 +6945,7 @@ export class SqliteScopeHost {
|
|
|
6771
6945
|
this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.ownerTenant }, null);
|
|
6772
6946
|
},
|
|
6773
6947
|
admitVersion: async (actor, versionId) => {
|
|
6774
|
-
const v = readVersion(versionId);
|
|
6948
|
+
const v = await readVersion(versionId);
|
|
6775
6949
|
if (!v)
|
|
6776
6950
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
6777
6951
|
if (v.admission === 'admitted') {
|
|
@@ -6799,7 +6973,7 @@ export class SqliteScopeHost {
|
|
|
6799
6973
|
});
|
|
6800
6974
|
},
|
|
6801
6975
|
rejectVersion: async (actor, versionId, note) => {
|
|
6802
|
-
const v = readVersion(versionId);
|
|
6976
|
+
const v = await readVersion(versionId);
|
|
6803
6977
|
if (!v)
|
|
6804
6978
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
6805
6979
|
if (v.admission === 'admitted') {
|
|
@@ -6816,7 +6990,7 @@ export class SqliteScopeHost {
|
|
|
6816
6990
|
});
|
|
6817
6991
|
},
|
|
6818
6992
|
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
6819
|
-
const incoming = readVersion(versionId);
|
|
6993
|
+
const incoming = await readVersion(versionId);
|
|
6820
6994
|
if (!incoming || incoming.verticalSlug !== verticalSlug) {
|
|
6821
6995
|
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
6822
6996
|
}
|
|
@@ -6828,7 +7002,7 @@ export class SqliteScopeHost {
|
|
|
6828
7002
|
return breaks;
|
|
6829
7003
|
},
|
|
6830
7004
|
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
6831
|
-
const incoming = readVersion(versionId);
|
|
7005
|
+
const incoming = await readVersion(versionId);
|
|
6832
7006
|
if (!incoming)
|
|
6833
7007
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
6834
7008
|
if (incoming.verticalSlug !== verticalSlug) {
|
|
@@ -6840,7 +7014,7 @@ export class SqliteScopeHost {
|
|
|
6840
7014
|
const current = this.directory
|
|
6841
7015
|
.prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
|
|
6842
7016
|
.get(verticalSlug, channel);
|
|
6843
|
-
const outgoing = current ? readVersion(current.version_id) : undefined;
|
|
7017
|
+
const outgoing = current ? await readVersion(current.version_id) : undefined;
|
|
6844
7018
|
const ack = promotionAcknowledgement.parse(acknowledge ?? {});
|
|
6845
7019
|
// The owning tenant's live scopes a PRIVATE prod promote re-points — below for an embedded
|
|
6846
7020
|
// vertical, after the in-place serve for a dispatch-backed one (#321) — read before
|
|
@@ -6863,6 +7037,18 @@ export class SqliteScopeHost {
|
|
|
6863
7037
|
served?.versionId,
|
|
6864
7038
|
...owned.map((s) => runningVersionOf(mapScope(s), served)),
|
|
6865
7039
|
], versionId, served || owned.length > 0 ? this.provisionedVersionsOf(verticalSlug, served, owned.length > 0 ? owning.ownerTenant : null) : []);
|
|
7040
|
+
// #2333: the acknowledgements answer the review they were given against, or none at all.
|
|
7041
|
+
const reviewed = (v) => ({
|
|
7042
|
+
...v,
|
|
7043
|
+
migrations: versionMigrationsOf(switchSqlOf(this.directory), v.id)?.migrations ?? null,
|
|
7044
|
+
});
|
|
7045
|
+
await assertPromoteReviewCurrent({
|
|
7046
|
+
ack,
|
|
7047
|
+
channel,
|
|
7048
|
+
baseId: outgoing?.id ?? null,
|
|
7049
|
+
migrationsChange: outgoing !== undefined && outgoing.migrationDigest !== incoming.migrationDigest,
|
|
7050
|
+
read: async () => ({ base: outgoing ? reviewed(outgoing) : null, incoming: reviewed(incoming) }),
|
|
7051
|
+
});
|
|
6866
7052
|
// §4's two checkpoints, fired where the blast radius is. A FIRST promotion
|
|
6867
7053
|
// has nothing to diff against, so there is nothing to acknowledge — the
|
|
6868
7054
|
// gate is about change, not about existence.
|
|
@@ -6872,8 +7058,7 @@ export class SqliteScopeHost {
|
|
|
6872
7058
|
`${incoming.permissionDigest}) — acknowledge it explicitly to promote`);
|
|
6873
7059
|
}
|
|
6874
7060
|
if (outgoing.migrationDigest !== incoming.migrationDigest && !ack.migrationChange) {
|
|
6875
|
-
throw substratError('conflict',
|
|
6876
|
-
`${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
|
|
7061
|
+
throw substratError('conflict', migrationChangeRefusal(outgoing.migrationDigest, incoming.migrationDigest));
|
|
6877
7062
|
}
|
|
6878
7063
|
// #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
|
|
6879
7064
|
if (!ack.exportBreak) {
|
|
@@ -6883,18 +7068,32 @@ export class SqliteScopeHost {
|
|
|
6883
7068
|
}
|
|
6884
7069
|
}
|
|
6885
7070
|
const promotedAt = new Date().toISOString();
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
7071
|
+
// #2333: a compare-and-set. Every gate above — the review binding, the digest checkpoints,
|
|
7072
|
+
// the export breaks — was judged against `outgoing`, across awaits another promote can run
|
|
7073
|
+
// in. So the pointer moves only if it still names `outgoing`, read and written in one
|
|
7074
|
+
// transaction; the promote that lost the race is refused as stale and writes nothing.
|
|
7075
|
+
const moved = this.directory.transaction(() => {
|
|
7076
|
+
const now = this.directory
|
|
7077
|
+
.prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
|
|
7078
|
+
.get(verticalSlug, channel);
|
|
7079
|
+
if ((now?.version_id ?? null) !== (outgoing?.id ?? null))
|
|
7080
|
+
return false;
|
|
7081
|
+
this.directory
|
|
7082
|
+
.prepare(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
|
|
7083
|
+
VALUES (?, ?, ?, ?)
|
|
7084
|
+
ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`)
|
|
7085
|
+
.run(verticalSlug, channel, versionId, promotedAt, versionId, promotedAt);
|
|
7086
|
+
// The timeline row: what makes rollback a choice among recorded moments, and
|
|
7087
|
+
// `at` the PITR anchor a data rollback would rewind to.
|
|
7088
|
+
this.directory
|
|
7089
|
+
.prepare(`INSERT INTO vertical_channel_history
|
|
7090
|
+
(id, vertical_slug, channel, version_id, from_version_id, actor, at)
|
|
7091
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
7092
|
+
.run(ulid(), verticalSlug, channel, versionId, outgoing?.id ?? null, actor, promotedAt);
|
|
7093
|
+
return true;
|
|
7094
|
+
})();
|
|
7095
|
+
if (!moved)
|
|
7096
|
+
throw promoteRaceRefusal(channel);
|
|
6898
7097
|
// For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
|
|
6899
7098
|
// owning tenant's live scopes in the same act, so merge-to-main (push +
|
|
6900
7099
|
// promote) is a complete deploy and a rollback promote reaches the running
|
|
@@ -6914,7 +7113,7 @@ export class SqliteScopeHost {
|
|
|
6914
7113
|
for (const s of incoming.deploymentRef ? [] : owned) {
|
|
6915
7114
|
if (s.vertical_version_id === versionId)
|
|
6916
7115
|
continue;
|
|
6917
|
-
const prev = s.vertical_version_id ? readVersion(s.vertical_version_id) : undefined;
|
|
7116
|
+
const prev = s.vertical_version_id ? await readVersion(s.vertical_version_id) : undefined;
|
|
6918
7117
|
if (prev && prev.migrationDigest !== incoming.migrationDigest) {
|
|
6919
7118
|
await this.snapshotScope(actor, s.tenant_id, s.scope_id);
|
|
6920
7119
|
}
|
|
@@ -6976,7 +7175,7 @@ export class SqliteScopeHost {
|
|
|
6976
7175
|
}));
|
|
6977
7176
|
},
|
|
6978
7177
|
bindingImpact: async (actor, tenantId, scopeId, versionId, opts) => {
|
|
6979
|
-
const { v, scope } = bindTarget(tenantId, scopeId, versionId);
|
|
7178
|
+
const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
|
|
6980
7179
|
// #2178: a move no acknowledgement passes is refused here too, so a caller asking first
|
|
6981
7180
|
// (an adopt, an Update) stops before anything is copied.
|
|
6982
7181
|
this.assertBindingShapesKept(mapScope(scope), versionId, opts?.servingRef);
|
|
@@ -6985,22 +7184,30 @@ export class SqliteScopeHost {
|
|
|
6985
7184
|
return breaks;
|
|
6986
7185
|
},
|
|
6987
7186
|
bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
|
|
6988
|
-
const { v, scope } = bindTarget(tenantId, scopeId, versionId);
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
7187
|
+
const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
|
|
7188
|
+
// #2347: a rebind judged against both lineages loses its race as stale, so its client reads
|
|
7189
|
+
// the review again; any other conditional bind keeps the refusal it always had.
|
|
7190
|
+
const lost = () => opts?.expectedServing
|
|
7191
|
+
? rebindRaceRefusal(opts.expectedServing.vertical)
|
|
7192
|
+
: substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
|
|
7193
|
+
if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId)
|
|
7194
|
+
throw lost();
|
|
7195
|
+
if (opts?.expectedServing && !this.servesNow(opts.expectedServing))
|
|
7196
|
+
throw lost();
|
|
6992
7197
|
const epoch = this.directory.prepare('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?')
|
|
6993
7198
|
.get(tenantId, scopeId);
|
|
6994
7199
|
if (opts?.expectedErasureEpoch !== undefined && epoch?.erasure_epoch !== opts.expectedErasureEpoch) {
|
|
6995
7200
|
throw substratError('precondition_failed', 'scope erasure changed; reload the scope and retry');
|
|
6996
7201
|
}
|
|
6997
7202
|
const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
|
|
7203
|
+
// #2347: a route moving in the same write is judged with it, as `bindingImpact` is.
|
|
7204
|
+
const servingRef = opts?.servingRef;
|
|
6998
7205
|
// #2178 first: no acknowledgement passes it, so none should be asked for ahead of it.
|
|
6999
|
-
this.assertBindingShapesKept(mapScope(scope), versionId);
|
|
7206
|
+
this.assertBindingShapesKept(mapScope(scope), versionId, servingRef);
|
|
7000
7207
|
// #1756: an export an app in this tenant imports, dropped or re-versioned by what this
|
|
7001
7208
|
// scope would run. Before the snapshot, so a refused bind leaves nothing behind.
|
|
7002
7209
|
if (!ack.exportBreak) {
|
|
7003
|
-
const breaks = await this.bindBreaks(actor, mapScope(scope), v);
|
|
7210
|
+
const breaks = await this.bindBreaks(actor, mapScope(scope), v, servingRef);
|
|
7004
7211
|
if (breaks.length > 0)
|
|
7005
7212
|
throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
|
|
7006
7213
|
}
|
|
@@ -7008,19 +7215,29 @@ export class SqliteScopeHost {
|
|
|
7008
7215
|
// crosses a migration boundary. Gated on a real digest change — a code-only
|
|
7009
7216
|
// rebind snapshots nothing — and on the caller opting in (until GC ships).
|
|
7010
7217
|
if (opts?.snapshot && scope.vertical_version_id) {
|
|
7011
|
-
const outgoing = readVersion(scope.vertical_version_id);
|
|
7218
|
+
const outgoing = await readVersion(scope.vertical_version_id);
|
|
7012
7219
|
if (outgoing && outgoing.migrationDigest !== v.migrationDigest) {
|
|
7013
7220
|
await this.snapshotScope(actor, tenantId, scopeId);
|
|
7014
7221
|
}
|
|
7015
7222
|
}
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
|
|
7223
|
+
const moved = servingRef === undefined
|
|
7224
|
+
? this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, v.verticalSlug], tenantId, scopeId, opts)
|
|
7225
|
+
: this.moveScopeRow('vertical_version_id = ?, vertical = ?, serving_ref = ?', [versionId, v.verticalSlug, servingRef], tenantId, scopeId, opts);
|
|
7226
|
+
if (!moved) {
|
|
7227
|
+
// The erasure and copy-move conditions keep their own refusal: only a moved binding or a
|
|
7228
|
+
// moved serving version is the race a rebind's review answers.
|
|
7229
|
+
const now = this.directory.prepare('SELECT vertical_version_id FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
7230
|
+
const raced = (opts?.expectedVersionId !== undefined && now?.vertical_version_id !== opts.expectedVersionId)
|
|
7231
|
+
|| (opts?.expectedServing !== undefined && !this.servesNow(opts.expectedServing));
|
|
7232
|
+
throw raced ? lost() : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
|
|
7233
|
+
}
|
|
7234
|
+
this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, servingRef === undefined ? null : { servingRef: scope.serving_ref }, {
|
|
7020
7235
|
versionId,
|
|
7021
7236
|
vertical: v.verticalSlug,
|
|
7022
7237
|
version: v.version,
|
|
7238
|
+
...(servingRef !== undefined ? { servingRef } : {}),
|
|
7023
7239
|
...(opts?.expectedVersionId !== undefined ? { expectedVersionId: opts.expectedVersionId } : {}),
|
|
7240
|
+
...(opts?.expectedServing ? { expectedServing: opts.expectedServing } : {}),
|
|
7024
7241
|
...(ack.exportBreak ? { acknowledged: ack } : {}),
|
|
7025
7242
|
});
|
|
7026
7243
|
},
|
|
@@ -7205,7 +7422,7 @@ export class SqliteScopeHost {
|
|
|
7205
7422
|
const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
|
|
7206
7423
|
// #1756: onto (or off) a serving script, the scope runs other code before its pointer
|
|
7207
7424
|
// moves. Judged here, so no caller that routes first and binds second passes unasked.
|
|
7208
|
-
const bound = scope.vertical_version_id ? readVersion(scope.vertical_version_id) : undefined;
|
|
7425
|
+
const bound = scope.vertical_version_id ? await readVersion(scope.vertical_version_id) : undefined;
|
|
7209
7426
|
if (bound)
|
|
7210
7427
|
this.assertBindingShapesKept(mapScope(scope), bound.id, servingRef);
|
|
7211
7428
|
if (!ack.exportBreak && bound) {
|
|
@@ -7782,48 +7999,74 @@ export class SqliteScopeHost {
|
|
|
7782
7999
|
createdAt: r.created_at,
|
|
7783
8000
|
}));
|
|
7784
8001
|
},
|
|
7785
|
-
// -- store-
|
|
7786
|
-
// The state is kernel code over the directory (store-
|
|
8002
|
+
// -- store-driver releases, rollouts and holds (D-63, #2300) -------------------
|
|
8003
|
+
// The state is kernel code over the directory (store-driver-releases.ts), shared with the
|
|
7787
8004
|
// Cloudflare control plane; each call is one transaction. A hold's expiry is judged against
|
|
7788
8005
|
// this host's clock, so a test can let a lease lapse without waiting.
|
|
7789
|
-
|
|
8006
|
+
recordStoreDriverRelease: async (actor, input) => {
|
|
7790
8007
|
const at = this.clock();
|
|
7791
|
-
const { record, created } = this.directory.transaction(() =>
|
|
8008
|
+
const { record, created } = this.directory.transaction(() => recordStoreDriverReleaseIn(switchSqlOf(this.directory), { ...input, actor, at }))();
|
|
7792
8009
|
if (created)
|
|
7793
|
-
this.recordAdmin(actor, '
|
|
8010
|
+
this.recordAdmin(actor, 'uploadStoreDriverRelease', { tenantId: null }, null, record, at);
|
|
7794
8011
|
return record;
|
|
7795
8012
|
},
|
|
7796
|
-
|
|
7797
|
-
const record =
|
|
7798
|
-
this.recordAccess(actor, '
|
|
8013
|
+
getStoreDriverRelease: async (actor, driver, sha256) => {
|
|
8014
|
+
const record = storeDriverReleaseIn(switchSqlOf(this.directory), driver, sha256);
|
|
8015
|
+
this.recordAccess(actor, 'getStoreDriverRelease', { tenantId: null }, { driver, sha256 }, record ? 1 : 0);
|
|
7799
8016
|
return record;
|
|
7800
8017
|
},
|
|
7801
|
-
|
|
7802
|
-
const state =
|
|
7803
|
-
this.recordAccess(actor, '
|
|
8018
|
+
storeDriverState: async (actor, driver) => {
|
|
8019
|
+
const state = storeDriverStateIn(switchSqlOf(this.directory), driver, Date.parse(this.clock()));
|
|
8020
|
+
this.recordAccess(actor, 'storeDriverState', { tenantId: null }, { driver }, state.rollouts.length);
|
|
7804
8021
|
return state;
|
|
7805
8022
|
},
|
|
7806
|
-
|
|
7807
|
-
const declared = this.directory.transaction(() =>
|
|
7808
|
-
this.recordAccess(actor, '
|
|
8023
|
+
storeDriverDeclarations: async (actor, driver) => {
|
|
8024
|
+
const declared = this.directory.transaction(() => driverDeclarationsOf(switchSqlOf(this.directory), driver))();
|
|
8025
|
+
this.recordAccess(actor, 'storeDriverDeclarations', { tenantId: null }, { driver }, declared.verticals.length);
|
|
7809
8026
|
return declared;
|
|
7810
8027
|
},
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
8028
|
+
storeDriverRolloutPending: async (actor, input) => {
|
|
8029
|
+
const page = this.directory.transaction(() => storeDriverRolloutPendingIn(switchSqlOf(this.directory), input))();
|
|
8030
|
+
this.recordAccess(actor, 'storeDriverRolloutPending', { tenantId: null }, { driver: input.driver, sha256: input.sha256, action: input.action }, page.verticals.length);
|
|
8031
|
+
return page;
|
|
8032
|
+
},
|
|
8033
|
+
storeDriverRolloutWalked: async (actor, input) => {
|
|
8034
|
+
const walked = this.directory.transaction(() => storeDriverRolloutWalkedIn(switchSqlOf(this.directory), input))();
|
|
8035
|
+
this.recordAccess(actor, 'storeDriverRolloutWalked', { tenantId: null }, { driver: input.driver, seq: input.seq }, walked.verticals.length);
|
|
8036
|
+
return walked;
|
|
8037
|
+
},
|
|
8038
|
+
holdStoreDriver: async (_actor, input) => this.directory.transaction(() => holdStoreDriverIn(switchSqlOf(this.directory), { ...input, nowMs: Date.parse(this.clock()) }))(),
|
|
8039
|
+
releaseStoreDriverHold: async (_actor, input) => {
|
|
8040
|
+
releaseStoreDriverHoldIn(switchSqlOf(this.directory), input);
|
|
7814
8041
|
},
|
|
7815
|
-
|
|
8042
|
+
beginStoreDriverRollout: async (actor, input) => {
|
|
7816
8043
|
const at = this.clock();
|
|
7817
|
-
const row = this.directory.transaction(() =>
|
|
7818
|
-
this.recordAdmin(actor, input.action === 'rollback' ? '
|
|
8044
|
+
const row = this.directory.transaction(() => beginStoreDriverRolloutIn(switchSqlOf(this.directory), { ...input, actor, at, nowMs: Date.parse(at) }))();
|
|
8045
|
+
this.recordAdmin(actor, input.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row, at);
|
|
7819
8046
|
return row;
|
|
7820
8047
|
},
|
|
7821
|
-
|
|
8048
|
+
finishStoreDriverRollout: async (actor, input) => {
|
|
7822
8049
|
const at = this.clock();
|
|
7823
|
-
const row = this.directory.transaction(() =>
|
|
7824
|
-
this.recordAdmin(actor, row.action === 'rollback' ? '
|
|
8050
|
+
const row = this.directory.transaction(() => finishStoreDriverRolloutIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
|
|
8051
|
+
this.recordAdmin(actor, row.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row, at);
|
|
7825
8052
|
return row;
|
|
7826
8053
|
},
|
|
8054
|
+
storeDriverRebuild: async (actor, driver) => {
|
|
8055
|
+
const rebuild = storeDriverRebuildIn(switchSqlOf(this.directory), driver);
|
|
8056
|
+
this.recordAccess(actor, 'storeDriverRebuild', { tenantId: null }, { driver }, rebuild ? 1 : 0);
|
|
8057
|
+
return rebuild;
|
|
8058
|
+
},
|
|
8059
|
+
claimStoreDriverRebuild: async (_actor, input) => {
|
|
8060
|
+
const at = this.clock();
|
|
8061
|
+
return this.directory.transaction(() => claimStoreDriverRebuildIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
|
|
8062
|
+
},
|
|
8063
|
+
advanceStoreDriverRebuild: async (_actor, input) => {
|
|
8064
|
+
const at = this.clock();
|
|
8065
|
+
return this.directory.transaction(() => advanceStoreDriverRebuildIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
|
|
8066
|
+
},
|
|
8067
|
+
recordStoreDriverRebuildRun: async (actor, entry) => {
|
|
8068
|
+
this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry, this.clock());
|
|
8069
|
+
},
|
|
7827
8070
|
getScopeRecord: async (actor, tenantId, scopeId) => {
|
|
7828
8071
|
const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
7829
8072
|
// Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
|
|
@@ -7958,7 +8201,10 @@ export class SqliteScopeHost {
|
|
|
7958
8201
|
// `.raw(true)` gives positional rows; cells stay as-is (blobs are Buffers, not
|
|
7959
8202
|
// nulled as in a UI read) so the dump is byte-faithful. The name is from the live
|
|
7960
8203
|
// schema, never user input, so the quoted identifier is safe.
|
|
7961
|
-
|
|
8204
|
+
// #2350: the orphan sweep's licence is this store's own, so its rows never leave in a dump.
|
|
8205
|
+
const stmt = db
|
|
8206
|
+
.prepare(name === ATTACHMENT_SWEEP_LICENCE_TABLE ? `SELECT * FROM "${name}" WHERE 0` : `SELECT * FROM "${name}"`)
|
|
8207
|
+
.raw(true);
|
|
7962
8208
|
const rows = stmt.all();
|
|
7963
8209
|
const columns = stmt.columns().map((c) => c.name);
|
|
7964
8210
|
return { name, ddl: sql, columns, rows };
|
|
@@ -8915,6 +9161,11 @@ export class SqliteScopeHost {
|
|
|
8915
9161
|
const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
|
|
8916
9162
|
this.recordAdmin(actor, action === 'mark' ? 'markScopeCopy' : 'clearScopeCopyMark', { tenantId, scopeId }, null, after);
|
|
8917
9163
|
},
|
|
9164
|
+
/** #2314: one orphan-attachment pass over a scope, written after the vertical's own pass. */
|
|
9165
|
+
recordOrphanSweep: async (actor, entry) => {
|
|
9166
|
+
const { tenantId, scopeId, ...after } = orphanSweepAudit.parse(entry);
|
|
9167
|
+
this.recordAdmin(actor, 'sweepOrphanAttachments', { tenantId, scopeId }, null, after);
|
|
9168
|
+
},
|
|
8918
9169
|
accessLog: async (actor, filter) => {
|
|
8919
9170
|
const where = [];
|
|
8920
9171
|
const params = [];
|
|
@@ -9373,7 +9624,8 @@ export class SqliteScopeHost {
|
|
|
9373
9624
|
const moved = this.directory.prepare(`UPDATE scopes SET ${set} WHERE scope_id = ?
|
|
9374
9625
|
${cond?.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
|
|
9375
9626
|
${cond?.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
|
|
9376
|
-
${cond?.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}
|
|
9627
|
+
${cond?.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}
|
|
9628
|
+
${cond?.expectedServing === undefined ? '' : SERVES_NOW_PREDICATE}`).run(...setArgs, scopeId, ...(cond?.expectedVersionId === undefined ? [] : [cond.expectedVersionId]), ...(cond?.expectedErasureEpoch === undefined ? [] : [cond.expectedErasureEpoch]), ...(cond?.confirmMove === undefined ? [] : copyMoveLiveParams(cond.confirmMove, now)), ...(cond?.expectedServing === undefined ? [] : servesNowParams(cond.expectedServing)));
|
|
9377
9629
|
if (moved.changes === 0)
|
|
9378
9630
|
return false;
|
|
9379
9631
|
if (cond?.confirmMove) {
|
|
@@ -9383,6 +9635,10 @@ export class SqliteScopeHost {
|
|
|
9383
9635
|
return true;
|
|
9384
9636
|
})();
|
|
9385
9637
|
}
|
|
9638
|
+
/** #2347: whether a lineage's serving script still runs the version a rebind was judged against. */
|
|
9639
|
+
servesNow(expected) {
|
|
9640
|
+
return this.directory.prepare(`SELECT 1 FROM verticals WHERE ${SERVES_NOW_CONDITION}`).get(...servesNowParams(expected)) !== undefined;
|
|
9641
|
+
}
|
|
9386
9642
|
ensureColumn(db, table, column, ddl) {
|
|
9387
9643
|
// Without case, as SQLite resolves a column name: a restore may have added this column
|
|
9388
9644
|
// already, untyped and spelled as a newer kernel's dump spelled it (#1883).
|
|
@@ -9805,7 +10061,7 @@ export class SqliteScopeHost {
|
|
|
9805
10061
|
// Lifted out of the object literal so `grant` can reuse it: a delegation
|
|
9806
10062
|
// check has to be the SAME check the operation itself passes, or the two
|
|
9807
10063
|
// could disagree about what the caller holds.
|
|
9808
|
-
const
|
|
10064
|
+
const decideCheck = async (unparsed, entity) => {
|
|
9809
10065
|
// #1642: parsed before the override actor's early return, which never reaches
|
|
9810
10066
|
// the checker — a cast key would otherwise become that path's proof relation.
|
|
9811
10067
|
const permission = assertPermissionKey(unparsed);
|
|
@@ -9834,6 +10090,59 @@ export class SqliteScopeHost {
|
|
|
9834
10090
|
}
|
|
9835
10091
|
return decision;
|
|
9836
10092
|
};
|
|
10093
|
+
// #2358: the same check, recorded. It reads the decision `decideCheck` already made and
|
|
10094
|
+
// changes nothing about it; the explanation of a refusal (which roles would have allowed it,
|
|
10095
|
+
// which the caller holds) is gathered only when a sink is installed.
|
|
10096
|
+
// Only inside a call that has an id on the runtime: a background delivery's fresh id would
|
|
10097
|
+
// open a trace nothing ever ends.
|
|
10098
|
+
const trace = invocationTrace(rt.invocationId);
|
|
10099
|
+
const runCheck = trace
|
|
10100
|
+
? async (unparsed, entity) => {
|
|
10101
|
+
const started = trace.now();
|
|
10102
|
+
const decision = await decideCheck(unparsed, entity);
|
|
10103
|
+
// Taken before the explanation below, which reads the role table and the checker's
|
|
10104
|
+
// holdings: the step times the check, not the work of describing a refusal.
|
|
10105
|
+
const durationMs = Math.max(0, Math.round(trace.now() - started));
|
|
10106
|
+
let requires = [];
|
|
10107
|
+
let callerHas = [];
|
|
10108
|
+
if (!decision.allowed) {
|
|
10109
|
+
try {
|
|
10110
|
+
const prefix = `${rt.tenantId}/`;
|
|
10111
|
+
requires = [...this.roles.entries()]
|
|
10112
|
+
.filter(([key, role]) => key.startsWith(prefix) && role.permissions.includes(decision.checked))
|
|
10113
|
+
.map(([, role]) => role.key);
|
|
10114
|
+
// On one entity, a grant on that entity would also have allowed it: say so, so the
|
|
10115
|
+
// trace does not point at assigning a role when the missing piece is a grant.
|
|
10116
|
+
if (entity)
|
|
10117
|
+
requires.push(`grant:${decision.checked}@${entityString(entity)}`);
|
|
10118
|
+
if (subject.kind === 'principal' && this.checker.holdings) {
|
|
10119
|
+
const held = await this.checker.holdings(asPrincipal(subject.id), { tenantId: rt.tenantId, scopeId: rt.scopeId });
|
|
10120
|
+
callerHas = [
|
|
10121
|
+
...held.roles,
|
|
10122
|
+
...held.granted.filter((p) => p === decision.checked).map((p) => `grant:${p}`),
|
|
10123
|
+
...held.narrowed
|
|
10124
|
+
.filter((n) => n.permission === decision.checked)
|
|
10125
|
+
.map((n) => `grant:${n.permission}@${entityString(n.entity)}`),
|
|
10126
|
+
];
|
|
10127
|
+
}
|
|
10128
|
+
}
|
|
10129
|
+
catch {
|
|
10130
|
+
/* an explanation that cannot be gathered is left empty, never an error */
|
|
10131
|
+
}
|
|
10132
|
+
}
|
|
10133
|
+
trace.step({
|
|
10134
|
+
type: 'check',
|
|
10135
|
+
permission: decision.allowed ? String(unparsed) : decision.checked,
|
|
10136
|
+
entity: entityString(entity),
|
|
10137
|
+
allowed: decision.allowed,
|
|
10138
|
+
decidedBy: decision.allowed ? decidedByOf(decision.proof) : null,
|
|
10139
|
+
requires,
|
|
10140
|
+
callerHas,
|
|
10141
|
+
durationMs,
|
|
10142
|
+
});
|
|
10143
|
+
return decision;
|
|
10144
|
+
}
|
|
10145
|
+
: decideCheck;
|
|
9837
10146
|
const runCanAssign = (roleKey) => this.assignmentBound(subject, rt.tenantId, rt.scopeId, roleKey);
|
|
9838
10147
|
// ctx.emit's writer, and the kernel's own events' (#1864): one path to the outbox, with the
|
|
9839
10148
|
// reserved-type refusal applied to module code only. Never handed to module code as-is.
|
|
@@ -9882,6 +10191,8 @@ export class SqliteScopeHost {
|
|
|
9882
10191
|
// #1237: a fact about the surrounding CALL, like the version above — never
|
|
9883
10192
|
// envelope data module code could set or branch on.
|
|
9884
10193
|
rt.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
|
|
10194
|
+
// #2358: type and entity only; the payload stays out of the trace.
|
|
10195
|
+
trace?.step({ type: 'emit', eventType: full.type, entity: `${full.entity.entityType}:${full.entity.entityId}` });
|
|
9885
10196
|
};
|
|
9886
10197
|
// #2192: the call's invocation id, one reader for `ctx.log` and `ctx.requestId`.
|
|
9887
10198
|
const requestIdNow = () => rt.invocationId ?? delivery?.invocationId ?? null;
|
|
@@ -9890,12 +10201,13 @@ export class SqliteScopeHost {
|
|
|
9890
10201
|
scopeId: rt.scopeId,
|
|
9891
10202
|
principal,
|
|
9892
10203
|
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)),
|
|
9893
|
-
|
|
10204
|
+
// #2358: the outermost layer, so a statement refused by a guard below is recorded too.
|
|
10205
|
+
sql: tracedSql(guardSecrets(guardSqlLimits(scopedSql(rt.db, true, statefulTablesOf(statePlans),
|
|
9894
10206
|
// #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
|
|
9895
10207
|
// derived onto any table is repaired — and a lost state column fails the operation.
|
|
9896
10208
|
derivesAnything(this.derivedPlans())
|
|
9897
10209
|
? () => afterRuntimeDdl(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans())
|
|
9898
|
-
: undefined)), minted),
|
|
10210
|
+
: undefined)), minted), trace, (text) => redactSecretText(text, minted), (value) => redactSecrets(value, minted)),
|
|
9899
10211
|
now: () => at,
|
|
9900
10212
|
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
9901
10213
|
// and its fields. The invocation id is read per call — it is set for the duration of
|
|
@@ -9910,7 +10222,12 @@ export class SqliteScopeHost {
|
|
|
9910
10222
|
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
9911
10223
|
// log's text is not JSON.
|
|
9912
10224
|
redact: (text) => redactSecretText(text, minted),
|
|
9913
|
-
},
|
|
10225
|
+
}, trace
|
|
10226
|
+
? (line) => {
|
|
10227
|
+
trace.step({ type: 'log', level: line.level, message: line.message });
|
|
10228
|
+
this.logSink(line);
|
|
10229
|
+
}
|
|
10230
|
+
: this.logSink),
|
|
9914
10231
|
// #2192: the same id the log lines carry.
|
|
9915
10232
|
get requestId() {
|
|
9916
10233
|
return requestIdNow();
|
|
@@ -10210,6 +10527,14 @@ export class SqliteScopeHost {
|
|
|
10210
10527
|
await this.repairDerived(rt);
|
|
10211
10528
|
return;
|
|
10212
10529
|
}
|
|
10530
|
+
// #2349: before any SQL, the registered set is held to the one stored for this version.
|
|
10531
|
+
// Only here, so a scope with nothing pending never computes it.
|
|
10532
|
+
const unstored = await migrationSetDivergence(this.modules.values(), this.migrationsDigest);
|
|
10533
|
+
if (unstored) {
|
|
10534
|
+
const refused = { version: `${pending[0].moduleId}@${pending[0].migration.version}`, error: unstored };
|
|
10535
|
+
this.recordMigrationState(rt, refused);
|
|
10536
|
+
throw migrationFailedError(refused.version, refused.error);
|
|
10537
|
+
}
|
|
10213
10538
|
// The failing `module@version` and its cause, captured structurally rather than
|
|
10214
10539
|
// re-parsed out of the thrown message — the directory record has to name both.
|
|
10215
10540
|
let failure;
|
|
@@ -10243,7 +10568,8 @@ export class SqliteScopeHost {
|
|
|
10243
10568
|
assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
|
|
10244
10569
|
// #2068: one statement at a time, keeping the table set either side of each — the
|
|
10245
10570
|
// same splitter the Durable Object runs, so both hosts execute identical statements.
|
|
10246
|
-
|
|
10571
|
+
// #115: an authored one is also held to the data-loss fence, inside this transaction.
|
|
10572
|
+
const steps = runMigrationStatements(spineSql(rt.db), migration, (stmt) => rt.db.exec(stmt), { key, authored });
|
|
10247
10573
|
assertTablesWithinColumnLimit(rt.db);
|
|
10248
10574
|
// #2090: the state columns this migration must have left, and after the last of the
|
|
10249
10575
|
// pass, the triggers and indexes a create-copy-rename rebuild dropped.
|
|
@@ -10523,6 +10849,9 @@ export class SqliteScopeHost {
|
|
|
10523
10849
|
// #2009: the copy classification on a scope DB built before it (NULL reads as a copy; see
|
|
10524
10850
|
// `COPY_ORIGIN_DDL`).
|
|
10525
10851
|
this.ensureColumn(db, '_substrat_copy_origin', 'is_copy', 'is_copy INTEGER');
|
|
10852
|
+
// #2315: the hash of the key a redemption carried, on a scope DB built before it. NULL = the
|
|
10853
|
+
// spending request carried none, the honest reading of every link already spent.
|
|
10854
|
+
this.ensureColumn(db, '_substrat_upload_links', 'redeemed_key_hash', 'redeemed_key_hash TEXT');
|
|
10526
10855
|
// #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
|
|
10527
10856
|
// that is never pruned. No index leads with invocation_id, so without this one SQLite
|
|
10528
10857
|
// walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a
|