@substrat-run/adapter-cloudflare 0.148.0 → 0.150.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,9 +1,9 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink as insertConnectLinkRow, listConnectLinks as listConnectLinkRows, readConnectLink as readConnectLinkRow, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, VERTICAL_SCOPE_STORES_DDL, STORE_KIND_RELEASES_DDL, beginStoreKindRolloutIn, finishStoreKindRolloutIn, holdStoreKindIn, kindDeclarationsOf, recordStoreKindReleaseIn, releaseStoreKindHoldIn, storeKindReleaseIn, storeKindStateIn, forgetVerticalScopeStores, indexVersionScopeStores, verticalScopeStoresOf, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, samePeerVertical, peerVerticalSpellings, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, unknownOutcomeOf, ADMIN_LOG_INDEX_DDL, ADMIN_LOG_INDEXES_SQL, readAuditedOperations, } from '@substrat-run/kernel';
2
+ import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink as insertConnectLinkRow, listConnectLinks as listConnectLinkRows, readConnectLink as readConnectLinkRow, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, VERTICAL_SCOPE_STORES_DDL, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, driverDeclarationsOf, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, forgetVerticalScopeStores, indexVersionScopeStores, verticalScopeStoresOf, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, versionsAwaitDigest, deriveMigrationDigestsBatch, deriveVersionMigrationDigest, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, samePeerVertical, peerVerticalSpellings, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, unknownOutcomeOf, ADMIN_LOG_INDEX_DDL, ADMIN_LOG_INDEXES_SQL, readAuditedOperations, } from '@substrat-run/kernel';
3
3
  import { replyOf } from './do-reply.js';
4
4
  import { switchSqlOver } from './scope-do.js';
5
5
  import { blankSqlComments, boundShapesColumn, executableSqlStatements, installsReconciledOf, PROVISIONED_VERSIONS_SQL, provisionedVersionsParams, } from '@substrat-run/kernel';
6
- import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
6
+ import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
7
7
  import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
8
8
  import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
9
9
  /**
@@ -247,8 +247,8 @@ const DIRECTORY_DDL = `
247
247
  created_at TEXT NOT NULL,
248
248
  PRIMARY KEY (tenant_id, vertical, binding)
249
249
  );
250
- -- A store kind's stored releases, rollouts and holds (D-63, #2300).
251
- ${STORE_KIND_RELEASES_DDL}
250
+ -- A store driver's stored releases, rollouts and holds (D-63, #2300).
251
+ ${STORE_DRIVER_RELEASES_DDL}
252
252
  CREATE TABLE IF NOT EXISTS verticals (
253
253
  slug TEXT PRIMARY KEY,
254
254
  name TEXT NOT NULL,
@@ -307,6 +307,9 @@ const DIRECTORY_DDL = `
307
307
  migration_count INTEGER,
308
308
  -- 1 once the SQL is out of manifest_json. NULL = stored before #1764, not yet backfilled.
309
309
  migrations_split INTEGER,
310
+ -- 1 once migration_digest is the one derived from the stored migrations (#2339).
311
+ -- NULL = stored before, still the pushed digest; backfilled, and derived on read until then.
312
+ migration_digest_derived INTEGER,
310
313
  created_at TEXT NOT NULL,
311
314
  UNIQUE (vertical_slug, version)
312
315
  );
@@ -779,16 +782,22 @@ export const REPLIED_METHODS = [
779
782
  'linkIdentity',
780
783
  // #1748: refuses an expiry outside its bounds with validation_failed.
781
784
  'createFindingRule',
782
- // #2286: refuses a re-kinded scope-store binding with conflict, and an unreadable manifest with unavailable.
785
+ // #2286: refuses a scope-store binding moved to another driver with conflict, and an unreadable manifest with unavailable.
783
786
  'insertVersion',
784
787
  'verticalScopeStores',
785
- // #2300: a store kind's release, rollout and hold writes refuse with conflict, not_found,
788
+ // #2300: a store driver's release, rollout and hold writes refuse with conflict, not_found,
786
789
  // validation_failed or unavailable.
787
- 'recordStoreKindRelease',
788
- 'storeKindDeclarations',
789
- 'holdStoreKind',
790
- 'beginStoreKindRollout',
791
- 'finishStoreKindRollout',
790
+ 'recordStoreDriverRelease',
791
+ 'storeDriverDeclarations',
792
+ // #2312: a rollout page refuses a limit that is not positive, and an unknown rollout.
793
+ 'storeDriverRolloutPending',
794
+ 'storeDriverRolloutWalked',
795
+ 'holdStoreDriver',
796
+ 'beginStoreDriverRollout',
797
+ 'finishStoreDriverRollout',
798
+ // #2309: a fleet rebuild's lease write refuses with conflict or validation_failed.
799
+ 'claimStoreDriverRebuild',
800
+ 'advanceStoreDriverRebuild',
792
801
  ];
793
802
  export class ControlPlaneDO extends DurableObject {
794
803
  sql;
@@ -813,7 +822,7 @@ export class ControlPlaneDO extends DurableObject {
813
822
  * instead (`importDump`).
814
823
  */
