@substrat-run/adapter-cloudflare 0.140.0 → 0.142.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,8 +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, 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, 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, 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, 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, executableSqlStatements } 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
7
  import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
7
8
  import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
8
9
  /**
@@ -139,6 +140,10 @@ const DIRECTORY_DDL = `
139
140
  status TEXT NOT NULL DEFAULT 'active',
140
141
  schema_version TEXT NOT NULL DEFAULT '0',
141
142
  vertical_version_id TEXT,
143
+ erasure_epoch INTEGER,
144
+ -- #1722: set once a reap has started draining this scope's script copies, and never
145
+ -- cleared: only deleting the row ends it. While set, no copy move is recorded or bound.
146
+ reap_claimed_at TEXT,
142
147
  -- Last FAILED migration attempt (§5.3). All null / 0 = healthy. Written on the
143
148
  -- failure path so a scope that fails closed stops rendering as active, and
144
149
  -- cleared on the next success. See ScopeDO.applyPendingMigrations.
@@ -154,12 +159,34 @@ const DIRECTORY_DDL = `
154
159
  -- filter (archived longer than N days); null for scopes that never archived. Stamped
155
160
  -- by transitionScope on the edge into archived, cleared on unarchive.
156
161
  archived_at TEXT,
162
+ -- #1524: the status it was archived FROM. 'provisioning' = it never held data, so the
163
+ -- storage gauge neither reads nor counts it. Written with archived_at, cleared with it.
164
+ archived_from_status TEXT,
157
165
  -- The scope's data lives in THIS dispatch script (#286). NULL = legacy per-version
158
166
  -- dispatch (the bound version's own script). Set at provision once the vertical has
159
167
  -- a serving script, or by adopt-serving when a legacy scope's data is moved over.
160
168
  serving_ref TEXT,
161
169
  created_at TEXT NOT NULL
162
170
  );
171
+ -- #1722: durable inventory of per-script copies. A fresh directory has no rows;
172
+ -- the historical audit-log backfill runs separately, after this additive migration.
173
+ CREATE TABLE IF NOT EXISTS scope_script_copies (
174
+ tenant_id TEXT NOT NULL,
175
+ scope_id TEXT NOT NULL,
176
+ script_ref TEXT NOT NULL,
177
+ move_id TEXT NOT NULL,
178
+ state TEXT NOT NULL,
179
+ load_stamp TEXT,
180
+ revision TEXT,
181
+ last_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
182
+ -- 'source' | 'destination'. A pending entry belongs to its move until lease_until; a
183
+ -- sweep that claims it after that sets lease_owner (kernel scope-copy-ledger.ts).
184
+ role TEXT,
185
+ lease_until TEXT,
186
+ lease_owner TEXT,
187
+ PRIMARY KEY (tenant_id, scope_id, script_ref, move_id)
188
+ );
189
+ CREATE INDEX IF NOT EXISTS scope_script_copies_state ON scope_script_copies (state, scope_id);
163
190
  ${PEER_BINDINGS_DDL}
164
191
  CREATE TABLE IF NOT EXISTS hostnames (
165
192
  hostname TEXT PRIMARY KEY,
@@ -607,6 +634,8 @@ const DIRECTORY_DDL = `
607
634
  );
608
635
  CREATE INDEX IF NOT EXISTS _substrat_model_usage_tenant ON _substrat_model_usage (tenant_id, at);
609
636
  CREATE INDEX IF NOT EXISTS _substrat_model_usage_at ON _substrat_model_usage (at);
637
+ -- #1524: the stored storage gauge — one row per (scope, UTC day), kernel-owned DDL.
638
+ ${SCOPE_STORAGE_DDL}
610
639
  CREATE INDEX IF NOT EXISTS scopes_tenant ON scopes (tenant_id, scope_id);
611
640
  -- #1713: the lifecycle each hosted scope's deployment last acknowledged, as
612
641
  -- "<scope status>/<tenant status>" (lifecycleReceipt). A CP-less deployment has no
@@ -653,6 +682,8 @@ const SCOPE_COLUMNS_ADDED = [
653
682
  'name TEXT',
654
683
  'vertical TEXT',
655
684
  'vertical_version_id TEXT',
685
+ 'erasure_epoch INTEGER',
686
+ 'reap_claimed_at TEXT',
656
687
  'provisioned_version_id TEXT',
657
688
  'migration_failed_version TEXT',
658
689
  'migration_error TEXT',
@@ -663,6 +694,7 @@ const SCOPE_COLUMNS_ADDED = [
663
694
  'expires_at TEXT',
664
695
  'serving_ref TEXT',
665
696
  'archived_at TEXT',
697
+ 'archived_from_status TEXT',
666
698
  ];
667
699
  /**
668
700
  * A directory DDL's statements in the order `applyDirectorySchema` runs them: `loop` before
@@ -1299,6 +1331,8 @@ export class ControlPlaneDO extends DurableObject {
1299
1331
  '_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
1300
1332
  '_substrat_findings', // #1748: the tenant's findings
1301
1333
  '_substrat_finding_rules', // #1748: the tenant's suppress rules
1334
+ '_substrat_scope_storage', // #1524: storage samples of scopes whose storage is gone
1335
+ '_substrat_scope_storage_attempts', // #1524: and the phase's last try at each
1302
1336
  ]) {
1303
1337
  this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
1304
1338
  }
@@ -1649,11 +1683,12 @@ export class ControlPlaneDO extends DurableObject {
1649
1683
  // Stamp/clear archived_at so the reap sweep can age scopes. Entering `archived`
1650
1684
  // records when; `unarchive` (→ active, a restore per §4.2) clears it so a later
1651
1685
  // re-archive dates from the new event. `reaped` keeps it — it is terminal history.
1686
+ // #1524: the status it left is recorded beside the stamp and cleared with it.
1652
1687
  if (to === 'archived') {
1653
- this.sql.exec('UPDATE scopes SET status = ?, archived_at = ? WHERE scope_id = ?', to, new Date().toISOString(), scopeId);
1688
+ this.sql.exec('UPDATE scopes SET status = ?, archived_at = ?, archived_from_status = ? WHERE scope_id = ?', to, new Date().toISOString(), row.status, scopeId);
1654
1689
  }
1655
1690
  else if (to === 'active') {
1656
- this.sql.exec('UPDATE scopes SET status = ?, archived_at = NULL WHERE scope_id = ?', to, scopeId);
1691
+ this.sql.exec('UPDATE scopes SET status = ?, archived_at = NULL, archived_from_status = NULL WHERE scope_id = ?', to, scopeId);
1657
1692
  }
1658
1693
  else {
1659
1694
  this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
@@ -1664,6 +1699,8 @@ export class ControlPlaneDO extends DurableObject {
1664
1699
  if (to === 'reaped') {
1665
1700
  forgetSwitchesOf(this.kernelSql, scopeId);
1666
1701
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
1702
+ // #1524: its storage is gone, so its samples describe nothing.
1703
+ forgetScopeStorage(doRedactionSql(this.sql), scopeId);
1667
1704
  }
1668
1705
  this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
1669
1706
  });
@@ -2081,9 +2118,11 @@ export class ControlPlaneDO extends DurableObject {
2081
2118
  this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
2082
2119
  serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, slug);
2083
2120
  }
2084
- /** Point a scope's routing at the serving script its data now lives in (#286). */
2085
- setScopeServingRef(scopeId, servingRef) {
2086
- this.sql.exec('UPDATE scopes SET serving_ref = ? WHERE scope_id = ?', servingRef, scopeId);
2121
+ /** Point a scope's routing at the serving script its data now lives in (#286). False when a
2122
+ * conditional move lost its compare-and-set: the host throws, since a refusal thrown here
2123
+ * would cross the RPC boundary without its code. */
2124
+ setScopeServingRef(scopeId, servingRef, expectedErasureEpoch, confirmMove) {
2125
+ return this.moveScopeRow('serving_ref = ?', [servingRef], scopeId, { expectedErasureEpoch, confirmMove });
2087
2126
  }
2088
2127
  /** Move a fork's GC deadline forward, or pin it (`null`) — preview-and-snapshots.md §9. */
2089
2128
  setScopeExpiresAt(scopeId, expiresAt) {
@@ -2107,13 +2146,152 @@ export class ControlPlaneDO extends DurableObject {
2107
2146
  setAdmission(id, admission, note) {
2108
2147
  this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id);
2109
2148
  }
2110
- bindScopeVersion(scopeId, versionId, verticalSlug, expectedVersionId) {
2111
- const update = expectedVersionId === undefined
2112
- ? this.sql.exec('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ?', versionId, verticalSlug, scopeId)
2113
- : this.sql.exec('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ? AND vertical_version_id IS ?', versionId, verticalSlug, scopeId, expectedVersionId);
2114
- if (update.rowsWritten === 0) {
2115
- throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
2149
+ /** False when a conditional bind lost its compare-and-set (the host throws, as above). */
2150
+ bindScopeVersion(scopeId, versionId, verticalSlug, expectedVersionId, expectedErasureEpoch, confirmMove) {
2151
+ return this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, verticalSlug], scopeId, { expectedVersionId, expectedErasureEpoch, confirmMove });
2152
+ }
2153
+ /**
2154
+ * Move a scope's binding or route (#1722), conditionally: on the binding the caller read, on
2155
+ * the erasure epoch its carry read (a NULL epoch, from before the column, is 0) with no reap
2156
+ * claimed, and on its copy move still owning a live lease. The move's confirmation commits in
2157
+ * the same transaction as the row. False when any condition failed and nothing was written.
2158
+ */
2159
+ moveScopeRow(set, setArgs, scopeId, cond) {
2160
+ const now = new Date().toISOString();
2161
+ return this.ctx.storage.transactionSync(() => {
2162
+ const moved = this.sql.exec(`UPDATE scopes SET ${set} WHERE scope_id = ?
2163
+ ${cond.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
2164
+ ${cond.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
2165
+ ${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)));
2166
+ if (moved.rowsWritten === 0)
2167
+ return false;
2168
+ if (cond.confirmMove) {
2169
+ const { tenant_id } = this.sql.exec('SELECT tenant_id FROM scopes WHERE scope_id = ?', scopeId).one();
2170
+ this.sql.exec(COPY_MOVE_CONFIRM_SQL, ...copyMoveConfirmParams(cond.confirmMove, tenant_id, scopeId, now));
2171
+ this.sql.exec(COPY_BACKFILL_SUPERSEDE_SQL, tenant_id, scopeId);
2172
+ }
2173
+ return true;
2174
+ });
2175
+ }
2176
+ scopeErasureEpoch(tenantId, scopeId) {
2177
+ // NULL on every row from before the column (#1722): the same 0 every CAS compares against.
2178
+ const row = this.sql.exec('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
2179
+ .toArray()[0];
2180
+ if (!row)
2181
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2182
+ return row.erasure_epoch;
2183
+ }
2184
+ claimSubjectErasure(tenantId, scopeId, expectedVersionId, expectedServingRef, expectedEpoch, expectedCopyCount) {
2185
+ return this.sql.exec(`UPDATE scopes SET erasure_epoch = COALESCE(erasure_epoch, 0) + 1
2186
+ WHERE tenant_id = ? AND scope_id = ? AND vertical_version_id IS ?
2187
+ AND serving_ref IS ? AND COALESCE(erasure_epoch, 0) = ?
2188
+ AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS copy
2189
+ WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id
2190
+ AND copy.state = 'pending')
2191
+ AND (SELECT COUNT(*) FROM scope_script_copies AS copy
2192
+ WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id) = ?`, tenantId, scopeId, expectedVersionId, expectedServingRef, expectedEpoch, expectedCopyCount).rowsWritten > 0;
2193
+ }
2194
+ recordScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, role = null, loadStamp = null, leaseMs = SCOPE_COPY_LEASE_MS) {
2195
+ if (!scriptRef || !moveId)
2196
+ return 'invalid';
2197
+ const written = this.sql.exec(`INSERT INTO scope_script_copies (tenant_id, scope_id, script_ref, move_id, state, role, load_stamp, lease_until)
2198
+ SELECT tenant_id, scope_id, ?, ?, 'pending', ?, ?, ? FROM scopes
2199
+ WHERE tenant_id = ? AND scope_id = ? AND reap_claimed_at IS NULL
2200
+ ON CONFLICT (tenant_id, scope_id, script_ref, move_id) DO NOTHING`, scriptRef, moveId, role, loadStamp, new Date(Date.now() + leaseMs).toISOString(), tenantId, scopeId);
2201
+ if (written.rowsWritten > 0)
2202
+ return 'recorded';
2203
+ const scope = this.sql.exec('SELECT reap_claimed_at FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
2204
+ .toArray()[0];
2205
+ if (!scope)
2206
+ return 'missing';
2207
+ if (scope.reap_claimed_at !== null)
2208
+ return 'reaping';
2209
+ return 'recorded'; // idempotent retry of this move
2210
+ }
2211
+ /**
2212
+ * Record a pre-ledger copy, and its `backfillScopeCopy` audit row in the same unit (#1722): the row
2213
+ * carries the erasure epoch read here, which the backfill orders erasures by, so an entry with no
2214
+ * row (an audit write lost after the insert) would let a later run compare against an older epoch.
2215
+ */
2216
+ backfillScopeScriptCopy(tenantId, scopeId, scriptRef, audit) {
2217
+ return this.ctx.storage.transactionSync(() => {
2218
+ const written = this.sql.exec(COPY_BACKFILL_SQL, ...copyBackfillParams(tenantId, scopeId, scriptRef)).rowsWritten > 0;
2219
+ const scope = this.sql.exec(COPY_BACKFILL_SCOPE_SQL, tenantId, scopeId).toArray()[0];
2220
+ if (!written)
2221
+ return copyBackfillRefusal(scope);
2222
+ this.recordAdmin({ ...audit, before: { erasureEpoch: scope.erasure_epoch } });
2223
+ return 'recorded';
2224
+ });
2225
+ }
2226
+ claimExpiredScopeScriptCopies(input) {
2227
+ const limit = assertRowLimit('limit', input.limit);
2228
+ return this.ctx.storage.transactionSync(() => {
2229
+ const due = this.sql.exec(COPY_EXPIRED_SQL, input.now, limit).toArray();
2230
+ const claimed = [];
2231
+ for (const r of due) {
2232
+ if (this.sql.exec(COPY_CLAIM_SQL, input.owner, input.leaseUntil, r.tenant_id, r.scope_id, r.script_ref, r.move_id, input.now).rowsWritten === 0)
2233
+ continue;
2234
+ claimed.push(this.sql.exec(`SELECT ${SCOPE_SCRIPT_COPY_COLUMNS} FROM scope_script_copies
2235
+ WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ?`, r.tenant_id, r.scope_id, r.script_ref, r.move_id).one());
2236
+ }
2237
+ return claimed;
2238
+ });
2239
+ }
2240
+ beginScopeScriptReap(tenantId, scopeId) {
2241
+ const written = this.sql.exec(`UPDATE scopes SET reap_claimed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
2242
+ WHERE tenant_id = ? AND scope_id = ? AND reap_claimed_at IS NULL
2243
+ AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS move
2244
+ WHERE move.tenant_id = scopes.tenant_id AND move.scope_id = scopes.scope_id
2245
+ AND move.state = 'pending')`, tenantId, scopeId);
2246
+ if (written.rowsWritten > 0)
2247
+ return 'claimed';
2248
+ const scope = this.sql.exec('SELECT reap_claimed_at FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
2249
+ .toArray()[0];
2250
+ if (!scope)
2251
+ return 'missing';
2252
+ return scope.reap_claimed_at !== null ? 'claimed' : 'pending'; // a reaper resumes its earlier claim
2253
+ }
2254
+ settleScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, state, loadStamp, revision, claimedBy) {
2255
+ return this.ctx.storage.transactionSync(() => {
2256
+ const settled = this.sql.exec(`UPDATE scope_script_copies SET state = ?, load_stamp = ?, revision = ?,
2257
+ last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
2258
+ WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ?
2259
+ AND (state <> 'done' OR ? = 'done')
2260
+ ${claimedBy === undefined ? '' : "AND state = 'pending' AND lease_owner = ?"}`, state, loadStamp, revision, tenantId, scopeId, scriptRef, moveId, state, ...(claimedBy === undefined ? [] : [claimedBy])).rowsWritten > 0;
2261
+ if (settled && state === 'done')
2262
+ this.sql.exec(COPY_BACKFILL_SUPERSEDE_SQL, tenantId, scopeId);
2263
+ return settled;
2264
+ });
2265
+ }
2266
+ touchScopeScriptCopy(tenantId, scopeId, scriptRef, moveId) {
2267
+ this.sql.exec(`UPDATE scope_script_copies SET last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
2268
+ WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ? AND state = 'eligible'`, tenantId, scopeId, scriptRef, moveId);
2269
+ }
2270
+ listScopeScriptCopies(filter) {
2271
+ const where = [];
2272
+ const args = [];
2273
+ if (filter.tenantId) {
2274
+ where.push('tenant_id = ?');
2275
+ args.push(filter.tenantId);
2276
+ }
2277
+ if (filter.scopeId) {
2278
+ where.push('scope_id = ?');
2279
+ args.push(filter.scopeId);
2280
+ }
2281
+ if (filter.state) {
2282
+ where.push('state = ?');
2283
+ args.push(filter.state);
2116
2284
  }
2285
+ if (filter.after) {
2286
+ if (!filter.tenantId || !filter.scopeId)
2287
+ throw substratError('validation_failed', 'copy cursor requires tenant and scope');
2288
+ where.push('(script_ref > ? OR (script_ref = ? AND move_id > ?))');
2289
+ args.push(filter.after.scriptRef, filter.after.scriptRef, filter.after.moveId);
2290
+ }
2291
+ const limit = assertRowLimit('limit', filter.limit ?? 100);
2292
+ const order = filter.scopeId ? 'script_ref, move_id' : 'last_attempt_at, tenant_id, scope_id, script_ref';
2293
+ return this.sql.exec(`SELECT ${SCOPE_SCRIPT_COPY_COLUMNS} FROM scope_script_copies ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
2294
+ ORDER BY ${order} LIMIT ?`, ...args, limit).toArray();
2117
2295
  }
2118
2296
  /**
2119
2297
  * Record which version this scope's provision last ran against (#1172).
@@ -2141,6 +2319,7 @@ export class ControlPlaneDO extends DurableObject {
2141
2319
  this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
2142
2320
  forgetSwitchesOf(this.kernelSql, scopeId);
2143
2321
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
2322
+ forgetScopeStorage(doRedactionSql(this.sql), scopeId);
2144
2323
  this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
2145
2324
  });
2146
2325
  }
@@ -2436,13 +2615,29 @@ export class ControlPlaneDO extends DurableObject {
2436
2615
  .exec(`SELECT tenant_id, slug, status FROM tenants${where}`, ...args)
2437
2616
  .toArray(),
2438
2617
  scopes: this.sql
2439
- .exec(`SELECT tenant_id, status FROM scopes${where}`, ...args)
2618
+ .exec(`SELECT tenant_id, status, archived_from_status FROM scopes${where}`, ...args)
2440
2619
  .toArray(),
2441
2620
  entitlements: this.sql
2442
2621
  .exec(`SELECT tenant_id, entitlement_key, plan, expires_at FROM _substrat_entitlements${where}`, ...args)
2443
2622
  .toArray(),
2623
+ // #1524: the stored gauge's latest sample per non-reaped scope. A directory read only.
2624
+ storage: listScopeStorageRows(doRedactionSql(this.sql), { tenantId: tenantId, latest: true }),
2625
+ storageAttempts: listScopeStorageAttemptRows(doRedactionSql(this.sql), tenantId),
2444
2626
  };
2445
2627
  }
2628
+ // -- the stored storage gauge (#1524) — kernel `storage-gauge.ts` --------------
2629
+ recordScopeStorage(readings) {
2630
+ return { recorded: this.ctx.storage.transactionSync(() => recordScopeStorageRows(doRedactionSql(this.sql), readings)) };
2631
+ }
2632
+ listScopeStorage(filter) {
2633
+ return listScopeStorageRows(doRedactionSql(this.sql), filter);
2634
+ }
2635
+ listScopeStorageAttempts() {
2636
+ return listScopeStorageAttemptRows(doRedactionSql(this.sql));
2637
+ }
2638
+ pruneScopeStorage(limit) {
2639
+ return pruneScopeStorageRows(doRedactionSql(this.sql), Date.now(), limit);
2640
+ }
2446
2641
  // -- identity pools (K-23) --------------------------------------------------
2447
2642
  readPool(provider) {
2448
2643
  return this.sql