@substrat-run/adapter-sqlite 0.116.0 → 0.118.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/actor.d.ts +10 -0
- package/dist/actor.d.ts.map +1 -1
- package/dist/actor.js +25 -1
- package/dist/actor.js.map +1 -1
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +5 -1
- package/dist/checker.js.map +1 -1
- package/dist/index.d.ts +100 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +822 -281
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
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, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId,
|
|
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, 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, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, } 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, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, 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, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, 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, 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, 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, } from '@substrat-run/kernel';
|
|
6
6
|
import { ScopeActor } from './actor.js';
|
|
7
7
|
import { createTupleChecker } from './checker.js';
|
|
8
|
+
/** The kernel's schedule-switch SQL (#1666), over one scope's database handle. */
|
|
9
|
+
const switchSqlOf = (db) => ({
|
|
10
|
+
all: (sql, ...params) => db.prepare(sql).all(...params),
|
|
11
|
+
run: (sql, ...params) => {
|
|
12
|
+
db.prepare(sql).run(...params);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* How many scopes may hold a read connection at once (#1624). Every open scope keeps its
|
|
17
|
+
* write connection for the life of the host, so an uncapped reader per scope would double
|
|
18
|
+
* the file handles a node deployment holds — and the lake drain alone reads every scope.
|
|
19
|
+
* Past the cap the least recently read scope's reader is closed, and reopened on its next
|
|
20
|
+
* read, which costs well under a millisecond.
|
|
21
|
+
*/
|
|
22
|
+
const READER_CAP = 32;
|
|
8
23
|
/**
|
|
9
24
|
* The scope spine, as this adapter builds it — one of two hand-written copies (#969).
|
|
10
25
|
*
|
|
@@ -130,6 +145,10 @@ const KERNEL_DDL = `
|
|
|
130
145
|
drained_at TEXT
|
|
131
146
|
);
|
|
132
147
|
${IDEMPOTENCY_DDL}
|
|
148
|
+
-- #1672: capabilities — authority carried by a secret (a link share), and the sessions
|
|
149
|
+
-- an exchange trades that secret for. Spine (kernel-written), shared with the DO adapter
|
|
150
|
+
-- from @substrat-run/kernel so the two cannot part company; the column comments are there.
|
|
151
|
+
${CAPABILITY_DDL}
|
|
133
152
|
-- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
|
|
134
153
|
-- families of row that the kind COLUMN — not the spelling of a key — tells apart.
|
|
135
154
|
-- Spine (kernel-written), never a module migration. Shared with the DO adapter from
|
|
@@ -330,23 +349,12 @@ function modelUsageEntryOf(r) {
|
|
|
330
349
|
elapsedMs: r.elapsed_ms,
|
|
331
350
|
});
|
|
332
351
|
}
|
|
333
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
requestedBy: JSON.parse(r.requested_by),
|
|
340
|
-
impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
|
|
341
|
-
status: r.status,
|
|
342
|
-
attempts: r.attempts,
|
|
343
|
-
lastError: r.last_error,
|
|
344
|
-
failure: r.last_failure == null ? null : JSON.parse(r.last_failure),
|
|
345
|
-
result: r.result === null ? null : JSON.parse(r.result),
|
|
346
|
-
requestedAt: r.requested_at,
|
|
347
|
-
settledAt: r.settled_at,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
352
|
+
/**
|
|
353
|
+
* The policy an undecodable event's executor delivery is journaled under (#1636): one
|
|
354
|
+
* attempt, so the first failure is the dead letter. A decode is pure — retrying it cannot
|
|
355
|
+
* succeed — and the Cloudflare coordinator records the same row with no next attempt.
|
|
356
|
+
*/
|
|
357
|
+
const UNDECODABLE_RETRY = { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 };
|
|
350
358
|
/**
|
|
351
359
|
* The dispatch capability (#726 remedy B): a connector delivery may read the attachments
|
|
352
360
|
* of the entity the DELIVERED EVENT names, and nothing else.
|
|
@@ -417,6 +425,8 @@ export class SqliteScopeHost {
|
|
|
417
425
|
directory;
|
|
418
426
|
scopes = new Map();
|
|
419
427
|
scopesById = new Map();
|
|
428
|
+
/** The runtimes holding an open `reader` (#1624), least recently read first — `READER_CAP`'s LRU. */
|
|
429
|
+
readers = new Map();
|
|
420
430
|
/** Per-tenant relational stores (#301), opened lazily and cached by `ref` (the bare
|
|
421
431
|
* `.sqlite` filename). The pure-adapter analogue of a per-tenant D1 — one file per
|
|
422
432
|
* (tenant, vertical, binding), physically separate from the scope DBs. */
|
|
@@ -1048,7 +1058,9 @@ export class SqliteScopeHost {
|
|
|
1048
1058
|
at TEXT NOT NULL
|
|
1049
1059
|
);
|
|
1050
1060
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_unit ON _substrat_sweep_runs (kind, unit, id);
|
|
1051
|
-
|
|
1061
|
+
-- (request_id, kind, unit) since #1572; a directory holding the older
|
|
1062
|
+
-- (request_id, unit) is rebuilt by ensureSweepRunsIntentKind, not by this line.
|
|
1063
|
+
${SWEEP_RUNS_INTENT_INDEX}
|
|
1052
1064
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_tenant ON _substrat_sweep_runs (tenant_id, id);
|
|
1053
1065
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_at ON _substrat_sweep_runs (at);
|
|
1054
1066
|
-- Model usage (#1054, meter 3): one line per model call a vertical made through
|
|
@@ -1436,21 +1448,29 @@ export class SqliteScopeHost {
|
|
|
1436
1448
|
await this.applyPendingMigrations(rt);
|
|
1437
1449
|
// Project each registered module's SCHEDULE grants (#383): a system principal
|
|
1438
1450
|
// holds exactly the permissions its schedules declared, on this scope. This is
|
|
1439
|
-
// what makes `ctx.check` resolve for scheduled work — the gate stays the check
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1451
|
+
// what makes `ctx.check` resolve for scheduled work — the gate stays the check.
|
|
1452
|
+
// Idempotent, so a re-provision re-asserts the same grants — SEATED (#1659), with the
|
|
1453
|
+
// statement the Cloudflare adapter seats with: a missing grant is recreated, a revoked
|
|
1454
|
+
// one stays revoked. Turning a scope's schedules off is `revokeFromSystem` (#1666), and
|
|
1455
|
+
// its OFF marker is not a grant, so no re-provision can seat it away; `restoreToSystem`
|
|
1456
|
+
// is the only way back (`grantToSystem` clears a tuple's tombstone, not the switch).
|
|
1457
|
+
//
|
|
1458
|
+
// One turn on the scope actor for the whole loop (#1678): a seat issued while an
|
|
1459
|
+
// invoke held its transaction open joined it, and a rollback un-seated the grant
|
|
1460
|
+
// after provisioning had returned. The switch check lives inside the seat statement.
|
|
1461
|
+
await rt.actor.turn(() => {
|
|
1462
|
+
for (const mod of this.modules.values()) {
|
|
1463
|
+
const perms = new Set();
|
|
1464
|
+
for (const s of mod.schedules)
|
|
1465
|
+
for (const p of s.permissions)
|
|
1466
|
+
perms.add(p);
|
|
1467
|
+
for (const perm of perms) {
|
|
1468
|
+
// A module switched off (#1666) gets nothing seated — see `seatScopeTuple`.
|
|
1469
|
+
const seat = seatScopeTuple(`system:${mod.id}`, `granted:${perm}`, `scope:${input.scopeId}`, null);
|
|
1470
|
+
rt.db.prepare(seat.sql).run(...seat.params);
|
|
1471
|
+
}
|
|
1452
1472
|
}
|
|
1453
|
-
}
|
|
1473
|
+
});
|
|
1454
1474
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
1455
1475
|
if (!existing) {
|
|
1456
1476
|
this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
@@ -1857,12 +1877,12 @@ export class SqliteScopeHost {
|
|
|
1857
1877
|
forkedFrom: input.forkedFrom ?? dump.scopeId,
|
|
1858
1878
|
forkedAt: input.forkedAt ?? dump.capturedAt,
|
|
1859
1879
|
});
|
|
1860
|
-
this.loadDump(input.tenantId, input.scopeId, dump.tables);
|
|
1880
|
+
await this.loadDump(input.tenantId, input.scopeId, dump.tables);
|
|
1861
1881
|
await this.admin.activateScope(actor, input.tenantId, input.scopeId);
|
|
1862
1882
|
this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1863
1883
|
}
|
|
1864
1884
|
/** Drop-then-replay a dump into a scope's db, refreshing the migration frontier. */
|
|
1865
|
-
loadDump(tenantId, scopeId, tables) {
|
|
1885
|
+
async loadDump(tenantId, scopeId, tables) {
|
|
1866
1886
|
const rt = this.runtime(tenantId, scopeId);
|
|
1867
1887
|
const db = rt.db;
|
|
1868
1888
|
const load = db.transaction((dumped) => {
|
|
@@ -1946,20 +1966,25 @@ export class SqliteScopeHost {
|
|
|
1946
1966
|
db.prepare(`UPDATE OR REPLACE _substrat_tuples SET object = ?
|
|
1947
1967
|
WHERE object LIKE 'scope:%' AND object <> ?`).run(`scope:${scopeId}`, `scope:${scopeId}`);
|
|
1948
1968
|
});
|
|
1949
|
-
|
|
1950
|
-
//
|
|
1951
|
-
//
|
|
1952
|
-
rt.
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1969
|
+
// On the scope actor (#1678): issued while an invoke held its transaction open, the
|
|
1970
|
+
// load was a SAVEPOINT inside it, and that invoke's rollback undid the restore after
|
|
1971
|
+
// this verb had audited it.
|
|
1972
|
+
await rt.actor.turn(() => {
|
|
1973
|
+
load(tables);
|
|
1974
|
+
// The frontier came in with the dump — refresh the cached applied-migration set so
|
|
1975
|
+
// a later bind/migrate builds on the loaded state, not the previous one.
|
|
1976
|
+
rt.appliedMigrations.clear();
|
|
1977
|
+
for (const r of db.prepare('SELECT module_id, version FROM _substrat_migrations').all()) {
|
|
1978
|
+
rt.appliedMigrations.add(`${r.module_id}@${r.version}`);
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1956
1981
|
}
|
|
1957
1982
|
async restoreScope(actor, tenantId, scopeId, dump) {
|
|
1958
1983
|
// Restore never creates a scope (that is importScope) — an unknown target fails closed.
|
|
1959
1984
|
const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1960
1985
|
if (!existing)
|
|
1961
1986
|
throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1962
|
-
this.loadDump(tenantId, scopeId, dump.tables);
|
|
1987
|
+
await this.loadDump(tenantId, scopeId, dump.tables);
|
|
1963
1988
|
this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1964
1989
|
}
|
|
1965
1990
|
async snapshotScope(actor, tenantId, scopeId, opts) {
|
|
@@ -1996,13 +2021,9 @@ export class SqliteScopeHost {
|
|
|
1996
2021
|
`archive a primary scope instead`);
|
|
1997
2022
|
}
|
|
1998
2023
|
// Close and evict the runtime handle before touching the file.
|
|
1999
|
-
const
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
rt.db.close();
|
|
2003
|
-
this.scopes.delete(key);
|
|
2004
|
-
this.scopesById.delete(scopeId);
|
|
2005
|
-
}
|
|
2024
|
+
const rt = this.scopes.get(`${tenantId}/${scopeId}`);
|
|
2025
|
+
if (rt)
|
|
2026
|
+
this.closeRuntime(rt);
|
|
2006
2027
|
// Order is the retry story: hostnames first (a reaped preview URL must stop
|
|
2007
2028
|
// resolving), then the STORAGE, then the directory row — so a crash mid-way leaves
|
|
2008
2029
|
// a visible row over empty storage (re-running deleteSnapshot converges), never
|
|
@@ -2180,6 +2201,82 @@ export class SqliteScopeHost {
|
|
|
2180
2201
|
await this.applyPendingMigrations(rt);
|
|
2181
2202
|
return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
|
|
2182
2203
|
}
|
|
2204
|
+
/**
|
|
2205
|
+
* The lifecycle gate the capability doors share (#1672): the scope must exist in this
|
|
2206
|
+
* tenant, the tenant must be active, the scope must be active — K-3's fail-closed pair
|
|
2207
|
+
* check, worded as the principal door words it, so a scope in another tenant reads the
|
|
2208
|
+
* same as one that does not exist. Migrations are applied as on every door.
|
|
2209
|
+
*/
|
|
2210
|
+
async openActiveScope(tenantId, scopeId) {
|
|
2211
|
+
const scope = this.directory
|
|
2212
|
+
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
2213
|
+
.get(scopeId);
|
|
2214
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
2215
|
+
throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2216
|
+
}
|
|
2217
|
+
const tenant = this.directory
|
|
2218
|
+
.prepare('SELECT status FROM tenants WHERE tenant_id = ?')
|
|
2219
|
+
.get(tenantId);
|
|
2220
|
+
if (!tenant || tenant.status !== 'active') {
|
|
2221
|
+
throw new Error(`tenant not active (status: ${tenant?.status ?? 'missing'}): ${tenantId}`);
|
|
2222
|
+
}
|
|
2223
|
+
if (scope.status !== 'active') {
|
|
2224
|
+
throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
|
|
2225
|
+
}
|
|
2226
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
2227
|
+
await this.applyPendingMigrations(rt);
|
|
2228
|
+
return rt;
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* The exchange (#1672) — a secret traded for a session, or for the principal a `become`
|
|
2232
|
+
* capability yields. One serialized scope task and one transaction, so the use it takes
|
|
2233
|
+
* and the `capability.exercised` event recording it commit together or not at all; the
|
|
2234
|
+
* event's consumers then dispatch as an invoke's would.
|
|
2235
|
+
*/
|
|
2236
|
+
async exchangeCapability(tenantId, scopeId, secret, options) {
|
|
2237
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2238
|
+
return rt.actor.enqueue(async () => {
|
|
2239
|
+
// ONE instant for the whole exchange: the row's `last_used_at`, the session's times and
|
|
2240
|
+
// the event's `occurredAt` are one fact, and two clock reads could disagree about it.
|
|
2241
|
+
const now = this.clock();
|
|
2242
|
+
rt.db.exec('BEGIN IMMEDIATE');
|
|
2243
|
+
let outcome;
|
|
2244
|
+
try {
|
|
2245
|
+
outcome = await exchangeCapabilitySecret({
|
|
2246
|
+
sql: spineSql(rt.db),
|
|
2247
|
+
now,
|
|
2248
|
+
// Stamped `{ capability }` by the ordinary emit path, under the exchange's own
|
|
2249
|
+
// pseudo-operation name — the actor is the capability, as on every event it
|
|
2250
|
+
// goes on to cause.
|
|
2251
|
+
emit: (capability, event) => this.operationContext(rt, { kind: 'capability', id: capability }, undefined, undefined, undefined, CAPABILITY_EXCHANGE_OPERATION, [], now).emit(event),
|
|
2252
|
+
}, secret, options?.mode);
|
|
2253
|
+
rt.db.exec('COMMIT');
|
|
2254
|
+
}
|
|
2255
|
+
catch (err) {
|
|
2256
|
+
rt.db.exec('ROLLBACK');
|
|
2257
|
+
throw err;
|
|
2258
|
+
}
|
|
2259
|
+
if (outcome) {
|
|
2260
|
+
await this.dispatch(rt, null);
|
|
2261
|
+
await this.dispatchExecutors(rt, null);
|
|
2262
|
+
}
|
|
2263
|
+
return outcome;
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* The capability door (#1672) — mirror of the connection and system doors. The token is
|
|
2268
|
+
* shape-checked and hashed HERE, and only its hash travels on: `buildStub` re-resolves it
|
|
2269
|
+
* to its capability on every invoke, so a revoke or an expiry refuses the very next call
|
|
2270
|
+
* on a stub minted before it.
|
|
2271
|
+
*/
|
|
2272
|
+
async getCapabilityScope(sessionToken, tenantId, scopeId, options) {
|
|
2273
|
+
if (!plausibleSessionToken(sessionToken)) {
|
|
2274
|
+
throw substratError('unauthenticated', 'not a capability session token');
|
|
2275
|
+
}
|
|
2276
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2277
|
+
const hash = await capabilityTokenHash(sessionToken);
|
|
2278
|
+
return this.buildStub(tenantId, scopeId, rt, { kind: 'capability-session', hash }, options);
|
|
2279
|
+
}
|
|
2183
2280
|
registeredSchedules() {
|
|
2184
2281
|
const out = [];
|
|
2185
2282
|
for (const mod of this.modules.values()) {
|
|
@@ -2241,45 +2338,51 @@ export class SqliteScopeHost {
|
|
|
2241
2338
|
}
|
|
2242
2339
|
if (windows.size === 0)
|
|
2243
2340
|
return report;
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
.
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
.
|
|
2281
|
-
|
|
2282
|
-
|
|
2341
|
+
// The WHOLE evaluation is one turn on the scope actor (#1678 review): the outbox probe,
|
|
2342
|
+
// the previous verdict and the write that records the new one. Read outside the turn, the
|
|
2343
|
+
// probe saw a suspended operation's UNCOMMITTED event on this shared connection, then
|
|
2344
|
+
// waited here and persisted an `ok` for an event its rollback was about to remove.
|
|
2345
|
+
await rt.actor.turn(() => {
|
|
2346
|
+
const types = [...windows.keys()];
|
|
2347
|
+
const observed = new Map();
|
|
2348
|
+
for (const row of rt.db
|
|
2349
|
+
.prepare(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
|
|
2350
|
+
WHERE type IN (${types.map(() => '?').join(', ')}) GROUP BY type`)
|
|
2351
|
+
.all(...types)) {
|
|
2352
|
+
if (row.at !== null)
|
|
2353
|
+
observed.set(row.type, row.at);
|
|
2354
|
+
}
|
|
2355
|
+
const nowIso = this.clock();
|
|
2356
|
+
const now = Date.parse(nowIso);
|
|
2357
|
+
for (const [eventType, withinHours] of windows) {
|
|
2358
|
+
const observedAt = observed.get(eventType) ?? null;
|
|
2359
|
+
const outcome = observedAt === null
|
|
2360
|
+
? 'skipped' // never observed — the never-run analogue, not a failure
|
|
2361
|
+
: now - Date.parse(observedAt) <= withinHours * 3_600_000
|
|
2362
|
+
? 'ok'
|
|
2363
|
+
: 'failed';
|
|
2364
|
+
const stateKey = `freshness:${eventType}`;
|
|
2365
|
+
// #1288: the evaluator's own row, named by kind. Before the column, a module
|
|
2366
|
+
// declaring a schedule called `freshness:<this type>` shared it — and the
|
|
2367
|
+
// verdict read back here was whichever of the two wrote last.
|
|
2368
|
+
const state = rt.db
|
|
2369
|
+
.prepare(`SELECT last_run_at, last_status FROM _substrat_schedule_state
|
|
2370
|
+
WHERE kind = 'freshness' AND schedule_op = ?`)
|
|
2371
|
+
.get(stateKey);
|
|
2372
|
+
const changed = state?.last_status !== outcome;
|
|
2373
|
+
const heartbeatDue = !state?.last_run_at ||
|
|
2374
|
+
now - Date.parse(state.last_run_at) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
|
|
2375
|
+
if (!changed && !heartbeatDue)
|
|
2376
|
+
continue;
|
|
2377
|
+
rt.db
|
|
2378
|
+
.prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
|
|
2379
|
+
VALUES ('freshness', ?, ?, ?)
|
|
2380
|
+
ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
|
|
2381
|
+
last_status = excluded.last_status`)
|
|
2382
|
+
.run(stateKey, nowIso, outcome);
|
|
2383
|
+
report.checks.push({ eventType, outcome, observedAt, withinHours });
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2283
2386
|
return report;
|
|
2284
2387
|
}
|
|
2285
2388
|
async runDueSchedules(moduleId, tenantId, scopeId) {
|
|
@@ -2298,18 +2401,48 @@ export class SqliteScopeHost {
|
|
|
2298
2401
|
return report;
|
|
2299
2402
|
const rt = this.runtime(tenantId, scopeId);
|
|
2300
2403
|
await this.applyPendingMigrations(rt);
|
|
2301
|
-
// The grant IS the switch (#383)
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2404
|
+
// The grant IS the switch (#383), and the kill switch is its lever (#1666): the
|
|
2405
|
+
// kernel's `systemScheduleState`, the one predicate both adapters run. A scope that
|
|
2406
|
+
// never held the module's grant (a foreign vertical's) is a quiet no-op, exactly as
|
|
2407
|
+
// before. A scope switched OFF reports every schedule `skipped`, never `failed`, and
|
|
2408
|
+
// does not touch its cadence rows — so a restore fires a due schedule on the next pass.
|
|
2305
2409
|
const nowIso = this.clock();
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2410
|
+
// The whole gate — the switch state and every cadence row — is ONE turn, on the write
|
|
2411
|
+
// connection (#1624). Outside a turn it saw a suspended operation's uncommitted grant,
|
|
2412
|
+
// revoke or cadence row, and fired (or skipped) on a state its rollback was about to
|
|
2413
|
+
// undo. Not the read connection either: these reads decide a write (a fire, then its
|
|
2414
|
+
// cadence row), and a snapshot read ahead of the write's turn is the split #1678 closed.
|
|
2415
|
+
// One turn rather than one per read, because the first turn already waits out any
|
|
2416
|
+
// stranger; nothing but this loop writes a `kind = 'schedule'` row, so reading them all
|
|
2417
|
+
// up front answers what reading each just before its fire did.
|
|
2418
|
+
//
|
|
2419
|
+
// #1288: each schedule's own row. `kind` is what makes that true even when the operation
|
|
2420
|
+
// is spelled `freshness:<eventType>` — without it, the cadence gate read the evaluator's
|
|
2421
|
+
// last recorded verdict as a run of this operation.
|
|
2422
|
+
const gate = await rt.actor.turn(() => {
|
|
2423
|
+
const switched = systemScheduleState(switchSqlOf(rt.db), moduleId, nowIso);
|
|
2424
|
+
const lastRuns = new Map();
|
|
2425
|
+
if (switched !== 'on')
|
|
2426
|
+
return { state: switched, lastRuns };
|
|
2427
|
+
const read = rt.db.prepare(`SELECT last_run_at FROM _substrat_schedule_state
|
|
2428
|
+
WHERE kind = 'schedule' AND schedule_op = ?`);
|
|
2429
|
+
for (const schedule of mod.schedules) {
|
|
2430
|
+
const row = read.get(schedule.operation);
|
|
2431
|
+
lastRuns.set(schedule.operation, row?.last_run_at ?? null);
|
|
2432
|
+
}
|
|
2433
|
+
return { state: switched, lastRuns };
|
|
2434
|
+
});
|
|
2435
|
+
const state = gate.state;
|
|
2436
|
+
if (state === 'ungranted')
|
|
2312
2437
|
return report;
|
|
2438
|
+
if (state === 'off') {
|
|
2439
|
+
for (const schedule of mod.schedules) {
|
|
2440
|
+
report.skipped += 1;
|
|
2441
|
+
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
2442
|
+
}
|
|
2443
|
+
report.switchedOff = true;
|
|
2444
|
+
return report;
|
|
2445
|
+
}
|
|
2313
2446
|
// The spine state table is KERNEL_DDL's (#1288) — kernel-owned (`_substrat_*`),
|
|
2314
2447
|
// never a module migration, and created by `runtime()` above rather than lazily
|
|
2315
2448
|
// here. This loop's rows are `kind = 'schedule'`, keyed by the operation
|
|
@@ -2318,14 +2451,8 @@ export class SqliteScopeHost {
|
|
|
2318
2451
|
// for what the same two columns mean there.
|
|
2319
2452
|
const now = Date.parse(nowIso);
|
|
2320
2453
|
for (const schedule of mod.schedules) {
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
// below read the evaluator's last recorded verdict as a run of this operation.
|
|
2324
|
-
const row = rt.db
|
|
2325
|
-
.prepare(`SELECT last_run_at FROM _substrat_schedule_state
|
|
2326
|
-
WHERE kind = 'schedule' AND schedule_op = ?`)
|
|
2327
|
-
.get(schedule.operation);
|
|
2328
|
-
const lastRun = row?.last_run_at ? Date.parse(row.last_run_at) : null;
|
|
2454
|
+
const lastRunAt = gate.lastRuns.get(schedule.operation) ?? null;
|
|
2455
|
+
const lastRun = lastRunAt ? Date.parse(lastRunAt) : null;
|
|
2329
2456
|
const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
|
|
2330
2457
|
if (now < dueAt) {
|
|
2331
2458
|
report.skipped += 1;
|
|
@@ -2348,18 +2475,27 @@ export class SqliteScopeHost {
|
|
|
2348
2475
|
error: err instanceof Error ? err.message : String(err),
|
|
2349
2476
|
});
|
|
2350
2477
|
}
|
|
2351
|
-
|
|
2478
|
+
// On the scope actor (#1678): the cadence row joined whatever transaction an invoke
|
|
2479
|
+
// held open, so its rollback forgot the run and the next pass fired it AGAIN.
|
|
2480
|
+
await rt.actor.turn(() => rt.db
|
|
2352
2481
|
.prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
.run(schedule.operation, new Date(now).toISOString(), status);
|
|
2482
|
+
VALUES ('schedule', ?, ?, ?)
|
|
2483
|
+
ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
|
|
2484
|
+
last_status = excluded.last_status`)
|
|
2485
|
+
.run(schedule.operation, new Date(now).toISOString(), status));
|
|
2357
2486
|
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
2358
2487
|
}
|
|
2359
2488
|
return report;
|
|
2360
2489
|
}
|
|
2361
|
-
/** The stub body, shared by the principal, connection, system and
|
|
2362
|
-
buildStub(tenantId, scopeId, rt,
|
|
2490
|
+
/** The stub body, shared by the principal, connection, system, impersonation and capability doors. */
|
|
2491
|
+
buildStub(tenantId, scopeId, rt,
|
|
2492
|
+
/**
|
|
2493
|
+
* Who the stub acts as — or, for the capability door (#1672), the SESSION it holds,
|
|
2494
|
+
* which every invoke re-resolves to its capability inside the scope's actor task. A
|
|
2495
|
+
* capability resolved once at mint time would outlive its own revocation for exactly
|
|
2496
|
+
* the one caller holding the stub.
|
|
2497
|
+
*/
|
|
2498
|
+
authority, options,
|
|
2363
2499
|
/**
|
|
2364
2500
|
* K-42: the session this stub acts under. `subject` is already the
|
|
2365
2501
|
* IMPERSONATED principal — that is what makes the permission model answer
|
|
@@ -2441,9 +2577,22 @@ export class SqliteScopeHost {
|
|
|
2441
2577
|
// which is the leak `causedBy` had to be moved off the host to avoid.
|
|
2442
2578
|
rt.invocationId = invokeOptions?.invocationId ?? null;
|
|
2443
2579
|
try {
|
|
2580
|
+
// #1672: the capability door's session, re-resolved on EVERY invoke and inside the
|
|
2581
|
+
// actor task — so nothing can revoke between this read and the transaction below.
|
|
2582
|
+
// Refuses a stale session, a revoked or expired capability and an operation off its
|
|
2583
|
+
// allowlist before anything opens; none of those is a K-35 denial (no key checked).
|
|
2584
|
+
const subject = authority.kind === 'capability-session'
|
|
2585
|
+
? {
|
|
2586
|
+
kind: 'capability',
|
|
2587
|
+
id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation),
|
|
2588
|
+
}
|
|
2589
|
+
: authority;
|
|
2590
|
+
// #1672: the secrets this invocation mints — withheld from its idempotency recording,
|
|
2591
|
+
// and what the tripwire on its writes looks for.
|
|
2592
|
+
const minted = [];
|
|
2444
2593
|
// Fresh per operation: the context carries the K-34 authorization accumulator,
|
|
2445
2594
|
// which must not leak across operations (invokes are serialized per scope).
|
|
2446
|
-
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation);
|
|
2595
|
+
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted);
|
|
2447
2596
|
const clonedInput = structuredClone(input);
|
|
2448
2597
|
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
2449
2598
|
// declaration. BEFORE `BEGIN`, so a malformed call never opens a
|
|
@@ -2529,7 +2678,9 @@ export class SqliteScopeHost {
|
|
|
2529
2678
|
// that adds a row.
|
|
2530
2679
|
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
2531
2680
|
const at = this.clock();
|
|
2532
|
-
const record = idempotencyRecordStatement(subject, idempotencyKey, operation, fingerprint,
|
|
2681
|
+
const record = idempotencyRecordStatement(subject, idempotencyKey, operation, fingerprint,
|
|
2682
|
+
// #1672: a replay of a mint returns the placeholder, never the secret.
|
|
2683
|
+
redactSecrets(result, minted), committedVersion ?? null, at);
|
|
2533
2684
|
rt.db.prepare(record.sql).run(...record.params);
|
|
2534
2685
|
const prune = idempotencyPruneStatement(at);
|
|
2535
2686
|
rt.db.prepare(prune.sql).run(...prune.params);
|
|
@@ -2635,10 +2786,8 @@ export class SqliteScopeHost {
|
|
|
2635
2786
|
}
|
|
2636
2787
|
}
|
|
2637
2788
|
async close() {
|
|
2638
|
-
for (const
|
|
2639
|
-
|
|
2640
|
-
this.scopes.clear();
|
|
2641
|
-
this.scopesById.clear();
|
|
2789
|
+
for (const rt of [...this.scopes.values()])
|
|
2790
|
+
this.closeRuntime(rt);
|
|
2642
2791
|
this.directory.close();
|
|
2643
2792
|
}
|
|
2644
2793
|
// -------------------------------------------------------------------------
|
|
@@ -2696,8 +2845,22 @@ export class SqliteScopeHost {
|
|
|
2696
2845
|
ORDER BY o.id`)
|
|
2697
2846
|
.all(deliveryId, executor.eventType, now);
|
|
2698
2847
|
for (const row of rows) {
|
|
2699
|
-
const event = this.parseOutboxRow(row);
|
|
2700
2848
|
report.attempted += 1;
|
|
2849
|
+
let event;
|
|
2850
|
+
try {
|
|
2851
|
+
event = domainEventOf(row);
|
|
2852
|
+
}
|
|
2853
|
+
catch (err) {
|
|
2854
|
+
// #1636: an event that does not decode is dead-lettered for this executor at
|
|
2855
|
+
// once, and the handler never sees it. The decode sat above this loop's `try`,
|
|
2856
|
+
// so one bad row threw the executor's whole pending list on every pass. Terminal
|
|
2857
|
+
// on the FIRST failure, unlike a handler's: the decode is pure, of text already
|
|
2858
|
+
// in hand, so a retry cannot succeed — and only the decode is caught here, so a
|
|
2859
|
+
// handler's transient failure keeps its backoff below.
|
|
2860
|
+
this.recordExecutorDelivery(rt, row.id, deliveryId, String(err), UNDECODABLE_RETRY, invocationId);
|
|
2861
|
+
report.deadLettered += 1;
|
|
2862
|
+
continue;
|
|
2863
|
+
}
|
|
2701
2864
|
this.causedBy = event.id;
|
|
2702
2865
|
try {
|
|
2703
2866
|
if (executor.kind === 'connector') {
|
|
@@ -2807,10 +2970,10 @@ export class SqliteScopeHost {
|
|
|
2807
2970
|
(id, module_id, job, instance, payload, status, cursor, counters, attempts,
|
|
2808
2971
|
last_error, started_at, updated_at, next_attempt_at, ended_at)
|
|
2809
2972
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2973
|
+
// A compare-and-set on `running` (#1632): a pass that outlived an erasure must not
|
|
2974
|
+
// write its cursor back over the redaction. The kernel's statement, so both adapters
|
|
2975
|
+
// hold the same line.
|
|
2976
|
+
const patchRun = db.prepare(JOB_RUN_PATCH_SQL);
|
|
2814
2977
|
const patchArgs = (id, p) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id];
|
|
2815
2978
|
// `db.transaction` on better-sqlite3 runs its body SYNCHRONOUSLY inside a real
|
|
2816
2979
|
// SQLite transaction, so nothing — not another turn of the event loop, not
|
|
@@ -2869,12 +3032,8 @@ export class SqliteScopeHost {
|
|
|
2869
3032
|
.prepare('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?')
|
|
2870
3033
|
.get(runId, name) ?? null),
|
|
2871
3034
|
recordStep: (runId, name, result, attempts, lastError, at) => turn(() => {
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
ON CONFLICT (run_id, step) DO UPDATE SET result = excluded.result,
|
|
2875
|
-
attempts = excluded.attempts,
|
|
2876
|
-
last_error = excluded.last_error,
|
|
2877
|
-
recorded_at = excluded.recorded_at`).run(runId, name, result, attempts, lastError, at);
|
|
3035
|
+
// Only while the run is still `running` (#1632) — see `JOB_STEP_RECORD_SQL`.
|
|
3036
|
+
db.prepare(JOB_STEP_RECORD_SQL).run(runId, name, result, attempts, lastError, at, runId);
|
|
2878
3037
|
}),
|
|
2879
3038
|
};
|
|
2880
3039
|
}
|
|
@@ -2936,7 +3095,7 @@ export class SqliteScopeHost {
|
|
|
2936
3095
|
// "not granted" where the checker answers allow — a read-back that disagrees with
|
|
2937
3096
|
// enforcement is worse than none, since it is the read an operator would trust.
|
|
2938
3097
|
const rows = [
|
|
2939
|
-
...rt
|
|
3098
|
+
...this.readDb(rt)
|
|
2940
3099
|
.prepare(`SELECT subject, relation, expires_at FROM _substrat_tuples
|
|
2941
3100
|
WHERE subject LIKE 'connection:%' AND relation LIKE 'granted:%'
|
|
2942
3101
|
AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
|
|
@@ -3016,7 +3175,7 @@ export class SqliteScopeHost {
|
|
|
3016
3175
|
async executorDeadLetters(tenantId, scopeId) {
|
|
3017
3176
|
const rt = this.runtime(tenantId, scopeId);
|
|
3018
3177
|
await this.applyPendingMigrations(rt);
|
|
3019
|
-
const rows = rt
|
|
3178
|
+
const rows = this.readDb(rt)
|
|
3020
3179
|
.prepare(`SELECT d.event_id, d.consumer_module, d.attempts, d.error, d.delivered_at, o.type
|
|
3021
3180
|
FROM _substrat_deliveries d
|
|
3022
3181
|
JOIN _substrat_outbox o ON o.id = d.event_id
|
|
@@ -3037,27 +3196,36 @@ export class SqliteScopeHost {
|
|
|
3037
3196
|
async listPlatformRequests(tenantId, scopeId) {
|
|
3038
3197
|
const rt = this.runtime(tenantId, scopeId);
|
|
3039
3198
|
await this.applyPendingMigrations(rt);
|
|
3040
|
-
const rows = rt
|
|
3199
|
+
const rows = this.readDb(rt)
|
|
3041
3200
|
.prepare(`SELECT ${PLATFORM_REQUEST_COLUMNS}
|
|
3042
3201
|
FROM _substrat_platform_requests WHERE status = 'pending' ORDER BY id`)
|
|
3043
3202
|
.all();
|
|
3044
|
-
|
|
3203
|
+
// Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
|
|
3204
|
+
return rows.map(platformRequestOf);
|
|
3045
3205
|
}
|
|
3046
3206
|
async listPlatformRequestHistory(tenantId, scopeId, filter) {
|
|
3047
3207
|
const rt = this.runtime(tenantId, scopeId);
|
|
3048
3208
|
await this.applyPendingMigrations(rt);
|
|
3049
3209
|
const q = platformRequestHistoryQuery(filter);
|
|
3050
|
-
return rt.
|
|
3210
|
+
return this.readDb(rt).prepare(q.sql).all(...q.params).map(platformRequestOf);
|
|
3051
3211
|
}
|
|
3052
3212
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
3053
3213
|
const rt = this.runtime(tenantId, scopeId);
|
|
3054
3214
|
await this.applyPendingMigrations(rt);
|
|
3055
3215
|
await rt.actor.enqueue(() => {
|
|
3216
|
+
// Compare-and-set on `pending` (#1600 review). The drain reads pending rows, runs a
|
|
3217
|
+
// handler, then settles — and between the read and the settle a subject erasure can
|
|
3218
|
+
// redact the row. Settling by `id` alone let that stale pass overwrite the redaction
|
|
3219
|
+
// and write a provider's reply, which can quote the person, back into `last_error`.
|
|
3220
|
+
// Nothing legitimate is refused by this: `listPlatformRequests` returns only pending
|
|
3221
|
+
// rows, so every settle in the tree targets one that was pending when it was read.
|
|
3222
|
+
// A settle that finds the row already terminal does nothing, deliberately silently —
|
|
3223
|
+
// throwing would make the drain's blanket catch retry a row that is correctly over.
|
|
3056
3224
|
rt.db
|
|
3057
3225
|
.prepare(`UPDATE _substrat_platform_requests
|
|
3058
3226
|
SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
|
|
3059
3227
|
attempts = attempts + 1, settled_at = ?
|
|
3060
|
-
WHERE id =
|
|
3228
|
+
WHERE id = ? AND status = 'pending'`)
|
|
3061
3229
|
.run(outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure), outcome.status === 'pending' ? null : new Date().toISOString(), id);
|
|
3062
3230
|
});
|
|
3063
3231
|
}
|
|
@@ -3082,7 +3250,24 @@ export class SqliteScopeHost {
|
|
|
3082
3250
|
ORDER BY o.id`)
|
|
3083
3251
|
.all(consumer.eventType, mod.id);
|
|
3084
3252
|
for (const row of rows) {
|
|
3085
|
-
|
|
3253
|
+
let event;
|
|
3254
|
+
try {
|
|
3255
|
+
event = domainEventOf(row);
|
|
3256
|
+
}
|
|
3257
|
+
catch (err) {
|
|
3258
|
+
// #1636: dead-letter an event that does not decode, exactly as a failed
|
|
3259
|
+
// handler is — the decode sat ABOVE the `try` below, so one bad row halted
|
|
3260
|
+
// every event of this type behind it, on every pass. The consumer is never
|
|
3261
|
+
// handed it: an event built from stand-ins is not one it may act on. Only
|
|
3262
|
+
// the decode is caught; the journal write is not, so a failure to record
|
|
3263
|
+
// this still surfaces as it would for a handler's dead letter.
|
|
3264
|
+
rt.db
|
|
3265
|
+
.prepare(`INSERT INTO _substrat_deliveries
|
|
3266
|
+
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3267
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
3268
|
+
.run(row.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
3269
|
+
continue;
|
|
3270
|
+
}
|
|
3086
3271
|
const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
|
|
3087
3272
|
system: mod.id,
|
|
3088
3273
|
});
|
|
@@ -3152,11 +3337,7 @@ export class SqliteScopeHost {
|
|
|
3152
3337
|
// policy, carries no permission key, and is left to the module.
|
|
3153
3338
|
if (!err.permission || !err.node)
|
|
3154
3339
|
return;
|
|
3155
|
-
const actor = subject
|
|
3156
|
-
? { system: subject.id }
|
|
3157
|
-
: subject.kind === 'connection'
|
|
3158
|
-
? { connection: subject.id }
|
|
3159
|
-
: subject.id;
|
|
3340
|
+
const actor = actorOf(subject);
|
|
3160
3341
|
rt.db
|
|
3161
3342
|
.prepare(`INSERT INTO _substrat_denials
|
|
3162
3343
|
(id, actor, permission, tenant_id, scope_id, operation, impersonation,
|
|
@@ -3166,29 +3347,6 @@ export class SqliteScopeHost {
|
|
|
3166
3347
|
// #1525: the call this refusal belongs to, as the caller named it.
|
|
3167
3348
|
invocationId, new Date().toISOString());
|
|
3168
3349
|
}
|
|
3169
|
-
parseOutboxRow(row) {
|
|
3170
|
-
return domainEvent.parse({
|
|
3171
|
-
id: row.id,
|
|
3172
|
-
type: row.type,
|
|
3173
|
-
schemaVersion: row.schema_version,
|
|
3174
|
-
occurredAt: row.occurred_at,
|
|
3175
|
-
tenantId: row.tenant_id,
|
|
3176
|
-
scopeId: row.scope_id,
|
|
3177
|
-
actor: JSON.parse(row.actor),
|
|
3178
|
-
entity: { entityType: row.entity_type, entityId: row.entity_id },
|
|
3179
|
-
piiClass: row.pii_class,
|
|
3180
|
-
...(row.subject_id ? { subjectId: row.subject_id } : {}),
|
|
3181
|
-
...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
|
|
3182
|
-
// K-42: the stamp survives the read, so a consumer's event and an executor's
|
|
3183
|
-
// are the same fact the stored row is. Absent rather than null when nobody
|
|
3184
|
-
// was impersonating, because `DomainEvent.impersonation` is optional — the
|
|
3185
|
-
// shape module code never sees is also the shape it cannot branch on.
|
|
3186
|
-
...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
|
|
3187
|
-
// #1231: absent rather than null, the same shape rule as the stamp above.
|
|
3188
|
-
...(row.operation ? { operation: row.operation } : {}),
|
|
3189
|
-
payload: row.payload === null ? undefined : JSON.parse(row.payload),
|
|
3190
|
-
});
|
|
3191
|
-
}
|
|
3192
3350
|
// -------------------------------------------------------------------------
|
|
3193
3351
|
// Admin surface (enforcement input, §9.2.5)
|
|
3194
3352
|
// -------------------------------------------------------------------------
|
|
@@ -3615,6 +3773,9 @@ export class SqliteScopeHost {
|
|
|
3615
3773
|
(tenant_id, subject, relation, object, expires_at)
|
|
3616
3774
|
VALUES (?, ?, ?, ?, ?)`)
|
|
3617
3775
|
.run(tenantId, subject, relation, object, expiresAt ?? null);
|
|
3776
|
+
// The EXPLICIT grant: `INSERT OR REPLACE` clears a tombstone, because a re-grant must
|
|
3777
|
+
// grant. Provisioning seats with `seatScopeTuple` instead, which never un-revokes
|
|
3778
|
+
// (#1659) — keep the two apart.
|
|
3618
3779
|
const writeScopeTuple = (node, subject, relation, object, expiresAt) => {
|
|
3619
3780
|
if (!node.scopeId)
|
|
3620
3781
|
throw new Error('scope tuple requires node.scopeId');
|
|
@@ -3635,6 +3796,82 @@ export class SqliteScopeHost {
|
|
|
3635
3796
|
writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt);
|
|
3636
3797
|
}
|
|
3637
3798
|
};
|
|
3799
|
+
/**
|
|
3800
|
+
* A node's write, on the scope actor when the node is a scope (#1678). `invoke` holds a
|
|
3801
|
+
* raw `BEGIN IMMEDIATE` on the scope's connection across awaits, so a bare statement
|
|
3802
|
+
* issued mid-invoke joined that transaction and rolled back with it — after the verb
|
|
3803
|
+
* had answered success and audited. `body` holds the check AND the write, synchronously,
|
|
3804
|
+
* so nothing moves between them. Tenant-level: a directory write, which no scope's
|
|
3805
|
+
* transaction can hold, so it runs as it always did.
|
|
3806
|
+
*/
|
|
3807
|
+
const onNode = (node, body) => node.scopeId
|
|
3808
|
+
? this.runtime(node.tenantId, node.scopeId).actor.turn(body)
|
|
3809
|
+
: Promise.resolve().then(body);
|
|
3810
|
+
/** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
|
|
3811
|
+
const switchSystem = async (actor, raw, to) => {
|
|
3812
|
+
const input = systemSwitch.parse(raw);
|
|
3813
|
+
const { tenantId, scopeId } = input.node;
|
|
3814
|
+
const scope = this.directory
|
|
3815
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
3816
|
+
.get(scopeId);
|
|
3817
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
3818
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3819
|
+
}
|
|
3820
|
+
// AUDIT FIRST (#1666 review), exactly as the Cloudflare adapter: the intent row before
|
|
3821
|
+
// anything moves, the outcome row after, on every attempt. The directory and the
|
|
3822
|
+
// scope's file are separate databases, so a crash between them leaves an intent with
|
|
3823
|
+
// no recorded outcome — never a moved switch with no audit row.
|
|
3824
|
+
const operationId = ulid();
|
|
3825
|
+
const action = to === 'off' ? 'revokeFromSystem' : 'restoreToSystem';
|
|
3826
|
+
const target = { tenantId, scopeId };
|
|
3827
|
+
const base = { operationId, moduleId: input.moduleId, schedules: to };
|
|
3828
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
3829
|
+
let outcome;
|
|
3830
|
+
try {
|
|
3831
|
+
// A turn on the scope actor (#1666 review), exactly as the job store (#1577): `invoke`
|
|
3832
|
+
// holds a raw `BEGIN IMMEDIATE` on this same connection across awaits, so a bare
|
|
3833
|
+
// `db.transaction` issued mid-invoke became a SAVEPOINT inside it and rolled back
|
|
3834
|
+
// with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
|
|
3835
|
+
// caller already inside one of this scope's tasks joins it rather than deadlocking.
|
|
3836
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
3837
|
+
outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), {
|
|
3838
|
+
moduleId: input.moduleId,
|
|
3839
|
+
scopeId,
|
|
3840
|
+
to,
|
|
3841
|
+
at: new Date().toISOString(),
|
|
3842
|
+
}))());
|
|
3843
|
+
}
|
|
3844
|
+
catch (err) {
|
|
3845
|
+
try {
|
|
3846
|
+
this.recordAdmin(actor, action, target, null, {
|
|
3847
|
+
...base,
|
|
3848
|
+
phase: 'failed',
|
|
3849
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3850
|
+
});
|
|
3851
|
+
}
|
|
3852
|
+
catch {
|
|
3853
|
+
// Best effort: the original error is what the caller must see.
|
|
3854
|
+
}
|
|
3855
|
+
throw err;
|
|
3856
|
+
}
|
|
3857
|
+
this.recordAdmin(actor, action, target, null, {
|
|
3858
|
+
...base,
|
|
3859
|
+
phase: outcome.held ? 'applied' : 'refused',
|
|
3860
|
+
changed: outcome.changed,
|
|
3861
|
+
permissions: outcome.permissions,
|
|
3862
|
+
});
|
|
3863
|
+
if (!outcome.held) {
|
|
3864
|
+
throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
|
|
3865
|
+
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`);
|
|
3866
|
+
}
|
|
3867
|
+
return {
|
|
3868
|
+
operationId,
|
|
3869
|
+
moduleId: input.moduleId,
|
|
3870
|
+
schedules: to,
|
|
3871
|
+
changed: outcome.changed,
|
|
3872
|
+
permissions: outcome.permissions,
|
|
3873
|
+
};
|
|
3874
|
+
};
|
|
3638
3875
|
return {
|
|
3639
3876
|
// #603: fixed at construction — a host built without a box can never store a
|
|
3640
3877
|
// credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
@@ -3693,7 +3930,8 @@ export class SqliteScopeHost {
|
|
|
3693
3930
|
assignRole: async (actor, assignment) => {
|
|
3694
3931
|
const subject = `principal:${assignment.principalId}`;
|
|
3695
3932
|
if (assignment.node.scopeId) {
|
|
3696
|
-
|
|
3933
|
+
const scopeNode = assignment.node.scopeId;
|
|
3934
|
+
await onNode(assignment.node, () => writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${scopeNode}`));
|
|
3697
3935
|
}
|
|
3698
3936
|
else {
|
|
3699
3937
|
writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`);
|
|
@@ -3709,11 +3947,14 @@ export class SqliteScopeHost {
|
|
|
3709
3947
|
const now = new Date().toISOString();
|
|
3710
3948
|
let changes;
|
|
3711
3949
|
if (assignment.node.scopeId) {
|
|
3712
|
-
|
|
3713
|
-
|
|
3950
|
+
// On the scope actor (#1678): a revoke that joined a stranger's transaction and
|
|
3951
|
+
// rolled back with it left the role LIVE after the caller was told it was gone.
|
|
3952
|
+
const scopeNode = assignment.node.scopeId;
|
|
3953
|
+
const rt = this.runtime(assignment.node.tenantId, scopeNode);
|
|
3954
|
+
changes = await rt.actor.turn(() => rt.db
|
|
3714
3955
|
.prepare(`UPDATE _substrat_tuples SET revoked_at = ?
|
|
3715
|
-
|
|
3716
|
-
.run(now, subject, relation, `scope:${
|
|
3956
|
+
WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
|
|
3957
|
+
.run(now, subject, relation, `scope:${scopeNode}`).changes);
|
|
3717
3958
|
}
|
|
3718
3959
|
else {
|
|
3719
3960
|
changes = this.directory
|
|
@@ -3730,44 +3971,56 @@ export class SqliteScopeHost {
|
|
|
3730
3971
|
// trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
|
|
3731
3972
|
// to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
|
|
3732
3973
|
const grant = capabilityGrant.parse(raw);
|
|
3733
|
-
writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
|
|
3974
|
+
await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
|
|
3734
3975
|
this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
3735
3976
|
},
|
|
3736
3977
|
grantToConnection: async (actor, raw) => {
|
|
3737
3978
|
const grant = connectionGrant.parse(raw);
|
|
3738
|
-
const
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
// The grant may not reach outside what the connection already is. A
|
|
3743
|
-
// connection is keyed (tenant, vertical, provider); letting it hold a
|
|
3744
|
-
// permission in another tenant would make the key decorative.
|
|
3745
|
-
if (conn.tenant_id !== grant.node.tenantId) {
|
|
3746
|
-
throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
|
|
3747
|
-
`be granted anything in ${grant.node.tenantId}`);
|
|
3748
|
-
}
|
|
3749
|
-
if (grant.node.scopeId) {
|
|
3750
|
-
const scope = this.directory
|
|
3751
|
-
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
3752
|
-
.get(grant.node.scopeId);
|
|
3753
|
-
if (!scope || scope.tenant_id !== grant.node.tenantId) {
|
|
3754
|
-
throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
|
|
3979
|
+
const check = () => {
|
|
3980
|
+
const conn = this.connectionRow(grant.connectionId);
|
|
3981
|
+
if (conn.revoked_at) {
|
|
3982
|
+
throw new Error(`connection ${grant.connectionId} is revoked — grant nothing to it`);
|
|
3755
3983
|
}
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3984
|
+
// The grant may not reach outside what the connection already is. A
|
|
3985
|
+
// connection is keyed (tenant, vertical, provider); letting it hold a
|
|
3986
|
+
// permission in another tenant would make the key decorative.
|
|
3987
|
+
if (conn.tenant_id !== grant.node.tenantId) {
|
|
3988
|
+
throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
|
|
3989
|
+
`be granted anything in ${grant.node.tenantId}`);
|
|
3759
3990
|
}
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3991
|
+
if (grant.node.scopeId) {
|
|
3992
|
+
const scope = this.directory
|
|
3993
|
+
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
3994
|
+
.get(grant.node.scopeId);
|
|
3995
|
+
if (!scope || scope.tenant_id !== grant.node.tenantId) {
|
|
3996
|
+
throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
|
|
3997
|
+
}
|
|
3998
|
+
if (scope.vertical !== conn.vertical) {
|
|
3999
|
+
throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
|
|
4000
|
+
`${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
return conn;
|
|
4004
|
+
};
|
|
4005
|
+
// Once BEFORE the turn, so a refused grant never opens (and so creates) the scope's
|
|
4006
|
+
// file; then again inside it, with the tuple and the directory row, in ONE body on
|
|
4007
|
+
// the scope actor (#1678) — so the tuple cannot roll back with a stranger's
|
|
4008
|
+
// transaction and leave the directory row (#592) claiming a grant the scope lacks.
|
|
4009
|
+
check();
|
|
4010
|
+
const conn = await onNode(grant.node, () => {
|
|
4011
|
+
const row = check();
|
|
4012
|
+
writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4013
|
+
// #592: the directory-side record, alongside the tuple. The tuple is checked
|
|
4014
|
+
// where it lives; this row is what provision/reconcile gather FROM, so the
|
|
4015
|
+
// grant reaches scopes provisioned after it without a human replaying it.
|
|
4016
|
+
this.directory
|
|
4017
|
+
.prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
|
|
4018
|
+
(connection_id, tenant_id, vertical, permission, scope_id, expires_at,
|
|
4019
|
+
granted_by, granted_at, revoked_at)
|
|
4020
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
|
|
4021
|
+
.run(grant.connectionId, grant.node.tenantId, row.vertical, grant.permission, grant.node.scopeId ?? null, grant.expiresAt ?? null, grant.grantedBy, new Date().toISOString());
|
|
4022
|
+
return row;
|
|
4023
|
+
});
|
|
3771
4024
|
this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
|
|
3772
4025
|
connectionId: grant.connectionId,
|
|
3773
4026
|
provider: conn.provider,
|
|
@@ -3783,15 +4036,79 @@ export class SqliteScopeHost {
|
|
|
3783
4036
|
// principal is bound to the module, and a scope only ever runs the modules
|
|
3784
4037
|
// its own host registered.
|
|
3785
4038
|
const grant = systemGrant.parse(raw);
|
|
3786
|
-
writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4039
|
+
const write = () => writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4040
|
+
const scope = grant.node.scopeId;
|
|
4041
|
+
if (scope) {
|
|
4042
|
+
// #1666: refused while the module is switched off on this scope. The check and the
|
|
4043
|
+
// write share one synchronous body inside one turn on the scope actor, so no switch
|
|
4044
|
+
// can move between them — and (#1666 review) neither can land inside an unrelated
|
|
4045
|
+
// `invoke`'s open transaction and roll back with it after this verb returned. The
|
|
4046
|
+
// turn is re-entrant: from inside one of this scope's tasks it joins that task.
|
|
4047
|
+
// Restore is the lever; a grant is not.
|
|
4048
|
+
const rt = this.runtime(grant.node.tenantId, scope);
|
|
4049
|
+
await rt.actor.turn(() => {
|
|
4050
|
+
if (systemSwitchedOff(switchSqlOf(rt.db), grant.moduleId)) {
|
|
4051
|
+
throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, scope));
|
|
4052
|
+
}
|
|
4053
|
+
write();
|
|
4054
|
+
});
|
|
4055
|
+
}
|
|
4056
|
+
else {
|
|
4057
|
+
// Tenant-level: a directory write, which no scope's transaction can hold.
|
|
4058
|
+
write();
|
|
4059
|
+
}
|
|
3787
4060
|
this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
|
|
3788
4061
|
},
|
|
4062
|
+
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
4063
|
+
// whole rule, shared with the Cloudflare adapter; this is the directory check, the
|
|
4064
|
+
// transaction and the audit row around it.
|
|
4065
|
+
revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
|
|
4066
|
+
restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
|
|
4067
|
+
// #1672 — the platform's two capability verbs, both a turn on the scope actor
|
|
4068
|
+
// (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
|
|
4069
|
+
// stranger's transaction) and both in the admin log without the secret or its hash.
|
|
4070
|
+
mintCapability: async (actor, tenantId, scopeId, input) => {
|
|
4071
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
4072
|
+
const minted = await rt.actor.turn(() => mintBecomeCapability(spineSql(rt.db), input, actor, this.clock()));
|
|
4073
|
+
this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId }, null, {
|
|
4074
|
+
capabilityId: minted.id,
|
|
4075
|
+
mode: 'become',
|
|
4076
|
+
principal: input.principal,
|
|
4077
|
+
expiresAt: input.expiresAt,
|
|
4078
|
+
maxUses: input.maxUses,
|
|
4079
|
+
label: input.label ?? null,
|
|
4080
|
+
});
|
|
4081
|
+
return minted;
|
|
4082
|
+
},
|
|
4083
|
+
revokeCapability: async (actor, tenantId, scopeId, capabilityId) => {
|
|
4084
|
+
const scope = this.directory
|
|
4085
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
4086
|
+
.get(scopeId);
|
|
4087
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
4088
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4089
|
+
}
|
|
4090
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
4091
|
+
await this.applyPendingMigrations(rt);
|
|
4092
|
+
const before = await rt.actor.turn(() => rt.db.transaction(() => revokeCapabilityAsPlatform(spineSql(rt.db), capabilityId, actor, this.clock()))());
|
|
4093
|
+
if (!before) {
|
|
4094
|
+
throw substratError('not_found', `no capability ${capabilityId} in scope ${scopeId}`);
|
|
4095
|
+
}
|
|
4096
|
+
this.recordAdmin(actor, 'revokeCapability', { tenantId, scopeId }, before, {
|
|
4097
|
+
capabilityId,
|
|
4098
|
+
revoked: true,
|
|
4099
|
+
});
|
|
4100
|
+
},
|
|
3789
4101
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
3790
4102
|
// The org must exist in the node's tenant. A grant to a phantom org is
|
|
3791
4103
|
// worse than an error: it looks applied, resolves for nobody, and shows up
|
|
3792
4104
|
// in the permission diff as though access were conferred.
|
|
4105
|
+
// Checked once before the turn, so a refusal opens no scope file; then again with
|
|
4106
|
+
// the write in one body, on the scope actor when the node is a scope (#1678).
|
|
3793
4107
|
requireOrg(node.tenantId, orgId);
|
|
3794
|
-
|
|
4108
|
+
await onNode(node, () => {
|
|
4109
|
+
requireOrg(node.tenantId, orgId);
|
|
4110
|
+
writeGrant(`org:${orgId}`, permission, node, entity);
|
|
4111
|
+
});
|
|
3795
4112
|
this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
|
|
3796
4113
|
},
|
|
3797
4114
|
// -- vertical + version registry (#31) ---------------------------------
|
|
@@ -4452,24 +4769,18 @@ export class SqliteScopeHost {
|
|
|
4452
4769
|
this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at }, { expiresAt });
|
|
4453
4770
|
},
|
|
4454
4771
|
readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
this.
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
causedBy: r.caused_by ?? null,
|
|
4468
|
-
// Same lift, same reason. `drainedEvent` requires this field, and the `as never`
|
|
4469
|
-
// below is what let it be missed: without it a SQLite-backed lake loses the
|
|
4470
|
-
// call grouping permanently, while a Cloudflare-backed one keeps it.
|
|
4471
|
-
invocationId: r.invocation_id ?? null,
|
|
4472
|
-
}));
|
|
4772
|
+
// The COMMITTED outbox (#1624). On the shared connection this read saw an in-flight
|
|
4773
|
+
// operation's uncommitted event, the drain shipped it, and the rollback left the lake
|
|
4774
|
+
// — which is append-only — holding an event the scope never had. The mark that
|
|
4775
|
+
// follows needs no such care: it stamps only rows that exist and are undrained.
|
|
4776
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4777
|
+
const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`);
|
|
4778
|
+
// The kernel's read, shared with the DO: the envelope and the columns lifted beside
|
|
4779
|
+
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
4780
|
+
// rather than stalling the scope on it (#1636).
|
|
4781
|
+
const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(Math.max(limit ?? 200, 1), 1000));
|
|
4782
|
+
this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
|
|
4783
|
+
return undrainedEventsOf(read);
|
|
4473
4784
|
},
|
|
4474
4785
|
markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
|
|
4475
4786
|
// BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
|
|
@@ -4490,12 +4801,16 @@ export class SqliteScopeHost {
|
|
|
4490
4801
|
// cannot absorb: the next read resumes from the middle of a batch already
|
|
4491
4802
|
// in the lake, and the object it writes overlaps the one already there —
|
|
4492
4803
|
// the duplication the first-id object key exists to prevent.
|
|
4493
|
-
|
|
4804
|
+
//
|
|
4805
|
+
// On the scope actor (#1678): issued mid-invoke, this transaction was a SAVEPOINT
|
|
4806
|
+
// inside the invoke's, and its rollback un-marked a batch already in the lake — so
|
|
4807
|
+
// the next pass shipped it again, the same duplicate by another road.
|
|
4808
|
+
const drained = await this.runtime(tenantId, scopeId).actor.turn(() => db.transaction(() => {
|
|
4494
4809
|
let n = 0;
|
|
4495
4810
|
for (const id of eventIds)
|
|
4496
4811
|
n += stmt.run(at, id).changes;
|
|
4497
4812
|
return n;
|
|
4498
|
-
})();
|
|
4813
|
+
})());
|
|
4499
4814
|
// K-24's rule, one tier down (#1334): declaring domain payloads shipped is an
|
|
4500
4815
|
// EGRESS, and the admin log is where "these events left the platform, at this
|
|
4501
4816
|
// time, on this actor's say-so" is recorded. `drainAccessLog` audits the same
|
|
@@ -4532,7 +4847,8 @@ export class SqliteScopeHost {
|
|
|
4532
4847
|
// an access row — the count itself as the result count, which is the fact an
|
|
4533
4848
|
// auditor asking "who counted this tenant's outbox, and how much was there" wants.
|
|
4534
4849
|
if (countOnly) {
|
|
4535
|
-
|
|
4850
|
+
// The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
|
|
4851
|
+
const redrainable = this.scopeReadDbFor(tenantId, scopeId)
|
|
4536
4852
|
.prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
|
|
4537
4853
|
WHERE drained_at IS NOT NULL AND drained_at < ?`)
|
|
4538
4854
|
.get(drainedBefore).c;
|
|
@@ -4556,14 +4872,17 @@ export class SqliteScopeHost {
|
|
|
4556
4872
|
// is never pruned, so the unbounded form scales with the scope's whole history.
|
|
4557
4873
|
// SQLite is built here without UPDATE...LIMIT, so the batch is a subselect over the
|
|
4558
4874
|
// primary key — oldest first, so a partial run leaves a prefix rather than holes.
|
|
4559
|
-
|
|
4875
|
+
//
|
|
4876
|
+
// On the scope actor (#1678), so a stranger's rollback cannot undo a reopen this
|
|
4877
|
+
// verb has already counted and receipted.
|
|
4878
|
+
const redrained = await this.runtime(tenantId, scopeId).actor.turn(() => db
|
|
4560
4879
|
.prepare(`UPDATE _substrat_outbox SET drained_at = NULL
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
.run(drainedBefore, REDRAIN_BATCH).changes;
|
|
4880
|
+
WHERE id IN (
|
|
4881
|
+
SELECT id FROM _substrat_outbox
|
|
4882
|
+
WHERE drained_at IS NOT NULL AND drained_at < ?
|
|
4883
|
+
ORDER BY id LIMIT ?
|
|
4884
|
+
)`)
|
|
4885
|
+
.run(drainedBefore, REDRAIN_BATCH).changes);
|
|
4567
4886
|
// The outcome beside the intent above — only when something changed, so a re-run
|
|
4568
4887
|
// over an already reopened window leaves no row claiming it did something.
|
|
4569
4888
|
if (redrained > 0) {
|
|
@@ -4574,7 +4893,7 @@ export class SqliteScopeHost {
|
|
|
4574
4893
|
facetEvents: async (actor, tenantId, scopeId, input) => {
|
|
4575
4894
|
// `facetEvents` is the sanctioned read: an erased payload yields the same
|
|
4576
4895
|
// NULL a missing field does, and only the helper counts them apart.
|
|
4577
|
-
const db = this.
|
|
4896
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4578
4897
|
const result = facetEvents({ sql: scopedSql(db) }, input);
|
|
4579
4898
|
this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
|
|
4580
4899
|
return result;
|
|
@@ -4584,7 +4903,7 @@ export class SqliteScopeHost {
|
|
|
4584
4903
|
// only one that decodes the envelope's nullable facts (an erased payload, an
|
|
4585
4904
|
// unrecorded authorization chain, nobody impersonating) as facts rather than
|
|
4586
4905
|
// as missing data.
|
|
4587
|
-
const db = this.
|
|
4906
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4588
4907
|
const page = readHistory({ sql: scopedSql(db) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
|
|
4589
4908
|
this.recordAccess(actor, 'entityHistory', { tenantId, scopeId }, { entityType: input.entityType, entityId: input.entityId }, page.entries.length);
|
|
4590
4909
|
return page;
|
|
@@ -4593,7 +4912,7 @@ export class SqliteScopeHost {
|
|
|
4593
4912
|
// #1237: the sanctioned walk. The helper owns the one distinction the whole
|
|
4594
4913
|
// view rests on — a null cause with an operation is the beginning of the
|
|
4595
4914
|
// chain, a null cause without one is the trail running out.
|
|
4596
|
-
const db = this.
|
|
4915
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4597
4916
|
const result = walkEventCause({ sql: scopedSql(db) }, input.eventId, input.maxDepth);
|
|
4598
4917
|
this.recordAccess(actor, 'eventCause', { tenantId, scopeId }, { eventId: input.eventId }, result.chain.length);
|
|
4599
4918
|
return result;
|
|
@@ -4601,27 +4920,27 @@ export class SqliteScopeHost {
|
|
|
4601
4920
|
eventEffects: async (actor, tenantId, scopeId, input) => {
|
|
4602
4921
|
// #1237 forward: the helper owns the two delivery traps — `delivered_at`
|
|
4603
4922
|
// meaning two different things, and an empty list being ambiguous.
|
|
4604
|
-
const db = this.
|
|
4923
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4605
4924
|
const tree = walkEventEffects({ sql: scopedSql(db) }, input.eventId, input.maxNodes);
|
|
4606
4925
|
this.recordAccess(actor, 'eventEffects', { tenantId, scopeId }, { eventId: input.eventId }, tree.count);
|
|
4607
4926
|
return tree;
|
|
4608
4927
|
},
|
|
4609
4928
|
invocationEvents: async (actor, tenantId, scopeId, input) => {
|
|
4610
4929
|
// #1237: one call's events, siblings included — what neither walk reaches.
|
|
4611
|
-
const db = this.
|
|
4930
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4612
4931
|
const read = readInvocation({ sql: scopedSql(db) }, input.invocationId, input.limit);
|
|
4613
4932
|
this.recordAccess(actor, 'invocationEvents', { tenantId, scopeId }, { invocationId: input.invocationId }, read.events.length);
|
|
4614
4933
|
return read;
|
|
4615
4934
|
},
|
|
4616
4935
|
deadLetters: async (actor, tenantId, scopeId, input) => {
|
|
4617
4936
|
// #1525: every delivery that gave up, scope-wide — what the walks reach only by event.
|
|
4618
|
-
const db = this.
|
|
4937
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4619
4938
|
const page = readDeadLetters({ sql: scopedSql(db) }, { limit: input.limit, cursor: input.cursor });
|
|
4620
4939
|
this.recordAccess(actor, 'deadLetters', { tenantId, scopeId }, null, page.entries.length);
|
|
4621
4940
|
return page;
|
|
4622
4941
|
},
|
|
4623
4942
|
scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
|
|
4624
|
-
const db = this.
|
|
4943
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4625
4944
|
const rows = db
|
|
4626
4945
|
.prepare(`SELECT module_id, version, applied_at FROM _substrat_migrations
|
|
4627
4946
|
ORDER BY applied_at DESC, module_id, version LIMIT 100`)
|
|
@@ -4645,6 +4964,16 @@ export class SqliteScopeHost {
|
|
|
4645
4964
|
this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, 0);
|
|
4646
4965
|
return [];
|
|
4647
4966
|
},
|
|
4967
|
+
scopeDatabaseSize: async (actor, tenantId, scopeId) => {
|
|
4968
|
+
// #1524: the SQLite twin of a DO's `databaseSize`. It counts allocated pages,
|
|
4969
|
+
// free ones included, which is what the file costs on disk, not the live row bytes.
|
|
4970
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4971
|
+
const pages = db.pragma('page_count', { simple: true });
|
|
4972
|
+
const pageSize = db.pragma('page_size', { simple: true });
|
|
4973
|
+
const bytes = pages * pageSize;
|
|
4974
|
+
this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
|
|
4975
|
+
return bytes;
|
|
4976
|
+
},
|
|
4648
4977
|
rewindScope: async () => {
|
|
4649
4978
|
throw new Error('point-in-time rewind is not available on this host (PITR is a Durable-Object-plane mechanism) — use the backup restore path');
|
|
4650
4979
|
},
|
|
@@ -4877,7 +5206,7 @@ export class SqliteScopeHost {
|
|
|
4877
5206
|
listScopeTables: async (actor, tenantId, scopeId) => {
|
|
4878
5207
|
// K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
|
|
4879
5208
|
// a scope under a different tenant is unreachable, never another tenant's DB.
|
|
4880
|
-
const db = this.
|
|
5209
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4881
5210
|
const names = db
|
|
4882
5211
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
|
4883
5212
|
.all().map((r) => r.name);
|
|
@@ -4890,7 +5219,7 @@ export class SqliteScopeHost {
|
|
|
4890
5219
|
return tables;
|
|
4891
5220
|
},
|
|
4892
5221
|
readScopeTable: async (actor, tenantId, scopeId, input) => {
|
|
4893
|
-
const db = this.
|
|
5222
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4894
5223
|
// Validate the table against the LIVE schema — an unknown name throws, it is
|
|
4895
5224
|
// never interpolated blind. That validated name is the only thing that reaches
|
|
4896
5225
|
// the query, so the quoted identifier below carries no user input.
|
|
@@ -4907,7 +5236,7 @@ export class SqliteScopeHost {
|
|
|
4907
5236
|
return { table: input.table, columns, rows, rowCount, limit, offset };
|
|
4908
5237
|
},
|
|
4909
5238
|
queryScope: async (actor, tenantId, scopeId, input) => {
|
|
4910
|
-
const db = this.
|
|
5239
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4911
5240
|
// Layer 1, shared: the kernel's textual gate (single statement, read verbs
|
|
4912
5241
|
// only) — the checked statement is the one that runs.
|
|
4913
5242
|
const sql = assertReadOnlyQuery(input.sql);
|
|
@@ -4942,14 +5271,14 @@ export class SqliteScopeHost {
|
|
|
4942
5271
|
// read records to the K-24 access log. What differs is that these two answer a
|
|
4943
5272
|
// question about the permission model rather than about the data.
|
|
4944
5273
|
listDenials: async (actor, tenantId, scopeId, filter) => {
|
|
4945
|
-
const db = this.
|
|
5274
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4946
5275
|
const q = denialListQuery(filter);
|
|
4947
5276
|
const rows = db.prepare(q.sql).all(...q.params).map(mapDenialRow);
|
|
4948
5277
|
this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
|
|
4949
5278
|
return rows;
|
|
4950
5279
|
},
|
|
4951
5280
|
summarizeDenials: async (actor, tenantId, scopeId, filter) => {
|
|
4952
|
-
const db = this.
|
|
5281
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4953
5282
|
const b = denialSummaryQuery(filter);
|
|
4954
5283
|
// Grouped as the filter asked (#1456), and the answer says which way.
|
|
4955
5284
|
const grouped = mapDenialSummaryBuckets(b.groupBy, db.prepare(b.sql).all(...b.params));
|
|
@@ -4969,7 +5298,7 @@ export class SqliteScopeHost {
|
|
|
4969
5298
|
},
|
|
4970
5299
|
exportScope: async (actor, tenantId, scopeId) => {
|
|
4971
5300
|
// K-3: cross-check the pair before opening anything (same as the introspection reads).
|
|
4972
|
-
const db = this.
|
|
5301
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4973
5302
|
// Every real table, keeping the `_substrat_*` spine but dropping SQLite's own
|
|
4974
5303
|
// `sqlite_*` internals — those are auto-managed and `CREATE TABLE sqlite_*` is
|
|
4975
5304
|
// rejected on reload. `sql` is the CREATE statement, replayed to rebuild the schema.
|
|
@@ -5110,13 +5439,11 @@ export class SqliteScopeHost {
|
|
|
5110
5439
|
throw new Error(`scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
|
|
5111
5440
|
`unbind it before reaping (reap wipes storage and cannot be undone)`);
|
|
5112
5441
|
}
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
this.
|
|
5118
|
-
this.scopesById.delete(scopeId);
|
|
5119
|
-
}
|
|
5442
|
+
// The reader goes with the writer (#1624): an open handle on a removed file keeps
|
|
5443
|
+
// reading the unlinked bytes, which is the reaped data answering as if it were live.
|
|
5444
|
+
const rt = this.scopes.get(`${tenantId}/${scopeId}`);
|
|
5445
|
+
if (rt)
|
|
5446
|
+
this.closeRuntime(rt);
|
|
5120
5447
|
rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
|
|
5121
5448
|
// The recoverable copy the caller stored first (#493), named in the audit entry so
|
|
5122
5449
|
// the trail answers "was there a backup" without correlating two timestamps.
|
|
@@ -5155,15 +5482,46 @@ export class SqliteScopeHost {
|
|
|
5155
5482
|
// and transaction facts remain". A consumer's timeline still shows that something
|
|
5156
5483
|
// happened, to what, and when; it no longer shows who or what was said.
|
|
5157
5484
|
const db = this.scopeDbFor(tenantId, scopeId);
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
.run(subjectId);
|
|
5485
|
+
// One instant for the whole erasure, read before the first write: the intent
|
|
5486
|
+
// tombstones below and the key's own tombstone should not disagree about when a
|
|
5487
|
+
// person was erased.
|
|
5162
5488
|
const at = new Date().toISOString();
|
|
5489
|
+
// Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
|
|
5490
|
+
// invoke held its transaction open, they joined it, and its rollback put the
|
|
5491
|
+
// person's PII back after this verb had destroyed the key and receipted the erasure.
|
|
5492
|
+
const { redacted, intentsRedacted, jobRunsRedacted } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
|
|
5493
|
+
redacted: db
|
|
5494
|
+
.prepare(`UPDATE _substrat_outbox SET payload = NULL
|
|
5495
|
+
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
|
|
5496
|
+
.run(subjectId),
|
|
5497
|
+
// The spine's OTHER copy of an event (#1600). A CP-less host routes a connector
|
|
5498
|
+
// delivery it cannot run into `_substrat_platform_requests` with the whole event
|
|
5499
|
+
// embedded, and nothing ever deletes those rows — so the same line has to be held
|
|
5500
|
+
// here, or the redaction above only moves the name one table across. Same
|
|
5501
|
+
// idempotence as the outbox half: a re-run finds the tombstones and changes
|
|
5502
|
+
// nothing.
|
|
5503
|
+
intentsRedacted: this.redactSubjectIntents(db, subjectId, at),
|
|
5504
|
+
// A failed delivery's error text about one of those events (#1632) — keyed by
|
|
5505
|
+
// event id, so the outbox predicate names it. Not a copy; not counted.
|
|
5506
|
+
deliveries: db
|
|
5507
|
+
.prepare(DELIVERY_ERROR_REDACTION_SQL)
|
|
5508
|
+
.run(REDACTED_DELIVERY_NOTE, REDACTED_DELIVERY_NOTE, subjectId),
|
|
5509
|
+
// The job-run tables (#1632): a run, its cursor or a step's memo can hold a copy
|
|
5510
|
+
// of the same event. The kernel's walk, so the DO runs the identical SQL.
|
|
5511
|
+
jobRunsRedacted: redactSubjectJobRuns((sql, params) => {
|
|
5512
|
+
const stmt = db.prepare(sql);
|
|
5513
|
+
if (stmt.reader)
|
|
5514
|
+
return stmt.all(...params);
|
|
5515
|
+
stmt.run(...params);
|
|
5516
|
+
return [];
|
|
5517
|
+
}, subjectId, at),
|
|
5518
|
+
}));
|
|
5163
5519
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
5164
5520
|
const receipt = subjectShredReceipt.parse({
|
|
5165
5521
|
subjectId,
|
|
5166
5522
|
eventsRedacted: redacted.changes,
|
|
5523
|
+
intentsRedacted,
|
|
5524
|
+
jobRunsRedacted,
|
|
5167
5525
|
keyDestroyed: existed,
|
|
5168
5526
|
tombstoned: true,
|
|
5169
5527
|
});
|
|
@@ -5171,7 +5529,9 @@ export class SqliteScopeHost {
|
|
|
5171
5529
|
// mutation, the access log because it destroys evidence. An erasure is the one
|
|
5172
5530
|
// action where "who asked for this to disappear" is itself the record.
|
|
5173
5531
|
this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
5174
|
-
|
|
5532
|
+
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
5533
|
+
// an intent payload is a whole event's worth of it.
|
|
5534
|
+
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted);
|
|
5175
5535
|
return receipt;
|
|
5176
5536
|
},
|
|
5177
5537
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -6148,6 +6508,24 @@ export class SqliteScopeHost {
|
|
|
6148
6508
|
// where the whole replay is already inside one.
|
|
6149
6509
|
db.transaction(() => db.exec(SCHEDULE_STATE_REBUILD))();
|
|
6150
6510
|
}
|
|
6511
|
+
/**
|
|
6512
|
+
* #1573: run a create-copy-drop-rename against the directory as ONE transaction.
|
|
6513
|
+
* `exec` of four statements is four autocommits, and the state between DROP and
|
|
6514
|
+
* RENAME is the one to fear: the next open runs the bootstrap first, whose `CREATE
|
|
6515
|
+
* TABLE IF NOT EXISTS` puts an EMPTY table of the new shape back, detection then
|
|
6516
|
+
* reads that shape and concludes the migration is done — and every copied row stays
|
|
6517
|
+
* orphaned in the `_new` table, with nothing ever erroring. Atomic, neither
|
|
6518
|
+
* intermediate state is reachable, so there is no recovery path to write (or to
|
|
6519
|
+
* leave untested).
|
|
6520
|
+
*
|
|
6521
|
+
* `db.transaction` nests as a SAVEPOINT, as `ensureScheduleStateKind` (#1571) relies
|
|
6522
|
+
* on. Each script leads with `DROP TABLE IF EXISTS <table>_new` to absorb a scratch
|
|
6523
|
+
* table arriving from BELOW the transaction (a torn copy of the file, a backup that
|
|
6524
|
+
* captured one mid-rebuild) — belt, not the fix.
|
|
6525
|
+
*/
|
|
6526
|
+
rebuildAtomically(script) {
|
|
6527
|
+
this.directory.transaction(() => this.directory.exec(script))();
|
|
6528
|
+
}
|
|
6151
6529
|
/**
|
|
6152
6530
|
* Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
|
|
6153
6531
|
* A PRIMARY KEY cannot be ALTERed, so this is create-copy-drop-rename.
|
|
@@ -6168,7 +6546,9 @@ export class SqliteScopeHost {
|
|
|
6168
6546
|
.get('_substrat_identities');
|
|
6169
6547
|
if (!row || row.sql.includes('PRIMARY KEY (tenant_id, provider, external_id)'))
|
|
6170
6548
|
return;
|
|
6171
|
-
|
|
6549
|
+
// #1573: one transaction — see `rebuildAtomically`.
|
|
6550
|
+
this.rebuildAtomically(`
|
|
6551
|
+
DROP TABLE IF EXISTS _substrat_identities_new;
|
|
6172
6552
|
CREATE TABLE _substrat_identities_new (
|
|
6173
6553
|
provider TEXT NOT NULL,
|
|
6174
6554
|
external_id TEXT NOT NULL,
|
|
@@ -6199,7 +6579,9 @@ export class SqliteScopeHost {
|
|
|
6199
6579
|
.get('_substrat_admin_log');
|
|
6200
6580
|
if (!row || !/tenant_id TEXT NOT NULL/.test(row.sql))
|
|
6201
6581
|
return;
|
|
6202
|
-
|
|
6582
|
+
// #1573: one transaction — see `rebuildAtomically`.
|
|
6583
|
+
this.rebuildAtomically(`
|
|
6584
|
+
DROP TABLE IF EXISTS _substrat_admin_log_new;
|
|
6203
6585
|
CREATE TABLE _substrat_admin_log_new (
|
|
6204
6586
|
id TEXT PRIMARY KEY,
|
|
6205
6587
|
actor TEXT NOT NULL,
|
|
@@ -6218,6 +6600,30 @@ export class SqliteScopeHost {
|
|
|
6218
6600
|
ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
|
|
6219
6601
|
`);
|
|
6220
6602
|
}
|
|
6603
|
+
/**
|
|
6604
|
+
* #1572: `_substrat_sweep_runs_intent` with `kind` in it, on a directory whose index
|
|
6605
|
+
* predates that. `CREATE UNIQUE INDEX IF NOT EXISTS` matches on the name alone, so the
|
|
6606
|
+
* bootstrap cannot do this: on an existing directory it sees the old index and skips,
|
|
6607
|
+
* and the schedule/freshness collision the new key refuses stays live for good.
|
|
6608
|
+
*
|
|
6609
|
+
* Detected from `sqlite_master.sql`, like the table rebuilds above, and replaced as
|
|
6610
|
+
* DROP-then-CREATE in one transaction (`rebuildAtomically`). Less rides on that than on
|
|
6611
|
+
* a table rebuild — the index holds no rows, and one dropped but not re-created comes
|
|
6612
|
+
* back on the next open, because the bootstrap creates it by name — but there is then
|
|
6613
|
+
* no committed directory with no dedupe key at all. The CREATE cannot meet a row it
|
|
6614
|
+
* refuses: every set of rows unique on (request_id, unit) is unique on the wider key.
|
|
6615
|
+
*/
|
|
6616
|
+
ensureSweepRunsIntentKind() {
|
|
6617
|
+
const row = this.directory
|
|
6618
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
|
|
6619
|
+
.get('_substrat_sweep_runs_intent');
|
|
6620
|
+
if (row && sweepRunsIntentHasKind(row.sql))
|
|
6621
|
+
return;
|
|
6622
|
+
this.rebuildAtomically(`
|
|
6623
|
+
DROP INDEX IF EXISTS _substrat_sweep_runs_intent;
|
|
6624
|
+
${SWEEP_RUNS_INTENT_INDEX}
|
|
6625
|
+
`);
|
|
6626
|
+
}
|
|
6221
6627
|
ensureDirectoryColumns() {
|
|
6222
6628
|
this.ensureIdentityKey();
|
|
6223
6629
|
this.ensureAdminLogTenantNullable();
|
|
@@ -6286,7 +6692,7 @@ export class SqliteScopeHost {
|
|
|
6286
6692
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'request_id', 'request_id TEXT');
|
|
6287
6693
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'event_type', 'event_type TEXT');
|
|
6288
6694
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'observed_at', 'observed_at TEXT');
|
|
6289
|
-
this.
|
|
6695
|
+
this.ensureSweepRunsIntentKind();
|
|
6290
6696
|
const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
|
|
6291
6697
|
for (const [column, ddl] of [
|
|
6292
6698
|
['parent_scope_id', 'parent_scope_id TEXT'],
|
|
@@ -6363,7 +6769,23 @@ export class SqliteScopeHost {
|
|
|
6363
6769
|
* onto every event it emits. Absent for consumer dispatch: a consumer runs on
|
|
6364
6770
|
* behalf of no operation, and the emitted row's NULL says so.
|
|
6365
6771
|
*/
|
|
6366
|
-
operation
|
|
6772
|
+
operation,
|
|
6773
|
+
/**
|
|
6774
|
+
* #1672: the secrets `ctx.capabilities.mint` hands out during this invocation. The
|
|
6775
|
+
* stub owns the array (it withholds them from the idempotency recording); this
|
|
6776
|
+
* context appends to it and holds `ctx.sql`, `ctx.emit` and `ctx.requestPlatform` to
|
|
6777
|
+
* it — the tripwire that catches a module persisting one by accident (not a boundary
|
|
6778
|
+
* against one that means to; see `assertNoSecret`).
|
|
6779
|
+
*/
|
|
6780
|
+
minted = [],
|
|
6781
|
+
/**
|
|
6782
|
+
* #1672: the instant to stamp, when the caller already read one. The exchange passes the
|
|
6783
|
+
* instant its row write used, so the `capability.exercised` event's `occurredAt` and the
|
|
6784
|
+
* row's `last_used_at` are the same value — a second clock read could disagree.
|
|
6785
|
+
*/
|
|
6786
|
+
at = this.clock()) {
|
|
6787
|
+
// For a connection, system or capability subject this carries THAT id so the type
|
|
6788
|
+
// holds — it is not a person, and the event actor below says what it is instead.
|
|
6367
6789
|
const principal = subject.id;
|
|
6368
6790
|
const checker = this.checker;
|
|
6369
6791
|
const relations = this.relations;
|
|
@@ -6388,7 +6810,6 @@ export class SqliteScopeHost {
|
|
|
6388
6810
|
* `ctx.now()` deterministic only in the sense that each reading is separately
|
|
6389
6811
|
* unpredictable.
|
|
6390
6812
|
*/
|
|
6391
|
-
const at = this.clock();
|
|
6392
6813
|
/**
|
|
6393
6814
|
* The scope host's half of `ctx.atomic` (#770) — everything else is the
|
|
6394
6815
|
* kernel's (`createAtomic`). SQLite gives us savepoints, which nest inside
|
|
@@ -6424,7 +6845,10 @@ export class SqliteScopeHost {
|
|
|
6424
6845
|
// Lifted out of the object literal so `grant` can reuse it: a delegation
|
|
6425
6846
|
// check has to be the SAME check the operation itself passes, or the two
|
|
6426
6847
|
// could disagree about what the caller holds.
|
|
6427
|
-
const runCheck = async (
|
|
6848
|
+
const runCheck = async (unparsed, entity) => {
|
|
6849
|
+
// #1642: parsed before the override actor's early return, which never reaches
|
|
6850
|
+
// the checker — a cast key would otherwise become that path's proof relation.
|
|
6851
|
+
const permission = assertPermissionKey(unparsed);
|
|
6428
6852
|
if (overrideActor) {
|
|
6429
6853
|
return {
|
|
6430
6854
|
allowed: true,
|
|
@@ -6462,15 +6886,17 @@ export class SqliteScopeHost {
|
|
|
6462
6886
|
scopeId: rt.scopeId,
|
|
6463
6887
|
});
|
|
6464
6888
|
};
|
|
6465
|
-
|
|
6889
|
+
const ctxRef = {
|
|
6466
6890
|
tenantId: rt.tenantId,
|
|
6467
6891
|
scopeId: rt.scopeId,
|
|
6468
6892
|
principal,
|
|
6469
|
-
sql: scopedSql(rt.db),
|
|
6893
|
+
sql: guardSecrets(scopedSql(rt.db), minted),
|
|
6470
6894
|
now: () => at,
|
|
6471
6895
|
emit: (event) => {
|
|
6472
6896
|
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
6473
6897
|
const input = domainEventInput.parse(event);
|
|
6898
|
+
// #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
|
|
6899
|
+
assertNoSecret('ctx.emit', input, minted);
|
|
6474
6900
|
const full = domainEvent.parse({
|
|
6475
6901
|
...input,
|
|
6476
6902
|
// #956: from the operation's instant, not the wall clock. `ORDER BY id`
|
|
@@ -6481,12 +6907,7 @@ export class SqliteScopeHost {
|
|
|
6481
6907
|
occurredAt: at,
|
|
6482
6908
|
tenantId: rt.tenantId,
|
|
6483
6909
|
scopeId: rt.scopeId,
|
|
6484
|
-
actor: overrideActor ??
|
|
6485
|
-
(subject.kind === 'system'
|
|
6486
|
-
? { system: subject.id }
|
|
6487
|
-
: subject.kind === 'connection'
|
|
6488
|
-
? { connection: subject.id }
|
|
6489
|
-
: principal),
|
|
6910
|
+
actor: overrideActor ?? actorOf(subject),
|
|
6490
6911
|
...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
|
|
6491
6912
|
...(stamp ? { impersonation: stamp } : {}),
|
|
6492
6913
|
// #1231: kernel-stamped like the two above — `domainEventInput.parse`
|
|
@@ -6516,6 +6937,8 @@ export class SqliteScopeHost {
|
|
|
6516
6937
|
requestPlatform: (request) => {
|
|
6517
6938
|
assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
|
|
6518
6939
|
const input = platformRequestInput.parse(request);
|
|
6940
|
+
// #1672: the COMPLETE parsed request — its `kind` is persisted as surely as its payload.
|
|
6941
|
+
assertNoSecret('ctx.requestPlatform', input, minted);
|
|
6519
6942
|
// #1474: a platform-authored kind (`sweep-runs`) is the platform's to enqueue, never
|
|
6520
6943
|
// module code's — its drain handler writes schedule verdicts the dashboard trusts.
|
|
6521
6944
|
assertModuleEnqueueableKind(input.kind);
|
|
@@ -6528,12 +6951,7 @@ export class SqliteScopeHost {
|
|
|
6528
6951
|
throw new Error(`too many pending platform requests (${pending}); retry once some have drained`);
|
|
6529
6952
|
}
|
|
6530
6953
|
const id = platformRequestId.parse(ulid());
|
|
6531
|
-
const requestedBy = overrideActor ??
|
|
6532
|
-
(subject.kind === 'system'
|
|
6533
|
-
? { system: subject.id }
|
|
6534
|
-
: subject.kind === 'connection'
|
|
6535
|
-
? { connection: subject.id }
|
|
6536
|
-
: principal);
|
|
6954
|
+
const requestedBy = overrideActor ?? actorOf(subject);
|
|
6537
6955
|
rt.db
|
|
6538
6956
|
.prepare(`INSERT INTO _substrat_platform_requests
|
|
6539
6957
|
(id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
|
|
@@ -6547,7 +6965,7 @@ export class SqliteScopeHost {
|
|
|
6547
6965
|
// predicate is needed or possible: the runtime IS the scope.
|
|
6548
6966
|
platformRequests: (filter) => {
|
|
6549
6967
|
const q = platformRequestHistoryQuery(filter);
|
|
6550
|
-
return rt.db.prepare(q.sql).all(...q.params).map(
|
|
6968
|
+
return rt.db.prepare(q.sql).all(...q.params).map(platformRequestOf);
|
|
6551
6969
|
},
|
|
6552
6970
|
// #901. This scope's own spine, so no tenancy predicate is needed or
|
|
6553
6971
|
// possible — the runtime IS the scope. The version is the last event's
|
|
@@ -6647,6 +7065,21 @@ export class SqliteScopeHost {
|
|
|
6647
7065
|
.run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
|
|
6648
7066
|
},
|
|
6649
7067
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
7068
|
+
// #1672: mint / revoke / list, written once in the kernel. The raw spine seam (this
|
|
7069
|
+
// is the kernel's own write to `_substrat_capabilities`), the operation's OWN check —
|
|
7070
|
+
// the predicate `grant` shares — and `ctx.emit` for the minted/revoked events. A
|
|
7071
|
+
// consumer's override actor is passed as the system actor it is, so a consumer cannot
|
|
7072
|
+
// mint: its checks allow unconditionally, which would make "the minter holds it" vacuous.
|
|
7073
|
+
capabilities: createCapabilityVerbs({
|
|
7074
|
+
sql: spineSql(rt.db),
|
|
7075
|
+
subject: overrideActor ? { kind: 'system', id: overrideActor.system } : subject,
|
|
7076
|
+
now: at,
|
|
7077
|
+
check: runCheck,
|
|
7078
|
+
emit: (event) => ctxRef.emit(event),
|
|
7079
|
+
isOperation: (name) => this.operations.has(name),
|
|
7080
|
+
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
7081
|
+
minted,
|
|
7082
|
+
}),
|
|
6650
7083
|
link: (child, parent) => {
|
|
6651
7084
|
assertImpersonationWrites(impersonation, 'ctx.link');
|
|
6652
7085
|
const allowed = relations.get(child.entityType);
|
|
@@ -6708,6 +7141,7 @@ export class SqliteScopeHost {
|
|
|
6708
7141
|
return sealTo({ keyId: key.keyId, publicKey: key.publicKey }, plaintext);
|
|
6709
7142
|
},
|
|
6710
7143
|
};
|
|
7144
|
+
return ctxRef;
|
|
6711
7145
|
}
|
|
6712
7146
|
async applyPendingMigrations(rt) {
|
|
6713
7147
|
const pending = [];
|
|
@@ -6816,10 +7250,98 @@ export class SqliteScopeHost {
|
|
|
6816
7250
|
throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
|
|
6817
7251
|
}
|
|
6818
7252
|
}
|
|
7253
|
+
/**
|
|
7254
|
+
* The intent-journal half of `shredSubject` (#1600) — the DO's `redactSubject` twin.
|
|
7255
|
+
*
|
|
7256
|
+
* Row-by-row rather than one `UPDATE … WHERE`: the decision is structural (does this
|
|
7257
|
+
* payload embed an event the erasure redacts?) and SQL cannot make it, because an
|
|
7258
|
+
* intent payload is opaque JSON with no `pii_class` column to test. The SQL narrows to
|
|
7259
|
+
* the rows that could possibly match; the kernel predicate decides; the kernel's
|
|
7260
|
+
* statement writes. All three are shared with the Durable-Object adapter so the two
|
|
7261
|
+
* cannot drift about what a redacted intent is.
|
|
7262
|
+
*
|
|
7263
|
+
* Returns how many rows it redacted — zero on a re-run, the tombstones having already
|
|
7264
|
+
* stopped matching.
|
|
7265
|
+
*/
|
|
7266
|
+
redactSubjectIntents(db, subjectId, at) {
|
|
7267
|
+
const q = platformRequestRedactionQuery(subjectId);
|
|
7268
|
+
const candidates = db.prepare(q.sql).all(...q.params);
|
|
7269
|
+
const write = db.prepare(PLATFORM_REQUEST_REDACTION_SQL);
|
|
7270
|
+
let redacted = 0;
|
|
7271
|
+
for (const row of candidates) {
|
|
7272
|
+
if (!intentPayloadCarriesSubject(row.payload, subjectId))
|
|
7273
|
+
continue;
|
|
7274
|
+
write.run(...platformRequestRedactionParams(row.id, subjectId, at));
|
|
7275
|
+
redacted += 1;
|
|
7276
|
+
}
|
|
7277
|
+
return redacted;
|
|
7278
|
+
}
|
|
6819
7279
|
scopeDbFor(tenantId, scopeId) {
|
|
6820
7280
|
this.assertScopeReachable(tenantId, scopeId);
|
|
6821
7281
|
return this.runtime(tenantId, scopeId).db;
|
|
6822
7282
|
}
|
|
7283
|
+
/**
|
|
7284
|
+
* The connection a `HostAdmin` READ runs on (#1624) — `scopeDbFor`'s twin, with the
|
|
7285
|
+
* same K-3 and reap gate in front of it.
|
|
7286
|
+
*
|
|
7287
|
+
* From OUTSIDE the scope's actor it is the scope's read-only connection. `invoke` holds
|
|
7288
|
+
* a `BEGIN IMMEDIATE` open on the shared connection across its awaits, so a read there
|
|
7289
|
+
* saw a stranger's uncommitted rows: a lake drain shipped an event that then rolled
|
|
7290
|
+
* back — permanently, since the lake is append-only — and an export captured one into a
|
|
7291
|
+
* backup. The file is WAL, so the reader sees exactly what was committed.
|
|
7292
|
+
*
|
|
7293
|
+
* From INSIDE one of the scope's actor tasks (an executor handed `HostAdmin`, reading
|
|
7294
|
+
* its own scope) it is the shared connection, as before. The only transaction open
|
|
7295
|
+
* there is the caller's own unit — the actor serializes everything else — so reading
|
|
7296
|
+
* it is reading your own writes, which is the answer the caller is owed and the one it
|
|
7297
|
+
* had. `ScopeActor.turn` makes the same call for the writers (#1678).
|
|
7298
|
+
*/
|
|
7299
|
+
scopeReadDbFor(tenantId, scopeId) {
|
|
7300
|
+
this.assertScopeReachable(tenantId, scopeId);
|
|
7301
|
+
return this.readDb(this.runtime(tenantId, scopeId));
|
|
7302
|
+
}
|
|
7303
|
+
/**
|
|
7304
|
+
* `scopeReadDbFor` without the directory gate, for the host-side reads that already
|
|
7305
|
+
* resolved their runtime — the platform-intent drain's `listPlatformRequests` among them,
|
|
7306
|
+
* where a stranger's uncommitted intent was a platform effect about to be carried out for
|
|
7307
|
+
* an operation that then rolled back.
|
|
7308
|
+
*/
|
|
7309
|
+
readDb(rt) {
|
|
7310
|
+
if (rt.actor.holds())
|
|
7311
|
+
return rt.db;
|
|
7312
|
+
const key = `${rt.tenantId}/${rt.scopeId}`;
|
|
7313
|
+
if (rt.reader) {
|
|
7314
|
+
// Most recently used goes to the back of the insertion order, which is the cap's LRU.
|
|
7315
|
+
this.readers.delete(key);
|
|
7316
|
+
this.readers.set(key, rt);
|
|
7317
|
+
return rt.reader;
|
|
7318
|
+
}
|
|
7319
|
+
// `runtime()` opened the writer first, in WAL, so the file and its `-shm` exist.
|
|
7320
|
+
rt.reader = new Database(rt.file, { readonly: true, fileMustExist: true });
|
|
7321
|
+
this.readers.set(key, rt);
|
|
7322
|
+
// The cap: a lake drain sweeps every scope, and without it every open scope would end
|
|
7323
|
+
// up holding two connections. The oldest reader is closed; the next read reopens it.
|
|
7324
|
+
while (this.readers.size > READER_CAP) {
|
|
7325
|
+
const [oldest, victim] = this.readers.entries().next().value;
|
|
7326
|
+
this.readers.delete(oldest);
|
|
7327
|
+
victim.reader?.close();
|
|
7328
|
+
victim.reader = null;
|
|
7329
|
+
}
|
|
7330
|
+
return rt.reader;
|
|
7331
|
+
}
|
|
7332
|
+
/**
|
|
7333
|
+
* Close a scope's connections and forget its runtime — the reader with the writer, so
|
|
7334
|
+
* neither outlives the file a reap or a snapshot delete is about to remove.
|
|
7335
|
+
*/
|
|
7336
|
+
closeRuntime(rt) {
|
|
7337
|
+
const key = `${rt.tenantId}/${rt.scopeId}`;
|
|
7338
|
+
rt.reader?.close();
|
|
7339
|
+
rt.reader = null;
|
|
7340
|
+
this.readers.delete(key);
|
|
7341
|
+
rt.db.close();
|
|
7342
|
+
this.scopes.delete(key);
|
|
7343
|
+
this.scopesById.delete(rt.scopeId);
|
|
7344
|
+
}
|
|
6823
7345
|
/**
|
|
6824
7346
|
* The additive spine-column migrations, shared by `runtime()` and the dump replay.
|
|
6825
7347
|
* KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before a column keeps the
|
|
@@ -6897,7 +7419,10 @@ export class SqliteScopeHost {
|
|
|
6897
7419
|
const existing = this.scopes.get(key);
|
|
6898
7420
|
if (existing)
|
|
6899
7421
|
return existing;
|
|
6900
|
-
const
|
|
7422
|
+
const file = join(this.dir, `${tenantId}__${scopeId}.sqlite`);
|
|
7423
|
+
const db = new Database(file);
|
|
7424
|
+
// WAL is also what the read connection rests on (#1624): a reader sees the last
|
|
7425
|
+
// committed snapshot and never blocks, or is blocked by, the writer.
|
|
6901
7426
|
db.pragma('journal_mode = WAL');
|
|
6902
7427
|
db.exec(KERNEL_DDL);
|
|
6903
7428
|
this.ensureSpineColumns(db);
|
|
@@ -6914,7 +7439,9 @@ export class SqliteScopeHost {
|
|
|
6914
7439
|
const created = {
|
|
6915
7440
|
tenantId,
|
|
6916
7441
|
scopeId,
|
|
7442
|
+
file,
|
|
6917
7443
|
db,
|
|
7444
|
+
reader: null,
|
|
6918
7445
|
actor: new ScopeActor(),
|
|
6919
7446
|
appliedMigrations,
|
|
6920
7447
|
mintEventId,
|
|
@@ -6945,6 +7472,20 @@ function cellToJson(v) {
|
|
|
6945
7472
|
* `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
|
|
6946
7473
|
* spine writes use `rt.db` directly and never pass through here.
|
|
6947
7474
|
*/
|
|
7475
|
+
/**
|
|
7476
|
+
* The kernel's OWN spine access (#1672) — the same seam as `scopedSql` without `guardSpine`,
|
|
7477
|
+
* because these are the kernel's writes to `_substrat_capabilities`, which module code may
|
|
7478
|
+
* never make. Never handed to module code.
|
|
7479
|
+
*/
|
|
7480
|
+
function spineSql(db) {
|
|
7481
|
+
return {
|
|
7482
|
+
query: (sql, params = []) => db.prepare(sql).all(...params),
|
|
7483
|
+
exec: (sql, params = []) => {
|
|
7484
|
+
const info = db.prepare(sql).run(...params);
|
|
7485
|
+
return { changes: info.changes };
|
|
7486
|
+
},
|
|
7487
|
+
};
|
|
7488
|
+
}
|
|
6948
7489
|
function scopedSql(db) {
|
|
6949
7490
|
return guardSpine({
|
|
6950
7491
|
query: (sql, params = []) => db.prepare(sql).all(...params),
|