815
824
  async armVersionMigrationsBackfill() {
816
- if (!versionsAwaitSplit(this.kernelSql))
825
+ if (!versionsAwaitSplit(this.kernelSql) && !versionsAwaitDigest(this.kernelSql))
817
826
  return;
818
827
  // An alarm already set is kept: it is the backfill's own next run, and a backoff retry
819
828
  // pulled forward to 1 s by any ordinary request would make the backoff meaningless.
@@ -834,6 +843,9 @@ export class ControlPlaneDO extends DurableObject {
834
843
  let more;
835
844
  try {
836
845
  more = this.ctx.storage.transactionSync(() => splitVersionMigrationsBatch(this.kernelSql)).more;
846
+ // #2339: then re-derive the migration digests of versions stored before the server did.
847
+ // Not in the transaction: the digest is computed between its read and its guarded write.
848
+ more = (await deriveMigrationDigestsBatch(this.kernelSql)).more || more;
837
849
  }
838
850
  catch (err) {
839
851
  const failures = ((await this.ctx.storage.get(BACKFILL_FAILURES_KEY)) ?? 0) + 1;
@@ -908,6 +920,18 @@ export class ControlPlaneDO extends DurableObject {
908
920
  * creates each table with its backfill; `holdFences` does the same for #1184's removal fence.
909
921
  */
910
922
  buildDirectorySchema({ holdSwitchRecords, holdFences = false }) {
923
+ // A directory built before the store driver rename keeps its rows under the old names (and one
924
+ // that ran pre-rename code again, a rollback, holds both names), and
925
+ // the DDL below would only create empty tables beside them. Probed, not attempted: an empty
926
+ // `SELECT` reads the columns here, and an emptied directory (a restore) has nothing to move.
927
+ const tables = this.sql.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`).toArray().map((t) => t.name);
928
+ const hasRows = (table) => this.sql.exec(`SELECT 1 FROM "${table}" LIMIT 1`).toArray().length > 0;
929
+ // Asked before the moves below, which drop a name with no rows.
930
+ const bothNames = legacyStoreDriverConflict(tables, hasRows);
931
+ if (bothNames)
932
+ console.error(bothNames);
933
+ for (const stmt of legacyStoreDriverRenames(tables, (table) => doBuiltColumnsOf(this.sql, table), hasRows))
934
+ this.sql.exec(stmt);
911
935
  const held = holdSwitchRecords.map(switchesTableOf);
912
936
  for (const stmt of DIRECTORY_DDL_PLAN.loop) {
913
937
  if (held.some((table) => stmt.includes(table)))
@@ -920,6 +944,8 @@ export class ControlPlaneDO extends DurableObject {
920
944
  // #1764's, held back by `planDirectoryDdl`: its index names a column added just above.
921
945
  for (const stmt of DIRECTORY_DDL_PLAN.afterColumns)
922
946
  this.sql.exec(stmt);
947
+ // #2322: a migration's declared moves and discards, on a table created before they were carried.
948
+ this.addColumn('vertical_version_migrations', 'declarations TEXT');
923
949
  }
924
950
  /**
925
951
  * The directory's own migration path (control-plane.md §7). A singleton DO that
@@ -1137,6 +1163,8 @@ export class ControlPlaneDO extends DurableObject {
1137
1163
  // #1764: the SQL migrations moved out of the manifest, and the backfill's progress.
1138
1164
  this.addColumn('vertical_versions', 'migration_count INTEGER');
1139
1165
  this.addColumn('vertical_versions', 'migrations_split INTEGER');
1166
+ // #2339: whether the migration digest is the derived one; NULL rows are backfilled.
1167
+ this.addColumn('vertical_versions', 'migration_digest_derived INTEGER');
1140
1168
  // #33: the SKU flag learns to express a plan. All nullable — a legacy row
1141
1169
  // reads as a perpetual boolean flag, exactly its pre-widening semantics.
1142
1170
  this.addColumn('_substrat_entitlements', 'expires_at TEXT');
@@ -1259,8 +1287,9 @@ export class ControlPlaneDO extends DurableObject {
1259
1287
  // next construction arms the backfill, since it finds none set.
1260
1288
  try {
1261
1289
  await this.ctx.storage.delete(BACKFILL_FAILURES_KEY);
1262
- if (versionsAwaitSplit(this.kernelSql))
1290
+ if (versionsAwaitSplit(this.kernelSql) || versionsAwaitDigest(this.kernelSql)) {
1263
1291
  await this.ctx.storage.setAlarm(Date.now() + BACKFILL_PAUSE_MS);
1292
+ }
1264
1293
  }
1265
1294
  catch (err) {
1266
1295
  console.error('substrat: the restore committed, but its version-migrations backfill was not armed', err);
@@ -1483,32 +1512,47 @@ export class ControlPlaneDO extends DurableObject {
1483
1512
  ' ORDER BY tenant_id, vertical, binding', ...params)
1484
1513
  .toArray();
1485
1514
  }
1486
- // -- store-kind releases, rollouts and holds (D-63, #2300) ---------------------
1487
- // Kernel code over the directory (store-kind-releases.ts), shared with the SQLite host. Each
1515
+ // -- store-driver releases, rollouts and holds (D-63, #2300) ---------------------
1516
+ // Kernel code over the directory (store-driver-releases.ts), shared with the SQLite host. Each
1488
1517
  // call is one transaction, and the object runs one at a time, so a hold is taken atomically.
1489
- recordStoreKindRelease(input) {
1490
- return this.ctx.storage.transactionSync(() => recordStoreKindReleaseIn(this.kernelSql, input));
1518
+ recordStoreDriverRelease(input) {
1519
+ return this.ctx.storage.transactionSync(() => recordStoreDriverReleaseIn(this.kernelSql, input));
1520
+ }
1521
+ getStoreDriverRelease(driver, sha256) {
1522
+ return storeDriverReleaseIn(this.kernelSql, driver, sha256);
1523
+ }
1524
+ storeDriverState(driver) {
1525
+ return this.ctx.storage.transactionSync(() => storeDriverStateIn(this.kernelSql, driver, Date.now()));
1526
+ }
1527
+ storeDriverDeclarations(driver) {
1528
+ return this.ctx.storage.transactionSync(() => driverDeclarationsOf(this.kernelSql, driver));
1529
+ }
1530
+ storeDriverRolloutPending(input) {
1531
+ return this.ctx.storage.transactionSync(() => storeDriverRolloutPendingIn(this.kernelSql, input));
1491
1532
  }
1492
- getStoreKindRelease(kind, sha256) {
1493
- return storeKindReleaseIn(this.kernelSql, kind, sha256);
1533
+ storeDriverRolloutWalked(input) {
1534
+ return this.ctx.storage.transactionSync(() => storeDriverRolloutWalkedIn(this.kernelSql, input));
1494
1535
  }
1495
- storeKindState(kind) {
1496
- return this.ctx.storage.transactionSync(() => storeKindStateIn(this.kernelSql, kind, Date.now()));
1536
+ holdStoreDriver(input) {
1537
+ return this.ctx.storage.transactionSync(() => holdStoreDriverIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1497
1538
  }
1498
- storeKindDeclarations(kind) {
1499
- return this.ctx.storage.transactionSync(() => kindDeclarationsOf(this.kernelSql, kind));
1539
+ releaseStoreDriverHold(input) {
1540
+ releaseStoreDriverHoldIn(this.kernelSql, input);
1500
1541
  }
1501
- holdStoreKind(input) {
1502
- return this.ctx.storage.transactionSync(() => holdStoreKindIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1542
+ beginStoreDriverRollout(input) {
1543
+ return this.ctx.storage.transactionSync(() => beginStoreDriverRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1503
1544
  }
1504
- releaseStoreKindHold(input) {
1505
- releaseStoreKindHoldIn(this.kernelSql, input);
1545
+ finishStoreDriverRollout(input) {
1546
+ return this.ctx.storage.transactionSync(() => finishStoreDriverRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1506
1547
  }
1507
- beginStoreKindRollout(input) {
1508
- return this.ctx.storage.transactionSync(() => beginStoreKindRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1548
+ storeDriverRebuild(driver) {
1549
+ return storeDriverRebuildIn(this.kernelSql, driver);
1509
1550
  }
1510
- finishStoreKindRollout(input) {
1511
- return this.ctx.storage.transactionSync(() => finishStoreKindRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1551
+ claimStoreDriverRebuild(input) {
1552
+ return this.ctx.storage.transactionSync(() => claimStoreDriverRebuildIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1553
+ }
1554
+ advanceStoreDriverRebuild(input) {
1555
+ return this.ctx.storage.transactionSync(() => advanceStoreDriverRebuildIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1512
1556
  }
1513
1557
  // -- scope lifecycle (control-plane.md §4.1/§4.2) ---------------------------
1514
1558
  /**
@@ -2169,10 +2213,17 @@ export class ControlPlaneDO extends DurableObject {
2169
2213
  const sql = 'SELECT * FROM verticals' + (where.length ? ` WHERE ${where.join(' AND ')}` : '') + tail;
2170
2214
  return this.sql.exec(sql, ...params).toArray();
2171
2215
  }
2172
- readVersion(id) {
2173
- return this.sql
2174
- .exec('SELECT * FROM vertical_versions WHERE id = ?', id)
2175
- .toArray()[0];
2216
+ /**
2217
+ * One version. A version stored before the server derived migration digests (#2339) is derived
2218
+ * first, so a gate never compares the digest its push sent while the backfill has not reached it.
2219
+ */
2220
+ async readVersion(id) {
2221
+ const read = () => this.sql.exec('SELECT * FROM vertical_versions WHERE id = ?', id).toArray()[0];
2222
+ const row = read();
2223
+ if (row?.migration_digest_derived !== null)
2224
+ return row;
2225
+ await deriveVersionMigrationDigest(this.kernelSql, id);
2226
+ return read();
2176
2227
  }
2177
2228
  /**
2178
2229
  * A version and its SQL migrations (#1764), in one transaction. `manifestJson` arrives with
@@ -2184,10 +2235,10 @@ export class ControlPlaneDO extends DurableObject {
2184
2235
  this.sql.exec(`INSERT INTO vertical_versions
2185
2236
  (id, vertical_slug, version, manifest_digest, permission_digest,
2186
2237
  migration_digest, deployment_ref, admission, admission_note, manifest_json,
2187
- origin_json, migration_count, migrations_split, created_at)
2188
- 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);
2238
+ origin_json, migration_count, migrations_split, migration_digest_derived, created_at)
2239
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 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);
2189
2240
  writeVersionMigrations(this.kernelSql, v.id, v.migrations);
2190
- // #2286: the version's scope-store bindings, refusing one re-kinded, in the insert's transaction.
2241
+ // #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
2191
2242
  indexVersionScopeStores(this.kernelSql, v.verticalSlug, v.id, v.manifestJson);
2192
2243
  });
