@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.
Files changed (43) hide show
  1. package/dist/checker.d.ts.map +1 -1
  2. package/dist/checker.js +3 -1
  3. package/dist/checker.js.map +1 -1
  4. package/dist/connector-calls.d.ts +11 -0
  5. package/dist/connector-calls.d.ts.map +1 -0
  6. package/dist/connector-calls.js +10 -0
  7. package/dist/connector-calls.js.map +1 -0
  8. package/dist/control-plane-do.d.ts +77 -3
  9. package/dist/control-plane-do.d.ts.map +1 -1
  10. package/dist/control-plane-do.js +250 -35
  11. package/dist/control-plane-do.js.map +1 -1
  12. package/dist/host.d.ts +297 -16
  13. package/dist/host.d.ts.map +1 -1
  14. package/dist/host.js +895 -132
  15. package/dist/host.js.map +1 -1
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/live-reads.d.ts +5 -4
  21. package/dist/live-reads.d.ts.map +1 -1
  22. package/dist/live-reads.js +5 -4
  23. package/dist/live-reads.js.map +1 -1
  24. package/dist/r2.d.ts +2 -1
  25. package/dist/r2.d.ts.map +1 -1
  26. package/dist/r2.js.map +1 -1
  27. package/dist/route-resolver.d.ts +7 -0
  28. package/dist/route-resolver.d.ts.map +1 -1
  29. package/dist/route-resolver.js +4 -0
  30. package/dist/route-resolver.js.map +1 -1
  31. package/dist/scope-do.d.ts +2 -0
  32. package/dist/scope-do.d.ts.map +1 -1
  33. package/dist/scope-do.js +896 -140
  34. package/dist/scope-do.js.map +1 -1
  35. package/dist/scope-sweeper-do.d.ts +32 -6
  36. package/dist/scope-sweeper-do.d.ts.map +1 -1
  37. package/dist/scope-sweeper-do.js +35 -5
  38. package/dist/scope-sweeper-do.js.map +1 -1
  39. package/dist/system-door.d.ts +21 -0
  40. package/dist/system-door.d.ts.map +1 -0
  41. package/dist/system-door.js +13 -0
  42. package/dist/system-door.js.map +1 -0
  43. package/package.json +10 -9
@@ -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, isPrimaryScope, resolveVerticalInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, } from '@substrat-run/kernel';
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
- const cursor = this.sql.exec(`SELECT * FROM "${name}"`);
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
- if (status === 'deleting') {
1138
- this.sql.exec('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?', status, new Date().toISOString(), tenantId);
1139
- }
1140
- else {
1141
- this.sql.exec('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?', status, tenantId);
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.sql.exec("UPDATE tenants SET status = 'reaped', deleting_at = NULL WHERE tenant_id = ?", tenantId);
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
- : !isPrimaryScope({ kind: caller.kind ?? '', forkedFrom: caller.forked_from ?? null })
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
- return this.sql
1684
- .exec(`SELECT h.tenant_id, h.scope_id, h.vertical_slug, h.surface, h.region,
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.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
1919
- forgetSystemSwitchesOf(this.kernelSql, scopeId);
1920
- this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
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, after the scope's switch held. */
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 `recordSystemSwitchedOn` found it — the ON that followed it failed. */
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)