@kedge/cli 0.9.0 → 0.10.1

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.
@@ -0,0 +1,68 @@
1
+ // The CLI's own audited batches against a store's D1 (p0.5 §3.4, D-054):
2
+ // `staff`, direct `keys create`, and a CLI session's row (p0.10 §6.2), each
3
+ // one file through `wrangler d1 execute`, locally or on Cloudflare.
4
+ import { spawnSync } from "node:child_process";
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import type { CliBatch } from "@kedge/db";
9
+ import { wranglerBin } from "./cf";
10
+ import { childFailed, childStdio } from "./output";
11
+
12
+ /** Runs one CLI batch against the store's D1; tests replace it. */
13
+ export type Executor = (
14
+ b: CliBatch,
15
+ opts: { database: string; remote: boolean; cwd: string; config?: string; env?: Record<string, string> },
16
+ ) => number;
17
+
18
+ /** The bootstrap Wrangler's whole environment: PATH, an empty HOME, and what Kedge resolved. */
19
+ export function bootstrapEnv(
20
+ env: NodeJS.ProcessEnv,
21
+ home: string,
22
+ resolved: Record<string, string> = {},
23
+ ): Record<string, string> {
24
+ return { PATH: env.PATH ?? "", HOME: home, WRANGLER_SEND_METRICS: "false", CI: "1", ...resolved };
25
+ }
26
+
27
+ export const wranglerExecute: Executor = (b, opts) => {
28
+ const dir = mkdtempSync(join(tmpdir(), "kedge-"));
29
+ const file = join(dir, "batch.sql");
30
+ // An empty dotenv file of our own: Wrangler then loads none of the store's, whose stale
31
+ // CLOUDFLARE_* values could redirect the resolved credentials (codex code r4).
32
+ const envFile = join(dir, "empty.env");
33
+ try {
34
+ writeFileSync(file, b.sql, { mode: 0o600 });
35
+ writeFileSync(envFile, "");
36
+ // The CLI's own Wrangler with only what Kedge resolved: an inherited environment could carry a
37
+ // global API key, which Wrangler prefers to a token and would send instead (codex code r5),
38
+ // and an empty HOME holds no one's login. The store directory stays the working one, for its
39
+ // config and its local state.
40
+ const r = spawnSync(
41
+ process.execPath,
42
+ [
43
+ wranglerBin(),
44
+ "d1",
45
+ "execute",
46
+ opts.database,
47
+ "--file",
48
+ file,
49
+ opts.remote ? "--remote" : "--local",
50
+ "--yes",
51
+ "--env-file",
52
+ envFile,
53
+ ...(opts.config ? ["-c", opts.config] : []),
54
+ ],
55
+ {
56
+ cwd: opts.cwd,
57
+ stdio: childStdio(),
58
+ env: bootstrapEnv(process.env, dir, opts.env),
59
+ maxBuffer: 1 << 26,
60
+ },
61
+ );
62
+ childFailed(r);
63
+ return r.status ?? 1;
64
+ } finally {
65
+ // The file holds no secret (only digests), but it needn't outlive the run.
66
+ rmSync(dir, { recursive: true, force: true });
67
+ }
68
+ };
package/src/deploy.ts CHANGED
@@ -11,7 +11,14 @@ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFi
11
11
  import { join, resolve } from "node:path";
12
12
  import { maintenanceModule } from "@kedge/runtime-cf/maintenance";
13
13
  import { DO_STATE_FORMAT } from "@kedge/runtime-cf/release";
14
- import { type Cloudflare, UnknownOutcome, type WorkerSettings } from "./cf";
14
+ import {
15
+ CHOOSE_JURISDICTION,
16
+ type Cloudflare,
17
+ type JurisdictionChoice,
18
+ NeedsPerson,
19
+ UnknownOutcome,
20
+ type WorkerSettings,
21
+ } from "./cf";
15
22
  import type { Lock, LockEntry } from "./compose";
16
23
  import { declaredClasses } from "./doctor";
17
24
  import type { Manifest } from "./manifest";
@@ -143,6 +150,8 @@ export interface DeployDeps {
143
150
  }
144
151
 
