@substrat-run/adapter-cloudflare 0.147.0 → 0.149.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, 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, 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,6 +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 driver's stored releases, rollouts and holds (D-63, #2300).
251
+ ${STORE_DRIVER_RELEASES_DDL}
250
252
  CREATE TABLE IF NOT EXISTS verticals (
251
253
  slug TEXT PRIMARY KEY,
252
254
  name TEXT NOT NULL,
@@ -305,10 +307,14 @@ const DIRECTORY_DDL = `
305
307
  migration_count INTEGER,
306
308
  -- 1 once the SQL is out of manifest_json. NULL = stored before #1764, not yet backfilled.
307
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,
308
313
  created_at TEXT NOT NULL,
309
314
  UNIQUE (vertical_slug, version)
310
315
  );
311
316
  ${VERSION_MIGRATIONS_DDL}
317
+ ${VERTICAL_SCOPE_STORES_DDL}
312
318
  CREATE TABLE IF NOT EXISTS vertical_channels (
313
319
  vertical_slug TEXT NOT NULL,
314
320
  channel TEXT NOT NULL,
@@ -776,6 +782,22 @@ export const REPLIED_METHODS = [
776
782
  'linkIdentity',
777
783
  // #1748: refuses an expiry outside its bounds with validation_failed.
778
784
  'createFindingRule',
785
+ // #2286: refuses a scope-store binding moved to another driver with conflict, and an unreadable manifest with unavailable.
786
+ 'insertVersion',
787
+ 'verticalScopeStores',
788
+ // #2300: a store driver's release, rollout and hold writes refuse with conflict, not_found,
789
+ // validation_failed or unavailable.
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',
779
801
  ];
780
802
  export class ControlPlaneDO extends DurableObject {
781
803
  sql;
@@ -800,7 +822,7 @@ export class ControlPlaneDO extends DurableObject {
800
822
  * instead (`importDump`).
801
823
  */
802
824
  async armVersionMigrationsBackfill() {
803
- if (!versionsAwaitSplit(this.kernelSql))
825
+ if (!versionsAwaitSplit(this.kernelSql) && !versionsAwaitDigest(this.kernelSql))
804
826
  return;
805
827
  // An alarm already set is kept: it is the backfill's own next run, and a backoff retry
806
828
  // pulled forward to 1 s by any ordinary request would make the backoff meaningless.
@@ -821,6 +843,9 @@ export class ControlPlaneDO extends DurableObject {
821
843
  let more;
822
844
  try {
823
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;
824
849
  }
825
850
  catch (err) {
826
851
  const failures = ((await this.ctx.storage.get(BACKFILL_FAILURES_KEY)) ?? 0) + 1;
@@ -895,6 +920,18 @@ export class ControlPlaneDO extends DurableObject {
895
920
  * creates each table with its backfill; `holdFences` does the same for #1184's removal fence.
896
921
  */
897
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);
898
935
  const held = holdSwitchRecords.map(switchesTableOf);
899
936
  for (const stmt of DIRECTORY_DDL_PLAN.loop) {
900
937
  if (held.some((table) => stmt.includes(table)))
@@ -907,6 +944,8 @@ export class ControlPlaneDO extends DurableObject {
907
944
  // #1764's, held back by `planDirectoryDdl`: its index names a column added just above.
908
945
  for (const stmt of DIRECTORY_DDL_PLAN.afterColumns)
909
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');
910
949
  }
911
950
  /**
912
951
  * The directory's own migration path (control-plane.md §7). A singleton DO that
@@ -1124,6 +1163,8 @@ export class ControlPlaneDO extends DurableObject {
1124
1163
  // #1764: the SQL migrations moved out of the manifest, and the backfill's progress.
1125
1164
  this.addColumn('vertical_versions', 'migration_count INTEGER');
1126
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');
1127
1168
  // #33: the SKU flag learns to express a plan. All nullable — a legacy row
1128
1169
  // reads as a perpetual boolean flag, exactly its pre-widening semantics.
1129
1170
  this.addColumn('_substrat_entitlements', 'expires_at TEXT');
@@ -1246,8 +1287,9 @@ export class ControlPlaneDO extends DurableObject {
1246
1287
  // next construction arms the backfill, since it finds none set.
1247
1288
  try {
1248
1289
  await this.ctx.storage.delete(BACKFILL_FAILURES_KEY);
1249
- if (versionsAwaitSplit(this.kernelSql))
1290
+ if (versionsAwaitSplit(this.kernelSql) || versionsAwaitDigest(this.kernelSql)) {
1250
1291
  await this.ctx.storage.setAlarm(Date.now() + BACKFILL_PAUSE_MS);
1292
+ }
1251
1293
  }
1252
1294
  catch (err) {
1253
1295
  console.error('substrat: the restore committed, but its version-migrations backfill was not armed', err);
@@ -1470,6 +1512,48 @@ export class ControlPlaneDO extends DurableObject {
1470
1512
  ' ORDER BY tenant_id, vertical, binding', ...params)
1471
1513
  .toArray();
1472
1514
  }
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
1517
+ // call is one transaction, and the object runs one at a time, so a hold is taken atomically.
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));
1532
+ }
1533
+ storeDriverRolloutWalked(input) {
1534
+ return this.ctx.storage.transactionSync(() => storeDriverRolloutWalkedIn(this.kernelSql, input));
1535
+ }
1536
+ holdStoreDriver(input) {
1537
+ return this.ctx.storage.transactionSync(() => holdStoreDriverIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1538
+ }
1539
+ releaseStoreDriverHold(input) {
1540
+ releaseStoreDriverHoldIn(this.kernelSql, input);
1541
+ }
1542
+ beginStoreDriverRollout(input) {
1543
+ return this.ctx.storage.transactionSync(() => beginStoreDriverRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1544
+ }
1545
+ finishStoreDriverRollout(input) {
1546
+ return this.ctx.storage.transactionSync(() => finishStoreDriverRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1547
+ }
1548
+ storeDriverRebuild(driver) {
1549
+ return storeDriverRebuildIn(this.kernelSql, driver);
1550
+ }
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() }));
1556
+ }
1473
1557
  // -- scope lifecycle (control-plane.md §4.1/§4.2) ---------------------------
1474
1558
  /**
1475
1559
  * Mandatory active tenant: a scope with no tenant record is the "tenant is an
@@ -2020,8 +2104,14 @@ export class ControlPlaneDO extends DurableObject {
2020
2104
  this.sql.exec(`UPDATE hostnames SET status = ?, status_note = ?, validation_records = ? WHERE hostname = ?`, fields.status, fields.note, fields.validationRecords, hostname);
2021
2105
  }
2022
2106
  }
2023
- deleteHostname(hostname) {
2024
- this.sql.exec('DELETE FROM hostnames WHERE hostname = ?', hostname);
2107
+ /**
2108
+ * Delete a binding and answer the row it removed, or null. With `owner`, only while the row
2109
+ * still names that tenant and scope (#2285): the delete decides it, not an earlier read.
2110
+ */
2111
+ deleteHostname(hostname, owner) {
2112
+ const rows = this.sql.exec(`DELETE FROM hostnames WHERE hostname = ?${owner ? ' AND tenant_id = ? AND scope_id = ?' : ''}
2113
+ RETURNING tenant_id, scope_id, status`, hostname, ...(owner ? [owner.tenantId, owner.scopeId] : [])).toArray();
2114
+ return rows[0] ?? null;
2025
2115
  }
2026
2116
  listHostnames(filter) {
2027
2117
  const where = [];
@@ -2112,6 +2202,7 @@ export class ControlPlaneDO extends DurableObject {
2112
2202
  this.sql.exec('DELETE FROM vertical_channel_history WHERE vertical_slug = ?', slug);
2113
2203
  this.sql.exec(`DELETE FROM vertical_version_migrations
2114
2204
  WHERE version_id IN (SELECT id FROM vertical_versions WHERE vertical_slug = ?)`, slug);
2205
+ forgetVerticalScopeStores(this.kernelSql, slug);
2115
2206
  this.sql.exec('DELETE FROM vertical_versions WHERE vertical_slug = ?', slug);
2116
2207
  this.sql.exec('DELETE FROM verticals WHERE slug = ?', slug);
2117
2208
  }
@@ -2122,10 +2213,17 @@ export class ControlPlaneDO extends DurableObject {
2122
2213
  const sql = 'SELECT * FROM verticals' + (where.length ? ` WHERE ${where.join(' AND ')}` : '') + tail;
2123
2214
  return this.sql.exec(sql, ...params).toArray();
2124
2215
  }
2125
- readVersion(id) {
2126
- return this.sql
2127
- .exec('SELECT * FROM vertical_versions WHERE id = ?', id)
2128
- .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();
2129
2227
  }
2130
2228
  /**
2131
2229
  * A version and its SQL migrations (#1764), in one transaction. `manifestJson` arrives with
@@ -2137,11 +2235,17 @@ export class ControlPlaneDO extends DurableObject {
2137
2235
  this.sql.exec(`INSERT INTO vertical_versions
2138
2236
  (id, vertical_slug, version, manifest_digest, permission_digest,
2139
2237
  migration_digest, deployment_ref, admission, admission_note, manifest_json,
2140
- origin_json, migration_count, migrations_split, created_at)
2141
- 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);
2142
2240
  writeVersionMigrations(this.kernelSql, v.id, v.migrations);
