@substrat-run/adapter-cloudflare 0.136.0 → 0.138.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/checker.d.ts +7 -1
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +5 -3
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +123 -52
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +321 -73
- package/dist/control-plane-do.js.map +1 -1
- package/dist/do-reply.d.ts +25 -0
- package/dist/do-reply.d.ts.map +1 -0
- package/dist/do-reply.js +17 -0
- package/dist/do-reply.js.map +1 -0
- package/dist/host.d.ts +221 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +1073 -401
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +39 -1
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +79 -0
- package/dist/live-reads.js.map +1 -1
- package/dist/scope-do.d.ts +4 -7
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +595 -134
- package/dist/scope-do.js.map +1 -1
- package/dist/system-door.d.ts +21 -0
- package/dist/system-door.d.ts.map +1 -0
- package/dist/system-door.js +13 -0
- package/dist/system-door.js.map +1 -0
- package/package.json +11 -11
package/dist/control-plane-do.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
-
import { IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind,
|
|
2
|
+
import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, resolveVerticalInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, } from '@substrat-run/kernel';
|
|
3
|
+
import { replyOf } from './do-reply.js';
|
|
3
4
|
import { splitSqlStatements, switchSqlOver } from './scope-do.js';
|
|
4
5
|
import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
|
|
5
|
-
import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, substratError } from '@substrat-run/contracts';
|
|
6
|
+
import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
|
|
6
7
|
/**
|
|
7
8
|
* The router's per-request directory read (`readRoute`), lifted out so its plan can be asked for
|
|
8
9
|
* (#2005): the scope's hostname set is a correlated subquery on the hot path every request takes,
|
|
@@ -71,6 +72,39 @@ function keysetTail(where, params, key, page, defaultOrder = 'asc') {
|
|
|
71
72
|
}
|
|
72
73
|
return tail;
|
|
73
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* The directory spine, as this adapter builds it — one of two hand-written copies (#969).
|
|
77
|
+
*
|
|
78
|
+
* The other is `applyDirectorySchema()` in `adapter-sqlite/src/index.ts`, and the two must
|
|
79
|
+
* describe the same schema: this side is production, that side is dev, CI, self-host and
|
|
80
|
+
* escrow. So a new directory or `_substrat_*` table both adapters need, or a new column on
|
|
81
|
+
* a table they both build, is added HERE and THERE — and for a directory created before it,
|
|
82
|
+
* to both column-addition lists as well (`ensureDirectoryColumns` below, and the pure
|
|
83
|
+
* adapter's method of the same name).
|
|
84
|
+
*
|
|
85
|
+
* `pnpm lint:spine-ddl` (`tools/spine-ddl-drift.mjs`) is what refuses a divergence: it
|
|
86
|
+
* executes each side's DDL plus those later ALTERs and compares the schemas a query would
|
|
87
|
+
* actually meet — columns, indexes (including the ones a UNIQUE creates) and foreign keys.
|
|
88
|
+
* Two things it does NOT judge. A table present on one side only is a note, not a failure,
|
|
89
|
+
* because the adapters legitimately partition the spine differently (this one projects
|
|
90
|
+
* tenant tuples, roles and entitlements into each scope where the pure adapter keeps one
|
|
91
|
+
* shared directory). And triggers and CHECK constraints are not compared at all; the spine
|
|
92
|
+
* has none today, so adding one here means adding it there with nothing to catch you.
|
|
93
|
+
* Keeping the copies and gating them, rather than moving the DDL into the kernel, is the
|
|
94
|
+
* recorded answer to #969; `docs/architecture/kernel-design.md` §8 says why.
|
|
95
|
+
*/
|
|
96
|
+
/** #1713: one hosted scope, its directory lifecycle, and what its deployment last acknowledged. */
|
|
97
|
+
/**
|
|
98
|
+
* #2016, Codex #2037 r2: the condition every delivery-bookkeeping write carries, in its own statement —
|
|
99
|
+
* the scope (bound as the last parameter) still exists, is not reaped, and its tenant is not reaped.
|
|
100
|
+
* A heal selects its targets, then writes its asks and its receipts after a network round trip; a
|
|
101
|
+
* delete or a reap that commits in between has already forgotten the scope's rows
|
|
102
|
+
* (`forgetLifecycleDeliveries`), and an unconditional write would bring them back as orphans.
|
|
103
|
+
*/
|
|
104
|
+
const STILL_DELIVERED = `EXISTS (
|
|
105
|
+
SELECT 1 FROM scopes s JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
106
|
+
WHERE s.scope_id = ? AND s.status <> 'reaped' AND t.status <> 'reaped'
|
|
107
|
+
)`;
|
|
74
108
|
const DIRECTORY_DDL = `
|
|
75
109
|
-- The ';' in this comment is a deliberate tripwire; the DDL must go through
|
|
76
110
|
-- splitSqlStatements, and a naive split(';') fails the directory at construction.
|
|
@@ -423,6 +457,9 @@ const DIRECTORY_DDL = `
|
|
|
423
457
|
);
|
|
424
458
|
${IMPERSONATION_DDL}
|
|
425
459
|
${SYSTEM_SWITCHES_DDL}
|
|
460
|
+
${MEMBERSHIP_FENCES_DDL}
|
|
461
|
+
${PEER_SWITCHES_DDL}
|
|
462
|
+
${SWITCH_OWED_DDL}
|
|
426
463
|
CREATE TABLE IF NOT EXISTS _substrat_admin_log (
|
|
427
464
|
id TEXT PRIMARY KEY,
|
|
428
465
|
actor TEXT NOT NULL,
|
|
@@ -573,10 +610,21 @@ const DIRECTORY_DDL = `
|
|
|
573
610
|
-- directory, so the platform delivers a scope's lifecycle to it, and the heal sweep
|
|
574
611
|
-- re-delivers wherever this differs from the directory. No row reads as active/active,
|
|
575
612
|
-- which is what a deployment holding no lifecycle runs as.
|
|
613
|
+
-- #2016: tenant_recorded, whether that deployment answered that the scope holds a record of its
|
|
614
|
+
-- tenant (1) or not (0); NULL is never asked. The heal keeps delivering to any served scope, a
|
|
615
|
+
-- copy as much as a primary, until it answers 1.
|
|
576
616
|
CREATE TABLE IF NOT EXISTS scope_lifecycle_receipts (
|
|
577
617
|
scope_id TEXT PRIMARY KEY,
|
|
578
618
|
delivered TEXT NOT NULL,
|
|
579
|
-
at TEXT NOT NULL
|
|
619
|
+
at TEXT NOT NULL,
|
|
620
|
+
tenant_recorded INTEGER
|
|
621
|
+
);
|
|
622
|
+
-- #2016: when the heal last asked a scope for its tenant record, whatever came of it. Kept apart
|
|
623
|
+
-- from the receipt, which only an answer writes, so a scope whose deployment never answers still
|
|
624
|
+
-- rotates behind the rest of the walk instead of heading it every pass.
|
|
625
|
+
CREATE TABLE IF NOT EXISTS scope_tenant_asks (
|
|
626
|
+
scope_id TEXT PRIMARY KEY,
|
|
627
|
+
asked_at TEXT NOT NULL
|
|
580
628
|
);
|
|
581
629
|
-- #1713: the lifecycle's revisions, one counter per subject ("scope:<id>" counts a
|
|
582
630
|
-- scope's status transitions, "tenant:<id>" a tenant's status changes), each bumped in
|
|
@@ -665,6 +713,19 @@ export const BACKFILL_FAILURES_KEY = 'versionMigrationsBackfillFailures';
|
|
|
665
713
|
export function backfillBackoffMs(failures) {
|
|
666
714
|
return Math.min(BACKFILL_PAUSE_MS * 2 ** failures, BACKFILL_BACKOFF_MAX_MS);
|
|
667
715
|
}
|
|
716
|
+
/** The directory writes `ControlPlaneDO.reply` answers for (#113): each refuses with a typed throw. */
|
|
717
|
+
export const REPLIED_METHODS = [
|
|
718
|
+
'createTenant',
|
|
719
|
+
'setTenantStatus',
|
|
720
|
+
'reapTenant',
|
|
721
|
+
'setTenantName',
|
|
722
|
+
'putTenantStore',
|
|
723
|
+
'putBlobStore',
|
|
724
|
+
'provisionScope',
|
|
725
|
+
'createOrg',
|
|
726
|
+
'registerIdentityPool',
|
|
727
|
+
'linkIdentity',
|
|
728
|
+
];
|
|
668
729
|
export class ControlPlaneDO extends DurableObject {
|
|
669
730
|
sql;
|
|
670
731
|
/** The directory's store as the kernel's SQL handle — the switch record's helpers (#1674). */
|
|
@@ -748,20 +809,30 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
748
809
|
* construction. A directory restore runs `buildDirectorySchema` inside its own transaction
|
|
749
810
|
* instead (`importDump`).
|
|
750
811
|
*
|
|
751
|
-
*
|
|
752
|
-
* creates its table, and its table is created in the SAME transaction as the
|
|
753
|
-
* (Copilot review): the gate is "the table does not exist yet", so a table committed
|
|
754
|
-
* of a backfill that then failed would read as already migrated on every later run. On
|
|
812
|
+
* Each kill switch's record (#1674, and #2029's for a peer) is backfilled from the admin log
|
|
813
|
+
* on a run that creates its table, and its table is created in the SAME transaction as the
|
|
814
|
+
* backfill (Copilot review): the gate is "the table does not exist yet", so a table committed
|
|
815
|
+
* ahead of a backfill that then failed would read as already migrated on every later run. On
|
|
755
816
|
* such a run its statements are held back from the loop below and run with the backfill.
|
|
756
817
|
*/
|
|
757
818
|
applyDirectorySchema() {
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
819
|
+
const newRecords = SWITCH_KINDS.filter((kind) => !switchesTableExists(this.kernelSql, kind));
|
|
820
|
+
const fencesAreNew = !membershipFencesTableExists(this.kernelSql);
|
|
821
|
+
this.buildDirectorySchema({ holdSwitchRecords: newRecords, holdFences: fencesAreNew });
|
|
822
|
+
for (const kind of newRecords) {
|
|
823
|
+
this.ctx.storage.transactionSync(() => {
|
|
824
|
+
for (const stmt of splitSqlStatements(switchesDdlOf(kind)))
|
|
825
|
+
this.sql.exec(stmt);
|
|
826
|
+
this.sql.exec(switchesBackfillSqlOf(kind));
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
// #1184: the removal fence and its backfill from the admin log, together, the same way.
|
|
830
|
+
if (fencesAreNew) {
|
|
761
831
|
this.ctx.storage.transactionSync(() => {
|
|
762
|
-
for (const stmt of splitSqlStatements(
|
|
832
|
+
for (const stmt of splitSqlStatements(MEMBERSHIP_FENCES_DDL))
|
|
763
833
|
this.sql.exec(stmt);
|
|
764
|
-
this
|
|
834
|
+
// Stamped now, inside this unit (`membershipFencesBackfillSql` says why).
|
|
835
|
+
this.sql.exec(membershipFencesBackfillSql(new Date().toISOString()));
|
|
765
836
|
});
|
|
766
837
|
}
|
|
767
838
|
}
|
|
@@ -769,12 +840,15 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
769
840
|
* The directory's tables as this code builds them, carrying whatever the directory already
|
|
770
841
|
* holds forward to that shape: the construction's pass, and a restore's (#1898, #1912), which
|
|
771
842
|
* runs it inside its transaction onto an emptied directory, before loading any row.
|
|
772
|
-
* `
|
|
773
|
-
* table with its backfill.
|
|
843
|
+
* `holdSwitchRecords` leaves those kinds' record statements (#1674, #2029) to the caller, which
|
|
844
|
+
* creates each table with its backfill; `holdFences` does the same for #1184's removal fence.
|
|
774
845
|
*/
|
|
775
|
-
buildDirectorySchema({
|
|
846
|
+
buildDirectorySchema({ holdSwitchRecords, holdFences = false }) {
|
|
847
|
+
const held = holdSwitchRecords.map(switchesTableOf);
|
|
776
848
|
for (const stmt of DIRECTORY_DDL_PLAN.loop) {
|
|
777
|
-
if (
|
|
849
|
+
if (held.some((table) => stmt.includes(table)))
|
|
850
|
+
continue;
|
|
851
|
+
if (holdFences && stmt.includes(MEMBERSHIP_FENCES_TABLE))
|
|
778
852
|
continue;
|
|
779
853
|
this.sql.exec(stmt);
|
|
780
854
|
}
|
|
@@ -930,6 +1004,8 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
930
1004
|
}
|
|
931
1005
|
// §4.8's grace-window timestamp on tenants (mirrors scopes' archived_at).
|
|
932
1006
|
this.addColumn('tenants', 'deleting_at TEXT');
|
|
1007
|
+
// #2016: whether a scope's deployment holds a record of its tenant (see the table's DDL).
|
|
1008
|
+
this.addColumn('scope_lifecycle_receipts', 'tenant_recorded INTEGER');
|
|
933
1009
|
// #412 provenance: the manager tenant that provisioned this one (NULL = direct staff).
|
|
934
1010
|
this.addColumn('tenants', 'provisioned_by_tenant TEXT REFERENCES tenants(tenant_id)');
|
|
935
1011
|
// §4.7 custom-hostname issuance: CF's hostname id + the DNS records to publish (JSON).
|
|
@@ -1089,12 +1165,13 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1089
1165
|
// Every table from this code's DDL, onto an empty directory, so none of the pass's
|
|
1090
1166
|
// rebuilds fires (each would reach `transactionSync`, which this async transaction cannot
|
|
1091
1167
|
// hold): every table is created on the current shape.
|
|
1092
|
-
this.buildDirectorySchema({
|
|
1168
|
+
this.buildDirectorySchema({ holdSwitchRecords: [] });
|
|
1093
1169
|
// Refuses a table this code does not build, adds unknown columns bare, loads the rows by
|
|
1094
1170
|
// name, then the legacy scope and #1674 backfills, all inside this transaction.
|
|
1095
1171
|
loadDirectoryDump(tables, {
|
|
1096
1172
|
columnsOf: (name) => doBuiltColumnsOf(this.sql, name),
|
|
1097
1173
|
exec: (sql) => this.sql.exec(sql),
|
|
1174
|
+
now: () => new Date().toISOString(),
|
|
1098
1175
|
maxColumns: DO_SQL_LIMITS.columns,
|
|
1099
1176
|
insert: (sql, rows) => {
|
|
1100
1177
|
for (const row of rows)
|
|
@@ -1153,7 +1230,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1153
1230
|
.exec('SELECT tenant_id FROM tenants WHERE slug = ?', slug)
|
|
1154
1231
|
.toArray()[0];
|
|
1155
1232
|
if (slugOwner) {
|
|
1156
|
-
throw
|
|
1233
|
+
throw substratError('conflict', `tenant slug '${slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
|
|
1157
1234
|
}
|
|
1158
1235
|
this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
|
|
1159
1236
|
VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant);
|
|
@@ -1163,12 +1240,12 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1163
1240
|
setTenantStatus(tenantId, status) {
|
|
1164
1241
|
const before = this.readTenant(tenantId);
|
|
1165
1242
|
if (!before)
|
|
1166
|
-
throw
|
|
1243
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
1167
1244
|
// `reaped` is terminal and destroys data — unreachable here, only ever set by
|
|
1168
1245
|
// reapTenant, so a plain status flip cannot forge a tombstone over live data
|
|
1169
1246
|
// (§4.8, the tenant analogue of reapScope's archived-only gate).
|
|
1170
1247
|
if (status === 'reaped') {
|
|
1171
|
-
throw
|
|
1248
|
+
throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
|
|
1172
1249
|
}
|
|
1173
1250
|
// Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
|
|
1174
1251
|
// entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
|
|
@@ -1195,9 +1272,9 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1195
1272
|
reapTenant(tenantId) {
|
|
1196
1273
|
const before = this.readTenant(tenantId);
|
|
1197
1274
|
if (!before)
|
|
1198
|
-
throw
|
|
1275
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
1199
1276
|
if (before.status !== 'deleting') {
|
|
1200
|
-
throw
|
|
1277
|
+
throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
|
|
1201
1278
|
}
|
|
1202
1279
|
// KEPT as the tombstone: the `tenants` row itself and `_substrat_admin_log` (the
|
|
1203
1280
|
// compliance witness — never swept). Scope rows were already reaped individually.
|
|
@@ -1209,11 +1286,16 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1209
1286
|
'_substrat_entitlements', // per-tenant SKU flags
|
|
1210
1287
|
'orgs', // K-22 org records
|
|
1211
1288
|
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
1289
|
+
'_substrat_membership_fences', // #1184: the latest removal, per principal
|
|
1290
|
+
'_substrat_peer_switches', // #2029: the peer switch's record, per scope
|
|
1291
|
+
'_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
|
|
1212
1292
|
]) {
|
|
1213
1293
|
this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
|
|
1214
1294
|
}
|
|
1215
1295
|
this.ctx.storage.transactionSync(() => {
|
|
1216
1296
|
this.sql.exec("UPDATE tenants SET status = 'reaped', deleting_at = NULL WHERE tenant_id = ?", tenantId);
|
|
1297
|
+
// Its scopes were reaped one by one, each forgetting its own; any left over go here.
|
|
1298
|
+
this.forgetLifecycleDeliveries('scope_id IN (SELECT scope_id FROM scopes WHERE tenant_id = ?)', tenantId);
|
|
1217
1299
|
this.bumpLifecycleRevision(`tenant:${tenantId}`); // #1713
|
|
1218
1300
|
});
|
|
1219
1301
|
return before.status;
|
|
@@ -1222,7 +1304,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1222
1304
|
setTenantName(tenantId, name) {
|
|
1223
1305
|
const before = this.readTenant(tenantId);
|
|
1224
1306
|
if (!before)
|
|
1225
|
-
throw
|
|
1307
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
1226
1308
|
this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId);
|
|
1227
1309
|
return before.name;
|
|
1228
1310
|
}
|
|
@@ -1256,10 +1338,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1256
1338
|
.exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
|
|
1257
1339
|
.toArray()[0];
|
|
1258
1340
|
if (!tenantRow) {
|
|
1259
|
-
throw
|
|
1341
|
+
throw substratError('not_found', `cannot provision tenant store under unknown tenant: ${row.tenantId}`);
|
|
1260
1342
|
}
|
|
1261
1343
|
if (tenantRow.status !== 'active') {
|
|
1262
|
-
throw
|
|
1344
|
+
throw substratError('conflict', `cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
|
|
1263
1345
|
}
|
|
1264
1346
|
this.sql.exec(`INSERT OR IGNORE INTO tenant_stores (tenant_id, vertical, binding, kind, ref, created_at)
|
|
1265
1347
|
VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
|
|
@@ -1300,10 +1382,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1300
1382
|
.exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
|
|
1301
1383
|
.toArray()[0];
|
|
1302
1384
|
if (!tenantRow) {
|
|
1303
|
-
throw
|
|
1385
|
+
throw substratError('not_found', `cannot provision blob store under unknown tenant: ${row.tenantId}`);
|
|
1304
1386
|
}
|
|
1305
1387
|
if (tenantRow.status !== 'active') {
|
|
1306
|
-
throw
|
|
1388
|
+
throw substratError('conflict', `cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
|
|
1307
1389
|
}
|
|
1308
1390
|
this.sql.exec(`INSERT OR IGNORE INTO blob_stores (tenant_id, vertical, binding, kind, ref, created_at)
|
|
1309
1391
|
VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
|
|
@@ -1340,10 +1422,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1340
1422
|
.exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
|
|
1341
1423
|
.toArray()[0];
|
|
1342
1424
|
if (!tenantRow) {
|
|
1343
|
-
throw
|
|
1425
|
+
throw substratError('conflict', `cannot provision scope under unknown tenant: ${tenantId}`);
|
|
1344
1426
|
}
|
|
1345
1427
|
if (tenantRow.status !== 'active') {
|
|
1346
|
-
throw
|
|
1428
|
+
throw substratError('conflict', `cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${tenantId}`);
|
|
1347
1429
|
}
|
|
1348
1430
|
// Idempotency is on the scope_id (§3.3: idempotent and journaled — safe to
|
|
1349
1431
|
// re-run), so an existing scope short-circuits before the slug check:
|
|
@@ -1356,7 +1438,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1356
1438
|
.exec("SELECT scope_id FROM scopes WHERE tenant_id = ? AND slug = ? AND status NOT IN ('archived', 'reaped')", tenantId, record.slug)
|
|
1357
1439
|
.toArray()[0];
|
|
1358
1440
|
if (slugOwner) {
|
|
1359
|
-
throw
|
|
1441
|
+
throw substratError('conflict', `scope slug '${record.slug}' already taken under tenant ${tenantId} ` +
|
|
1360
1442
|
`by ${slugOwner.scope_id} (slugs are unique within a tenant)`);
|
|
1361
1443
|
}
|
|
1362
1444
|
// A scope born while its vertical serves in place (#286) is born ON the serving
|
|
@@ -1438,6 +1520,21 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1438
1520
|
return undefined;
|
|
1439
1521
|
return row;
|
|
1440
1522
|
}
|
|
1523
|
+
/**
|
|
1524
|
+
* A directory write, its refusal answered as DATA (#113): the typed throws in these methods
|
|
1525
|
+
* would reach the coordinator flattened, so it calls them through here and rethrows the
|
|
1526
|
+
* refusal with its code (`unwrapReply`). Called directly, they still throw — a coordinator
|
|
1527
|
+
* from before this change does — and that caller reads the code folded into the message.
|
|
1528
|
+
*/
|
|
1529
|
+
async reply(method, args) {
|
|
1530
|
+
return replyOf(() => {
|
|
1531
|
+
// An RPC argument, so held to the list rather than trusted to be one of its names.
|
|
1532
|
+
if (!REPLIED_METHODS.includes(method))
|
|
1533
|
+
throw new Error(`not a replied directory method: ${String(method)}`);
|
|
1534
|
+
const run = this[method];
|
|
1535
|
+
return run.apply(this, args);
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1441
1538
|
/**
|
|
1442
1539
|
* The getScope gate (control-plane.md §4.1/§4.2): validate the scope belongs
|
|
1443
1540
|
* to the tenant and both records are active, or throw the fail-closed reason.
|
|
@@ -1454,11 +1551,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1454
1551
|
* The same gate, answering its refusal as DATA (#1718). An error thrown in a Durable
|
|
1455
1552
|
* Object arrives at the coordinator flattened, its code gone, so a typed throw here
|
|
1456
1553
|
* would still reach the caller untyped (#1714 measured it). A record crosses intact, so
|
|
1457
|
-
* the coordinator throws the refusal itself, typed
|
|
1458
|
-
*
|
|
1554
|
+
* the coordinator throws the refusal itself, typed (#113: every refusal but a failed
|
|
1555
|
+
* migration carries its code). Null means access is allowed.
|
|
1459
1556
|
*/
|
|
1460
1557
|
scopeAccessRefusal(tenantId, scopeId) {
|
|
1461
|
-
const refuse = (message) => ({ code: null, message });
|
|
1462
1558
|
const row = this.sql
|
|
1463
1559
|
.exec('SELECT tenant_id, status FROM scopes WHERE scope_id = ?', scopeId)
|
|
1464
1560
|
.toArray()[0];
|
|
@@ -1469,10 +1565,14 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1469
1565
|
.exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
|
|
1470
1566
|
.toArray()[0];
|
|
1471
1567
|
if (!tenantRow) {
|
|
1472
|
-
return
|
|
1568
|
+
return { code: 'not_found', message: `scope has no tenant record: (${tenantId}, ${scopeId})`, reason: SCOPE_GATE_REASONS.unrecorded };
|
|
1473
1569
|
}
|
|
1474
1570
|
if (tenantRow.status !== 'active') {
|
|
1475
|
-
return
|
|
1571
|
+
return {
|
|
1572
|
+
code: 'conflict',
|
|
1573
|
+
message: `tenant not active (status: ${tenantRow.status}): ${tenantId}`,
|
|
1574
|
+
reason: SCOPE_GATE_REASONS.notActive,
|
|
1575
|
+
};
|
|
1476
1576
|
}
|
|
1477
1577
|
if (row.status !== 'active') {
|
|
1478
1578
|
// A scope stuck in provisioning because its migrations failed must say so.
|
|
@@ -1483,10 +1583,17 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1483
1583
|
.exec('SELECT migration_failed_version, migration_error FROM scopes WHERE scope_id = ?', scopeId)
|
|
1484
1584
|
.toArray()[0];
|
|
1485
1585
|
if (failure?.migration_failed_version) {
|
|
1486
|
-
return
|
|
1487
|
-
|
|
1586
|
+
return {
|
|
1587
|
+
code: null,
|
|
1588
|
+
message: `migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
|
|
1589
|
+
`${failure.migration_error ?? 'unknown error'}`,
|
|
1590
|
+
};
|
|
1488
1591
|
}
|
|
1489
|
-
return
|
|
1592
|
+
return {
|
|
1593
|
+
code: 'conflict',
|
|
1594
|
+
message: `scope not active (status: ${row.status}): ${scopeId}`,
|
|
1595
|
+
reason: SCOPE_GATE_REASONS.notActive,
|
|
1596
|
+
};
|
|
1490
1597
|
}
|
|
1491
1598
|
return null;
|
|
1492
1599
|
}
|
|
@@ -1521,7 +1628,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1521
1628
|
if (!from.includes(row.status)) {
|
|
1522
1629
|
return {
|
|
1523
1630
|
ok: false,
|
|
1524
|
-
code:
|
|
1631
|
+
code: 'conflict',
|
|
1525
1632
|
message: `illegal scope transition for ${action}: ${row.status} → ${to} ` +
|
|
1526
1633
|
`(allowed from: ${from.join('|')})`,
|
|
1527
1634
|
};
|
|
@@ -1541,8 +1648,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1541
1648
|
else {
|
|
1542
1649
|
this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
|
|
1543
1650
|
}
|
|
1544
|
-
if (to === 'reaped')
|
|
1545
|
-
|
|
1651
|
+
if (to === 'reaped') {
|
|
1652
|
+
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
1653
|
+
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
1654
|
+
}
|
|
1546
1655
|
this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
|
|
1547
1656
|
});
|
|
1548
1657
|
return { ok: true, status: row.status, vertical: row.vertical };
|
|
@@ -1948,6 +2057,11 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1948
2057
|
* for a repair that failed, which is the one outcome that must keep being retried.
|
|
1949
2058
|
*/
|
|
1950
2059
|
markScopeProvisioned(scopeId, versionId) {
|
|
2060
|
+
// #2045: a scope owed a switch re-assert keeps no receipt, so the sweep reconciles it again
|
|
2061
|
+
// until its re-assert has settled every mark — including a mark written while this
|
|
2062
|
+
// reconcile ran, after its re-assert had read none.
|
|
2063
|
+
if (versionId !== null && scopeOwesSwitch(this.kernelSql, scopeId))
|
|
2064
|
+
versionId = null;
|
|
1951
2065
|
this.sql.exec('UPDATE scopes SET provisioned_version_id = ? WHERE scope_id = ?', versionId, scopeId);
|
|
1952
2066
|
}
|
|
1953
2067
|
/**
|
|
@@ -1956,9 +2070,24 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1956
2070
|
* wiping the scope DO's storage — the fork-only refusal lives there, not here.
|
|
1957
2071
|
*/
|
|
1958
2072
|
deleteScopeDirectory(scopeId) {
|
|
1959
|
-
this.
|
|
1960
|
-
|
|
1961
|
-
|
|
2073
|
+
this.ctx.storage.transactionSync(() => {
|
|
2074
|
+
this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
|
|
2075
|
+
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
2076
|
+
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
2077
|
+
this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* #1713 / #2016: drop the delivery bookkeeping of scopes that will never be delivered to again —
|
|
2082
|
+
* what each deployment acknowledged (`scope_lifecycle_receipts`) and when the heal last asked it
|
|
2083
|
+
* for its tenant record (`scope_tenant_asks`). A hard-deleted fork's rows would otherwise outlive
|
|
2084
|
+
* it, and every preview created and deleted would grow both tables, and every directory dump, for
|
|
2085
|
+
* good. A reaped scope keeps its tombstone row but leaves every delivery walk, so its go too.
|
|
2086
|
+
*/
|
|
2087
|
+
forgetLifecycleDeliveries(where, ...params) {
|
|
2088
|
+
for (const table of ['scope_lifecycle_receipts', 'scope_tenant_asks']) {
|
|
2089
|
+
this.sql.exec(`DELETE FROM ${table} WHERE ${where}`, ...params);
|
|
2090
|
+
}
|
|
1962
2091
|
}
|
|
1963
2092
|
readChannel(verticalSlug, channel) {
|
|
1964
2093
|
return this.sql
|
|
@@ -2010,7 +2139,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2010
2139
|
.exec('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?', tenantId, slug)
|
|
2011
2140
|
.toArray()[0];
|
|
2012
2141
|
if (slugOwner) {
|
|
2013
|
-
throw
|
|
2142
|
+
throw substratError('conflict', `org slug '${slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
|
|
2014
2143
|
}
|
|
2015
2144
|
this.sql.exec('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)', orgId, tenantId, slug, name, createdAt);
|
|
2016
2145
|
return true;
|
|
@@ -2020,13 +2149,69 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2020
2149
|
.exec('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug', tenantId)
|
|
2021
2150
|
.toArray();
|
|
2022
2151
|
}
|
|
2152
|
+
/** The tenant-level reads, synchronous, for a bound asked inside one of this object's units (#1184). */
|
|
2153
|
+
directoryReader = {
|
|
2154
|
+
now: () => new Date().toISOString(),
|
|
2155
|
+
tenantTuples: (tenantId, subject, prefix) => this.tenantTuples(tenantId, subject, prefix),
|
|
2156
|
+
getRole: (tenantId, key) => this.getRole(tenantId, key),
|
|
2157
|
+
};
|
|
2023
2158
|
/**
|
|
2024
|
-
*
|
|
2025
|
-
*
|
|
2026
|
-
*
|
|
2159
|
+
* A membership change as ONE DO unit (#1184) — `HostAdmin.applyMembership`. Synchronous, so
|
|
2160
|
+
* no other request into this object runs between its reads and its writes: an add's removal
|
|
2161
|
+
* fence, then the bound — `boundedBy` covering every permission `roleKey` carries, against the
|
|
2162
|
+
* tuples and the role as they stand now (`tenantCoverage`) — then the write: the TENANT role
|
|
2163
|
+
* and its audit row for an add, `revokeAndFence`'s three for a removal. The coordinator mints
|
|
2164
|
+
* the row (id, actor, attribution, `causedBy`).
|
|
2027
2165
|
*/
|
|
2028
|
-
|
|
2029
|
-
|
|
2166
|
+
applyMembership(change, row) {
|
|
2167
|
+
const { tenantId, principal, roleKey, boundedBy } = change;
|
|
2168
|
+
return this.ctx.storage.transactionSync(() => {
|
|
2169
|
+
if (change.op === 'add') {
|
|
2170
|
+
const fence = this.sql.exec(MEMBERSHIP_FENCE_SINCE_SQL, tenantId, principal, change.unlessRemovedSince).toArray()[0];
|
|
2171
|
+
if (fence)
|
|
2172
|
+
return { applied: false, removedAt: fence.removed_at };
|
|
2173
|
+
}
|
|
2174
|
+
const role = this.getRole(tenantId, roleKey);
|
|
2175
|
+
if (!role)
|
|
2176
|
+
return { applied: false, unknownRole: roleKey };
|
|
2177
|
+
const bound = tenantCoverage(this.directoryReader, tenantId, boundedBy, role.permissions);
|
|
2178
|
+
if (!bound.covered)
|
|
2179
|
+
return { applied: false, missing: bound.missing };
|
|
2180
|
+
if (change.op === 'remove') {
|
|
2181
|
+
return { applied: true, changed: this.revokeAndFenceIn(tenantId, principal, `role:${roleKey}`, `tenant:${tenantId}`, row) };
|
|
2182
|
+
}
|
|
2183
|
+
this.writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`, null);
|
|
2184
|
+
this.recordAdmin({ ...row, at: new Date().toISOString() });
|
|
2185
|
+
return { applied: true };
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* A tenant-level removal as ONE DO unit (#1184) — `unassignRole` at the tenant node, and
|
|
2190
|
+
* `removeMember`: the tombstone, the principal's removal fence, and the audit row, which
|
|
2191
|
+
* K-21 writes only when something changed. The fence is raised either way: a removal of
|
|
2192
|
+
* someone whose add is still on its way takes nothing, and must still win. One method, so
|
|
2193
|
+
* no add can run between the revoke and the fence. Returns whether anything changed.
|
|
2194
|
+
*/
|
|
2195
|
+
revokeAndFence(tenantId, principal, relation, object, row) {
|
|
2196
|
+
return this.ctx.storage.transactionSync(() => this.revokeAndFenceIn(tenantId, principal, relation, object, row));
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* `revokeAndFence`'s writes, for inside a transaction a caller already holds.
|
|
2200
|
+
*
|
|
2201
|
+
* The time is minted HERE, in the unit, never taken from `row.at`: the coordinator mints its
|
|
2202
|
+
* row before an awaited RPC, and a queue delay in between would age the fence by however
|
|
2203
|
+
* long the call waited, which an add's cutoff would read as a removal from before its window
|
|
2204
|
+
* (Codex round 3). The fence is compared with an add's `occurredAt`, stamped by the ScopeDO's
|
|
2205
|
+
* clock. Both are this platform's own NTP-disciplined time, sub-second apart, which is far
|
|
2206
|
+
* inside `MEMBERSHIP_REMOVAL_SKEW_MS`.
|
|
2207
|
+
*/
|
|
2208
|
+
revokeAndFenceIn(tenantId, principal, relation, object, row) {
|
|
2209
|
+
const at = new Date().toISOString();
|
|
2210
|
+
const changed = this.revokeTenantTuple(tenantId, `principal:${principal}`, relation, object, at);
|
|
2211
|
+
this.sql.exec(RAISE_MEMBERSHIP_FENCE_SQL, tenantId, principal, at);
|
|
2212
|
+
if (changed)
|
|
2213
|
+
this.recordAdmin({ ...row, at });
|
|
2214
|
+
return changed;
|
|
2030
2215
|
}
|
|
2031
2216
|
/** Tombstone any tenant tuple by its exact (subject, relation, object). Returns
|
|
2032
2217
|
* whether a live row changed, so a repeat revoke is a silent no-op. */
|
|
@@ -2183,7 +2368,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2183
2368
|
if (existing) {
|
|
2184
2369
|
if (existing.topology === topology && existing.tenant_id === tenantId)
|
|
2185
2370
|
return false;
|
|
2186
|
-
throw
|
|
2371
|
+
throw substratError('conflict', `identity pool '${provider}' is already registered as ${existing.topology}` +
|
|
2187
2372
|
`${existing.tenant_id ? ` for tenant ${existing.tenant_id}` : ''}`);
|
|
2188
2373
|
}
|
|
2189
2374
|
this.sql.exec('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)', provider, topology, tenantId, createdAt);
|
|
@@ -2386,30 +2571,74 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2386
2571
|
granted_by, granted_at, revoked_at)
|
|
2387
2572
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
|
|
2388
2573
|
}
|
|
2389
|
-
// -- the
|
|
2390
|
-
/** OFF's write,
|
|
2391
|
-
|
|
2392
|
-
|
|
2574
|
+
// -- the kill switches' record (#1674, #2029) — `system-switch-record.ts` is the whole rule ---
|
|
2575
|
+
/** OFF's write, before the scope's switch moves (#1823). Answers the row as it was. */
|
|
2576
|
+
recordSwitchedOff(row) {
|
|
2577
|
+
return this.writeSwitchIntent(row, recordSwitchedOff);
|
|
2393
2578
|
}
|
|
2394
|
-
/** ON's write, before the scope moves. Answers the row as it was, for a
|
|
2395
|
-
|
|
2396
|
-
return
|
|
2579
|
+
/** ON's write, before the scope moves. Answers the row as it was, for a call that held nothing to restore. */
|
|
2580
|
+
recordSwitchedOn(row) {
|
|
2581
|
+
return this.writeSwitchIntent(row, recordSwitchedOn);
|
|
2397
2582
|
}
|
|
2398
|
-
/**
|
|
2399
|
-
|
|
2400
|
-
|
|
2583
|
+
/**
|
|
2584
|
+
* #2045 (Codex r3): a switch call's record write is also its write-ahead intent, in ONE
|
|
2585
|
+
* transaction: the record, the subject's owed mark under this call's id, and the scope's reconcile
|
|
2586
|
+
* receipt cleared, so the sweep reconciles it and its re-assert settles the mark. Nothing that
|
|
2587
|
+
* happens to the move after this (a throw, a lost answer, a failed clear) can leave the scope
|
|
2588
|
+
* apart from its record with no mark to repair it. A write a newer call's row refuses writes
|
|
2589
|
+
* nothing at all.
|
|
2590
|
+
*/
|
|
2591
|
+
writeSwitchIntent(row, write) {
|
|
2592
|
+
return this.ctx.storage.transactionSync(() => {
|
|
2593
|
+
const prior = write(this.kernelSql, row);
|
|
2594
|
+
if (recordWriteSuperseded(prior, row))
|
|
2595
|
+
return prior;
|
|
2596
|
+
markSwitchOwed(this.kernelSql, row.kind, row.tenantId, row.scopeId, row.key, row.operationId);
|
|
2597
|
+
this.sql.exec('UPDATE scopes SET provisioned_version_id = NULL WHERE scope_id = ?', row.scopeId);
|
|
2598
|
+
return prior;
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Put a row back as the switch call's record write found it — the move that followed it held
|
|
2603
|
+
* nothing. #2045: with `clearOwed`, the call's own owed mark goes in the same transaction. The
|
|
2604
|
+
* row put back carries the PRIOR call's operation id, which is the fence every later re-assert
|
|
2605
|
+
* confirms under, and a mark newer than that fence is never cleared by one; so a mark left behind
|
|
2606
|
+
* by a separate clear that failed would keep the scope un-receipted on every pass.
|
|
2607
|
+
*/
|
|
2608
|
+
restoreSwitchRecord(key, prior, clearOwed = false) {
|
|
2609
|
+
this.ctx.storage.transactionSync(() => {
|
|
2610
|
+
restoreSwitchRecord(this.kernelSql, key, prior);
|
|
2611
|
+
if (clearOwed)
|
|
2612
|
+
clearSwitchOwed(this.kernelSql, key.kind, key.tenantId, key.scopeId, key.key, key.operationId);
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* #1823 (#2030 for a peer): which of these subjects the tenant holds a live TENANT-level grant
|
|
2617
|
+
* for — what the switch passes the scope as `tenantHeld`, so a subject whose only authority
|
|
2618
|
+
* there is a tenant tuple is still switchable. Read here, where the tenant tuples live.
|
|
2619
|
+
*/
|
|
2620
|
+
tenantHeldOf(kind, tenantId, keys, now) {
|
|
2621
|
+
return tenantHeldOf(this.kernelSql, kind, tenantId, keys, now);
|
|
2401
2622
|
}
|
|
2402
2623
|
/** The fleet read. */
|
|
2403
2624
|
listSystemSwitches(filter) {
|
|
2404
2625
|
return listSystemSwitchRecords(this.kernelSql, filter);
|
|
2405
2626
|
}
|
|
2406
|
-
/** One scope's recorded positions, by
|
|
2407
|
-
|
|
2408
|
-
return [...
|
|
2627
|
+
/** One scope's recorded positions of one kind, by subject — the status read's `recorded` join. */
|
|
2628
|
+
switchRecordsOf(kind, tenantId, scopeId) {
|
|
2629
|
+
return [...switchRecordsOf(this.kernelSql, kind, tenantId, scopeId)];
|
|
2630
|
+
}
|
|
2631
|
+
/** #2045: each record row's position and fence, by subject, read together — `switchRecordStatesOf`. */
|
|
2632
|
+
switchRecordStatesOf(kind, tenantId, scopeId) {
|
|
2633
|
+
return [...switchRecordStatesOf(this.kernelSql, kind, tenantId, scopeId)];
|
|
2634
|
+
}
|
|
2635
|
+
/** #2045: the subjects of one kind owed a re-assert on one scope. */
|
|
2636
|
+
switchesOwedOf(kind, tenantId, scopeId) {
|
|
2637
|
+
return [...switchesOwedOf(this.kernelSql, kind, tenantId, scopeId)];
|
|
2409
2638
|
}
|
|
2410
|
-
/**
|
|
2411
|
-
|
|
2412
|
-
|
|
2639
|
+
/** #2045: the scope confirmed a move of this subject under `fence` — `clearSwitchOwed`. */
|
|
2640
|
+
clearSwitchOwed(kind, tenantId, scopeId, key, fence) {
|
|
2641
|
+
clearSwitchOwed(this.kernelSql, kind, tenantId, scopeId, key, fence);
|
|
2413
2642
|
}
|
|
2414
2643
|
/** The tenant's LIVE connection grants (#592) — the provision/reconcile gather read. */
|
|
2415
2644
|
listConnectionGrants(tenantId) {
|
|
@@ -2473,7 +2702,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2473
2702
|
if (existing) {
|
|
2474
2703
|
if (existing.principal_id === principal)
|
|
2475
2704
|
return false;
|
|
2476
|
-
throw
|
|
2705
|
+
throw substratError('conflict', `identity ${provider}:${externalId} in tenant ${tenantId} is already bound to ${existing.principal_id}`);
|
|
2477
2706
|
}
|
|
2478
2707
|
this.sql.exec(`INSERT INTO _substrat_identities
|
|
2479
2708
|
(provider, external_id, principal_id, tenant_id, scope_id, created_at)
|
|
@@ -2733,17 +2962,28 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2733
2962
|
where.push(`(${drifted} OR s.status NOT IN ('active', 'archived') OR t.status <> 'active')`);
|
|
2734
2963
|
params.push(...driftParams);
|
|
2735
2964
|
}
|
|
2736
|
-
|
|
2965
|
+
if (filter.unrecorded)
|
|
2966
|
+
where.push('COALESCE(r.tenant_recorded, 0) <> 1');
|
|
2967
|
+
// The unrecorded walk rotates on the ask, not the answer: never asked first, then the one asked
|
|
2968
|
+
// longest ago, so a scope that answers 0, or never answers at all, cannot starve the rest of a
|
|
2969
|
+
// bounded pass.
|
|
2970
|
+
const order = filter.unrecorded
|
|
2971
|
+
? 'a.asked_at IS NOT NULL, a.asked_at, s.scope_id'
|
|
2972
|
+
: `CASE WHEN ${drifted} THEN 0 ELSE 1 END, s.scope_id`;
|
|
2973
|
+
if (!filter.unrecorded)
|
|
2974
|
+
params.push(...driftParams);
|
|
2975
|
+
params.push(filter.limit ?? 1000);
|
|
2737
2976
|
return this.sql
|
|
2738
2977
|
.exec(`SELECT s.tenant_id, s.scope_id, s.status AS scope_status, t.status AS tenant_status, r.delivered,
|
|
2739
2978
|
${epoch} AS epoch, COALESCE(rs.revision, 0) AS scope_rev, COALESCE(rt.revision, 0) AS tenant_rev
|
|
2740
2979
|
FROM scopes s
|
|
2741
2980
|
JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
2742
2981
|
LEFT JOIN scope_lifecycle_receipts r ON r.scope_id = s.scope_id
|
|
2982
|
+
LEFT JOIN scope_tenant_asks a ON a.scope_id = s.scope_id
|
|
2743
2983
|
LEFT JOIN lifecycle_revisions rs ON rs.subject = 'scope:' || s.scope_id
|
|
2744
2984
|
LEFT JOIN lifecycle_revisions rt ON rt.subject = 'tenant:' || s.tenant_id
|
|
2745
2985
|
WHERE ${where.join(' AND ')}
|
|
2746
|
-
ORDER BY
|
|
2986
|
+
ORDER BY ${order}
|
|
2747
2987
|
LIMIT ?`, ...params)
|
|
2748
2988
|
.toArray();
|
|
2749
2989
|
}
|
|
@@ -2774,9 +3014,17 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2774
3014
|
bumpLifecycleRevision(subject) {
|
|
2775
3015
|
this.sql.exec('INSERT INTO lifecycle_revisions (subject, revision) VALUES (?, 1) ON CONFLICT (subject) DO UPDATE SET revision = revision + 1', subject);
|
|
2776
3016
|
}
|
|
2777
|
-
/** #
|
|
2778
|
-
|
|
2779
|
-
|
|
3017
|
+
/** #2016: note that the heal asked these scopes for their tenant record at `at`, before it asks. */
|
|
3018
|
+
recordTenantAsks(scopeIds, at) {
|
|
3019
|
+
for (const scopeId of scopeIds) {
|
|
3020
|
+
this.sql.exec(`INSERT OR REPLACE INTO scope_tenant_asks (scope_id, asked_at) SELECT ?, ? WHERE ${STILL_DELIVERED}`, scopeId, at, scopeId);
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
/** #1713: record what a scope's deployment acknowledged holding (`lifecycleReceipt`), and #2016:
|
|
3024
|
+
* whether it answered that the scope holds a record of its tenant. */
|
|
3025
|
+
recordLifecycleReceipt(scopeId, delivered, at, tenantRecorded = false) {
|
|
3026
|
+
this.sql.exec(`INSERT OR REPLACE INTO scope_lifecycle_receipts (scope_id, delivered, at, tenant_recorded)
|
|
3027
|
+
SELECT ?, ?, ?, ? WHERE ${STILL_DELIVERED}`, scopeId, delivered, at, tenantRecorded ? 1 : 0, scopeId);
|
|
2780
3028
|
}
|
|
2781
3029
|
recordOpsFailure(row) {
|
|
2782
3030
|
this.sql.exec(`INSERT INTO _substrat_ops_failures
|