145
152
  export interface DeployOptions {
153
+ /** The person's choice of where a new store's data lives (p0.10 §7); read from the record after. */
154
+ jurisdiction?: JurisdictionChoice;
146
155
  vars?: Record<string, string>;
147
156
  window?: { from: number; to: number };
148
157
  adopt?: string[];
@@ -161,16 +170,29 @@ export class DeployRefused extends Error {
161
170
 
162
171
  const lockSha = (lock: Lock) => sha256(JSON.stringify(lock.entries.map((e) => [e.file, e.sha256])));
163
172
 
164
- /** The ops database: found or created by exact name, and refused unless it's this store's (§6.1). */
173
+ /**
174
+ * The ops database: found or created by exact name, and refused unless it's
175
+ * this store's (§6.1). Creating it fixes the store's jurisdiction (p0.10 §7),
176
+ * so only a caller given the person's choice may create it: `ask` (a first
177
+ * deploy or preview without one) hands off for it, and a caller passing
178
+ * nothing never creates, refusing a store that was never deployed. Cloudflare's record is the authority: a database
179
+ * found, made by a run that stopped or by another asking for something else,
180
+ * is read back and refused unless it matches the choice, and the record is
181
+ * written from its own value.
182
+ */
165
183
  export async function openOps(
166
184
  cf: Cloudflare,
167
185
  names: StoreNames,
168
186
  now = Date.now,
169
- ): Promise<{ ops: OpsStore; id: string }> {
187
+ want?: JurisdictionChoice | "ask",
188
+ ): Promise<{ ops: OpsStore; id: string; jurisdiction: JurisdictionChoice }> {
170
189
  let id = await cf.findD1(names.ops);
171
190
  if (!id) {
191
+ if (want === "ask") throw new NeedsPerson(CHOOSE_JURISDICTION);
192
+ if (want === undefined)
193
+ throw new DeployRefused(`no ${names.ops}: this store hasn't been deployed; run kedge deploy first`);
172
194
  try {
173
- id = await cf.createD1(names.ops);
195
+ id = await cf.createD1(names.ops, want === "none" ? null : want);
174
196
  } catch (e) {
175
197
  // Two first deploys contending here both end at the same database (§6.1). Whatever the
176
198
  // error said, the name is what decides: found, it's the other deploy's; the meta check
@@ -185,9 +207,17 @@ export async function openOps(
185
207
  ).map((r) => String(r.name));
186
208
  if (tables.length && !tables.includes("kedge_ops_meta"))
187
209
  throw new DeployRefused(`${names.ops} exists and isn't a Kedge operations database: it holds ${tables.join(", ")}`);
210
+ const actual = await cf.d1Jurisdiction(dbId);
211
+ if (want !== undefined && want !== "ask" && want !== actual)
212
+ throw new DeployRefused(
213
+ `${names.ops} exists in jurisdiction ${actual}, not ${want}: a store's jurisdiction is fixed when its first database is made`,
214
+ );
188
215
  const ops = new OpsStore((sql, params) => cf.queryD1(dbId, sql, params), now);
189
216
  await ops.init(names.worker);
190
- return { ops, id: dbId };
217
+ const recorded = await ops.settle("jurisdiction", actual);
218
+ if (recorded !== actual)
219
+ throw new DeployRefused(`${names.ops} records jurisdiction ${recorded}, but Cloudflare says ${actual}`);
220
+ return { ops, id: dbId, jurisdiction: actual };
191
221
  }
192
222
 
193
223
  /**
@@ -259,6 +289,8 @@ export class Deployer {
259
289
  readonly names: StoreNames;
260
290
  readonly wrangler: WranglerJson;
261
291
  private ops!: OpsStore;
292
+ /** The store's jurisdiction, as `<store>-ops` records it (p0.10 §7). */
293
+ private jurisdiction: JurisdictionChoice = "none";
262
294
  private opId!: string;
263
295
  private plan!: Plan;
264
296
  private progress: Progress = {};
@@ -317,6 +349,13 @@ export class Deployer {
317
349
  "wrangler.jsonc declares routes: kedge deploy manages cron and settings in P0.8, not routes or custom domains; attach a domain in the dashboard until P0.10",
318
350
  );
319
351
  const vars = { ...(this.wrangler.vars ?? {}), ...(o.vars ?? {}) };
352
+ // Every Durable Object ID is minted in the store's jurisdiction from this (p0.10 §7): another value
353
+ // would reach empty objects under the same names, so it's the record's, never the config's.
354
+ if (vars.KEDGE_JURISDICTION !== undefined && vars.KEDGE_JURISDICTION !== this.jurisdiction)
355
+ throw new DeployRefused(
356
+ `KEDGE_JURISDICTION is ${vars.KEDGE_JURISDICTION} in the config or --var, but this store's jurisdiction is ${this.jurisdiction}`,
357
+ );
358
+ vars.KEDGE_JURISDICTION = this.jurisdiction;
320
359
  const config: WranglerJson = { ...this.wrangler, vars };
321
360
  delete config.build;
322
361
  const dbs = (config.d1_databases ?? []).map((d) =>
@@ -398,9 +437,13 @@ export class Deployer {
398
437
  create: () => Promise<string>,
399
438
  ): Promise<string> {
400
439
  const rec = await this.ops.resource(kind, name);
440
+ // A recorded database is checked like a found one: the record is ours, the jurisdiction Cloudflare's (codex code r12).
441
+ if (rec?.id && kind === "d1") await this.sameJurisdiction(name, rec.id);
401
442
  if (rec?.id) return rec.id;
402
443
  const found = await find();
403
444
  if (found) {
445
+ // A database found is accepted only in the store's jurisdiction: Cloudflare can't change it after (p0.10 §7).
446
+ if (kind === "d1") await this.sameJurisdiction(name, found);
404
447
  if (rec?.state === "creating" || this.plan.adopt.includes(name)) {
405
448
  await this.ops.recordResource(kind, name, found, "ours");
406
449
  return found;
@@ -424,6 +467,12 @@ export class Deployer {
424
467
  return id;
425
468
  }
426
469
 
470
+ private async sameJurisdiction(name: string, id: string): Promise<void> {
471
+ const j = await this.d.cf.d1Jurisdiction(id);
472
+ if (j !== this.jurisdiction)
473
+ throw new DeployRefused(`${name} is in jurisdiction ${j}, and this store's is ${this.jurisdiction}`);
474
+ }
475
+
427
476
  private async ownWorker(): Promise<boolean> {
428
477
  const rec = await this.ops.resource("worker", this.names.worker);
429
478
  const exists = await this.d.cf.findWorker(this.names.worker);
@@ -487,7 +536,8 @@ export class Deployer {
487
536
  const v = this.d.env?.[s];
488
537
  if (v !== undefined && v.length < 32) throw new DeployRefused(`${s} in the environment is under 32 bytes`);
489
538
  }
490
- const { ops } = await openOps(this.d.cf, this.names, this.now);
539
+ const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now, o.jurisdiction ?? "ask");
540
+ this.jurisdiction = jurisdiction;
491
541
  this.ops = ops;
492
542
  const held = await ops.current(SCOPE);
493
543
  if (o.resume || o.takeOver) {
@@ -552,8 +602,9 @@ export class Deployer {
552
602
  * a resume, with its unanswered calls unknown.
553
603
  */
554
604
  private async end(e: unknown): Promise<void> {
555
- const clean =
556
- e instanceof DeployRefused && !this.progress.deploymentId && (await this.ops.unresolved(SCOPE)).length === 0;
605
+ // A handoff is a refusal too: nothing ran that a resume would need (p0.10 §5).
606
+ const refused = e instanceof DeployRefused || e instanceof NeedsPerson;
607
+ const clean = refused && !this.progress.deploymentId && (await this.ops.unresolved(SCOPE)).length === 0;
557
608
  if (clean) await this.ops.finish(SCOPE, this.opId, "refused", [this.plan]).catch(() => {});
558
609
  else await this.ops.stop(SCOPE, this.opId).catch(() => {});
559
610
  }
@@ -717,7 +768,7 @@ export class Deployer {
717
768
  "d1",
718
769
  this.names.database,
719
770
  () => cf.findD1(this.names.database),
720
- () => cf.createD1(this.names.database),
771
+ () => cf.createD1(this.names.database, this.jurisdiction === "none" ? null : this.jurisdiction),
721
772
  );
722
773
  p.queueId = await this.own(
723
774
  "queue",
@@ -781,9 +832,10 @@ export class Deployer {
781
832
  if (!p.deploymentId) this.compatible(servingRow);
782
833
  if (p.mode === "cutover" && !p.first && !p.deploymentId) {
783
834
  const w = this.plan.window;
835
+ // Downtime on a store that has served customers is the merchant's to schedule (p0.10 §5).
784
836
  if (!w || this.now() < w.from || this.now() > w.to)
785
- await this.refuse(
786
- "a pending core migration is a cutover: on a store that has served traffic it needs --maintenance-window <from>/<to>, with now inside it (§6.3)",
837
+ throw new NeedsPerson(
838
+ "this release changes the database in a way the running store can't serve through, so the store shows a maintenance page for a few minutes while it applies: choose when, then run kedge deploy --maintenance-window <from>/<to> during that window",
787
839
  );
788
840
  }
789
841
  await this.step("classified");
@@ -1100,7 +1152,8 @@ export class Deployer {
1100
1152
  */
1101
1153
  async supersede(opId: string): Promise<void> {
1102
1154
  if ((await this.d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
1103
- const { ops } = await openOps(this.d.cf, this.names, this.now);
1155
+ const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
1156
+ this.jurisdiction = jurisdiction;
1104
1157
  this.ops = ops;
1105
1158
  const held = await ops.current(SCOPE);
1106
1159
  if (!held || held.op_id !== opId) throw new DeployRefused(`no operation ${opId} holds this store`);
@@ -1189,7 +1242,8 @@ export class Deployer {
1189
1242
  throw new DeployRefused(
1190
1243
  "--resolve-intent needs --not-executed: it's a statement that the call never took effect",
1191
1244
  );
1192
- const { ops } = await openOps(this.d.cf, this.names, this.now);
1245
+ const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
1246
+ this.jurisdiction = jurisdiction;
1193
1247
  const i = await ops.intent(id);
1194
1248
  // Also a migration reconcile closed for its absence: absence permits a retry, only a person can say it never ran.
1195
1249
  const restatable = i?.kind === "migrate" && i.state === "not_executed" && !i.resolved_by?.startsWith("stated by");
@@ -1205,7 +1259,8 @@ export class Deployer {
1205
1259
 
1206
1260
  async recordActive(reason: string): Promise<void> {
1207
1261
  if (!reason) throw new DeployRefused("--record-active needs --reason");
1208
- const { ops } = await openOps(this.d.cf, this.names, this.now);
1262
+ const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
1263
+ this.jurisdiction = jurisdiction;
1209
1264
  this.ops = ops;
1210
1265
  // The operation first, then the reads it records (codex code r12).
1211
1266
  this.opId = await ops.start(SCOPE, "record-active", { reason }, "record");
@@ -1250,7 +1305,8 @@ export class Deployer {
1250
1305
  }
1251
1306
 
1252
1307
  async abortCutover(): Promise<void> {
1253
- const { ops } = await openOps(this.d.cf, this.names, this.now);
1308
+ const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
1309
+ this.jurisdiction = jurisdiction;
1254
1310
  this.ops = ops;
1255
1311
  const held = await ops.current(SCOPE);
1256
1312
  if (!held) throw new DeployRefused("no operation to abort");
@@ -1303,7 +1359,8 @@ export class Deployer {
1303
1359
  async secret(name: string, value: string | null): Promise<void> {
1304
1360
  if (!/^[A-Z][A-Z0-9_]*$/.test(name))
1305
1361
  throw new DeployRefused("a secret's name is upper-case letters, digits and underscores");
1306
- const { ops } = await openOps(this.d.cf, this.names, this.now);
1362
+ const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now);
1363
+ this.jurisdiction = jurisdiction;
1307
1364
  this.ops = ops;
1308
1365
  const rows = await ops.deployments();
1309
1366
  this.plan = { commit: "secret", adopt: [], vars: {} } as unknown as Plan;
@@ -1410,7 +1467,11 @@ export class Deployer {
1410
1467
  /** A fresh run, or (from `kedge deploy --resume`) the held one, which this checkout's lock must match. */
1411
1468
  async migrateRemote(held?: OperationRow): Promise<void> {
1412
1469
  // A resumed run keeps the executor that claimed it.
1413
- if (!held) this.ops = (await openOps(this.d.cf, this.names, this.now)).ops;
1470
+ if (!held) {
1471
+ const opened = await openOps(this.d.cf, this.names, this.now);
1472
+ this.ops = opened.ops;
1473
+ this.jurisdiction = opened.jurisdiction;
1474
+ }
1414
1475
  const ops = this.ops;
1415
1476
  const db = await ops.resource("d1", this.names.database);
1416
1477
  if (!db?.id) throw new DeployRefused("no database is recorded for this store: kedge deploy makes it");
@@ -1481,8 +1542,19 @@ export class Deployer {
1481
1542
  }
1482
1543
 
1483
1544
  // ---- §6.1: a store deployed before P0.8 ---------------------------------------------------------------
1484
- async adoptExisting(release: string, texts: (file: string) => string): Promise<void> {
1485
- const { ops } = await openOps(this.d.cf, this.names, this.now);
1545
+ async adoptExisting(release: string, texts: (file: string) => string, choice?: JurisdictionChoice): Promise<void> {
1546
+ // A store deployed before P0.10 minted its Durable Objects with no jurisdiction, so its jurisdiction is
1547
+ // none whatever anyone would choose now: another would address new, empty objects under the same
1548
+ // names. Its database says so too, read before anything is made (codex code r12).
1549
+ const live = await this.d.cf.findD1(this.names.database);
1550
+ if (!live) throw new DeployRefused(`no database named ${this.names.database}`);
1551
+ const actual = await this.d.cf.d1Jurisdiction(live);
1552
+ if (actual !== "none" || (choice !== undefined && choice !== "none"))
1553
+ throw new DeployRefused(
1554
+ `a store deployed before P0.10 keeps jurisdiction none (its Durable Objects were made without one); ${this.names.database} is ${actual}${choice ? ` and --jurisdiction says ${choice}` : ""}`,
1555
+ );
1556
+ const { ops, jurisdiction } = await openOps(this.d.cf, this.names, this.now, "none");
1557
+ this.jurisdiction = jurisdiction;
1486
1558
  this.ops = ops;
1487
1559
  // The operation first, then every read it decides on: read before, a deploy finishing in between
1488
1560
  // would be recorded over with what it replaced (codex code r12).
package/src/doctor.ts CHANGED
@@ -26,6 +26,8 @@ export interface CheckResult {
26
26
  message?: string;
27
27
  /** Lines under the message: each problem, or each stated exception. */
28
28
  details?: string[];
29
+ /** A failure only a person can fix (p0.10 §5): doctor then exits 3 with this as its `for you:` line. */
30
+ handoff?: string;
29
31
  }
30
32
 
31
33
  export interface DoctorInput {
@@ -447,6 +449,52 @@ const FAMILY_SECRET: Record<string, string> = {
447
449
  totp: "KEDGE_TOTP_KEY_V",
448
450
  };
449
451
 
452
+ /**
453
+ * Whether the resolved credentials reach the store's account, claiming only
454
+ * what a read can show (p0.10 §4). A list succeeding proves the token answers
455
+ * for this account; it doesn't prove a write (Cloudflare's D1 list takes `D1
456
+ * Read` or `D1 Write`, creating a database only the latter:
457
+ * https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/list/),
458
+ * so the writes are named as unverified, never passed. The token itself is never shown.
459
+ */
460
+ export async function credentialsChecks(input: {
461
+ cf: import("./cf").Cloudflare;
462
+ source: string;
463
+ base: string;
464
+ }): Promise<CheckResult[]> {
465
+ const { NeedsPerson } = await import("./cf");
466
+ const where = `from ${input.source}, sent to ${input.base}, account ${input.cf.account}`;
467
+ const read = await input.cf.readable();
468
+ const refused = Object.entries(read).filter(([, status]) => status !== null);
469
+ const out: CheckResult[] = [];
470
+ if (refused.length === Object.keys(read).length && refused.every(([, s]) => s === 401 || s === 403))
471
+ out.push(fail("credentials", `the token doesn't answer for this account (${where})`));
472
+ else if (refused.length)
473
+ out.push(
474
+ fail(
475
+ "credentials",
476
+ `the token can't list everything a store uses (${where})`,
477
+ refused.map(([kind, s]) => (s ? `${kind}: Cloudflare answered ${s}` : `${kind}: no answer`)),
478
+ ),
479
+ );
480
+ else
481
+ out.push(
482
+ pass(
483
+ "credentials",
484
+ `${where}; lists Workers, D1 and Queues. Writes are unverified until the first deploy: Workers Scripts Edit, D1 Edit, Queues Edit`,
485
+ ),
486
+ );
487
+ try {
488
+ out.push(pass("workers.dev", `${await input.cf.workersSubdomain()}.workers.dev`));
489
+ } catch (e) {
490
+ // Any answer is a report: a token that can't read the subdomain fails this check, never the run (codex code r7).
491
+ // An account without one is the owner's to choose: a handoff, not a technical failure (codex code r9).
492
+ if (e instanceof NeedsPerson) out.push({ ...fail("workers.dev", e.handoff), handoff: e.handoff });
493
+ else out.push(fail("workers.dev", (e as Error).message));
494
+ }
495
+ return out;
496
+ }
497
+
450
498
  export interface RemoteDoctorInput {
451
499
  cwd: string;
452
500
  cf: import("./cf").Cloudflare;
@@ -465,9 +513,19 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
465
513
  const names = storeNames(input.cwd).names;
466
514
  const opsId = await input.cf.findD1(names.ops);
467
515
  if (!opsId) return [skip("remote", `no ${names.ops}: this store hasn't been deployed with kedge deploy`)];
468
- const { ops } = await openOps(input.cf, names);
516
+ const { ops, jurisdiction } = await openOps(input.cf, names);
469
517
  const db = await ops.resource("d1", names.database);
470
518
 
519
+ // jurisdiction: the store's database is where <store>-ops records the store's data lives (p0.10 §7).
520
+ if (db?.id) {
521
+ const actual = await input.cf.d1Jurisdiction(db.id);
522
+ out.push(
523
+ actual === jurisdiction
524
+ ? pass("jurisdiction", jurisdiction)
525
+ : fail("jurisdiction", `${names.database} is in ${actual}, but the store's jurisdiction is ${jurisdiction}`),
526
+ );
527
+ }
528
+
471
529
  // applied: D1's record is a prefix of the lock, and what ops recorded of each file's bytes still holds.
472
530
  if (db?.id) {
473
531
  const problems = appliedProblems(input.lock, await appliedNames(input.cf, db.id), await ops.appliedHashes());
@@ -559,7 +617,7 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
559
617
 
560
618
  // previews: old ones, and lost creates whose names are re-checked for 7 days (§3.1).
561
619
  const previews = await ops.query(
562
- "SELECT identity, worker, database_name, queue_name, state, lost_create, updated_at FROM preview",
620
+ "SELECT identity, worker, database_name, database_id, queue_name, state, lost_create, updated_at FROM preview",
563
621
  );
564
622
  const old = previews.filter((p) => p.state !== "destroyed" && now - Number(p.updated_at) > 14 * 86_400_000);
565
623
  const orphans: string[] = [];
@@ -578,7 +636,14 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
578
636
  if (await input.cf.findD1(String(p.database_name))) orphans.push(`database ${String(p.database_name)}`);
579
637
  if (await input.cf.findQueue(String(p.queue_name))) orphans.push(`queue ${String(p.queue_name)}`);
580
638
  }
581
- if (orphans.length)
639
+ // A live preview's database where its store's data lives (p0.10 §7, codex code r13).
640
+ const misplaced: string[] = [];
641
+ for (const p of previews.filter((p) => p.state === "bootstrapped" && p.database_id))
642
+ if ((await input.cf.d1Jurisdiction(String(p.database_id))) !== jurisdiction)
643
+ misplaced.push(`database ${String(p.database_name)}`);
644
+ if (misplaced.length)
645
+ out.push(fail("previews", `a preview's database isn't in the store's jurisdiction, ${jurisdiction}`, misplaced));
646
+ else if (orphans.length)
582
647
  out.push(
583
648
  fail("previews", "a destroyed preview's late create left resources: kedge preview destroy --sweep", orphans),
584
649
  );