@substrat-run/adapter-sqlite 0.159.0 → 0.161.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 +98 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +887 -358
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, writeFileSync, writeSync } from 'node:fs';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import Database from 'better-sqlite3';
|
|
4
|
-
import { accessLogEntry, adminLogEntry, delegatedReadParams, operationSeriesCount, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED,
|
|
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, checkHeldOperationsOf, countChecks, noteUncheckedCompletion, 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, assertModuleDeclared, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, 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';
|
|
5
|
+
import { accessLogEntry, adminLogEntry, delegatedReadParams, operationSeriesCount, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, assertNoBindingShapeBreak, importsOfManifestJson, connectionLandingsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, 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, deploymentWireAuth, 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';
|
|
6
|
+
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, assertPlatformRequestRoom, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, scopeNotHeldRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, checkHeldOperationsOf, countChecks, noteUncheckedCompletion, 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, isSizedByteStream, knownLengthStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, ATTACHMENT_BYTES_SQL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeAttachmentStorageRows, retireScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, REQUEST_ROLLUP_DDL, listRequestDayRows, listRequestRollupDayRows, pruneRequestDayRows, recordRequestDayRows, METER_DAYS_DDL, VERTICAL_OWNERS_BACKFILL, VERTICAL_OWNERS_DDL, closeVerticalOwnerRow, listVerticalOwnerRows, openVerticalOwnerRow, verticalOwnerNow, listMeterDayRows, listMeterDayTakeRows, pruneMeterDayRows, recordMeterDayRows, 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, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, dumpRoleRefusal, reservedGrantRefusal, reservedRoleDefinitionRefusal, reservedRoleRefusal, reservedShapeRefusal, SCOPE_SEATED_ROLES_SQL, SEAT_HOLDING_SCOPE_STATUSES, TENANT_SEATED_ROLES_SQL, 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, DEPLOYMENT_WIRE_AUTH_DDL, assertWireAuthFilter, deploymentWireAuthOf, forgetVerticalWireAuth, wireAuthAccessTarget, writeDeploymentWireAuth, 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, servingRaceRefusal, 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, fetchLikeOf, deadlineFetch, 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
7
|
import { attributedView, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, shapeTopUpBatch, topUpEntityGrantShapes, settlePlatformRequestIn, } from '@substrat-run/kernel';
|
|
7
8
|
import { ADMIN_LOG_INDEXES_SQL, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, readAuditedOperations, unknownOutcomeOf, } from '@substrat-run/kernel';
|
|
8
9
|
import { INERT_SCOPE_REASON, isPrimaryScopeRow, normalizeUploadMediaType } from '@substrat-run/kernel';
|
|
10
|
+
import { opsFailureReferenceWhere } from '@substrat-run/kernel';
|
|
9
11
|
import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_RENEW_SQL, copyMoveRenewParams, PENDING_COPY_KEEP_ALIVE_SQL, pendingCopyKeepAliveParams, 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
12
|
import { GRANT_CHILDREN_INDEX_DDL, SHAPE_MARKER_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
|
|
11
13
|
import { LEGACY_SCOPE_ROWS_BACKFILL, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
|
|
@@ -28,6 +30,101 @@ export function isStorageFault(err) {
|
|
|
28
30
|
* this map — cannot write one. Registered as each context is built.
|
|
29
31
|
*/
|
|
30
32
|
const kernelEmitters = new WeakMap();
|
|
33
|
+
/**
|
|
34
|
+
* Node's running SHA-256 (#2498), for a streamed upload — the kernel's `putAttachmentBytes` takes
|
|
35
|
+
* it. `node:crypto` because Web Crypto has no running digest, and hashing the written file
|
|
36
|
+
* afterwards would read it whole into memory, which is what streaming is for.
|
|
37
|
+
*/
|
|
38
|
+
function nodeSha256() {
|
|
39
|
+
const hash = createHash('sha256');
|
|
40
|
+
return {
|
|
41
|
+
update: (chunk) => {
|
|
42
|
+
hash.update(chunk);
|
|
43
|
+
},
|
|
44
|
+
hex: async () => hash.digest('hex'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Write a sized stream to `path` one chunk at a time (#2498), so the file is never held whole,
|
|
49
|
+
* held to its declared length as the store contract says: a stream that carries more or fewer
|
|
50
|
+
* bytes leaves no file.
|
|
51
|
+
*/
|
|
52
|
+
async function writeStreamToFile(path, body) {
|
|
53
|
+
const reader = knownLengthStream(body).getReader();
|
|
54
|
+
const fd = openSync(path, 'w');
|
|
55
|
+
try {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const { done, value } = await reader.read();
|
|
58
|
+
if (done)
|
|
59
|
+
break;
|
|
60
|
+
if (value)
|
|
61
|
+
writeSync(fd, value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
rmSync(path, { force: true });
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A dropped stream's file handle, closed when the stream is collected (#2514): a consumer that neither
|
|
74
|
+
* reads a stream to its end nor cancels it would otherwise hold the handle for the life of the process.
|
|
75
|
+
* Best effort, since collection has no deadline; reading or cancelling is what closes it promptly.
|
|
76
|
+
*/
|
|
77
|
+
const unreadFiles = new FinalizationRegistry((handle) => handle.close());
|
|
78
|
+
/**
|
|
79
|
+
* The file at `path` as a stream read 64 KiB at a time as it is pulled (#2514), with its size, or
|
|
80
|
+
* `null` when there is no such file. The file is opened here, so a key removed after the open is still
|
|
81
|
+
* read whole from the open handle, and closed once the stream ends, errors or is cancelled, or (best
|
|
82
|
+
* effort) once a stream nobody finished is collected.
|
|
83
|
+
*/
|
|
84
|
+
function fileStream(path) {
|
|
85
|
+
const CHUNK = 64 * 1024;
|
|
86
|
+
let fd;
|
|
87
|
+
try {
|
|
88
|
+
fd = openSync(path, 'r');
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// Removed between the read gate and here: the same answer as a key that was never there.
|
|
92
|
+
if (err.code === 'ENOENT')
|
|
93
|
+
return null;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
const size = fstatSync(fd).size;
|
|
97
|
+
let closed = false;
|
|
98
|
+
const handle = {
|
|
99
|
+
close: () => {
|
|
100
|
+
if (!closed)
|
|
101
|
+
closeSync(fd);
|
|
102
|
+
closed = true;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const close = handle.close;
|
|
106
|
+
const stream = new ReadableStream({
|
|
107
|
+
pull(controller) {
|
|
108
|
+
try {
|
|
109
|
+
const chunk = new Uint8Array(CHUNK);
|
|
110
|
+
const read = readSync(fd, chunk, 0, CHUNK, null);
|
|
111
|
+
if (read === 0) {
|
|
112
|
+
close();
|
|
113
|
+
controller.close();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
controller.enqueue(chunk.subarray(0, read));
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
close();
|
|
120
|
+
controller.error(err);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
cancel: close,
|
|
124
|
+
}, { highWaterMark: 0 });
|
|
125
|
+
unreadFiles.register(stream, handle);
|
|
126
|
+
return { stream, size };
|
|
127
|
+
}
|
|
31
128
|
function kernelEmit(ctx, event) {
|
|
32
129
|
const write = kernelEmitters.get(ctx);
|
|
33
130
|
if (!write)
|
|
@@ -508,6 +605,93 @@ const admitByDelivery = (rt, eventId, row) => {
|
|
|
508
605
|
* it refuses another process.
|
|
509
606
|
*/
|
|
510
607
|
const PROCESS_HOST_DIRS = new Map();
|
|
608
|
+
/** A scope database's size in bytes: allocated pages, free ones included, which is what the file costs on disk (#1524). */
|
|
609
|
+
const databaseBytesOf = (db) => db.pragma('page_count', { simple: true }) * db.pragma('page_size', { simple: true });
|
|
610
|
+
/** What a scope database's attachment rows sum to (#2501). */
|
|
611
|
+
const attachmentBytesOf = (db) => db.prepare(ATTACHMENT_BYTES_SQL).get().bytes;
|
|
612
|
+
/**
|
|
613
|
+
* The introspection reads, over an already-resolved connection: the bodies behind both the
|
|
614
|
+
* directory-checked admin verbs and the `*Local` verbs a vertical's `/internal/*` routes call by
|
|
615
|
+
* scope id alone (#2544). How the connection was reached, and what is recorded, stay with the caller.
|
|
616
|
+
*/
|
|
617
|
+
function scopeTablesOf(db) {
|
|
618
|
+
const names = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).all().map((r) => r.name);
|
|
619
|
+
const count = (sql) => db.prepare(sql).get().c;
|
|
620
|
+
const tombstones = (name) => db.prepare(`SELECT name FROM pragma_table_info(?)`).all(name).some((c) => c.name === 'revoked_at');
|
|
621
|
+
return names.map((name) => ({
|
|
622
|
+
name,
|
|
623
|
+
rowCount: count(`SELECT COUNT(*) AS c FROM "${name}"`),
|
|
624
|
+
system: isSystemTable(name),
|
|
625
|
+
// #2531: rows whose `revoked_at` is null, for any table with that column (`ScopeTable.liveRowCount`; the Cloudflare adapter's twin).
|
|
626
|
+
...(tombstones(name) ? { liveRowCount: count(`SELECT COUNT(*) AS c FROM "${name}" WHERE revoked_at IS NULL`) } : {}),
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
function scopeTablePageOf(db, input) {
|
|
630
|
+
// Validate the table against the LIVE schema — an unknown name throws, it is
|
|
631
|
+
// never interpolated blind. That validated name is the only thing that reaches
|
|
632
|
+
// the query, so the quoted identifier below carries no user input.
|
|
633
|
+
const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
|
|
634
|
+
if (!known.has(input.table))
|
|
635
|
+
throw substratError('not_found', `unknown table '${input.table}'`);
|
|
636
|
+
// The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
|
|
637
|
+
// negative) is refused instead of reaching LIMIT / OFFSET (#1632).
|
|
638
|
+
const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
|
|
639
|
+
const offset = assertRowOffset('offset', input.offset ?? 0);
|
|
640
|
+
const stmt = db.prepare(`SELECT * FROM "${input.table}" LIMIT ? OFFSET ?`).raw(true);
|
|
641
|
+
const rows = stmt.all(limit, offset).map((row) => row.map(cellToJson));
|
|
642
|
+
const columns = stmt.columns().map((c) => c.name);
|
|
643
|
+
const rowCount = db.prepare(`SELECT COUNT(*) AS c FROM "${input.table}"`).get().c;
|
|
644
|
+
return { table: input.table, columns, rows, rowCount, limit, offset };
|
|
645
|
+
}
|
|
646
|
+
function scopeQueryOf(db, input) {
|
|
647
|
+
// Layer 1, shared: the kernel's textual gate (single statement, read verbs
|
|
648
|
+
// only) — the checked statement is the one that runs.
|
|
649
|
+
const sql = assertReadOnlyQuery(input.sql);
|
|
650
|
+
// Layer 2, authoritative: sqlite3_stmt_readonly via better-sqlite3. prepare()
|
|
651
|
+
// itself rejects multi-statement strings and bad SQL with the driver's message.
|
|
652
|
+
const stmt = db.prepare(sql);
|
|
653
|
+
if (!stmt.readonly)
|
|
654
|
+
throw substratError('validation_failed', 'read-only console: statement is not read-only');
|
|
655
|
+
if (!stmt.reader)
|
|
656
|
+
return { sql, reader: false, result: { columns: [], rows: [], truncated: false } };
|
|
657
|
+
stmt.raw(true);
|
|
658
|
+
const rows = [];
|
|
659
|
+
let truncated = false;
|
|
660
|
+
// Iterate rather than .all(): the row cap bounds what leaves the DB, so an
|
|
661
|
+
// over-broad SELECT costs a screenful, not the table.
|
|
662
|
+
for (const row of stmt.iterate()) {
|
|
663
|
+
if (rows.length >= SCOPE_QUERY_ROW_MAX) {
|
|
664
|
+
truncated = true;
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
rows.push(row.map(cellToJson));
|
|
668
|
+
}
|
|
669
|
+
return { sql, reader: true, result: { columns: stmt.columns().map((c) => c.name), rows, truncated } };
|
|
670
|
+
}
|
|
671
|
+
function appliedMigrationsOf(db) {
|
|
672
|
+
const rows = db
|
|
673
|
+
.prepare(`SELECT module_id, version, applied_at, duration_ms, rows_changed FROM _substrat_migrations
|
|
674
|
+
ORDER BY applied_at DESC, rowid DESC`)
|
|
675
|
+
.all();
|
|
676
|
+
return rows.map((r) => ({
|
|
677
|
+
moduleId: r.module_id,
|
|
678
|
+
version: r.version,
|
|
679
|
+
appliedAt: r.applied_at ?? null,
|
|
680
|
+
durationMs: r.duration_ms,
|
|
681
|
+
rowsChanged: r.rows_changed,
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
function undrainedEventsFrom(db, limit) {
|
|
685
|
+
const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`);
|
|
686
|
+
// The kernel's read, shared with the DO: the envelope and the columns lifted beside
|
|
687
|
+
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
688
|
+
// rather than stalling the scope on it (#1636).
|
|
689
|
+
return undrainedEventsOf(readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(assertRowLimit('limit', limit), 1000)));
|
|
690
|
+
}
|
|
691
|
+
const redrainCountOf = (db, drainedBefore) => db
|
|
692
|
+
.prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
|
|
693
|
+
WHERE drained_at IS NOT NULL AND drained_at < ?`)
|
|
694
|
+
.get(drainedBefore).c;
|
|
511
695
|
/**
|
|
512
696
|
* #119: claim a host directory for this process, for one live `SqliteScopeHost`. The first host in
|
|
513
697
|
* the process takes an exclusive lock on `_host.lock` (a SQLite file: `locking_mode = EXCLUSIVE`
|
|
@@ -631,6 +815,8 @@ export class SqliteScopeHost {
|
|
|
631
815
|
modules = new Map();
|
|
632
816
|
/** #2430: what a registered module declares `holder: 'system'` — the role doors' refusal reads it. */
|
|
633
817
|
reservedKeys = new Set();
|
|
818
|
+
/** #2447: every key a registered or listed module declares, marked or not — a role definition reads it. */
|
|
819
|
+
declaredKeys = new Set();
|
|
634
820
|
/** #2430: the declared permission surface `registerModule` holds each module to, when given. */
|
|
635
821
|
permissions;
|
|
636
822
|
/** #1705: what this deployment exports to other verticals and imports from them. */
|
|
@@ -722,6 +908,7 @@ export class SqliteScopeHost {
|
|
|
722
908
|
wrapBlobStore;
|
|
723
909
|
attachmentTextBounds;
|
|
724
910
|
fetchImpl;
|
|
911
|
+
streamIdleMs;
|
|
725
912
|
connectorCalls;
|
|
726
913
|
clock;
|
|
727
914
|
versionId;
|
|
@@ -741,15 +928,19 @@ export class SqliteScopeHost {
|
|
|
741
928
|
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
742
929
|
this.wrapBlobStore = options.wrapBlobStore ?? ((store) => store);
|
|
743
930
|
this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
|
|
744
|
-
|
|
931
|
+
// #2498: whatever fetch is injected, a sized stream body reaches it converted (`fetchLikeOf`).
|
|
932
|
+
this.fetchImpl = fetchLikeOf(options.fetch ?? globalFetch);
|
|
933
|
+
this.streamIdleMs = options.streamIdleTimeoutMs;
|
|
745
934
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
746
935
|
this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
|
|
747
936
|
this.versionId = options.versionId ?? null;
|
|
748
937
|
this.migrationsDigest = options.migrationsDigest;
|
|
749
938
|
this.permissions = options.permissions;
|
|
750
939
|
// #2430: what the declared surface reserves holds here too, whatever is registered under its ids.
|
|
751
|
-
for (const m of options.permissions?.modules ?? [])
|
|
940
|
+
for (const m of options.permissions?.modules ?? []) {
|
|
752
941
|
noteReservedKeys(this.reservedKeys, m.manifest);
|
|
942
|
+
noteDeclaredKeys(this.declaredKeys, m.manifest);
|
|
943
|
+
}
|
|
753
944
|
this.scopeStoreVertical = options.scopeStores?.vertical;
|
|
754
945
|
this.scopeStoreRunners = scopeStoreRunnersOf(options.scopeStores);
|
|
755
946
|
this.scopeStoreLifecycle = scopeStoreLifecycleOf(this.scopeStoreRunners);
|
|
@@ -1047,6 +1238,7 @@ export class SqliteScopeHost {
|
|
|
1047
1238
|
);
|
|
1048
1239
|
${VERSION_MIGRATIONS_DDL}
|
|
1049
1240
|
${VERTICAL_SCOPE_STORES_DDL}
|
|
1241
|
+
${DEPLOYMENT_WIRE_AUTH_DDL}
|
|
1050
1242
|
-- Channels (#31 step 2): a named pointer per vertical. Promotion moves it,
|
|
1051
1243
|
-- and promotion is where the migration and permission diffs fire — the
|
|
1052
1244
|
-- moment a change reaches anyone, rather than the moment it was typed.
|
|
@@ -1437,6 +1629,12 @@ export class SqliteScopeHost {
|
|
|
1437
1629
|
CREATE INDEX IF NOT EXISTS _substrat_model_usage_at ON _substrat_model_usage (at);
|
|
1438
1630
|
-- #1524: the stored storage gauge — one row per (scope, UTC day), kernel-owned DDL.
|
|
1439
1631
|
${SCOPE_STORAGE_DDL}
|
|
1632
|
+
-- #2501: the daily request rollup — one row per (UTC day, tenant, vertical, scope), kernel-owned DDL.
|
|
1633
|
+
${REQUEST_ROLLUP_DDL}
|
|
1634
|
+
-- #2501: the daily meter snapshot — one row per (UTC day, tenant), kernel-owned DDL.
|
|
1635
|
+
${METER_DAYS_DDL}
|
|
1636
|
+
-- #2501: who owned each vertical slug, period by period — kernel-owned DDL.
|
|
1637
|
+
${VERTICAL_OWNERS_DDL}
|
|
1440
1638
|
CREATE INDEX IF NOT EXISTS scopes_tenant ON scopes (tenant_id, scope_id);
|
|
1441
1639
|
`);
|
|
1442
1640
|
this.ensureDirectoryColumns();
|
|
@@ -1558,7 +1756,8 @@ export class SqliteScopeHost {
|
|
|
1558
1756
|
.prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
|
|
1559
1757
|
.get(rt.scopeId)?.vertical ?? null;
|
|
1560
1758
|
const admin = this.admin;
|
|
1561
|
-
|
|
1759
|
+
// #2518: the call under `timeoutMs`, a streamed body under the idle window instead.
|
|
1760
|
+
const fetchImpl = deadlineFetch(this.fetchImpl, timeoutMs, { idleMs: this.streamIdleMs });
|
|
1562
1761
|
return {
|
|
1563
1762
|
admin,
|
|
1564
1763
|
tenantId: rt.tenantId,
|
|
@@ -1573,6 +1772,12 @@ export class SqliteScopeHost {
|
|
|
1573
1772
|
if (!open) {
|
|
1574
1773
|
throw new Error(`no live '${provider}' connection for tenant ${rt.tenantId} / vertical '${vertical}'`);
|
|
1575
1774
|
}
|
|
1775
|
+
const readSurface = () => this.buildAttachments(rt, { kind: 'connection', id: open.id }, this.attachmentStore(rt.tenantId, vertical), {
|
|
1776
|
+
reentrant: holdsActor,
|
|
1777
|
+
// #726 remedy (B): inside a dispatch the authority to read is the DELIVERY,
|
|
1778
|
+
// not a standing grant. See `admitByEvent` on `buildAttachments`.
|
|
1779
|
+
admitByEvent: eventId,
|
|
1780
|
+
});
|
|
1576
1781
|
return {
|
|
1577
1782
|
...open,
|
|
1578
1783
|
// The outbound read (#711), on THIS connection — so it is authorized as
|
|
@@ -1592,15 +1797,9 @@ export class SqliteScopeHost {
|
|
|
1592
1797
|
grants: async () => (await this.connectionGrantsInScope(rt.tenantId, rt.scopeId))
|
|
1593
1798
|
.filter((g) => g.connectionId === open.id)
|
|
1594
1799
|
.map((g) => g.permission),
|
|
1595
|
-
openAttachment: async (attachmentId) =>
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
reentrant: holdsActor,
|
|
1599
|
-
// #726 remedy (B): inside a dispatch the authority to read is the DELIVERY,
|
|
1600
|
-
// not a standing grant. See `admitByEvent` on `buildAttachments`.
|
|
1601
|
-
admitByEvent: eventId,
|
|
1602
|
-
}).open(attachmentId);
|
|
1603
|
-
},
|
|
1800
|
+
openAttachment: async (attachmentId) => readSurface().open(attachmentId),
|
|
1801
|
+
// #2514: the same read, its bytes a stream.
|
|
1802
|
+
openAttachmentStream: async (attachmentId) => readSurface().openStream(attachmentId),
|
|
1604
1803
|
// #687: open a cell the scope sealed TO this connection. The keyId-indexed
|
|
1605
1804
|
// map goes in, never out — a connector is handed the plaintext of one cell,
|
|
1606
1805
|
// not a private key it could mislay. Retired keys are in the map too, so a
|
|
@@ -1611,10 +1810,7 @@ export class SqliteScopeHost {
|
|
|
1611
1810
|
// records beside the health line carries a duration.
|
|
1612
1811
|
const started = Date.now();
|
|
1613
1812
|
try {
|
|
1614
|
-
const res = await fetchImpl(input,
|
|
1615
|
-
...init,
|
|
1616
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
1617
|
-
});
|
|
1813
|
+
const res = await fetchImpl(input, init);
|
|
1618
1814
|
// A 5xx is the provider failing; a 4xx is usually us. Both are
|
|
1619
1815
|
// worth recording, because "the connection stopped working" is the
|
|
1620
1816
|
// question a health view answers.
|
|
@@ -1744,6 +1940,7 @@ export class SqliteScopeHost {
|
|
|
1744
1940
|
purgeOnlyKeys: purgeOnlyKeysOf(manifest.schedules ?? []),
|
|
1745
1941
|
});
|
|
1746
1942
|
noteReservedKeys(this.reservedKeys, manifest);
|
|
1943
|
+
noteDeclaredKeys(this.declaredKeys, manifest);
|
|
1747
1944
|
if (erasure)
|
|
1748
1945
|
this.erasurePlans.push(erasure);
|
|
1749
1946
|
for (const rel of manifest.entityRelations ?? []) {
|
|
@@ -2126,27 +2323,45 @@ export class SqliteScopeHost {
|
|
|
2126
2323
|
out.push(r);
|
|
2127
2324
|
}
|
|
2128
2325
|
};
|
|
2326
|
+
const contentTypeOf = (p) => {
|
|
2327
|
+
try {
|
|
2328
|
+
const meta = JSON.parse(readFileSync(`${p}.meta`, 'utf8'));
|
|
2329
|
+
return meta.contentType != null ? { contentType: meta.contentType } : {};
|
|
2330
|
+
}
|
|
2331
|
+
catch {
|
|
2332
|
+
// No sidecar (or unreadable): the caller falls back to the metadata row.
|
|
2333
|
+
return {};
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2129
2336
|
return this.wrapBlobStore({
|
|
2130
2337
|
put: async (key, body, opts) => {
|
|
2131
2338
|
const p = resolveKey(key);
|
|
2132
2339
|
mkdirSync(dirname(p), { recursive: true });
|
|
2133
|
-
|
|
2340
|
+
if (isSizedByteStream(body))
|
|
2341
|
+
await writeStreamToFile(p, body);
|
|
2342
|
+
else
|
|
2343
|
+
writeFileSync(p, body);
|
|
2134
2344
|
writeFileSync(`${p}.meta`, JSON.stringify({ contentType: opts?.contentType ?? null }));
|
|
2135
2345
|
},
|
|
2136
2346
|
get: async (key) => {
|
|
2137
2347
|
const p = resolveKey(key);
|
|
2138
|
-
|
|
2139
|
-
return null;
|
|
2140
|
-
const body = new Uint8Array(readFileSync(p));
|
|
2141
|
-
let contentType;
|
|
2348
|
+
let body;
|
|
2142
2349
|
try {
|
|
2143
|
-
|
|
2144
|
-
contentType = meta.contentType ?? undefined;
|
|
2350
|
+
body = new Uint8Array(readFileSync(p));
|
|
2145
2351
|
}
|
|
2146
|
-
catch {
|
|
2147
|
-
//
|
|
2352
|
+
catch (err) {
|
|
2353
|
+
// Absent, or removed after the read gate: one answer either way, naming no host path (#2514).
|
|
2354
|
+
if (err.code === 'ENOENT')
|
|
2355
|
+
return null;
|
|
2356
|
+
throw err;
|
|
2148
2357
|
}
|
|
2149
|
-
return
|
|
2358
|
+
return { body, ...contentTypeOf(p) };
|
|
2359
|
+
},
|
|
2360
|
+
// #2514: the file read as the stream is read, never whole.
|
|
2361
|
+
getStream: async (key) => {
|
|
2362
|
+
const p = resolveKey(key);
|
|
2363
|
+
const file = fileStream(p);
|
|
2364
|
+
return file && { ...file, ...contentTypeOf(p) };
|
|
2150
2365
|
},
|
|
2151
2366
|
delete: async (key) => {
|
|
2152
2367
|
const p = resolveKey(key);
|
|
@@ -2403,6 +2618,21 @@ export class SqliteScopeHost {
|
|
|
2403
2618
|
}
|
|
2404
2619
|
};
|
|
2405
2620
|
const guarded = opts.reentrant ? reentrant : serialized;
|
|
2621
|
+
// The read gate, decided before the blob store is touched: a refused read never reaches it.
|
|
2622
|
+
const authorizeRead = (attachmentId) => guarded('attachments.open', async (ctx) => {
|
|
2623
|
+
const row = rt.db
|
|
2624
|
+
.prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
|
|
2625
|
+
.get(attachmentId);
|
|
2626
|
+
if (!row)
|
|
2627
|
+
return null;
|
|
2628
|
+
if (opts.admitByEvent !== undefined) {
|
|
2629
|
+
admitByDelivery(rt, opts.admitByEvent, row);
|
|
2630
|
+
return attachmentRecordOfRow(row);
|
|
2631
|
+
}
|
|
2632
|
+
const gate = targetGate(row.entity_type);
|
|
2633
|
+
assertAllowed(await ctx.check(gate.read, { entityType: row.entity_type, entityId: row.entity_id }));
|
|
2634
|
+
return attachmentRecordOfRow(row);
|
|
2635
|
+
});
|
|
2406
2636
|
return {
|
|
2407
2637
|
upload: async (input) => {
|
|
2408
2638
|
if (capabilitySession !== undefined) {
|
|
@@ -2416,24 +2646,33 @@ export class SqliteScopeHost {
|
|
|
2416
2646
|
});
|
|
2417
2647
|
}
|
|
2418
2648
|
const gate = targetGate(input.entity.entityType);
|
|
2649
|
+
// A streamed body is the case where bytes are the cost, so the write gate is asked before
|
|
2650
|
+
// the first one is read (#2516). The row's own check below stays: this one is only early.
|
|
2651
|
+
if (isSizedByteStream(input.body)) {
|
|
2652
|
+
// A read that writes nothing: one actor turn, no transaction and no drain (the denial is
|
|
2653
|
+
// still recorded). Already inside the turn on a reentrant surface, which must not queue again.
|
|
2654
|
+
const askGate = () => reentrant('attachments.upload', async (ctx) => {
|
|
2655
|
+
assertAllowed(await ctx.check(gate.write, input.entity));
|
|
2656
|
+
});
|
|
2657
|
+
await (opts.reentrant ? askGate() : rt.actor.enqueue(askGate));
|
|
2658
|
+
}
|
|
2419
2659
|
const creator = authority;
|
|
2420
2660
|
const id = ulid();
|
|
2421
2661
|
const key = attachmentBlobKey(rt.scopeId, id);
|
|
2422
|
-
const
|
|
2662
|
+
const fields = {
|
|
2423
2663
|
id,
|
|
2424
2664
|
entity: input.entity,
|
|
2425
2665
|
filename: input.filename,
|
|
2426
2666
|
contentType: input.contentType,
|
|
2427
|
-
size: input.body.byteLength,
|
|
2428
|
-
sha256: await attachmentSha256(input.body),
|
|
2429
2667
|
visibility: input.visibility,
|
|
2430
2668
|
createdBy: creator.id,
|
|
2431
2669
|
createdAt: new Date().toISOString(),
|
|
2432
|
-
}
|
|
2670
|
+
};
|
|
2433
2671
|
// Bytes first, row second: a crash between the two leaves an orphaned object that no
|
|
2434
2672
|
// row points at (#2314). A refused check compensates the object away below; a drain
|
|
2435
2673
|
// fault after the commit is not a refusal (`serialized` keeps it from becoming one).
|
|
2436
|
-
|
|
2674
|
+
// A streamed body is hashed and counted as it is written (#2498).
|
|
2675
|
+
const record = await putAttachmentBytes(store, key, fields, input.body, nodeSha256);
|
|
2437
2676
|
try {
|
|
2438
2677
|
return await guarded('attachments.upload', async (ctx) => {
|
|
2439
2678
|
assertAllowed(await ctx.check(gate.write, input.entity));
|
|
@@ -2481,20 +2720,7 @@ export class SqliteScopeHost {
|
|
|
2481
2720
|
return rows.map(attachmentRecordOfRow);
|
|
2482
2721
|
}),
|
|
2483
2722
|
open: async (attachmentId) => {
|
|
2484
|
-
const record = await
|
|
2485
|
-
const row = rt.db
|
|
2486
|
-
.prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
|
|
2487
|
-
.get(attachmentId);
|
|
2488
|
-
if (!row)
|
|
2489
|
-
return null;
|
|
2490
|
-
if (opts.admitByEvent !== undefined) {
|
|
2491
|
-
admitByDelivery(rt, opts.admitByEvent, row);
|
|
2492
|
-
return attachmentRecordOfRow(row);
|
|
2493
|
-
}
|
|
2494
|
-
const gate = targetGate(row.entity_type);
|
|
2495
|
-
assertAllowed(await ctx.check(gate.read, { entityType: row.entity_type, entityId: row.entity_id }));
|
|
2496
|
-
return attachmentRecordOfRow(row);
|
|
2497
|
-
});
|
|
2723
|
+
const record = await authorizeRead(attachmentId);
|
|
2498
2724
|
if (!record)
|
|
2499
2725
|
return null;
|
|
2500
2726
|
const obj = await store.get(attachmentBlobKey(rt.scopeId, record.id));
|
|
@@ -2507,6 +2733,14 @@ export class SqliteScopeHost {
|
|
|
2507
2733
|
}
|
|
2508
2734
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
2509
2735
|
},
|
|
2736
|
+
// #2514: the same gate, then the file as it is read, checked against the record as it passes.
|
|
2737
|
+
// The bytes are read after the gate's turn has ended, so a long read holds no actor turn.
|
|
2738
|
+
openStream: async (attachmentId) => {
|
|
2739
|
+
const record = await authorizeRead(attachmentId);
|
|
2740
|
+
if (!record)
|
|
2741
|
+
return null;
|
|
2742
|
+
return openAttachmentByteStream(store, attachmentBlobKey(rt.scopeId, record.id), record, nodeSha256);
|
|
2743
|
+
},
|
|
2510
2744
|
remove: async (attachmentId) => {
|
|
2511
2745
|
const removed = await guarded('attachments.remove', async (ctx, subject) => {
|
|
2512
2746
|
const row = rt.db
|
|
@@ -2553,7 +2787,31 @@ export class SqliteScopeHost {
|
|
|
2553
2787
|
};
|
|
2554
2788
|
}
|
|
2555
2789
|
async importScope(actor, input, dump) {
|
|
2790
|
+
await this.importScopeFrom(actor, input, dump, true);
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* `importScope`, with its role judgement (#2555) when `judge`: once before the scope is created,
|
|
2794
|
+
* so a refused import leaves nothing behind, and again inside the load's transaction, so a role
|
|
2795
|
+
* defined since that read is judged too. That second refusal comes after the scope was created, so
|
|
2796
|
+
* the scope goes with it. A snapshot passes false: it is the platform's copy of a live scope of this
|
|
2797
|
+
* host, made at once, so its seats are the ones that scope holds now (K-55).
|
|
2798
|
+
*/
|
|
2799
|
+
async importScopeFrom(actor, input, dump, judge) {
|
|
2556
2800
|
this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
|
|
2801
|
+
if (judge)
|
|
2802
|
+
this.refuseDumpRoles(input.tenantId, dump);
|
|
2803
|
+
let refusedInLoad = false;
|
|
2804
|
+
const recheck = judge
|
|
2805
|
+
? () => {
|
|
2806
|
+
try {
|
|
2807
|
+
this.refuseDumpRoles(input.tenantId, dump);
|
|
2808
|
+
}
|
|
2809
|
+
catch (e) {
|
|
2810
|
+
refusedInLoad = true;
|
|
2811
|
+
throw e;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
: undefined;
|
|
2557
2815
|
// Create the destination scope (directory row + storage). Its migration step
|
|
2558
2816
|
// creates some tables; the dump then replaces them wholesale, so the end state
|
|
2559
2817
|
// is the dump, not whatever the local module set would have built. Provenance is
|
|
@@ -2565,7 +2823,33 @@ export class SqliteScopeHost {
|
|
|
2565
2823
|
forkedAt: input.forkedAt ?? dump.capturedAt,
|
|
2566
2824
|
});
|
|
2567
2825
|
// A fork: its callers (snapshotScope) hand it a dump the platform exported.
|
|
2568
|
-
|
|
2826
|
+
try {
|
|
2827
|
+
await this.loadDump(input.tenantId, input.scopeId, { ...dump, exact: true }, undefined, recheck);
|
|
2828
|
+
}
|
|
2829
|
+
catch (e) {
|
|
2830
|
+
// The scope was created for this import alone and holds nothing loaded, so it goes with the
|
|
2831
|
+
// refusal, and a retry meets no scope. Best effort: the refusal is the answer whatever the
|
|
2832
|
+
// teardown does, and a teardown that fails is a staff-only ops row (no tenant on it), with the
|
|
2833
|
+
// scope left in `provisioning` for staff to reap. No admin row is written for the teardown:
|
|
2834
|
+
// the import never happened, and its `provisionScope` row stays as the record of the attempt.
|
|
2835
|
+
if (refusedInLoad) {
|
|
2836
|
+
await this.dropScopeOutright(input.tenantId, input.scopeId).catch((teardown) => {
|
|
2837
|
+
try {
|
|
2838
|
+
this.writeOpsFailure({
|
|
2839
|
+
actor,
|
|
2840
|
+
operation: 'importScope',
|
|
2841
|
+
stage: 'refused-import-teardown',
|
|
2842
|
+
scopeId: input.scopeId,
|
|
2843
|
+
message: `a refused import's scope could not be taken down: ${teardown instanceof Error ? teardown.message : String(teardown)}`,
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
catch {
|
|
2847
|
+
// Nothing more to do here: the refusal below is still the answer.
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
throw e;
|
|
2852
|
+
}
|
|
2569
2853
|
await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
|
|
2570
2854
|
await this.admin.activateScope(actor, input.tenantId, input.scopeId);
|
|
2571
2855
|
this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
@@ -2577,10 +2861,14 @@ export class SqliteScopeHost {
|
|
|
2577
2861
|
dump,
|
|
2578
2862
|
/** Run inside the load's own transaction, after the replay (#1742) — the restore's re-assert
|
|
2579
2863
|
* of the switch. It throws to roll the load back. */
|
|
2580
|
-
afterLoad
|
|
2864
|
+
afterLoad,
|
|
2865
|
+
/** Run first inside the load's own transaction, before any drop (#2555): the role judgement,
|
|
2866
|
+
* against the roles stored at the moment of the load. It throws to refuse with nothing moved. */
|
|
2867
|
+
beforeLoad) {
|
|
2581
2868
|
const rt = this.runtime(tenantId, scopeId);
|
|
2582
2869
|
const db = rt.db;
|
|
2583
2870
|
const load = db.transaction((dumped) => {
|
|
2871
|
+
beforeLoad?.();
|
|
2584
2872
|
// Deferred foreign keys cover the WHOLE drop-then-replay, drops included.
|
|
2585
2873
|
//
|
|
2586
2874
|
// Two distinct FK hazards, and the second is why this cannot wrap the inserts alone:
|
|
@@ -2727,6 +3015,8 @@ export class SqliteScopeHost {
|
|
|
2727
3015
|
if (!existing)
|
|
2728
3016
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2729
3017
|
this.refuseUnusableScopeStores(dump.stores, existing.vertical);
|
|
3018
|
+
// #2555: judged before the fence, so a refused restore moves nothing; again inside the load.
|
|
3019
|
+
this.refuseDumpRoles(tenantId, dump);
|
|
2730
3020
|
// #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
|
|
2731
3021
|
await this.fenceOrphanSweep(tenantId, scopeId, existing.vertical ?? null);
|
|
2732
3022
|
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
@@ -2736,7 +3026,7 @@ export class SqliteScopeHost {
|
|
|
2736
3026
|
const rows = [];
|
|
2737
3027
|
// #1869: the re-point's source is the separate hint when given; the dump is otherwise as sent.
|
|
2738
3028
|
const source = { tables: dump.tables, scopeId: opts?.sourceScopeId ?? dump.scopeId };
|
|
2739
|
-
await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])));
|
|
3029
|
+
await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])), () => this.refuseDumpRoles(tenantId, dump));
|
|
2740
3030
|
await this.importScopeStores(tenantId, scopeId, dump.stores);
|
|
2741
3031
|
const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
|
|
2742
3032
|
for (const [action, after] of rows)
|
|
@@ -2755,15 +3045,16 @@ export class SqliteScopeHost {
|
|
|
2755
3045
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2756
3046
|
const dump = await this.admin.exportScope(actor, tenantId, scopeId);
|
|
2757
3047
|
const snapshotId = ulid();
|
|
2758
|
-
await this.
|
|
3048
|
+
await this.importScopeFrom(actor, {
|
|
2759
3049
|
tenantId,
|
|
2760
3050
|
scopeId: snapshotId,
|
|
2761
3051
|
kind: opts?.kind ?? 'archive',
|
|
2762
3052
|
vertical: source.vertical,
|
|
3053
|
+
verticalSource: 'copy',
|
|
2763
3054
|
jurisdiction: source.jurisdiction,
|
|
2764
3055
|
expiresAt: opts?.expiresAt,
|
|
2765
3056
|
// forkedFrom/forkedAt are stamped from the dump by importScope.
|
|
2766
|
-
}, dump);
|
|
3057
|
+
}, dump, false);
|
|
2767
3058
|
// Bind the snapshot to the source's current version so it is a runnable copy at the
|
|
2768
3059
|
// same frontier (a fresh bind, so it never re-triggers the snapshot path).
|
|
2769
3060
|
if (source.verticalVersionId) {
|
|
@@ -2783,6 +3074,16 @@ export class SqliteScopeHost {
|
|
|
2783
3074
|
throw substratError('forbidden', `scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
|
|
2784
3075
|
`archive a primary scope instead`);
|
|
2785
3076
|
}
|
|
3077
|
+
await this.dropScopeOutright(tenantId, scopeId);
|
|
3078
|
+
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
3079
|
+
forkedFrom: rec.forkedFrom,
|
|
3080
|
+
forkedAt: rec.forkedAt,
|
|
3081
|
+
expiresAt: rec.expiresAt,
|
|
3082
|
+
kind: rec.kind,
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
/** `deleteSnapshot`'s teardown, without its refusal or its audit row: the scope's stores, file and directory rows. */
|
|
3086
|
+
async dropScopeOutright(tenantId, scopeId) {
|
|
2786
3087
|
await this.wipeScopeStores(tenantId, scopeId);
|
|
2787
3088
|
// Close and evict the runtime handle before touching the file.
|
|
2788
3089
|
const rt = this.scopes.get(`${tenantId}/${scopeId}`);
|
|
@@ -2802,12 +3103,6 @@ export class SqliteScopeHost {
|
|
|
2802
3103
|
forgetScopeStorage(redactionSqlOf(this.directory), scopeId);
|
|
2803
3104
|
this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
|
|
2804
3105
|
})();
|
|
2805
|
-
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
2806
|
-
forkedFrom: rec.forkedFrom,
|
|
2807
|
-
forkedAt: rec.forkedAt,
|
|
2808
|
-
expiresAt: rec.expiresAt,
|
|
2809
|
-
kind: rec.kind,
|
|
2810
|
-
});
|
|
2811
3106
|
}
|
|
2812
3107
|
async getScope(principal, tenantId, scopeId, options) {
|
|
2813
3108
|
const row = this.directory
|
|
@@ -3785,6 +4080,165 @@ export class SqliteScopeHost {
|
|
|
3785
4080
|
if (refusal)
|
|
3786
4081
|
throw substratError('forbidden', refusal);
|
|
3787
4082
|
}
|
|
4083
|
+
/**
|
|
4084
|
+
* #2447: refuse defining `role` for `tenantId` (`reservedRoleDefinitionRefusal`): against the keys
|
|
4085
|
+
* the modules registered or listed here reserve and declare, and the tenant's other roles.
|
|
4086
|
+
*/
|
|
4087
|
+
refuseReservedDefinition(tenantId, role) {
|
|
4088
|
+
const prefix = `${tenantId}/`;
|
|
4089
|
+
const tenantRoles = [...this.roles].filter(([k]) => k.startsWith(prefix)).map(([, r]) => r);
|
|
4090
|
+
const stored = this.roles.get(`${tenantId}/${role.key}`);
|
|
4091
|
+
// The seats are read only for the one case they decide: a system-held definition of a key with no
|
|
4092
|
+
// system-held definition stored (none at all included: a seat can outlive its role's row).
|
|
4093
|
+
const seated = stored?.holder !== 'system' && role.holder === 'system' && this.roleSeated(tenantId, role.key);
|
|
4094
|
+
const refusal = reservedRoleDefinitionRefusal(role, { reserved: this.reservedKeys, declared: this.declaredKeys }, tenantRoles, seated);
|
|
4095
|
+
if (refusal)
|
|
4096
|
+
throw substratError('forbidden', refusal);
|
|
4097
|
+
}
|
|
4098
|
+
/**
|
|
4099
|
+
* #2555: refuse loading `dump` into a scope of `tenantId` (`dumpRoleRefusal`): the tenant's roles
|
|
4100
|
+
* as this host stores them now, against the dump's role rows and seats, with the keys the modules
|
|
4101
|
+
* registered or listed here reserve and declare. Synchronous, so a caller inside the load's
|
|
4102
|
+
* transaction judges the roles stored at that moment.
|
|
4103
|
+
*/
|
|
4104
|
+
refuseDumpRoles(tenantId, dump) {
|
|
4105
|
+
const prefix = `${tenantId}/`;
|
|
4106
|
+
const roles = [...this.roles].filter(([k]) => k.startsWith(prefix)).map(([, r]) => r);
|
|
4107
|
+
const refusal = dumpRoleRefusal(roles, { reserved: this.reservedKeys, declared: this.declaredKeys }, dump, this.clock());
|
|
4108
|
+
if (refusal)
|
|
4109
|
+
throw substratError('forbidden', refusal);
|
|
4110
|
+
}
|
|
4111
|
+
/**
|
|
4112
|
+
* #2447: does anyone but a system principal hold a live seat in `roleKey` in this tenant — a
|
|
4113
|
+
* tenant-level assignment, or a scope-level one in any of its scopes not reaped
|
|
4114
|
+
* (`SEAT_HOLDING_SCOPE_STATUSES`: an archived scope's seats come back on unarchive, one archived
|
|
4115
|
+
* straight from provisioning included)? A scope with no store on disk holds none and is not opened.
|
|
4116
|
+
*/
|
|
4117
|
+
roleSeated(tenantId, roleKey) {
|
|
4118
|
+
const now = this.clock();
|
|
4119
|
+
const holds = (rows) => rows.some((r) => r.role_key === roleKey);
|
|
4120
|
+
if (holds(this.directory.prepare(TENANT_SEATED_ROLES_SQL).all(tenantId, now)))
|
|
4121
|
+
return true;
|
|
4122
|
+
const scopes = this.directory
|
|
4123
|
+
.prepare(`SELECT scope_id FROM scopes WHERE tenant_id = ? AND status IN (${SEAT_HOLDING_SCOPE_STATUSES.map(() => '?').join(', ')})`)
|
|
4124
|
+
.all(tenantId, ...SEAT_HOLDING_SCOPE_STATUSES);
|
|
4125
|
+
return scopes.some((s) => {
|
|
4126
|
+
// Read without creating storage: a scope whose store is neither open nor on disk never held a
|
|
4127
|
+
// seat (a scope archived straight from provisioning may be one), and opening it would create it.
|
|
4128
|
+
const id = s.scope_id;
|
|
4129
|
+
if (!this.scopes.has(`${tenantId}/${id}`) && !existsSync(join(this.dir, `${tenantId}__${id}.sqlite`)))
|
|
4130
|
+
return false;
|
|
4131
|
+
return holds(this.runtime(tenantId, id).db.prepare(SCOPE_SEATED_ROLES_SQL).all(now));
|
|
4132
|
+
});
|
|
4133
|
+
}
|
|
4134
|
+
/**
|
|
4135
|
+
* #2446: the roles this host stores for `tenantId` and the role keys anyone but a system principal
|
|
4136
|
+
* holds a live seat in at `scopeId` (`SCOPE_SEATED_ROLES_SQL`): the Cloudflare host's
|
|
4137
|
+
* `roleStateLocal`, the state a CP-less reconcile judges a role set against and a promote judges the
|
|
4138
|
+
* incoming version's set with. It is deliberately narrower than this host's own `defineRole`
|
|
4139
|
+
* judgement (`roleSeated`), which also counts tenant-level seats and every other scope's: a
|
|
4140
|
+
* reconcile defines a scope's role set and reads that scope's seats only, and this answers what it
|
|
4141
|
+
* reads (`roleStateContractSuite` pins the two adapters to the same answer, a tenant-level seat
|
|
4142
|
+
* and another scope's seat excluded). Read-only: a scope with no store on disk holds no seat and is
|
|
4143
|
+
* not opened, so the read creates nothing.
|
|
4144
|
+
*/
|
|
4145
|
+
async roleStateLocal(tenantId, scopeId) {
|
|
4146
|
+
const prefix = `${tenantId}/`;
|
|
4147
|
+
const roles = [...this.roles]
|
|
4148
|
+
.filter(([k]) => k.startsWith(prefix))
|
|
4149
|
+
.map(([, r]) => ({ key: r.key, permissions: [...r.permissions], ...(r.holder ? { holder: r.holder } : {}) }));
|
|
4150
|
+
const stored = this.scopes.has(`${tenantId}/${scopeId}`) || existsSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`));
|
|
4151
|
+
const seated = stored
|
|
4152
|
+
? [...new Set(this.runtime(tenantId, scopeId).db.prepare(SCOPE_SEATED_ROLES_SQL).all(this.clock()).map((r) => r.role_key))]
|
|
4153
|
+
: [];
|
|
4154
|
+
return { roles, seated };
|
|
4155
|
+
}
|
|
4156
|
+
/**
|
|
4157
|
+
* The scope database's size in bytes, asked by scope id alone (#2541): the Cloudflare host's
|
|
4158
|
+
* `databaseSizeLocal`, which a vertical's `/internal/database-size` calls. Counts as
|
|
4159
|
+
* `scopeDatabaseSize` does. A scope this host does not hold is refused before any store is opened.
|
|
4160
|
+
*/
|
|
4161
|
+
async databaseSizeLocal(scopeId) {
|
|
4162
|
+
return databaseBytesOf(this.heldReadDb(scopeId));
|
|
4163
|
+
}
|
|
4164
|
+
/** What the scope's attachment rows sum to (#2501), asked by scope id alone: refused as the size is (#2541). */
|
|
4165
|
+
async attachmentSizeLocal(scopeId) {
|
|
4166
|
+
return attachmentBytesOf(this.heldReadDb(scopeId));
|
|
4167
|
+
}
|
|
4168
|
+
// -- the introspection reads a vertical's `/internal/*` routes make by scope id alone (#2544) --------
|
|
4169
|
+
// The Cloudflare host's `*Local` verbs, refused from the directory before any store is opened
|
|
4170
|
+
// (`heldReadDb`), so a scope this host does not hold is `conflict`, never an empty answer and never
|
|
4171
|
+
// a store file created for it. No audit row, as there: the control plane that called is the auditor.
|
|
4172
|
+
async facetEventsLocal(scopeId, input) {
|
|
4173
|
+
return facetEvents({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
|
|
4174
|
+
}
|
|
4175
|
+
async eventEffectsLocal(scopeId, input) {
|
|
4176
|
+
return walkEventEffects({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.eventId, input.maxNodes);
|
|
4177
|
+
}
|
|
4178
|
+
async invocationEventsLocal(scopeId, input) {
|
|
4179
|
+
return readInvocation({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.invocationId, input.limit);
|
|
4180
|
+
}
|
|
4181
|
+
async deadLettersLocal(scopeId, input) {
|
|
4182
|
+
return readDeadLetters({ sql: scopedSql(this.heldReadDb(scopeId)) }, { limit: input.limit, cursor: input.cursor });
|
|
4183
|
+
}
|
|
4184
|
+
async lifecycleFlowLocal(scopeId, input) {
|
|
4185
|
+
return readLifecycleFlow({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
|
|
4186
|
+
}
|
|
4187
|
+
async operationSeriesLocal(scopeId, input) {
|
|
4188
|
+
return readOperationSeries({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
|
|
4189
|
+
}
|
|
4190
|
+
async eventCauseLocal(scopeId, input) {
|
|
4191
|
+
return walkEventCause({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.eventId, input.maxDepth);
|
|
4192
|
+
}
|
|
4193
|
+
async entityHistoryLocal(scopeId, input) {
|
|
4194
|
+
return readHistory({ sql: scopedSql(this.heldReadDb(scopeId)) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
|
|
4195
|
+
}
|
|
4196
|
+
async appliedMigrationsLocal(scopeId) {
|
|
4197
|
+
return appliedMigrationsOf(this.heldReadDb(scopeId));
|
|
4198
|
+
}
|
|
4199
|
+
/** No PITR on plain SQLite: an empty list for a held scope, as `scopeMigrationBookmarks` answers. */
|
|
4200
|
+
async migrationBookmarksLocal(scopeId) {
|
|
4201
|
+
this.heldReadDb(scopeId);
|
|
4202
|
+
return [];
|
|
4203
|
+
}
|
|
4204
|
+
async introspectScopeTables(scopeId) {
|
|
4205
|
+
return scopeTablesOf(this.heldReadDb(scopeId));
|
|
4206
|
+
}
|
|
4207
|
+
/** The Data view's table page (#2563): refused `conflict` with the shared sentence for a scope this host does not hold. */
|
|
4208
|
+
async introspectScopeTable(scopeId, input) {
|
|
4209
|
+
return scopeTablePageOf(this.heldReadDb(scopeId), input);
|
|
4210
|
+
}
|
|
4211
|
+
/**
|
|
4212
|
+
* The carry's read of a wiped copy's tombstone (#2563). Plain SQLite has no carry and no wipe, so
|
|
4213
|
+
* a store it does not hold is one it does not have: this reads what `introspectScopeTable` reads.
|
|
4214
|
+
*/
|
|
4215
|
+
async readTombstoneTable(scopeId, input) {
|
|
4216
|
+
return scopeTablePageOf(this.heldReadDb(scopeId), input);
|
|
4217
|
+
}
|
|
4218
|
+
/** The SQL console's CP-less path (#219), guarded as the table page is (#2563). */
|
|
4219
|
+
async introspectScopeQuery(scopeId, input) {
|
|
4220
|
+
return scopeQueryOf(this.heldReadDb(scopeId), input).result;
|
|
4221
|
+
}
|
|
4222
|
+
async undrainedEventsLocal(scopeId, limit) {
|
|
4223
|
+
return undrainedEventsFrom(this.heldReadDb(scopeId), Math.max(limit, 1));
|
|
4224
|
+
}
|
|
4225
|
+
async redrainCountLocal(scopeId, drainedBefore) {
|
|
4226
|
+
return redrainCountOf(this.heldReadDb(scopeId), drainedBefore);
|
|
4227
|
+
}
|
|
4228
|
+
/**
|
|
4229
|
+
* The read connection of a scope this host holds, for a read that names only the scope id (#2541),
|
|
4230
|
+
* with the directory read ONCE. Refused `conflict` before anything is opened: no row, a reaped one
|
|
4231
|
+
* (a tombstone whose store is gone), or a scope with no store open or on disk (archived straight
|
|
4232
|
+
* from provisioning, as the Cloudflare host's object holding nothing is), since `runtime()` would
|
|
4233
|
+
* create the store those leave absent.
|
|
4234
|
+
*/
|
|
4235
|
+
heldReadDb(scopeId) {
|
|
4236
|
+
const row = this.directory.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
4237
|
+
const stored = row !== undefined && (this.scopes.has(`${row.tenant_id}/${scopeId}`) || existsSync(join(this.dir, `${row.tenant_id}__${scopeId}.sqlite`)));
|
|
4238
|
+
if (!row || row.status === 'reaped' || !stored)
|
|
4239
|
+
throw substratError('conflict', scopeNotHeldRefusal(scopeId));
|
|
4240
|
+
return this.readDb(this.runtime(row.tenant_id, scopeId));
|
|
4241
|
+
}
|
|
3788
4242
|
/** #2430: refuse a raw grant of a key a module registered here reserves (`reservedGrantRefusal`). */
|
|
3789
4243
|
refuseReservedGrant(permissions) {
|
|
3790
4244
|
const refusal = reservedGrantRefusal(permissions, this.reservedKeys);
|
|
@@ -3921,7 +4375,9 @@ export class SqliteScopeHost {
|
|
|
3921
4375
|
});
|
|
3922
4376
|
return report;
|
|
3923
4377
|
}
|
|
3924
|
-
async runDueSchedules(moduleId, tenantId, scopeId
|
|
4378
|
+
async runDueSchedules(moduleId, tenantId, scopeId,
|
|
4379
|
+
/** #2517: what the sweep tick's earlier calls did to this scope (`ScopeHost.runDueSchedules`). */
|
|
4380
|
+
tick) {
|
|
3925
4381
|
const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
|
|
3926
4382
|
const mod = this.modules.get(moduleId);
|
|
3927
4383
|
if (!mod || mod.schedules.length === 0)
|
|
@@ -3989,6 +4445,16 @@ export class SqliteScopeHost {
|
|
|
3989
4445
|
// scope at once. `ScheduleRunReport.platformRequests` says what that does and does not count.
|
|
3990
4446
|
let platformRequests = 0;
|
|
3991
4447
|
const counted = { onPlatformRequests: (n) => (platformRequests += n) };
|
|
4448
|
+
// #2517: the part of that the purge runs enqueued, which is what defers a later purge head the
|
|
4449
|
+
// cap refuses (`runPurgePass`): this call's, on top of what the tick's earlier calls report.
|
|
4450
|
+
const earlierPurgeRequests = tick?.earlierPurgeRequests ?? 0;
|
|
4451
|
+
let purgeRequests = 0;
|
|
4452
|
+
const purgeCounted = {
|
|
4453
|
+
onPlatformRequests: (n) => {
|
|
4454
|
+
platformRequests += n;
|
|
4455
|
+
purgeRequests += n;
|
|
4456
|
+
},
|
|
4457
|
+
};
|
|
3992
4458
|
for (const schedule of mod.schedules) {
|
|
3993
4459
|
const lastRunAt = gate.lastRuns.get(schedule.operation) ?? null;
|
|
3994
4460
|
const lastRun = lastRunAt ? Date.parse(lastRunAt) : null;
|
|
@@ -4016,7 +4482,16 @@ export class SqliteScopeHost {
|
|
|
4016
4482
|
// #119: a purge horizon's schedule runs its operation once per due entity, never once.
|
|
4017
4483
|
// #2192: under the run's own id, so the purges and their deliveries join this run's line.
|
|
4018
4484
|
// #2431: each purge is an invoke, and reports what it enqueued through `counted` like any other.
|
|
4019
|
-
|
|
4485
|
+
// #2517: what the purge runs before this one in the tick enqueued, so a head the cap refuses
|
|
4486
|
+
// because they filled the queue is deferred to the next tick rather than failed.
|
|
4487
|
+
const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation, invocationId, purgeCounted, earlierPurgeRequests + purgeRequests);
|
|
4488
|
+
if (pass.deferred) {
|
|
4489
|
+
// Ran nothing: no line, no cadence row, `skipped` like any run that did not happen.
|
|
4490
|
+
report.skipped += 1;
|
|
4491
|
+
report.deferred = (report.deferred ?? 0) + 1;
|
|
4492
|
+
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
4493
|
+
continue;
|
|
4494
|
+
}
|
|
4020
4495
|
stillDue = purgeStillDue(pass);
|
|
4021
4496
|
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
4022
4497
|
report.errors.push(...outcome.errors);
|
|
@@ -4079,6 +4554,8 @@ export class SqliteScopeHost {
|
|
|
4079
4554
|
lines.end();
|
|
4080
4555
|
if (platformRequests > 0)
|
|
4081
4556
|
report.platformRequests = platformRequests;
|
|
4557
|
+
if (purgeRequests > 0)
|
|
4558
|
+
report.purgeRequests = purgeRequests;
|
|
4082
4559
|
return report;
|
|
4083
4560
|
}
|
|
4084
4561
|
/**
|
|
@@ -4093,7 +4570,9 @@ export class SqliteScopeHost {
|
|
|
4093
4570
|
/** #2192: the schedule run this pass belongs to; every purge in it runs under that id. */
|
|
4094
4571
|
runInvocationId,
|
|
4095
4572
|
/** #2431: how each purge reports the platform requests it enqueued. */
|
|
4096
|
-
counted
|
|
4573
|
+
counted,
|
|
4574
|
+
/** #2517: platform requests the purge runs earlier in this tick enqueued. */
|
|
4575
|
+
earlierPurgeRequests = 0) {
|
|
4097
4576
|
const entityType = this.modules
|
|
4098
4577
|
.get(moduleId)
|
|
4099
4578
|
?.schedules.find((s) => s.operation === operation && s.purge)?.purge?.entityType;
|
|
@@ -4121,7 +4600,7 @@ export class SqliteScopeHost {
|
|
|
4121
4600
|
const stub = await this.openSystemScope(moduleId, tenantId, scopeId, true, counted);
|
|
4122
4601
|
return runPurgePass(operation, due, async (entityId) => {
|
|
4123
4602
|
await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: runInvocationId });
|
|
4124
|
-
}, (write) => rt.actor.turn(() => write(spineSql(rt.db))));
|
|
4603
|
+
}, (write) => rt.actor.turn(() => write(spineSql(rt.db))), earlierPurgeRequests);
|
|
4125
4604
|
}
|
|
4126
4605
|
/**
|
|
4127
4606
|
* #119: the purge gate's facts (`PurgeGateFacts`) — the module's switch from the scope's own
|
|
@@ -5600,15 +6079,20 @@ export class SqliteScopeHost {
|
|
|
5600
6079
|
// #1748: the evidence, its issue and its finding in ONE transaction — a detector that
|
|
5601
6080
|
// throws takes the evidence with it, so the caller's retry observes it rather than finding
|
|
5602
6081
|
// the row already there and the observation lost.
|
|
6082
|
+
// #2540: a tenant's row with a `detail` is a pair — the tenant's row says what was answered, and
|
|
6083
|
+
// a row with no tenant, which only staff read, carries the failure's own text. Counted once.
|
|
6084
|
+
const detail = entry.tenantId && typeof entry.detail === 'string' ? entry.detail : null;
|
|
5603
6085
|
this.directory.transaction(() => {
|
|
5604
|
-
this.directory
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
|
|
6086
|
+
const insert = this.directory.prepare(`INSERT INTO _substrat_ops_failures
|
|
6087
|
+
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
6088
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
6089
|
+
const row = (tenantId, message) => insert.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, tenantId, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
|
|
5609
6090
|
// Bounded here, not trusted from the catch site: one runaway upstream
|
|
5610
6091
|
// body must not become a runaway directory row (#559).
|
|
5611
|
-
|
|
6092
|
+
message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
|
|
6093
|
+
if (detail !== null)
|
|
6094
|
+
row(null, detail);
|
|
6095
|
+
row(entry.tenantId ?? null, entry.message);
|
|
5612
6096
|
// Prune-on-write (#559): retention lives here, not in a cron — every insert
|
|
5613
6097
|
// pays for its own housekeeping, so the table stays bounded even where no
|
|
5614
6098
|
// scheduled pass runs (this adapter has none).
|
|
@@ -5632,7 +6116,11 @@ export class SqliteScopeHost {
|
|
|
5632
6116
|
last_version = COALESCE(excluded.last_version, last_version),
|
|
5633
6117
|
origin = COALESCE(excluded.origin, origin),
|
|
5634
6118
|
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
|
|
5635
|
-
.run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at,
|
|
6119
|
+
.run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at,
|
|
6120
|
+
// The issues are staff's (#2540): a pair's exemplar is its own text, not the tenant's
|
|
6121
|
+
// sentence, and it is owned as the row it is copied from is, the platform's — so erasure
|
|
6122
|
+
// reaches it on an id match, as it reaches that row.
|
|
6123
|
+
(detail ?? entry.message).slice(0, 2000), detail !== null ? null : (entry.tenantId ?? null), issueExemplarOwner(detail !== null ? null : (entry.tenantId ?? null)), entry.vertical ?? null, entry.version ?? null);
|
|
5636
6124
|
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
5637
6125
|
this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
|
|
5638
6126
|
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
@@ -5746,6 +6234,20 @@ export class SqliteScopeHost {
|
|
|
5746
6234
|
.get(slugValue);
|
|
5747
6235
|
return r ? mapVertical(r) : undefined;
|
|
5748
6236
|
};
|
|
6237
|
+
/**
|
|
6238
|
+
* #2528: one vertical row update whose admin row takes its `before` (and whose guard runs, by
|
|
6239
|
+
* throwing) from the vertical as the write's own transaction reads it.
|
|
6240
|
+
*/
|
|
6241
|
+
const updateVertical = (actor, action, slugValue, set, args, judge, after) => {
|
|
6242
|
+
let before = null;
|
|
6243
|
+
this.directoryWriteAndAudit(() => {
|
|
6244
|
+
const existing = readVertical(slugValue);
|
|
6245
|
+
if (!existing)
|
|
6246
|
+
throw substratError('not_found', `unknown vertical '${slugValue}'`);
|
|
6247
|
+
before = judge(existing);
|
|
6248
|
+
return this.directory.prepare(`UPDATE verticals SET ${set} WHERE slug = ?`).run(...args, slugValue).changes > 0;
|
|
6249
|
+
}, () => this.recordAdmin(actor, action, { tenantId: null }, before, after));
|
|
6250
|
+
};
|
|
5749
6251
|
const mapVersion = (r) => verticalVersion.parse({
|
|
5750
6252
|
id: r.id,
|
|
5751
6253
|
verticalSlug: r.vertical_slug,
|
|
@@ -5772,6 +6274,14 @@ export class SqliteScopeHost {
|
|
|
5772
6274
|
}
|
|
5773
6275
|
return r ? mapVersion(r) : undefined;
|
|
5774
6276
|
};
|
|
6277
|
+
/** A version's admission, read synchronously so a transaction can judge it (#2528). */
|
|
6278
|
+
const versionAdmission = (id) => {
|
|
6279
|
+
const v = this.directory.prepare('SELECT admission, admission_note FROM vertical_versions WHERE id = ?')
|
|
6280
|
+
.get(id);
|
|
6281
|
+
if (!v)
|
|
6282
|
+
throw substratError('not_found', `unknown version ${id}`);
|
|
6283
|
+
return v;
|
|
6284
|
+
};
|
|
5775
6285
|
const mapOrg = (r) => orgSchema.parse({
|
|
5776
6286
|
id: r.org_id,
|
|
5777
6287
|
tenantId: r.tenant_id,
|
|
@@ -5833,6 +6343,32 @@ export class SqliteScopeHost {
|
|
|
5833
6343
|
archivedFromStatus: (r.archived_from_status ?? null),
|
|
5834
6344
|
createdAt: r.created_at,
|
|
5835
6345
|
});
|
|
6346
|
+
// The directory rows `listScopes` and `listScopesReadable` map, filtered and paged alike.
|
|
6347
|
+
const scopeRows = (filter) => {
|
|
6348
|
+
const where = [];
|
|
6349
|
+
const params = [];
|
|
6350
|
+
if (filter?.tenantId) {
|
|
6351
|
+
where.push('tenant_id = ?');
|
|
6352
|
+
params.push(filter.tenantId);
|
|
6353
|
+
}
|
|
6354
|
+
if (filter?.status) {
|
|
6355
|
+
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
6356
|
+
// An empty array means "no status is acceptable" — match nothing, rather
|
|
6357
|
+
// than degenerating into an unfiltered read of the whole fleet.
|
|
6358
|
+
if (statuses.length === 0)
|
|
6359
|
+
return [];
|
|
6360
|
+
// One JSON array, the shape the DO twin needs for its 100-parameter limit (#1776).
|
|
6361
|
+
where.push('status IN (SELECT value FROM json_each(?))');
|
|
6362
|
+
params.push(JSON.stringify(statuses));
|
|
6363
|
+
}
|
|
6364
|
+
if (filter?.vertical) {
|
|
6365
|
+
where.push('vertical = ?');
|
|
6366
|
+
params.push(filter.vertical);
|
|
6367
|
+
}
|
|
6368
|
+
const tail = keysetTail(where, params, 'scope_id', filter);
|
|
6369
|
+
const sql = 'SELECT * FROM scopes' + (where.length ? ` WHERE ${where.join(' AND ')}` : '') + tail;
|
|
6370
|
+
return this.directory.prepare(sql).all(...params);
|
|
6371
|
+
};
|
|
5836
6372
|
// The (version, scope) pair a bind and its impact read both start from, and the refusals
|
|
5837
6373
|
// that come before any export-break question, in the order the bind makes them (#1756).
|
|
5838
6374
|
const bindTarget = async (tenantId, scopeId, versionId) => {
|
|
@@ -5900,8 +6436,8 @@ export class SqliteScopeHost {
|
|
|
5900
6436
|
}
|
|
5901
6437
|
if (to === 'reaped') {
|
|
5902
6438
|
forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
5903
|
-
// #
|
|
5904
|
-
|
|
6439
|
+
// #2515: its storage is gone, and its measured days are kept for the invoice (retention ends them).
|
|
6440
|
+
retireScopeStorage(redactionSqlOf(this.directory), scopeId);
|
|
5905
6441
|
}
|
|
5906
6442
|
// The audit target carries the scope's vertical (control-plane.md §4.4:
|
|
5907
6443
|
// "vertical stays null until §4.2 lifecycle actions that name one"). It is
|
|
@@ -6236,6 +6772,10 @@ export class SqliteScopeHost {
|
|
|
6236
6772
|
canStoreSecrets: isSecretBoxConfigured(this.secretBox),
|
|
6237
6773
|
defineRole: async (actor, tenantId, role) => {
|
|
6238
6774
|
const parsed = roleDefinition.parse(role);
|
|
6775
|
+
// #2447: judged before the write, against this host's keys and the tenant's other roles, so a
|
|
6776
|
+
// role assigned through a door cannot be widened afterwards. A refusal writes nothing, so a
|
|
6777
|
+
// seat of the role as it was stored keeps working.
|
|
6778
|
+
this.refuseReservedDefinition(tenantId, parsed);
|
|
6239
6779
|
const before = this.roles.get(`${tenantId}/${parsed.key}`) ?? null;
|
|
6240
6780
|
this.directory
|
|
6241
6781
|
.prepare(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder)
|
|
@@ -6856,10 +7396,17 @@ export class SqliteScopeHost {
|
|
|
6856
7396
|
}
|
|
6857
7397
|
throw substratError('conflict', `vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
6858
7398
|
}
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
7399
|
+
const createdAt = verticalOwnerNow();
|
|
7400
|
+
// #2501: the slug's ownership period opens with its row, in the same transaction.
|
|
7401
|
+
this.directoryWriteAndAudit(() => {
|
|
7402
|
+
const changed = this.directory
|
|
7403
|
+
.prepare('INSERT INTO verticals (slug, name, source, owner_tenant, env_spec, install_spec, listed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
7404
|
+
.run(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, createdAt)
|
|
7405
|
+
.changes > 0;
|
|
7406
|
+
if (changed)
|
|
7407
|
+
openVerticalOwnerRow(redactionSqlOf(this.directory), parsed.slug, parsed.ownerTenant, createdAt);
|
|
7408
|
+
return changed;
|
|
7409
|
+
}, () => this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed));
|
|
6863
7410
|
},
|
|
6864
7411
|
listVerticals: async (actor, page) => {
|
|
6865
7412
|
const where = [];
|
|
@@ -6872,15 +7419,15 @@ export class SqliteScopeHost {
|
|
|
6872
7419
|
},
|
|
6873
7420
|
publishVersion: async (actor, input) => {
|
|
6874
7421
|
const parsed = publishVersionInput.parse(input);
|
|
6875
|
-
const
|
|
6876
|
-
if (!
|
|
6877
|
-
throw
|
|
6878
|
-
}
|
|
7422
|
+
const unknownVertical = () => substratError('not_found', `unknown vertical '${parsed.verticalSlug}'`);
|
|
7423
|
+
if (!readVertical(parsed.verticalSlug))
|
|
7424
|
+
throw unknownVertical();
|
|
6879
7425
|
// Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
|
|
6880
7426
|
// (tenant-owned, not listed), whose blast radius is its own tenant: there the
|
|
6881
7427
|
// sandbox contract is the gate and the version self-admits, noted so the
|
|
6882
|
-
// publish seam can tell a staff vouch from this shortcut.
|
|
6883
|
-
|
|
7428
|
+
// publish seam can tell a staff vouch from this shortcut. Judged in the insert's
|
|
7429
|
+
// transaction (#2528), on the vertical as it reads it there.
|
|
7430
|
+
let selfAdmits = false;
|
|
6884
7431
|
// The manifest is retained for the serving upload (#286), not audited — a whole
|
|
6885
7432
|
// manifest per publish would drown the admin log in bundle metadata.
|
|
6886
7433
|
const { manifestJson, origin, ...audited } = parsed;
|
|
@@ -6891,7 +7438,11 @@ export class SqliteScopeHost {
|
|
|
6891
7438
|
const migrationDigest = await publishedMigrationDigest({
|
|
6892
7439
|
id: audited.id, migrationDigest: audited.migrationDigest, manifestJson: manifestJson ?? null, migrations: split.migrations,
|
|
6893
7440
|
});
|
|
6894
|
-
this.
|
|
7441
|
+
this.directoryWriteAndAudit(() => {
|
|
7442
|
+
const owning = readVertical(parsed.verticalSlug);
|
|
7443
|
+
if (!owning)
|
|
7444
|
+
throw unknownVertical();
|
|
7445
|
+
selfAdmits = owning.ownerTenant !== null && !owning.listed;
|
|
6895
7446
|
this.directory
|
|
6896
7447
|
.prepare(`INSERT INTO vertical_versions
|
|
6897
7448
|
(id, vertical_slug, version, manifest_digest, permission_digest,
|
|
@@ -6902,14 +7453,14 @@ export class SqliteScopeHost {
|
|
|
6902
7453
|
writeVersionMigrations(switchSqlOf(this.directory), audited.id, split.migrations);
|
|
6903
7454
|
// #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
|
|
6904
7455
|
indexVersionScopeStores(switchSqlOf(this.directory), audited.verticalSlug, audited.id, split.manifestJson);
|
|
6905
|
-
|
|
6906
|
-
this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
|
|
7456
|
+
return true;
|
|
7457
|
+
}, () => this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
|
|
6907
7458
|
...audited,
|
|
6908
7459
|
// The digest the registry holds, which differs from the pushed one only for an unheld version.
|
|
6909
7460
|
migrationDigest,
|
|
6910
7461
|
...(origin ? { origin } : {}),
|
|
6911
7462
|
admission: selfAdmits ? 'admitted' : 'pending',
|
|
6912
|
-
});
|
|
7463
|
+
}));
|
|
6913
7464
|
},
|
|
6914
7465
|
verticalScopeStores: async (actor, verticalSlug) => {
|
|
6915
7466
|
// One transaction: versions not indexed yet are indexed by the read (#2286).
|
|
@@ -6940,141 +7491,120 @@ export class SqliteScopeHost {
|
|
|
6940
7491
|
this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
|
|
6941
7492
|
return hit;
|
|
6942
7493
|
},
|
|
7494
|
+
// #2528: each vertical write below reads its row's `before`, and runs its guard, inside the
|
|
7495
|
+
// transaction that writes (`updateVertical`).
|
|
6943
7496
|
setVerticalListed: async (actor, slug, listed) => {
|
|
6944
|
-
const existing = readVertical(slug);
|
|
6945
|
-
if (!existing)
|
|
6946
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
6947
|
-
// Listing is the moment other tenants start trusting this code, so the
|
|
6948
|
-
// version they would install must carry a real staff vouch — an auto-admitted
|
|
6949
|
-
// prod version has never been read by anyone but its author.
|
|
6950
|
-
if (listed) {
|
|
6951
|
-
const prod = this.directory
|
|
6952
|
-
.prepare("SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = 'prod'")
|
|
6953
|
-
.get(slug);
|
|
6954
|
-
const prodVersion = prod ? await readVersion(prod.version_id) : undefined;
|
|
6955
|
-
if (prodVersion?.admissionNote === AUTO_ADMISSION_NOTE) {
|
|
6956
|
-
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
6957
|
-
`a staff admit must vouch for it before listing`);
|
|
6958
|
-
}
|
|
6959
|
-
}
|
|
6960
7497
|
// Resolve any pending publish request either way, and set the flag.
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
7498
|
+
updateVertical(actor, 'setVerticalListed', slug, 'listed = ?, publish_requested_at = NULL', [listed ? 1 : 0], (existing) => {
|
|
7499
|
+
// Listing is the moment other tenants start trusting this code, so the
|
|
7500
|
+
// version they would install must carry a real staff vouch — an auto-admitted
|
|
7501
|
+
// prod version has never been read by anyone but its author.
|
|
7502
|
+
if (listed) {
|
|
7503
|
+
const prod = this.directory
|
|
7504
|
+
.prepare(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
|
|
7505
|
+
WHERE c.vertical_slug = ? AND c.channel = 'prod'`)
|
|
7506
|
+
.get(slug);
|
|
7507
|
+
if (prod?.admission_note === AUTO_ADMISSION_NOTE) {
|
|
7508
|
+
throw substratError('conflict', `vertical '${slug}' prod version ${prod.id} is auto-admitted (private self-serve) — ` +
|
|
7509
|
+
`a staff admit must vouch for it before listing`);
|
|
7510
|
+
}
|
|
7511
|
+
}
|
|
7512
|
+
return { listed: existing.listed };
|
|
7513
|
+
}, { listed });
|
|
6965
7514
|
},
|
|
6966
7515
|
requestPublish: async (actor, slug) => {
|
|
6967
|
-
|
|
6968
|
-
if (!existing)
|
|
6969
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
6970
|
-
this.directory.prepare('UPDATE verticals SET publish_requested_at = ? WHERE slug = ?').run(new Date().toISOString(), slug);
|
|
6971
|
-
this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug });
|
|
7516
|
+
updateVertical(actor, 'requestPublish', slug, 'publish_requested_at = ?', [new Date().toISOString()], () => null, { slug });
|
|
6972
7517
|
},
|
|
6973
7518
|
setVerticalInstallsBlocked: async (actor, slug, blocked) => {
|
|
6974
|
-
|
|
6975
|
-
if (!existing)
|
|
6976
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
6977
|
-
this.directory
|
|
6978
|
-
.prepare('UPDATE verticals SET installs_blocked = ? WHERE slug = ?')
|
|
6979
|
-
.run(blocked ? 1 : 0, slug);
|
|
6980
|
-
this.recordAdmin(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: existing.installsBlocked }, { installsBlocked: blocked });
|
|
7519
|
+
updateVertical(actor, 'setVerticalInstallsBlocked', slug, 'installs_blocked = ?', [blocked ? 1 : 0], (existing) => ({ installsBlocked: existing.installsBlocked }), { installsBlocked: blocked });
|
|
6981
7520
|
},
|
|
6982
7521
|
setVerticalTenantProvisioner: async (actor, slug, granted) => {
|
|
6983
|
-
|
|
6984
|
-
if (!existing)
|
|
6985
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
6986
|
-
this.directory
|
|
6987
|
-
.prepare('UPDATE verticals SET tenant_provisioner = ? WHERE slug = ?')
|
|
6988
|
-
.run(granted ? 1 : 0, slug);
|
|
6989
|
-
this.recordAdmin(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: existing.tenantProvisioner }, { tenantProvisioner: granted });
|
|
7522
|
+
updateVertical(actor, 'setVerticalTenantProvisioner', slug, 'tenant_provisioner = ?', [granted ? 1 : 0], (existing) => ({ tenantProvisioner: existing.tenantProvisioner }), { tenantProvisioner: granted });
|
|
6990
7523
|
},
|
|
6991
7524
|
setVerticalEmailSender: async (actor, slug, granted) => {
|
|
6992
|
-
|
|
6993
|
-
if (!existing)
|
|
6994
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
6995
|
-
this.directory
|
|
6996
|
-
.prepare('UPDATE verticals SET email_sender = ? WHERE slug = ?')
|
|
6997
|
-
.run(granted ? 1 : 0, slug);
|
|
6998
|
-
this.recordAdmin(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: existing.emailSender }, { emailSender: granted });
|
|
7525
|
+
updateVertical(actor, 'setVerticalEmailSender', slug, 'email_sender = ?', [granted ? 1 : 0], (existing) => ({ emailSender: existing.emailSender }), { emailSender: granted });
|
|
6999
7526
|
},
|
|
7000
7527
|
deleteVertical: async (actor, slug) => {
|
|
7001
|
-
const existing = readVertical(slug);
|
|
7002
|
-
if (!existing)
|
|
7003
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7004
|
-
// Refuse while any restorable scope is bound: a deleted registry row would strand
|
|
7005
|
-
// those scopes' version pins and routing. An `archived` scope (a deleted app) still
|
|
7006
|
-
// blocks — unarchive can bring it back — but the refusal names reap/restore, not
|
|
7007
|
-
// "delete", because the app itself is already gone. `reaped` is terminal history
|
|
7008
|
-
// and never blocks. The count names the blast radius.
|
|
7009
|
-
const bound = this.directory
|
|
7010
|
-
.prepare('SELECT ' +
|
|
7011
|
-
"COUNT(*) FILTER (WHERE status NOT IN ('archived', 'reaped')) AS live, " +
|
|
7012
|
-
"COUNT(*) FILTER (WHERE status = 'archived') AS archived " +
|
|
7013
|
-
'FROM scopes WHERE vertical = ?')
|
|
7014
|
-
.get(slug);
|
|
7015
|
-
if (bound.live > 0) {
|
|
7016
|
-
throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
7017
|
-
}
|
|
7018
|
-
if (bound.archived > 0) {
|
|
7019
|
-
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
7020
|
-
}
|
|
7021
7528
|
// Deployed dispatch scripts are NOT reaped here — they become orphans for the
|
|
7022
7529
|
// cleanup script (#248), never destroyed alongside a registry row.
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
if (
|
|
7042
|
-
|
|
7530
|
+
// #2501: one transaction, so the row and its ownership period close together; #2528: its
|
|
7531
|
+
// `before` and the bound-scope refusal are read in it too.
|
|
7532
|
+
let existing;
|
|
7533
|
+
this.directoryWriteAndAudit(() => {
|
|
7534
|
+
existing = readVertical(slug);
|
|
7535
|
+
if (!existing)
|
|
7536
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7537
|
+
// Refuse while any restorable scope is bound: a deleted registry row would strand
|
|
7538
|
+
// those scopes' version pins and routing. An `archived` scope (a deleted app) still
|
|
7539
|
+
// blocks — unarchive can bring it back — but the refusal names reap/restore, not
|
|
7540
|
+
// "delete", because the app itself is already gone. `reaped` is terminal history
|
|
7541
|
+
// and never blocks. The count names the blast radius.
|
|
7542
|
+
const bound = this.directory
|
|
7543
|
+
.prepare('SELECT ' +
|
|
7544
|
+
"COUNT(*) FILTER (WHERE status NOT IN ('archived', 'reaped')) AS live, " +
|
|
7545
|
+
"COUNT(*) FILTER (WHERE status = 'archived') AS archived " +
|
|
7546
|
+
'FROM scopes WHERE vertical = ?')
|
|
7547
|
+
.get(slug);
|
|
7548
|
+
if (bound.live > 0) {
|
|
7549
|
+
throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
7550
|
+
}
|
|
7551
|
+
if (bound.archived > 0) {
|
|
7552
|
+
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
7553
|
+
}
|
|
7554
|
+
this.directory.prepare('DELETE FROM vertical_channels WHERE vertical_slug = ?').run(slug);
|
|
7555
|
+
this.directory.prepare('DELETE FROM vertical_channel_history WHERE vertical_slug = ?').run(slug);
|
|
7043
7556
|
this.directory
|
|
7044
|
-
.prepare(
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
}
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
this.
|
|
7059
|
-
|
|
7060
|
-
|
|
7557
|
+
.prepare(`DELETE FROM vertical_version_migrations
|
|
7558
|
+
WHERE version_id IN (SELECT id FROM vertical_versions WHERE vertical_slug = ?)`)
|
|
7559
|
+
.run(slug);
|
|
7560
|
+
forgetVerticalScopeStores(switchSqlOf(this.directory), slug);
|
|
7561
|
+
forgetVerticalWireAuth(switchSqlOf(this.directory), slug);
|
|
7562
|
+
this.directory.prepare('DELETE FROM vertical_versions WHERE vertical_slug = ?').run(slug);
|
|
7563
|
+
const changed = this.directory.prepare('DELETE FROM verticals WHERE slug = ?').run(slug).changes > 0;
|
|
7564
|
+
if (changed)
|
|
7565
|
+
closeVerticalOwnerRow(redactionSqlOf(this.directory), slug, verticalOwnerNow());
|
|
7566
|
+
return changed;
|
|
7567
|
+
}, () => this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.ownerTenant }, null));
|
|
7568
|
+
},
|
|
7569
|
+
listVerticalOwners: async (actor, slug) => {
|
|
7570
|
+
const periods = listVerticalOwnerRows(redactionSqlOf(this.directory), slug);
|
|
7571
|
+
this.recordAccess(actor, 'listVerticalOwners', { tenantId: null }, { slug }, periods.length);
|
|
7572
|
+
return periods;
|
|
7573
|
+
},
|
|
7574
|
+
// #2528: the admission is judged, and the row's `before` read, in the transaction that writes.
|
|
7575
|
+
admitVersion: async (actor, versionId) => {
|
|
7576
|
+
let row = { before: null, after: null };
|
|
7577
|
+
this.directoryWriteAndAudit(() => {
|
|
7578
|
+
const v = versionAdmission(versionId);
|
|
7579
|
+
if (v.admission === 'admitted') {
|
|
7580
|
+
// Idempotent — except an AUTO-admitted version, which this upgrades to a
|
|
7581
|
+
// manual vouch by clearing the note (what the publish seam requires).
|
|
7582
|
+
if (v.admission_note !== AUTO_ADMISSION_NOTE)
|
|
7583
|
+
return false;
|
|
7584
|
+
row = { before: { admission: v.admission, note: v.admission_note }, after: { admission: 'admitted', note: null } };
|
|
7585
|
+
return this.directory.prepare('UPDATE vertical_versions SET admission_note = NULL WHERE id = ?').run(versionId).changes > 0;
|
|
7586
|
+
}
|
|
7587
|
+
if (v.admission === 'rejected') {
|
|
7588
|
+
throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
|
|
7589
|
+
}
|
|
7590
|
+
row = { before: { admission: v.admission }, after: { admission: 'admitted' } };
|
|
7591
|
+
return this.directory.prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?").run(versionId).changes > 0;
|
|
7592
|
+
}, () => this.recordAdmin(actor, 'admitVersion', { tenantId: null }, row.before, row.after));
|
|
7061
7593
|
},
|
|
7062
7594
|
rejectVersion: async (actor, versionId, note) => {
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
.
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
note,
|
|
7077
|
-
});
|
|
7595
|
+
let before = null;
|
|
7596
|
+
this.directoryWriteAndAudit(() => {
|
|
7597
|
+
const v = versionAdmission(versionId);
|
|
7598
|
+
if (v.admission === 'admitted') {
|
|
7599
|
+
throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
|
|
7600
|
+
}
|
|
7601
|
+
if (v.admission === 'rejected')
|
|
7602
|
+
return false; // idempotent
|
|
7603
|
+
before = { admission: v.admission };
|
|
7604
|
+
return this.directory
|
|
7605
|
+
.prepare("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?")
|
|
7606
|
+
.run(note, versionId).changes > 0;
|
|
7607
|
+
}, () => this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, before, { admission: 'rejected', note }));
|
|
7078
7608
|
},
|
|
7079
7609
|
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
7080
7610
|
const incoming = await readVersion(versionId);
|
|
@@ -7158,8 +7688,9 @@ export class SqliteScopeHost {
|
|
|
7158
7688
|
// #2333: a compare-and-set. Every gate above — the review binding, the digest checkpoints,
|
|
7159
7689
|
// the export breaks — was judged against `outgoing`, across awaits another promote can run
|
|
7160
7690
|
// in. So the pointer moves only if it still names `outgoing`, read and written in one
|
|
7161
|
-
// transaction; the promote that lost the race is refused as stale and writes nothing.
|
|
7162
|
-
|
|
7691
|
+
// transaction; the promote that lost the race is refused as stale and writes nothing. Its admin
|
|
7692
|
+
// row commits with the move, ahead of the rebinds below, which record their own.
|
|
7693
|
+
const moved = this.directoryWriteAndAudit(() => {
|
|
7163
7694
|
const now = this.directory
|
|
7164
7695
|
.prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
|
|
7165
7696
|
.get(verticalSlug, channel);
|
|
@@ -7178,7 +7709,10 @@ export class SqliteScopeHost {
|
|
|
7178
7709
|
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
7179
7710
|
.run(ulid(), verticalSlug, channel, versionId, outgoing?.id ?? null, actor, promotedAt);
|
|
7180
7711
|
return true;
|
|
7181
|
-
}
|
|
7712
|
+
},
|
|
7713
|
+
// The acknowledgement is recorded, not just enforced: that is what turns
|
|
7714
|
+
// "someone reviewed the permission change" into evidence.
|
|
7715
|
+
() => this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
|
|
7182
7716
|
if (!moved)
|
|
7183
7717
|
throw promoteRaceRefusal(channel);
|
|
7184
7718
|
// For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
|
|
@@ -7208,9 +7742,6 @@ export class SqliteScopeHost {
|
|
|
7208
7742
|
.prepare('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ?')
|
|
7209
7743
|
.run(versionId, verticalSlug, s.scope_id).changes > 0, () => this.recordAdmin(actor, 'bindScopeVersion', { tenantId: s.tenant_id, scopeId: s.scope_id }, prev ? { versionId: prev.id, version: prev.version } : null, { versionId, vertical: verticalSlug, version: incoming.version, via: 'promoteVersion' }));
|
|
7210
7744
|
}
|
|
7211
|
-
// The acknowledgement is recorded, not just enforced: that is what turns
|
|
7212
|
-
// "someone reviewed the permission change" into evidence.
|
|
7213
|
-
this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
|
|
7214
7745
|
},
|
|
7215
7746
|
listChannels: async (actor, verticalSlug, page) => {
|
|
7216
7747
|
// `prod` is the only live channel (#509 retired dev/staging). Filtering here keeps a
|
|
@@ -7482,11 +8013,27 @@ export class SqliteScopeHost {
|
|
|
7482
8013
|
throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
|
|
7483
8014
|
// #2178: every install on the serving script runs the new version from this moment.
|
|
7484
8015
|
this.assertNoStrandFrom([r.serving_version_id], parsed.versionId, r.serving_ref ? this.provisionedVersionsOf(verticalSlug, r.serving_version_id ? { ref: r.serving_ref, versionId: r.serving_version_id } : null, null) : []);
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
8016
|
+
// #2528: a compare-and-set on the serving ref and version the gate judged
|
|
8017
|
+
// (`SERVES_NOW_CONDITION`), and the row's `before` read in the transaction that moves it,
|
|
8018
|
+
// as the Cloudflare host does.
|
|
8019
|
+
let before = null;
|
|
8020
|
+
let raced = false;
|
|
8021
|
+
this.directoryWriteAndAudit(() => {
|
|
8022
|
+
const now = this.directory
|
|
8023
|
+
.prepare('SELECT serving_ref, serving_version_id FROM verticals WHERE slug = ?')
|
|
8024
|
+
.get(verticalSlug);
|
|
8025
|
+
if (!now)
|
|
8026
|
+
throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
|
|
8027
|
+
before = now.serving_ref ? { ref: now.serving_ref, versionId: now.serving_version_id } : null;
|
|
8028
|
+
const moved = this.directory
|
|
8029
|
+
.prepare(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
|
|
8030
|
+
serving_do_classes = ?, serving_migration_tag = ? WHERE ${SERVES_NOW_CONDITION}`)
|
|
8031
|
+
.run(parsed.ref, parsed.versionId, JSON.stringify(parsed.doClasses), parsed.migrationTag, verticalSlug, r.serving_ref ?? null, r.serving_version_id ?? null).changes > 0;
|
|
8032
|
+
raced = !moved;
|
|
8033
|
+
return moved;
|
|
8034
|
+
}, () => this.recordAdmin(actor, 'setVerticalServing', { tenantId: null }, before, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }));
|
|
8035
|
+
if (raced)
|
|
8036
|
+
throw servingRaceRefusal(verticalSlug);
|
|
7490
8037
|
},
|
|
7491
8038
|
versionManifest: async (actor, verticalSlug, versionId) => {
|
|
7492
8039
|
const v = this.directory
|
|
@@ -7506,6 +8053,19 @@ export class SqliteScopeHost {
|
|
|
7506
8053
|
this.recordAccess(actor, 'versionMigrations', {}, { verticalSlug, versionId }, v.migrations?.length ?? 0);
|
|
7507
8054
|
return v.migrations;
|
|
7508
8055
|
},
|
|
8056
|
+
recordWireAuth: async (actor, input) => {
|
|
8057
|
+
const row = deploymentWireAuth.parse(input);
|
|
8058
|
+
const written = this.directoryWriteAndAudit(() => writeDeploymentWireAuth(switchSqlOf(this.directory), row), () => this.recordAdmin(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
|
|
8059
|
+
if (!written)
|
|
8060
|
+
throw substratError('not_found', `no capability record for '${row.deploymentRef}': vertical '${row.verticalSlug}' is not registered`);
|
|
8061
|
+
},
|
|
8062
|
+
listWireAuth: async (actor, input) => {
|
|
8063
|
+
// The judged copy, not the caller's object: only its own fields, the reach first, reach the log.
|
|
8064
|
+
const filter = assertWireAuthFilter(input);
|
|
8065
|
+
const rows = deploymentWireAuthOf(switchSqlOf(this.directory), filter);
|
|
8066
|
+
this.recordAccess(actor, 'listWireAuth', wireAuthAccessTarget(filter), filter, rows.length);
|
|
8067
|
+
return rows;
|
|
8068
|
+
},
|
|
7509
8069
|
setScopeServingRef: async (actor, tenantId, scopeId, servingRef, opts) => {
|
|
7510
8070
|
const scope = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
7511
8071
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
@@ -7555,14 +8115,9 @@ export class SqliteScopeHost {
|
|
|
7555
8115
|
// operation's uncommitted event, the drain shipped it, and the rollback left the lake
|
|
7556
8116
|
// — which is append-only — holding an event the scope never had. The mark that
|
|
7557
8117
|
// follows needs no such care: it stamps only rows that exist and are undrained.
|
|
7558
|
-
const
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
7562
|
-
// rather than stalling the scope on it (#1636).
|
|
7563
|
-
const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(assertRowLimit('limit', limit ?? 200), 1000));
|
|
7564
|
-
this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
|
|
7565
|
-
return undrainedEventsOf(read);
|
|
8118
|
+
const events = undrainedEventsFrom(this.scopeReadDbFor(tenantId, scopeId), limit ?? 200);
|
|
8119
|
+
this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, events.length);
|
|
8120
|
+
return events;
|
|
7566
8121
|
},
|
|
7567
8122
|
markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
|
|
7568
8123
|
// BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
|
|
@@ -7691,10 +8246,7 @@ export class SqliteScopeHost {
|
|
|
7691
8246
|
// auditor asking "who counted this tenant's outbox, and how much was there" wants.
|
|
7692
8247
|
if (countOnly) {
|
|
7693
8248
|
// The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
|
|
7694
|
-
const redrainable = this.scopeReadDbFor(tenantId, scopeId)
|
|
7695
|
-
.prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
|
|
7696
|
-
WHERE drained_at IS NOT NULL AND drained_at < ?`)
|
|
7697
|
-
.get(drainedBefore).c;
|
|
8249
|
+
const redrainable = redrainCountOf(this.scopeReadDbFor(tenantId, scopeId), drainedBefore);
|
|
7698
8250
|
this.recordAccess(actor, 'redrainEvents', { tenantId, scopeId }, { drainedBefore, countOnly: true }, redrainable);
|
|
7699
8251
|
return redrainable;
|
|
7700
8252
|
}
|
|
@@ -7796,20 +8348,12 @@ export class SqliteScopeHost {
|
|
|
7796
8348
|
this.recordAccess(actor, 'operationSeries', { tenantId, scopeId }, delegatedReadParams.operationSeries(input), operationSeriesCount(series));
|
|
7797
8349
|
return series;
|
|
7798
8350
|
},
|
|
8351
|
+
// Every row, newest first (#2553); `rowid DESC` orders migrations stamped in one millisecond.
|
|
7799
8352
|
scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
|
|
7800
8353
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
7801
|
-
const
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
.all();
|
|
7805
|
-
this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, rows.length);
|
|
7806
|
-
return rows.map((r) => ({
|
|
7807
|
-
moduleId: r.module_id,
|
|
7808
|
-
version: r.version,
|
|
7809
|
-
appliedAt: r.applied_at ?? null,
|
|
7810
|
-
durationMs: r.duration_ms,
|
|
7811
|
-
rowsChanged: r.rows_changed,
|
|
7812
|
-
}));
|
|
8354
|
+
const migrations = appliedMigrationsOf(db);
|
|
8355
|
+
this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, migrations.length);
|
|
8356
|
+
return migrations;
|
|
7813
8357
|
},
|
|
7814
8358
|
scopeMigrationBookmarks: async (actor, tenantId, scopeId) => {
|
|
7815
8359
|
const scope = this.directory
|
|
@@ -7826,10 +8370,7 @@ export class SqliteScopeHost {
|
|
|
7826
8370
|
scopeDatabaseSize: async (actor, tenantId, scopeId) => {
|
|
7827
8371
|
// #1524: the SQLite twin of a DO's `databaseSize`. It counts allocated pages,
|
|
7828
8372
|
// free ones included, which is what the file costs on disk, not the live row bytes.
|
|
7829
|
-
const
|
|
7830
|
-
const pages = db.pragma('page_count', { simple: true });
|
|
7831
|
-
const pageSize = db.pragma('page_size', { simple: true });
|
|
7832
|
-
const bytes = pages * pageSize;
|
|
8373
|
+
const bytes = databaseBytesOf(this.scopeReadDbFor(tenantId, scopeId));
|
|
7833
8374
|
this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
|
|
7834
8375
|
return bytes;
|
|
7835
8376
|
},
|
|
@@ -7838,6 +8379,11 @@ export class SqliteScopeHost {
|
|
|
7838
8379
|
this.recordAccess(actor, 'scopeStoreSizes', { tenantId, scopeId }, null, sizes.length);
|
|
7839
8380
|
return sizes;
|
|
7840
8381
|
},
|
|
8382
|
+
scopeAttachmentBytes: async (actor, tenantId, scopeId) => {
|
|
8383
|
+
const bytes = attachmentBytesOf(this.scopeReadDbFor(tenantId, scopeId));
|
|
8384
|
+
this.recordAccess(actor, 'scopeAttachmentBytes', { tenantId, scopeId }, null, 1);
|
|
8385
|
+
return bytes;
|
|
8386
|
+
},
|
|
7841
8387
|
rewindScope: async () => {
|
|
7842
8388
|
throw new Error('point-in-time rewind is not available on this host (PITR is a Durable-Object-plane mechanism) — use the backup restore path');
|
|
7843
8389
|
},
|
|
@@ -7960,18 +8506,18 @@ export class SqliteScopeHost {
|
|
|
7960
8506
|
const slugOwner = this.directory
|
|
7961
8507
|
.prepare('SELECT tenant_id FROM tenants WHERE slug = ?')
|
|
7962
8508
|
.get(parsed.slug);
|
|
8509
|
+
// #2556: the refusal never names the owner. Another tenant holds it, and the text
|
|
8510
|
+
// reaches whoever asked (a manager vertical's intent journal among them).
|
|
7963
8511
|
if (slugOwner) {
|
|
7964
|
-
throw substratError('conflict', `tenant slug '${parsed.slug}' already taken
|
|
8512
|
+
throw substratError('conflict', `tenant slug '${parsed.slug}' is already taken (slugs are unique)`);
|
|
7965
8513
|
}
|
|
7966
|
-
this.directory
|
|
8514
|
+
this.directoryWriteAndAudit(() => this.directory
|
|
7967
8515
|
.prepare(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
|
|
7968
8516
|
VALUES (?, ?, ?, 'active', ?, ?)`)
|
|
7969
|
-
.run(parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null);
|
|
7970
|
-
this.recordAdmin(actor, 'createTenant', { tenantId: parsed.id }, null, readTenant(parsed.id));
|
|
8517
|
+
.run(parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null).changes > 0, () => this.recordAdmin(actor, 'createTenant', { tenantId: parsed.id }, null, readTenant(parsed.id)));
|
|
7971
8518
|
},
|
|
7972
8519
|
setTenantStatus: async (actor, tenantId, status) => {
|
|
7973
|
-
|
|
7974
|
-
if (!before)
|
|
8520
|
+
if (!readTenant(tenantId))
|
|
7975
8521
|
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
7976
8522
|
// `reaped` is terminal and destroys data — it is unreachable here and only
|
|
7977
8523
|
// ever set by reapTenant, so a plain status flip cannot forge a tombstone
|
|
@@ -7981,27 +8527,33 @@ export class SqliteScopeHost {
|
|
|
7981
8527
|
}
|
|
7982
8528
|
// Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
|
|
7983
8529
|
// entering `deleting` stamps, leaving it (an un-delete) clears — exactly the
|
|
7984
|
-
// shape suspendScope/archiveScope use for archived_at.
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
.
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
8530
|
+
// shape suspendScope/archiveScope use for archived_at. #2528: the row's `before` is the
|
|
8531
|
+
// status the write's own transaction reads.
|
|
8532
|
+
let before;
|
|
8533
|
+
this.directoryWriteAndAudit(() => {
|
|
8534
|
+
before = readTenant(tenantId);
|
|
8535
|
+
if (!before)
|
|
8536
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
8537
|
+
return (status === 'deleting'
|
|
8538
|
+
? this.directory
|
|
8539
|
+
.prepare('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?')
|
|
8540
|
+
.run(status, new Date().toISOString(), tenantId)
|
|
8541
|
+
: this.directory
|
|
8542
|
+
.prepare('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?')
|
|
8543
|
+
.run(status, tenantId)).changes > 0;
|
|
8544
|
+
}, () => this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before.status }, { status }));
|
|
7996
8545
|
},
|
|
7997
8546
|
setTenantName: async (actor, tenantId, name) => {
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8547
|
+
// A no-op rename is not audited — nothing changed. #2528: judged, and `before` read, in the
|
|
8548
|
+
// write's own transaction.
|
|
8549
|
+
let before;
|
|
8550
|
+
this.directoryWriteAndAudit(() => {
|
|
8551
|
+
before = readTenant(tenantId);
|
|
8552
|
+
if (!before)
|
|
8553
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
8554
|
+
return before.name !== name
|
|
8555
|
+
&& this.directory.prepare('UPDATE tenants SET name = ? WHERE tenant_id = ?').run(name, tenantId).changes > 0;
|
|
8556
|
+
}, () => this.recordAdmin(actor, 'setTenantName', { tenantId }, { name: before.name }, { name }));
|
|
8005
8557
|
},
|
|
8006
8558
|
listTenants: async (actor, page) => {
|
|
8007
8559
|
const where = [];
|
|
@@ -8019,32 +8571,15 @@ export class SqliteScopeHost {
|
|
|
8019
8571
|
return t;
|
|
8020
8572
|
},
|
|
8021
8573
|
listScopes: async (actor, filter) => {
|
|
8022
|
-
const
|
|
8023
|
-
const params = [];
|
|
8024
|
-
if (filter?.tenantId) {
|
|
8025
|
-
where.push('tenant_id = ?');
|
|
8026
|
-
params.push(filter.tenantId);
|
|
8027
|
-
}
|
|
8028
|
-
if (filter?.status) {
|
|
8029
|
-
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
8030
|
-
// An empty array means "no status is acceptable" — match nothing, rather
|
|
8031
|
-
// than degenerating into an unfiltered read of the whole fleet.
|
|
8032
|
-
if (statuses.length === 0)
|
|
8033
|
-
return [];
|
|
8034
|
-
// One JSON array, the shape the DO twin needs for its 100-parameter limit (#1776).
|
|
8035
|
-
where.push('status IN (SELECT value FROM json_each(?))');
|
|
8036
|
-
params.push(JSON.stringify(statuses));
|
|
8037
|
-
}
|
|
8038
|
-
if (filter?.vertical) {
|
|
8039
|
-
where.push('vertical = ?');
|
|
8040
|
-
params.push(filter.vertical);
|
|
8041
|
-
}
|
|
8042
|
-
const tail = keysetTail(where, params, 'scope_id', filter);
|
|
8043
|
-
const sql = 'SELECT * FROM scopes' + (where.length ? ` WHERE ${where.join(' AND ')}` : '') + tail;
|
|
8044
|
-
const scopes = this.directory.prepare(sql).all(...params).map(mapScope);
|
|
8574
|
+
const scopes = scopeRows(filter).map(mapScope);
|
|
8045
8575
|
this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, scopes.length);
|
|
8046
8576
|
return scopes;
|
|
8047
8577
|
},
|
|
8578
|
+
listScopesReadable: async (actor, filter) => {
|
|
8579
|
+
const rows = scopeRows(filter);
|
|
8580
|
+
this.recordAccess(actor, 'listScopesReadable', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
8581
|
+
return readableScopes(rows, mapScope);
|
|
8582
|
+
},
|
|
8048
8583
|
listTenantStores: async (actor, filter) => {
|
|
8049
8584
|
const where = [];
|
|
8050
8585
|
const params = [];
|
|
@@ -8165,6 +8700,9 @@ export class SqliteScopeHost {
|
|
|
8165
8700
|
recordStoreDriverRebuildRun: async (actor, entry) => {
|
|
8166
8701
|
this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry, this.clock());
|
|
8167
8702
|
},
|
|
8703
|
+
recordWireWalk: async (actor, entry) => {
|
|
8704
|
+
this.recordAdmin(actor, 'walkWireAuth', { tenantId: null }, null, entry, this.clock());
|
|
8705
|
+
},
|
|
8168
8706
|
getScopeRecord: async (actor, tenantId, scopeId) => {
|
|
8169
8707
|
const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
8170
8708
|
// Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
|
|
@@ -8179,65 +8717,26 @@ export class SqliteScopeHost {
|
|
|
8179
8717
|
// K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
|
|
8180
8718
|
// a scope under a different tenant is unreachable, never another tenant's DB.
|
|
8181
8719
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
8182
|
-
const
|
|
8183
|
-
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
|
8184
|
-
.all().map((r) => r.name);
|
|
8185
|
-
const tables = names.map((name) => ({
|
|
8186
|
-
name,
|
|
8187
|
-
rowCount: db.prepare(`SELECT COUNT(*) AS c FROM "${name}"`).get().c,
|
|
8188
|
-
system: isSystemTable(name),
|
|
8189
|
-
}));
|
|
8720
|
+
const tables = scopeTablesOf(db);
|
|
8190
8721
|
this.recordAccess(actor, 'listScopeTables', { tenantId, scopeId }, null, tables.length);
|
|
8191
8722
|
return tables;
|
|
8192
8723
|
},
|
|
8193
8724
|
readScopeTable: async (actor, tenantId, scopeId, input) => {
|
|
8194
8725
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
8195
|
-
|
|
8196
|
-
|
|
8197
|
-
|
|
8198
|
-
const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
|
|
8199
|
-
if (!known.has(input.table))
|
|
8200
|
-
throw substratError('not_found', `unknown table '${input.table}'`);
|
|
8201
|
-
// The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
|
|
8202
|
-
// negative) is refused instead of reaching LIMIT / OFFSET (#1632).
|
|
8203
|
-
const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
|
|
8204
|
-
const offset = assertRowOffset('offset', input.offset ?? 0);
|
|
8205
|
-
const stmt = db.prepare(`SELECT * FROM "${input.table}" LIMIT ? OFFSET ?`).raw(true);
|
|
8206
|
-
const rows = stmt.all(limit, offset).map((row) => row.map(cellToJson));
|
|
8207
|
-
const columns = stmt.columns().map((c) => c.name);
|
|
8208
|
-
const rowCount = db.prepare(`SELECT COUNT(*) AS c FROM "${input.table}"`).get().c;
|
|
8209
|
-
this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit, offset }, rows.length);
|
|
8210
|
-
return { table: input.table, columns, rows, rowCount, limit, offset };
|
|
8726
|
+
const page = scopeTablePageOf(db, input);
|
|
8727
|
+
this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
|
|
8728
|
+
return page;
|
|
8211
8729
|
},
|
|
8212
8730
|
queryScope: async (actor, tenantId, scopeId, input) => {
|
|
8213
8731
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
8214
|
-
|
|
8215
|
-
//
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
// itself rejects multi-statement strings and bad SQL with the driver's message.
|
|
8219
|
-
const stmt = db.prepare(sql);
|
|
8220
|
-
if (!stmt.readonly)
|
|
8221
|
-
throw substratError('validation_failed', 'read-only console: statement is not read-only');
|
|
8222
|
-
if (!stmt.reader)
|
|
8223
|
-
return { columns: [], rows: [], truncated: false };
|
|
8224
|
-
stmt.raw(true);
|
|
8225
|
-
const rows = [];
|
|
8226
|
-
let truncated = false;
|
|
8227
|
-
// Iterate rather than .all(): the row cap bounds what leaves the DB, so an
|
|
8228
|
-
// over-broad SELECT costs a screenful, not the table.
|
|
8229
|
-
for (const row of stmt.iterate()) {
|
|
8230
|
-
if (rows.length >= SCOPE_QUERY_ROW_MAX) {
|
|
8231
|
-
truncated = true;
|
|
8232
|
-
break;
|
|
8233
|
-
}
|
|
8234
|
-
rows.push(row.map(cellToJson));
|
|
8235
|
-
}
|
|
8236
|
-
const columns = stmt.columns().map((c) => c.name);
|
|
8732
|
+
const { sql, reader, result } = scopeQueryOf(db, input);
|
|
8733
|
+
// A statement that reads nothing returns before any row is recorded, as it always has.
|
|
8734
|
+
if (!reader)
|
|
8735
|
+
return result;
|
|
8237
8736
|
// The statement itself is the logged argument — the K-24 access log is the
|
|
8238
8737
|
// evidence trail for what staff read, and here the read IS the SQL.
|
|
8239
|
-
this.recordAccess(actor, 'queryScope', { tenantId, scopeId }, { sql }, rows.length);
|
|
8240
|
-
return
|
|
8738
|
+
this.recordAccess(actor, 'queryScope', { tenantId, scopeId }, { sql }, result.rows.length);
|
|
8739
|
+
return result;
|
|
8241
8740
|
},
|
|
8242
8741
|
// -- the denial log (K-35, #867) -------------------------------------
|
|
8243
8742
|
// Same shape as the introspection reads above: `scopeDbFor` is the K-3
|
|
@@ -8659,13 +9158,9 @@ export class SqliteScopeHost {
|
|
|
8659
9158
|
// caller (the reap route / grace-window sweep) has already reaped every scope's
|
|
8660
9159
|
// storage above the kernel; this clears the tenant's directory-side PII/config
|
|
8661
9160
|
// and flips the row to a `reaped` tombstone. Only a `deleting` tenant may be
|
|
8662
|
-
// reaped — an illegal source fails closed, like reapScope's archived-only gate.
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
8666
|
-
if (before.status !== 'deleting') {
|
|
8667
|
-
throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
|
|
8668
|
-
}
|
|
9161
|
+
// reaped — an illegal source fails closed, like reapScope's archived-only gate. #2528: that
|
|
9162
|
+
// gate and the row's `before` are read in the transaction that reaps.
|
|
9163
|
+
let before;
|
|
8669
9164
|
// Clear the tenant's directory-side rows. Idempotent (set-to-empty), so a crash
|
|
8670
9165
|
// mid-reap converges on retry. KEPT as the tombstone: the `tenants` row itself
|
|
8671
9166
|
// and `_substrat_admin_log` (the compliance witness — never swept). Scope rows
|
|
@@ -8687,23 +9182,28 @@ export class SqliteScopeHost {
|
|
|
8687
9182
|
'_substrat_scope_storage', // #1524: storage samples of scopes whose storage is gone
|
|
8688
9183
|
'_substrat_scope_storage_attempts', // #1524: and the phase's last try at each
|
|
8689
9184
|
'_substrat_scope_store_storage', // #2212: scope-store samples, gone with the tenant
|
|
9185
|
+
'_substrat_scope_attachment_storage', // #2501: attachment samples, gone with the tenant
|
|
8690
9186
|
];
|
|
8691
|
-
|
|
9187
|
+
this.directoryWriteAndAudit(() => {
|
|
9188
|
+
before = readTenant(tenantId);
|
|
9189
|
+
if (!before)
|
|
9190
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
9191
|
+
if (before.status !== 'deleting') {
|
|
9192
|
+
throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
|
|
9193
|
+
}
|
|
8692
9194
|
for (const table of tables) {
|
|
8693
9195
|
this.directory.prepare(`DELETE FROM ${table} WHERE tenant_id = ?`).run(tenantId);
|
|
8694
9196
|
}
|
|
8695
|
-
this.directory
|
|
9197
|
+
return this.directory
|
|
8696
9198
|
.prepare(`UPDATE tenants SET status = 'reaped', deleting_at = NULL WHERE tenant_id = ?`)
|
|
8697
|
-
.run(tenantId);
|
|
8698
|
-
});
|
|
8699
|
-
clear();
|
|
9199
|
+
.run(tenantId).changes > 0;
|
|
9200
|
+
}, () => this.recordAdmin(actor, 'reapTenant', { tenantId }, { status: before.status }, { status: 'reaped' }));
|
|
8700
9201
|
// Evict the in-memory role cache for this tenant (the one directory table
|
|
8701
9202
|
// mirrored in memory) so a reaped role never resolves after the DELETE.
|
|
8702
9203
|
for (const cacheKey of [...this.roles.keys()]) {
|
|
8703
9204
|
if (cacheKey.startsWith(`${tenantId}/`))
|
|
8704
9205
|
this.roles.delete(cacheKey);
|
|
8705
9206
|
}
|
|
8706
|
-
this.recordAdmin(actor, 'reapTenant', { tenantId }, { status: before.status }, { status: 'reaped' });
|
|
8707
9207
|
},
|
|
8708
9208
|
grantEntitlement: async (actor, tenantId, entitlementKey, plan) => {
|
|
8709
9209
|
const input = entitlementGrantInput.parse(plan ?? {});
|
|
@@ -8796,6 +9296,7 @@ export class SqliteScopeHost {
|
|
|
8796
9296
|
storage: listScopeStorageRows(redactionSqlOf(this.directory), { tenantId: only, latest: true }),
|
|
8797
9297
|
storageAttempts: listScopeStorageAttemptRows(redactionSqlOf(this.directory), only),
|
|
8798
9298
|
storeStorage: listScopeStoreStorageRows(redactionSqlOf(this.directory), only),
|
|
9299
|
+
attachmentStorage: listScopeAttachmentStorageRows(redactionSqlOf(this.directory), only),
|
|
8799
9300
|
});
|
|
8800
9301
|
// The count that matters for K-24 is how many TENANTS this reading covered —
|
|
8801
9302
|
// "read the meter for one tenant" and "metered the whole fleet" are different
|
|
@@ -9449,8 +9950,9 @@ export class SqliteScopeHost {
|
|
|
9449
9950
|
params.push(filter.fingerprint);
|
|
9450
9951
|
}
|
|
9451
9952
|
if (filter?.reference) {
|
|
9452
|
-
|
|
9453
|
-
|
|
9953
|
+
const byReference = opsFailureReferenceWhere(filter.reference, filter.referenceInText === true);
|
|
9954
|
+
where.push(byReference.sql);
|
|
9955
|
+
params.push(...byReference.params);
|
|
9454
9956
|
}
|
|
9455
9957
|
if (filter?.since) {
|
|
9456
9958
|
where.push('at >= ?');
|
|
@@ -9683,6 +10185,33 @@ export class SqliteScopeHost {
|
|
|
9683
10185
|
return rows;
|
|
9684
10186
|
},
|
|
9685
10187
|
pruneScopeStorage: async (_actor, limit) => pruneScopeStorageRows(redactionSqlOf(this.directory), Date.now(), limit),
|
|
10188
|
+
recordRequestDay: async (_actor, input) => ({
|
|
10189
|
+
recorded: this.directory.transaction(() => recordRequestDayRows(redactionSqlOf(this.directory), input))(),
|
|
10190
|
+
}),
|
|
10191
|
+
listRequestDays: async (actor, filter) => {
|
|
10192
|
+
const rows = listRequestDayRows(redactionSqlOf(this.directory), filter);
|
|
10193
|
+
this.recordAccess(actor, 'listRequestDays', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter ?? null, rows.length);
|
|
10194
|
+
return rows;
|
|
10195
|
+
},
|
|
10196
|
+
listRequestRollupDays: async (actor, filter) => {
|
|
10197
|
+
const rows = listRequestRollupDayRows(redactionSqlOf(this.directory), filter);
|
|
10198
|
+
this.recordAccess(actor, 'listRequestRollupDays', { tenantId: null }, filter ?? null, rows.length);
|
|
10199
|
+
return rows;
|
|
10200
|
+
},
|
|
10201
|
+
pruneRequestDays: async (_actor, limit, retentionMonths) => this.directory.transaction(() => pruneRequestDayRows(redactionSqlOf(this.directory), Date.now(), limit, retentionMonths))(),
|
|
10202
|
+
// #2501: the snapshot reads and writes in one transaction, so its rows are one instant's reading.
|
|
10203
|
+
recordMeterDay: async (_actor, input) => this.directory.transaction(() => recordMeterDayRows(redactionSqlOf(this.directory), input))(),
|
|
10204
|
+
listMeterDays: async (actor, filter) => {
|
|
10205
|
+
const rows = listMeterDayRows(redactionSqlOf(this.directory), filter);
|
|
10206
|
+
this.recordAccess(actor, 'listMeterDays', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
10207
|
+
return rows;
|
|
10208
|
+
},
|
|
10209
|
+
listMeterDayTakes: async (actor, filter) => {
|
|
10210
|
+
const rows = listMeterDayTakeRows(redactionSqlOf(this.directory), filter);
|
|
10211
|
+
this.recordAccess(actor, 'listMeterDayTakes', { tenantId: null }, filter ?? null, rows.length);
|
|
10212
|
+
return rows;
|
|
10213
|
+
},
|
|
10214
|
+
pruneMeterDays: async (_actor, limit, retentionMonths) => this.directory.transaction(() => pruneMeterDayRows(redactionSqlOf(this.directory), Date.now(), limit, retentionMonths))(),
|
|
9686
10215
|
listModelUsage: async (actor, filter) => {
|
|
9687
10216
|
const rows = this.selectModelUsage(filter);
|
|
9688
10217
|
this.recordAccess(actor, 'listModelUsage', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
@@ -10034,6 +10563,8 @@ export class SqliteScopeHost {
|
|
|
10034
10563
|
}
|
|
10035
10564
|
for (const stmt of LEGACY_SCOPE_ROWS_BACKFILL)
|
|
10036
10565
|
this.directory.exec(stmt);
|
|
10566
|
+
// #2501: an ownership period for every vertical registered before the history existed.
|
|
10567
|
+
this.directory.exec(VERTICAL_OWNERS_BACKFILL);
|
|
10037
10568
|
// Created after the backfill: a UNIQUE index over NULL slugs would permit the
|
|
10038
10569
|
// duplicates it exists to forbid (SQLite treats NULLs as distinct).
|
|
10039
10570
|
//
|
|
@@ -10364,9 +10895,7 @@ export class SqliteScopeHost {
|
|
|
10364
10895
|
const pending = rt.db
|
|
10365
10896
|
.prepare(`SELECT COUNT(*) AS c FROM _substrat_platform_requests WHERE status = 'pending'`)
|
|
10366
10897
|
.get().c;
|
|
10367
|
-
|
|
10368
|
-
throw new Error(`too many pending platform requests (${pending}); retry once some have drained`);
|
|
10369
|
-
}
|
|
10898
|
+
assertPlatformRequestRoom(pending);
|
|
10370
10899
|
const id = platformRequestId.parse(ulid());
|
|
10371
10900
|
const requestedBy = overrideActor ?? actorOf(subject);
|
|
10372
10901
|
rt.db
|