@substrat-run/adapter-sqlite 0.114.0 → 0.117.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 +170 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1199 -335
- 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, 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, 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
|
*
|
|
@@ -119,10 +134,39 @@ const KERNEL_DDL = `
|
|
|
119
134
|
operation TEXT,
|
|
120
135
|
-- K-42: WHICH of the two actors was refused is exactly what this log is for.
|
|
121
136
|
impersonation TEXT,
|
|
137
|
+
-- #1525: the INVOCATION this refusal happened during, the same id #1237 stamps on
|
|
138
|
+
-- every event of a call. A denial joins to nothing but actor and time otherwise —
|
|
139
|
+
-- the operation column names what was attempted, never WHICH attempt — so "what
|
|
140
|
+
-- else did this request do" cannot reach a refusal, which is the one thing an
|
|
141
|
+
-- incident asks about first. NULL = the transport carried no id (a seed, a test,
|
|
142
|
+
-- an internal call, an attachment RPC), or the row predates the column.
|
|
143
|
+
invocation_id TEXT,
|
|
122
144
|
at TEXT NOT NULL,
|
|
123
145
|
drained_at TEXT
|
|
124
146
|
);
|
|
125
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}
|
|
152
|
+
-- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
|
|
153
|
+
-- families of row that the kind COLUMN — not the spelling of a key — tells apart.
|
|
154
|
+
-- Spine (kernel-written), never a module migration. Shared with the DO adapter from
|
|
155
|
+
-- @substrat-run/kernel, so the shape a rebuild produces and the shape a fresh store
|
|
156
|
+
-- gets cannot part company; the column comments are in there.
|
|
157
|
+
--
|
|
158
|
+
-- HERE since #1288, rather than lazily in runDueSchedules/checkFreshness as it was:
|
|
159
|
+
-- a table only one adapter's KERNEL_DDL declares is a one-sided NOTE to
|
|
160
|
+
-- lint:spine-ddl, never a failure, so the copies this issue had to keep in step were
|
|
161
|
+
-- the copies the gate could not see. Every scope db passes through runtime(), which
|
|
162
|
+
-- runs this, so the lazy creates were load-bearing for nothing.
|
|
163
|
+
${SCHEDULE_STATE_DDL}
|
|
164
|
+
-- #1577: the resumable-run driver's record and the step ledger of the pass it
|
|
165
|
+
-- currently has in flight. Spine (kernel-written), never a module migration.
|
|
166
|
+
-- Shared with the DO adapter from @substrat-run/kernel so the shape a self-host
|
|
167
|
+
-- builds and the shape production builds cannot part company; the column
|
|
168
|
+
-- comments, and the reason coalescing is NOT a unique index, are in there.
|
|
169
|
+
${JOB_RUN_DDL}
|
|
126
170
|
CREATE TABLE IF NOT EXISTS _substrat_migrations (
|
|
127
171
|
module_id TEXT NOT NULL,
|
|
128
172
|
version TEXT NOT NULL,
|
|
@@ -155,6 +199,17 @@ const KERNEL_DDL = `
|
|
|
155
199
|
-- next_attempt_at IS NULL -> terminal: error IS NULL delivered, else dead
|
|
156
200
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
157
201
|
next_attempt_at TEXT,
|
|
202
|
+
-- #1525: the invocation THIS ATTEMPT ran in, the same id #1237 stamps on every
|
|
203
|
+
-- event of a call. Not the emitting event's — that one is already on the outbox
|
|
204
|
+
-- and a delivery joins to it through event_id. What that join cannot say is
|
|
205
|
+
-- which call attempted the DELIVERY, and for an executor the two differ by
|
|
206
|
+
-- design: attempt one runs in the emitting call's post-commit tail, every retry
|
|
207
|
+
-- afterwards in a drain that is a different call or none. Moves with the row on
|
|
208
|
+
-- an upsert, like delivered_at, error and attempts — it describes the
|
|
209
|
+
-- latest attempt, not the first. NULL = that attempt carried no call (a
|
|
210
|
+
-- scheduled drain, an alarm, a seed, an attachment RPC), or the row predates
|
|
211
|
+
-- the column.
|
|
212
|
+
invocation_id TEXT,
|
|
158
213
|
PRIMARY KEY (event_id, consumer_module)
|
|
159
214
|
);
|
|
160
215
|
-- Attachment metadata facts (#473): one row per object in the per-tenant blob store,
|
|
@@ -294,23 +349,12 @@ function modelUsageEntryOf(r) {
|
|
|
294
349
|
elapsedMs: r.elapsed_ms,
|
|
295
350
|
});
|
|
296
351
|
}
|
|
297
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
requestedBy: JSON.parse(r.requested_by),
|
|
304
|
-
impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
|
|
305
|
-
status: r.status,
|
|
306
|
-
attempts: r.attempts,
|
|
307
|
-
lastError: r.last_error,
|
|
308
|
-
failure: r.last_failure == null ? null : JSON.parse(r.last_failure),
|
|
309
|
-
result: r.result === null ? null : JSON.parse(r.result),
|
|
310
|
-
requestedAt: r.requested_at,
|
|
311
|
-
settledAt: r.settled_at,
|
|
312
|
-
});
|
|
313
|
-
}
|
|
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 };
|
|
314
358
|
/**
|
|
315
359
|
* The dispatch capability (#726 remedy B): a connector delivery may read the attachments
|
|
316
360
|
* of the entity the DELIVERED EVENT names, and nothing else.
|
|
@@ -350,11 +394,39 @@ const admitByDelivery = (rt, eventId, row) => {
|
|
|
350
394
|
};
|
|
351
395
|
export class SqliteScopeHost {
|
|
352
396
|
admin;
|
|
397
|
+
/**
|
|
398
|
+
* Live reads are not available on this host (#938) — `never`, not `undefined`.
|
|
399
|
+
*
|
|
400
|
+
* This is the `clock?: never` seam (`CloudflareScopeHostOptions`) pointing the other
|
|
401
|
+
* way. There, the hosted adapter refuses an option it could only honour for half of
|
|
402
|
+
* the host's judgements; here, the pure adapter refuses a surface it cannot honour at
|
|
403
|
+
* all. A live read needs something that outlives a request and can be woken when an
|
|
404
|
+
* event lands. On Cloudflare that is the scope's own Durable Object, which the runtime
|
|
405
|
+
* keeps addressable between requests. This host is a function call inside somebody
|
|
406
|
+
* else's process: nothing here survives the return, so the only honest answer is that
|
|
407
|
+
* there is no subscription to hand out.
|
|
408
|
+
*
|
|
409
|
+
* `never` rather than an omission, and rather than a `subscribe` that throws, because
|
|
410
|
+
* those two fail in the places this one does not: an omission makes
|
|
411
|
+
* `host.liveReads?.subscribe(…)` compile and silently do nothing, and a thrower moves
|
|
412
|
+
* the discovery to runtime, in production, on the one path nobody drove in a test.
|
|
413
|
+
* The field is declared so that reaching for it is refused by the compiler at the call
|
|
414
|
+
* site — the same argument the clock seam makes in its own comment.
|
|
415
|
+
*
|
|
416
|
+
* What this costs, stated rather than hidden: nothing in the fan-out is held to a
|
|
417
|
+
* CONTRACT suite, because a contract suite asserts the two adapters agree and on this
|
|
418
|
+
* they deliberately do not. The tests live on `adapter-cloudflare` instead, beside the
|
|
419
|
+
* only host that can run them. The day a pure host grows a long-lived transport, this
|
|
420
|
+
* comment and those tests' home are the whole change.
|
|
421
|
+
*/
|
|
422
|
+
liveReads;
|
|
353
423
|
dir;
|
|
354
424
|
checker;
|
|
355
425
|
directory;
|
|
356
426
|
scopes = new Map();
|
|
357
427
|
scopesById = new Map();
|
|
428
|
+
/** The runtimes holding an open `reader` (#1624), least recently read first — `READER_CAP`'s LRU. */
|
|
429
|
+
readers = new Map();
|
|
358
430
|
/** Per-tenant relational stores (#301), opened lazily and cached by `ref` (the bare
|
|
359
431
|
* `.sqlite` filename). The pure-adapter analogue of a per-tenant D1 — one file per
|
|
360
432
|
* (tenant, vertical, binding), physically separate from the scope DBs. */
|
|
@@ -399,6 +471,13 @@ export class SqliteScopeHost {
|
|
|
399
471
|
roles = new Map(); // 'tenantId/roleKey'
|
|
400
472
|
/** Executor id → {eventType, handler} (K-22 §4.2). Host code, not module code. */
|
|
401
473
|
executors = new Map();
|
|
474
|
+
/**
|
|
475
|
+
* `<moduleId>/<job>` → the pass body and its default step policy (#1577). Host
|
|
476
|
+
* code like `executors`, and keyed the way a run row is: the coalescing key's
|
|
477
|
+
* first two thirds, so a run picked off the table finds its handler by the
|
|
478
|
+
* columns it already carries.
|
|
479
|
+
*/
|
|
480
|
+
jobs = new Map();
|
|
402
481
|
/**
|
|
403
482
|
* The event currently being effected by an executor, stamped onto any admin rows
|
|
404
483
|
* it writes. Ambient rather than threaded through every HostAdmin signature: it is
|
|
@@ -979,7 +1058,9 @@ export class SqliteScopeHost {
|
|
|
979
1058
|
at TEXT NOT NULL
|
|
980
1059
|
);
|
|
981
1060
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_unit ON _substrat_sweep_runs (kind, unit, id);
|
|
982
|
-
|
|
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}
|
|
983
1064
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_tenant ON _substrat_sweep_runs (tenant_id, id);
|
|
984
1065
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_at ON _substrat_sweep_runs (at);
|
|
985
1066
|
-- Model usage (#1054, meter 3): one line per model call a vertical made through
|
|
@@ -1367,21 +1448,29 @@ export class SqliteScopeHost {
|
|
|
1367
1448
|
await this.applyPendingMigrations(rt);
|
|
1368
1449
|
// Project each registered module's SCHEDULE grants (#383): a system principal
|
|
1369
1450
|
// holds exactly the permissions its schedules declared, on this scope. This is
|
|
1370
|
-
// what makes `ctx.check` resolve for scheduled work — the gate stays the check
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
+
}
|
|
1383
1472
|
}
|
|
1384
|
-
}
|
|
1473
|
+
});
|
|
1385
1474
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
1386
1475
|
if (!existing) {
|
|
1387
1476
|
this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
@@ -1627,12 +1716,16 @@ export class SqliteScopeHost {
|
|
|
1627
1716
|
}
|
|
1628
1717
|
catch (err) {
|
|
1629
1718
|
rt.db.exec('ROLLBACK');
|
|
1719
|
+
// #1525: null. This task is the attachment RPC's own — no invoke set an id on
|
|
1720
|
+
// the runtime, and the actor guarantees no other task is mid-flight holding one.
|
|
1630
1721
|
if (err instanceof PermissionDenied)
|
|
1631
|
-
this.recordDenial(rt, subject, operation, err);
|
|
1722
|
+
this.recordDenial(rt, subject, operation, err, null);
|
|
1632
1723
|
throw err;
|
|
1633
1724
|
}
|
|
1634
|
-
|
|
1635
|
-
|
|
1725
|
+
// #1525: null, for the reason the denial two lines up records null — this task
|
|
1726
|
+
// is the attachment RPC's own and no invoke set an id on the runtime.
|
|
1727
|
+
await this.dispatch(rt, null);
|
|
1728
|
+
await this.dispatchExecutors(rt, null);
|
|
1636
1729
|
return result;
|
|
1637
1730
|
});
|
|
1638
1731
|
/**
|
|
@@ -1653,8 +1746,12 @@ export class SqliteScopeHost {
|
|
|
1653
1746
|
return await fn(this.operationContext(rt, subject, undefined, undefined, undefined, operation));
|
|
1654
1747
|
}
|
|
1655
1748
|
catch (err) {
|
|
1749
|
+
// #1525: the ENCLOSING call's id, deliberately. This runner takes no turn of its
|
|
1750
|
+
// own — it is the connector's dispatch-time read, already inside that call's
|
|
1751
|
+
// actor task — so its refusal belongs to that call, for the reason a consumer's
|
|
1752
|
+
// emit does (#1237): dispatch runs in the same post-commit tail and IS the call.
|
|
1656
1753
|
if (err instanceof PermissionDenied)
|
|
1657
|
-
this.recordDenial(rt, subject, operation, err);
|
|
1754
|
+
this.recordDenial(rt, subject, operation, err, rt.invocationId);
|
|
1658
1755
|
throw err;
|
|
1659
1756
|
}
|
|
1660
1757
|
};
|
|
@@ -1780,12 +1877,12 @@ export class SqliteScopeHost {
|
|
|
1780
1877
|
forkedFrom: input.forkedFrom ?? dump.scopeId,
|
|
1781
1878
|
forkedAt: input.forkedAt ?? dump.capturedAt,
|
|
1782
1879
|
});
|
|
1783
|
-
this.loadDump(input.tenantId, input.scopeId, dump.tables);
|
|
1880
|
+
await this.loadDump(input.tenantId, input.scopeId, dump.tables);
|
|
1784
1881
|
await this.admin.activateScope(actor, input.tenantId, input.scopeId);
|
|
1785
1882
|
this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1786
1883
|
}
|
|
1787
1884
|
/** Drop-then-replay a dump into a scope's db, refreshing the migration frontier. */
|
|
1788
|
-
loadDump(tenantId, scopeId, tables) {
|
|
1885
|
+
async loadDump(tenantId, scopeId, tables) {
|
|
1789
1886
|
const rt = this.runtime(tenantId, scopeId);
|
|
1790
1887
|
const db = rt.db;
|
|
1791
1888
|
const load = db.transaction((dumped) => {
|
|
@@ -1869,20 +1966,25 @@ export class SqliteScopeHost {
|
|
|
1869
1966
|
db.prepare(`UPDATE OR REPLACE _substrat_tuples SET object = ?
|
|
1870
1967
|
WHERE object LIKE 'scope:%' AND object <> ?`).run(`scope:${scopeId}`, `scope:${scopeId}`);
|
|
1871
1968
|
});
|
|
1872
|
-
|
|
1873
|
-
//
|
|
1874
|
-
//
|
|
1875
|
-
rt.
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
+
});
|
|
1879
1981
|
}
|
|
1880
1982
|
async restoreScope(actor, tenantId, scopeId, dump) {
|
|
1881
1983
|
// Restore never creates a scope (that is importScope) — an unknown target fails closed.
|
|
1882
1984
|
const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1883
1985
|
if (!existing)
|
|
1884
1986
|
throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1885
|
-
this.loadDump(tenantId, scopeId, dump.tables);
|
|
1987
|
+
await this.loadDump(tenantId, scopeId, dump.tables);
|
|
1886
1988
|
this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1887
1989
|
}
|
|
1888
1990
|
async snapshotScope(actor, tenantId, scopeId, opts) {
|
|
@@ -1919,13 +2021,9 @@ export class SqliteScopeHost {
|
|
|
1919
2021
|
`archive a primary scope instead`);
|
|
1920
2022
|
}
|
|
1921
2023
|
// Close and evict the runtime handle before touching the file.
|
|
1922
|
-
const
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
rt.db.close();
|
|
1926
|
-
this.scopes.delete(key);
|
|
1927
|
-
this.scopesById.delete(scopeId);
|
|
1928
|
-
}
|
|
2024
|
+
const rt = this.scopes.get(`${tenantId}/${scopeId}`);
|
|
2025
|
+
if (rt)
|
|
2026
|
+
this.closeRuntime(rt);
|
|
1929
2027
|
// Order is the retry story: hostnames first (a reaped preview URL must stop
|
|
1930
2028
|
// resolving), then the STORAGE, then the directory row — so a crash mid-way leaves
|
|
1931
2029
|
// a visible row over empty storage (re-running deleteSnapshot converges), never
|
|
@@ -2103,6 +2201,82 @@ export class SqliteScopeHost {
|
|
|
2103
2201
|
await this.applyPendingMigrations(rt);
|
|
2104
2202
|
return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
|
|
2105
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
|
+
}
|
|
2106
2280
|
registeredSchedules() {
|
|
2107
2281
|
const out = [];
|
|
2108
2282
|
for (const mod of this.modules.values()) {
|
|
@@ -2148,22 +2322,11 @@ export class SqliteScopeHost {
|
|
|
2148
2322
|
return report;
|
|
2149
2323
|
const rt = this.runtime(tenantId, scopeId);
|
|
2150
2324
|
await this.applyPendingMigrations(rt);
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
//
|
|
2154
|
-
//
|
|
2155
|
-
// `
|
|
2156
|
-
// Only half the no-collision claim is enforced: an event type has passed
|
|
2157
|
-
// contracts' `eventType` regex (lowercase `ns.verb`, no colon), so a freshness
|
|
2158
|
-
// key can never look like an operation — but `scheduleSpec.operation` is
|
|
2159
|
-
// `z.string().min(1)`, so a schedule literally named `freshness:orders.placed`
|
|
2160
|
-
// would share this row and nothing would refuse it. #1288 tracks the `kind`
|
|
2161
|
-
// column that would make the distinction real instead of conventional.
|
|
2162
|
-
rt.db.exec(`CREATE TABLE IF NOT EXISTS _substrat_schedule_state (
|
|
2163
|
-
schedule_op TEXT PRIMARY KEY,
|
|
2164
|
-
last_run_at TEXT,
|
|
2165
|
-
last_status TEXT
|
|
2166
|
-
)`);
|
|
2325
|
+
// The gating-state table is KERNEL_DDL's since #1288 — `runtime()` above ran it,
|
|
2326
|
+
// so a freshness-only module on a scope no schedule ever swept finds the table
|
|
2327
|
+
// there rather than creating it here. The rows written below are the evaluator's,
|
|
2328
|
+
// `kind = 'freshness'`, keyed `freshness:<eventType>`: for those `last_run_at` /
|
|
2329
|
+
// `last_status` are the last RECORDED at and verdict — nothing ran.
|
|
2167
2330
|
// Collapse duplicate event types to the tightest window, ACROSS modules.
|
|
2168
2331
|
const windows = new Map();
|
|
2169
2332
|
for (const m of this.modules.values()) {
|
|
@@ -2175,41 +2338,51 @@ export class SqliteScopeHost {
|
|
|
2175
2338
|
}
|
|
2176
2339
|
if (windows.size === 0)
|
|
2177
2340
|
return report;
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
.
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
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
|
+
});
|
|
2213
2386
|
return report;
|
|
2214
2387
|
}
|
|
2215
2388
|
async runDueSchedules(moduleId, tenantId, scopeId) {
|
|
@@ -2228,35 +2401,58 @@ export class SqliteScopeHost {
|
|
|
2228
2401
|
return report;
|
|
2229
2402
|
const rt = this.runtime(tenantId, scopeId);
|
|
2230
2403
|
await this.applyPendingMigrations(rt);
|
|
2231
|
-
// The grant IS the switch (#383)
|
|
2232
|
-
//
|
|
2233
|
-
//
|
|
2234
|
-
//
|
|
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.
|
|
2235
2409
|
const nowIso = this.clock();
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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')
|
|
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;
|
|
2242
2444
|
return report;
|
|
2243
|
-
|
|
2244
|
-
// kernel-owned (`_substrat_*`),
|
|
2245
|
-
//
|
|
2246
|
-
//
|
|
2247
|
-
//
|
|
2248
|
-
//
|
|
2249
|
-
|
|
2250
|
-
schedule_op TEXT PRIMARY KEY,
|
|
2251
|
-
last_run_at TEXT,
|
|
2252
|
-
last_status TEXT
|
|
2253
|
-
)`);
|
|
2445
|
+
}
|
|
2446
|
+
// The spine state table is KERNEL_DDL's (#1288) — kernel-owned (`_substrat_*`),
|
|
2447
|
+
// never a module migration, and created by `runtime()` above rather than lazily
|
|
2448
|
+
// here. This loop's rows are `kind = 'schedule'`, keyed by the operation
|
|
2449
|
+
// (`module/verb`, where `last_run_at`/`last_status` are when it RAN and how it
|
|
2450
|
+
// ended); the freshness evaluator's are `kind = 'freshness'` — see checkFreshness
|
|
2451
|
+
// for what the same two columns mean there.
|
|
2254
2452
|
const now = Date.parse(nowIso);
|
|
2255
2453
|
for (const schedule of mod.schedules) {
|
|
2256
|
-
const
|
|
2257
|
-
|
|
2258
|
-
.get(schedule.operation);
|
|
2259
|
-
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;
|
|
2260
2456
|
const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
|
|
2261
2457
|
if (now < dueAt) {
|
|
2262
2458
|
report.skipped += 1;
|
|
@@ -2279,18 +2475,27 @@ export class SqliteScopeHost {
|
|
|
2279
2475
|
error: err instanceof Error ? err.message : String(err),
|
|
2280
2476
|
});
|
|
2281
2477
|
}
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
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
|
|
2481
|
+
.prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_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));
|
|
2288
2486
|
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
2289
2487
|
}
|
|
2290
2488
|
return report;
|
|
2291
2489
|
}
|
|
2292
|
-
/** The stub body, shared by the principal, connection, system and
|
|
2293
|
-
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,
|
|
2294
2499
|
/**
|
|
2295
2500
|
* K-42: the session this stub acts under. `subject` is already the
|
|
2296
2501
|
* IMPERSONATED principal — that is what makes the permission model answer
|
|
@@ -2372,9 +2577,22 @@ export class SqliteScopeHost {
|
|
|
2372
2577
|
// which is the leak `causedBy` had to be moved off the host to avoid.
|
|
2373
2578
|
rt.invocationId = invokeOptions?.invocationId ?? null;
|
|
2374
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 = [];
|
|
2375
2593
|
// Fresh per operation: the context carries the K-34 authorization accumulator,
|
|
2376
2594
|
// which must not leak across operations (invokes are serialized per scope).
|
|
2377
|
-
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation);
|
|
2595
|
+
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted);
|
|
2378
2596
|
const clonedInput = structuredClone(input);
|
|
2379
2597
|
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
2380
2598
|
// declaration. BEFORE `BEGIN`, so a malformed call never opens a
|
|
@@ -2460,7 +2678,9 @@ export class SqliteScopeHost {
|
|
|
2460
2678
|
// that adds a row.
|
|
2461
2679
|
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
2462
2680
|
const at = this.clock();
|
|
2463
|
-
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);
|
|
2464
2684
|
rt.db.prepare(record.sql).run(...record.params);
|
|
2465
2685
|
const prune = idempotencyPruneStatement(at);
|
|
2466
2686
|
rt.db.prepare(prune.sql).run(...prune.params);
|
|
@@ -2481,7 +2701,8 @@ export class SqliteScopeHost {
|
|
|
2481
2701
|
// K-35: a refused check rolled the operation back. Record it now — a fresh
|
|
2482
2702
|
// statement in autocommit, AFTER the rollback, so the denial survives it.
|
|
2483
2703
|
if (err instanceof PermissionDenied)
|
|
2484
|
-
|
|
2704
|
+
// Inside the actor task that set it, so the field is this call's own (#1237).
|
|
2705
|
+
this.recordDenial(rt, subject, operation, err, rt.invocationId, session);
|
|
2485
2706
|
throw err;
|
|
2486
2707
|
}
|
|
2487
2708
|
// Post-commit, still inside the actor task: drain outbox → consumers,
|
|
@@ -2492,8 +2713,10 @@ export class SqliteScopeHost {
|
|
|
2492
2713
|
// drain — and draining anyway would run consumers as a side effect of a
|
|
2493
2714
|
// session that may not have side effects.
|
|
2494
2715
|
if (session?.mode !== 'read-only') {
|
|
2495
|
-
|
|
2496
|
-
|
|
2716
|
+
// #1525: still inside the actor task that set it, so these deliveries are
|
|
2717
|
+
// this call's own work — the same tail its consumers' emits are stamped in.
|
|
2718
|
+
await this.dispatch(rt, rt.invocationId);
|
|
2719
|
+
await this.dispatchExecutors(rt, rt.invocationId);
|
|
2497
2720
|
}
|
|
2498
2721
|
return structuredClone(result);
|
|
2499
2722
|
}
|
|
@@ -2563,10 +2786,8 @@ export class SqliteScopeHost {
|
|
|
2563
2786
|
}
|
|
2564
2787
|
}
|
|
2565
2788
|
async close() {
|
|
2566
|
-
for (const
|
|
2567
|
-
|
|
2568
|
-
this.scopes.clear();
|
|
2569
|
-
this.scopesById.clear();
|
|
2789
|
+
for (const rt of [...this.scopes.values()])
|
|
2790
|
+
this.closeRuntime(rt);
|
|
2570
2791
|
this.directory.close();
|
|
2571
2792
|
}
|
|
2572
2793
|
// -------------------------------------------------------------------------
|
|
@@ -2594,7 +2815,15 @@ export class SqliteScopeHost {
|
|
|
2594
2815
|
* At-least-once still requires idempotent handlers. Retry is the backstop, not a
|
|
2595
2816
|
* substitute.
|
|
2596
2817
|
*/
|
|
2597
|
-
async dispatchExecutors(rt
|
|
2818
|
+
async dispatchExecutors(rt,
|
|
2819
|
+
/**
|
|
2820
|
+
* #1525: the call this drain pass is running in, or null — PASSED, not read off
|
|
2821
|
+
* `rt.invocationId`, for the reason `recordDenial` states. The Cloudflare twin
|
|
2822
|
+
* has no ambient field to read here at all: its executors run on the coordinator
|
|
2823
|
+
* and journal over RPC, so the id has to travel as an argument on that side, and
|
|
2824
|
+
* a recorded fact whose correctness argument differs per adapter drifts.
|
|
2825
|
+
*/
|
|
2826
|
+
invocationId) {
|
|
2598
2827
|
const report = {
|
|
2599
2828
|
attempted: 0,
|
|
2600
2829
|
delivered: 0,
|
|
@@ -2616,8 +2845,22 @@ export class SqliteScopeHost {
|
|
|
2616
2845
|
ORDER BY o.id`)
|
|
2617
2846
|
.all(deliveryId, executor.eventType, now);
|
|
2618
2847
|
for (const row of rows) {
|
|
2619
|
-
const event = this.parseOutboxRow(row);
|
|
2620
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
|
+
}
|
|
2621
2864
|
this.causedBy = event.id;
|
|
2622
2865
|
try {
|
|
2623
2866
|
if (executor.kind === 'connector') {
|
|
@@ -2628,11 +2871,11 @@ export class SqliteScopeHost {
|
|
|
2628
2871
|
else {
|
|
2629
2872
|
await executor.handler(this.admin, event);
|
|
2630
2873
|
}
|
|
2631
|
-
this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry);
|
|
2874
|
+
this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry, invocationId);
|
|
2632
2875
|
report.delivered += 1;
|
|
2633
2876
|
}
|
|
2634
2877
|
catch (err) {
|
|
2635
|
-
const dead = this.recordExecutorDelivery(rt, row.id, deliveryId, err instanceof Error ? (err.stack ?? err.message) : String(err), executor.retry);
|
|
2878
|
+
const dead = this.recordExecutorDelivery(rt, row.id, deliveryId, err instanceof Error ? (err.stack ?? err.message) : String(err), executor.retry, invocationId);
|
|
2636
2879
|
if (dead)
|
|
2637
2880
|
report.deadLettered += 1;
|
|
2638
2881
|
else
|
|
@@ -2653,7 +2896,9 @@ export class SqliteScopeHost {
|
|
|
2653
2896
|
* silently marking success. Claiming first would make delivery at-most-once and
|
|
2654
2897
|
* lose an effect on any crash in between.
|
|
2655
2898
|
*/
|
|
2656
|
-
recordExecutorDelivery(rt, eventId, deliveryId, error, retry
|
|
2899
|
+
recordExecutorDelivery(rt, eventId, deliveryId, error, retry,
|
|
2900
|
+
/** #1525: the call THIS attempt ran in, or null. Last, mirroring the DO's RPC. */
|
|
2901
|
+
invocationId) {
|
|
2657
2902
|
const prior = rt.db
|
|
2658
2903
|
.prepare('SELECT attempts FROM _substrat_deliveries WHERE event_id = ? AND consumer_module = ?')
|
|
2659
2904
|
.get(eventId, deliveryId)?.attempts ?? 0;
|
|
@@ -2663,20 +2908,160 @@ export class SqliteScopeHost {
|
|
|
2663
2908
|
const nextAttemptAt = error === null || exhausted ? null : backoffAt(attempts, retry, new Date());
|
|
2664
2909
|
rt.db
|
|
2665
2910
|
.prepare(`INSERT INTO _substrat_deliveries
|
|
2666
|
-
(event_id, consumer_module, delivered_at, error, attempts, next_attempt_at
|
|
2667
|
-
|
|
2911
|
+
(event_id, consumer_module, delivered_at, error, attempts, next_attempt_at,
|
|
2912
|
+
invocation_id)
|
|
2913
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2668
2914
|
ON CONFLICT (event_id, consumer_module) DO UPDATE SET
|
|
2669
2915
|
delivered_at = excluded.delivered_at,
|
|
2670
2916
|
error = excluded.error,
|
|
2671
2917
|
attempts = excluded.attempts,
|
|
2672
|
-
next_attempt_at = excluded.next_attempt_at
|
|
2673
|
-
|
|
2918
|
+
next_attempt_at = excluded.next_attempt_at,
|
|
2919
|
+
-- #1525: overwritten, like the four above. The row describes the LATEST
|
|
2920
|
+
-- attempt, so a retry drained by a different call (or by none) must not
|
|
2921
|
+
-- keep claiming the call that made the first one.
|
|
2922
|
+
invocation_id = excluded.invocation_id`)
|
|
2923
|
+
.run(eventId, deliveryId, new Date().toISOString(), error, attempts, nextAttemptAt,
|
|
2924
|
+
// #1525: the call this attempt ran in, as the caller named it.
|
|
2925
|
+
invocationId);
|
|
2674
2926
|
return error !== null && exhausted;
|
|
2675
2927
|
}
|
|
2676
2928
|
async drainDue(tenantId, scopeId) {
|
|
2677
2929
|
const rt = this.runtime(tenantId, scopeId);
|
|
2678
2930
|
await this.applyPendingMigrations(rt);
|
|
2679
|
-
|
|
2931
|
+
// #1525: null, and honestly so — a sweep is not a call. An attempt this pass makes
|
|
2932
|
+
// records no invocation, which is what distinguishes it from the first attempt the
|
|
2933
|
+
// emitting operation's own tail made.
|
|
2934
|
+
return rt.actor.enqueue(() => this.dispatchExecutors(rt, null));
|
|
2935
|
+
}
|
|
2936
|
+
registerJob(moduleId, name, handler, retry) {
|
|
2937
|
+
const key = `${moduleId}/${name}`;
|
|
2938
|
+
if (this.jobs.has(key))
|
|
2939
|
+
throw new Error(`job '${key}' is already registered`);
|
|
2940
|
+
this.jobs.set(key, { handler, retry });
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* D-14's in-process driver: the run store is direct SQL on the scope db.
|
|
2944
|
+
*
|
|
2945
|
+
* **The PASS stays outside the actor; every STORE OPERATION takes a turn on it.**
|
|
2946
|
+
* That split is the whole of the concurrency design here, and the first cut got
|
|
2947
|
+
* the second half wrong — it ran these statements on `rt.db` with no turn at all,
|
|
2948
|
+
* under a comment claiming they "interleave exactly as any other write does".
|
|
2949
|
+
* They do not. `invoke` opens a raw `BEGIN IMMEDIATE` on this same connection and
|
|
2950
|
+
* HOLDS IT ACROSS AWAITS (its guards, its handler), so a job-store statement
|
|
2951
|
+
* issued while some unrelated request was mid-operation landed INSIDE that
|
|
2952
|
+
* request's transaction — and rolled back with it. A step whose effect had
|
|
2953
|
+
* already happened would lose its ledger row and run a second time, for no reason
|
|
2954
|
+
* connected to the job at all.
|
|
2955
|
+
*
|
|
2956
|
+
* A turn each is the fix, and it keeps the property the original comment wanted:
|
|
2957
|
+
* a pass may take an hour, and holding the scope's lock for it would stop every
|
|
2958
|
+
* request on that scope, so the pass itself is never enqueued. Only the short
|
|
2959
|
+
* writes are. Nothing nests — a store operation never invokes, and the `invoke`s
|
|
2960
|
+
* a pass makes through `pass.scope()` take their own separate turns — so there is
|
|
2961
|
+
* no path by which this can deadlock against itself.
|
|
2962
|
+
*/
|
|
2963
|
+
jobStore(rt) {
|
|
2964
|
+
const db = rt.db;
|
|
2965
|
+
const row = (v) => v ?? null;
|
|
2966
|
+
const findLive = db.prepare(`SELECT * FROM _substrat_job_runs
|
|
2967
|
+
WHERE module_id = ? AND job = ? AND instance = ? AND status = 'running'
|
|
2968
|
+
ORDER BY id DESC LIMIT 1`);
|
|
2969
|
+
const insertRun = db.prepare(`INSERT INTO _substrat_job_runs
|
|
2970
|
+
(id, module_id, job, instance, payload, status, cursor, counters, attempts,
|
|
2971
|
+
last_error, started_at, updated_at, next_attempt_at, ended_at)
|
|
2972
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
2973
|
+
const patchRun = db.prepare(`UPDATE _substrat_job_runs
|
|
2974
|
+
SET status = ?, cursor = ?, counters = ?, attempts = ?, last_error = ?,
|
|
2975
|
+
updated_at = ?, next_attempt_at = ?, ended_at = ?
|
|
2976
|
+
WHERE id = ?`);
|
|
2977
|
+
const patchArgs = (id, p) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id];
|
|
2978
|
+
// `db.transaction` on better-sqlite3 runs its body SYNCHRONOUSLY inside a real
|
|
2979
|
+
// SQLite transaction, so nothing — not another turn of the event loop, not
|
|
2980
|
+
// another caller of this host — can interleave between the read and the write.
|
|
2981
|
+
// That is what makes the two operations below atomic rather than merely ordered.
|
|
2982
|
+
const startOrJoinTx = db.transaction((key, r) => {
|
|
2983
|
+
const live = row(findLive.get(key.moduleId, key.job, key.instance));
|
|
2984
|
+
if (live)
|
|
2985
|
+
return live;
|
|
2986
|
+
insertRun.run(r.id, r.module_id, r.job, r.instance, r.payload, r.status, r.cursor, r.counters, r.attempts, r.last_error, r.started_at, r.updated_at, r.next_attempt_at, r.ended_at);
|
|
2987
|
+
return r;
|
|
2988
|
+
});
|
|
2989
|
+
const commitPassTx = db.transaction((id, p) => {
|
|
2990
|
+
patchRun.run(...patchArgs(id, p));
|
|
2991
|
+
db.prepare('DELETE FROM _substrat_job_steps WHERE run_id = ?').run(id);
|
|
2992
|
+
});
|
|
2993
|
+
// Every store operation takes its own short turn on the scope actor, so no
|
|
2994
|
+
// statement here can land inside an `invoke`'s open transaction. `turn` is the
|
|
2995
|
+
// one place that happens; adding a method without it reintroduces the bug.
|
|
2996
|
+
const turn = (fn) => rt.actor.enqueue(fn);
|
|
2997
|
+
return {
|
|
2998
|
+
startOrJoin: (key, r) => turn(() => startOrJoinTx(key, r)),
|
|
2999
|
+
get: (id) => turn(() => row(db.prepare('SELECT * FROM _substrat_job_runs WHERE id = ?').get(id))),
|
|
3000
|
+
due: (now, limit, afterId) => turn(() => db
|
|
3001
|
+
.prepare(`SELECT * FROM _substrat_job_runs
|
|
3002
|
+
WHERE status = 'running' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
3003
|
+
AND (? IS NULL OR id > ?)
|
|
3004
|
+
ORDER BY id LIMIT ?`)
|
|
3005
|
+
.all(now, afterId ?? null, afterId ?? null, limit)),
|
|
3006
|
+
list: (filter) => turn(() => {
|
|
3007
|
+
const where = [];
|
|
3008
|
+
const params = [];
|
|
3009
|
+
for (const [column, value] of [
|
|
3010
|
+
['module_id', filter.moduleId],
|
|
3011
|
+
['job', filter.job],
|
|
3012
|
+
['instance', filter.instance],
|
|
3013
|
+
['status', filter.status],
|
|
3014
|
+
]) {
|
|
3015
|
+
if (value !== undefined) {
|
|
3016
|
+
where.push(`${column} = ?`);
|
|
3017
|
+
params.push(value);
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
params.push(jobRunListLimit(filter.limit));
|
|
3021
|
+
return db
|
|
3022
|
+
.prepare(`SELECT * FROM _substrat_job_runs
|
|
3023
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
3024
|
+
ORDER BY id DESC LIMIT ?`)
|
|
3025
|
+
.all(...params);
|
|
3026
|
+
}),
|
|
3027
|
+
patch: (id, p) => turn(() => {
|
|
3028
|
+
patchRun.run(...patchArgs(id, p));
|
|
3029
|
+
}),
|
|
3030
|
+
commitPass: (id, p) => turn(() => commitPassTx(id, p)),
|
|
3031
|
+
step: (runId, name) => turn(() => db
|
|
3032
|
+
.prepare('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?')
|
|
3033
|
+
.get(runId, name) ?? null),
|
|
3034
|
+
recordStep: (runId, name, result, attempts, lastError, at) => turn(() => {
|
|
3035
|
+
db.prepare(`INSERT INTO _substrat_job_steps (run_id, step, result, attempts, last_error, recorded_at)
|
|
3036
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
3037
|
+
ON CONFLICT (run_id, step) DO UPDATE SET result = excluded.result,
|
|
3038
|
+
attempts = excluded.attempts,
|
|
3039
|
+
last_error = excluded.last_error,
|
|
3040
|
+
recorded_at = excluded.recorded_at`).run(runId, name, result, attempts, lastError, at);
|
|
3041
|
+
}),
|
|
3042
|
+
};
|
|
3043
|
+
}
|
|
3044
|
+
async startJobRun(tenantId, scopeId, input) {
|
|
3045
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
3046
|
+
await this.applyPendingMigrations(rt);
|
|
3047
|
+
return jobRunOf(await startJobRun(this.jobStore(rt), input, ulid, this.clock));
|
|
3048
|
+
}
|
|
3049
|
+
async runDueJobs(tenantId, scopeId, options) {
|
|
3050
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
3051
|
+
await this.applyPendingMigrations(rt);
|
|
3052
|
+
return runDueJobRuns({
|
|
3053
|
+
store: this.jobStore(rt),
|
|
3054
|
+
handlerFor: (run) => this.jobs.get(`${run.module_id}/${run.job}`),
|
|
3055
|
+
now: this.clock,
|
|
3056
|
+
openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
|
|
3057
|
+
maxPasses: options?.maxPasses,
|
|
3058
|
+
limit: options?.limit,
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
3061
|
+
async jobRuns(tenantId, scopeId, filter) {
|
|
3062
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
3063
|
+
await this.applyPendingMigrations(rt);
|
|
3064
|
+
return (await this.jobStore(rt).list(filter ?? {})).map(jobRunOf);
|
|
2680
3065
|
}
|
|
2681
3066
|
async dispatchConnector(tenantId, scopeId, handler, event, options) {
|
|
2682
3067
|
// The platform half of a routed `connector:<provider>` intent (#574 phase 3): run
|
|
@@ -2714,7 +3099,7 @@ export class SqliteScopeHost {
|
|
|
2714
3099
|
// "not granted" where the checker answers allow — a read-back that disagrees with
|
|
2715
3100
|
// enforcement is worse than none, since it is the read an operator would trust.
|
|
2716
3101
|
const rows = [
|
|
2717
|
-
...rt
|
|
3102
|
+
...this.readDb(rt)
|
|
2718
3103
|
.prepare(`SELECT subject, relation, expires_at FROM _substrat_tuples
|
|
2719
3104
|
WHERE subject LIKE 'connection:%' AND relation LIKE 'granted:%'
|
|
2720
3105
|
AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
|
|
@@ -2794,7 +3179,7 @@ export class SqliteScopeHost {
|
|
|
2794
3179
|
async executorDeadLetters(tenantId, scopeId) {
|
|
2795
3180
|
const rt = this.runtime(tenantId, scopeId);
|
|
2796
3181
|
await this.applyPendingMigrations(rt);
|
|
2797
|
-
const rows = rt
|
|
3182
|
+
const rows = this.readDb(rt)
|
|
2798
3183
|
.prepare(`SELECT d.event_id, d.consumer_module, d.attempts, d.error, d.delivered_at, o.type
|
|
2799
3184
|
FROM _substrat_deliveries d
|
|
2800
3185
|
JOIN _substrat_outbox o ON o.id = d.event_id
|
|
@@ -2815,31 +3200,46 @@ export class SqliteScopeHost {
|
|
|
2815
3200
|
async listPlatformRequests(tenantId, scopeId) {
|
|
2816
3201
|
const rt = this.runtime(tenantId, scopeId);
|
|
2817
3202
|
await this.applyPendingMigrations(rt);
|
|
2818
|
-
const rows = rt
|
|
3203
|
+
const rows = this.readDb(rt)
|
|
2819
3204
|
.prepare(`SELECT ${PLATFORM_REQUEST_COLUMNS}
|
|
2820
3205
|
FROM _substrat_platform_requests WHERE status = 'pending' ORDER BY id`)
|
|
2821
3206
|
.all();
|
|
2822
|
-
|
|
3207
|
+
// Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
|
|
3208
|
+
return rows.map(platformRequestOf);
|
|
2823
3209
|
}
|
|
2824
3210
|
async listPlatformRequestHistory(tenantId, scopeId, filter) {
|
|
2825
3211
|
const rt = this.runtime(tenantId, scopeId);
|
|
2826
3212
|
await this.applyPendingMigrations(rt);
|
|
2827
3213
|
const q = platformRequestHistoryQuery(filter);
|
|
2828
|
-
return rt.
|
|
3214
|
+
return this.readDb(rt).prepare(q.sql).all(...q.params).map(platformRequestOf);
|
|
2829
3215
|
}
|
|
2830
3216
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
2831
3217
|
const rt = this.runtime(tenantId, scopeId);
|
|
2832
3218
|
await this.applyPendingMigrations(rt);
|
|
2833
3219
|
await rt.actor.enqueue(() => {
|
|
3220
|
+
// Compare-and-set on `pending` (#1600 review). The drain reads pending rows, runs a
|
|
3221
|
+
// handler, then settles — and between the read and the settle a subject erasure can
|
|
3222
|
+
// redact the row. Settling by `id` alone let that stale pass overwrite the redaction
|
|
3223
|
+
// and write a provider's reply, which can quote the person, back into `last_error`.
|
|
3224
|
+
// Nothing legitimate is refused by this: `listPlatformRequests` returns only pending
|
|
3225
|
+
// rows, so every settle in the tree targets one that was pending when it was read.
|
|
3226
|
+
// A settle that finds the row already terminal does nothing, deliberately silently —
|
|
3227
|
+
// throwing would make the drain's blanket catch retry a row that is correctly over.
|
|
2834
3228
|
rt.db
|
|
2835
3229
|
.prepare(`UPDATE _substrat_platform_requests
|
|
2836
3230
|
SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
|
|
2837
3231
|
attempts = attempts + 1, settled_at = ?
|
|
2838
|
-
WHERE id =
|
|
3232
|
+
WHERE id = ? AND status = 'pending'`)
|
|
2839
3233
|
.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);
|
|
2840
3234
|
});
|
|
2841
3235
|
}
|
|
2842
|
-
async dispatch(rt
|
|
3236
|
+
async dispatch(rt,
|
|
3237
|
+
/**
|
|
3238
|
+
* #1525: the call this drain is running in, or null — PASSED, for the reason
|
|
3239
|
+
* `dispatchExecutors` states. Every round of the loop belongs to it: a consumer's
|
|
3240
|
+
* own emit is delivered in the same tail, so the whole cascade is one call's work.
|
|
3241
|
+
*/
|
|
3242
|
+
invocationId) {
|
|
2843
3243
|
for (let round = 0; round < 50; round++) {
|
|
2844
3244
|
let deliveredAny = false;
|
|
2845
3245
|
for (const mod of this.modules.values()) {
|
|
@@ -2854,7 +3254,24 @@ export class SqliteScopeHost {
|
|
|
2854
3254
|
ORDER BY o.id`)
|
|
2855
3255
|
.all(consumer.eventType, mod.id);
|
|
2856
3256
|
for (const row of rows) {
|
|
2857
|
-
|
|
3257
|
+
let event;
|
|
3258
|
+
try {
|
|
3259
|
+
event = domainEventOf(row);
|
|
3260
|
+
}
|
|
3261
|
+
catch (err) {
|
|
3262
|
+
// #1636: dead-letter an event that does not decode, exactly as a failed
|
|
3263
|
+
// handler is — the decode sat ABOVE the `try` below, so one bad row halted
|
|
3264
|
+
// every event of this type behind it, on every pass. The consumer is never
|
|
3265
|
+
// handed it: an event built from stand-ins is not one it may act on. Only
|
|
3266
|
+
// the decode is caught; the journal write is not, so a failure to record
|
|
3267
|
+
// this still surfaces as it would for a handler's dead letter.
|
|
3268
|
+
rt.db
|
|
3269
|
+
.prepare(`INSERT INTO _substrat_deliveries
|
|
3270
|
+
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3271
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
3272
|
+
.run(row.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
3273
|
+
continue;
|
|
3274
|
+
}
|
|
2858
3275
|
const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
|
|
2859
3276
|
system: mod.id,
|
|
2860
3277
|
});
|
|
@@ -2867,9 +3284,10 @@ export class SqliteScopeHost {
|
|
|
2867
3284
|
try {
|
|
2868
3285
|
await consumer.handler(ctx, event);
|
|
2869
3286
|
rt.db
|
|
2870
|
-
.prepare(`INSERT INTO _substrat_deliveries
|
|
2871
|
-
|
|
2872
|
-
|
|
3287
|
+
.prepare(`INSERT INTO _substrat_deliveries
|
|
3288
|
+
(event_id, consumer_module, delivered_at, invocation_id)
|
|
3289
|
+
VALUES (?, ?, ?, ?)`)
|
|
3290
|
+
.run(event.id, mod.id, new Date().toISOString(), invocationId);
|
|
2873
3291
|
rt.db.exec('COMMIT');
|
|
2874
3292
|
deliveredAny = true;
|
|
2875
3293
|
}
|
|
@@ -2878,9 +3296,10 @@ export class SqliteScopeHost {
|
|
|
2878
3296
|
// Dead-letter (v0): journal the failure so one poison event
|
|
2879
3297
|
// can't wedge the loop. Real redelivery/backoff is a later cut.
|
|
2880
3298
|
rt.db
|
|
2881
|
-
.prepare(`INSERT INTO _substrat_deliveries
|
|
2882
|
-
|
|
2883
|
-
|
|
3299
|
+
.prepare(`INSERT INTO _substrat_deliveries
|
|
3300
|
+
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3301
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
3302
|
+
.run(event.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
2884
3303
|
}
|
|
2885
3304
|
finally {
|
|
2886
3305
|
// Cleared on BOTH paths. Left set, the id would leak onto every later
|
|
@@ -2902,6 +3321,19 @@ export class SqliteScopeHost {
|
|
|
2902
3321
|
* point, since the denial is exactly the write the rolled-back operation could not make.
|
|
2903
3322
|
*/
|
|
2904
3323
|
recordDenial(rt, subject, operation, err,
|
|
3324
|
+
/**
|
|
3325
|
+
* #1525: the invocation this refusal belongs to, or null — PASSED, not read off
|
|
3326
|
+
* `rt.invocationId` here.
|
|
3327
|
+
*
|
|
3328
|
+
* Every denial path on THIS adapter runs inside the scope's actor task, so reading
|
|
3329
|
+
* `rt.invocationId` would in fact be right at each one. It is passed anyway, to keep
|
|
3330
|
+
* one discipline across both adapters: the Cloudflare side has two denial paths that
|
|
3331
|
+
* run outside its queue, where the ambient read rests on an argument about workerd's
|
|
3332
|
+
* input gate rather than on anything local. A recorded fact whose correctness
|
|
3333
|
+
* argument differs per adapter is the kind that drifts, so on both sides the caller
|
|
3334
|
+
* says what it knows.
|
|
3335
|
+
*/
|
|
3336
|
+
invocationId,
|
|
2905
3337
|
/** K-42: the session the refused call ran under, when it ran under one. */
|
|
2906
3338
|
impersonation) {
|
|
2907
3339
|
// Only an ENFORCED denial (assertAllowed, which attaches the checked permission +
|
|
@@ -2909,39 +3341,15 @@ export class SqliteScopeHost {
|
|
|
2909
3341
|
// policy, carries no permission key, and is left to the module.
|
|
2910
3342
|
if (!err.permission || !err.node)
|
|
2911
3343
|
return;
|
|
2912
|
-
const actor = subject
|
|
2913
|
-
? { system: subject.id }
|
|
2914
|
-
: subject.kind === 'connection'
|
|
2915
|
-
? { connection: subject.id }
|
|
2916
|
-
: subject.id;
|
|
3344
|
+
const actor = actorOf(subject);
|
|
2917
3345
|
rt.db
|
|
2918
3346
|
.prepare(`INSERT INTO _substrat_denials
|
|
2919
|
-
(id, actor, permission, tenant_id, scope_id, operation, impersonation,
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
id: row.id,
|
|
2926
|
-
type: row.type,
|
|
2927
|
-
schemaVersion: row.schema_version,
|
|
2928
|
-
occurredAt: row.occurred_at,
|
|
2929
|
-
tenantId: row.tenant_id,
|
|
2930
|
-
scopeId: row.scope_id,
|
|
2931
|
-
actor: JSON.parse(row.actor),
|
|
2932
|
-
entity: { entityType: row.entity_type, entityId: row.entity_id },
|
|
2933
|
-
piiClass: row.pii_class,
|
|
2934
|
-
...(row.subject_id ? { subjectId: row.subject_id } : {}),
|
|
2935
|
-
...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
|
|
2936
|
-
// K-42: the stamp survives the read, so a consumer's event and an executor's
|
|
2937
|
-
// are the same fact the stored row is. Absent rather than null when nobody
|
|
2938
|
-
// was impersonating, because `DomainEvent.impersonation` is optional — the
|
|
2939
|
-
// shape module code never sees is also the shape it cannot branch on.
|
|
2940
|
-
...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
|
|
2941
|
-
// #1231: absent rather than null, the same shape rule as the stamp above.
|
|
2942
|
-
...(row.operation ? { operation: row.operation } : {}),
|
|
2943
|
-
payload: row.payload === null ? undefined : JSON.parse(row.payload),
|
|
2944
|
-
});
|
|
3347
|
+
(id, actor, permission, tenant_id, scope_id, operation, impersonation,
|
|
3348
|
+
invocation_id, at)
|
|
3349
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
3350
|
+
.run(ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, impersonation ? JSON.stringify(impersonationStampOf(impersonation)) : null,
|
|
3351
|
+
// #1525: the call this refusal belongs to, as the caller named it.
|
|
3352
|
+
invocationId, new Date().toISOString());
|
|
2945
3353
|
}
|
|
2946
3354
|
// -------------------------------------------------------------------------
|
|
2947
3355
|
// Admin surface (enforcement input, §9.2.5)
|
|
@@ -3369,6 +3777,9 @@ export class SqliteScopeHost {
|
|
|
3369
3777
|
(tenant_id, subject, relation, object, expires_at)
|
|
3370
3778
|
VALUES (?, ?, ?, ?, ?)`)
|
|
3371
3779
|
.run(tenantId, subject, relation, object, expiresAt ?? null);
|
|
3780
|
+
// The EXPLICIT grant: `INSERT OR REPLACE` clears a tombstone, because a re-grant must
|
|
3781
|
+
// grant. Provisioning seats with `seatScopeTuple` instead, which never un-revokes
|
|
3782
|
+
// (#1659) — keep the two apart.
|
|
3372
3783
|
const writeScopeTuple = (node, subject, relation, object, expiresAt) => {
|
|
3373
3784
|
if (!node.scopeId)
|
|
3374
3785
|
throw new Error('scope tuple requires node.scopeId');
|
|
@@ -3389,6 +3800,82 @@ export class SqliteScopeHost {
|
|
|
3389
3800
|
writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt);
|
|
3390
3801
|
}
|
|
3391
3802
|
};
|
|
3803
|
+
/**
|
|
3804
|
+
* A node's write, on the scope actor when the node is a scope (#1678). `invoke` holds a
|
|
3805
|
+
* raw `BEGIN IMMEDIATE` on the scope's connection across awaits, so a bare statement
|
|
3806
|
+
* issued mid-invoke joined that transaction and rolled back with it — after the verb
|
|
3807
|
+
* had answered success and audited. `body` holds the check AND the write, synchronously,
|
|
3808
|
+
* so nothing moves between them. Tenant-level: a directory write, which no scope's
|
|
3809
|
+
* transaction can hold, so it runs as it always did.
|
|
3810
|
+
*/
|
|
3811
|
+
const onNode = (node, body) => node.scopeId
|
|
3812
|
+
? this.runtime(node.tenantId, node.scopeId).actor.turn(body)
|
|
3813
|
+
: Promise.resolve().then(body);
|
|
3814
|
+
/** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
|
|
3815
|
+
const switchSystem = async (actor, raw, to) => {
|
|
3816
|
+
const input = systemSwitch.parse(raw);
|
|
3817
|
+
const { tenantId, scopeId } = input.node;
|
|
3818
|
+
const scope = this.directory
|
|
3819
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
3820
|
+
.get(scopeId);
|
|
3821
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
3822
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3823
|
+
}
|
|
3824
|
+
// AUDIT FIRST (#1666 review), exactly as the Cloudflare adapter: the intent row before
|
|
3825
|
+
// anything moves, the outcome row after, on every attempt. The directory and the
|
|
3826
|
+
// scope's file are separate databases, so a crash between them leaves an intent with
|
|
3827
|
+
// no recorded outcome — never a moved switch with no audit row.
|
|
3828
|
+
const operationId = ulid();
|
|
3829
|
+
const action = to === 'off' ? 'revokeFromSystem' : 'restoreToSystem';
|
|
3830
|
+
const target = { tenantId, scopeId };
|
|
3831
|
+
const base = { operationId, moduleId: input.moduleId, schedules: to };
|
|
3832
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
3833
|
+
let outcome;
|
|
3834
|
+
try {
|
|
3835
|
+
// A turn on the scope actor (#1666 review), exactly as the job store (#1577): `invoke`
|
|
3836
|
+
// holds a raw `BEGIN IMMEDIATE` on this same connection across awaits, so a bare
|
|
3837
|
+
// `db.transaction` issued mid-invoke became a SAVEPOINT inside it and rolled back
|
|
3838
|
+
// with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
|
|
3839
|
+
// caller already inside one of this scope's tasks joins it rather than deadlocking.
|
|
3840
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
3841
|
+
outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), {
|
|
3842
|
+
moduleId: input.moduleId,
|
|
3843
|
+
scopeId,
|
|
3844
|
+
to,
|
|
3845
|
+
at: new Date().toISOString(),
|
|
3846
|
+
}))());
|
|
3847
|
+
}
|
|
3848
|
+
catch (err) {
|
|
3849
|
+
try {
|
|
3850
|
+
this.recordAdmin(actor, action, target, null, {
|
|
3851
|
+
...base,
|
|
3852
|
+
phase: 'failed',
|
|
3853
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3856
|
+
catch {
|
|
3857
|
+
// Best effort: the original error is what the caller must see.
|
|
3858
|
+
}
|
|
3859
|
+
throw err;
|
|
3860
|
+
}
|
|
3861
|
+
this.recordAdmin(actor, action, target, null, {
|
|
3862
|
+
...base,
|
|
3863
|
+
phase: outcome.held ? 'applied' : 'refused',
|
|
3864
|
+
changed: outcome.changed,
|
|
3865
|
+
permissions: outcome.permissions,
|
|
3866
|
+
});
|
|
3867
|
+
if (!outcome.held) {
|
|
3868
|
+
throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
|
|
3869
|
+
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`);
|
|
3870
|
+
}
|
|
3871
|
+
return {
|
|
3872
|
+
operationId,
|
|
3873
|
+
moduleId: input.moduleId,
|
|
3874
|
+
schedules: to,
|
|
3875
|
+
changed: outcome.changed,
|
|
3876
|
+
permissions: outcome.permissions,
|
|
3877
|
+
};
|
|
3878
|
+
};
|
|
3392
3879
|
return {
|
|
3393
3880
|
// #603: fixed at construction — a host built without a box can never store a
|
|
3394
3881
|
// credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
@@ -3447,7 +3934,8 @@ export class SqliteScopeHost {
|
|
|
3447
3934
|
assignRole: async (actor, assignment) => {
|
|
3448
3935
|
const subject = `principal:${assignment.principalId}`;
|
|
3449
3936
|
if (assignment.node.scopeId) {
|
|
3450
|
-
|
|
3937
|
+
const scopeNode = assignment.node.scopeId;
|
|
3938
|
+
await onNode(assignment.node, () => writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${scopeNode}`));
|
|
3451
3939
|
}
|
|
3452
3940
|
else {
|
|
3453
3941
|
writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`);
|
|
@@ -3463,11 +3951,14 @@ export class SqliteScopeHost {
|
|
|
3463
3951
|
const now = new Date().toISOString();
|
|
3464
3952
|
let changes;
|
|
3465
3953
|
if (assignment.node.scopeId) {
|
|
3466
|
-
|
|
3467
|
-
|
|
3954
|
+
// On the scope actor (#1678): a revoke that joined a stranger's transaction and
|
|
3955
|
+
// rolled back with it left the role LIVE after the caller was told it was gone.
|
|
3956
|
+
const scopeNode = assignment.node.scopeId;
|
|
3957
|
+
const rt = this.runtime(assignment.node.tenantId, scopeNode);
|
|
3958
|
+
changes = await rt.actor.turn(() => rt.db
|
|
3468
3959
|
.prepare(`UPDATE _substrat_tuples SET revoked_at = ?
|
|
3469
|
-
|
|
3470
|
-
.run(now, subject, relation, `scope:${
|
|
3960
|
+
WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
|
|
3961
|
+
.run(now, subject, relation, `scope:${scopeNode}`).changes);
|
|
3471
3962
|
}
|
|
3472
3963
|
else {
|
|
3473
3964
|
changes = this.directory
|
|
@@ -3484,44 +3975,56 @@ export class SqliteScopeHost {
|
|
|
3484
3975
|
// trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
|
|
3485
3976
|
// to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
|
|
3486
3977
|
const grant = capabilityGrant.parse(raw);
|
|
3487
|
-
writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
|
|
3978
|
+
await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
|
|
3488
3979
|
this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
3489
3980
|
},
|
|
3490
3981
|
grantToConnection: async (actor, raw) => {
|
|
3491
3982
|
const grant = connectionGrant.parse(raw);
|
|
3492
|
-
const
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
// The grant may not reach outside what the connection already is. A
|
|
3497
|
-
// connection is keyed (tenant, vertical, provider); letting it hold a
|
|
3498
|
-
// permission in another tenant would make the key decorative.
|
|
3499
|
-
if (conn.tenant_id !== grant.node.tenantId) {
|
|
3500
|
-
throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
|
|
3501
|
-
`be granted anything in ${grant.node.tenantId}`);
|
|
3502
|
-
}
|
|
3503
|
-
if (grant.node.scopeId) {
|
|
3504
|
-
const scope = this.directory
|
|
3505
|
-
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
3506
|
-
.get(grant.node.scopeId);
|
|
3507
|
-
if (!scope || scope.tenant_id !== grant.node.tenantId) {
|
|
3508
|
-
throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
|
|
3983
|
+
const check = () => {
|
|
3984
|
+
const conn = this.connectionRow(grant.connectionId);
|
|
3985
|
+
if (conn.revoked_at) {
|
|
3986
|
+
throw new Error(`connection ${grant.connectionId} is revoked — grant nothing to it`);
|
|
3509
3987
|
}
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3988
|
+
// The grant may not reach outside what the connection already is. A
|
|
3989
|
+
// connection is keyed (tenant, vertical, provider); letting it hold a
|
|
3990
|
+
// permission in another tenant would make the key decorative.
|
|
3991
|
+
if (conn.tenant_id !== grant.node.tenantId) {
|
|
3992
|
+
throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
|
|
3993
|
+
`be granted anything in ${grant.node.tenantId}`);
|
|
3513
3994
|
}
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3995
|
+
if (grant.node.scopeId) {
|
|
3996
|
+
const scope = this.directory
|
|
3997
|
+
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
3998
|
+
.get(grant.node.scopeId);
|
|
3999
|
+
if (!scope || scope.tenant_id !== grant.node.tenantId) {
|
|
4000
|
+
throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
|
|
4001
|
+
}
|
|
4002
|
+
if (scope.vertical !== conn.vertical) {
|
|
4003
|
+
throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
|
|
4004
|
+
`${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
return conn;
|
|
4008
|
+
};
|
|
4009
|
+
// Once BEFORE the turn, so a refused grant never opens (and so creates) the scope's
|
|
4010
|
+
// file; then again inside it, with the tuple and the directory row, in ONE body on
|
|
4011
|
+
// the scope actor (#1678) — so the tuple cannot roll back with a stranger's
|
|
4012
|
+
// transaction and leave the directory row (#592) claiming a grant the scope lacks.
|
|
4013
|
+
check();
|
|
4014
|
+
const conn = await onNode(grant.node, () => {
|
|
4015
|
+
const row = check();
|
|
4016
|
+
writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4017
|
+
// #592: the directory-side record, alongside the tuple. The tuple is checked
|
|
4018
|
+
// where it lives; this row is what provision/reconcile gather FROM, so the
|
|
4019
|
+
// grant reaches scopes provisioned after it without a human replaying it.
|
|
4020
|
+
this.directory
|
|
4021
|
+
.prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
|
|
4022
|
+
(connection_id, tenant_id, vertical, permission, scope_id, expires_at,
|
|
4023
|
+
granted_by, granted_at, revoked_at)
|
|
4024
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
|
|
4025
|
+
.run(grant.connectionId, grant.node.tenantId, row.vertical, grant.permission, grant.node.scopeId ?? null, grant.expiresAt ?? null, grant.grantedBy, new Date().toISOString());
|
|
4026
|
+
return row;
|
|
4027
|
+
});
|
|
3525
4028
|
this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
|
|
3526
4029
|
connectionId: grant.connectionId,
|
|
3527
4030
|
provider: conn.provider,
|
|
@@ -3537,15 +4040,79 @@ export class SqliteScopeHost {
|
|
|
3537
4040
|
// principal is bound to the module, and a scope only ever runs the modules
|
|
3538
4041
|
// its own host registered.
|
|
3539
4042
|
const grant = systemGrant.parse(raw);
|
|
3540
|
-
writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4043
|
+
const write = () => writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4044
|
+
const scope = grant.node.scopeId;
|
|
4045
|
+
if (scope) {
|
|
4046
|
+
// #1666: refused while the module is switched off on this scope. The check and the
|
|
4047
|
+
// write share one synchronous body inside one turn on the scope actor, so no switch
|
|
4048
|
+
// can move between them — and (#1666 review) neither can land inside an unrelated
|
|
4049
|
+
// `invoke`'s open transaction and roll back with it after this verb returned. The
|
|
4050
|
+
// turn is re-entrant: from inside one of this scope's tasks it joins that task.
|
|
4051
|
+
// Restore is the lever; a grant is not.
|
|
4052
|
+
const rt = this.runtime(grant.node.tenantId, scope);
|
|
4053
|
+
await rt.actor.turn(() => {
|
|
4054
|
+
if (systemSwitchedOff(switchSqlOf(rt.db), grant.moduleId)) {
|
|
4055
|
+
throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, scope));
|
|
4056
|
+
}
|
|
4057
|
+
write();
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
else {
|
|
4061
|
+
// Tenant-level: a directory write, which no scope's transaction can hold.
|
|
4062
|
+
write();
|
|
4063
|
+
}
|
|
3541
4064
|
this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
|
|
3542
4065
|
},
|
|
4066
|
+
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
4067
|
+
// whole rule, shared with the Cloudflare adapter; this is the directory check, the
|
|
4068
|
+
// transaction and the audit row around it.
|
|
4069
|
+
revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
|
|
4070
|
+
restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
|
|
4071
|
+
// #1672 — the platform's two capability verbs, both a turn on the scope actor
|
|
4072
|
+
// (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
|
|
4073
|
+
// stranger's transaction) and both in the admin log without the secret or its hash.
|
|
4074
|
+
mintCapability: async (actor, tenantId, scopeId, input) => {
|
|
4075
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
4076
|
+
const minted = await rt.actor.turn(() => mintBecomeCapability(spineSql(rt.db), input, actor, this.clock()));
|
|
4077
|
+
this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId }, null, {
|
|
4078
|
+
capabilityId: minted.id,
|
|
4079
|
+
mode: 'become',
|
|
4080
|
+
principal: input.principal,
|
|
4081
|
+
expiresAt: input.expiresAt,
|
|
4082
|
+
maxUses: input.maxUses,
|
|
4083
|
+
label: input.label ?? null,
|
|
4084
|
+
});
|
|
4085
|
+
return minted;
|
|
4086
|
+
},
|
|
4087
|
+
revokeCapability: async (actor, tenantId, scopeId, capabilityId) => {
|
|
4088
|
+
const scope = this.directory
|
|
4089
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
4090
|
+
.get(scopeId);
|
|
4091
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
4092
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4093
|
+
}
|
|
4094
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
4095
|
+
await this.applyPendingMigrations(rt);
|
|
4096
|
+
const before = await rt.actor.turn(() => rt.db.transaction(() => revokeCapabilityAsPlatform(spineSql(rt.db), capabilityId, actor, this.clock()))());
|
|
4097
|
+
if (!before) {
|
|
4098
|
+
throw substratError('not_found', `no capability ${capabilityId} in scope ${scopeId}`);
|
|
4099
|
+
}
|
|
4100
|
+
this.recordAdmin(actor, 'revokeCapability', { tenantId, scopeId }, before, {
|
|
4101
|
+
capabilityId,
|
|
4102
|
+
revoked: true,
|
|
4103
|
+
});
|
|
4104
|
+
},
|
|
3543
4105
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
3544
4106
|
// The org must exist in the node's tenant. A grant to a phantom org is
|
|
3545
4107
|
// worse than an error: it looks applied, resolves for nobody, and shows up
|
|
3546
4108
|
// in the permission diff as though access were conferred.
|
|
4109
|
+
// Checked once before the turn, so a refusal opens no scope file; then again with
|
|
4110
|
+
// the write in one body, on the scope actor when the node is a scope (#1678).
|
|
3547
4111
|
requireOrg(node.tenantId, orgId);
|
|
3548
|
-
|
|
4112
|
+
await onNode(node, () => {
|
|
4113
|
+
requireOrg(node.tenantId, orgId);
|
|
4114
|
+
writeGrant(`org:${orgId}`, permission, node, entity);
|
|
4115
|
+
});
|
|
3549
4116
|
this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
|
|
3550
4117
|
},
|
|
3551
4118
|
// -- vertical + version registry (#31) ---------------------------------
|
|
@@ -3787,7 +4354,7 @@ export class SqliteScopeHost {
|
|
|
3787
4354
|
const parsed = publishVersionInput.parse(input);
|
|
3788
4355
|
const owning = readVertical(parsed.verticalSlug);
|
|
3789
4356
|
if (!owning) {
|
|
3790
|
-
throw
|
|
4357
|
+
throw substratError('not_found', `unknown vertical '${parsed.verticalSlug}'`);
|
|
3791
4358
|
}
|
|
3792
4359
|
// Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
|
|
3793
4360
|
// (tenant-owned, not listed), whose blast radius is its own tenant: there the
|
|
@@ -3830,7 +4397,7 @@ export class SqliteScopeHost {
|
|
|
3830
4397
|
setVerticalListed: async (actor, slug, listed) => {
|
|
3831
4398
|
const existing = readVertical(slug);
|
|
3832
4399
|
if (!existing)
|
|
3833
|
-
throw
|
|
4400
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
3834
4401
|
// Listing is the moment other tenants start trusting this code, so the
|
|
3835
4402
|
// version they would install must carry a real staff vouch — an auto-admitted
|
|
3836
4403
|
// prod version has never been read by anyone but its author.
|
|
@@ -3853,14 +4420,14 @@ export class SqliteScopeHost {
|
|
|
3853
4420
|
requestPublish: async (actor, slug) => {
|
|
3854
4421
|
const existing = readVertical(slug);
|
|
3855
4422
|
if (!existing)
|
|
3856
|
-
throw
|
|
4423
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
3857
4424
|
this.directory.prepare('UPDATE verticals SET publish_requested_at = ? WHERE slug = ?').run(new Date().toISOString(), slug);
|
|
3858
4425
|
this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug });
|
|
3859
4426
|
},
|
|
3860
4427
|
setVerticalInstallsBlocked: async (actor, slug, blocked) => {
|
|
3861
4428
|
const existing = readVertical(slug);
|
|
3862
4429
|
if (!existing)
|
|
3863
|
-
throw
|
|
4430
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
3864
4431
|
this.directory
|
|
3865
4432
|
.prepare('UPDATE verticals SET installs_blocked = ? WHERE slug = ?')
|
|
3866
4433
|
.run(blocked ? 1 : 0, slug);
|
|
@@ -3869,7 +4436,7 @@ export class SqliteScopeHost {
|
|
|
3869
4436
|
setVerticalTenantProvisioner: async (actor, slug, granted) => {
|
|
3870
4437
|
const existing = readVertical(slug);
|
|
3871
4438
|
if (!existing)
|
|
3872
|
-
throw
|
|
4439
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
3873
4440
|
this.directory
|
|
3874
4441
|
.prepare('UPDATE verticals SET tenant_provisioner = ? WHERE slug = ?')
|
|
3875
4442
|
.run(granted ? 1 : 0, slug);
|
|
@@ -3878,7 +4445,7 @@ export class SqliteScopeHost {
|
|
|
3878
4445
|
setVerticalEmailSender: async (actor, slug, granted) => {
|
|
3879
4446
|
const existing = readVertical(slug);
|
|
3880
4447
|
if (!existing)
|
|
3881
|
-
throw
|
|
4448
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
3882
4449
|
this.directory
|
|
3883
4450
|
.prepare('UPDATE verticals SET email_sender = ? WHERE slug = ?')
|
|
3884
4451
|
.run(granted ? 1 : 0, slug);
|
|
@@ -3887,7 +4454,7 @@ export class SqliteScopeHost {
|
|
|
3887
4454
|
deleteVertical: async (actor, slug) => {
|
|
3888
4455
|
const existing = readVertical(slug);
|
|
3889
4456
|
if (!existing)
|
|
3890
|
-
throw
|
|
4457
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
3891
4458
|
// Refuse while any restorable scope is bound: a deleted registry row would strand
|
|
3892
4459
|
// those scopes' version pins and routing. An `archived` scope (a deleted app) still
|
|
3893
4460
|
// blocks — unarchive can bring it back — but the refusal names reap/restore, not
|
|
@@ -3900,10 +4467,10 @@ export class SqliteScopeHost {
|
|
|
3900
4467
|
'FROM scopes WHERE vertical = ?')
|
|
3901
4468
|
.get(slug);
|
|
3902
4469
|
if (bound.live > 0) {
|
|
3903
|
-
throw
|
|
4470
|
+
throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
3904
4471
|
}
|
|
3905
4472
|
if (bound.archived > 0) {
|
|
3906
|
-
throw
|
|
4473
|
+
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
3907
4474
|
}
|
|
3908
4475
|
// Deployed dispatch scripts are NOT reaped here — they become orphans for the
|
|
3909
4476
|
// cleanup script (#248), never destroyed alongside a registry row.
|
|
@@ -3916,7 +4483,7 @@ export class SqliteScopeHost {
|
|
|
3916
4483
|
admitVersion: async (actor, versionId) => {
|
|
3917
4484
|
const v = readVersion(versionId);
|
|
3918
4485
|
if (!v)
|
|
3919
|
-
throw
|
|
4486
|
+
throw substratError('not_found', `unknown version ${versionId}`);
|
|
3920
4487
|
if (v.admission === 'admitted') {
|
|
3921
4488
|
// Idempotent — except an AUTO-admitted version, which this upgrades to a
|
|
3922
4489
|
// manual vouch by clearing the note (what the publish seam requires).
|
|
@@ -3932,7 +4499,7 @@ export class SqliteScopeHost {
|
|
|
3932
4499
|
return;
|
|
3933
4500
|
}
|
|
3934
4501
|
if (v.admission === 'rejected') {
|
|
3935
|
-
throw
|
|
4502
|
+
throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
|
|
3936
4503
|
}
|
|
3937
4504
|
this.directory
|
|
3938
4505
|
.prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?")
|
|
@@ -3944,7 +4511,7 @@ export class SqliteScopeHost {
|
|
|
3944
4511
|
rejectVersion: async (actor, versionId, note) => {
|
|
3945
4512
|
const v = readVersion(versionId);
|
|
3946
4513
|
if (!v)
|
|
3947
|
-
throw
|
|
4514
|
+
throw substratError('not_found', `unknown version ${versionId}`);
|
|
3948
4515
|
if (v.admission === 'admitted') {
|
|
3949
4516
|
throw new Error(`version ${versionId} is already admitted — it may be bound`);
|
|
3950
4517
|
}
|
|
@@ -3961,7 +4528,7 @@ export class SqliteScopeHost {
|
|
|
3961
4528
|
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
3962
4529
|
const incoming = readVersion(versionId);
|
|
3963
4530
|
if (!incoming)
|
|
3964
|
-
throw
|
|
4531
|
+
throw substratError('not_found', `unknown version ${versionId}`);
|
|
3965
4532
|
if (incoming.verticalSlug !== verticalSlug) {
|
|
3966
4533
|
throw new Error(`version ${versionId} belongs to '${incoming.verticalSlug}'`);
|
|
3967
4534
|
}
|
|
@@ -4091,7 +4658,7 @@ export class SqliteScopeHost {
|
|
|
4091
4658
|
bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
|
|
4092
4659
|
const v = readVersion(versionId);
|
|
4093
4660
|
if (!v)
|
|
4094
|
-
throw
|
|
4661
|
+
throw substratError('not_found', `unknown version ${versionId}`);
|
|
4095
4662
|
const scope = this.directory
|
|
4096
4663
|
.prepare('SELECT tenant_id, kind, vertical_version_id FROM scopes WHERE scope_id = ?')
|
|
4097
4664
|
.get(scopeId);
|
|
@@ -4147,7 +4714,7 @@ export class SqliteScopeHost {
|
|
|
4147
4714
|
.prepare('SELECT * FROM verticals WHERE slug = ?')
|
|
4148
4715
|
.get(verticalSlug);
|
|
4149
4716
|
if (!r)
|
|
4150
|
-
throw
|
|
4717
|
+
throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
|
|
4151
4718
|
this.recordAccess(actor, 'verticalServing', {}, { verticalSlug }, r.serving_ref ? 1 : 0);
|
|
4152
4719
|
if (!r.serving_ref || !r.serving_version_id || !r.serving_migration_tag)
|
|
4153
4720
|
return null;
|
|
@@ -4164,7 +4731,7 @@ export class SqliteScopeHost {
|
|
|
4164
4731
|
.prepare('SELECT * FROM verticals WHERE slug = ?')
|
|
4165
4732
|
.get(verticalSlug);
|
|
4166
4733
|
if (!r)
|
|
4167
|
-
throw
|
|
4734
|
+
throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
|
|
4168
4735
|
this.directory
|
|
4169
4736
|
.prepare(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
|
|
4170
4737
|
serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`)
|
|
@@ -4176,7 +4743,7 @@ export class SqliteScopeHost {
|
|
|
4176
4743
|
.prepare('SELECT * FROM vertical_versions WHERE id = ?')
|
|
4177
4744
|
.get(versionId);
|
|
4178
4745
|
if (!v || v.vertical_slug !== verticalSlug) {
|
|
4179
|
-
throw
|
|
4746
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
4180
4747
|
}
|
|
4181
4748
|
this.recordAccess(actor, 'versionManifest', {}, { verticalSlug, versionId }, v.manifest_json ? 1 : 0);
|
|
4182
4749
|
return v.manifest_json;
|
|
@@ -4206,24 +4773,18 @@ export class SqliteScopeHost {
|
|
|
4206
4773
|
this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at }, { expiresAt });
|
|
4207
4774
|
},
|
|
4208
4775
|
readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
this.
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
causedBy: r.caused_by ?? null,
|
|
4222
|
-
// Same lift, same reason. `drainedEvent` requires this field, and the `as never`
|
|
4223
|
-
// below is what let it be missed: without it a SQLite-backed lake loses the
|
|
4224
|
-
// call grouping permanently, while a Cloudflare-backed one keeps it.
|
|
4225
|
-
invocationId: r.invocation_id ?? null,
|
|
4226
|
-
}));
|
|
4776
|
+
// The COMMITTED outbox (#1624). On the shared connection this read saw an in-flight
|
|
4777
|
+
// operation's uncommitted event, the drain shipped it, and the rollback left the lake
|
|
4778
|
+
// — which is append-only — holding an event the scope never had. The mark that
|
|
4779
|
+
// follows needs no such care: it stamps only rows that exist and are undrained.
|
|
4780
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4781
|
+
const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`);
|
|
4782
|
+
// The kernel's read, shared with the DO: the envelope and the columns lifted beside
|
|
4783
|
+
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
4784
|
+
// rather than stalling the scope on it (#1636).
|
|
4785
|
+
const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(Math.max(limit ?? 200, 1), 1000));
|
|
4786
|
+
this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
|
|
4787
|
+
return undrainedEventsOf(read);
|
|
4227
4788
|
},
|
|
4228
4789
|
markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
|
|
4229
4790
|
// BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
|
|
@@ -4244,12 +4805,16 @@ export class SqliteScopeHost {
|
|
|
4244
4805
|
// cannot absorb: the next read resumes from the middle of a batch already
|
|
4245
4806
|
// in the lake, and the object it writes overlaps the one already there —
|
|
4246
4807
|
// the duplication the first-id object key exists to prevent.
|
|
4247
|
-
|
|
4808
|
+
//
|
|
4809
|
+
// On the scope actor (#1678): issued mid-invoke, this transaction was a SAVEPOINT
|
|
4810
|
+
// inside the invoke's, and its rollback un-marked a batch already in the lake — so
|
|
4811
|
+
// the next pass shipped it again, the same duplicate by another road.
|
|
4812
|
+
const drained = await this.runtime(tenantId, scopeId).actor.turn(() => db.transaction(() => {
|
|
4248
4813
|
let n = 0;
|
|
4249
4814
|
for (const id of eventIds)
|
|
4250
4815
|
n += stmt.run(at, id).changes;
|
|
4251
4816
|
return n;
|
|
4252
|
-
})();
|
|
4817
|
+
})());
|
|
4253
4818
|
// K-24's rule, one tier down (#1334): declaring domain payloads shipped is an
|
|
4254
4819
|
// EGRESS, and the admin log is where "these events left the platform, at this
|
|
4255
4820
|
// time, on this actor's say-so" is recorded. `drainAccessLog` audits the same
|
|
@@ -4265,12 +4830,35 @@ export class SqliteScopeHost {
|
|
|
4265
4830
|
// Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
|
|
4266
4831
|
// address this scope" are different answers.
|
|
4267
4832
|
this.assertScopeReachable(tenantId, scopeId);
|
|
4268
|
-
const { drainedBefore } = redrainEventsInput.parse(input);
|
|
4833
|
+
const { drainedBefore, countOnly } = redrainEventsInput.parse(input);
|
|
4269
4834
|
// The window rule, enforced HERE and not only at the control-plane door: this verb
|
|
4270
4835
|
// is public `HostAdmin`, so the fleet script reaches it without passing that route.
|
|
4271
|
-
// The injected clock, so a test pins the boundary rather than racing it.
|
|
4836
|
+
// The injected clock, so a test pins the boundary rather than racing it. A count is
|
|
4837
|
+
// held to it too — the two answers come from one verb and one door.
|
|
4272
4838
|
assertRedrainWindow(drainedBefore, this.clock());
|
|
4273
4839
|
const db = this.scopeDbFor(tenantId, scopeId);
|
|
4840
|
+
// A COUNT reopens nothing, writes nothing, and records nothing (#1545). The two
|
|
4841
|
+
// receipts below are there because a reopen is a second egress of a tenant's
|
|
4842
|
+
// payloads; a count egresses none, and an admin row claiming a redrain on a scope
|
|
4843
|
+
// that was only counted is a false statement in the one log that is evidence.
|
|
4844
|
+
//
|
|
4845
|
+
// UNBOUNDED, unlike the reopen under it: the batch exists because an UPDATE over
|
|
4846
|
+
// the whole window must fit one Durable-Object request on the other adapter, and an
|
|
4847
|
+
// aggregate materialises no rows. A capped count would answer "5000, or more",
|
|
4848
|
+
// which is not an answer.
|
|
4849
|
+
//
|
|
4850
|
+
// It IS a read, and K-24 takes all reads rather than a chosen subset, so it leaves
|
|
4851
|
+
// an access row — the count itself as the result count, which is the fact an
|
|
4852
|
+
// auditor asking "who counted this tenant's outbox, and how much was there" wants.
|
|
4853
|
+
if (countOnly) {
|
|
4854
|
+
// The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
|
|
4855
|
+
const redrainable = this.scopeReadDbFor(tenantId, scopeId)
|
|
4856
|
+
.prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
|
|
4857
|
+
WHERE drained_at IS NOT NULL AND drained_at < ?`)
|
|
4858
|
+
.get(drainedBefore).c;
|
|
4859
|
+
this.recordAccess(actor, 'redrainEvents', { tenantId, scopeId }, { drainedBefore, countOnly: true }, redrainable);
|
|
4860
|
+
return redrainable;
|
|
4861
|
+
}
|
|
4274
4862
|
// Audit FIRST, matching the Cloudflare adapter and `rewindScope` (K-33): the row is
|
|
4275
4863
|
// a separate statement outside the update's transaction, so a failure between them
|
|
4276
4864
|
// would leave a reopen with no receipt — and the retry cannot repair it, because the
|
|
@@ -4288,14 +4876,17 @@ export class SqliteScopeHost {
|
|
|
4288
4876
|
// is never pruned, so the unbounded form scales with the scope's whole history.
|
|
4289
4877
|
// SQLite is built here without UPDATE...LIMIT, so the batch is a subselect over the
|
|
4290
4878
|
// primary key — oldest first, so a partial run leaves a prefix rather than holes.
|
|
4291
|
-
|
|
4879
|
+
//
|
|
4880
|
+
// On the scope actor (#1678), so a stranger's rollback cannot undo a reopen this
|
|
4881
|
+
// verb has already counted and receipted.
|
|
4882
|
+
const redrained = await this.runtime(tenantId, scopeId).actor.turn(() => db
|
|
4292
4883
|
.prepare(`UPDATE _substrat_outbox SET drained_at = NULL
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
.run(drainedBefore, REDRAIN_BATCH).changes;
|
|
4884
|
+
WHERE id IN (
|
|
4885
|
+
SELECT id FROM _substrat_outbox
|
|
4886
|
+
WHERE drained_at IS NOT NULL AND drained_at < ?
|
|
4887
|
+
ORDER BY id LIMIT ?
|
|
4888
|
+
)`)
|
|
4889
|
+
.run(drainedBefore, REDRAIN_BATCH).changes);
|
|
4299
4890
|
// The outcome beside the intent above — only when something changed, so a re-run
|
|
4300
4891
|
// over an already reopened window leaves no row claiming it did something.
|
|
4301
4892
|
if (redrained > 0) {
|
|
@@ -4306,7 +4897,7 @@ export class SqliteScopeHost {
|
|
|
4306
4897
|
facetEvents: async (actor, tenantId, scopeId, input) => {
|
|
4307
4898
|
// `facetEvents` is the sanctioned read: an erased payload yields the same
|
|
4308
4899
|
// NULL a missing field does, and only the helper counts them apart.
|
|
4309
|
-
const db = this.
|
|
4900
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4310
4901
|
const result = facetEvents({ sql: scopedSql(db) }, input);
|
|
4311
4902
|
this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
|
|
4312
4903
|
return result;
|
|
@@ -4316,7 +4907,7 @@ export class SqliteScopeHost {
|
|
|
4316
4907
|
// only one that decodes the envelope's nullable facts (an erased payload, an
|
|
4317
4908
|
// unrecorded authorization chain, nobody impersonating) as facts rather than
|
|
4318
4909
|
// as missing data.
|
|
4319
|
-
const db = this.
|
|
4910
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4320
4911
|
const page = readHistory({ sql: scopedSql(db) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
|
|
4321
4912
|
this.recordAccess(actor, 'entityHistory', { tenantId, scopeId }, { entityType: input.entityType, entityId: input.entityId }, page.entries.length);
|
|
4322
4913
|
return page;
|
|
@@ -4325,7 +4916,7 @@ export class SqliteScopeHost {
|
|
|
4325
4916
|
// #1237: the sanctioned walk. The helper owns the one distinction the whole
|
|
4326
4917
|
// view rests on — a null cause with an operation is the beginning of the
|
|
4327
4918
|
// chain, a null cause without one is the trail running out.
|
|
4328
|
-
const db = this.
|
|
4919
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4329
4920
|
const result = walkEventCause({ sql: scopedSql(db) }, input.eventId, input.maxDepth);
|
|
4330
4921
|
this.recordAccess(actor, 'eventCause', { tenantId, scopeId }, { eventId: input.eventId }, result.chain.length);
|
|
4331
4922
|
return result;
|
|
@@ -4333,27 +4924,27 @@ export class SqliteScopeHost {
|
|
|
4333
4924
|
eventEffects: async (actor, tenantId, scopeId, input) => {
|
|
4334
4925
|
// #1237 forward: the helper owns the two delivery traps — `delivered_at`
|
|
4335
4926
|
// meaning two different things, and an empty list being ambiguous.
|
|
4336
|
-
const db = this.
|
|
4927
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4337
4928
|
const tree = walkEventEffects({ sql: scopedSql(db) }, input.eventId, input.maxNodes);
|
|
4338
4929
|
this.recordAccess(actor, 'eventEffects', { tenantId, scopeId }, { eventId: input.eventId }, tree.count);
|
|
4339
4930
|
return tree;
|
|
4340
4931
|
},
|
|
4341
4932
|
invocationEvents: async (actor, tenantId, scopeId, input) => {
|
|
4342
4933
|
// #1237: one call's events, siblings included — what neither walk reaches.
|
|
4343
|
-
const db = this.
|
|
4934
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4344
4935
|
const read = readInvocation({ sql: scopedSql(db) }, input.invocationId, input.limit);
|
|
4345
4936
|
this.recordAccess(actor, 'invocationEvents', { tenantId, scopeId }, { invocationId: input.invocationId }, read.events.length);
|
|
4346
4937
|
return read;
|
|
4347
4938
|
},
|
|
4348
4939
|
deadLetters: async (actor, tenantId, scopeId, input) => {
|
|
4349
4940
|
// #1525: every delivery that gave up, scope-wide — what the walks reach only by event.
|
|
4350
|
-
const db = this.
|
|
4941
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4351
4942
|
const page = readDeadLetters({ sql: scopedSql(db) }, { limit: input.limit, cursor: input.cursor });
|
|
4352
4943
|
this.recordAccess(actor, 'deadLetters', { tenantId, scopeId }, null, page.entries.length);
|
|
4353
4944
|
return page;
|
|
4354
4945
|
},
|
|
4355
4946
|
scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
|
|
4356
|
-
const db = this.
|
|
4947
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4357
4948
|
const rows = db
|
|
4358
4949
|
.prepare(`SELECT module_id, version, applied_at FROM _substrat_migrations
|
|
4359
4950
|
ORDER BY applied_at DESC, module_id, version LIMIT 100`)
|
|
@@ -4377,6 +4968,16 @@ export class SqliteScopeHost {
|
|
|
4377
4968
|
this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, 0);
|
|
4378
4969
|
return [];
|
|
4379
4970
|
},
|
|
4971
|
+
scopeDatabaseSize: async (actor, tenantId, scopeId) => {
|
|
4972
|
+
// #1524: the SQLite twin of a DO's `databaseSize`. It counts allocated pages,
|
|
4973
|
+
// free ones included, which is what the file costs on disk, not the live row bytes.
|
|
4974
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4975
|
+
const pages = db.pragma('page_count', { simple: true });
|
|
4976
|
+
const pageSize = db.pragma('page_size', { simple: true });
|
|
4977
|
+
const bytes = pages * pageSize;
|
|
4978
|
+
this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
|
|
4979
|
+
return bytes;
|
|
4980
|
+
},
|
|
4380
4981
|
rewindScope: async () => {
|
|
4381
4982
|
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');
|
|
4382
4983
|
},
|
|
@@ -4609,7 +5210,7 @@ export class SqliteScopeHost {
|
|
|
4609
5210
|
listScopeTables: async (actor, tenantId, scopeId) => {
|
|
4610
5211
|
// K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
|
|
4611
5212
|
// a scope under a different tenant is unreachable, never another tenant's DB.
|
|
4612
|
-
const db = this.
|
|
5213
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4613
5214
|
const names = db
|
|
4614
5215
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
|
4615
5216
|
.all().map((r) => r.name);
|
|
@@ -4622,7 +5223,7 @@ export class SqliteScopeHost {
|
|
|
4622
5223
|
return tables;
|
|
4623
5224
|
},
|
|
4624
5225
|
readScopeTable: async (actor, tenantId, scopeId, input) => {
|
|
4625
|
-
const db = this.
|
|
5226
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4626
5227
|
// Validate the table against the LIVE schema — an unknown name throws, it is
|
|
4627
5228
|
// never interpolated blind. That validated name is the only thing that reaches
|
|
4628
5229
|
// the query, so the quoted identifier below carries no user input.
|
|
@@ -4639,7 +5240,7 @@ export class SqliteScopeHost {
|
|
|
4639
5240
|
return { table: input.table, columns, rows, rowCount, limit, offset };
|
|
4640
5241
|
},
|
|
4641
5242
|
queryScope: async (actor, tenantId, scopeId, input) => {
|
|
4642
|
-
const db = this.
|
|
5243
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4643
5244
|
// Layer 1, shared: the kernel's textual gate (single statement, read verbs
|
|
4644
5245
|
// only) — the checked statement is the one that runs.
|
|
4645
5246
|
const sql = assertReadOnlyQuery(input.sql);
|
|
@@ -4674,14 +5275,14 @@ export class SqliteScopeHost {
|
|
|
4674
5275
|
// read records to the K-24 access log. What differs is that these two answer a
|
|
4675
5276
|
// question about the permission model rather than about the data.
|
|
4676
5277
|
listDenials: async (actor, tenantId, scopeId, filter) => {
|
|
4677
|
-
const db = this.
|
|
5278
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4678
5279
|
const q = denialListQuery(filter);
|
|
4679
5280
|
const rows = db.prepare(q.sql).all(...q.params).map(mapDenialRow);
|
|
4680
5281
|
this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
|
|
4681
5282
|
return rows;
|
|
4682
5283
|
},
|
|
4683
5284
|
summarizeDenials: async (actor, tenantId, scopeId, filter) => {
|
|
4684
|
-
const db = this.
|
|
5285
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4685
5286
|
const b = denialSummaryQuery(filter);
|
|
4686
5287
|
// Grouped as the filter asked (#1456), and the answer says which way.
|
|
4687
5288
|
const grouped = mapDenialSummaryBuckets(b.groupBy, db.prepare(b.sql).all(...b.params));
|
|
@@ -4701,7 +5302,7 @@ export class SqliteScopeHost {
|
|
|
4701
5302
|
},
|
|
4702
5303
|
exportScope: async (actor, tenantId, scopeId) => {
|
|
4703
5304
|
// K-3: cross-check the pair before opening anything (same as the introspection reads).
|
|
4704
|
-
const db = this.
|
|
5305
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4705
5306
|
// Every real table, keeping the `_substrat_*` spine but dropping SQLite's own
|
|
4706
5307
|
// `sqlite_*` internals — those are auto-managed and `CREATE TABLE sqlite_*` is
|
|
4707
5308
|
// rejected on reload. `sql` is the CREATE statement, replayed to rebuild the schema.
|
|
@@ -4842,13 +5443,11 @@ export class SqliteScopeHost {
|
|
|
4842
5443
|
throw new Error(`scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
|
|
4843
5444
|
`unbind it before reaping (reap wipes storage and cannot be undone)`);
|
|
4844
5445
|
}
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
this.
|
|
4850
|
-
this.scopesById.delete(scopeId);
|
|
4851
|
-
}
|
|
5446
|
+
// The reader goes with the writer (#1624): an open handle on a removed file keeps
|
|
5447
|
+
// reading the unlinked bytes, which is the reaped data answering as if it were live.
|
|
5448
|
+
const rt = this.scopes.get(`${tenantId}/${scopeId}`);
|
|
5449
|
+
if (rt)
|
|
5450
|
+
this.closeRuntime(rt);
|
|
4852
5451
|
rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
|
|
4853
5452
|
// The recoverable copy the caller stored first (#493), named in the audit entry so
|
|
4854
5453
|
// the trail answers "was there a backup" without correlating two timestamps.
|
|
@@ -4887,15 +5486,31 @@ export class SqliteScopeHost {
|
|
|
4887
5486
|
// and transaction facts remain". A consumer's timeline still shows that something
|
|
4888
5487
|
// happened, to what, and when; it no longer shows who or what was said.
|
|
4889
5488
|
const db = this.scopeDbFor(tenantId, scopeId);
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
.run(subjectId);
|
|
5489
|
+
// One instant for the whole erasure, read before the first write: the intent
|
|
5490
|
+
// tombstones below and the key's own tombstone should not disagree about when a
|
|
5491
|
+
// person was erased.
|
|
4894
5492
|
const at = new Date().toISOString();
|
|
5493
|
+
// Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
|
|
5494
|
+
// invoke held its transaction open, they joined it, and its rollback put the
|
|
5495
|
+
// person's PII back after this verb had destroyed the key and receipted the erasure.
|
|
5496
|
+
const { redacted, intentsRedacted } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
|
|
5497
|
+
redacted: db
|
|
5498
|
+
.prepare(`UPDATE _substrat_outbox SET payload = NULL
|
|
5499
|
+
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
|
|
5500
|
+
.run(subjectId),
|
|
5501
|
+
// The spine's OTHER copy of an event (#1600). A CP-less host routes a connector
|
|
5502
|
+
// delivery it cannot run into `_substrat_platform_requests` with the whole event
|
|
5503
|
+
// embedded, and nothing ever deletes those rows — so the same line has to be held
|
|
5504
|
+
// here, or the redaction above only moves the name one table across. Same
|
|
5505
|
+
// idempotence as the outbox half: a re-run finds the tombstones and changes
|
|
5506
|
+
// nothing.
|
|
5507
|
+
intentsRedacted: this.redactSubjectIntents(db, subjectId, at),
|
|
5508
|
+
}));
|
|
4895
5509
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
4896
5510
|
const receipt = subjectShredReceipt.parse({
|
|
4897
5511
|
subjectId,
|
|
4898
5512
|
eventsRedacted: redacted.changes,
|
|
5513
|
+
intentsRedacted,
|
|
4899
5514
|
keyDestroyed: existed,
|
|
4900
5515
|
tombstoned: true,
|
|
4901
5516
|
});
|
|
@@ -4903,7 +5518,9 @@ export class SqliteScopeHost {
|
|
|
4903
5518
|
// mutation, the access log because it destroys evidence. An erasure is the one
|
|
4904
5519
|
// action where "who asked for this to disappear" is itself the record.
|
|
4905
5520
|
this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
4906
|
-
|
|
5521
|
+
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
5522
|
+
// an intent payload is a whole event's worth of it.
|
|
5523
|
+
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted);
|
|
4907
5524
|
return receipt;
|
|
4908
5525
|
},
|
|
4909
5526
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -5121,6 +5738,40 @@ export class SqliteScopeHost {
|
|
|
5121
5738
|
this.recordAccess(actor, 'listIdentityTenants', {}, { provider }, tenants.length);
|
|
5122
5739
|
return tenants;
|
|
5123
5740
|
},
|
|
5741
|
+
listIdentityMemberships: async (actor, provider, externalId) => {
|
|
5742
|
+
const pool = readPool(provider);
|
|
5743
|
+
if (!pool)
|
|
5744
|
+
throw new Error(`identity pool '${provider}' is not registered`);
|
|
5745
|
+
if (pool.topology !== 'central') {
|
|
5746
|
+
throw new Error(`identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
|
|
5747
|
+
`meaningful on a central pool, where the same externalId is the same person`);
|
|
5748
|
+
}
|
|
5749
|
+
const rows = this.directory
|
|
5750
|
+
.prepare(`SELECT i.tenant_id, i.principal_id, s.scope_id, s.vertical, s.status AS scope_status
|
|
5751
|
+
FROM _substrat_identities i
|
|
5752
|
+
LEFT JOIN scopes s ON s.scope_id = i.scope_id AND s.tenant_id = i.tenant_id
|
|
5753
|
+
WHERE i.provider = ? AND i.external_id = ?
|
|
5754
|
+
ORDER BY i.tenant_id`)
|
|
5755
|
+
.all(provider, externalId);
|
|
5756
|
+
const memberships = rows.flatMap((r) => {
|
|
5757
|
+
// A link whose tenant row is gone names nothing a caller could land in.
|
|
5758
|
+
const tenant = readTenant(r.tenant_id);
|
|
5759
|
+
if (!tenant)
|
|
5760
|
+
return [];
|
|
5761
|
+
return [
|
|
5762
|
+
identityMembership.parse({
|
|
5763
|
+
tenant,
|
|
5764
|
+
principal: r.principal_id,
|
|
5765
|
+
scope: r.scope_id === null
|
|
5766
|
+
? null
|
|
5767
|
+
: { id: r.scope_id, vertical: r.vertical, status: r.scope_status ?? 'active' },
|
|
5768
|
+
}),
|
|
5769
|
+
];
|
|
5770
|
+
});
|
|
5771
|
+
// One row for the whole answer — the read this replaces logged one per tenant.
|
|
5772
|
+
this.recordAccess(actor, 'listIdentityMemberships', {}, { provider }, memberships.length);
|
|
5773
|
+
return memberships;
|
|
5774
|
+
},
|
|
5124
5775
|
listIdentityLinks: async (actor, tid) => {
|
|
5125
5776
|
// The projection read (#406): what the platform gathers to deliver a tenant's
|
|
5126
5777
|
// links with provisioning/reconcile — the same trust line entitlements ride
|
|
@@ -5826,6 +6477,44 @@ export class SqliteScopeHost {
|
|
|
5826
6477
|
if (!existing)
|
|
5827
6478
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
5828
6479
|
}
|
|
6480
|
+
/**
|
|
6481
|
+
* #1288: `_substrat_schedule_state`, rebuilt with `kind` in its key on a scope db
|
|
6482
|
+
* whose table predates the column. The statements are the kernel's, so the DO
|
|
6483
|
+
* adapter rebuilds byte-identically, and the detection is the `sqlite_master.sql`
|
|
6484
|
+
* read `ensureIdentityKey` already uses — PRAGMA would do here, but not there.
|
|
6485
|
+
*/
|
|
6486
|
+
ensureScheduleStateKind(db) {
|
|
6487
|
+
const row = db
|
|
6488
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
6489
|
+
.get('_substrat_schedule_state');
|
|
6490
|
+
if (!row || scheduleStateHasKind(row.sql))
|
|
6491
|
+
return;
|
|
6492
|
+
// In a transaction, for the reason the kernel constant spells out: create-copy-
|
|
6493
|
+
// drop-rename has two intermediate states and BOTH are unrecoverable on the next
|
|
6494
|
+
// wake — one dies on the leftover scratch table, one silently orphans the rows
|
|
6495
|
+
// behind an empty table `CREATE TABLE IF NOT EXISTS` put back. `db.transaction`
|
|
6496
|
+
// nests as a SAVEPOINT, which is what makes this safe on the `loadDump` path too,
|
|
6497
|
+
// where the whole replay is already inside one.
|
|
6498
|
+
db.transaction(() => db.exec(SCHEDULE_STATE_REBUILD))();
|
|
6499
|
+
}
|
|
6500
|
+
/**
|
|
6501
|
+
* #1573: run a create-copy-drop-rename against the directory as ONE transaction.
|
|
6502
|
+
* `exec` of four statements is four autocommits, and the state between DROP and
|
|
6503
|
+
* RENAME is the one to fear: the next open runs the bootstrap first, whose `CREATE
|
|
6504
|
+
* TABLE IF NOT EXISTS` puts an EMPTY table of the new shape back, detection then
|
|
6505
|
+
* reads that shape and concludes the migration is done — and every copied row stays
|
|
6506
|
+
* orphaned in the `_new` table, with nothing ever erroring. Atomic, neither
|
|
6507
|
+
* intermediate state is reachable, so there is no recovery path to write (or to
|
|
6508
|
+
* leave untested).
|
|
6509
|
+
*
|
|
6510
|
+
* `db.transaction` nests as a SAVEPOINT, as `ensureScheduleStateKind` (#1571) relies
|
|
6511
|
+
* on. Each script leads with `DROP TABLE IF EXISTS <table>_new` to absorb a scratch
|
|
6512
|
+
* table arriving from BELOW the transaction (a torn copy of the file, a backup that
|
|
6513
|
+
* captured one mid-rebuild) — belt, not the fix.
|
|
6514
|
+
*/
|
|
6515
|
+
rebuildAtomically(script) {
|
|
6516
|
+
this.directory.transaction(() => this.directory.exec(script))();
|
|
6517
|
+
}
|
|
5829
6518
|
/**
|
|
5830
6519
|
* Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
|
|
5831
6520
|
* A PRIMARY KEY cannot be ALTERed, so this is create-copy-drop-rename.
|
|
@@ -5846,7 +6535,9 @@ export class SqliteScopeHost {
|
|
|
5846
6535
|
.get('_substrat_identities');
|
|
5847
6536
|
if (!row || row.sql.includes('PRIMARY KEY (tenant_id, provider, external_id)'))
|
|
5848
6537
|
return;
|
|
5849
|
-
|
|
6538
|
+
// #1573: one transaction — see `rebuildAtomically`.
|
|
6539
|
+
this.rebuildAtomically(`
|
|
6540
|
+
DROP TABLE IF EXISTS _substrat_identities_new;
|
|
5850
6541
|
CREATE TABLE _substrat_identities_new (
|
|
5851
6542
|
provider TEXT NOT NULL,
|
|
5852
6543
|
external_id TEXT NOT NULL,
|
|
@@ -5877,7 +6568,9 @@ export class SqliteScopeHost {
|
|
|
5877
6568
|
.get('_substrat_admin_log');
|
|
5878
6569
|
if (!row || !/tenant_id TEXT NOT NULL/.test(row.sql))
|
|
5879
6570
|
return;
|
|
5880
|
-
|
|
6571
|
+
// #1573: one transaction — see `rebuildAtomically`.
|
|
6572
|
+
this.rebuildAtomically(`
|
|
6573
|
+
DROP TABLE IF EXISTS _substrat_admin_log_new;
|
|
5881
6574
|
CREATE TABLE _substrat_admin_log_new (
|
|
5882
6575
|
id TEXT PRIMARY KEY,
|
|
5883
6576
|
actor TEXT NOT NULL,
|
|
@@ -5896,6 +6589,30 @@ export class SqliteScopeHost {
|
|
|
5896
6589
|
ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
|
|
5897
6590
|
`);
|
|
5898
6591
|
}
|
|
6592
|
+
/**
|
|
6593
|
+
* #1572: `_substrat_sweep_runs_intent` with `kind` in it, on a directory whose index
|
|
6594
|
+
* predates that. `CREATE UNIQUE INDEX IF NOT EXISTS` matches on the name alone, so the
|
|
6595
|
+
* bootstrap cannot do this: on an existing directory it sees the old index and skips,
|
|
6596
|
+
* and the schedule/freshness collision the new key refuses stays live for good.
|
|
6597
|
+
*
|
|
6598
|
+
* Detected from `sqlite_master.sql`, like the table rebuilds above, and replaced as
|
|
6599
|
+
* DROP-then-CREATE in one transaction (`rebuildAtomically`). Less rides on that than on
|
|
6600
|
+
* a table rebuild — the index holds no rows, and one dropped but not re-created comes
|
|
6601
|
+
* back on the next open, because the bootstrap creates it by name — but there is then
|
|
6602
|
+
* no committed directory with no dedupe key at all. The CREATE cannot meet a row it
|
|
6603
|
+
* refuses: every set of rows unique on (request_id, unit) is unique on the wider key.
|
|
6604
|
+
*/
|
|
6605
|
+
ensureSweepRunsIntentKind() {
|
|
6606
|
+
const row = this.directory
|
|
6607
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
|
|
6608
|
+
.get('_substrat_sweep_runs_intent');
|
|
6609
|
+
if (row && sweepRunsIntentHasKind(row.sql))
|
|
6610
|
+
return;
|
|
6611
|
+
this.rebuildAtomically(`
|
|
6612
|
+
DROP INDEX IF EXISTS _substrat_sweep_runs_intent;
|
|
6613
|
+
${SWEEP_RUNS_INTENT_INDEX}
|
|
6614
|
+
`);
|
|
6615
|
+
}
|
|
5899
6616
|
ensureDirectoryColumns() {
|
|
5900
6617
|
this.ensureIdentityKey();
|
|
5901
6618
|
this.ensureAdminLogTenantNullable();
|
|
@@ -5964,7 +6681,7 @@ export class SqliteScopeHost {
|
|
|
5964
6681
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'request_id', 'request_id TEXT');
|
|
5965
6682
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'event_type', 'event_type TEXT');
|
|
5966
6683
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'observed_at', 'observed_at TEXT');
|
|
5967
|
-
this.
|
|
6684
|
+
this.ensureSweepRunsIntentKind();
|
|
5968
6685
|
const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
|
|
5969
6686
|
for (const [column, ddl] of [
|
|
5970
6687
|
['parent_scope_id', 'parent_scope_id TEXT'],
|
|
@@ -6041,7 +6758,23 @@ export class SqliteScopeHost {
|
|
|
6041
6758
|
* onto every event it emits. Absent for consumer dispatch: a consumer runs on
|
|
6042
6759
|
* behalf of no operation, and the emitted row's NULL says so.
|
|
6043
6760
|
*/
|
|
6044
|
-
operation
|
|
6761
|
+
operation,
|
|
6762
|
+
/**
|
|
6763
|
+
* #1672: the secrets `ctx.capabilities.mint` hands out during this invocation. The
|
|
6764
|
+
* stub owns the array (it withholds them from the idempotency recording); this
|
|
6765
|
+
* context appends to it and holds `ctx.sql`, `ctx.emit` and `ctx.requestPlatform` to
|
|
6766
|
+
* it — the tripwire that catches a module persisting one by accident (not a boundary
|
|
6767
|
+
* against one that means to; see `assertNoSecret`).
|
|
6768
|
+
*/
|
|
6769
|
+
minted = [],
|
|
6770
|
+
/**
|
|
6771
|
+
* #1672: the instant to stamp, when the caller already read one. The exchange passes the
|
|
6772
|
+
* instant its row write used, so the `capability.exercised` event's `occurredAt` and the
|
|
6773
|
+
* row's `last_used_at` are the same value — a second clock read could disagree.
|
|
6774
|
+
*/
|
|
6775
|
+
at = this.clock()) {
|
|
6776
|
+
// For a connection, system or capability subject this carries THAT id so the type
|
|
6777
|
+
// holds — it is not a person, and the event actor below says what it is instead.
|
|
6045
6778
|
const principal = subject.id;
|
|
6046
6779
|
const checker = this.checker;
|
|
6047
6780
|
const relations = this.relations;
|
|
@@ -6066,7 +6799,6 @@ export class SqliteScopeHost {
|
|
|
6066
6799
|
* `ctx.now()` deterministic only in the sense that each reading is separately
|
|
6067
6800
|
* unpredictable.
|
|
6068
6801
|
*/
|
|
6069
|
-
const at = this.clock();
|
|
6070
6802
|
/**
|
|
6071
6803
|
* The scope host's half of `ctx.atomic` (#770) — everything else is the
|
|
6072
6804
|
* kernel's (`createAtomic`). SQLite gives us savepoints, which nest inside
|
|
@@ -6102,7 +6834,10 @@ export class SqliteScopeHost {
|
|
|
6102
6834
|
// Lifted out of the object literal so `grant` can reuse it: a delegation
|
|
6103
6835
|
// check has to be the SAME check the operation itself passes, or the two
|
|
6104
6836
|
// could disagree about what the caller holds.
|
|
6105
|
-
const runCheck = async (
|
|
6837
|
+
const runCheck = async (unparsed, entity) => {
|
|
6838
|
+
// #1642: parsed before the override actor's early return, which never reaches
|
|
6839
|
+
// the checker — a cast key would otherwise become that path's proof relation.
|
|
6840
|
+
const permission = assertPermissionKey(unparsed);
|
|
6106
6841
|
if (overrideActor) {
|
|
6107
6842
|
return {
|
|
6108
6843
|
allowed: true,
|
|
@@ -6140,15 +6875,17 @@ export class SqliteScopeHost {
|
|
|
6140
6875
|
scopeId: rt.scopeId,
|
|
6141
6876
|
});
|
|
6142
6877
|
};
|
|
6143
|
-
|
|
6878
|
+
const ctxRef = {
|
|
6144
6879
|
tenantId: rt.tenantId,
|
|
6145
6880
|
scopeId: rt.scopeId,
|
|
6146
6881
|
principal,
|
|
6147
|
-
sql: scopedSql(rt.db),
|
|
6882
|
+
sql: guardSecrets(scopedSql(rt.db), minted),
|
|
6148
6883
|
now: () => at,
|
|
6149
6884
|
emit: (event) => {
|
|
6150
6885
|
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
6151
6886
|
const input = domainEventInput.parse(event);
|
|
6887
|
+
// #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
|
|
6888
|
+
assertNoSecret('ctx.emit', input, minted);
|
|
6152
6889
|
const full = domainEvent.parse({
|
|
6153
6890
|
...input,
|
|
6154
6891
|
// #956: from the operation's instant, not the wall clock. `ORDER BY id`
|
|
@@ -6159,12 +6896,7 @@ export class SqliteScopeHost {
|
|
|
6159
6896
|
occurredAt: at,
|
|
6160
6897
|
tenantId: rt.tenantId,
|
|
6161
6898
|
scopeId: rt.scopeId,
|
|
6162
|
-
actor: overrideActor ??
|
|
6163
|
-
(subject.kind === 'system'
|
|
6164
|
-
? { system: subject.id }
|
|
6165
|
-
: subject.kind === 'connection'
|
|
6166
|
-
? { connection: subject.id }
|
|
6167
|
-
: principal),
|
|
6899
|
+
actor: overrideActor ?? actorOf(subject),
|
|
6168
6900
|
...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
|
|
6169
6901
|
...(stamp ? { impersonation: stamp } : {}),
|
|
6170
6902
|
// #1231: kernel-stamped like the two above — `domainEventInput.parse`
|
|
@@ -6194,6 +6926,8 @@ export class SqliteScopeHost {
|
|
|
6194
6926
|
requestPlatform: (request) => {
|
|
6195
6927
|
assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
|
|
6196
6928
|
const input = platformRequestInput.parse(request);
|
|
6929
|
+
// #1672: the COMPLETE parsed request — its `kind` is persisted as surely as its payload.
|
|
6930
|
+
assertNoSecret('ctx.requestPlatform', input, minted);
|
|
6197
6931
|
// #1474: a platform-authored kind (`sweep-runs`) is the platform's to enqueue, never
|
|
6198
6932
|
// module code's — its drain handler writes schedule verdicts the dashboard trusts.
|
|
6199
6933
|
assertModuleEnqueueableKind(input.kind);
|
|
@@ -6206,12 +6940,7 @@ export class SqliteScopeHost {
|
|
|
6206
6940
|
throw new Error(`too many pending platform requests (${pending}); retry once some have drained`);
|
|
6207
6941
|
}
|
|
6208
6942
|
const id = platformRequestId.parse(ulid());
|
|
6209
|
-
const requestedBy = overrideActor ??
|
|
6210
|
-
(subject.kind === 'system'
|
|
6211
|
-
? { system: subject.id }
|
|
6212
|
-
: subject.kind === 'connection'
|
|
6213
|
-
? { connection: subject.id }
|
|
6214
|
-
: principal);
|
|
6943
|
+
const requestedBy = overrideActor ?? actorOf(subject);
|
|
6215
6944
|
rt.db
|
|
6216
6945
|
.prepare(`INSERT INTO _substrat_platform_requests
|
|
6217
6946
|
(id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
|
|
@@ -6225,7 +6954,7 @@ export class SqliteScopeHost {
|
|
|
6225
6954
|
// predicate is needed or possible: the runtime IS the scope.
|
|
6226
6955
|
platformRequests: (filter) => {
|
|
6227
6956
|
const q = platformRequestHistoryQuery(filter);
|
|
6228
|
-
return rt.db.prepare(q.sql).all(...q.params).map(
|
|
6957
|
+
return rt.db.prepare(q.sql).all(...q.params).map(platformRequestOf);
|
|
6229
6958
|
},
|
|
6230
6959
|
// #901. This scope's own spine, so no tenancy predicate is needed or
|
|
6231
6960
|
// possible — the runtime IS the scope. The version is the last event's
|
|
@@ -6325,6 +7054,21 @@ export class SqliteScopeHost {
|
|
|
6325
7054
|
.run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
|
|
6326
7055
|
},
|
|
6327
7056
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
7057
|
+
// #1672: mint / revoke / list, written once in the kernel. The raw spine seam (this
|
|
7058
|
+
// is the kernel's own write to `_substrat_capabilities`), the operation's OWN check —
|
|
7059
|
+
// the predicate `grant` shares — and `ctx.emit` for the minted/revoked events. A
|
|
7060
|
+
// consumer's override actor is passed as the system actor it is, so a consumer cannot
|
|
7061
|
+
// mint: its checks allow unconditionally, which would make "the minter holds it" vacuous.
|
|
7062
|
+
capabilities: createCapabilityVerbs({
|
|
7063
|
+
sql: spineSql(rt.db),
|
|
7064
|
+
subject: overrideActor ? { kind: 'system', id: overrideActor.system } : subject,
|
|
7065
|
+
now: at,
|
|
7066
|
+
check: runCheck,
|
|
7067
|
+
emit: (event) => ctxRef.emit(event),
|
|
7068
|
+
isOperation: (name) => this.operations.has(name),
|
|
7069
|
+
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
7070
|
+
minted,
|
|
7071
|
+
}),
|
|
6328
7072
|
link: (child, parent) => {
|
|
6329
7073
|
assertImpersonationWrites(impersonation, 'ctx.link');
|
|
6330
7074
|
const allowed = relations.get(child.entityType);
|
|
@@ -6386,6 +7130,7 @@ export class SqliteScopeHost {
|
|
|
6386
7130
|
return sealTo({ keyId: key.keyId, publicKey: key.publicKey }, plaintext);
|
|
6387
7131
|
},
|
|
6388
7132
|
};
|
|
7133
|
+
return ctxRef;
|
|
6389
7134
|
}
|
|
6390
7135
|
async applyPendingMigrations(rt) {
|
|
6391
7136
|
const pending = [];
|
|
@@ -6494,10 +7239,98 @@ export class SqliteScopeHost {
|
|
|
6494
7239
|
throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
|
|
6495
7240
|
}
|
|
6496
7241
|
}
|
|
7242
|
+
/**
|
|
7243
|
+
* The intent-journal half of `shredSubject` (#1600) — the DO's `redactSubject` twin.
|
|
7244
|
+
*
|
|
7245
|
+
* Row-by-row rather than one `UPDATE … WHERE`: the decision is structural (does this
|
|
7246
|
+
* payload embed an event the erasure redacts?) and SQL cannot make it, because an
|
|
7247
|
+
* intent payload is opaque JSON with no `pii_class` column to test. The SQL narrows to
|
|
7248
|
+
* the rows that could possibly match; the kernel predicate decides; the kernel's
|
|
7249
|
+
* statement writes. All three are shared with the Durable-Object adapter so the two
|
|
7250
|
+
* cannot drift about what a redacted intent is.
|
|
7251
|
+
*
|
|
7252
|
+
* Returns how many rows it redacted — zero on a re-run, the tombstones having already
|
|
7253
|
+
* stopped matching.
|
|
7254
|
+
*/
|
|
7255
|
+
redactSubjectIntents(db, subjectId, at) {
|
|
7256
|
+
const q = platformRequestRedactionQuery(subjectId);
|
|
7257
|
+
const candidates = db.prepare(q.sql).all(...q.params);
|
|
7258
|
+
const write = db.prepare(PLATFORM_REQUEST_REDACTION_SQL);
|
|
7259
|
+
let redacted = 0;
|
|
7260
|
+
for (const row of candidates) {
|
|
7261
|
+
if (!intentPayloadCarriesSubject(row.payload, subjectId))
|
|
7262
|
+
continue;
|
|
7263
|
+
write.run(...platformRequestRedactionParams(row.id, subjectId, at));
|
|
7264
|
+
redacted += 1;
|
|
7265
|
+
}
|
|
7266
|
+
return redacted;
|
|
7267
|
+
}
|
|
6497
7268
|
scopeDbFor(tenantId, scopeId) {
|
|
6498
7269
|
this.assertScopeReachable(tenantId, scopeId);
|
|
6499
7270
|
return this.runtime(tenantId, scopeId).db;
|
|
6500
7271
|
}
|
|
7272
|
+
/**
|
|
7273
|
+
* The connection a `HostAdmin` READ runs on (#1624) — `scopeDbFor`'s twin, with the
|
|
7274
|
+
* same K-3 and reap gate in front of it.
|
|
7275
|
+
*
|
|
7276
|
+
* From OUTSIDE the scope's actor it is the scope's read-only connection. `invoke` holds
|
|
7277
|
+
* a `BEGIN IMMEDIATE` open on the shared connection across its awaits, so a read there
|
|
7278
|
+
* saw a stranger's uncommitted rows: a lake drain shipped an event that then rolled
|
|
7279
|
+
* back — permanently, since the lake is append-only — and an export captured one into a
|
|
7280
|
+
* backup. The file is WAL, so the reader sees exactly what was committed.
|
|
7281
|
+
*
|
|
7282
|
+
* From INSIDE one of the scope's actor tasks (an executor handed `HostAdmin`, reading
|
|
7283
|
+
* its own scope) it is the shared connection, as before. The only transaction open
|
|
7284
|
+
* there is the caller's own unit — the actor serializes everything else — so reading
|
|
7285
|
+
* it is reading your own writes, which is the answer the caller is owed and the one it
|
|
7286
|
+
* had. `ScopeActor.turn` makes the same call for the writers (#1678).
|
|
7287
|
+
*/
|
|
7288
|
+
scopeReadDbFor(tenantId, scopeId) {
|
|
7289
|
+
this.assertScopeReachable(tenantId, scopeId);
|
|
7290
|
+
return this.readDb(this.runtime(tenantId, scopeId));
|
|
7291
|
+
}
|
|
7292
|
+
/**
|
|
7293
|
+
* `scopeReadDbFor` without the directory gate, for the host-side reads that already
|
|
7294
|
+
* resolved their runtime — the platform-intent drain's `listPlatformRequests` among them,
|
|
7295
|
+
* where a stranger's uncommitted intent was a platform effect about to be carried out for
|
|
7296
|
+
* an operation that then rolled back.
|
|
7297
|
+
*/
|
|
7298
|
+
readDb(rt) {
|
|
7299
|
+
if (rt.actor.holds())
|
|
7300
|
+
return rt.db;
|
|
7301
|
+
const key = `${rt.tenantId}/${rt.scopeId}`;
|
|
7302
|
+
if (rt.reader) {
|
|
7303
|
+
// Most recently used goes to the back of the insertion order, which is the cap's LRU.
|
|
7304
|
+
this.readers.delete(key);
|
|
7305
|
+
this.readers.set(key, rt);
|
|
7306
|
+
return rt.reader;
|
|
7307
|
+
}
|
|
7308
|
+
// `runtime()` opened the writer first, in WAL, so the file and its `-shm` exist.
|
|
7309
|
+
rt.reader = new Database(rt.file, { readonly: true, fileMustExist: true });
|
|
7310
|
+
this.readers.set(key, rt);
|
|
7311
|
+
// The cap: a lake drain sweeps every scope, and without it every open scope would end
|
|
7312
|
+
// up holding two connections. The oldest reader is closed; the next read reopens it.
|
|
7313
|
+
while (this.readers.size > READER_CAP) {
|
|
7314
|
+
const [oldest, victim] = this.readers.entries().next().value;
|
|
7315
|
+
this.readers.delete(oldest);
|
|
7316
|
+
victim.reader?.close();
|
|
7317
|
+
victim.reader = null;
|
|
7318
|
+
}
|
|
7319
|
+
return rt.reader;
|
|
7320
|
+
}
|
|
7321
|
+
/**
|
|
7322
|
+
* Close a scope's connections and forget its runtime — the reader with the writer, so
|
|
7323
|
+
* neither outlives the file a reap or a snapshot delete is about to remove.
|
|
7324
|
+
*/
|
|
7325
|
+
closeRuntime(rt) {
|
|
7326
|
+
const key = `${rt.tenantId}/${rt.scopeId}`;
|
|
7327
|
+
rt.reader?.close();
|
|
7328
|
+
rt.reader = null;
|
|
7329
|
+
this.readers.delete(key);
|
|
7330
|
+
rt.db.close();
|
|
7331
|
+
this.scopes.delete(key);
|
|
7332
|
+
this.scopesById.delete(rt.scopeId);
|
|
7333
|
+
}
|
|
6501
7334
|
/**
|
|
6502
7335
|
* The additive spine-column migrations, shared by `runtime()` and the dump replay.
|
|
6503
7336
|
* KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before a column keeps the
|
|
@@ -6516,6 +7349,11 @@ export class SqliteScopeHost {
|
|
|
6516
7349
|
// consumer dead-letter.
|
|
6517
7350
|
this.ensureColumn(db, '_substrat_deliveries', 'attempts', 'attempts INTEGER NOT NULL DEFAULT 0');
|
|
6518
7351
|
this.ensureColumn(db, '_substrat_deliveries', 'next_attempt_at', 'next_attempt_at TEXT');
|
|
7352
|
+
// #1525: the invocation an attempt ran in, on a scope DB created before the column.
|
|
7353
|
+
// Nullable, and the null is honestly "no call was carried" — which attempt produced
|
|
7354
|
+
// a delivery already journalled cannot be decided afterwards, exactly as #1237's
|
|
7355
|
+
// outbox column argued.
|
|
7356
|
+
this.ensureColumn(db, '_substrat_deliveries', 'invocation_id', 'invocation_id TEXT');
|
|
6519
7357
|
// K-34: the authorization column on a scope DB created before it existed. Nullable,
|
|
6520
7358
|
// so legacy outbox rows read as "unrecorded" — the honest value. (_substrat_denials
|
|
6521
7359
|
// is a whole new table, so KERNEL_DDL's IF NOT EXISTS covers it with no ALTER.)
|
|
@@ -6531,6 +7369,10 @@ export class SqliteScopeHost {
|
|
|
6531
7369
|
this.ensureColumn(db, '_substrat_outbox', 'impersonation', 'impersonation TEXT');
|
|
6532
7370
|
this.ensureColumn(db, '_substrat_platform_requests', 'impersonation', 'impersonation TEXT');
|
|
6533
7371
|
this.ensureColumn(db, '_substrat_denials', 'impersonation', 'impersonation TEXT');
|
|
7372
|
+
// #1525: the invocation a refusal happened during, on a scope DB created before the
|
|
7373
|
+
// column. Nullable, and the null is honestly "no id was carried" — a past denial's
|
|
7374
|
+
// call cannot be decided afterwards, exactly as #1237's outbox column argued.
|
|
7375
|
+
this.ensureColumn(db, '_substrat_denials', 'invocation_id', 'invocation_id TEXT');
|
|
6534
7376
|
// #1231: the emitting operation, on a scope DB created before the column. Nullable
|
|
6535
7377
|
// so every legacy row reads as unrecorded rather than claiming a name nobody stamped.
|
|
6536
7378
|
this.ensureColumn(db, '_substrat_outbox', 'operation', 'operation TEXT');
|
|
@@ -6544,6 +7386,9 @@ export class SqliteScopeHost {
|
|
|
6544
7386
|
// #1237: existing scopes get it on next wake. Rows already written keep NULL, which
|
|
6545
7387
|
// honestly means "no invocation id was carried" — nothing can decide one afterwards.
|
|
6546
7388
|
this.ensureColumn(db, '_substrat_outbox', 'invocation_id', 'invocation_id TEXT');
|
|
7389
|
+
// #1288: not an ensureColumn, because `kind` joins the schedule-state PRIMARY KEY
|
|
7390
|
+
// and no ALTER can widen a key. Rebuilt instead, from the kernel's statements.
|
|
7391
|
+
this.ensureScheduleStateKind(db);
|
|
6547
7392
|
// #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
|
|
6548
7393
|
// that is never pruned. No index leads with invocation_id, so without this one SQLite
|
|
6549
7394
|
// walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a
|
|
@@ -6563,7 +7408,10 @@ export class SqliteScopeHost {
|
|
|
6563
7408
|
const existing = this.scopes.get(key);
|
|
6564
7409
|
if (existing)
|
|
6565
7410
|
return existing;
|
|
6566
|
-
const
|
|
7411
|
+
const file = join(this.dir, `${tenantId}__${scopeId}.sqlite`);
|
|
7412
|
+
const db = new Database(file);
|
|
7413
|
+
// WAL is also what the read connection rests on (#1624): a reader sees the last
|
|
7414
|
+
// committed snapshot and never blocks, or is blocked by, the writer.
|
|
6567
7415
|
db.pragma('journal_mode = WAL');
|
|
6568
7416
|
db.exec(KERNEL_DDL);
|
|
6569
7417
|
this.ensureSpineColumns(db);
|
|
@@ -6580,7 +7428,9 @@ export class SqliteScopeHost {
|
|
|
6580
7428
|
const created = {
|
|
6581
7429
|
tenantId,
|
|
6582
7430
|
scopeId,
|
|
7431
|
+
file,
|
|
6583
7432
|
db,
|
|
7433
|
+
reader: null,
|
|
6584
7434
|
actor: new ScopeActor(),
|
|
6585
7435
|
appliedMigrations,
|
|
6586
7436
|
mintEventId,
|
|
@@ -6611,6 +7461,20 @@ function cellToJson(v) {
|
|
|
6611
7461
|
* `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
|
|
6612
7462
|
* spine writes use `rt.db` directly and never pass through here.
|
|
6613
7463
|
*/
|
|
7464
|
+
/**
|
|
7465
|
+
* The kernel's OWN spine access (#1672) — the same seam as `scopedSql` without `guardSpine`,
|
|
7466
|
+
* because these are the kernel's writes to `_substrat_capabilities`, which module code may
|
|
7467
|
+
* never make. Never handed to module code.
|
|
7468
|
+
*/
|
|
7469
|
+
function spineSql(db) {
|
|
7470
|
+
return {
|
|
7471
|
+
query: (sql, params = []) => db.prepare(sql).all(...params),
|
|
7472
|
+
exec: (sql, params = []) => {
|
|
7473
|
+
const info = db.prepare(sql).run(...params);
|
|
7474
|
+
return { changes: info.changes };
|
|
7475
|
+
},
|
|
7476
|
+
};
|
|
7477
|
+
}
|
|
6614
7478
|
function scopedSql(db) {
|
|
6615
7479
|
return guardSpine({
|
|
6616
7480
|
query: (sql, params = []) => db.prepare(sql).all(...params),
|