@substrat-run/adapter-cloudflare 0.120.0 → 0.122.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.
@@ -1,7 +1,7 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, isPrimaryScope, resolveVerticalInstanceFrom, } from '@substrat-run/kernel';
3
- import { splitSqlStatements } from './scope-do.js';
4
- import { assertReplayableDump } from '@substrat-run/contracts';
2
+ import { IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, switchedOffModulesOf, systemSwitchRecordsOf, systemSwitchesTableExists, VERSION_MIGRATIONS_DDL, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, ulid, isPrimaryScope, resolveVerticalInstanceFrom, } from '@substrat-run/kernel';
3
+ import { splitSqlStatements, switchSqlOver } from './scope-do.js';
4
+ import { assertReplayableDump, opsFailureFingerprint } from '@substrat-run/contracts';
5
5
  /** snake_case row → the camelCase `issueEntry` shape (#1233). */
6
6
  function issueOf(r) {
7
7
  return {
@@ -217,10 +217,16 @@ const DIRECTORY_DDL = `
217
217
  -- The full DeployManifest as pushed (JSON). What promote/backout rebuild the
218
218
  -- serving upload's metadata from — the archive script stores the module BYTES,
219
219
  -- this stores the shape (entry, compat, doClasses, bindings). NULL = pre-#286 push.
220
+ -- Stored WITHOUT its SQL migrations, which live in vertical_version_migrations (#1764).
220
221
  manifest_json TEXT,
222
+ -- How many migration rows the version stored. NULL = it carries no SQL to show.
223
+ migration_count INTEGER,
224
+ -- 1 once the SQL is out of manifest_json. NULL = stored before #1764, not yet backfilled.
225
+ migrations_split INTEGER,
221
226
  created_at TEXT NOT NULL,
222
227
  UNIQUE (vertical_slug, version)
223
228
  );
229
+ ${VERSION_MIGRATIONS_DDL}
224
230
  CREATE TABLE IF NOT EXISTS vertical_channels (
225
231
  vertical_slug TEXT NOT NULL,
226
232
  channel TEXT NOT NULL,
@@ -407,6 +413,7 @@ const DIRECTORY_DDL = `
407
413
  PRIMARY KEY (scope_id, subject_id)
408
414
  );
409
415
  ${IMPERSONATION_DDL}
416
+ ${SYSTEM_SWITCHES_DDL}
410
417
  CREATE TABLE IF NOT EXISTS _substrat_admin_log (
411
418
  id TEXT PRIMARY KEY,
412
419
  actor TEXT NOT NULL,
@@ -541,6 +548,12 @@ const DIRECTORY_DDL = `
541
548
  CREATE INDEX IF NOT EXISTS _substrat_model_usage_at ON _substrat_model_usage (at);
542
549
  CREATE INDEX IF NOT EXISTS scopes_tenant ON scopes (tenant_id, scope_id);
543
550
  `;
551
+ /**
552
+ * `DIRECTORY_DDL`, split once into what runs before the column additions and after (#1764).
553
+ * Checked at module load: it is a constant, so a drift is a build that never works rather
554
+ * than a directory that quietly skips or double-runs a statement.
555
+ */
556
+ export const DIRECTORY_DDL_PLAN = assertDirectoryDdlPlan(planDirectoryDdl(DIRECTORY_DDL));
544
557
  /** The scope columns added after the directory's first shape shipped. */
545
558
  const SCOPE_COLUMNS_ADDED = [
546
559
  'parent_scope_id TEXT',
@@ -560,15 +573,164 @@ const SCOPE_COLUMNS_ADDED = [
560
573
  'serving_ref TEXT',
561
574
  'archived_at TEXT',
562
575
  ];
576
+ /**
577
+ * A directory DDL's statements in the order `applyDirectorySchema` runs them: `loop` before
578
+ * the column additions, `afterColumns` after. `VERSION_MIGRATIONS_DDL` indexes a column a
579
+ * directory from before #1764 gets only from those additions, so its statements are held back.
580
+ *
581
+ * Held back by EXACT statement, never by a name a statement contains: a later statement that
582
+ * merely mentions the table runs in the loop like any other, rather than being skipped. And
583
+ * `missing` names any held-back statement the DDL does not carry, which would mean the
584
+ * fragment and the DDL had parted company (its test holds it empty).
585
+ */
586
+ export function planDirectoryDdl(ddl) {
587
+ const all = splitSqlStatements(ddl);
588
+ const afterColumns = splitSqlStatements(VERSION_MIGRATIONS_DDL);
589
+ const held = new Set(afterColumns);
590
+ return {
591
+ loop: all.filter((stmt) => !held.has(stmt)),
592
+ afterColumns,
593
+ missing: afterColumns.filter((stmt) => !all.includes(stmt)),
594
+ };
595
+ }
596
+ /** Refuses a plan whose held-back statements the DDL does not carry; returns it otherwise. */
597
+ export function assertDirectoryDdlPlan(plan) {
598
+ if (plan.missing.length > 0) {
599
+ throw new Error(`the directory DDL does not carry ${plan.missing.length} statement(s) of VERSION_MIGRATIONS_DDL — ` +
600
+ `interpolate the fragment whole: ${plan.missing[0]}`);
601
+ }
602
+ return plan;
603
+ }
604
+ /**
605
+ * The pause before each #1764 backfill batch. A batch holds the directory DO, which every
606
+ * control-plane request goes through, so batches are spaced rather than run back to back.
607
+ */
608
+ const BACKFILL_PAUSE_MS = 1000;
609
+ /** The longest a failing #1764 backfill waits before it tries again. */
610
+ const BACKFILL_BACKOFF_MAX_MS = 60 * 60 * 1000;
611
+ /**
612
+ * The actor a directory's own ops-failure rows carry: the directory DO acting for itself, with
613
+ * no request behind it. A fixed ULID, as the sweeper's is.
614
+ */
615
+ export const DIRECTORY_ACTOR = '01JZ00000000000000000000DR';
616
+ /** The operation a failing #1764 backfill is recorded under in `_substrat_ops_failures`. */
617
+ export const BACKFILL_OPERATION = 'directory.version-migrations-backfill';
618
+ /**
619
+ * Where the #1764 backfill keeps how many batches have failed in a row: in the DO's storage,
620
+ * not in the instance, so an eviction between failures does not reset the backoff (or keep
621
+ * `backoff-capped` from ever being reached). Cleared only after a batch succeeds.
622
+ */
623
+ export const BACKFILL_FAILURES_KEY = 'versionMigrationsBackfillFailures';
624
+ /** How long the backfill waits after its `failures`-th failure in a row: doubling, capped. */
625
+ export function backfillBackoffMs(failures) {
626
+ return Math.min(BACKFILL_PAUSE_MS * 2 ** failures, BACKFILL_BACKOFF_MAX_MS);
627
+ }
563
628
  export class ControlPlaneDO extends DurableObject {
564
629
  sql;
630
+ /** The directory's store as the kernel's SQL handle — the switch record's helpers (#1674). */
631
+ kernelSql;
632
+ /** Settles once the constructor's #1764 backfill check has run (awaited by the tests). */
633
+ backfillArmed;
565
634
  constructor(ctx, env) {
566
635
  super(ctx, env);
567
636
  this.sql = ctx.storage.sql;
568
- for (const stmt of splitSqlStatements(DIRECTORY_DDL)) {
637
+ this.kernelSql = switchSqlOver(this.sql);
638
+ this.applyDirectorySchema();
639
+ this.backfillArmed = ctx.blockConcurrencyWhile(() => this.armVersionMigrationsBackfill());
640
+ }
641
+ /**
642
+ * Schedule the #1764 backfill when a version still carries its SQL in its manifest.
643
+ *
644
+ * Only the probe runs here, never the backfill: this is on the constructor's path, and a
645
+ * directory DO that cannot construct is a control plane that is down. The probe reads the
646
+ * partial index of versions not yet split, so it costs the same for ten versions or ten
647
+ * thousand. The alarm then moves a bounded batch per run. A restore arms its own new episode
648
+ * instead (`importDump`).
649
+ */
650
+ async armVersionMigrationsBackfill() {
651
+ if (!versionsAwaitSplit(this.kernelSql))
652
+ return;
653
+ // An alarm already set is kept: it is the backfill's own next run, and a backoff retry
654
+ // pulled forward to 1 s by any ordinary request would make the backoff meaningless.
655
+ if ((await this.ctx.storage.getAlarm()) !== null)
656
+ return;
657
+ await this.ctx.storage.setAlarm(Date.now() + BACKFILL_PAUSE_MS);
658
+ }
659
+ /**
660
+ * One bounded batch of the #1764 backfill, then re-arm while versions are left. A batch
661
+ * and its progress marks commit together, so a run that fails moved nothing.
662
+ *
663
+ * A failure is caught and the alarm re-armed with a doubling backoff (capped at an hour),
664
+ * rather than thrown: workerd's own alarm retries give up after a few attempts, and the
665
+ * backfill would then stay stopped until the DO was next constructed. Nothing is lost
666
+ * meanwhile, because a version the backfill has not reached reads from its manifest.
667
+ */
668
+ async alarm() {
669
+ let more;
670
+ try {
671
+ more = this.ctx.storage.transactionSync(() => splitVersionMigrationsBatch(this.kernelSql)).more;
672
+ }
673
+ catch (err) {
674
+ const failures = ((await this.ctx.storage.get(BACKFILL_FAILURES_KEY)) ?? 0) + 1;
675
+ await this.ctx.storage.put(BACKFILL_FAILURES_KEY, failures);
676
+ const delay = backfillBackoffMs(failures);
677
+ console.error(`substrat: version-migrations backfill failed, retrying in ${delay} ms`, err);
678
+ // Visible where staff look, not only in logs: once when it starts failing, and once
679
+ // more when the backoff reaches its cap (a backfill that is stuck, not unlucky).
680
+ const stage = failures === 1 ? 'first-failure'
681
+ : delay === BACKFILL_BACKOFF_MAX_MS && backfillBackoffMs(failures - 1) < delay ? 'backoff-capped'
682
+ : null;
683
+ if (stage) {
684
+ try {
685
+ this.recordOpsFailure({
686
+ id: ulid(), actor: DIRECTORY_ACTOR, operation: BACKFILL_OPERATION, stage,
687
+ tenant_id: null, scope_id: null, vertical: null, version: null, status: null,
688
+ message: `failed ${failures} time(s) in a row, retrying in ${delay} ms: ${String(err)}`.slice(0, 2000),
689
+ reference: null, origin: null, code: null,
690
+ fingerprint: opsFailureFingerprint({ operation: BACKFILL_OPERATION, stage }), at: new Date().toISOString(),
691
+ });
692
+ }
693
+ catch (recordErr) {
694
+ console.error('substrat: could not record the version-migrations backfill failure', recordErr);
695
+ }
696
+ }
697
+ await this.ctx.storage.setAlarm(Date.now() + delay);
698
+ return;
699
+ }
700
+ if ((await this.ctx.storage.get(BACKFILL_FAILURES_KEY)) !== undefined) {
701
+ await this.ctx.storage.delete(BACKFILL_FAILURES_KEY);
702
+ }
703
+ if (more)
704
+ await this.ctx.storage.setAlarm(Date.now() + BACKFILL_PAUSE_MS);
705
+ }
706
+ /**
707
+ * The DDL, then the migrations for a directory that predates part of it. Run on every
708
+ * construction and after a directory restore, which may land a dump from before a table.
709
+ *
710
+ * The schedule switch's record (#1674) is backfilled from the admin log on a run that
711
+ * creates its table, and its table is created in the SAME transaction as the backfill
712
+ * (Copilot review): the gate is "the table does not exist yet", so a table committed ahead
713
+ * of a backfill that then failed would read as already migrated on every later run. On
714
+ * such a run its statements are held back from the loop below and run with the backfill.
715
+ */
716
+ applyDirectorySchema() {
717
+ const switchRecordIsNew = !systemSwitchesTableExists(this.kernelSql);
718
+ for (const stmt of DIRECTORY_DDL_PLAN.loop) {
719
+ if (switchRecordIsNew && stmt.includes('_substrat_system_switches'))
720
+ continue;
569
721
  this.sql.exec(stmt);
570
722
  }
571
723
  this.ensureDirectoryColumns();
724
+ // #1764's, held back by `planDirectoryDdl`: its index names a column added just above.
725
+ for (const stmt of DIRECTORY_DDL_PLAN.afterColumns)
726
+ this.sql.exec(stmt);
727
+ if (switchRecordIsNew) {
728
+ this.ctx.storage.transactionSync(() => {
729
+ for (const stmt of splitSqlStatements(SYSTEM_SWITCHES_DDL))
730
+ this.sql.exec(stmt);
731
+ this.sql.exec(SYSTEM_SWITCHES_BACKFILL_SQL);
732
+ });
733
+ }
572
734
  }
573
735
  /**
574
736
  * The directory's own migration path (control-plane.md §7). A singleton DO that
@@ -761,6 +923,9 @@ export class ControlPlaneDO extends DurableObject {
761
923
  // Push provenance (git CI vs a terminal), as pushed-alongside JSON. NULL = pushed
762
924
  // before origin tracking, or by an old CLI.
763
925
  this.addColumn('vertical_versions', 'origin_json TEXT');
926
+ // #1764: the SQL migrations moved out of the manifest, and the backfill's progress.
927
+ this.addColumn('vertical_versions', 'migration_count INTEGER');
928
+ this.addColumn('vertical_versions', 'migrations_split INTEGER');
764
929
  // #33: the SKU flag learns to express a plan. All nullable — a legacy row
765
930
  // reads as a perpetual boolean flag, exactly its pre-widening semantics.
766
931
  this.addColumn('_substrat_entitlements', 'expires_at TEXT');
@@ -798,9 +963,12 @@ export class ControlPlaneDO extends DurableObject {
798
963
  * did before the restore, which is the opposite of what a recovery is for.
799
964
  */
800
965
  exportDump() {
966
+ // Not `_cf_*` either: workerd's own tables (the #1764 backfill's alarm creates
967
+ // `_cf_METADATA`), which are not the directory's and which it refuses to drop on restore.
801
968
  const defs = this.sql
802
969
  .exec(`SELECT name, sql FROM sqlite_master
803
- WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
970
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT GLOB '_cf_*'
971
+ AND sql IS NOT NULL
804
972
  ORDER BY name`)
805
973
  .toArray();
806
974
  return defs.map(({ name, sql }) => {
@@ -831,8 +999,10 @@ export class ControlPlaneDO extends DurableObject {
831
999
  async importDump(tables) {
832
1000
  await this.ctx.storage.transaction(async () => {
833
1001
  this.sql.exec('PRAGMA defer_foreign_keys = ON');
1002
+ // `_cf_*` is workerd's, and dropping it is refused (SQLITE_AUTH), as `exportDump` says.
834
1003
  const existing = this.sql
835
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
1004
+ .exec(`SELECT name FROM sqlite_master
1005
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT GLOB '_cf_*'`)
836
1006
  .toArray();
837
1007
  for (const { name } of existing)
838
1008
  this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
@@ -855,9 +1025,21 @@ export class ControlPlaneDO extends DurableObject {
855
1025
  // Outside the transaction, like the constructor's own path: these are idempotent
856
1026
  // schema assertions, and an ALTER that has to be tolerated (duplicate column) must
857
1027
  // not take the restore's data down with it.
858
- for (const stmt of splitSqlStatements(DIRECTORY_DDL))
859
- this.sql.exec(stmt);
860
- this.ensureDirectoryColumns();
1028
+ this.applyDirectorySchema();
1029
+ // A restore is a new #1764 backfill episode. It replaced the data the old failure count and
1030
+ // backoff were about, so neither may delay or silence it: the count is cleared and the next
1031
+ // batch armed outright, a pause away. (An ordinary construction keeps a pending alarm; this
1032
+ // does not.) A dump taken before the backfill lands unsplit versions, so it runs again.
1033
+ // The restore has committed by now, so failing to arm must not report it as failed: the
1034
+ // next construction arms the backfill, since it finds none set.
1035
+ try {
1036
+ await this.ctx.storage.delete(BACKFILL_FAILURES_KEY);
1037
+ if (versionsAwaitSplit(this.kernelSql))
1038
+ await this.ctx.storage.setAlarm(Date.now() + BACKFILL_PAUSE_MS);
1039
+ }
1040
+ catch (err) {
1041
+ console.error('substrat: the restore committed, but its version-migrations backfill was not armed', err);
1042
+ }
861
1043
  }
862
1044
  // -- tenant registry (control-plane.md §4.1) --------------------------------
863
1045
  mapTenant(r) {
@@ -941,6 +1123,7 @@ export class ControlPlaneDO extends DurableObject {
941
1123
  '_substrat_roles', // operator-defined roles
942
1124
  '_substrat_entitlements', // per-tenant SKU flags
943
1125
  'orgs', // K-22 org records
1126
+ '_substrat_system_switches', // #1674: the schedule switch's record, per scope
944
1127
  ]) {
945
1128
  this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
946
1129
  }
@@ -1145,8 +1328,10 @@ export class ControlPlaneDO extends DurableObject {
1145
1328
  // than degenerating into an unfiltered read of the whole fleet.
1146
1329
  if (filter.status.length === 0)
1147
1330
  return [];
1148
- where.push(`status IN (${filter.status.map(() => '?').join(', ')})`);
1149
- params.push(...filter.status);
1331
+ // ONE bound JSON array, not a `?` per entry (#1776): a DO binds at most 100
1332
+ // parameters, and nothing bounds this list's length (a status may repeat).
1333
+ where.push('status IN (SELECT value FROM json_each(?))');
1334
+ params.push(JSON.stringify(filter.status));
1150
1335
  }
1151
1336
  if (filter.vertical) {
1152
1337
  where.push('vertical = ?');
@@ -1168,22 +1353,38 @@ export class ControlPlaneDO extends DurableObject {
1168
1353
  /**
1169
1354
  * The getScope gate (control-plane.md §4.1/§4.2): validate the scope belongs
1170
1355
  * to the tenant and both records are active, or throw the fail-closed reason.
1356
+ *
1357
+ * Kept for a coordinator from before #1718, which still calls it. The current one
1358
+ * calls `scopeAccessRefusal` instead, because a throw from here reaches it untyped.
1171
1359
  */
1172
1360
  validateScopeAccess(tenantId, scopeId) {
1361
+ const refusal = this.scopeAccessRefusal(tenantId, scopeId);
1362
+ if (refusal)
1363
+ throw new Error(refusal.message);
1364
+ }
1365
+ /**
1366
+ * The same gate, answering its refusal as DATA (#1718). An error thrown in a Durable
1367
+ * Object arrives at the coordinator flattened, its code gone, so a typed throw here
1368
+ * would still reach the caller untyped (#1714 measured it). A record crosses intact, so
1369
+ * the coordinator throws the refusal itself, typed. `code` is set only where a code is
1370
+ * known; the lifecycle refusals keep their bare messages. Null means access is allowed.
1371
+ */
1372
+ scopeAccessRefusal(tenantId, scopeId) {
1373
+ const refuse = (message) => ({ code: null, message });
1173
1374
  const row = this.sql
1174
1375
  .exec('SELECT tenant_id, status FROM scopes WHERE scope_id = ?', scopeId)
1175
1376
  .toArray()[0];
1176
1377
  if (!row || row.tenant_id !== tenantId) {
1177
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
1378
+ return { code: 'not_found', message: `unknown scope for tenant: (${tenantId}, ${scopeId})` };
1178
1379
  }
1179
1380
  const tenantRow = this.sql
1180
1381
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
1181
1382
  .toArray()[0];
1182
1383
  if (!tenantRow) {
1183
- throw new Error(`scope has no tenant record: (${tenantId}, ${scopeId})`);
1384
+ return refuse(`scope has no tenant record: (${tenantId}, ${scopeId})`);
1184
1385
  }
1185
1386
  if (tenantRow.status !== 'active') {
1186
- throw new Error(`tenant not active (status: ${tenantRow.status}): ${tenantId}`);
1387
+ return refuse(`tenant not active (status: ${tenantRow.status}): ${tenantId}`);
1187
1388
  }
1188
1389
  if (row.status !== 'active') {
1189
1390
  // A scope stuck in provisioning because its migrations failed must say so.
@@ -1194,42 +1395,68 @@ export class ControlPlaneDO extends DurableObject {
1194
1395
  .exec('SELECT migration_failed_version, migration_error FROM scopes WHERE scope_id = ?', scopeId)
1195
1396
  .toArray()[0];
1196
1397
  if (failure?.migration_failed_version) {
1197
- throw new Error(`migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
1398
+ return refuse(`migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
1198
1399
  `${failure.migration_error ?? 'unknown error'}`);
1199
1400
  }
1200
- throw new Error(`scope not active (status: ${row.status}): ${scopeId}`);
1401
+ return refuse(`scope not active (status: ${row.status}): ${scopeId}`);
1201
1402
  }
1403
+ return null;
1202
1404
  }
1203
1405
  /**
1204
1406
  * Validate ownership, enforce the legal transition graph (fail closed on an
1205
1407
  * illegal one), flip the status. Returns the previous status AND the scope's
1206
1408
  * vertical — both for the audit entry, sparing the coordinator a read
1207
1409
  * round-trip. `action` rides along only to name the illegal-transition message.
1410
+ *
1411
+ * Kept for a coordinator from before #1718, which still calls it. The current one
1412
+ * calls `transitionScopeOrRefusal` instead, because a throw from here reaches it untyped.
1208
1413
  */
1209
1414
  transitionScope(tenantId, scopeId, from, to, action) {
1415
+ const outcome = this.transitionScopeOrRefusal(tenantId, scopeId, from, to, action);
1416
+ if (!outcome.ok)
1417
+ throw new Error(outcome.message);
1418
+ return { status: outcome.status, vertical: outcome.vertical };
1419
+ }
1420
+ /**
1421
+ * The same transition, answering its refusal as DATA (#1718) — for the reason
1422
+ * `scopeAccessRefusal` does. The pair check has to be answered HERE, in the same
1423
+ * synchronous read as the write it guards: a coordinator-side pre-read cannot stand in
1424
+ * for it, since the row can be deleted (`deleteScopeDirectory`) between the two.
1425
+ */
1426
+ transitionScopeOrRefusal(tenantId, scopeId, from, to, action) {
1210
1427
  const row = this.sql
1211
1428
  .exec('SELECT tenant_id, status, vertical FROM scopes WHERE scope_id = ?', scopeId)
1212
1429
  .toArray()[0];
1213
1430
  if (!row || row.tenant_id !== tenantId) {
1214
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
1431
+ return { ok: false, code: 'not_found', message: `unknown scope for tenant: (${tenantId}, ${scopeId})` };
1215
1432
  }
1216
1433
  if (!from.includes(row.status)) {
1217
- throw new Error(`illegal scope transition for ${action}: ${row.status} → ${to} ` +
1218
- `(allowed from: ${from.join('|')})`);
1219
- }
1220
- // Stamp/clear archived_at so the reap sweep can age scopes. Entering `archived`
1221
- // records when; `unarchive` (→ active, a restore per §4.2) clears it so a later
1222
- // re-archive dates from the new event. `reaped` keeps it — it is terminal history.
1223
- if (to === 'archived') {
1224
- this.sql.exec('UPDATE scopes SET status = ?, archived_at = ? WHERE scope_id = ?', to, new Date().toISOString(), scopeId);
1225
- }
1226
- else if (to === 'active') {
1227
- this.sql.exec('UPDATE scopes SET status = ?, archived_at = NULL WHERE scope_id = ?', to, scopeId);
1228
- }
1229
- else {
1230
- this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
1231
- }
1232
- return { status: row.status, vertical: row.vertical };
1434
+ return {
1435
+ ok: false,
1436
+ code: null,
1437
+ message: `illegal scope transition for ${action}: ${row.status} → ${to} ` +
1438
+ `(allowed from: ${from.join('|')})`,
1439
+ };
1440
+ }
1441
+ // One transaction: the status and — on a reap — the scope's switch records (#1674).
1442
+ // Reaped is terminal, so a cleanup that failed after the flip could never be retried.
1443
+ this.ctx.storage.transactionSync(() => {
1444
+ // Stamp/clear archived_at so the reap sweep can age scopes. Entering `archived`
1445
+ // records when; `unarchive` (→ active, a restore per §4.2) clears it so a later
1446
+ // re-archive dates from the new event. `reaped` keeps it — it is terminal history.
1447
+ if (to === 'archived') {
1448
+ this.sql.exec('UPDATE scopes SET status = ?, archived_at = ? WHERE scope_id = ?', to, new Date().toISOString(), scopeId);
1449
+ }
1450
+ else if (to === 'active') {
1451
+ this.sql.exec('UPDATE scopes SET status = ?, archived_at = NULL WHERE scope_id = ?', to, scopeId);
1452
+ }
1453
+ else {
1454
+ this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
1455
+ }
1456
+ if (to === 'reaped')
1457
+ forgetSystemSwitchesOf(this.kernelSql, scopeId);
1458
+ });
1459
+ return { ok: true, status: row.status, vertical: row.vertical };
1233
1460
  }
1234
1461
  // -- roles (checker rule 1) -------------------------------------------------
1235
1462
  /** INSERT OR REPLACE; return the previous role (for the audit `before`) or null. */
@@ -1541,6 +1768,8 @@ export class ControlPlaneDO extends DurableObject {
1541
1768
  deleteVertical(slug) {
1542
1769
  this.sql.exec('DELETE FROM vertical_channels WHERE vertical_slug = ?', slug);
1543
1770
  this.sql.exec('DELETE FROM vertical_channel_history WHERE vertical_slug = ?', slug);
1771
+ this.sql.exec(`DELETE FROM vertical_version_migrations
1772
+ WHERE version_id IN (SELECT id FROM vertical_versions WHERE vertical_slug = ?)`, slug);
1544
1773
  this.sql.exec('DELETE FROM vertical_versions WHERE vertical_slug = ?', slug);
1545
1774
  this.sql.exec('DELETE FROM verticals WHERE slug = ?', slug);
1546
1775
  }
@@ -1556,12 +1785,24 @@ export class ControlPlaneDO extends DurableObject {
1556
1785
  .exec('SELECT * FROM vertical_versions WHERE id = ?', id)
1557
1786
  .toArray()[0];
1558
1787
  }
1788
+ /**
1789
+ * A version and its SQL migrations (#1764), in one transaction. `manifestJson` arrives with
1790
+ * the SQL already split off (`splitManifestMigrations`); `migrations` is null for a version
1791
+ * that carries none to show.
1792
+ */
1559
1793
  insertVersion(v) {
1560
- this.sql.exec(`INSERT INTO vertical_versions
1561
- (id, vertical_slug, version, manifest_digest, permission_digest,
1562
- migration_digest, deployment_ref, admission, admission_note, manifest_json,
1563
- origin_json, created_at)
1564
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, v.id, v.verticalSlug, v.version, v.manifestDigest, v.permissionDigest, v.migrationDigest, v.deploymentRef, v.admission, v.admissionNote, v.manifestJson, v.originJson, v.createdAt);
1794
+ this.ctx.storage.transactionSync(() => {
1795
+ this.sql.exec(`INSERT INTO vertical_versions
1796
+ (id, vertical_slug, version, manifest_digest, permission_digest,
1797
+ migration_digest, deployment_ref, admission, admission_note, manifest_json,
1798
+ origin_json, migration_count, migrations_split, created_at)
1799
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`, v.id, v.verticalSlug, v.version, v.manifestDigest, v.permissionDigest, v.migrationDigest, v.deploymentRef, v.admission, v.admissionNote, v.manifestJson, v.originJson, v.migrations?.length ?? null, v.createdAt);
1800
+ writeVersionMigrations(this.kernelSql, v.id, v.migrations);
1801
+ });
1802
+ }
1803
+ /** One version's SQL migrations (#1764), or `undefined` for no such version. */
1804
+ readVersionMigrations(id) {
1805
+ return versionMigrationsOf(this.kernelSql, id);
1565
1806
  }
1566
1807
  /** Record what the serving script now runs — written only after a successful
1567
1808
  * in-place upload, so a failed serve leaves the trailing state visible. */
@@ -1581,8 +1822,15 @@ export class ControlPlaneDO extends DurableObject {
1581
1822
  const where = ['vertical_slug = ?'];
1582
1823
  const params = [verticalSlug];
1583
1824
  const tail = keysetTail(where, params, 'id', page);
1825
+ // Named columns, never `manifest_json` itself. `json_valid` first: a manifest that is not
1826
+ // JSON lists with null surfaces, as `outboundOfManifestJson` reads it, rather than
1827
+ // failing the whole page on `->`.
1584
1828
  return this.sql
1585
- .exec(`SELECT * FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`, ...params)
1829
+ .exec(`SELECT id, vertical_slug, version, manifest_digest, permission_digest, migration_digest,
1830
+ deployment_ref, admission, admission_note, origin_json, created_at,
1831
+ CASE WHEN json_valid(manifest_json) THEN manifest_json -> '$.outbound' END AS outbound_json,
1832
+ CASE WHEN json_valid(manifest_json) THEN manifest_json -> '$.calls' END AS calls_json
1833
+ FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`, ...params)
1586
1834
  .toArray();
1587
1835
  }
1588
1836
  setAdmission(id, admission, note) {
@@ -1608,6 +1856,7 @@ export class ControlPlaneDO extends DurableObject {
1608
1856
  */
1609
1857
  deleteScopeDirectory(scopeId) {
1610
1858
  this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
1859
+ forgetSystemSwitchesOf(this.kernelSql, scopeId);
1611
1860
  this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
1612
1861
  }
1613
1862
  readChannel(verticalSlug, channel) {
@@ -2036,6 +2285,31 @@ export class ControlPlaneDO extends DurableObject {
2036
2285
  granted_by, granted_at, revoked_at)
2037
2286
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
2038
2287
  }
2288
+ // -- the schedule switch's record (#1674) — `system-switch-record.ts` is the whole rule ---
2289
+ /** OFF's write, after the scope's switch held. */
2290
+ recordSystemSwitchedOff(row) {
2291
+ recordSystemSwitchedOff(this.kernelSql, row);
2292
+ }
2293
+ /** ON's write, before the scope moves. Answers the row as it was, for a failed ON to restore. */
2294
+ recordSystemSwitchedOn(row) {
2295
+ return recordSystemSwitchedOn(this.kernelSql, row);
2296
+ }
2297
+ /** Put a row back as `recordSystemSwitchedOn` found it — the ON that followed it failed. */
2298
+ restoreSystemSwitchRecord(key, prior) {
2299
+ restoreSystemSwitchRecord(this.kernelSql, key, prior);
2300
+ }
2301
+ /** The fleet read. */
2302
+ listSystemSwitches(filter) {
2303
+ return listSystemSwitchRecords(this.kernelSql, filter);
2304
+ }
2305
+ /** One scope's recorded positions, by module — the status read's `recorded` join. */
2306
+ systemSwitchRecordsOf(tenantId, scopeId) {
2307
+ return [...systemSwitchRecordsOf(this.kernelSql, tenantId, scopeId)];
2308
+ }
2309
+ /** The modules a re-assert switches back off on one scope. */
2310
+ switchedOffModulesOf(tenantId, scopeId) {
2311
+ return switchedOffModulesOf(this.kernelSql, tenantId, scopeId);
2312
+ }
2039
2313
  /** The tenant's LIVE connection grants (#592) — the provision/reconcile gather read. */
2040
2314
  listConnectionGrants(tenantId) {
2041
2315
  return this.sql
@@ -2279,8 +2553,10 @@ export class ControlPlaneDO extends DurableObject {
2279
2553
  if (query.action) {
2280
2554
  if (query.action.length === 0)
2281
2555
  return []; // no action is acceptable — match nothing
2282
- where.push(`action IN (${query.action.map(() => '?').join(', ')})`);
2283
- params.push(...query.action);
2556
+ // ONE bound JSON array, not a `?` per entry (#1776): a DO binds at most 100
2557
+ // parameters, and nothing bounds this list's length (an action may repeat).
2558
+ where.push('action IN (SELECT value FROM json_each(?))');
2559
+ params.push(JSON.stringify(query.action));
2284
2560
  }
2285
2561
  if (query.since) {
2286
2562
  where.push('at >= ?');