2241
+ // #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
2242
+ indexVersionScopeStores(this.kernelSql, v.verticalSlug, v.id, v.manifestJson);
2143
2243
  });
2144
2244
  }
2245
+ /** Every scope-store binding any version of the vertical declared (#2286); indexes versions not indexed yet. */
2246
+ verticalScopeStores(slug) {
2247
+ return this.ctx.storage.transactionSync(() => verticalScopeStoresOf(this.kernelSql, slug));
2248
+ }
2145
2249
  /** One version's SQL migrations (#1764), or `undefined` for no such version. */
2146
2250
  readVersionMigrations(id) {
2147
2251
  return versionMigrationsOf(this.kernelSql, id);
@@ -2162,27 +2266,45 @@ export class ControlPlaneDO extends DurableObject {
2162
2266
  setScopeExpiresAt(scopeId, expiresAt) {
2163
2267
  this.sql.exec('UPDATE scopes SET expires_at = ? WHERE scope_id = ?', expiresAt, scopeId);
2164
2268
  }
2165
- listVersions(verticalSlug, page) {
2269
+ async listVersions(verticalSlug, page) {
2166
2270
  const where = ['vertical_slug = ?'];
2167
2271
  const params = [verticalSlug];
2168
2272
  const tail = keysetTail(where, params, 'id', page);
2169
2273
  // Named columns, never `manifest_json` itself. `json_valid` first: a manifest that is not
2170
2274
  // JSON lists with null surfaces, as `outboundOfManifestJson` reads it, rather than
2171
2275
  // failing the whole page on `->`.
2172
- return this.sql
2276
+ const read = () => this.sql
2173
2277
  .exec(`SELECT id, vertical_slug, version, manifest_digest, permission_digest, migration_digest,
2174
- deployment_ref, admission, admission_note, origin_json, created_at,
2175
- CASE WHEN json_valid(manifest_json) THEN manifest_json -> '$.outbound' END AS outbound_json,
2176
- CASE WHEN json_valid(manifest_json) THEN manifest_json -> '$.calls' END AS calls_json
2177
- 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)
2178
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();
2179
2291
  }
2180
2292
  setAdmission(id, admission, note) {
2181
2293
  this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id);
2182
2294
  }
2183
- /** False when a conditional bind lost its compare-and-set (the host throws, as above). */
2184
- bindScopeVersion(scopeId, versionId, verticalSlug, expectedVersionId, expectedErasureEpoch, confirmMove) {
2185
- 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;
2186
2308
  }
2187
2309
  /**
2188
2310
  * Move a scope's binding or route (#1722), conditionally: on the binding the caller read, on
@@ -2196,7 +2318,8 @@ export class ControlPlaneDO extends DurableObject {
2196
2318
  const moved = this.sql.exec(`UPDATE scopes SET ${set} WHERE scope_id = ?
2197
2319
  ${cond.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
2198
2320
  ${cond.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
2199
- ${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)));
2200
2323
  if (moved.rowsWritten === 0)
2201
2324
  return false;
2202
2325
  if (cond.confirmMove) {
@@ -2215,15 +2338,9 @@ export class ControlPlaneDO extends DurableObject {
2215
2338
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2216
2339
  return row.erasure_epoch;
2217
2340
  }
2218
- claimSubjectErasure(tenantId, scopeId, expectedVersionId, expectedServingRef, expectedEpoch, expectedCopyCount) {
2219
- return this.sql.exec(`UPDATE scopes SET erasure_epoch = COALESCE(erasure_epoch, 0) + 1
2220
- WHERE tenant_id = ? AND scope_id = ? AND vertical_version_id IS ?
2221
- AND serving_ref IS ? AND COALESCE(erasure_epoch, 0) = ?
2222
- AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS copy
2223
- WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id
2224
- AND copy.state = 'pending')
2225
- AND (SELECT COUNT(*) FROM scope_script_copies AS copy
2226
- WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id) = ?`, tenantId, scopeId, expectedVersionId, expectedServingRef, expectedEpoch, expectedCopyCount).rowsWritten > 0;
2341
+ /** The erasure claim (`SUBJECT_ERASURE_CLAIM_SQL`): false when the scope moved since the fence was read. */
2342
+ claimSubjectErasure(tenantId, scopeId, fence) {
2343
+ return this.sql.exec(SUBJECT_ERASURE_CLAIM_SQL, ...subjectErasureClaimParams(tenantId, scopeId, fence)).rowsWritten > 0;
2227
2344
  }
2228
2345
  recordScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, role = null, loadStamp = null, leaseMs = SCOPE_COPY_LEASE_MS) {
2229
2346
  if (!scriptRef || !moveId)
@@ -2376,10 +2493,23 @@ export class ControlPlaneDO extends DurableObject {
2376
2493
  .exec('SELECT * FROM vertical_channels WHERE vertical_slug = ? AND channel = ?', verticalSlug, channel)
2377
2494
  .toArray()[0];
2378
2495
  }
2379
- setChannel(verticalSlug, channel, versionId, updatedAt) {
2380
- this.sql.exec(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
2381
- VALUES (?, ?, ?, ?)
2382
- 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
+ });
2383
2513
  }
2384
2514
  listChannels(verticalSlug, page) {
2385
2515
  const where = ['vertical_slug = ?'];