@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.
- package/dist/control-plane-do.d.ts +153 -4
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +317 -41
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +103 -4
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +507 -89
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/kick-coalescer-do.d.ts +57 -0
- package/dist/kick-coalescer-do.d.ts.map +1 -0
- package/dist/kick-coalescer-do.js +106 -0
- package/dist/kick-coalescer-do.js.map +1 -0
- package/dist/scope-do.d.ts +7 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +91 -27
- package/dist/scope-do.js.map +1 -1
- package/package.json +7 -5
package/dist/control-plane-do.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1384
|
+
return refuse(`scope has no tenant record: (${tenantId}, ${scopeId})`);
|
|
1184
1385
|
}
|
|
1185
1386
|
if (tenantRow.status !== 'active') {
|
|
1186
|
-
|
|
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
|
-
|
|
1398
|
+
return refuse(`migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
|
|
1198
1399
|
`${failure.migration_error ?? 'unknown error'}`);
|
|
1199
1400
|
}
|
|
1200
|
-
|
|
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
|
-
|
|
1431
|
+
return { ok: false, code: 'not_found', message: `unknown scope for tenant: (${tenantId}, ${scopeId})` };
|
|
1215
1432
|
}
|
|
1216
1433
|
if (!from.includes(row.status)) {
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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.
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
|
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
|
-
|
|
2283
|
-
|
|
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 >= ?');
|