@substrat-run/adapter-sqlite 0.98.0 → 0.102.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 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +403 -23
- 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, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, 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, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, } from '@substrat-run/contracts';
|
|
5
|
-
import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, OPS_FAILURE_RETENTION_DAYS, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, 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, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, } from '@substrat-run/kernel';
|
|
4
|
+
import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, 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, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, } from '@substrat-run/contracts';
|
|
5
|
+
import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, 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, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, } from '@substrat-run/kernel';
|
|
6
6
|
import { ScopeActor } from './actor.js';
|
|
7
7
|
import { createTupleChecker } from './checker.js';
|
|
8
8
|
const KERNEL_DDL = `
|
|
@@ -26,8 +26,19 @@ const KERNEL_DDL = `
|
|
|
26
26
|
-- session/by). NULL is the ordinary case — nobody was impersonating — and the
|
|
27
27
|
-- actor column above stays the principal the permission model answered about.
|
|
28
28
|
impersonation TEXT,
|
|
29
|
+
-- #1231: the invoke() string this event was emitted from. NULL is two facts the
|
|
30
|
+
-- spine cannot tell apart afterwards: a consumer emit (no operation ran), and a
|
|
31
|
+
-- row written before the column.
|
|
32
|
+
operation TEXT,
|
|
33
|
+
-- #1242: the version REGISTRY id of the vertical version the host ran when this
|
|
34
|
+
-- event was emitted (the signals version dimension, #1231). NULL = the host was
|
|
35
|
+
-- handed no version id, or the row predates the column.
|
|
36
|
+
version TEXT,
|
|
29
37
|
drained_at TEXT
|
|
30
38
|
);
|
|
39
|
+
-- #1232: the freshness evaluator's read — MAX(occurred_at) per type, every pass,
|
|
40
|
+
-- over a table that is never pruned. Unindexed, that is a full history scan.
|
|
41
|
+
CREATE INDEX IF NOT EXISTS _substrat_outbox_type_at ON _substrat_outbox (type, occurred_at);
|
|
31
42
|
-- platform-intents.md: durable intents a vertical enqueues (ctx.requestPlatform) for the platform
|
|
32
43
|
-- to drain and execute with HostAdmin authority — the sandbox-clean way a vertical asks for a
|
|
33
44
|
-- privileged action. Written by the kernel (spine), settled by the platform drain.
|
|
@@ -196,6 +207,23 @@ function keysetTail(where, params, key, page, defaultOrder = 'asc') {
|
|
|
196
207
|
}
|
|
197
208
|
return tail;
|
|
198
209
|
}
|
|
210
|
+
/** snake_case row → the camelCase `issueEntry` shape (#1233). */
|
|
211
|
+
function issueOf(r) {
|
|
212
|
+
return {
|
|
213
|
+
fingerprint: r.fingerprint,
|
|
214
|
+
operation: r.operation,
|
|
215
|
+
stage: r.stage,
|
|
216
|
+
origin: r.origin,
|
|
217
|
+
code: r.code,
|
|
218
|
+
status: r.status,
|
|
219
|
+
count: r.seen_count,
|
|
220
|
+
firstSeen: r.first_seen,
|
|
221
|
+
lastSeen: r.last_seen,
|
|
222
|
+
lastMessage: r.last_message,
|
|
223
|
+
lastVertical: r.last_vertical,
|
|
224
|
+
resolvedAt: r.resolved_at,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
199
227
|
/** A ledger row → the wire entry, the attribution re-nested. Shared by both adapters' shape. */
|
|
200
228
|
function modelUsageEntryOf(r) {
|
|
201
229
|
return modelUsageEntry.parse({
|
|
@@ -337,10 +365,12 @@ export class SqliteScopeHost {
|
|
|
337
365
|
secretBox;
|
|
338
366
|
fetchImpl;
|
|
339
367
|
clock;
|
|
368
|
+
versionId;
|
|
340
369
|
constructor(options) {
|
|
341
370
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
342
|
-
this.fetchImpl = options.fetch ??
|
|
371
|
+
this.fetchImpl = options.fetch ?? globalFetch;
|
|
343
372
|
this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
|
|
373
|
+
this.versionId = options.versionId ?? null;
|
|
344
374
|
this.dir = options.dir;
|
|
345
375
|
mkdirSync(this.dir, { recursive: true });
|
|
346
376
|
this.directory = new Database(join(this.dir, '_directory.sqlite'));
|
|
@@ -822,15 +852,69 @@ export class SqliteScopeHost {
|
|
|
822
852
|
tenant_id TEXT,
|
|
823
853
|
scope_id TEXT,
|
|
824
854
|
vertical TEXT,
|
|
855
|
+
version TEXT,
|
|
825
856
|
status INTEGER,
|
|
826
857
|
message TEXT NOT NULL,
|
|
827
858
|
reference TEXT,
|
|
859
|
+
origin TEXT,
|
|
860
|
+
code TEXT,
|
|
861
|
+
fingerprint TEXT,
|
|
828
862
|
at TEXT NOT NULL
|
|
829
863
|
);
|
|
830
864
|
CREATE INDEX IF NOT EXISTS _substrat_ops_failures_vertical ON _substrat_ops_failures (vertical, id);
|
|
831
865
|
CREATE INDEX IF NOT EXISTS _substrat_ops_failures_tenant ON _substrat_ops_failures (tenant_id, id);
|
|
832
866
|
CREATE INDEX IF NOT EXISTS _substrat_ops_failures_reference ON _substrat_ops_failures (reference);
|
|
833
867
|
CREATE INDEX IF NOT EXISTS _substrat_ops_failures_at ON _substrat_ops_failures (at);
|
|
868
|
+
-- #1233: fingerprint-grouped failure classes ("issues"), materialized on
|
|
869
|
+
-- every ops-failure insert. The row OWNS its counters: the evidence beneath
|
|
870
|
+
-- it prunes at 90 days, and a count must survive its own exemplars.
|
|
871
|
+
CREATE TABLE IF NOT EXISTS _substrat_issues (
|
|
872
|
+
fingerprint TEXT PRIMARY KEY,
|
|
873
|
+
operation TEXT NOT NULL,
|
|
874
|
+
stage TEXT,
|
|
875
|
+
origin TEXT,
|
|
876
|
+
code TEXT,
|
|
877
|
+
status TEXT NOT NULL DEFAULT 'new',
|
|
878
|
+
seen_count INTEGER NOT NULL DEFAULT 0,
|
|
879
|
+
first_seen TEXT NOT NULL,
|
|
880
|
+
last_seen TEXT NOT NULL,
|
|
881
|
+
last_message TEXT NOT NULL,
|
|
882
|
+
last_vertical TEXT,
|
|
883
|
+
resolved_at TEXT
|
|
884
|
+
);
|
|
885
|
+
CREATE INDEX IF NOT EXISTS _substrat_issues_seen ON _substrat_issues (last_seen);
|
|
886
|
+
-- The durable sweep record (#1232): one row per unit outcome per pass —
|
|
887
|
+
-- a connection swept/skipped/failed, a schedule fired/skipped/failed. What
|
|
888
|
+
-- makes "when was this last swept" answerable after the log line rolls off.
|
|
889
|
+
-- Retention-bounded harder than ops failures (SWEEP_RUN_RETENTION_DAYS):
|
|
890
|
+
-- high-frequency telemetry whose job is the recent-runs strip.
|
|
891
|
+
CREATE TABLE IF NOT EXISTS _substrat_sweep_runs (
|
|
892
|
+
id TEXT PRIMARY KEY,
|
|
893
|
+
kind TEXT NOT NULL,
|
|
894
|
+
unit TEXT NOT NULL,
|
|
895
|
+
outcome TEXT NOT NULL,
|
|
896
|
+
tenant_id TEXT,
|
|
897
|
+
scope_id TEXT,
|
|
898
|
+
vertical TEXT,
|
|
899
|
+
version TEXT,
|
|
900
|
+
operation TEXT,
|
|
901
|
+
connection_id TEXT,
|
|
902
|
+
error TEXT,
|
|
903
|
+
elapsed_ms INTEGER,
|
|
904
|
+
-- #1232: the platform-intent id a drained batch arrived under (NULL for the
|
|
905
|
+
-- direct sweep path). The unique index below is what makes a replayed drain
|
|
906
|
+
-- write nothing twice; NULLs are distinct, so direct writes never collide.
|
|
907
|
+
request_id TEXT,
|
|
908
|
+
-- #1232 freshness rows: the judged event type (its own dimension, never
|
|
909
|
+
-- the operation column) and the newest matching evidence. NULL on other kinds.
|
|
910
|
+
event_type TEXT,
|
|
911
|
+
observed_at TEXT,
|
|
912
|
+
at TEXT NOT NULL
|
|
913
|
+
);
|
|
914
|
+
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_unit ON _substrat_sweep_runs (kind, unit, id);
|
|
915
|
+
CREATE UNIQUE INDEX IF NOT EXISTS _substrat_sweep_runs_intent ON _substrat_sweep_runs (request_id, unit);
|
|
916
|
+
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_tenant ON _substrat_sweep_runs (tenant_id, id);
|
|
917
|
+
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_at ON _substrat_sweep_runs (at);
|
|
834
918
|
-- Model usage (#1054, meter 3): one line per model call a vertical made through
|
|
835
919
|
-- the platform's model host, drained here as a model-usage intent. request_id is
|
|
836
920
|
-- the intent id and UNIQUE, which is what makes a retried drain write nothing twice.
|
|
@@ -1069,6 +1153,7 @@ export class SqliteScopeHost {
|
|
|
1069
1153
|
migrations: [...migrations, ...searchMigrations, ...listMigrations],
|
|
1070
1154
|
consumers,
|
|
1071
1155
|
schedules: manifest.schedules ?? [],
|
|
1156
|
+
freshness: manifest.freshness ?? [],
|
|
1072
1157
|
});
|
|
1073
1158
|
for (const rel of manifest.entityRelations ?? []) {
|
|
1074
1159
|
const parents = this.relations.get(rel.entityType) ?? new Set();
|
|
@@ -1466,7 +1551,7 @@ export class SqliteScopeHost {
|
|
|
1466
1551
|
// like an operation invoke (including K-35 denial recording and prompt dispatch of
|
|
1467
1552
|
// the events it emitted).
|
|
1468
1553
|
const serialized = (operation, fn) => rt.actor.enqueue(async () => {
|
|
1469
|
-
const ctx = this.operationContext(rt, subject);
|
|
1554
|
+
const ctx = this.operationContext(rt, subject, undefined, undefined, undefined, operation);
|
|
1470
1555
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
1471
1556
|
let result;
|
|
1472
1557
|
try {
|
|
@@ -1498,7 +1583,7 @@ export class SqliteScopeHost {
|
|
|
1498
1583
|
*/
|
|
1499
1584
|
const reentrant = async (operation, fn) => {
|
|
1500
1585
|
try {
|
|
1501
|
-
return await fn(this.operationContext(rt, subject));
|
|
1586
|
+
return await fn(this.operationContext(rt, subject, undefined, undefined, undefined, operation));
|
|
1502
1587
|
}
|
|
1503
1588
|
catch (err) {
|
|
1504
1589
|
if (err instanceof PermissionDenied)
|
|
@@ -1692,6 +1777,10 @@ export class SqliteScopeHost {
|
|
|
1692
1777
|
// KERNEL_DDL is all IF NOT EXISTS, so it fills only the gaps and never disturbs a
|
|
1693
1778
|
// table the dump carried.
|
|
1694
1779
|
db.exec(KERNEL_DDL);
|
|
1780
|
+
// …and the additive columns, for the same reason: a dump captured before a
|
|
1781
|
+
// column existed replays DDL WITHOUT it, and IF NOT EXISTS cannot widen a
|
|
1782
|
+
// table the dump did bring.
|
|
1783
|
+
this.ensureSpineColumns(db);
|
|
1695
1784
|
// Rebuild the derived search indexes over the rows just loaded (#827). The DDL
|
|
1696
1785
|
// drops and recreates, so this also repairs an index the dump left stale, and
|
|
1697
1786
|
// the triggers it recreates are what keep the restored scope in step from here.
|
|
@@ -1956,8 +2045,99 @@ export class SqliteScopeHost {
|
|
|
1956
2045
|
}
|
|
1957
2046
|
return out;
|
|
1958
2047
|
}
|
|
2048
|
+
registeredFreshness() {
|
|
2049
|
+
const out = [];
|
|
2050
|
+
for (const mod of this.modules.values()) {
|
|
2051
|
+
if (mod.freshness.length > 0) {
|
|
2052
|
+
out.push({ moduleId: mod.id, freshness: mod.freshness });
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
return out;
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* The freshness evaluator (#1232): judge each declared expectation against this
|
|
2059
|
+
* scope's own outbox, and return only what should be RECORDED — a verdict that
|
|
2060
|
+
* changed since the last recorded one, or one whose hourly heartbeat lapsed.
|
|
2061
|
+
* State rides `_substrat_schedule_state` under a `freshness:<eventType>` key
|
|
2062
|
+
* (operation names are `module/verb`, event types `ns.verb` — the prefix cannot
|
|
2063
|
+
* collide), reusing its columns as (last recorded at, last recorded outcome).
|
|
2064
|
+
* Aggregates across EVERY registered module, not just the asking one: two
|
|
2065
|
+
* modules declaring one type share a single gating-state key and a single
|
|
2066
|
+
* dedupe unit, so per-module evaluation would have them fighting over both —
|
|
2067
|
+
* flip-flopping state, and colliding rows the dedupe index silently eats.
|
|
2068
|
+
* Duplicates collapse to the TIGHTEST window; the caller invokes this once
|
|
2069
|
+
* per scope, with any registered module's id as the entry ticket.
|
|
2070
|
+
*
|
|
2071
|
+
* Deliberately NOT gated on the system grant — see the interface doc.
|
|
2072
|
+
*/
|
|
2073
|
+
async checkFreshness(moduleId, tenantId, scopeId) {
|
|
2074
|
+
const report = { checks: [] };
|
|
2075
|
+
if (!this.modules.has(moduleId))
|
|
2076
|
+
return report;
|
|
2077
|
+
const scope = this.directory
|
|
2078
|
+
.prepare('SELECT status FROM scopes WHERE scope_id = ? AND tenant_id = ?')
|
|
2079
|
+
.get(scopeId, tenantId);
|
|
2080
|
+
if (!scope || scope.status !== 'active')
|
|
2081
|
+
return report;
|
|
2082
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
2083
|
+
await this.applyPendingMigrations(rt);
|
|
2084
|
+
// Same lazy create as runDueSchedules — a freshness-only module must not hit
|
|
2085
|
+
// `no such table` on a scope no schedule ever swept.
|
|
2086
|
+
rt.db.exec(`CREATE TABLE IF NOT EXISTS _substrat_schedule_state (
|
|
2087
|
+
schedule_op TEXT PRIMARY KEY,
|
|
2088
|
+
last_run_at TEXT,
|
|
2089
|
+
last_status TEXT
|
|
2090
|
+
)`);
|
|
2091
|
+
// Collapse duplicate event types to the tightest window, ACROSS modules.
|
|
2092
|
+
const windows = new Map();
|
|
2093
|
+
for (const m of this.modules.values()) {
|
|
2094
|
+
for (const f of m.freshness) {
|
|
2095
|
+
const prev = windows.get(f.eventType);
|
|
2096
|
+
if (prev === undefined || f.within.hours < prev)
|
|
2097
|
+
windows.set(f.eventType, f.within.hours);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
if (windows.size === 0)
|
|
2101
|
+
return report;
|
|
2102
|
+
const types = [...windows.keys()];
|
|
2103
|
+
const observed = new Map();
|
|
2104
|
+
for (const row of rt.db
|
|
2105
|
+
.prepare(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
|
|
2106
|
+
WHERE type IN (${types.map(() => '?').join(', ')}) GROUP BY type`)
|
|
2107
|
+
.all(...types)) {
|
|
2108
|
+
if (row.at !== null)
|
|
2109
|
+
observed.set(row.type, row.at);
|
|
2110
|
+
}
|
|
2111
|
+
const nowIso = this.clock();
|
|
2112
|
+
const now = Date.parse(nowIso);
|
|
2113
|
+
for (const [eventType, withinHours] of windows) {
|
|
2114
|
+
const observedAt = observed.get(eventType) ?? null;
|
|
2115
|
+
const outcome = observedAt === null
|
|
2116
|
+
? 'skipped' // never observed — the never-run analogue, not a failure
|
|
2117
|
+
: now - Date.parse(observedAt) <= withinHours * 3_600_000
|
|
2118
|
+
? 'ok'
|
|
2119
|
+
: 'failed';
|
|
2120
|
+
const stateKey = `freshness:${eventType}`;
|
|
2121
|
+
const state = rt.db
|
|
2122
|
+
.prepare('SELECT last_run_at, last_status FROM _substrat_schedule_state WHERE schedule_op = ?')
|
|
2123
|
+
.get(stateKey);
|
|
2124
|
+
const changed = state?.last_status !== outcome;
|
|
2125
|
+
const heartbeatDue = !state?.last_run_at ||
|
|
2126
|
+
now - Date.parse(state.last_run_at) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
|
|
2127
|
+
if (!changed && !heartbeatDue)
|
|
2128
|
+
continue;
|
|
2129
|
+
rt.db
|
|
2130
|
+
.prepare(`INSERT INTO _substrat_schedule_state (schedule_op, last_run_at, last_status)
|
|
2131
|
+
VALUES (?, ?, ?)
|
|
2132
|
+
ON CONFLICT(schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
|
|
2133
|
+
last_status = excluded.last_status`)
|
|
2134
|
+
.run(stateKey, nowIso, outcome);
|
|
2135
|
+
report.checks.push({ eventType, outcome, observedAt, withinHours });
|
|
2136
|
+
}
|
|
2137
|
+
return report;
|
|
2138
|
+
}
|
|
1959
2139
|
async runDueSchedules(moduleId, tenantId, scopeId) {
|
|
1960
|
-
const report = { fired: 0, skipped: 0, failed: 0, errors: [] };
|
|
2140
|
+
const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
|
|
1961
2141
|
const mod = this.modules.get(moduleId);
|
|
1962
2142
|
if (!mod || mod.schedules.length === 0)
|
|
1963
2143
|
return report;
|
|
@@ -2000,6 +2180,7 @@ export class SqliteScopeHost {
|
|
|
2000
2180
|
const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
|
|
2001
2181
|
if (now < dueAt) {
|
|
2002
2182
|
report.skipped += 1;
|
|
2183
|
+
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
2003
2184
|
continue;
|
|
2004
2185
|
}
|
|
2005
2186
|
// Due — invoke through the system door (a fresh stub per schedule keeps the
|
|
@@ -2024,6 +2205,7 @@ export class SqliteScopeHost {
|
|
|
2024
2205
|
ON CONFLICT(schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
|
|
2025
2206
|
last_status = excluded.last_status`)
|
|
2026
2207
|
.run(schedule.operation, new Date(now).toISOString(), status);
|
|
2208
|
+
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
2027
2209
|
}
|
|
2028
2210
|
return report;
|
|
2029
2211
|
}
|
|
@@ -2105,7 +2287,7 @@ export class SqliteScopeHost {
|
|
|
2105
2287
|
const invoked = await rt.actor.enqueue(async () => {
|
|
2106
2288
|
// Fresh per operation: the context carries the K-34 authorization accumulator,
|
|
2107
2289
|
// which must not leak across operations (invokes are serialized per scope).
|
|
2108
|
-
const ctx = this.operationContext(rt, subject, undefined, signals, session);
|
|
2290
|
+
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation);
|
|
2109
2291
|
const clonedInput = structuredClone(input);
|
|
2110
2292
|
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
2111
2293
|
// declaration. BEFORE `BEGIN`, so a malformed call never opens a
|
|
@@ -2648,6 +2830,8 @@ export class SqliteScopeHost {
|
|
|
2648
2830
|
// was impersonating, because `DomainEvent.impersonation` is optional — the
|
|
2649
2831
|
// shape module code never sees is also the shape it cannot branch on.
|
|
2650
2832
|
...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
|
|
2833
|
+
// #1231: absent rather than null, the same shape rule as the stamp above.
|
|
2834
|
+
...(row.operation ? { operation: row.operation } : {}),
|
|
2651
2835
|
payload: row.payload === null ? undefined : JSON.parse(row.payload),
|
|
2652
2836
|
});
|
|
2653
2837
|
}
|
|
@@ -5087,19 +5271,39 @@ export class SqliteScopeHost {
|
|
|
5087
5271
|
}));
|
|
5088
5272
|
},
|
|
5089
5273
|
recordOpsFailure: async (entry) => {
|
|
5274
|
+
const at = new Date().toISOString();
|
|
5275
|
+
const fingerprint = opsFailureFingerprint(entry);
|
|
5090
5276
|
this.directory
|
|
5091
5277
|
.prepare(`INSERT INTO _substrat_ops_failures
|
|
5092
|
-
(id, actor, operation, stage, tenant_id, scope_id, vertical, status, message, reference, at)
|
|
5093
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
5094
|
-
.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.status ?? null,
|
|
5278
|
+
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
5279
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
5280
|
+
.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
|
|
5095
5281
|
// Bounded here, not trusted from the catch site: one runaway upstream
|
|
5096
5282
|
// body must not become a runaway directory row (#559).
|
|
5097
|
-
entry.message.slice(0, 2000), entry.reference ?? null,
|
|
5283
|
+
entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
|
|
5098
5284
|
// Prune-on-write (#559): retention lives here, not in a cron — every insert
|
|
5099
5285
|
// pays for its own housekeeping, so the table stays bounded even where no
|
|
5100
5286
|
// scheduled pass runs (this adapter has none).
|
|
5101
5287
|
const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
5102
5288
|
this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
|
|
5289
|
+
// The issues materialization (#1233): the group's counters live on their
|
|
5290
|
+
// own row, bumped in the same call, because the evidence self-prunes above
|
|
5291
|
+
// and a count must survive its own exemplars. A fresh arrival regresses a
|
|
5292
|
+
// resolved issue; an ignored one stays ignored — that is what ignoring means.
|
|
5293
|
+
this.directory
|
|
5294
|
+
.prepare(`INSERT INTO _substrat_issues
|
|
5295
|
+
(fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_vertical, resolved_at)
|
|
5296
|
+
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, NULL)
|
|
5297
|
+
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
5298
|
+
seen_count = seen_count + 1,
|
|
5299
|
+
last_seen = excluded.last_seen,
|
|
5300
|
+
last_message = excluded.last_message,
|
|
5301
|
+
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
5302
|
+
origin = COALESCE(excluded.origin, origin),
|
|
5303
|
+
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
|
|
5304
|
+
.run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.vertical ?? null);
|
|
5305
|
+
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
5306
|
+
this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
|
|
5103
5307
|
},
|
|
5104
5308
|
listOpsFailures: async (actor, filter) => {
|
|
5105
5309
|
const where = [];
|
|
@@ -5116,10 +5320,22 @@ export class SqliteScopeHost {
|
|
|
5116
5320
|
where.push('vertical = ?');
|
|
5117
5321
|
params.push(filter.vertical);
|
|
5118
5322
|
}
|
|
5323
|
+
if (filter?.version) {
|
|
5324
|
+
where.push('version = ?');
|
|
5325
|
+
params.push(filter.version);
|
|
5326
|
+
}
|
|
5119
5327
|
if (filter?.operation) {
|
|
5120
5328
|
where.push('operation = ?');
|
|
5121
5329
|
params.push(filter.operation);
|
|
5122
5330
|
}
|
|
5331
|
+
if (filter?.code) {
|
|
5332
|
+
where.push('code = ?');
|
|
5333
|
+
params.push(filter.code);
|
|
5334
|
+
}
|
|
5335
|
+
if (filter?.fingerprint) {
|
|
5336
|
+
where.push('fingerprint = ?');
|
|
5337
|
+
params.push(filter.fingerprint);
|
|
5338
|
+
}
|
|
5123
5339
|
if (filter?.reference) {
|
|
5124
5340
|
where.push('reference = ?');
|
|
5125
5341
|
params.push(filter.reference);
|
|
@@ -5158,12 +5374,130 @@ export class SqliteScopeHost {
|
|
|
5158
5374
|
tenantId: r.tenant_id,
|
|
5159
5375
|
scopeId: r.scope_id,
|
|
5160
5376
|
vertical: r.vertical,
|
|
5377
|
+
version: r.version,
|
|
5161
5378
|
status: r.status,
|
|
5162
5379
|
message: r.message,
|
|
5163
5380
|
reference: r.reference,
|
|
5381
|
+
origin: r.origin,
|
|
5382
|
+
code: r.code,
|
|
5383
|
+
fingerprint: r.fingerprint,
|
|
5384
|
+
at: r.at,
|
|
5385
|
+
}));
|
|
5386
|
+
},
|
|
5387
|
+
recordSweepRun: async (entry) => {
|
|
5388
|
+
this.directory
|
|
5389
|
+
.prepare(`INSERT OR IGNORE INTO _substrat_sweep_runs
|
|
5390
|
+
(id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
|
|
5391
|
+
connection_id, error, elapsed_ms, request_id, event_type, observed_at, at)
|
|
5392
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
5393
|
+
.run(ulid(), entry.kind, entry.unit, entry.outcome, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.operation ?? null, entry.connectionId ?? null,
|
|
5394
|
+
// Bounded here, not trusted from the sweep: one runaway provider body
|
|
5395
|
+
// must not become a runaway directory row (the #559 rule).
|
|
5396
|
+
entry.error == null ? null : entry.error.slice(0, 2000), entry.elapsedMs ?? null, entry.requestId ?? null, entry.eventType ?? null, entry.observedAt ?? null, entry.at ?? new Date().toISOString());
|
|
5397
|
+
// Prune-on-write, like ops failures and for the same reason: the table stays
|
|
5398
|
+
// bounded even on a deployment whose scheduled pass is broken.
|
|
5399
|
+
const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
|
|
5400
|
+
this.directory.prepare('DELETE FROM _substrat_sweep_runs WHERE at < ?').run(horizon);
|
|
5401
|
+
},
|
|
5402
|
+
listSweepRuns: async (actor, filter) => {
|
|
5403
|
+
const where = [];
|
|
5404
|
+
const params = [];
|
|
5405
|
+
const eq = (col, v) => {
|
|
5406
|
+
if (v !== undefined) {
|
|
5407
|
+
where.push(`${col} = ?`);
|
|
5408
|
+
params.push(v);
|
|
5409
|
+
}
|
|
5410
|
+
};
|
|
5411
|
+
eq('kind', filter?.kind);
|
|
5412
|
+
eq('unit', filter?.unit);
|
|
5413
|
+
eq('outcome', filter?.outcome);
|
|
5414
|
+
eq('tenant_id', filter?.tenantId);
|
|
5415
|
+
eq('scope_id', filter?.scopeId);
|
|
5416
|
+
eq('vertical', filter?.vertical);
|
|
5417
|
+
eq('connection_id', filter?.connectionId);
|
|
5418
|
+
if (filter?.since) {
|
|
5419
|
+
where.push('at >= ?');
|
|
5420
|
+
params.push(filter.since);
|
|
5421
|
+
}
|
|
5422
|
+
if (filter?.until) {
|
|
5423
|
+
where.push('at < ?');
|
|
5424
|
+
params.push(filter.until);
|
|
5425
|
+
}
|
|
5426
|
+
// Default DESC: "last run" and the recent-runs strip both read newest-first.
|
|
5427
|
+
const order = (filter?.order ?? 'desc') === 'desc' ? 'DESC' : 'ASC';
|
|
5428
|
+
if (filter?.cursor) {
|
|
5429
|
+
where.push(order === 'DESC' ? 'id < ?' : 'id > ?');
|
|
5430
|
+
params.push(filter.cursor);
|
|
5431
|
+
}
|
|
5432
|
+
let sql = 'SELECT * FROM _substrat_sweep_runs' +
|
|
5433
|
+
(where.length ? ` WHERE ${where.join(' AND ')}` : '') +
|
|
5434
|
+
` ORDER BY id ${order}`;
|
|
5435
|
+
if (filter?.limit !== undefined) {
|
|
5436
|
+
sql += ' LIMIT ?';
|
|
5437
|
+
params.push(filter.limit);
|
|
5438
|
+
}
|
|
5439
|
+
const rows = this.directory.prepare(sql).all(...params);
|
|
5440
|
+
// Rows can name tenants and scopes, so reading them is recorded (K-24).
|
|
5441
|
+
this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
5442
|
+
return rows.map((r) => sweepRunEntry.parse({
|
|
5443
|
+
id: r.id,
|
|
5444
|
+
kind: r.kind,
|
|
5445
|
+
unit: r.unit,
|
|
5446
|
+
outcome: r.outcome,
|
|
5447
|
+
tenantId: r.tenant_id,
|
|
5448
|
+
scopeId: r.scope_id,
|
|
5449
|
+
vertical: r.vertical,
|
|
5450
|
+
version: r.version,
|
|
5451
|
+
operation: r.operation,
|
|
5452
|
+
connectionId: r.connection_id,
|
|
5453
|
+
error: r.error,
|
|
5454
|
+
elapsedMs: r.elapsed_ms,
|
|
5455
|
+
eventType: r.event_type,
|
|
5456
|
+
observedAt: r.observed_at,
|
|
5164
5457
|
at: r.at,
|
|
5165
5458
|
}));
|
|
5166
5459
|
},
|
|
5460
|
+
listIssues: async (actor, filter) => {
|
|
5461
|
+
const where = [];
|
|
5462
|
+
const params = [];
|
|
5463
|
+
if (filter?.status) {
|
|
5464
|
+
where.push('status = ?');
|
|
5465
|
+
params.push(filter.status);
|
|
5466
|
+
}
|
|
5467
|
+
if (filter?.operation) {
|
|
5468
|
+
where.push('operation = ?');
|
|
5469
|
+
params.push(filter.operation);
|
|
5470
|
+
}
|
|
5471
|
+
if (filter?.code) {
|
|
5472
|
+
where.push('code = ?');
|
|
5473
|
+
params.push(filter.code);
|
|
5474
|
+
}
|
|
5475
|
+
// No cursor by design: grouping IS the compression — cardinality is the
|
|
5476
|
+
// number of distinct failure shapes, not the number of failures.
|
|
5477
|
+
const sql = 'SELECT * FROM _substrat_issues' +
|
|
5478
|
+
(where.length ? ` WHERE ${where.join(' AND ')}` : '') +
|
|
5479
|
+
' ORDER BY last_seen DESC, fingerprint LIMIT ?';
|
|
5480
|
+
params.push(filter?.limit ?? 100);
|
|
5481
|
+
const rows = this.directory.prepare(sql).all(...params);
|
|
5482
|
+
// Issues aggregate fleet-wide failures; the read is recorded like the rows' own (K-24).
|
|
5483
|
+
this.recordAccess(actor, 'listIssues', {}, filter, rows.length);
|
|
5484
|
+
return rows.map((r) => issueEntry.parse(issueOf(r)));
|
|
5485
|
+
},
|
|
5486
|
+
setIssueStatus: async (actor, fingerprint, status) => {
|
|
5487
|
+
const existing = this.directory
|
|
5488
|
+
.prepare('SELECT * FROM _substrat_issues WHERE fingerprint = ?')
|
|
5489
|
+
.get(fingerprint);
|
|
5490
|
+
if (!existing)
|
|
5491
|
+
return undefined;
|
|
5492
|
+
const resolvedAt = status === 'resolved' ? new Date().toISOString() : null;
|
|
5493
|
+
this.directory
|
|
5494
|
+
.prepare('UPDATE _substrat_issues SET status = ?, resolved_at = ? WHERE fingerprint = ?')
|
|
5495
|
+
.run(status, resolvedAt, fingerprint);
|
|
5496
|
+
const after = issueEntry.parse(issueOf({ ...existing, status, resolved_at: resolvedAt }));
|
|
5497
|
+
// A lifecycle flip is a staff mutation — audited with the diff (K-33).
|
|
5498
|
+
this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: existing.status }, { fingerprint, status: after.status });
|
|
5499
|
+
return after;
|
|
5500
|
+
},
|
|
5167
5501
|
recordModelUsage: async (input) => {
|
|
5168
5502
|
const l = input.line;
|
|
5169
5503
|
// INSERT OR IGNORE on the UNIQUE request id: the drain may replay a settled-then-
|
|
@@ -5330,6 +5664,22 @@ export class SqliteScopeHost {
|
|
|
5330
5664
|
]) {
|
|
5331
5665
|
this.ensureColumn(this.directory, '_substrat_entitlements', col, ddl);
|
|
5332
5666
|
}
|
|
5667
|
+
// The signals `version` stamp (#1231): a directory created before the column
|
|
5668
|
+
// must still open, and an old row's NULL reads as "predates the stamp".
|
|
5669
|
+
this.ensureColumn(this.directory, '_substrat_ops_failures', 'version', 'version TEXT');
|
|
5670
|
+
// The error shape (#1233): who refused + the taxonomy code, on a directory that predates them.
|
|
5671
|
+
this.ensureColumn(this.directory, '_substrat_ops_failures', 'origin', 'origin TEXT');
|
|
5672
|
+
this.ensureColumn(this.directory, '_substrat_ops_failures', 'code', 'code TEXT');
|
|
5673
|
+
// #1233: the grouping key + its exemplar-walk index — created only after the
|
|
5674
|
+
// column exists on every path, the sweep-runs pattern.
|
|
5675
|
+
this.ensureColumn(this.directory, '_substrat_ops_failures', 'fingerprint', 'fingerprint TEXT');
|
|
5676
|
+
this.directory.exec('CREATE INDEX IF NOT EXISTS _substrat_ops_failures_fingerprint ON _substrat_ops_failures (fingerprint, id)');
|
|
5677
|
+
// #1232: the drained batch's dedupe key, on a directory created before it. The
|
|
5678
|
+
// unique index rides here too — created after the column exists on every path.
|
|
5679
|
+
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'request_id', 'request_id TEXT');
|
|
5680
|
+
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'event_type', 'event_type TEXT');
|
|
5681
|
+
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'observed_at', 'observed_at TEXT');
|
|
5682
|
+
this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS _substrat_sweep_runs_intent ON _substrat_sweep_runs (request_id, unit)');
|
|
5333
5683
|
const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
|
|
5334
5684
|
for (const [column, ddl] of [
|
|
5335
5685
|
['parent_scope_id', 'parent_scope_id TEXT'],
|
|
@@ -5400,7 +5750,13 @@ export class SqliteScopeHost {
|
|
|
5400
5750
|
* on it, and "hide this row when support is looking" is the one behaviour
|
|
5401
5751
|
* this whole feature exists to make impossible.
|
|
5402
5752
|
*/
|
|
5403
|
-
impersonation
|
|
5753
|
+
impersonation,
|
|
5754
|
+
/**
|
|
5755
|
+
* #1231: the exact `invoke()` string this context runs on behalf of — stamped
|
|
5756
|
+
* onto every event it emits. Absent for consumer dispatch: a consumer runs on
|
|
5757
|
+
* behalf of no operation, and the emitted row's NULL says so.
|
|
5758
|
+
*/
|
|
5759
|
+
operation) {
|
|
5404
5760
|
const principal = subject.id;
|
|
5405
5761
|
const checker = this.checker;
|
|
5406
5762
|
const relations = this.relations;
|
|
@@ -5522,14 +5878,21 @@ export class SqliteScopeHost {
|
|
|
5522
5878
|
: principal),
|
|
5523
5879
|
...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
|
|
5524
5880
|
...(stamp ? { impersonation: stamp } : {}),
|
|
5881
|
+
// #1231: kernel-stamped like the two above — `domainEventInput.parse`
|
|
5882
|
+
// already stripped anything module code tried to smuggle under this key.
|
|
5883
|
+
...(operation ? { operation } : {}),
|
|
5525
5884
|
});
|
|
5526
5885
|
rt.db
|
|
5527
5886
|
.prepare(`INSERT INTO _substrat_outbox
|
|
5528
5887
|
(id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
|
|
5529
5888
|
entity_type, entity_id, pii_class, subject_id, authorization,
|
|
5530
|
-
impersonation, payload)
|
|
5531
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
5532
|
-
.run(full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.
|
|
5889
|
+
impersonation, operation, version, payload)
|
|
5890
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
5891
|
+
.run(full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.operation ?? null,
|
|
5892
|
+
// #1242: host configuration, not envelope data — the version is a fact
|
|
5893
|
+
// about the process, so it never rides `DomainEvent` for module code to
|
|
5894
|
+
// branch on; it exists for the observability joins the column serves.
|
|
5895
|
+
this.versionId, full.payload === undefined ? null : JSON.stringify(full.payload));
|
|
5533
5896
|
},
|
|
5534
5897
|
requestPlatform: (request) => {
|
|
5535
5898
|
assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
|
|
@@ -5815,14 +6178,15 @@ export class SqliteScopeHost {
|
|
|
5815
6178
|
throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5816
6179
|
return this.runtime(tenantId, scopeId).db;
|
|
5817
6180
|
}
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
6181
|
+
/**
|
|
6182
|
+
* The additive spine-column migrations, shared by `runtime()` and the dump replay.
|
|
6183
|
+
* KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before a column keeps the
|
|
6184
|
+
* old shape — and so does a table a DUMP replay just recreated from legacy DDL,
|
|
6185
|
+
* which is why `loadDump` re-runs this after its replay: `runtime()` already ran
|
|
6186
|
+
* for that scope, and without the re-run the very next emit in this process fails
|
|
6187
|
+
* with `no such column`.
|
|
6188
|
+
*/
|
|
6189
|
+
ensureSpineColumns(db) {
|
|
5826
6190
|
// KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before K-21 keeps the
|
|
5827
6191
|
// old shape — ALTER the tombstone in.
|
|
5828
6192
|
this.ensureColumn(db, '_substrat_tuples', 'revoked_at', 'revoked_at TEXT');
|
|
@@ -5847,6 +6211,22 @@ export class SqliteScopeHost {
|
|
|
5847
6211
|
this.ensureColumn(db, '_substrat_outbox', 'impersonation', 'impersonation TEXT');
|
|
5848
6212
|
this.ensureColumn(db, '_substrat_platform_requests', 'impersonation', 'impersonation TEXT');
|
|
5849
6213
|
this.ensureColumn(db, '_substrat_denials', 'impersonation', 'impersonation TEXT');
|
|
6214
|
+
// #1231: the emitting operation, on a scope DB created before the column. Nullable
|
|
6215
|
+
// so every legacy row reads as unrecorded rather than claiming a name nobody stamped.
|
|
6216
|
+
this.ensureColumn(db, '_substrat_outbox', 'operation', 'operation TEXT');
|
|
6217
|
+
// #1242: the signals `version` dimension on the outbox, for a scope DB created
|
|
6218
|
+
// before the column. NULL stays honest — unstamped, not a version nobody named.
|
|
6219
|
+
this.ensureColumn(db, '_substrat_outbox', 'version', 'version TEXT');
|
|
6220
|
+
}
|
|
6221
|
+
runtime(tenantId, scopeId) {
|
|
6222
|
+
const key = `${tenantId}/${scopeId}`;
|
|
6223
|
+
const existing = this.scopes.get(key);
|
|
6224
|
+
if (existing)
|
|
6225
|
+
return existing;
|
|
6226
|
+
const db = new Database(join(this.dir, `${tenantId}__${scopeId}.sqlite`));
|
|
6227
|
+
db.pragma('journal_mode = WAL');
|
|
6228
|
+
db.exec(KERNEL_DDL);
|
|
6229
|
+
this.ensureSpineColumns(db);
|
|
5850
6230
|
const appliedMigrations = new Set(db.prepare('SELECT module_id, version FROM _substrat_migrations').all().map((r) => `${r.module_id}@${r.version}`));
|
|
5851
6231
|
const created = { tenantId, scopeId, db, actor: new ScopeActor(), appliedMigrations };
|
|
5852
6232
|
this.scopes.set(key, created);
|