@substrat-run/adapter-sqlite 0.120.0 → 0.122.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 +39 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +406 -72
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
|
-
import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, callsOfManifestJson, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, 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, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, switchSystemSchedules, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, switchPeer, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, } from '@substrat-run/kernel';
|
|
4
|
+
import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, callsOfManifestJson, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, guardSqlLimits, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, 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, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, switchSystemSchedules, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, switchPeer, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, switchedOffModulesOf, systemSwitchRecordsOf, systemSwitchesTableExists, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, } from '@substrat-run/kernel';
|
|
6
6
|
import { ScopeActor } from './actor.js';
|
|
7
7
|
import { createTupleChecker } from './checker.js';
|
|
8
|
+
/**
|
|
9
|
+
* A fault in the database itself (full, corrupt, I/O), as opposed to one statement failing.
|
|
10
|
+
* The #1764 backfill logs and opens past a failed batch, but never past one of these.
|
|
11
|
+
*/
|
|
12
|
+
export function isStorageFault(err) {
|
|
13
|
+
const code = err?.code;
|
|
14
|
+
return typeof code === 'string' && /^SQLITE_(FULL|CORRUPT|IOERR|NOTADB)/.test(code);
|
|
15
|
+
}
|
|
8
16
|
/** The kernel's schedule-switch SQL (#1666), over one scope's database handle. */
|
|
9
17
|
const switchSqlOf = (db) => ({
|
|
10
18
|
all: (sql, ...params) => db.prepare(sql).all(...params),
|
|
@@ -512,7 +520,7 @@ export class SqliteScopeHost {
|
|
|
512
520
|
mkdirSync(this.dir, { recursive: true });
|
|
513
521
|
this.directory = new Database(join(this.dir, '_directory.sqlite'));
|
|
514
522
|
this.directory.pragma('journal_mode = WAL');
|
|
515
|
-
this.
|
|
523
|
+
this.ensureDirectorySchema();
|
|
516
524
|
this.loadRoles();
|
|
517
525
|
this.checker =
|
|
518
526
|
options.checker ??
|
|
@@ -723,10 +731,16 @@ export class SqliteScopeHost {
|
|
|
723
731
|
admission_note TEXT,
|
|
724
732
|
-- The pushed DeployManifest (JSON) — what a serve rebuilds upload metadata
|
|
725
733
|
-- from (#286). NULL = pre-#286 push, archivable but never served in place.
|
|
734
|
+
-- Stored WITHOUT its SQL migrations, which live in vertical_version_migrations (#1764).
|
|
726
735
|
manifest_json TEXT,
|
|
736
|
+
-- How many migration rows the version stored. NULL = it carries no SQL to show.
|
|
737
|
+
migration_count INTEGER,
|
|
738
|
+
-- 1 once the SQL is out of manifest_json. NULL = stored before #1764, not yet backfilled.
|
|
739
|
+
migrations_split INTEGER,
|
|
727
740
|
created_at TEXT NOT NULL,
|
|
728
741
|
UNIQUE (vertical_slug, version)
|
|
729
742
|
);
|
|
743
|
+
${VERSION_MIGRATIONS_DDL}
|
|
730
744
|
-- Channels (#31 step 2): a named pointer per vertical. Promotion moves it,
|
|
731
745
|
-- and promotion is where the migration and permission diffs fire — the
|
|
732
746
|
-- moment a change reaches anyone, rather than the moment it was typed.
|
|
@@ -968,6 +982,7 @@ export class SqliteScopeHost {
|
|
|
968
982
|
PRIMARY KEY (scope_id, subject_id)
|
|
969
983
|
);
|
|
970
984
|
${IMPERSONATION_DDL}
|
|
985
|
+
${SYSTEM_SWITCHES_DDL}
|
|
971
986
|
CREATE TABLE IF NOT EXISTS _substrat_admin_log (
|
|
972
987
|
id TEXT PRIMARY KEY,
|
|
973
988
|
actor TEXT NOT NULL,
|
|
@@ -1101,6 +1116,48 @@ export class SqliteScopeHost {
|
|
|
1101
1116
|
`);
|
|
1102
1117
|
this.ensureDirectoryColumns();
|
|
1103
1118
|
}
|
|
1119
|
+
/**
|
|
1120
|
+
* `applyDirectorySchema`, plus the backfill of the schedule switch's record (#1674) from
|
|
1121
|
+
* the admin log. Whether the table exists is asked BEFORE the DDL creates it, so the
|
|
1122
|
+
* backfill runs only on an application that creates the table: once on a directory that
|
|
1123
|
+
* never had it, and again after a directory restore whose dump predates it (from that
|
|
1124
|
+
* dump's own log). A directory that already holds the table is never backfilled over.
|
|
1125
|
+
*
|
|
1126
|
+
* One transaction (Copilot review, #1674): the table and its backfill commit together, so
|
|
1127
|
+
* a backfill that fails leaves no table behind, and the next start tries again rather than
|
|
1128
|
+
* reading an empty table as already migrated.
|
|
1129
|
+
*/
|
|
1130
|
+
ensureDirectorySchema() {
|
|
1131
|
+
this.directory.transaction(() => {
|
|
1132
|
+
const switchRecordIsNew = !systemSwitchesTableExists(switchSqlOf(this.directory));
|
|
1133
|
+
// #1764: `VERSION_MIGRATIONS_DDL` indexes these two columns, so a directory created
|
|
1134
|
+
// before them needs them before `applyDirectorySchema` reaches that statement.
|
|
1135
|
+
if (this.directory.prepare('PRAGMA table_info(vertical_versions)').all().length > 0) {
|
|
1136
|
+
this.ensureColumn(this.directory, 'vertical_versions', 'migration_count', 'migration_count INTEGER');
|
|
1137
|
+
this.ensureColumn(this.directory, 'vertical_versions', 'migrations_split', 'migrations_split INTEGER');
|
|
1138
|
+
}
|
|
1139
|
+
this.applyDirectorySchema();
|
|
1140
|
+
if (switchRecordIsNew)
|
|
1141
|
+
this.directory.exec(SYSTEM_SWITCHES_BACKFILL_SQL);
|
|
1142
|
+
})();
|
|
1143
|
+
// #1764: move the SQL out of every version stored before the split. A batch per
|
|
1144
|
+
// transaction, the same resumable step the Durable-Object adapter runs per alarm. This
|
|
1145
|
+
// adapter has no constructor budget to protect, so it runs them all on open. A batch that
|
|
1146
|
+
// fails is logged and the rest left for the next open: the host must still open, and a
|
|
1147
|
+
// version the backfill has not reached reads from its manifest.
|
|
1148
|
+
const sql = switchSqlOf(this.directory);
|
|
1149
|
+
try {
|
|
1150
|
+
while (this.directory.transaction(() => splitVersionMigrationsBatch(sql))().more)
|
|
1151
|
+
;
|
|
1152
|
+
}
|
|
1153
|
+
catch (err) {
|
|
1154
|
+
// The directory's own storage failing is not a backfill problem, and must not be
|
|
1155
|
+
// opened past as one.
|
|
1156
|
+
if (isStorageFault(err))
|
|
1157
|
+
throw err;
|
|
1158
|
+
console.error('substrat: version-migrations backfill failed; unsplit versions read from their manifests', err);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1104
1161
|
registerExecutor(id, eventType, handler, retry) {
|
|
1105
1162
|
if (this.executors.has(id))
|
|
1106
1163
|
throw new Error(`executor '${id}' is already registered`);
|
|
@@ -1303,7 +1360,9 @@ export class SqliteScopeHost {
|
|
|
1303
1360
|
this.crossVertical.register(manifest, registration.imports);
|
|
1304
1361
|
this.modules.set(manifest.id, {
|
|
1305
1362
|
id: manifest.id,
|
|
1306
|
-
|
|
1363
|
+
// The order the kernel writes once (#1677): authored, then search, then list indexes.
|
|
1364
|
+
// The two derived sets are still computed above, for the duplicate-version refusals.
|
|
1365
|
+
migrations: moduleMigrations(registration),
|
|
1307
1366
|
consumers,
|
|
1308
1367
|
schedules: manifest.schedules ?? [],
|
|
1309
1368
|
freshness: manifest.freshness ?? [],
|
|
@@ -1484,12 +1543,38 @@ export class SqliteScopeHost {
|
|
|
1484
1543
|
const seat = seatScopeTuple(peer.subject, peer.relation, peer.object, null);
|
|
1485
1544
|
rt.db.prepare(seat.sql).run(...seat.params);
|
|
1486
1545
|
}
|
|
1546
|
+
// #1674: re-assert the recorded OFF positions after the seat, in the same turn
|
|
1547
|
+
// (`system-switch-record.ts`).
|
|
1548
|
+
this.reassertSwitchesInTurn(rt, actor, input.tenantId, input.scopeId);
|
|
1487
1549
|
});
|
|
1488
1550
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
1489
1551
|
if (!existing) {
|
|
1490
1552
|
this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
1491
1553
|
}
|
|
1492
1554
|
}
|
|
1555
|
+
/**
|
|
1556
|
+
* `HostAdmin.reassertSystemSwitches`' body (#1674), run inside a turn the caller already
|
|
1557
|
+
* holds on the scope's actor: every module the directory records OFF on the scope is
|
|
1558
|
+
* switched off again. Already off answers `changed: false` and writes nothing, and a
|
|
1559
|
+
* module the scope does not hold is left alone. Audited only when something moved.
|
|
1560
|
+
*/
|
|
1561
|
+
reassertSwitchesInTurn(rt, actor, tenantId, scopeId) {
|
|
1562
|
+
const at = new Date().toISOString();
|
|
1563
|
+
return switchedOffModulesOf(switchSqlOf(this.directory), tenantId, scopeId).map((moduleId) => {
|
|
1564
|
+
const outcome = rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId, scopeId, to: 'off', at }))();
|
|
1565
|
+
if (outcome.changed) {
|
|
1566
|
+
this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId }, null, {
|
|
1567
|
+
operationId: ulid(),
|
|
1568
|
+
moduleId,
|
|
1569
|
+
schedules: 'off',
|
|
1570
|
+
phase: 'applied',
|
|
1571
|
+
changed: true,
|
|
1572
|
+
permissions: outcome.permissions,
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
return { moduleId, held: outcome.held, changed: outcome.changed };
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1493
1578
|
async provisionTenantStore(actor, input) {
|
|
1494
1579
|
// Fail closed on an unknown/inactive tenant, exactly as provisionScope does: a store
|
|
1495
1580
|
// for a tenant that does not exist is the same "FK string" hole (§4.1). A store is
|
|
@@ -2078,6 +2163,7 @@ export class SqliteScopeHost {
|
|
|
2078
2163
|
// orphaned bytes with no record (the §9 hazard).
|
|
2079
2164
|
this.directory.prepare('DELETE FROM hostnames WHERE scope_id = ?').run(scopeId);
|
|
2080
2165
|
rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
|
|
2166
|
+
forgetSystemSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
2081
2167
|
this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
|
|
2082
2168
|
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
2083
2169
|
forkedFrom: rec.forkedFrom,
|
|
@@ -2411,6 +2497,60 @@ export class SqliteScopeHost {
|
|
|
2411
2497
|
return result;
|
|
2412
2498
|
});
|
|
2413
2499
|
}
|
|
2500
|
+
/**
|
|
2501
|
+
* A stored version's manifest, unaudited: the platform reading its own code metadata (#1705).
|
|
2502
|
+
* `undefined` when the registry has no such version (under `slug`, when one is named).
|
|
2503
|
+
*/
|
|
2504
|
+
manifestJsonOf(versionId, slug) {
|
|
2505
|
+
const row = (slug === undefined
|
|
2506
|
+
? this.directory.prepare('SELECT manifest_json FROM vertical_versions WHERE id = ?').get(versionId)
|
|
2507
|
+
: this.directory
|
|
2508
|
+
.prepare('SELECT manifest_json FROM vertical_versions WHERE id = ? AND vertical_slug = ?')
|
|
2509
|
+
.get(versionId, slug));
|
|
2510
|
+
return row ? row.manifest_json : undefined;
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* #1756: the bind gate's question for one install (`bindExportBreaksOf`), from the directory.
|
|
2514
|
+
* The serving pointer is read only when the scope is on a serving script before or after the
|
|
2515
|
+
* move, the one case it decides anything.
|
|
2516
|
+
*/
|
|
2517
|
+
bindBreaks(actor, scope, incoming, servingRef) {
|
|
2518
|
+
const vertical = scope.vertical && (scope.servingRef || servingRef)
|
|
2519
|
+
? this.directory
|
|
2520
|
+
.prepare('SELECT serving_ref, serving_version_id FROM verticals WHERE slug = ?')
|
|
2521
|
+
.get(scope.vertical)
|
|
2522
|
+
: undefined;
|
|
2523
|
+
return bindExportBreaksOf({
|
|
2524
|
+
admin: this.admin,
|
|
2525
|
+
actor,
|
|
2526
|
+
scope,
|
|
2527
|
+
incoming: { id: incoming.id, verticalSlug: incoming.verticalSlug },
|
|
2528
|
+
...(servingRef !== undefined ? { servingRef } : {}),
|
|
2529
|
+
serving: vertical?.serving_ref && vertical.serving_version_id
|
|
2530
|
+
? { ref: vertical.serving_ref, versionId: vertical.serving_version_id }
|
|
2531
|
+
: null,
|
|
2532
|
+
readExports: async (versionId) => exportsOfManifestJson(this.manifestJsonOf(versionId) ?? null),
|
|
2533
|
+
readImports: async (slug, versionId) => this.versionImports(slug, versionId),
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
/** What one version of `slug` imports, from the registry; `not_found` for a version it does not know. */
|
|
2537
|
+
versionImports(slug, versionId) {
|
|
2538
|
+
const manifest = this.manifestJsonOf(versionId, slug);
|
|
2539
|
+
if (manifest === undefined)
|
|
2540
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${slug}'`);
|
|
2541
|
+
return importsOfManifestJson(manifest);
|
|
2542
|
+
}
|
|
2543
|
+
/** #1705 PR 3: the promote gate's question, over two stored versions (`exportBreaksOf`). */
|
|
2544
|
+
exportBreaksBetween(actor, producer, outgoingId, incomingId) {
|
|
2545
|
+
return exportBreaksOf({
|
|
2546
|
+
admin: this.admin,
|
|
2547
|
+
actor,
|
|
2548
|
+
producer,
|
|
2549
|
+
outgoing: exportsOfManifestJson(this.manifestJsonOf(outgoingId)),
|
|
2550
|
+
incoming: exportsOfManifestJson(this.manifestJsonOf(incomingId)),
|
|
2551
|
+
readImports: async (slug, versionId) => this.versionImports(slug, versionId),
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2414
2554
|
/**
|
|
2415
2555
|
* The lifecycle gate the capability doors share (#1672): the scope must exist in this
|
|
2416
2556
|
* tenant, the tenant must be active, the scope must be active — K-3's fail-closed pair
|
|
@@ -2610,8 +2750,9 @@ export class SqliteScopeHost {
|
|
|
2610
2750
|
const observed = new Map();
|
|
2611
2751
|
for (const row of rt.db
|
|
2612
2752
|
.prepare(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
|
|
2613
|
-
WHERE type IN (
|
|
2614
|
-
|
|
2753
|
+
WHERE type IN (SELECT value FROM json_each(?)) GROUP BY type`)
|
|
2754
|
+
// One JSON array, the shape the DO twin needs for its 100-parameter limit (#1776).
|
|
2755
|
+
.all(JSON.stringify(types))) {
|
|
2615
2756
|
if (row.at !== null)
|
|
2616
2757
|
observed.set(row.type, row.at);
|
|
2617
2758
|
}
|
|
@@ -2826,6 +2967,11 @@ export class SqliteScopeHost {
|
|
|
2826
2967
|
// harness only after the enqueue task resolves — i.e. after COMMIT — so a
|
|
2827
2968
|
// rolled-back intent never signals.
|
|
2828
2969
|
const signals = { platformRequests: 0 };
|
|
2970
|
+
// #1705 PR 2: exported-type rows this invoke (and its consumers' tail) committed, for
|
|
2971
|
+
// `onExportedEvents`. Counted only in a deployment that exports something, and never
|
|
2972
|
+
// for a read-only session, which commits nothing.
|
|
2973
|
+
const exportTypes = session?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
|
|
2974
|
+
let exported = 0;
|
|
2829
2975
|
// #129: read inside the transaction, reported after it commits — a tag
|
|
2830
2976
|
// handed out for writes that were rolled back is a tag nobody may hold.
|
|
2831
2977
|
let committedVersion;
|
|
@@ -2839,6 +2985,9 @@ export class SqliteScopeHost {
|
|
|
2839
2985
|
// `finally` so a later consumer carries none rather than the last caller's,
|
|
2840
2986
|
// which is the leak `causedBy` had to be moved off the host to avoid.
|
|
2841
2987
|
rt.invocationId = invokeOptions?.invocationId ?? null;
|
|
2988
|
+
// The outbox's insertion mark, inside the actor task so nothing else writes between it
|
|
2989
|
+
// and this invoke (`OUTBOX_MARK_SQL` says why rowid and not the id).
|
|
2990
|
+
const exportMark = exportTypes.length > 0 ? Number(rt.db.prepare(OUTBOX_MARK_SQL).get().mark) : null;
|
|
2842
2991
|
try {
|
|
2843
2992
|
// #1672: the capability door's session, re-resolved on EVERY invoke and inside the
|
|
2844
2993
|
// actor task — so nothing can revoke between this read and the transaction below.
|
|
@@ -2986,6 +3135,10 @@ export class SqliteScopeHost {
|
|
|
2986
3135
|
await this.dispatch(rt, rt.invocationId);
|
|
2987
3136
|
await this.dispatchExecutors(rt, rt.invocationId);
|
|
2988
3137
|
}
|
|
3138
|
+
if (exportMark !== null && !replayed) {
|
|
3139
|
+
const q = exportedSinceQuery(exportTypes, exportMark);
|
|
3140
|
+
exported = Number(rt.db.prepare(q.sql).get(...q.params).n);
|
|
3141
|
+
}
|
|
2989
3142
|
return structuredClone(result);
|
|
2990
3143
|
}
|
|
2991
3144
|
finally {
|
|
@@ -2999,6 +3152,8 @@ export class SqliteScopeHost {
|
|
|
2999
3152
|
});
|
|
3000
3153
|
if (signals.platformRequests > 0)
|
|
3001
3154
|
options?.onPlatformRequests?.(signals.platformRequests);
|
|
3155
|
+
if (exported > 0)
|
|
3156
|
+
options?.onExportedEvents?.(exported);
|
|
3002
3157
|
if (committedVersion !== undefined)
|
|
3003
3158
|
invokeOptions?.onEntityVersion?.(committedVersion);
|
|
3004
3159
|
if (replayed)
|
|
@@ -3996,6 +4151,28 @@ export class SqliteScopeHost {
|
|
|
3996
4151
|
archivedAt: r.archived_at ?? null,
|
|
3997
4152
|
createdAt: r.created_at,
|
|
3998
4153
|
});
|
|
4154
|
+
// The (version, scope) pair a bind and its impact read both start from, and the refusals
|
|
4155
|
+
// that come before any export-break question, in the order the bind makes them (#1756).
|
|
4156
|
+
const bindTarget = (tenantId, scopeId, versionId) => {
|
|
4157
|
+
const v = readVersion(versionId);
|
|
4158
|
+
if (!v)
|
|
4159
|
+
throw substratError('not_found', `unknown version ${versionId}`);
|
|
4160
|
+
const scope = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
4161
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
4162
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
4163
|
+
}
|
|
4164
|
+
// The refusal this registry exists for. Without it, "a push lands pending"
|
|
4165
|
+
// is a convention, and D-30's argument is that we cannot afford conventions
|
|
4166
|
+
// where lockstep upgrades are the failure mode. Scoped to a SERVING bind, though:
|
|
4167
|
+
// a PREVIEW fork serves no install (the builder's own tenant's data, non-canonical
|
|
4168
|
+
// URL), so it may run pending PR code — the own-tenant blast radius that lets a
|
|
4169
|
+
// private vertical self-admit, and what lets a LISTED vertical's builder keep
|
|
4170
|
+
// previewing their own new code (issue #509 ask (d)).
|
|
4171
|
+
if (v.admission !== 'admitted' && scope.kind !== 'preview') {
|
|
4172
|
+
throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
|
|
4173
|
+
}
|
|
4174
|
+
return { v, scope };
|
|
4175
|
+
};
|
|
3999
4176
|
// Scope lifecycle transition (control-plane.md §4.2): validate ownership,
|
|
4000
4177
|
// enforce the legal transition graph (fail closed on an illegal one), flip
|
|
4001
4178
|
// the status, and audit before/after. un-archive is just another entry here
|
|
@@ -4015,27 +4192,34 @@ export class SqliteScopeHost {
|
|
|
4015
4192
|
throw new Error(`illegal scope transition for ${action}: ${row.status} → ${to} ` +
|
|
4016
4193
|
`(allowed from: ${from.join('|')})`);
|
|
4017
4194
|
}
|
|
4018
|
-
//
|
|
4019
|
-
//
|
|
4020
|
-
//
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4195
|
+
// One directory transaction: the status, the audit row and — on a reap — the scope's
|
|
4196
|
+
// switch records (#1674). Reaped is terminal, so a cleanup that failed after the flip
|
|
4197
|
+
// could never be retried; together, a failure leaves the scope where it was.
|
|
4198
|
+
this.directory.transaction(() => {
|
|
4199
|
+
// Stamp/clear archived_at so the reap sweep can age scopes (§4.4). Entering
|
|
4200
|
+
// `archived` records when; `unarchive` (→ active, a restore) clears it so a later
|
|
4201
|
+
// re-archive dates from the new event; `reaped` keeps it as terminal history.
|
|
4202
|
+
if (to === 'archived') {
|
|
4203
|
+
this.directory
|
|
4204
|
+
.prepare('UPDATE scopes SET status = ?, archived_at = ? WHERE scope_id = ?')
|
|
4205
|
+
.run(to, new Date().toISOString(), scopeId);
|
|
4206
|
+
}
|
|
4207
|
+
else if (to === 'active') {
|
|
4208
|
+
this.directory
|
|
4209
|
+
.prepare('UPDATE scopes SET status = ?, archived_at = NULL WHERE scope_id = ?')
|
|
4210
|
+
.run(to, scopeId);
|
|
4211
|
+
}
|
|
4212
|
+
else {
|
|
4213
|
+
this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
|
|
4214
|
+
}
|
|
4215
|
+
if (to === 'reaped')
|
|
4216
|
+
forgetSystemSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
4217
|
+
// The audit target carries the scope's vertical (control-plane.md §4.4:
|
|
4218
|
+
// "vertical stays null until §4.2 lifecycle actions that name one"). It is
|
|
4219
|
+
// read from the scope rather than passed in, so the trail cannot disagree
|
|
4220
|
+
// with the directory about which deployment the action touched.
|
|
4221
|
+
this.recordAdmin(actor, action, { tenantId, scopeId, vertical: row.vertical }, { status: row.status }, { status: to, ...afterExtra });
|
|
4222
|
+
})();
|
|
4039
4223
|
};
|
|
4040
4224
|
const writeTenantTuple = (tenantId, subject, relation, object, expiresAt) => this.directory
|
|
4041
4225
|
.prepare(`INSERT OR REPLACE INTO _substrat_tenant_tuples
|
|
@@ -4095,6 +4279,44 @@ export class SqliteScopeHost {
|
|
|
4095
4279
|
const target = { tenantId, scopeId };
|
|
4096
4280
|
const base = { operationId, moduleId: input.moduleId, schedules: to };
|
|
4097
4281
|
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
4282
|
+
// The directory's record (#1674): ON before the scope moves, OFF after it held — see
|
|
4283
|
+
// `recordSystemSwitchedOn` for why that order is the safe one.
|
|
4284
|
+
const at = new Date().toISOString();
|
|
4285
|
+
const directorySql = switchSqlOf(this.directory);
|
|
4286
|
+
const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
|
|
4287
|
+
const errorOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
4288
|
+
let prior = null;
|
|
4289
|
+
if (to === 'on') {
|
|
4290
|
+
try {
|
|
4291
|
+
prior = recordSystemSwitchedOn(directorySql, record);
|
|
4292
|
+
}
|
|
4293
|
+
catch (err) {
|
|
4294
|
+
// Nothing has moved: fail the call here, audited — the Cloudflare adapter's posture.
|
|
4295
|
+
try {
|
|
4296
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) });
|
|
4297
|
+
}
|
|
4298
|
+
catch {
|
|
4299
|
+
// Best effort: the original error is what the caller must see.
|
|
4300
|
+
}
|
|
4301
|
+
throw err;
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
/** A record write after the scope moved (review #5): retried once, never swallowed. */
|
|
4305
|
+
const recordWrite = (write) => {
|
|
4306
|
+
try {
|
|
4307
|
+
write();
|
|
4308
|
+
return null;
|
|
4309
|
+
}
|
|
4310
|
+
catch {
|
|
4311
|
+
try {
|
|
4312
|
+
write();
|
|
4313
|
+
return null;
|
|
4314
|
+
}
|
|
4315
|
+
catch (err) {
|
|
4316
|
+
return errorOf(err);
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
};
|
|
4098
4320
|
let outcome;
|
|
4099
4321
|
try {
|
|
4100
4322
|
// A turn on the scope actor (#1666 review), exactly as the job store (#1577): `invoke`
|
|
@@ -4103,19 +4325,16 @@ export class SqliteScopeHost {
|
|
|
4103
4325
|
// with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
|
|
4104
4326
|
// caller already inside one of this scope's tasks joins it rather than deadlocking.
|
|
4105
4327
|
const rt = this.runtime(tenantId, scopeId);
|
|
4106
|
-
outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), {
|
|
4107
|
-
moduleId: input.moduleId,
|
|
4108
|
-
scopeId,
|
|
4109
|
-
to,
|
|
4110
|
-
at: new Date().toISOString(),
|
|
4111
|
-
}))());
|
|
4328
|
+
outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId: input.moduleId, scopeId, to, at }))());
|
|
4112
4329
|
}
|
|
4113
4330
|
catch (err) {
|
|
4331
|
+
const recordError = recordWrite(() => restoreSystemSwitchRecord(directorySql, record, prior));
|
|
4114
4332
|
try {
|
|
4115
4333
|
this.recordAdmin(actor, action, target, null, {
|
|
4116
4334
|
...base,
|
|
4117
4335
|
phase: 'failed',
|
|
4118
|
-
error:
|
|
4336
|
+
error: errorOf(err),
|
|
4337
|
+
...(recordError ? { recordError } : {}),
|
|
4119
4338
|
});
|
|
4120
4339
|
}
|
|
4121
4340
|
catch {
|
|
@@ -4123,15 +4342,30 @@ export class SqliteScopeHost {
|
|
|
4123
4342
|
}
|
|
4124
4343
|
throw err;
|
|
4125
4344
|
}
|
|
4345
|
+
// OFF is recorded once the scope held it. A refused ON moved nothing, so its record
|
|
4346
|
+
// write is undone: left `on`, the next reconcile of a wiped scope would leave the
|
|
4347
|
+
// module running.
|
|
4348
|
+
const recordError = to === 'off' && outcome.held
|
|
4349
|
+
? recordWrite(() => recordSystemSwitchedOff(directorySql, record))
|
|
4350
|
+
: to === 'on' && !outcome.held
|
|
4351
|
+
? recordWrite(() => restoreSystemSwitchRecord(directorySql, record, prior))
|
|
4352
|
+
: null;
|
|
4126
4353
|
this.recordAdmin(actor, action, target, null, {
|
|
4127
4354
|
...base,
|
|
4128
4355
|
phase: outcome.held ? 'applied' : 'refused',
|
|
4129
4356
|
changed: outcome.changed,
|
|
4130
4357
|
permissions: outcome.permissions,
|
|
4358
|
+
...(recordError ? { recordError } : {}),
|
|
4131
4359
|
});
|
|
4132
4360
|
if (!outcome.held) {
|
|
4133
4361
|
throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
|
|
4134
|
-
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`
|
|
4362
|
+
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
|
|
4363
|
+
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
4364
|
+
}
|
|
4365
|
+
if (recordError) {
|
|
4366
|
+
throw substratError('unavailable', `module '${input.moduleId}' is switched ${to} on scope ${scopeId}, but the directory's record of it ` +
|
|
4367
|
+
`could not be written (${recordError}) — repeat the call to record it; a wipe of this scope would ` +
|
|
4368
|
+
`otherwise lose the switch`);
|
|
4135
4369
|
}
|
|
4136
4370
|
return {
|
|
4137
4371
|
operationId,
|
|
@@ -4267,10 +4501,12 @@ export class SqliteScopeHost {
|
|
|
4267
4501
|
const states = await rt.actor.turn(() => systemGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
|
|
4268
4502
|
const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
|
|
4269
4503
|
const explanations = offModules.size > 0 ? lastSwitchedOff(tenantId, scopeId, offModules) : new Map();
|
|
4270
|
-
const
|
|
4504
|
+
const recorded = systemSwitchRecordsOf(switchSqlOf(this.directory), tenantId, scopeId);
|
|
4505
|
+
const result = withRecorded(states, recorded).map((s) => ({
|
|
4271
4506
|
moduleId: s.moduleId,
|
|
4272
4507
|
schedules: s.schedules,
|
|
4273
4508
|
switchedOff: explanations.get(s.moduleId) ?? null,
|
|
4509
|
+
recorded: s.recorded,
|
|
4274
4510
|
}));
|
|
4275
4511
|
// K-24: reading the switch's position and any live incident reason is itself
|
|
4276
4512
|
// access-logged, the same as every other HostAdmin read.
|
|
@@ -4501,6 +4737,18 @@ export class SqliteScopeHost {
|
|
|
4501
4737
|
// #1674: the switch's status read — same gate, the admin log to explain an `off`
|
|
4502
4738
|
// entry. Nothing to delegate here: the pure adapter's scope storage IS the store.
|
|
4503
4739
|
systemGrantsStatus: systemGrantsStatusOf,
|
|
4740
|
+
// #1674: the switch's directory record — the fleet read, and the re-assert (whose body
|
|
4741
|
+
// is `reassertSwitchesInTurn`; this is the scope check and the turn around it).
|
|
4742
|
+
listSystemSwitches: async (actor, filter) => {
|
|
4743
|
+
const rows = listSystemSwitchRecords(switchSqlOf(this.directory), filter);
|
|
4744
|
+
this.recordAccess(actor, 'listSystemSwitches', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
4745
|
+
return rows.map((r) => systemSwitchRecord.parse(r));
|
|
4746
|
+
},
|
|
4747
|
+
reassertSystemSwitches: async (actor, node) => {
|
|
4748
|
+
this.assertScope(node.tenantId, node.scopeId);
|
|
4749
|
+
const rt = this.runtime(node.tenantId, node.scopeId);
|
|
4750
|
+
return rt.actor.turn(() => this.reassertSwitchesInTurn(rt, actor, node.tenantId, node.scopeId));
|
|
4751
|
+
},
|
|
4504
4752
|
peerGrantsStatus: peerGrantsStatusOf,
|
|
4505
4753
|
// #1672 — the platform's two capability verbs, both a turn on the scope actor
|
|
4506
4754
|
// (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
|
|
@@ -4817,13 +5065,19 @@ export class SqliteScopeHost {
|
|
|
4817
5065
|
// The manifest is retained for the serving upload (#286), not audited — a whole
|
|
4818
5066
|
// manifest per publish would drown the admin log in bundle metadata.
|
|
4819
5067
|
const { manifestJson, origin, ...audited } = parsed;
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
5068
|
+
// The SQL migrations are stored apart from the manifest (#1764), so no read of the
|
|
5069
|
+
// version but the promote review's carries them.
|
|
5070
|
+
const split = splitManifestMigrations(manifestJson ?? null, 'push');
|
|
5071
|
+
this.directory.transaction(() => {
|
|
5072
|
+
this.directory
|
|
5073
|
+
.prepare(`INSERT INTO vertical_versions
|
|
5074
|
+
(id, vertical_slug, version, manifest_digest, permission_digest,
|
|
5075
|
+
migration_digest, deployment_ref, admission, admission_note, manifest_json,
|
|
5076
|
+
origin_json, migration_count, migrations_split, created_at)
|
|
5077
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`)
|
|
5078
|
+
.run(audited.id, audited.verticalSlug, audited.version, audited.manifestDigest, audited.permissionDigest, audited.migrationDigest, audited.deploymentRef, selfAdmits ? 'admitted' : 'pending', selfAdmits ? AUTO_ADMISSION_NOTE : null, split.manifestJson, origin ? JSON.stringify(origin) : null, split.migrations?.length ?? null, new Date().toISOString());
|
|
5079
|
+
writeVersionMigrations(switchSqlOf(this.directory), audited.id, split.migrations);
|
|
5080
|
+
})();
|
|
4827
5081
|
this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
|
|
4828
5082
|
...audited,
|
|
4829
5083
|
...(origin ? { origin } : {}),
|
|
@@ -4929,6 +5183,10 @@ export class SqliteScopeHost {
|
|
|
4929
5183
|
// cleanup script (#248), never destroyed alongside a registry row.
|
|
4930
5184
|
this.directory.prepare('DELETE FROM vertical_channels WHERE vertical_slug = ?').run(slug);
|
|
4931
5185
|
this.directory.prepare('DELETE FROM vertical_channel_history WHERE vertical_slug = ?').run(slug);
|
|
5186
|
+
this.directory
|
|
5187
|
+
.prepare(`DELETE FROM vertical_version_migrations
|
|
5188
|
+
WHERE version_id IN (SELECT id FROM vertical_versions WHERE vertical_slug = ?)`)
|
|
5189
|
+
.run(slug);
|
|
4932
5190
|
this.directory.prepare('DELETE FROM vertical_versions WHERE vertical_slug = ?').run(slug);
|
|
4933
5191
|
this.directory.prepare('DELETE FROM verticals WHERE slug = ?').run(slug);
|
|
4934
5192
|
this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.ownerTenant }, null);
|
|
@@ -4978,6 +5236,18 @@ export class SqliteScopeHost {
|
|
|
4978
5236
|
note,
|
|
4979
5237
|
});
|
|
4980
5238
|
},
|
|
5239
|
+
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
5240
|
+
const incoming = readVersion(versionId);
|
|
5241
|
+
if (!incoming || incoming.verticalSlug !== verticalSlug) {
|
|
5242
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
5243
|
+
}
|
|
5244
|
+
const current = this.directory
|
|
5245
|
+
.prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
|
|
5246
|
+
.get(verticalSlug, channel);
|
|
5247
|
+
const breaks = current ? await this.exportBreaksBetween(actor, verticalSlug, current.version_id, versionId) : [];
|
|
5248
|
+
this.recordAccess(actor, 'promotionImpact', { tenantId: null }, { verticalSlug, channel, versionId }, breaks.length);
|
|
5249
|
+
return breaks;
|
|
5250
|
+
},
|
|
4981
5251
|
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
4982
5252
|
const incoming = readVersion(versionId);
|
|
4983
5253
|
if (!incoming)
|
|
@@ -5005,6 +5275,12 @@ export class SqliteScopeHost {
|
|
|
5005
5275
|
throw new Error(`promotion changes migrations (${outgoing.migrationDigest} → ` +
|
|
5006
5276
|
`${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
|
|
5007
5277
|
}
|
|
5278
|
+
// #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
|
|
5279
|
+
if (!ack.exportBreak) {
|
|
5280
|
+
const breaks = await this.exportBreaksBetween(actor, verticalSlug, outgoing.id, incoming.id);
|
|
5281
|
+
if (breaks.length > 0)
|
|
5282
|
+
throw substratError('precondition_failed', exportBreakRefusal(breaks));
|
|
5283
|
+
}
|
|
5008
5284
|
}
|
|
5009
5285
|
const promotedAt = new Date().toISOString();
|
|
5010
5286
|
this.directory
|
|
@@ -5108,25 +5384,21 @@ export class SqliteScopeHost {
|
|
|
5108
5384
|
at: r.at,
|
|
5109
5385
|
}));
|
|
5110
5386
|
},
|
|
5387
|
+
bindingImpact: async (actor, tenantId, scopeId, versionId, opts) => {
|
|
5388
|
+
const { v, scope } = bindTarget(tenantId, scopeId, versionId);
|
|
5389
|
+
const breaks = await this.bindBreaks(actor, mapScope(scope), v, opts?.servingRef);
|
|
5390
|
+
this.recordAccess(actor, 'bindingImpact', { tenantId, scopeId }, { versionId, ...opts }, breaks.length);
|
|
5391
|
+
return breaks;
|
|
5392
|
+
},
|
|
5111
5393
|
bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
|
|
5112
|
-
const v =
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
.
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
}
|
|
5121
|
-
// The refusal this registry exists for. Without it, "a push lands pending"
|
|
5122
|
-
// is a convention, and D-30's argument is that we cannot afford conventions
|
|
5123
|
-
// where lockstep upgrades are the failure mode. Scoped to a SERVING bind, though:
|
|
5124
|
-
// a PREVIEW fork serves no install (the builder's own tenant's data, non-canonical
|
|
5125
|
-
// URL), so it may run pending PR code — the own-tenant blast radius that lets a
|
|
5126
|
-
// private vertical self-admit, and what lets a LISTED vertical's builder keep
|
|
5127
|
-
// previewing their own new code (issue #509 ask (d)).
|
|
5128
|
-
if (v.admission !== 'admitted' && scope.kind !== 'preview') {
|
|
5129
|
-
throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
|
|
5394
|
+
const { v, scope } = bindTarget(tenantId, scopeId, versionId);
|
|
5395
|
+
const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
|
|
5396
|
+
// #1756: an export an app in this tenant imports, dropped or re-versioned by what this
|
|
5397
|
+
// scope would run. Before the snapshot, so a refused bind leaves nothing behind.
|
|
5398
|
+
if (!ack.exportBreak) {
|
|
5399
|
+
const breaks = await this.bindBreaks(actor, mapScope(scope), v);
|
|
5400
|
+
if (breaks.length > 0)
|
|
5401
|
+
throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
|
|
5130
5402
|
}
|
|
5131
5403
|
// Fork-before-promote (§4): snapshot the pre-migration data if this rebind
|
|
5132
5404
|
// crosses a migration boundary. Gated on a real digest change — a code-only
|
|
@@ -5144,6 +5416,7 @@ export class SqliteScopeHost {
|
|
|
5144
5416
|
versionId,
|
|
5145
5417
|
vertical: v.verticalSlug,
|
|
5146
5418
|
version: v.version,
|
|
5419
|
+
...(ack.exportBreak ? { acknowledged: ack } : {}),
|
|
5147
5420
|
});
|
|
5148
5421
|
},
|
|
5149
5422
|
/**
|
|
@@ -5201,17 +5474,32 @@ export class SqliteScopeHost {
|
|
|
5201
5474
|
this.recordAccess(actor, 'versionManifest', {}, { verticalSlug, versionId }, v.manifest_json ? 1 : 0);
|
|
5202
5475
|
return v.manifest_json;
|
|
5203
5476
|
},
|
|
5204
|
-
|
|
5205
|
-
const
|
|
5206
|
-
|
|
5207
|
-
|
|
5477
|
+
versionMigrations: async (actor, verticalSlug, versionId) => {
|
|
5478
|
+
const v = versionMigrationsOf(switchSqlOf(this.directory), versionId);
|
|
5479
|
+
if (!v || v.verticalSlug !== verticalSlug) {
|
|
5480
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
5481
|
+
}
|
|
5482
|
+
this.recordAccess(actor, 'versionMigrations', {}, { verticalSlug, versionId }, v.migrations?.length ?? 0);
|
|
5483
|
+
return v.migrations;
|
|
5484
|
+
},
|
|
5485
|
+
setScopeServingRef: async (actor, tenantId, scopeId, servingRef, opts) => {
|
|
5486
|
+
const scope = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
5208
5487
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
5209
5488
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
5210
5489
|
}
|
|
5490
|
+
const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
|
|
5491
|
+
// #1756: onto (or off) a serving script, the scope runs other code before its pointer
|
|
5492
|
+
// moves. Judged here, so no caller that routes first and binds second passes unasked.
|
|
5493
|
+
const bound = scope.vertical_version_id ? readVersion(scope.vertical_version_id) : undefined;
|
|
5494
|
+
if (!ack.exportBreak && bound) {
|
|
5495
|
+
const breaks = await this.bindBreaks(actor, mapScope(scope), bound, servingRef);
|
|
5496
|
+
if (breaks.length > 0)
|
|
5497
|
+
throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
|
|
5498
|
+
}
|
|
5211
5499
|
this.directory
|
|
5212
5500
|
.prepare('UPDATE scopes SET serving_ref = ? WHERE scope_id = ?')
|
|
5213
5501
|
.run(servingRef, scopeId);
|
|
5214
|
-
this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref }, { servingRef });
|
|
5502
|
+
this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref }, { servingRef, ...(ack.exportBreak ? { acknowledged: ack } : {}) });
|
|
5215
5503
|
},
|
|
5216
5504
|
setScopeExpiresAt: async (actor, tenantId, scopeId, expiresAt) => {
|
|
5217
5505
|
const scope = this.directory
|
|
@@ -5299,6 +5587,47 @@ export class SqliteScopeHost {
|
|
|
5299
5587
|
this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, cursors.length);
|
|
5300
5588
|
return { consumes, cursors };
|
|
5301
5589
|
},
|
|
5590
|
+
moveImportCursor: async (actor, tenantId, scopeId, raw) => {
|
|
5591
|
+
// #1705 PR 3: the replay lever. Parsed at the door: the acknowledgement literal is part
|
|
5592
|
+
// of the input, and a move without it never reaches the store.
|
|
5593
|
+
const move = importCursorMove.parse(raw);
|
|
5594
|
+
this.assertScopeReachable(tenantId, scopeId);
|
|
5595
|
+
const rec = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
5596
|
+
const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
|
|
5597
|
+
const replayId = ulid();
|
|
5598
|
+
const target = { tenantId, scopeId };
|
|
5599
|
+
const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
5600
|
+
this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
|
|
5601
|
+
let moved;
|
|
5602
|
+
try {
|
|
5603
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
5604
|
+
const now = Date.parse(this.clock());
|
|
5605
|
+
// One turn on the scope actor, which is where `deliverToPeer` runs: a delivery on this
|
|
5606
|
+
// edge is either wholly before the move or refused by its compare-and-set after it.
|
|
5607
|
+
moved = await rt.actor.turn(() => rt.db.transaction(() => moveImportCursor(switchSqlOf(rt.db), { move, source, replayId, now, imports: this.crossVertical.consumes() }))());
|
|
5608
|
+
}
|
|
5609
|
+
catch (err) {
|
|
5610
|
+
try {
|
|
5611
|
+
this.recordAdmin(actor, 'moveImportCursor', target, null, {
|
|
5612
|
+
...base,
|
|
5613
|
+
phase: 'failed',
|
|
5614
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5615
|
+
});
|
|
5616
|
+
}
|
|
5617
|
+
catch {
|
|
5618
|
+
// Best effort: the original error is what the caller must see.
|
|
5619
|
+
}
|
|
5620
|
+
throw err;
|
|
5621
|
+
}
|
|
5622
|
+
this.recordAdmin(actor, 'moveImportCursor', target, null, {
|
|
5623
|
+
...base,
|
|
5624
|
+
phase: 'applied',
|
|
5625
|
+
previous: moved.previous,
|
|
5626
|
+
cursor: moved.cursor,
|
|
5627
|
+
archived: moved.archived,
|
|
5628
|
+
});
|
|
5629
|
+
return moved;
|
|
5630
|
+
},
|
|
5302
5631
|
redrainEvents: async (actor, tenantId, scopeId, input) => {
|
|
5303
5632
|
// Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
|
|
5304
5633
|
// address this scope" are different answers.
|
|
@@ -5605,8 +5934,9 @@ export class SqliteScopeHost {
|
|
|
5605
5934
|
// than degenerating into an unfiltered read of the whole fleet.
|
|
5606
5935
|
if (statuses.length === 0)
|
|
5607
5936
|
return [];
|
|
5608
|
-
|
|
5609
|
-
|
|
5937
|
+
// One JSON array, the shape the DO twin needs for its 100-parameter limit (#1776).
|
|
5938
|
+
where.push('status IN (SELECT value FROM json_each(?))');
|
|
5939
|
+
params.push(JSON.stringify(statuses));
|
|
5610
5940
|
}
|
|
5611
5941
|
if (filter?.vertical) {
|
|
5612
5942
|
where.push('vertical = ?');
|
|
@@ -5854,7 +6184,7 @@ export class SqliteScopeHost {
|
|
|
5854
6184
|
})();
|
|
5855
6185
|
// Carry a copy taken before a directory migration forward to the running
|
|
5856
6186
|
// code's shape — the same assertion a cold start makes.
|
|
5857
|
-
this.
|
|
6187
|
+
this.ensureDirectorySchema();
|
|
5858
6188
|
// Roles are held in memory (`loadRoles`), so a restore that only rewrote the
|
|
5859
6189
|
// table would leave every permission check reading the PRE-restore roles until
|
|
5860
6190
|
// the process restarted — the exact silent-divergence a recovery must not have.
|
|
@@ -6076,6 +6406,7 @@ export class SqliteScopeHost {
|
|
|
6076
6406
|
'_substrat_roles', // operator-defined roles
|
|
6077
6407
|
'_substrat_entitlements', // per-tenant SKU flags
|
|
6078
6408
|
'orgs', // K-22 org records
|
|
6409
|
+
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
6079
6410
|
];
|
|
6080
6411
|
const clear = this.directory.transaction(() => {
|
|
6081
6412
|
for (const table of tables) {
|
|
@@ -6657,8 +6988,9 @@ export class SqliteScopeHost {
|
|
|
6657
6988
|
const actions = Array.isArray(filter.action) ? filter.action : [filter.action];
|
|
6658
6989
|
if (actions.length === 0)
|
|
6659
6990
|
return []; // no action is acceptable — match nothing
|
|
6660
|
-
|
|
6661
|
-
|
|
6991
|
+
// One JSON array, the shape the DO twin needs for its 100-parameter limit (#1776).
|
|
6992
|
+
where.push('action IN (SELECT value FROM json_each(?))');
|
|
6993
|
+
params.push(JSON.stringify(actions));
|
|
6662
6994
|
}
|
|
6663
6995
|
if (filter?.since) {
|
|
6664
6996
|
where.push('at >= ?');
|
|
@@ -7152,6 +7484,8 @@ export class SqliteScopeHost {
|
|
|
7152
7484
|
this.ensureColumn(this.directory, 'vertical_versions', 'manifest_json', 'manifest_json TEXT');
|
|
7153
7485
|
// Push provenance (git CI vs a terminal) — null for a pre-tracking push.
|
|
7154
7486
|
this.ensureColumn(this.directory, 'vertical_versions', 'origin_json', 'origin_json TEXT');
|
|
7487
|
+
// #1764's `migration_count` and `migrations_split` are added in `ensureDirectorySchema`,
|
|
7488
|
+
// BEFORE the DDL, because `VERSION_MIGRATIONS_DDL` indexes one of them.
|
|
7155
7489
|
// #33: the SKU flag learns to express a plan. All nullable — a legacy row
|
|
7156
7490
|
// reads as a perpetual boolean flag, exactly its pre-widening semantics.
|
|
7157
7491
|
for (const [col, ddl] of [
|
|
@@ -7379,7 +7713,7 @@ export class SqliteScopeHost {
|
|
|
7379
7713
|
tenantId: rt.tenantId,
|
|
7380
7714
|
scopeId: rt.scopeId,
|
|
7381
7715
|
principal,
|
|
7382
|
-
sql: guardSecrets(scopedSql(rt.db), minted),
|
|
7716
|
+
sql: guardSecrets(guardSqlLimits(scopedSql(rt.db)), minted),
|
|
7383
7717
|
now: () => at,
|
|
7384
7718
|
emit: (event) => {
|
|
7385
7719
|
assertImpersonationWrites(impersonation, 'ctx.emit');
|