@substrat-run/adapter-cloudflare 0.135.0 → 0.137.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.map +1 -1
- package/dist/checker.js +3 -1
- package/dist/checker.js.map +1 -1
- package/dist/connector-calls.d.ts +11 -0
- package/dist/connector-calls.d.ts.map +1 -0
- package/dist/connector-calls.js +10 -0
- package/dist/connector-calls.js.map +1 -0
- package/dist/control-plane-do.d.ts +77 -3
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +250 -35
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +297 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +895 -132
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/live-reads.d.ts +5 -4
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +5 -4
- package/dist/live-reads.js.map +1 -1
- package/dist/r2.d.ts +2 -1
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js.map +1 -1
- package/dist/route-resolver.d.ts +7 -0
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +4 -0
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +2 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +896 -140
- package/dist/scope-do.js.map +1 -1
- package/dist/scope-sweeper-do.d.ts +32 -6
- package/dist/scope-sweeper-do.d.ts.map +1 -1
- package/dist/scope-sweeper-do.js +35 -5
- package/dist/scope-sweeper-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 +10 -9
package/dist/control-plane-do.js
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
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, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_TABLE, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, scopesSwitchedOffFor, switchedOffModulesOf, systemSwitchRecordsOf, systemSwitchesTableExists, VERSION_MIGRATIONS_DDL, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid,
|
|
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, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_TABLE, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, scopesSwitchedOffFor, tenantHoldsSystemGrant, switchedOffModulesOf, systemSwitchRecordsOf, systemSwitchesTableExists, 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
3
|
import { splitSqlStatements, switchSqlOver } from './scope-do.js';
|
|
4
4
|
import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
|
|
5
|
-
import { assertReplayableDump, opsFailureFingerprint, substratError } from '@substrat-run/contracts';
|
|
5
|
+
import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, substratError } from '@substrat-run/contracts';
|
|
6
|
+
/**
|
|
7
|
+
* The router's per-request directory read (`readRoute`), lifted out so its plan can be asked for
|
|
8
|
+
* (#2005): the scope's hostname set is a correlated subquery on the hot path every request takes,
|
|
9
|
+
* and it must stay a range read on `hostnames_scope`, never a scan.
|
|
10
|
+
*/
|
|
11
|
+
export const READ_ROUTE_SQL = `SELECT h.tenant_id, h.scope_id, h.vertical_slug, h.surface, h.region,
|
|
12
|
+
COALESCE(s.serving_ref, vv.deployment_ref) AS deployment_ref,
|
|
13
|
+
CASE WHEN s.serving_ref IS NOT NULL
|
|
14
|
+
THEN json_extract(sv.manifest_json, '$.outbound')
|
|
15
|
+
ELSE json_extract(vv.manifest_json, '$.outbound') END AS outbound_json,
|
|
16
|
+
CASE WHEN s.serving_ref IS NOT NULL
|
|
17
|
+
THEN json_extract(sv.manifest_json, '$.calls')
|
|
18
|
+
ELSE json_extract(vv.manifest_json, '$.calls') END AS calls_json,
|
|
19
|
+
s.kind, s.forked_from,
|
|
20
|
+
(SELECT json_group_array(sh.hostname) FROM (
|
|
21
|
+
-- INDEXED BY: unpinned, the planner reads hostnames_status — every active
|
|
22
|
+
-- hostname in the fleet, per request. A range on the scope is what it is.
|
|
23
|
+
SELECT hostname FROM hostnames INDEXED BY hostnames_scope
|
|
24
|
+
WHERE scope_id = s.scope_id AND status = 'active'
|
|
25
|
+
ORDER BY hostname LIMIT ${ROUTE_SCOPE_HOSTNAMES_MAX}
|
|
26
|
+
) sh) AS scope_hostnames_json
|
|
27
|
+
FROM hostnames h
|
|
28
|
+
JOIN scopes s ON s.scope_id = h.scope_id AND s.tenant_id = h.tenant_id
|
|
29
|
+
JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
30
|
+
LEFT JOIN vertical_versions vv ON vv.id = s.vertical_version_id
|
|
31
|
+
LEFT JOIN verticals vr ON vr.slug = s.vertical
|
|
32
|
+
LEFT JOIN vertical_versions sv ON sv.id = vr.serving_version_id
|
|
33
|
+
WHERE h.hostname = ? AND h.status = 'active'
|
|
34
|
+
AND s.status = 'active' AND t.status = 'active'`;
|
|
6
35
|
/** snake_case row → the camelCase `issueEntry` shape (#1233). */
|
|
7
36
|
function issueOf(r) {
|
|
8
37
|
return {
|
|
@@ -63,6 +92,18 @@ function keysetTail(where, params, key, page, defaultOrder = 'asc') {
|
|
|
63
92
|
* Keeping the copies and gating them, rather than moving the DDL into the kernel, is the
|
|
64
93
|
* recorded answer to #969; `docs/architecture/kernel-design.md` §8 says why.
|
|
65
94
|
*/
|
|
95
|
+
/** #1713: one hosted scope, its directory lifecycle, and what its deployment last acknowledged. */
|
|
96
|
+
/**
|
|
97
|
+
* #2016, Codex #2037 r2: the condition every delivery-bookkeeping write carries, in its own statement —
|
|
98
|
+
* the scope (bound as the last parameter) still exists, is not reaped, and its tenant is not reaped.
|
|
99
|
+
* A heal selects its targets, then writes its asks and its receipts after a network round trip; a
|
|
100
|
+
* delete or a reap that commits in between has already forgotten the scope's rows
|
|
101
|
+
* (`forgetLifecycleDeliveries`), and an unconditional write would bring them back as orphans.
|
|
102
|
+
*/
|
|
103
|
+
const STILL_DELIVERED = `EXISTS (
|
|
104
|
+
SELECT 1 FROM scopes s JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
105
|
+
WHERE s.scope_id = ? AND s.status <> 'reaped' AND t.status <> 'reaped'
|
|
106
|
+
)`;
|
|
66
107
|
const DIRECTORY_DDL = `
|
|
67
108
|
-- The ';' in this comment is a deliberate tripwire; the DDL must go through
|
|
68
109
|
-- splitSqlStatements, and a naive split(';') fails the directory at construction.
|
|
@@ -560,6 +601,36 @@ const DIRECTORY_DDL = `
|
|
|
560
601
|
CREATE INDEX IF NOT EXISTS _substrat_model_usage_tenant ON _substrat_model_usage (tenant_id, at);
|
|
561
602
|
CREATE INDEX IF NOT EXISTS _substrat_model_usage_at ON _substrat_model_usage (at);
|
|
562
603
|
CREATE INDEX IF NOT EXISTS scopes_tenant ON scopes (tenant_id, scope_id);
|
|
604
|
+
-- #1713: the lifecycle each hosted scope's deployment last acknowledged, as
|
|
605
|
+
-- "<scope status>/<tenant status>" (lifecycleReceipt). A CP-less deployment has no
|
|
606
|
+
-- directory, so the platform delivers a scope's lifecycle to it, and the heal sweep
|
|
607
|
+
-- re-delivers wherever this differs from the directory. No row reads as active/active,
|
|
608
|
+
-- which is what a deployment holding no lifecycle runs as.
|
|
609
|
+
-- #2016: tenant_recorded, whether that deployment answered that the scope holds a record of its
|
|
610
|
+
-- tenant (1) or not (0); NULL is never asked. The heal keeps delivering to any served scope, a
|
|
611
|
+
-- copy as much as a primary, until it answers 1.
|
|
612
|
+
CREATE TABLE IF NOT EXISTS scope_lifecycle_receipts (
|
|
613
|
+
scope_id TEXT PRIMARY KEY,
|
|
614
|
+
delivered TEXT NOT NULL,
|
|
615
|
+
at TEXT NOT NULL,
|
|
616
|
+
tenant_recorded INTEGER
|
|
617
|
+
);
|
|
618
|
+
-- #2016: when the heal last asked a scope for its tenant record, whatever came of it. Kept apart
|
|
619
|
+
-- from the receipt, which only an answer writes, so a scope whose deployment never answers still
|
|
620
|
+
-- rotates behind the rest of the walk instead of heading it every pass.
|
|
621
|
+
CREATE TABLE IF NOT EXISTS scope_tenant_asks (
|
|
622
|
+
scope_id TEXT PRIMARY KEY,
|
|
623
|
+
asked_at TEXT NOT NULL
|
|
624
|
+
);
|
|
625
|
+
-- #1713: the lifecycle's revisions, one counter per subject ("scope:<id>" counts a
|
|
626
|
+
-- scope's status transitions, "tenant:<id>" a tenant's status changes), each bumped in
|
|
627
|
+
-- the same transaction as the change it counts, and "directory:epoch", the history the
|
|
628
|
+
-- directory is in, minted afresh by every restore (importDump). A deployment orders
|
|
629
|
+
-- deliveries by them, never by a clock. No row reads as 0.
|
|
630
|
+
CREATE TABLE IF NOT EXISTS lifecycle_revisions (
|
|
631
|
+
subject TEXT PRIMARY KEY,
|
|
632
|
+
revision INTEGER NOT NULL
|
|
633
|
+
);
|
|
563
634
|
`;
|
|
564
635
|
/**
|
|
565
636
|
* `DIRECTORY_DDL`, split once into what runs before the column additions and after (#1764).
|
|
@@ -903,6 +974,8 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
903
974
|
}
|
|
904
975
|
// §4.8's grace-window timestamp on tenants (mirrors scopes' archived_at).
|
|
905
976
|
this.addColumn('tenants', 'deleting_at TEXT');
|
|
977
|
+
// #2016: whether a scope's deployment holds a record of its tenant (see the table's DDL).
|
|
978
|
+
this.addColumn('scope_lifecycle_receipts', 'tenant_recorded INTEGER');
|
|
906
979
|
// #412 provenance: the manager tenant that provisioned this one (NULL = direct staff).
|
|
907
980
|
this.addColumn('tenants', 'provisioned_by_tenant TEXT REFERENCES tenants(tenant_id)');
|
|
908
981
|
// §4.7 custom-hostname issuance: CF's hostname id + the DNS records to publish (JSON).
|
|
@@ -1013,7 +1086,11 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1013
1086
|
ORDER BY name`)
|
|
1014
1087
|
.toArray();
|
|
1015
1088
|
return defs.map(({ name, sql }) => {
|
|
1016
|
-
|
|
1089
|
+
// #1713: the directory's epoch describes THIS store's history, not its data, so it never
|
|
1090
|
+
// leaves in a dump. A restore mints its own from the store it lands in (`importDump`).
|
|
1091
|
+
const cursor = name === 'lifecycle_revisions'
|
|
1092
|
+
? this.sql.exec(`SELECT * FROM "${name}" WHERE subject <> 'directory:epoch'`)
|
|
1093
|
+
: this.sql.exec(`SELECT * FROM "${name}"`);
|
|
1017
1094
|
const columns = cursor.columnNames;
|
|
1018
1095
|
const rows = Array.from(cursor.raw(), (row) => row);
|
|
1019
1096
|
return { name, ddl: sql, columns, rows };
|
|
@@ -1045,6 +1122,8 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1045
1122
|
// nothing.
|
|
1046
1123
|
assertReplayableDump(tables, { maxColumns: DO_SQL_LIMITS.columns });
|
|
1047
1124
|
await this.ctx.storage.transaction(async () => {
|
|
1125
|
+
// #1713: the epoch this directory was in, read before the drops take it.
|
|
1126
|
+
const epochBefore = this.lifecycleEpoch();
|
|
1048
1127
|
this.sql.exec('PRAGMA defer_foreign_keys = ON');
|
|
1049
1128
|
// `_cf_*` is workerd's, and dropping it is refused (SQLITE_AUTH), as `exportDump` says.
|
|
1050
1129
|
const existing = this.sql
|
|
@@ -1068,6 +1147,12 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1068
1147
|
this.sql.exec(sql, ...row);
|
|
1069
1148
|
},
|
|
1070
1149
|
});
|
|
1150
|
+
// #1713: a restore is a new history. Its lifecycle counters went back with it, so it
|
|
1151
|
+
// mints an epoch newer than the one this store was in — and than any minted on a store it
|
|
1152
|
+
// no longer remembers (a fresh directory restored from a copy), since an epoch is a time.
|
|
1153
|
+
// A dump never carries one (`exportDump`); a hand-made dump that does is outranked too.
|
|
1154
|
+
// Every deployment then takes this history's deliveries, whatever its counters say.
|
|
1155
|
+
this.sql.exec("INSERT OR REPLACE INTO lifecycle_revisions (subject, revision) VALUES ('directory:epoch', ?)", Math.max(epochBefore + 1, this.lifecycleEpoch() + 1, Date.now()));
|
|
1071
1156
|
});
|
|
1072
1157
|
// A restore is a new #1764 backfill episode. It replaced the data the old failure count and
|
|
1073
1158
|
// backoff were about, so neither may delay or silence it: the count is cleared and the next
|
|
@@ -1134,12 +1219,15 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1134
1219
|
// Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
|
|
1135
1220
|
// entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
|
|
1136
1221
|
// transitionScope uses for archived_at.
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1222
|
+
this.ctx.storage.transactionSync(() => {
|
|
1223
|
+
if (status === 'deleting') {
|
|
1224
|
+
this.sql.exec('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?', status, new Date().toISOString(), tenantId);
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
this.sql.exec('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?', status, tenantId);
|
|
1228
|
+
}
|
|
1229
|
+
this.bumpLifecycleRevision(`tenant:${tenantId}`); // #1713, with the change it counts
|
|
1230
|
+
});
|
|
1143
1231
|
return before.status;
|
|
1144
1232
|
}
|
|
1145
1233
|
/**
|
|
@@ -1170,7 +1258,12 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1170
1258
|
]) {
|
|
1171
1259
|
this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
|
|
1172
1260
|
}
|
|
1173
|
-
this.
|
|
1261
|
+
this.ctx.storage.transactionSync(() => {
|
|
1262
|
+
this.sql.exec("UPDATE tenants SET status = 'reaped', deleting_at = NULL WHERE tenant_id = ?", tenantId);
|
|
1263
|
+
// Its scopes were reaped one by one, each forgetting its own; any left over go here.
|
|
1264
|
+
this.forgetLifecycleDeliveries('scope_id IN (SELECT scope_id FROM scopes WHERE tenant_id = ?)', tenantId);
|
|
1265
|
+
this.bumpLifecycleRevision(`tenant:${tenantId}`); // #1713
|
|
1266
|
+
});
|
|
1174
1267
|
return before.status;
|
|
1175
1268
|
}
|
|
1176
1269
|
/** Rename the DISPLAY name (never the slug); throw if absent; return the previous name. */
|
|
@@ -1496,8 +1589,11 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1496
1589
|
else {
|
|
1497
1590
|
this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
|
|
1498
1591
|
}
|
|
1499
|
-
if (to === 'reaped')
|
|
1592
|
+
if (to === 'reaped') {
|
|
1500
1593
|
forgetSystemSwitchesOf(this.kernelSql, scopeId);
|
|
1594
|
+
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
1595
|
+
}
|
|
1596
|
+
this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
|
|
1501
1597
|
});
|
|
1502
1598
|
return { ok: true, status: row.status, vertical: row.vertical };
|
|
1503
1599
|
}
|
|
@@ -1646,7 +1742,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1646
1742
|
// for the target, and the router must not carry a second copy of "what is primary".
|
|
1647
1743
|
const callerState = !caller || caller.tenant_id !== tenantId || caller.vertical !== callerVertical
|
|
1648
1744
|
? 'unknown'
|
|
1649
|
-
: !
|
|
1745
|
+
: !isPrimaryScopeRow(caller)
|
|
1650
1746
|
? 'not-primary'
|
|
1651
1747
|
: caller.status !== 'active' || caller.tenant_status !== 'active'
|
|
1652
1748
|
? 'inactive'
|
|
@@ -1680,24 +1776,19 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1680
1776
|
// json_extract keeps the hot path from parsing whole manifests in JS: SQLite hands
|
|
1681
1777
|
// back just the `outbound` array as JSON text, or NULL for a pre-#303 manifest —
|
|
1682
1778
|
// which the egress worker treats as unenforced-but-metered.
|
|
1683
|
-
|
|
1684
|
-
.exec(
|
|
1685
|
-
COALESCE(s.serving_ref, vv.deployment_ref) AS deployment_ref,
|
|
1686
|
-
CASE WHEN s.serving_ref IS NOT NULL
|
|
1687
|
-
THEN json_extract(sv.manifest_json, '$.outbound')
|
|
1688
|
-
ELSE json_extract(vv.manifest_json, '$.outbound') END AS outbound_json,
|
|
1689
|
-
CASE WHEN s.serving_ref IS NOT NULL
|
|
1690
|
-
THEN json_extract(sv.manifest_json, '$.calls')
|
|
1691
|
-
ELSE json_extract(vv.manifest_json, '$.calls') END AS calls_json
|
|
1692
|
-
FROM hostnames h
|
|
1693
|
-
JOIN scopes s ON s.scope_id = h.scope_id AND s.tenant_id = h.tenant_id
|
|
1694
|
-
JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
1695
|
-
LEFT JOIN vertical_versions vv ON vv.id = s.vertical_version_id
|
|
1696
|
-
LEFT JOIN verticals vr ON vr.slug = s.vertical
|
|
1697
|
-
LEFT JOIN vertical_versions sv ON sv.id = vr.serving_version_id
|
|
1698
|
-
WHERE h.hostname = ? AND h.status = 'active'
|
|
1699
|
-
AND s.status = 'active' AND t.status = 'active'`, hostname)
|
|
1779
|
+
const row = this.sql
|
|
1780
|
+
.exec(READ_ROUTE_SQL, hostname)
|
|
1700
1781
|
.toArray()[0];
|
|
1782
|
+
if (!row)
|
|
1783
|
+
return undefined;
|
|
1784
|
+
// #2005: the predicate runs here, once, rather than travelling as two columns for the
|
|
1785
|
+
// router to re-derive — every reader of the directory answers it the same way.
|
|
1786
|
+
const { kind, forked_from, scope_hostnames_json, ...route } = row;
|
|
1787
|
+
return {
|
|
1788
|
+
...route,
|
|
1789
|
+
primary: isPrimaryScopeRow({ kind, forked_from }),
|
|
1790
|
+
scope_hostnames: JSON.parse(scope_hostnames_json),
|
|
1791
|
+
};
|
|
1701
1792
|
}
|
|
1702
1793
|
/** Demote any current canonical for this surface — exactly one may hold it. */
|
|
1703
1794
|
demoteCanonical(scopeId, surface) {
|
|
@@ -1915,9 +2006,24 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1915
2006
|
* wiping the scope DO's storage — the fork-only refusal lives there, not here.
|
|
1916
2007
|
*/
|
|
1917
2008
|
deleteScopeDirectory(scopeId) {
|
|
1918
|
-
this.
|
|
1919
|
-
|
|
1920
|
-
|
|
2009
|
+
this.ctx.storage.transactionSync(() => {
|
|
2010
|
+
this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
|
|
2011
|
+
forgetSystemSwitchesOf(this.kernelSql, scopeId);
|
|
2012
|
+
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
2013
|
+
this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* #1713 / #2016: drop the delivery bookkeeping of scopes that will never be delivered to again —
|
|
2018
|
+
* what each deployment acknowledged (`scope_lifecycle_receipts`) and when the heal last asked it
|
|
2019
|
+
* for its tenant record (`scope_tenant_asks`). A hard-deleted fork's rows would otherwise outlive
|
|
2020
|
+
* it, and every preview created and deleted would grow both tables, and every directory dump, for
|
|
2021
|
+
* good. A reaped scope keeps its tombstone row but leaves every delivery walk, so its go too.
|
|
2022
|
+
*/
|
|
2023
|
+
forgetLifecycleDeliveries(where, ...params) {
|
|
2024
|
+
for (const table of ['scope_lifecycle_receipts', 'scope_tenant_asks']) {
|
|
2025
|
+
this.sql.exec(`DELETE FROM ${table} WHERE ${where}`, ...params);
|
|
2026
|
+
}
|
|
1921
2027
|
}
|
|
1922
2028
|
readChannel(verticalSlug, channel) {
|
|
1923
2029
|
return this.sql
|
|
@@ -2346,18 +2452,26 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2346
2452
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
|
|
2347
2453
|
}
|
|
2348
2454
|
// -- the schedule switch's record (#1674) — `system-switch-record.ts` is the whole rule ---
|
|
2349
|
-
/** OFF's write,
|
|
2455
|
+
/** OFF's write, before the scope's switch moves (#1823). Answers the row as it was. */
|
|
2350
2456
|
recordSystemSwitchedOff(row) {
|
|
2351
|
-
recordSystemSwitchedOff(this.kernelSql, row);
|
|
2457
|
+
return recordSystemSwitchedOff(this.kernelSql, row);
|
|
2352
2458
|
}
|
|
2353
2459
|
/** ON's write, before the scope moves. Answers the row as it was, for a failed ON to restore. */
|
|
2354
2460
|
recordSystemSwitchedOn(row) {
|
|
2355
2461
|
return recordSystemSwitchedOn(this.kernelSql, row);
|
|
2356
2462
|
}
|
|
2357
|
-
/** Put a row back as
|
|
2463
|
+
/** Put a row back as the switch call's record write found it — the move that followed it failed. */
|
|
2358
2464
|
restoreSystemSwitchRecord(key, prior) {
|
|
2359
2465
|
restoreSystemSwitchRecord(this.kernelSql, key, prior);
|
|
2360
2466
|
}
|
|
2467
|
+
/**
|
|
2468
|
+
* #1823: which of these modules the tenant holds a live TENANT-level grant for — what the
|
|
2469
|
+
* switch passes the scope as `tenantHeld`, so a module whose only authority there is a tenant
|
|
2470
|
+
* tuple is still switchable. Read here, where the tenant tuples live.
|
|
2471
|
+
*/
|
|
2472
|
+
tenantHeldSystemModules(tenantId, moduleIds, now) {
|
|
2473
|
+
return moduleIds.filter((m) => tenantHoldsSystemGrant(this.kernelSql, tenantId, m, now));
|
|
2474
|
+
}
|
|
2361
2475
|
/** The fleet read. */
|
|
2362
2476
|
listSystemSwitches(filter) {
|
|
2363
2477
|
return listSystemSwitchRecords(this.kernelSql, filter);
|
|
@@ -2655,6 +2769,107 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2655
2769
|
at: r.at,
|
|
2656
2770
|
}));
|
|
2657
2771
|
}
|
|
2772
|
+
/**
|
|
2773
|
+
* #1713: the scopes whose lifecycle a deployment must hold, with their lifecycle as this
|
|
2774
|
+
* directory holds it. A scope with a vertical (only a deployment serves one), past provisioning
|
|
2775
|
+
* and not reaped (a reaped store must not be recreated by a delivery), under a tenant that is
|
|
2776
|
+
* not reaped. `scopeId` narrows it to one scope and `tenantId` to one tenant's scopes, the
|
|
2777
|
+
* fan-out of a tenant transition. With `drift`, only the scopes the heal sweep must deliver to:
|
|
2778
|
+
* a receipt that differs from the directory, and every scope held now. Re-delivering a held
|
|
2779
|
+
* scope every pass is what puts a hold back on a store a carry or a restore landed without it,
|
|
2780
|
+
* and held scopes are few. Drifted rows first, so held ones never starve them.
|
|
2781
|
+
*/
|
|
2782
|
+
lifecycleTargets(filter) {
|
|
2783
|
+
const where = [
|
|
2784
|
+
's.vertical IS NOT NULL',
|
|
2785
|
+
"s.status NOT IN ('provisioning', 'reaped')",
|
|
2786
|
+
"t.status <> 'reaped'",
|
|
2787
|
+
];
|
|
2788
|
+
const params = [];
|
|
2789
|
+
if (filter.tenantId) {
|
|
2790
|
+
where.push('s.tenant_id = ?');
|
|
2791
|
+
params.push(filter.tenantId);
|
|
2792
|
+
}
|
|
2793
|
+
if (filter.scopeId) {
|
|
2794
|
+
where.push('s.scope_id = ?');
|
|
2795
|
+
params.push(filter.scopeId);
|
|
2796
|
+
}
|
|
2797
|
+
const epoch = this.lifecycleEpoch();
|
|
2798
|
+
// What the deployment would acknowledge now (`lifecycleReceipt`): the statuses and the full
|
|
2799
|
+
// revision. No receipt is drift only after a restore (epoch > 0), when what the deployment
|
|
2800
|
+
// holds is unknown; before any, a live scope with no receipt runs as its deployment does.
|
|
2801
|
+
const acked = "s.status || '/' || t.status || '@' || CAST(? AS INTEGER) || '.' || COALESCE(rs.revision, 0) || '.' || COALESCE(rt.revision, 0)";
|
|
2802
|
+
// CAST: a bound JS number reaches DO SQLite as a REAL, and would concatenate as '0.0'.
|
|
2803
|
+
const drifted = `(r.delivered IS NULL AND CAST(? AS INTEGER) > 0) OR r.delivered <> ${acked}`;
|
|
2804
|
+
const driftParams = [epoch, epoch];
|
|
2805
|
+
if (filter.drift) {
|
|
2806
|
+
where.push(`(${drifted} OR s.status NOT IN ('active', 'archived') OR t.status <> 'active')`);
|
|
2807
|
+
params.push(...driftParams);
|
|
2808
|
+
}
|
|
2809
|
+
if (filter.unrecorded)
|
|
2810
|
+
where.push('COALESCE(r.tenant_recorded, 0) <> 1');
|
|
2811
|
+
// The unrecorded walk rotates on the ask, not the answer: never asked first, then the one asked
|
|
2812
|
+
// longest ago, so a scope that answers 0, or never answers at all, cannot starve the rest of a
|
|
2813
|
+
// bounded pass.
|
|
2814
|
+
const order = filter.unrecorded
|
|
2815
|
+
? 'a.asked_at IS NOT NULL, a.asked_at, s.scope_id'
|
|
2816
|
+
: `CASE WHEN ${drifted} THEN 0 ELSE 1 END, s.scope_id`;
|
|
2817
|
+
if (!filter.unrecorded)
|
|
2818
|
+
params.push(...driftParams);
|
|
2819
|
+
params.push(filter.limit ?? 1000);
|
|
2820
|
+
return this.sql
|
|
2821
|
+
.exec(`SELECT s.tenant_id, s.scope_id, s.status AS scope_status, t.status AS tenant_status, r.delivered,
|
|
2822
|
+
${epoch} AS epoch, COALESCE(rs.revision, 0) AS scope_rev, COALESCE(rt.revision, 0) AS tenant_rev
|
|
2823
|
+
FROM scopes s
|
|
2824
|
+
JOIN tenants t ON t.tenant_id = s.tenant_id
|
|
2825
|
+
LEFT JOIN scope_lifecycle_receipts r ON r.scope_id = s.scope_id
|
|
2826
|
+
LEFT JOIN scope_tenant_asks a ON a.scope_id = s.scope_id
|
|
2827
|
+
LEFT JOIN lifecycle_revisions rs ON rs.subject = 'scope:' || s.scope_id
|
|
2828
|
+
LEFT JOIN lifecycle_revisions rt ON rt.subject = 'tenant:' || s.tenant_id
|
|
2829
|
+
WHERE ${where.join(' AND ')}
|
|
2830
|
+
ORDER BY ${order}
|
|
2831
|
+
LIMIT ?`, ...params)
|
|
2832
|
+
.toArray();
|
|
2833
|
+
}
|
|
2834
|
+
/** #1713: the directory's epoch (`lifecycleRevision.epoch`): 0 until a restore mints one. */
|
|
2835
|
+
lifecycleEpoch() {
|
|
2836
|
+
const row = this.sql
|
|
2837
|
+
.exec("SELECT revision FROM lifecycle_revisions WHERE subject = 'directory:epoch'")
|
|
2838
|
+
.toArray()[0];
|
|
2839
|
+
return row?.revision ?? 0;
|
|
2840
|
+
}
|
|
2841
|
+
/**
|
|
2842
|
+
* #1713: raise the directory's epoch to at least `atLeast`, never lower it, and answer the epoch
|
|
2843
|
+
* now. One statement, so concurrent heals that each learned a held epoch settle on the highest.
|
|
2844
|
+
* Called when a scope refuses this directory's delivery while holding an epoch another history
|
|
2845
|
+
* of the directory minted (a fresh-directory restore whose clock ran behind the old one's).
|
|
2846
|
+
*/
|
|
2847
|
+
raiseLifecycleEpoch(atLeast) {
|
|
2848
|
+
if (!Number.isSafeInteger(atLeast) || atLeast < 0)
|
|
2849
|
+
throw new Error(`not an epoch: ${atLeast}`);
|
|
2850
|
+
// An epoch is a mint time: one a day past the clock is forged or broken, and never raised to.
|
|
2851
|
+
if (atLeast > Date.now() + 24 * 60 * 60 * 1000)
|
|
2852
|
+
throw new Error(`epoch ${atLeast} is ahead of the clock; not raised`);
|
|
2853
|
+
this.sql.exec(`INSERT INTO lifecycle_revisions (subject, revision) VALUES ('directory:epoch', ?)
|
|
2854
|
+
ON CONFLICT (subject) DO UPDATE SET revision = MAX(revision, excluded.revision)`, atLeast);
|
|
2855
|
+
return this.lifecycleEpoch();
|
|
2856
|
+
}
|
|
2857
|
+
/** #1713: count one lifecycle change of a scope or a tenant. Call inside the change's transaction. */
|
|
2858
|
+
bumpLifecycleRevision(subject) {
|
|
2859
|
+
this.sql.exec('INSERT INTO lifecycle_revisions (subject, revision) VALUES (?, 1) ON CONFLICT (subject) DO UPDATE SET revision = revision + 1', subject);
|
|
2860
|
+
}
|
|
2861
|
+
/** #2016: note that the heal asked these scopes for their tenant record at `at`, before it asks. */
|
|
2862
|
+
recordTenantAsks(scopeIds, at) {
|
|
2863
|
+
for (const scopeId of scopeIds) {
|
|
2864
|
+
this.sql.exec(`INSERT OR REPLACE INTO scope_tenant_asks (scope_id, asked_at) SELECT ?, ? WHERE ${STILL_DELIVERED}`, scopeId, at, scopeId);
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
/** #1713: record what a scope's deployment acknowledged holding (`lifecycleReceipt`), and #2016:
|
|
2868
|
+
* whether it answered that the scope holds a record of its tenant. */
|
|
2869
|
+
recordLifecycleReceipt(scopeId, delivered, at, tenantRecorded = false) {
|
|
2870
|
+
this.sql.exec(`INSERT OR REPLACE INTO scope_lifecycle_receipts (scope_id, delivered, at, tenant_recorded)
|
|
2871
|
+
SELECT ?, ?, ?, ? WHERE ${STILL_DELIVERED}`, scopeId, delivered, at, tenantRecorded ? 1 : 0, scopeId);
|
|
2872
|
+
}
|
|
2658
2873
|
recordOpsFailure(row) {
|
|
2659
2874
|
this.sql.exec(`INSERT INTO _substrat_ops_failures
|
|
2660
2875
|
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|