2193
2244
  }
@@ -2215,27 +2266,45 @@ export class ControlPlaneDO extends DurableObject {
2215
2266
  setScopeExpiresAt(scopeId, expiresAt) {
2216
2267
  this.sql.exec('UPDATE scopes SET expires_at = ? WHERE scope_id = ?', expiresAt, scopeId);
2217
2268
  }
2218
- listVersions(verticalSlug, page) {
2269
+ async listVersions(verticalSlug, page) {
2219
2270
  const where = ['vertical_slug = ?'];
2220
2271
  const params = [verticalSlug];
2221
2272
  const tail = keysetTail(where, params, 'id', page);
2222
2273
  // Named columns, never `manifest_json` itself. `json_valid` first: a manifest that is not
2223
2274
  // JSON lists with null surfaces, as `outboundOfManifestJson` reads it, rather than
2224
2275
  // failing the whole page on `->`.
2225
- return this.sql
2276
+ const read = () => this.sql
2226
2277
  .exec(`SELECT id, vertical_slug, version, manifest_digest, permission_digest, migration_digest,
2227
- deployment_ref, admission, admission_note, origin_json, created_at,
2228
- CASE WHEN json_valid(manifest_json) THEN manifest_json -> '$.outbound' END AS outbound_json,
2229
- CASE WHEN json_valid(manifest_json) THEN manifest_json -> '$.calls' END AS calls_json
2230
- FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`, ...params)
2278
+ migration_digest_derived, deployment_ref, admission, admission_note, origin_json, created_at,
2279
+ CASE WHEN json_valid(manifest_json) THEN manifest_json -> '$.outbound' END AS outbound_json,
2280
+ CASE WHEN json_valid(manifest_json) THEN manifest_json -> '$.calls' END AS calls_json
2281
+ FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`, ...params)
2231
2282
  .toArray();
2283
+ const rows = read();
2284
+ // #2339: a page holding versions whose digest is still the pushed one derives them first.
2285
+ const underived = rows.filter((r) => r.migration_digest_derived === null);
2286
+ if (underived.length === 0)
2287
+ return rows;
2288
+ for (const r of underived)
2289
+ await deriveVersionMigrationDigest(this.kernelSql, r.id);
2290
+ return read();
2232
2291
  }
2233
2292
  setAdmission(id, admission, note) {
2234
2293
  this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id);
2235
2294
  }
2236
- /** False when a conditional bind lost its compare-and-set (the host throws, as above). */
2237
- bindScopeVersion(scopeId, versionId, verticalSlug, expectedVersionId, expectedErasureEpoch, confirmMove) {
2238
- return this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, verticalSlug], scopeId, { expectedVersionId, expectedErasureEpoch, confirmMove });
2295
+ /**
2296
+ * False when a conditional bind lost its compare-and-set (the host throws, as above). #2347:
2297
+ * `servingRef` moves the route in the same write, and `expectedServing` holds it to what the
2298
+ * target lineage's serving script runs, read in this same synchronous transaction.
2299
+ */
2300
+ bindScopeVersion(scopeId, versionId, verticalSlug, expectedVersionId, expectedErasureEpoch, confirmMove, servingRef, expectedServing) {
2301
+ return servingRef === undefined
2302
+ ? this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, verticalSlug], scopeId, { expectedVersionId, expectedErasureEpoch, confirmMove, expectedServing })
2303
+ : this.moveScopeRow('vertical_version_id = ?, vertical = ?, serving_ref = ?', [versionId, verticalSlug, servingRef], scopeId, { expectedVersionId, expectedErasureEpoch, confirmMove, expectedServing });
2304
+ }
2305
+ /** #2347: whether a lineage's serving script still runs the version a rebind was judged against. */
2306
+ servesNow(expected) {
2307
+ return this.sql.exec(`SELECT 1 FROM verticals WHERE ${SERVES_NOW_CONDITION}`, ...servesNowParams(expected)).toArray().length > 0;
2239
2308
  }
2240
2309
  /**
2241
2310
  * Move a scope's binding or route (#1722), conditionally: on the binding the caller read, on
@@ -2249,7 +2318,8 @@ export class ControlPlaneDO extends DurableObject {
2249
2318
  const moved = this.sql.exec(`UPDATE scopes SET ${set} WHERE scope_id = ?
2250
2319
  ${cond.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
2251
2320
  ${cond.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
2252
- ${cond.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}`, ...setArgs, scopeId, ...(cond.expectedVersionId === undefined ? [] : [cond.expectedVersionId]), ...(cond.expectedErasureEpoch === undefined ? [] : [cond.expectedErasureEpoch]), ...(cond.confirmMove === undefined ? [] : copyMoveLiveParams(cond.confirmMove, now)));
2321
+ ${cond.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}
2322
+ ${cond.expectedServing === undefined ? '' : SERVES_NOW_PREDICATE}`, ...setArgs, scopeId, ...(cond.expectedVersionId === undefined ? [] : [cond.expectedVersionId]), ...(cond.expectedErasureEpoch === undefined ? [] : [cond.expectedErasureEpoch]), ...(cond.confirmMove === undefined ? [] : copyMoveLiveParams(cond.confirmMove, now)), ...(cond.expectedServing === undefined ? [] : servesNowParams(cond.expectedServing)));
2253
2323
  if (moved.rowsWritten === 0)
2254
2324
  return false;
2255
2325
  if (cond.confirmMove) {
@@ -2423,10 +2493,23 @@ export class ControlPlaneDO extends DurableObject {
2423
2493
  .exec('SELECT * FROM vertical_channels WHERE vertical_slug = ? AND channel = ?', verticalSlug, channel)
2424
2494
  .toArray()[0];
2425
2495
  }
2426
- setChannel(verticalSlug, channel, versionId, updatedAt) {
2427
- this.sql.exec(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
2428
- VALUES (?, ?, ?, ?)
2429
- ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`, verticalSlug, channel, versionId, updatedAt, versionId, updatedAt);
2496
+ /**
2497
+ * #2333: move a channel's pointer, and record the move, only if it still names `from` (null: the
2498
+ * channel has none yet). A compare-and-set in one synchronous transaction, so no other promote
2499
+ * runs between the read and the write: a promote judged its gates against `from` across awaits,
2500
+ * and the one that lost the race is told so (`false`) and writes nothing.
2501
+ */
2502
+ moveChannel(from, history) {
2503
+ return this.ctx.storage.transactionSync(() => {
2504
+ const now = this.readChannel(history.vertical_slug, history.channel);
2505
+ if ((now?.version_id ?? null) !== from)
2506
+ return false;
2507
+ this.sql.exec(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
2508
+ VALUES (?, ?, ?, ?)
2509
+ ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`, history.vertical_slug, history.channel, history.version_id, history.at, history.version_id, history.at);
2510
+ this.insertChannelHistory(history);
2511
+ return true;
2512
+ });
2430
2513
  }
2431
2514
  listChannels(verticalSlug, page) {
2432
2515
  const where = ['vertical_slug = ?'];