@substrat-run/adapter-sqlite 0.119.0 → 0.121.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 +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +287 -61
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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, 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, 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, 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
8
|
/** The kernel's schedule-switch SQL (#1666), over one scope's database handle. */
|
|
@@ -512,7 +512,7 @@ export class SqliteScopeHost {
|
|
|
512
512
|
mkdirSync(this.dir, { recursive: true });
|
|
513
513
|
this.directory = new Database(join(this.dir, '_directory.sqlite'));
|
|
514
514
|
this.directory.pragma('journal_mode = WAL');
|
|
515
|
-
this.
|
|
515
|
+
this.ensureDirectorySchema();
|
|
516
516
|
this.loadRoles();
|
|
517
517
|
this.checker =
|
|
518
518
|
options.checker ??
|
|
@@ -968,6 +968,7 @@ export class SqliteScopeHost {
|
|
|
968
968
|
PRIMARY KEY (scope_id, subject_id)
|
|
969
969
|
);
|
|
970
970
|
${IMPERSONATION_DDL}
|
|
971
|
+
${SYSTEM_SWITCHES_DDL}
|
|
971
972
|
CREATE TABLE IF NOT EXISTS _substrat_admin_log (
|
|
972
973
|
id TEXT PRIMARY KEY,
|
|
973
974
|
actor TEXT NOT NULL,
|
|
@@ -1101,6 +1102,25 @@ export class SqliteScopeHost {
|
|
|
1101
1102
|
`);
|
|
1102
1103
|
this.ensureDirectoryColumns();
|
|
1103
1104
|
}
|
|
1105
|
+
/**
|
|
1106
|
+
* `applyDirectorySchema`, plus the backfill of the schedule switch's record (#1674) from
|
|
1107
|
+
* the admin log. Whether the table exists is asked BEFORE the DDL creates it, so the
|
|
1108
|
+
* backfill runs only on an application that creates the table: once on a directory that
|
|
1109
|
+
* never had it, and again after a directory restore whose dump predates it (from that
|
|
1110
|
+
* dump's own log). A directory that already holds the table is never backfilled over.
|
|
1111
|
+
*
|
|
1112
|
+
* One transaction (Copilot review, #1674): the table and its backfill commit together, so
|
|
1113
|
+
* a backfill that fails leaves no table behind, and the next start tries again rather than
|
|
1114
|
+
* reading an empty table as already migrated.
|
|
1115
|
+
*/
|
|
1116
|
+
ensureDirectorySchema() {
|
|
1117
|
+
this.directory.transaction(() => {
|
|
1118
|
+
const switchRecordIsNew = !systemSwitchesTableExists(switchSqlOf(this.directory));
|
|
1119
|
+
this.applyDirectorySchema();
|
|
1120
|
+
if (switchRecordIsNew)
|
|
1121
|
+
this.directory.exec(SYSTEM_SWITCHES_BACKFILL_SQL);
|
|
1122
|
+
})();
|
|
1123
|
+
}
|
|
1104
1124
|
registerExecutor(id, eventType, handler, retry) {
|
|
1105
1125
|
if (this.executors.has(id))
|
|
1106
1126
|
throw new Error(`executor '${id}' is already registered`);
|
|
@@ -1303,7 +1323,9 @@ export class SqliteScopeHost {
|
|
|
1303
1323
|
this.crossVertical.register(manifest, registration.imports);
|
|
1304
1324
|
this.modules.set(manifest.id, {
|
|
1305
1325
|
id: manifest.id,
|
|
1306
|
-
|
|
1326
|
+
// The order the kernel writes once (#1677): authored, then search, then list indexes.
|
|
1327
|
+
// The two derived sets are still computed above, for the duplicate-version refusals.
|
|
1328
|
+
migrations: moduleMigrations(registration),
|
|
1307
1329
|
consumers,
|
|
1308
1330
|
schedules: manifest.schedules ?? [],
|
|
1309
1331
|
freshness: manifest.freshness ?? [],
|
|
@@ -1484,12 +1506,38 @@ export class SqliteScopeHost {
|
|
|
1484
1506
|
const seat = seatScopeTuple(peer.subject, peer.relation, peer.object, null);
|
|
1485
1507
|
rt.db.prepare(seat.sql).run(...seat.params);
|
|
1486
1508
|
}
|
|
1509
|
+
// #1674: re-assert the recorded OFF positions after the seat, in the same turn
|
|
1510
|
+
// (`system-switch-record.ts`).
|
|
1511
|
+
this.reassertSwitchesInTurn(rt, actor, input.tenantId, input.scopeId);
|
|
1487
1512
|
});
|
|
1488
1513
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
1489
1514
|
if (!existing) {
|
|
1490
1515
|
this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
1491
1516
|
}
|
|
1492
1517
|
}
|
|
1518
|
+
/**
|
|
1519
|
+
* `HostAdmin.reassertSystemSwitches`' body (#1674), run inside a turn the caller already
|
|
1520
|
+
* holds on the scope's actor: every module the directory records OFF on the scope is
|
|
1521
|
+
* switched off again. Already off answers `changed: false` and writes nothing, and a
|
|
1522
|
+
* module the scope does not hold is left alone. Audited only when something moved.
|
|
1523
|
+
*/
|
|
1524
|
+
reassertSwitchesInTurn(rt, actor, tenantId, scopeId) {
|
|
1525
|
+
const at = new Date().toISOString();
|
|
1526
|
+
return switchedOffModulesOf(switchSqlOf(this.directory), tenantId, scopeId).map((moduleId) => {
|
|
1527
|
+
const outcome = rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId, scopeId, to: 'off', at }))();
|
|
1528
|
+
if (outcome.changed) {
|
|
1529
|
+
this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId }, null, {
|
|
1530
|
+
operationId: ulid(),
|
|
1531
|
+
moduleId,
|
|
1532
|
+
schedules: 'off',
|
|
1533
|
+
phase: 'applied',
|
|
1534
|
+
changed: true,
|
|
1535
|
+
permissions: outcome.permissions,
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
return { moduleId, held: outcome.held, changed: outcome.changed };
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1493
1541
|
async provisionTenantStore(actor, input) {
|
|
1494
1542
|
// Fail closed on an unknown/inactive tenant, exactly as provisionScope does: a store
|
|
1495
1543
|
// for a tenant that does not exist is the same "FK string" hole (§4.1). A store is
|
|
@@ -2031,14 +2079,14 @@ export class SqliteScopeHost {
|
|
|
2031
2079
|
// Restore never creates a scope (that is importScope) — an unknown target fails closed.
|
|
2032
2080
|
const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
2033
2081
|
if (!existing)
|
|
2034
|
-
throw
|
|
2082
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2035
2083
|
await this.loadDump(tenantId, scopeId, dump.tables);
|
|
2036
2084
|
this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
2037
2085
|
}
|
|
2038
2086
|
async snapshotScope(actor, tenantId, scopeId, opts) {
|
|
2039
2087
|
const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
2040
2088
|
if (!source)
|
|
2041
|
-
throw
|
|
2089
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2042
2090
|
const dump = await this.admin.exportScope(actor, tenantId, scopeId);
|
|
2043
2091
|
const snapshotId = ulid();
|
|
2044
2092
|
await this.importScope(actor, {
|
|
@@ -2063,7 +2111,7 @@ export class SqliteScopeHost {
|
|
|
2063
2111
|
// #509 ask (b)). A PRIMARY scope keeps the platform's tombstone-only rule (archive it).
|
|
2064
2112
|
const rec = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
2065
2113
|
if (!rec)
|
|
2066
|
-
throw
|
|
2114
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2067
2115
|
if (!rec.forkedFrom && rec.kind !== 'preview') {
|
|
2068
2116
|
throw new Error(`scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
|
|
2069
2117
|
`archive a primary scope instead`);
|
|
@@ -2078,6 +2126,7 @@ export class SqliteScopeHost {
|
|
|
2078
2126
|
// orphaned bytes with no record (the §9 hazard).
|
|
2079
2127
|
this.directory.prepare('DELETE FROM hostnames WHERE scope_id = ?').run(scopeId);
|
|
2080
2128
|
rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
|
|
2129
|
+
forgetSystemSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
2081
2130
|
this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
|
|
2082
2131
|
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
2083
2132
|
forkedFrom: rec.forkedFrom,
|
|
@@ -2091,7 +2140,7 @@ export class SqliteScopeHost {
|
|
|
2091
2140
|
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
2092
2141
|
.get(scopeId);
|
|
2093
2142
|
if (!row || row.tenant_id !== tenantId) {
|
|
2094
|
-
throw
|
|
2143
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2095
2144
|
}
|
|
2096
2145
|
// Lifecycle gates (control-plane.md §4.1/§4.2), all the K-3 fail-closed path.
|
|
2097
2146
|
// The tenant record is mandatory: every scope has a tenant with a status
|
|
@@ -2152,7 +2201,7 @@ export class SqliteScopeHost {
|
|
|
2152
2201
|
if (!scope || scope.tenant_id !== conn.tenant_id) {
|
|
2153
2202
|
// Same wording as the principal path: a scope in another tenant is
|
|
2154
2203
|
// indistinguishable from one that does not exist.
|
|
2155
|
-
throw
|
|
2204
|
+
throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
|
|
2156
2205
|
}
|
|
2157
2206
|
if (scope.vertical !== conn.vertical) {
|
|
2158
2207
|
throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
|
|
@@ -2180,7 +2229,7 @@ export class SqliteScopeHost {
|
|
|
2180
2229
|
.prepare('SELECT tenant_id, vertical, status FROM scopes WHERE scope_id = ?')
|
|
2181
2230
|
.get(scopeId);
|
|
2182
2231
|
if (!scope || scope.tenant_id !== conn.tenant_id) {
|
|
2183
|
-
throw
|
|
2232
|
+
throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
|
|
2184
2233
|
}
|
|
2185
2234
|
if (scope.vertical !== conn.vertical) {
|
|
2186
2235
|
throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
|
|
@@ -2219,7 +2268,7 @@ export class SqliteScopeHost {
|
|
|
2219
2268
|
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
2220
2269
|
.get(scopeId);
|
|
2221
2270
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
2222
|
-
throw
|
|
2271
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2223
2272
|
}
|
|
2224
2273
|
if (scope.status !== 'active') {
|
|
2225
2274
|
throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
|
|
@@ -2240,7 +2289,7 @@ export class SqliteScopeHost {
|
|
|
2240
2289
|
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
2241
2290
|
.get(scopeId);
|
|
2242
2291
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
2243
|
-
throw
|
|
2292
|
+
throw substratError('not_found', `unknown scope: ${scopeId}`);
|
|
2244
2293
|
}
|
|
2245
2294
|
if (scope.status !== 'active') {
|
|
2246
2295
|
throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
|
|
@@ -2411,6 +2460,34 @@ export class SqliteScopeHost {
|
|
|
2411
2460
|
return result;
|
|
2412
2461
|
});
|
|
2413
2462
|
}
|
|
2463
|
+
/**
|
|
2464
|
+
* A stored version's manifest, unaudited: the platform reading its own code metadata (#1705).
|
|
2465
|
+
* `undefined` when the registry has no such version (under `slug`, when one is named).
|
|
2466
|
+
*/
|
|
2467
|
+
manifestJsonOf(versionId, slug) {
|
|
2468
|
+
const row = (slug === undefined
|
|
2469
|
+
? this.directory.prepare('SELECT manifest_json FROM vertical_versions WHERE id = ?').get(versionId)
|
|
2470
|
+
: this.directory
|
|
2471
|
+
.prepare('SELECT manifest_json FROM vertical_versions WHERE id = ? AND vertical_slug = ?')
|
|
2472
|
+
.get(versionId, slug));
|
|
2473
|
+
return row ? row.manifest_json : undefined;
|
|
2474
|
+
}
|
|
2475
|
+
/** #1705 PR 3: the promote gate's question, over two stored versions (`exportBreaksOf`). */
|
|
2476
|
+
exportBreaksBetween(actor, producer, outgoingId, incomingId) {
|
|
2477
|
+
return exportBreaksOf({
|
|
2478
|
+
admin: this.admin,
|
|
2479
|
+
actor,
|
|
2480
|
+
producer,
|
|
2481
|
+
outgoing: exportsOfManifestJson(this.manifestJsonOf(outgoingId)),
|
|
2482
|
+
incoming: exportsOfManifestJson(this.manifestJsonOf(incomingId)),
|
|
2483
|
+
readImports: async (slug, versionId) => {
|
|
2484
|
+
const manifest = this.manifestJsonOf(versionId, slug);
|
|
2485
|
+
if (manifest === undefined)
|
|
2486
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${slug}'`);
|
|
2487
|
+
return importsOfManifestJson(manifest);
|
|
2488
|
+
},
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2414
2491
|
/**
|
|
2415
2492
|
* The lifecycle gate the capability doors share (#1672): the scope must exist in this
|
|
2416
2493
|
* tenant, the tenant must be active, the scope must be active — K-3's fail-closed pair
|
|
@@ -2826,6 +2903,11 @@ export class SqliteScopeHost {
|
|
|
2826
2903
|
// harness only after the enqueue task resolves — i.e. after COMMIT — so a
|
|
2827
2904
|
// rolled-back intent never signals.
|
|
2828
2905
|
const signals = { platformRequests: 0 };
|
|
2906
|
+
// #1705 PR 2: exported-type rows this invoke (and its consumers' tail) committed, for
|
|
2907
|
+
// `onExportedEvents`. Counted only in a deployment that exports something, and never
|
|
2908
|
+
// for a read-only session, which commits nothing.
|
|
2909
|
+
const exportTypes = session?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
|
|
2910
|
+
let exported = 0;
|
|
2829
2911
|
// #129: read inside the transaction, reported after it commits — a tag
|
|
2830
2912
|
// handed out for writes that were rolled back is a tag nobody may hold.
|
|
2831
2913
|
let committedVersion;
|
|
@@ -2839,6 +2921,9 @@ export class SqliteScopeHost {
|
|
|
2839
2921
|
// `finally` so a later consumer carries none rather than the last caller's,
|
|
2840
2922
|
// which is the leak `causedBy` had to be moved off the host to avoid.
|
|
2841
2923
|
rt.invocationId = invokeOptions?.invocationId ?? null;
|
|
2924
|
+
// The outbox's insertion mark, inside the actor task so nothing else writes between it
|
|
2925
|
+
// and this invoke (`OUTBOX_MARK_SQL` says why rowid and not the id).
|
|
2926
|
+
const exportMark = exportTypes.length > 0 ? Number(rt.db.prepare(OUTBOX_MARK_SQL).get().mark) : null;
|
|
2842
2927
|
try {
|
|
2843
2928
|
// #1672: the capability door's session, re-resolved on EVERY invoke and inside the
|
|
2844
2929
|
// actor task — so nothing can revoke between this read and the transaction below.
|
|
@@ -2986,6 +3071,10 @@ export class SqliteScopeHost {
|
|
|
2986
3071
|
await this.dispatch(rt, rt.invocationId);
|
|
2987
3072
|
await this.dispatchExecutors(rt, rt.invocationId);
|
|
2988
3073
|
}
|
|
3074
|
+
if (exportMark !== null && !replayed) {
|
|
3075
|
+
const q = exportedSinceQuery(exportTypes, exportMark);
|
|
3076
|
+
exported = Number(rt.db.prepare(q.sql).get(...q.params).n);
|
|
3077
|
+
}
|
|
2989
3078
|
return structuredClone(result);
|
|
2990
3079
|
}
|
|
2991
3080
|
finally {
|
|
@@ -2999,6 +3088,8 @@ export class SqliteScopeHost {
|
|
|
2999
3088
|
});
|
|
3000
3089
|
if (signals.platformRequests > 0)
|
|
3001
3090
|
options?.onPlatformRequests?.(signals.platformRequests);
|
|
3091
|
+
if (exported > 0)
|
|
3092
|
+
options?.onExportedEvents?.(exported);
|
|
3002
3093
|
if (committedVersion !== undefined)
|
|
3003
3094
|
invokeOptions?.onEntityVersion?.(committedVersion);
|
|
3004
3095
|
if (replayed)
|
|
@@ -3401,7 +3492,7 @@ export class SqliteScopeHost {
|
|
|
3401
3492
|
.get(scopeId);
|
|
3402
3493
|
// K-3: a scope under another tenant is indistinguishable from one that does not exist.
|
|
3403
3494
|
if (!row || row.tenant_id !== tenantId) {
|
|
3404
|
-
throw
|
|
3495
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3405
3496
|
}
|
|
3406
3497
|
// `provisioning` allowed — a scope stuck there on a failed migration is a
|
|
3407
3498
|
// sweep target. Suspended/archived are deliberate states; not disturbed.
|
|
@@ -3694,7 +3785,7 @@ export class SqliteScopeHost {
|
|
|
3694
3785
|
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
3695
3786
|
.get(scopeId);
|
|
3696
3787
|
if (!rec || rec.tenant_id !== tenantId) {
|
|
3697
|
-
throw
|
|
3788
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3698
3789
|
}
|
|
3699
3790
|
}
|
|
3700
3791
|
/**
|
|
@@ -4009,33 +4100,40 @@ export class SqliteScopeHost {
|
|
|
4009
4100
|
.prepare('SELECT tenant_id, status, vertical FROM scopes WHERE scope_id = ?')
|
|
4010
4101
|
.get(scopeId);
|
|
4011
4102
|
if (!row || row.tenant_id !== tenantId) {
|
|
4012
|
-
throw
|
|
4103
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4013
4104
|
}
|
|
4014
4105
|
if (!from.includes(row.status)) {
|
|
4015
4106
|
throw new Error(`illegal scope transition for ${action}: ${row.status} → ${to} ` +
|
|
4016
4107
|
`(allowed from: ${from.join('|')})`);
|
|
4017
4108
|
}
|
|
4018
|
-
//
|
|
4019
|
-
//
|
|
4020
|
-
//
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4109
|
+
// One directory transaction: the status, the audit row and — on a reap — the scope's
|
|
4110
|
+
// switch records (#1674). Reaped is terminal, so a cleanup that failed after the flip
|
|
4111
|
+
// could never be retried; together, a failure leaves the scope where it was.
|
|
4112
|
+
this.directory.transaction(() => {
|
|
4113
|
+
// Stamp/clear archived_at so the reap sweep can age scopes (§4.4). Entering
|
|
4114
|
+
// `archived` records when; `unarchive` (→ active, a restore) clears it so a later
|
|
4115
|
+
// re-archive dates from the new event; `reaped` keeps it as terminal history.
|
|
4116
|
+
if (to === 'archived') {
|
|
4117
|
+
this.directory
|
|
4118
|
+
.prepare('UPDATE scopes SET status = ?, archived_at = ? WHERE scope_id = ?')
|
|
4119
|
+
.run(to, new Date().toISOString(), scopeId);
|
|
4120
|
+
}
|
|
4121
|
+
else if (to === 'active') {
|
|
4122
|
+
this.directory
|
|
4123
|
+
.prepare('UPDATE scopes SET status = ?, archived_at = NULL WHERE scope_id = ?')
|
|
4124
|
+
.run(to, scopeId);
|
|
4125
|
+
}
|
|
4126
|
+
else {
|
|
4127
|
+
this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
|
|
4128
|
+
}
|
|
4129
|
+
if (to === 'reaped')
|
|
4130
|
+
forgetSystemSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
4131
|
+
// The audit target carries the scope's vertical (control-plane.md §4.4:
|
|
4132
|
+
// "vertical stays null until §4.2 lifecycle actions that name one"). It is
|
|
4133
|
+
// read from the scope rather than passed in, so the trail cannot disagree
|
|
4134
|
+
// with the directory about which deployment the action touched.
|
|
4135
|
+
this.recordAdmin(actor, action, { tenantId, scopeId, vertical: row.vertical }, { status: row.status }, { status: to, ...afterExtra });
|
|
4136
|
+
})();
|
|
4039
4137
|
};
|
|
4040
4138
|
const writeTenantTuple = (tenantId, subject, relation, object, expiresAt) => this.directory
|
|
4041
4139
|
.prepare(`INSERT OR REPLACE INTO _substrat_tenant_tuples
|
|
@@ -4095,6 +4193,44 @@ export class SqliteScopeHost {
|
|
|
4095
4193
|
const target = { tenantId, scopeId };
|
|
4096
4194
|
const base = { operationId, moduleId: input.moduleId, schedules: to };
|
|
4097
4195
|
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
4196
|
+
// The directory's record (#1674): ON before the scope moves, OFF after it held — see
|
|
4197
|
+
// `recordSystemSwitchedOn` for why that order is the safe one.
|
|
4198
|
+
const at = new Date().toISOString();
|
|
4199
|
+
const directorySql = switchSqlOf(this.directory);
|
|
4200
|
+
const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
|
|
4201
|
+
const errorOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
4202
|
+
let prior = null;
|
|
4203
|
+
if (to === 'on') {
|
|
4204
|
+
try {
|
|
4205
|
+
prior = recordSystemSwitchedOn(directorySql, record);
|
|
4206
|
+
}
|
|
4207
|
+
catch (err) {
|
|
4208
|
+
// Nothing has moved: fail the call here, audited — the Cloudflare adapter's posture.
|
|
4209
|
+
try {
|
|
4210
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) });
|
|
4211
|
+
}
|
|
4212
|
+
catch {
|
|
4213
|
+
// Best effort: the original error is what the caller must see.
|
|
4214
|
+
}
|
|
4215
|
+
throw err;
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
/** A record write after the scope moved (review #5): retried once, never swallowed. */
|
|
4219
|
+
const recordWrite = (write) => {
|
|
4220
|
+
try {
|
|
4221
|
+
write();
|
|
4222
|
+
return null;
|
|
4223
|
+
}
|
|
4224
|
+
catch {
|
|
4225
|
+
try {
|
|
4226
|
+
write();
|
|
4227
|
+
return null;
|
|
4228
|
+
}
|
|
4229
|
+
catch (err) {
|
|
4230
|
+
return errorOf(err);
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
};
|
|
4098
4234
|
let outcome;
|
|
4099
4235
|
try {
|
|
4100
4236
|
// A turn on the scope actor (#1666 review), exactly as the job store (#1577): `invoke`
|
|
@@ -4103,19 +4239,16 @@ export class SqliteScopeHost {
|
|
|
4103
4239
|
// with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
|
|
4104
4240
|
// caller already inside one of this scope's tasks joins it rather than deadlocking.
|
|
4105
4241
|
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
|
-
}))());
|
|
4242
|
+
outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId: input.moduleId, scopeId, to, at }))());
|
|
4112
4243
|
}
|
|
4113
4244
|
catch (err) {
|
|
4245
|
+
const recordError = recordWrite(() => restoreSystemSwitchRecord(directorySql, record, prior));
|
|
4114
4246
|
try {
|
|
4115
4247
|
this.recordAdmin(actor, action, target, null, {
|
|
4116
4248
|
...base,
|
|
4117
4249
|
phase: 'failed',
|
|
4118
|
-
error:
|
|
4250
|
+
error: errorOf(err),
|
|
4251
|
+
...(recordError ? { recordError } : {}),
|
|
4119
4252
|
});
|
|
4120
4253
|
}
|
|
4121
4254
|
catch {
|
|
@@ -4123,15 +4256,30 @@ export class SqliteScopeHost {
|
|
|
4123
4256
|
}
|
|
4124
4257
|
throw err;
|
|
4125
4258
|
}
|
|
4259
|
+
// OFF is recorded once the scope held it. A refused ON moved nothing, so its record
|
|
4260
|
+
// write is undone: left `on`, the next reconcile of a wiped scope would leave the
|
|
4261
|
+
// module running.
|
|
4262
|
+
const recordError = to === 'off' && outcome.held
|
|
4263
|
+
? recordWrite(() => recordSystemSwitchedOff(directorySql, record))
|
|
4264
|
+
: to === 'on' && !outcome.held
|
|
4265
|
+
? recordWrite(() => restoreSystemSwitchRecord(directorySql, record, prior))
|
|
4266
|
+
: null;
|
|
4126
4267
|
this.recordAdmin(actor, action, target, null, {
|
|
4127
4268
|
...base,
|
|
4128
4269
|
phase: outcome.held ? 'applied' : 'refused',
|
|
4129
4270
|
changed: outcome.changed,
|
|
4130
4271
|
permissions: outcome.permissions,
|
|
4272
|
+
...(recordError ? { recordError } : {}),
|
|
4131
4273
|
});
|
|
4132
4274
|
if (!outcome.held) {
|
|
4133
4275
|
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')`
|
|
4276
|
+
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
|
|
4277
|
+
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
4278
|
+
}
|
|
4279
|
+
if (recordError) {
|
|
4280
|
+
throw substratError('unavailable', `module '${input.moduleId}' is switched ${to} on scope ${scopeId}, but the directory's record of it ` +
|
|
4281
|
+
`could not be written (${recordError}) — repeat the call to record it; a wipe of this scope would ` +
|
|
4282
|
+
`otherwise lose the switch`);
|
|
4135
4283
|
}
|
|
4136
4284
|
return {
|
|
4137
4285
|
operationId,
|
|
@@ -4267,10 +4415,12 @@ export class SqliteScopeHost {
|
|
|
4267
4415
|
const states = await rt.actor.turn(() => systemGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
|
|
4268
4416
|
const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
|
|
4269
4417
|
const explanations = offModules.size > 0 ? lastSwitchedOff(tenantId, scopeId, offModules) : new Map();
|
|
4270
|
-
const
|
|
4418
|
+
const recorded = systemSwitchRecordsOf(switchSqlOf(this.directory), tenantId, scopeId);
|
|
4419
|
+
const result = withRecorded(states, recorded).map((s) => ({
|
|
4271
4420
|
moduleId: s.moduleId,
|
|
4272
4421
|
schedules: s.schedules,
|
|
4273
4422
|
switchedOff: explanations.get(s.moduleId) ?? null,
|
|
4423
|
+
recorded: s.recorded,
|
|
4274
4424
|
}));
|
|
4275
4425
|
// K-24: reading the switch's position and any live incident reason is itself
|
|
4276
4426
|
// access-logged, the same as every other HostAdmin read.
|
|
@@ -4425,7 +4575,7 @@ export class SqliteScopeHost {
|
|
|
4425
4575
|
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
4426
4576
|
.get(grant.node.scopeId);
|
|
4427
4577
|
if (!scope || scope.tenant_id !== grant.node.tenantId) {
|
|
4428
|
-
throw
|
|
4578
|
+
throw substratError('not_found', `unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
|
|
4429
4579
|
}
|
|
4430
4580
|
if (scope.vertical !== conn.vertical) {
|
|
4431
4581
|
throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
|
|
@@ -4501,6 +4651,18 @@ export class SqliteScopeHost {
|
|
|
4501
4651
|
// #1674: the switch's status read — same gate, the admin log to explain an `off`
|
|
4502
4652
|
// entry. Nothing to delegate here: the pure adapter's scope storage IS the store.
|
|
4503
4653
|
systemGrantsStatus: systemGrantsStatusOf,
|
|
4654
|
+
// #1674: the switch's directory record — the fleet read, and the re-assert (whose body
|
|
4655
|
+
// is `reassertSwitchesInTurn`; this is the scope check and the turn around it).
|
|
4656
|
+
listSystemSwitches: async (actor, filter) => {
|
|
4657
|
+
const rows = listSystemSwitchRecords(switchSqlOf(this.directory), filter);
|
|
4658
|
+
this.recordAccess(actor, 'listSystemSwitches', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
4659
|
+
return rows.map((r) => systemSwitchRecord.parse(r));
|
|
4660
|
+
},
|
|
4661
|
+
reassertSystemSwitches: async (actor, node) => {
|
|
4662
|
+
this.assertScope(node.tenantId, node.scopeId);
|
|
4663
|
+
const rt = this.runtime(node.tenantId, node.scopeId);
|
|
4664
|
+
return rt.actor.turn(() => this.reassertSwitchesInTurn(rt, actor, node.tenantId, node.scopeId));
|
|
4665
|
+
},
|
|
4504
4666
|
peerGrantsStatus: peerGrantsStatusOf,
|
|
4505
4667
|
// #1672 — the platform's two capability verbs, both a turn on the scope actor
|
|
4506
4668
|
// (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
|
|
@@ -4557,7 +4719,7 @@ export class SqliteScopeHost {
|
|
|
4557
4719
|
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
4558
4720
|
.get(parsed.scopeId);
|
|
4559
4721
|
if (!scope || scope.tenant_id !== parsed.tenantId) {
|
|
4560
|
-
throw
|
|
4722
|
+
throw substratError('not_found', `unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
|
|
4561
4723
|
}
|
|
4562
4724
|
const existing = this.directory
|
|
4563
4725
|
.prepare(`SELECT h.scope_id AS scope_id, s.status AS scope_status
|
|
@@ -4690,6 +4852,8 @@ export class SqliteScopeHost {
|
|
|
4690
4852
|
resolveHostname: async (raw) => {
|
|
4691
4853
|
// The router's per-request read. No actor, not logged — same carve-out as
|
|
4692
4854
|
// resolveIdentity (K-24): this is a machine path, not a staff read.
|
|
4855
|
+
// Like readRoute in Cloudflare, require an active binding, correctly paired
|
|
4856
|
+
// active scope and active owning tenant. Refusal leaves the binding intact.
|
|
4693
4857
|
const hostname = raw.toLowerCase();
|
|
4694
4858
|
// Join the scope's dispatch script in the same read (orchestration.md §5.4).
|
|
4695
4859
|
// A scope on the stable serving script (#286) routes THERE; falls back to the
|
|
@@ -4704,11 +4868,13 @@ export class SqliteScopeHost {
|
|
|
4704
4868
|
THEN json_extract(sv.manifest_json, '$.outbound')
|
|
4705
4869
|
ELSE json_extract(vv.manifest_json, '$.outbound') END AS outbound_json
|
|
4706
4870
|
FROM hostnames h
|
|
4707
|
-
|
|
4871
|
+
JOIN scopes s ON s.scope_id = h.scope_id AND s.tenant_id = h.tenant_id
|
|
4872
|
+
JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
4708
4873
|
LEFT JOIN vertical_versions vv ON vv.id = s.vertical_version_id
|
|
4709
4874
|
LEFT JOIN verticals vr ON vr.slug = s.vertical
|
|
4710
4875
|
LEFT JOIN vertical_versions sv ON sv.id = vr.serving_version_id
|
|
4711
|
-
WHERE h.hostname = ? AND h.status = 'active'
|
|
4876
|
+
WHERE h.hostname = ? AND h.status = 'active'
|
|
4877
|
+
AND s.status = 'active' AND t.status = 'active'`)
|
|
4712
4878
|
.get(hostname);
|
|
4713
4879
|
if (!r)
|
|
4714
4880
|
return undefined;
|
|
@@ -4974,6 +5140,18 @@ export class SqliteScopeHost {
|
|
|
4974
5140
|
note,
|
|
4975
5141
|
});
|
|
4976
5142
|
},
|
|
5143
|
+
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
5144
|
+
const incoming = readVersion(versionId);
|
|
5145
|
+
if (!incoming || incoming.verticalSlug !== verticalSlug) {
|
|
5146
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
5147
|
+
}
|
|
5148
|
+
const current = this.directory
|
|
5149
|
+
.prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
|
|
5150
|
+
.get(verticalSlug, channel);
|
|
5151
|
+
const breaks = current ? await this.exportBreaksBetween(actor, verticalSlug, current.version_id, versionId) : [];
|
|
5152
|
+
this.recordAccess(actor, 'promotionImpact', { tenantId: null }, { verticalSlug, channel, versionId }, breaks.length);
|
|
5153
|
+
return breaks;
|
|
5154
|
+
},
|
|
4977
5155
|
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
4978
5156
|
const incoming = readVersion(versionId);
|
|
4979
5157
|
if (!incoming)
|
|
@@ -5001,6 +5179,12 @@ export class SqliteScopeHost {
|
|
|
5001
5179
|
throw new Error(`promotion changes migrations (${outgoing.migrationDigest} → ` +
|
|
5002
5180
|
`${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
|
|
5003
5181
|
}
|
|
5182
|
+
// #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
|
|
5183
|
+
if (!ack.exportBreak) {
|
|
5184
|
+
const breaks = await this.exportBreaksBetween(actor, verticalSlug, outgoing.id, incoming.id);
|
|
5185
|
+
if (breaks.length > 0)
|
|
5186
|
+
throw substratError('precondition_failed', exportBreakRefusal(breaks));
|
|
5187
|
+
}
|
|
5004
5188
|
}
|
|
5005
5189
|
const promotedAt = new Date().toISOString();
|
|
5006
5190
|
this.directory
|
|
@@ -5020,8 +5204,8 @@ export class SqliteScopeHost {
|
|
|
5020
5204
|
// promote) is a complete deploy and a rollback promote reaches the running
|
|
5021
5205
|
// app. D-30's lockstep concern is a SHARED vertical's many tenants, which a
|
|
5022
5206
|
// private vertical cannot have — this fires for no one else. Snapshots and
|
|
5023
|
-
// forks (forked_from set) keep their frontier untouched
|
|
5024
|
-
// crosses a migration digest snapshots first (fork-before-promote, §4).
|
|
5207
|
+
// forks (forked_from set) and all previews keep their frontier untouched. A rebind
|
|
5208
|
+
// that crosses a migration digest snapshots first (fork-before-promote, §4).
|
|
5025
5209
|
//
|
|
5026
5210
|
// EXCEPTION (#321): a DISPATCH-BACKED vertical (its version has a
|
|
5027
5211
|
// `deploymentRef`) serves in place off a stable script. Rebinding a legacy
|
|
@@ -5036,7 +5220,7 @@ export class SqliteScopeHost {
|
|
|
5036
5220
|
if (owning && owning.ownerTenant !== null && !owning.listed) {
|
|
5037
5221
|
const bound = this.directory
|
|
5038
5222
|
.prepare(`SELECT scope_id, tenant_id, vertical_version_id FROM scopes
|
|
5039
|
-
WHERE vertical = ? AND tenant_id = ? AND status = 'active' AND forked_from IS NULL`)
|
|
5223
|
+
WHERE vertical = ? AND tenant_id = ? AND status = 'active' AND forked_from IS NULL AND kind != 'preview'`)
|
|
5040
5224
|
.all(verticalSlug, owning.ownerTenant);
|
|
5041
5225
|
for (const s of bound) {
|
|
5042
5226
|
if (s.vertical_version_id === versionId)
|
|
@@ -5112,7 +5296,7 @@ export class SqliteScopeHost {
|
|
|
5112
5296
|
.prepare('SELECT tenant_id, kind, vertical_version_id FROM scopes WHERE scope_id = ?')
|
|
5113
5297
|
.get(scopeId);
|
|
5114
5298
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
5115
|
-
throw
|
|
5299
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
5116
5300
|
}
|
|
5117
5301
|
// The refusal this registry exists for. Without it, "a push lands pending"
|
|
5118
5302
|
// is a convention, and D-30's argument is that we cannot afford conventions
|
|
@@ -5151,7 +5335,7 @@ export class SqliteScopeHost {
|
|
|
5151
5335
|
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
5152
5336
|
.get(scopeId);
|
|
5153
5337
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
5154
|
-
throw
|
|
5338
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
5155
5339
|
}
|
|
5156
5340
|
this.directory
|
|
5157
5341
|
.prepare('UPDATE scopes SET provisioned_version_id = ? WHERE scope_id = ?')
|
|
@@ -5202,7 +5386,7 @@ export class SqliteScopeHost {
|
|
|
5202
5386
|
.prepare('SELECT tenant_id, serving_ref FROM scopes WHERE scope_id = ?')
|
|
5203
5387
|
.get(scopeId);
|
|
5204
5388
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
5205
|
-
throw
|
|
5389
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
5206
5390
|
}
|
|
5207
5391
|
this.directory
|
|
5208
5392
|
.prepare('UPDATE scopes SET serving_ref = ? WHERE scope_id = ?')
|
|
@@ -5214,7 +5398,7 @@ export class SqliteScopeHost {
|
|
|
5214
5398
|
.prepare('SELECT tenant_id, expires_at FROM scopes WHERE scope_id = ?')
|
|
5215
5399
|
.get(scopeId);
|
|
5216
5400
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
5217
|
-
throw
|
|
5401
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
5218
5402
|
}
|
|
5219
5403
|
this.directory
|
|
5220
5404
|
.prepare('UPDATE scopes SET expires_at = ? WHERE scope_id = ?')
|
|
@@ -5295,6 +5479,47 @@ export class SqliteScopeHost {
|
|
|
5295
5479
|
this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, cursors.length);
|
|
5296
5480
|
return { consumes, cursors };
|
|
5297
5481
|
},
|
|
5482
|
+
moveImportCursor: async (actor, tenantId, scopeId, raw) => {
|
|
5483
|
+
// #1705 PR 3: the replay lever. Parsed at the door: the acknowledgement literal is part
|
|
5484
|
+
// of the input, and a move without it never reaches the store.
|
|
5485
|
+
const move = importCursorMove.parse(raw);
|
|
5486
|
+
this.assertScopeReachable(tenantId, scopeId);
|
|
5487
|
+
const rec = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
5488
|
+
const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
|
|
5489
|
+
const replayId = ulid();
|
|
5490
|
+
const target = { tenantId, scopeId };
|
|
5491
|
+
const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
5492
|
+
this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
|
|
5493
|
+
let moved;
|
|
5494
|
+
try {
|
|
5495
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
5496
|
+
const now = Date.parse(this.clock());
|
|
5497
|
+
// One turn on the scope actor, which is where `deliverToPeer` runs: a delivery on this
|
|
5498
|
+
// edge is either wholly before the move or refused by its compare-and-set after it.
|
|
5499
|
+
moved = await rt.actor.turn(() => rt.db.transaction(() => moveImportCursor(switchSqlOf(rt.db), { move, source, replayId, now, imports: this.crossVertical.consumes() }))());
|
|
5500
|
+
}
|
|
5501
|
+
catch (err) {
|
|
5502
|
+
try {
|
|
5503
|
+
this.recordAdmin(actor, 'moveImportCursor', target, null, {
|
|
5504
|
+
...base,
|
|
5505
|
+
phase: 'failed',
|
|
5506
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5507
|
+
});
|
|
5508
|
+
}
|
|
5509
|
+
catch {
|
|
5510
|
+
// Best effort: the original error is what the caller must see.
|
|
5511
|
+
}
|
|
5512
|
+
throw err;
|
|
5513
|
+
}
|
|
5514
|
+
this.recordAdmin(actor, 'moveImportCursor', target, null, {
|
|
5515
|
+
...base,
|
|
5516
|
+
phase: 'applied',
|
|
5517
|
+
previous: moved.previous,
|
|
5518
|
+
cursor: moved.cursor,
|
|
5519
|
+
archived: moved.archived,
|
|
5520
|
+
});
|
|
5521
|
+
return moved;
|
|
5522
|
+
},
|
|
5298
5523
|
redrainEvents: async (actor, tenantId, scopeId, input) => {
|
|
5299
5524
|
// Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
|
|
5300
5525
|
// address this scope" are different answers.
|
|
@@ -5430,7 +5655,7 @@ export class SqliteScopeHost {
|
|
|
5430
5655
|
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
5431
5656
|
.get(scopeId);
|
|
5432
5657
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
5433
|
-
throw
|
|
5658
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
5434
5659
|
}
|
|
5435
5660
|
// No PITR on plain SQLite — an empty list, not an error: there is simply
|
|
5436
5661
|
// nothing to offer. The backup/restore path (#278) is this host's rewind.
|
|
@@ -5850,7 +6075,7 @@ export class SqliteScopeHost {
|
|
|
5850
6075
|
})();
|
|
5851
6076
|
// Carry a copy taken before a directory migration forward to the running
|
|
5852
6077
|
// code's shape — the same assertion a cold start makes.
|
|
5853
|
-
this.
|
|
6078
|
+
this.ensureDirectorySchema();
|
|
5854
6079
|
// Roles are held in memory (`loadRoles`), so a restore that only rewrote the
|
|
5855
6080
|
// table would leave every permission check reading the PRE-restore roles until
|
|
5856
6081
|
// the process restarted — the exact silent-divergence a recovery must not have.
|
|
@@ -5890,7 +6115,7 @@ export class SqliteScopeHost {
|
|
|
5890
6115
|
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
5891
6116
|
.get(scopeId);
|
|
5892
6117
|
if (!rec || rec.tenant_id !== tenantId) {
|
|
5893
|
-
throw
|
|
6118
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5894
6119
|
}
|
|
5895
6120
|
if (rec.status !== 'archived') {
|
|
5896
6121
|
throw new Error(`scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
|
|
@@ -6072,6 +6297,7 @@ export class SqliteScopeHost {
|
|
|
6072
6297
|
'_substrat_roles', // operator-defined roles
|
|
6073
6298
|
'_substrat_entitlements', // per-tenant SKU flags
|
|
6074
6299
|
'orgs', // K-22 org records
|
|
6300
|
+
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
6075
6301
|
];
|
|
6076
6302
|
const clear = this.directory.transaction(() => {
|
|
6077
6303
|
for (const table of tables) {
|
|
@@ -7375,7 +7601,7 @@ export class SqliteScopeHost {
|
|
|
7375
7601
|
tenantId: rt.tenantId,
|
|
7376
7602
|
scopeId: rt.scopeId,
|
|
7377
7603
|
principal,
|
|
7378
|
-
sql: guardSecrets(scopedSql(rt.db), minted),
|
|
7604
|
+
sql: guardSecrets(guardSqlLimits(scopedSql(rt.db)), minted),
|
|
7379
7605
|
now: () => at,
|
|
7380
7606
|
emit: (event) => {
|
|
7381
7607
|
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
@@ -7730,7 +7956,7 @@ export class SqliteScopeHost {
|
|
|
7730
7956
|
assertScopeReachable(tenantId, scopeId) {
|
|
7731
7957
|
const r = this.directory.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
7732
7958
|
if (!r || r.tenant_id !== tenantId)
|
|
7733
|
-
throw
|
|
7959
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
7734
7960
|
if (r.status === 'reaped') {
|
|
7735
7961
|
throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
|
|
7736
7962
|
}
|