@kedge/cli 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/deploy.ts ADDED
@@ -0,0 +1,1610 @@
1
+ // `kedge deploy` and `kedge migrate --remote` (docs/proposals/p0.8-cli-agent-kit.md §6).
2
+ // Migrations apply before the Worker, from the plan's own staged bytes. An
3
+ // ordinary deploy is N−1: the previous Worker survives every pending file, by
4
+ // proof or by statement (n1.ts). A core cutover runs behind a maintenance
5
+ // Worker and the fence its migration installs. Every control-plane call is an
6
+ // intent recorded first and closed by evidence, never by time (§3.1); every
7
+ // step runs under an operation this process holds in the ops database, so a
8
+ // lost runner loses nothing and two runners can't both act.
9
+ import { randomBytes } from "node:crypto";
10
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { maintenanceModule } from "@kedge/runtime-cf/maintenance";
13
+ import { DO_STATE_FORMAT } from "@kedge/runtime-cf/release";
14
+ import { type Cloudflare, UnknownOutcome, type WorkerSettings } from "./cf";
15
+ import type { Lock, LockEntry } from "./compose";
16
+ import { declaredClasses } from "./doctor";
17
+ import type { Manifest } from "./manifest";
18
+ import { type Classified, classify } from "./n1";
19
+ import { type DeploymentRow, type IntentRow, LostOwnership, OperationHeld, type OperationRow, OpsStore } from "./ops";
20
+ import { byCodePoint } from "./order";
21
+ import { schemaOf } from "./schema";
22
+ import {
23
+ classesKey,
24
+ dirtyBeyondId,
25
+ type Git,
26
+ hashTree,
27
+ PLACEHOLDER_ID,
28
+ packageVersions,
29
+ type StoreNames,
30
+ sha256,
31
+ storeNames,
32
+ type WranglerJson,
33
+ } from "./store";
34
+
35
+ export const SCOPE = "production";
36
+ /** Core's secrets, generated only at a first deploy and only where missing (§6.1). */
37
+ export const CORE_SECRETS = [
38
+ "CART_TOKEN_SECRET",
39
+ "ORDER_TOKEN_SECRET",
40
+ "KEDGE_AUTH_SECRET_V1",
41
+ "KEDGE_KEY_PEPPER_V1",
42
+ "KEDGE_TOTP_KEY_V1",
43
+ ] as const;
44
+ /** Publications a cutover's abort must survive: maintenance and the release (§6.3). */
45
+ const CUTOVER_PUBLICATIONS = 2;
46
+ /** For anything done outside Kedge. */
47
+ const OUTSIDE_MARGIN = 10;
48
+
49
+ export interface Plan {
50
+ /** A person's --allow-older, kept with the plan so a resume honours it and nothing else does. */
51
+ allowOlder?: boolean;
52
+ version: 1;
53
+ commit: string;
54
+ release: string;
55
+ upgradesFrom: string | null;
56
+ doStateFormat: number;
57
+ lockSha: string;
58
+ /** Every composed file's hash: what's staged, and what a resume must reproduce. */
59
+ files: Record<string, string>;
60
+ vars: Record<string, string>;
61
+ /** The effective Wrangler config: wrangler.jsonc with ops's ids and the plan's vars, paths relative. */
62
+ config: WranglerJson;
63
+ packages: Record<string, string>;
64
+ modules: Record<string, string>;
65
+ /** The admin's staged assets, each file's hash (p0.9 §3.3): a resume must reproduce them too. */
66
+ assets?: Record<string, string>;
67
+ window: { from: number; to: number } | null;
68
+ /** --adopt names a person said are the store's own. */
69
+ adopt: string[];
70
+ }
71
+
72
+ export interface Progress {
73
+ databaseId?: string;
74
+ queueId?: string;
75
+ first?: boolean;
76
+ deploymentId?: string;
77
+ mode?: "n1" | "cutover";
78
+ pending?: string[];
79
+ classified?: Classified[];
80
+ maintenanceVersion?: string;
81
+ appVersion?: string;
82
+ /** The plan `appVersion` was uploaded for: a version is reused only by the plan it was built from. */
83
+ appFor?: string;
84
+ /** A rotation's uploaded version, so a recovery can finish activating it. */
85
+ secretVersion?: string;
86
+ /**
87
+ * The release has served: from here the lifecycle is the release's, and maintenance from before it
88
+ * can never be activated again, whether by a resume, a recovery or a supersede. Never cleared.
89
+ */
90
+ released?: boolean;
91
+ /** The Durable Object migrations serving once released: a maintenance needed after that is built from them. */
92
+ liveMigrations?: WranglerJson["migrations"];
93
+ /** Maintenance has been sent: the store may be down, so from here a resume recovers rather than asks for a window. */
94
+ downtime?: boolean;
95
+ }
96
+
97
+ export interface Half {
98
+ bookmark?: string | null;
99
+ lockSha: string;
100
+ versionId: string | null;
101
+ release: string | null;
102
+ doMigrations: WranglerJson["migrations"] | null;
103
+ /** The queue consumers deployed: an upload doesn't apply consumer settings, a full deploy does (codex code r13). */
104
+ consumers?: unknown[] | null;
105
+ doStateFormat: number | null;
106
+ crons: string[] | null;
107
+ settings: WorkerSettings | null;
108
+ /**
109
+ * In a before half, the commit being deployed. In an after half, the commit of the
110
+ * application serving after the operation, carried through secret rotations and recorded
111
+ * versions, so the stale-job guard always has one to compare (codex code r1).
112
+ */
113
+ commit?: string;
114
+ packages?: Record<string, string>;
115
+ configSha?: string;
116
+ config?: WranglerJson;
117
+ mode?: string;
118
+ window?: Plan["window"];
119
+ note?: string;
120
+ }
121
+
122
+ export interface DeployDeps {
123
+ cf: Cloudflare;
124
+ cwd: string;
125
+ manifest: Manifest | null;
126
+ lock: Lock;
127
+ git: Git;
128
+ /** `wrangler deploy --dry-run --outdir`: bundles the store's code (§6.2). */
129
+ bundle: (config: string, outdir: string) => void;
130
+ /** `kedge check --fast`; tests skip it. */
131
+ checkFast: () => Promise<number>;
132
+ // biome-ignore lint/suspicious/noExplicitAny: each plugin brings its own options type
133
+ plugins: readonly import("@kedge/core").PluginInstance<any>[];
134
+ now?: () => number;
135
+ log?: (s: string) => void;
136
+ /** Random secret material; tests make it deterministic. */
137
+ secret?: () => string;
138
+ /**
139
+ * The deploy's environment: a core secret found here (a merchant's vault, exported to the deploy) is
140
+ * what a first deploy attaches instead of a generated one (codex code r21).
141
+ */
142
+ env?: NodeJS.ProcessEnv;
143
+ }
144
+
145
+ export interface DeployOptions {
146
+ vars?: Record<string, string>;
147
+ window?: { from: number; to: number };
148
+ adopt?: string[];
149
+ allowOlder?: boolean;
150
+ resume?: boolean;
151
+ takeOver?: string;
152
+ confirmStopped?: boolean;
153
+ }
154
+
155
+ export class DeployRefused extends Error {
156
+ constructor(message: string) {
157
+ super(message);
158
+ this.name = "DeployRefused";
159
+ }
160
+ }
161
+
162
+ const lockSha = (lock: Lock) => sha256(JSON.stringify(lock.entries.map((e) => [e.file, e.sha256])));
163
+
164
+ /** The ops database: found or created by exact name, and refused unless it's this store's (§6.1). */
165
+ export async function openOps(
166
+ cf: Cloudflare,
167
+ names: StoreNames,
168
+ now = Date.now,
169
+ ): Promise<{ ops: OpsStore; id: string }> {
170
+ let id = await cf.findD1(names.ops);
171
+ if (!id) {
172
+ try {
173
+ id = await cf.createD1(names.ops);
174
+ } catch (e) {
175
+ // Two first deploys contending here both end at the same database (§6.1). Whatever the
176
+ // error said, the name is what decides: found, it's the other deploy's; the meta check
177
+ // below refuses a database that isn't Kedge's.
178
+ id = await cf.findD1(names.ops);
179
+ if (!id) throw e;
180
+ }
181
+ }
182
+ const dbId = id;
183
+ const tables = (
184
+ await cf.queryD1(dbId, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_cf_%'")
185
+ ).map((r) => String(r.name));
186
+ if (tables.length && !tables.includes("kedge_ops_meta"))
187
+ throw new DeployRefused(`${names.ops} exists and isn't a Kedge operations database: it holds ${tables.join(", ")}`);
188
+ const ops = new OpsStore((sql, params) => cf.queryD1(dbId, sql, params), now);
189
+ await ops.init(names.worker);
190
+ return { ops, id: dbId };
191
+ }
192
+
193
+ /**
194
+ * Migrations this operation submitted that aren't proven to have stayed unrun: neither applied,
195
+ * nor refused by Cloudflare, nor stated never-run by a person. Absence from d1_migrations permits
196
+ * retrying the same bytes, and nothing else: a late landing would put those bytes under the
197
+ * filename (codex code r5, r6).
198
+ */
199
+ export async function uncertainMigrations(ops: OpsStore, opId: string): Promise<IntentRow[]> {
200
+ return (await ops.intents(SCOPE, opId)).filter(
201
+ (i) =>
202
+ i.kind === "migrate" &&
203
+ i.state !== "done" &&
204
+ i.state !== "failed" &&
205
+ !(i.state === "not_executed" && i.resolved_by?.startsWith("stated by")),
206
+ );
207
+ }
208
+
209
+ /** The commit of the application serving now: the latest finished operation's after half. */
210
+ function servingCommit(rows: readonly { after_half: string | null }[]): string | undefined {
211
+ const last = rows.find((r) => r.after_half);
212
+ return last ? (JSON.parse(last.after_half as string) as Half).commit : undefined;
213
+ }
214
+
215
+ /**
216
+ * d1_migrations' names, in the order they were applied; none on a fresh database, where
217
+ * Wrangler hasn't made the table yet. Asked of sqlite_master, not read from an error:
218
+ * Cloudflare's answer to a missing table carries a code (7500), not SQLite's text.
219
+ */
220
+ export async function appliedNames(cf: Cloudflare, databaseId: string): Promise<string[]> {
221
+ const made = await cf.queryD1(
222
+ databaseId,
223
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'd1_migrations'",
224
+ );
225
+ if (!made.length) return [];
226
+ return (await cf.queryD1(databaseId, "SELECT name FROM d1_migrations ORDER BY id")).map((r) => String(r.name));
227
+ }
228
+
229
+ /**
230
+ * Doctor's `applied` check (§4.3): what D1 recorded is a prefix of the lock,
231
+ * and what ops recorded of each file's bytes still matches. Wrangler applies
232
+ * any file it hasn't recorded, even one numbered below applied ones (research/15).
233
+ */
234
+ export function appliedProblems(lock: Lock, applied: readonly string[], hashes: ReadonlyMap<string, string>): string[] {
235
+ const problems: string[] = [];
236
+ const files = lock.entries.map((e) => e.file);
237
+ const known = new Set(files);
238
+ for (const a of applied) if (!known.has(a)) problems.push(`D1 applied ${a}, which kedge.lock.json doesn't list`);
239
+ const set = new Set(applied);
240
+ let gap: string | null = null;
241
+ for (const f of files) {
242
+ if (!set.has(f)) gap ??= f;
243
+ else if (gap)
244
+ problems.push(`${f} is applied but ${gap}, before it in the lock, isn't: wrangler would apply it out of order`);
245
+ }
246
+ for (const e of lock.entries) {
247
+ const h = hashes.get(e.file);
248
+ if (h !== undefined && h !== e.sha256)
249
+ problems.push(
250
+ `${e.file} changed after it was applied (recorded ${h.slice(0, 12)}, lock ${e.sha256.slice(0, 12)})`,
251
+ );
252
+ }
253
+ return problems;
254
+ }
255
+
256
+ export class Deployer {
257
+ readonly now: () => number;
258
+ readonly log: (s: string) => void;
259
+ readonly names: StoreNames;
260
+ readonly wrangler: WranglerJson;
261
+ private ops!: OpsStore;
262
+ private opId!: string;
263
+ private plan!: Plan;
264
+ private progress: Progress = {};
265
+
266
+ constructor(private readonly d: DeployDeps) {
267
+ this.now = d.now ?? Date.now;
268
+ this.log = d.log ?? ((s) => console.log(s));
269
+ const s = storeNames(d.cwd);
270
+ this.names = s.names;
271
+ this.wrangler = s.config;
272
+ }
273
+
274
+ private dir(...p: string[]) {
275
+ return join(this.d.cwd, ".kedge", "deploy", ...p);
276
+ }
277
+
278
+ private async step(step: string) {
279
+ await this.ops.step(SCOPE, this.opId, step, this.progress);
280
+ this.log(`· ${step}`);
281
+ }
282
+
283
+ /** Runs a control-plane call as an intent: recorded first, closed by its answer, unknown on silence (§3.1). */
284
+ private async intent<T>(
285
+ step: string,
286
+ kind: string,
287
+ tag: string,
288
+ detail: unknown,
289
+ call: () => Promise<T>,
290
+ ): Promise<T> {
291
+ const id = await this.ops.openIntent(SCOPE, this.opId, step, kind, tag, detail);
292
+ try {
293
+ const out = await call();
294
+ await this.ops.closeIntent(id, "done", "answer");
295
+ return out;
296
+ } catch (e) {
297
+ await this.ops.closeIntent(id, e instanceof UnknownOutcome ? "unknown" : "failed", "answer");
298
+ throw e;
299
+ }
300
+ }
301
+
302
+ private tag(kind: string) {
303
+ return `kedge:${this.opId}:${kind}:${this.now().toString(36)}`;
304
+ }
305
+
306
+ // ---- the plan (§6.2 step 2) -----------------------------------------------------------------------
307
+ /** Builds the effective config, bundles and stages; nothing on Cloudflare yet. */
308
+ async buildPlan(o: DeployOptions, databaseId: string | null, buildDir: string): Promise<Plan> {
309
+ const g = this.d.git;
310
+ const commit = g.head();
311
+ const dirty = dirtyBeyondId(this.d.cwd, g, databaseId ?? this.names.databaseId);
312
+ if (dirty.length)
313
+ throw new DeployRefused(`commit first: the worktree has changes (${dirty.slice(0, 5).join(", ")})`);
314
+ const routes = this.wrangler.routes ?? this.wrangler.route;
315
+ if (routes !== undefined)
316
+ throw new DeployRefused(
317
+ "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
+ );
319
+ const vars = { ...(this.wrangler.vars ?? {}), ...(o.vars ?? {}) };
320
+ const config: WranglerJson = { ...this.wrangler, vars };
321
+ delete config.build;
322
+ const dbs = (config.d1_databases ?? []).map((d) =>
323
+ d.binding === "DB" && databaseId ? { ...d, database_id: databaseId } : d,
324
+ );
325
+ config.d1_databases = dbs;
326
+ rmSync(buildDir, { recursive: true, force: true });
327
+ mkdirSync(buildDir, { recursive: true });
328
+ // The admin's assets, staged beside the bundle (p0.9 §3.3): what uploads, and what a resume reproduces.
329
+ const admin = stageAssets(this.d.cwd, config, join(buildDir, "assets"));
330
+ // The bundle: from the store's code, with a config whose paths are absolute, and the admin's build
331
+ // in its code, so a new admin build is new Worker code (p0.9 §2.4).
332
+ const buildConfig = join(buildDir, "build.wrangler.json");
333
+ writeFileSync(
334
+ buildConfig,
335
+ JSON.stringify({
336
+ ...config,
337
+ main: resolve(this.d.cwd, String(config.main ?? "src/index.ts")),
338
+ // The store's own constants kept, the admin's build beside them (it wins its one name).
339
+ ...(admin
340
+ ? {
341
+ assets: { ...config.assets, directory: join(buildDir, "assets") },
342
+ define: { ...((config as { define?: Record<string, string> }).define ?? {}), ...admin.define },
343
+ }
344
+ : {}),
345
+ }),
346
+ );
347
+ this.d.bundle(buildConfig, join(buildDir, "bundle"));
348
+ // The plan's SQL is what runs: every composed file, staged as bytes (§6.2).
349
+ mkdirSync(join(buildDir, "migrations"), { recursive: true });
350
+ const files: Record<string, string> = {};
351
+ for (const e of this.d.lock.entries) {
352
+ const bytes = readFileSync(join(this.d.cwd, "migrations", e.file));
353
+ if (sha256(bytes) !== e.sha256)
354
+ throw new DeployRefused(`${e.file} doesn't match kedge.lock.json; run kedge doctor`);
355
+ writeFileSync(join(buildDir, "migrations", e.file), bytes);
356
+ files[e.file] = e.sha256;
357
+ }
358
+ const m = this.d.manifest;
359
+ return {
360
+ version: 1,
361
+ commit,
362
+ release: m?.version ?? "workspace",
363
+ upgradesFrom: m?.upgradesFrom ?? null,
364
+ doStateFormat: m?.doStateFormat ?? DO_STATE_FORMAT,
365
+ lockSha: lockSha(this.d.lock),
366
+ files,
367
+ vars,
368
+ config,
369
+ packages: packageVersions(this.d.cwd),
370
+ modules: hashTree(join(buildDir, "bundle")),
371
+ ...(admin ? { assets: hashTree(join(buildDir, "assets")) } : {}),
372
+ window: o.window ?? null,
373
+ adopt: o.adopt ?? [],
374
+ };
375
+ }
376
+
377
+ /** A resume deploys what was planned or nothing (§6.2): the rebuild must reproduce it. */
378
+ private reproduces(planned: Plan, rebuilt: Plan): string[] {
379
+ const diffs: string[] = [];
380
+ if (planned.commit !== rebuilt.commit)
381
+ diffs.push(`commit ${rebuilt.commit.slice(0, 10)} isn't the plan's ${planned.commit.slice(0, 10)}`);
382
+ if (JSON.stringify(planned.modules) !== JSON.stringify(rebuilt.modules))
383
+ diffs.push("the bundle's modules differ from the plan's");
384
+ if (JSON.stringify(planned.assets ?? null) !== JSON.stringify(rebuilt.assets ?? null))
385
+ diffs.push("the admin's assets differ from the plan's");
386
+ if (JSON.stringify(planned.packages) !== JSON.stringify(rebuilt.packages))
387
+ diffs.push("installed Kedge differs from the plan's");
388
+ if (JSON.stringify(planned.files) !== JSON.stringify(rebuilt.files))
389
+ diffs.push("the composed migrations differ from the plan's");
390
+ return diffs;
391
+ }
392
+
393
+ // ---- resources (§6.1) ------------------------------------------------------------------------------
394
+ private async own(
395
+ kind: "d1" | "queue",
396
+ name: string,
397
+ find: () => Promise<string | null>,
398
+ create: () => Promise<string>,
399
+ ): Promise<string> {
400
+ const rec = await this.ops.resource(kind, name);
401
+ if (rec?.id) return rec.id;
402
+ const found = await find();
403
+ if (found) {
404
+ if (rec?.state === "creating" || this.plan.adopt.includes(name)) {
405
+ await this.ops.recordResource(kind, name, found, "ours");
406
+ return found;
407
+ }
408
+ throw new DeployRefused(
409
+ `${name} exists and this store never recorded making it; if it's yours, deploy with --adopt ${name}`,
410
+ );
411
+ }
412
+ await this.ops.recordResource(kind, name, null, "creating");
413
+ let id: string;
414
+ try {
415
+ id = await this.intent("provision", "create", `${kind}:${name}`, { kind, name }, create);
416
+ } catch (e) {
417
+ if (!(e instanceof UnknownOutcome)) throw e;
418
+ // A lost answer: the name is ours (recorded first), so a lookup recovers it.
419
+ const again = await find();
420
+ if (!again) throw e;
421
+ id = again;
422
+ }
423
+ await this.ops.recordResource(kind, name, id, "ours");
424
+ return id;
425
+ }
426
+
427
+ private async ownWorker(): Promise<boolean> {
428
+ const rec = await this.ops.resource("worker", this.names.worker);
429
+ const exists = await this.d.cf.findWorker(this.names.worker);
430
+ if (rec) return exists;
431
+ if (exists && !this.plan.adopt.includes(this.names.worker))
432
+ throw new DeployRefused(
433
+ `a Worker named ${this.names.worker} exists and this store never recorded deploying it; if it's this store, deploy with --adopt ${this.names.worker}`,
434
+ );
435
+ // Recorded as ours before its first upload (§6.1).
436
+ await this.ops.recordResource("worker", this.names.worker, null, "ours");
437
+ return exists;
438
+ }
439
+
440
+ // ---- what's serving (§6.2) ------------------------------------------------------------------------
441
+ /** The deployment row whose version serves, or null on a first deploy; refuses what nobody recorded. */
442
+ private async serving(
443
+ rows: readonly DeploymentRow[],
444
+ ): Promise<{ row: DeploymentRow | null; versionId: string | null }> {
445
+ const deps = await this.d.cf.deployments(this.names.worker);
446
+ const active = deps[0];
447
+ if (!active) return { row: null, versionId: null };
448
+ if (active.versions.length !== 1)
449
+ throw new DeployRefused(
450
+ `${this.names.worker} serves a split across ${active.versions.length} versions; nobody can say what's serving`,
451
+ );
452
+ const versionId = (active.versions[0] as { version_id: string }).version_id;
453
+ const row = rows.find((r) => (r.after_half ? (JSON.parse(r.after_half) as Half).versionId === versionId : false));
454
+ if (!row)
455
+ throw new DeployRefused(
456
+ `${this.names.worker} serves version ${versionId}, which no deployment recorded (a dashboard edit, a rollback by hand or a plain wrangler secret put?); if it's this store's code, run kedge deploy --record-active --reason "<what>"`,
457
+ );
458
+ return { row, versionId };
459
+ }
460
+
461
+ private compatible(row: DeploymentRow | null): void {
462
+ if (!row) return;
463
+ this.compatibleWith((JSON.parse(row.after_half ?? "{}") as Half).release);
464
+ }
465
+
466
+ private compatibleWith(release: string | null): void {
467
+ if (release === null || release === this.plan.release || release === this.plan.upgradesFrom) return;
468
+ throw new DeployRefused(
469
+ `the store serves Kedge ${release}; ${this.plan.release} was proven against ${this.plan.upgradesFrom ?? "nothing"}: deploy the releases between in turn (kedge upgrade lists them)`,
470
+ );
471
+ }
472
+
473
+ private async triggersOf(exists: boolean): Promise<{ crons: string[] | null; settings: WorkerSettings | null }> {
474
+ if (!exists) return { crons: null, settings: null };
475
+ return {
476
+ crons: await this.d.cf.schedules(this.names.worker),
477
+ settings: await this.d.cf.settings(this.names.worker),
478
+ };
479
+ }
480
+
481
+ // ---- the run ----------------------------------------------------------------------------------------
482
+ /** A fresh deploy, or a resume of the held one. */
483
+ async deploy(o: DeployOptions): Promise<void> {
484
+ if ((await this.d.checkFast()) !== 0) throw new DeployRefused("kedge check --fast failed");
485
+ // A merchant's own core secret, checked before anything starts rather than at the upload.
486
+ for (const s of CORE_SECRETS) {
487
+ const v = this.d.env?.[s];
488
+ if (v !== undefined && v.length < 32) throw new DeployRefused(`${s} in the environment is under 32 bytes`);
489
+ }
490
+ const { ops } = await openOps(this.d.cf, this.names, this.now);
491
+ this.ops = ops;
492
+ const held = await ops.current(SCOPE);
493
+ if (o.resume || o.takeOver) {
494
+ if (!held) throw new DeployRefused("nothing to resume: no operation holds this store");
495
+ if (!["deploy", "secret", "migrate", "adopt", "record-active"].includes(held.kind))
496
+ throw new DeployRefused(`the held operation is a ${held.kind}, which kedge deploy can't continue`);
497
+ const row = o.takeOver ? await this.takeOver(held, o) : await ops.resume(SCOPE, held.op_id);
498
+ // Every production operation has a way out, or one stopped would hold the store for good (codex code r1).
499
+ if (row.kind === "secret") return this.recoverSecret(row);
500
+ if (row.kind === "migrate") return this.migrateRemote(row);
501
+ if (row.kind === "adopt" || row.kind === "record-active") {
502
+ this.opId = row.op_id;
503
+ await this.abandonRecording(`${row.kind} stopped before it finished`);
504
+ this.log(
505
+ `released ${row.op_id}: nothing it began changed Cloudflare; run kedge deploy --${row.kind === "adopt" ? "adopt-existing" : "record-active"} again`,
506
+ );
507
+ return;
508
+ }
509
+ this.opId = row.op_id;
510
+ this.plan = JSON.parse(row.plan) as Plan;
511
+ this.progress = JSON.parse(row.progress) as Progress;
512
+ if (o.vars && Object.entries(o.vars).some(([k, v]) => this.plan.vars?.[k] !== v)) {
513
+ await this.ops.stop(SCOPE, this.opId);
514
+ throw new DeployRefused("a resume takes its vars from the plan; a conflicting --var refuses");
515
+ }
516
+ // A new window, while nothing is down yet: kept with the plan (codex code r18).
517
+ if (o.window && !this.progress.downtime) {
518
+ this.plan.window = o.window;
519
+ await this.ops.supersede(SCOPE, this.opId, this.plan);
520
+ }
521
+ } else {
522
+ if (held) throw new DeployRefused(new OperationHeld(held).message);
523
+ const commit = this.d.git.head();
524
+ const dirty = dirtyBeyondId(this.d.cwd, this.d.git, (await ops.resource("d1", this.names.database))?.id ?? null);
525
+ if (dirty.length)
526
+ throw new DeployRefused(`commit first: the worktree has changes (${dirty.slice(0, 5).join(", ")})`);
527
+ // Provisioning is the operation's first phase (§6.1): the plan freezes the ids it finds.
528
+ // Every option the plan will need, kept from the start: a run stopped before the plan froze resumes
529
+ // with them (codex code r26).
530
+ this.plan = {
531
+ commit,
532
+ adopt: o.adopt ?? [],
533
+ vars: o.vars ?? {},
534
+ window: o.window ?? null,
535
+ ...(o.allowOlder ? { allowOlder: true } : {}),
536
+ } as Plan;
537
+ this.opId = await ops.start(SCOPE, "deploy", this.plan, "started");
538
+ this.pending = o;
539
+ }
540
+ try {
541
+ await this.reconcile();
542
+ await this.run();
543
+ } catch (e) {
544
+ await this.end(e);
545
+ throw e;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * After a failure: a refusal before anything was recorded about the store
551
+ * ends the operation (nothing to resume); anything else stops it, held for
552
+ * a resume, with its unanswered calls unknown.
553
+ */
554
+ private async end(e: unknown): Promise<void> {
555
+ const clean =
556
+ e instanceof DeployRefused && !this.progress.deploymentId && (await this.ops.unresolved(SCOPE)).length === 0;
557
+ if (clean) await this.ops.finish(SCOPE, this.opId, "refused", [this.plan]).catch(() => {});
558
+ else await this.ops.stop(SCOPE, this.opId).catch(() => {});
559
+ }
560
+
561
+ /** Set while a superseding plan, already built and checked, runs its first pass. */
562
+ private superseded = false;
563
+
564
+ /** Options of a fresh deploy, for the plan built once provisioning knows the ids. */
565
+ private pending: DeployOptions | null = null;
566
+
567
+ /** Builds and freezes the plan, or, on a resume, proves this checkout reproduces it (§6.2). */
568
+ private async freeze(): Promise<void> {
569
+ const p = this.progress;
570
+ const target = this.dir(this.opId);
571
+ if (this.plan.modules) {
572
+ const rebuilt = await this.buildPlan(
573
+ { vars: this.plan.vars, adopt: this.plan.adopt, ...(this.plan.window ? { window: this.plan.window } : {}) },
574
+ p.databaseId ?? null,
575
+ `${target}-rebuild`,
576
+ );
577
+ const diffs = this.reproduces(this.plan, rebuilt);
578
+ if (diffs.length) throw new DeployRefused(`this checkout doesn't reproduce the plan:\n ${diffs.join("\n ")}`);
579
+ // A plan saved before its guard ran (a lost answer) is guarded here, before anything mutates (codex code r10).
580
+ await this.staleGuard(this.plan);
581
+ rmSync(target, { recursive: true, force: true });
582
+ cpSync(`${target}-rebuild`, target, { recursive: true });
583
+ rmSync(`${target}-rebuild`, { recursive: true, force: true });
584
+ return;
585
+ }
586
+ const o = this.pending ?? {};
587
+ const started = this.plan;
588
+ this.plan = await this.buildPlan(
589
+ { ...o, adopt: started.adopt, vars: started.vars, ...(started.window ? { window: started.window } : {}) },
590
+ p.databaseId ?? null,
591
+ target,
592
+ );
593
+ if (o.allowOlder || started.allowOlder) this.plan.allowOlder = true;
594
+ await this.staleGuard(this.plan);
595
+ await this.ops.supersede(SCOPE, this.opId, this.plan);
596
+ }
597
+
598
+ /** An older job mustn't overwrite a newer release (§3.1), unless a person said so with --allow-older. */
599
+ private async staleGuard(plan: Plan): Promise<void> {
600
+ const stale = await this.staleness(plan);
601
+ if (stale) await this.refuse(stale);
602
+ }
603
+
604
+ private async staleness(plan: Plan): Promise<string | null> {
605
+ const lastCommit = servingCommit(await this.ops.deployments());
606
+ if (lastCommit && !plan.allowOlder && !this.d.git.isAncestor(lastCommit, plan.commit))
607
+ return `the last deployment's commit ${lastCommit.slice(0, 10)} isn't an ancestor of ${plan.commit.slice(0, 10)}: an older job mustn't overwrite a newer release (--allow-older if it's meant)`;
608
+ return null;
609
+ }
610
+
611
+ /**
612
+ * Adoption and recording write only to the ops database, so an interrupted one is undone, not
613
+ * continued: its unfinished manifest row removed (a retry of --adopt-existing needs none), and
614
+ * the operation released. Its resource and applied records are true either way (codex code r9).
615
+ */
616
+ private async abandonRecording(outcome: string): Promise<void> {
617
+ await this.ops.query("DELETE FROM deployment WHERE op_id = ? AND after_half IS NULL", [this.opId]);
618
+ await this.ops.finish(SCOPE, this.opId, outcome, []);
619
+ }
620
+
621
+ /**
622
+ * A refusal, ended by end(): finished only when it provably came before any deployment, held
623
+ * otherwise. A refused corrected plan inside a cutover already in maintenance must not take the
624
+ * operation, and its recovery, with it (codex code r25).
625
+ */
626
+ private async refuse(message: string): Promise<never> {
627
+ throw new DeployRefused(message);
628
+ }
629
+
630
+ private async takeOver(held: OperationRow, o: DeployOptions): Promise<OperationRow> {
631
+ if (held.op_id !== o.takeOver) throw new DeployRefused(`the held operation is ${held.op_id}, not ${o.takeOver}`);
632
+ if (held.executor && !o.confirmStopped)
633
+ throw new DeployRefused(
634
+ `${held.op_id} is run by ${held.executor_label}: take over only once it's cancelled, or its Cloudflare token rotated, and say so with --confirm-stopped`,
635
+ );
636
+ return this.ops.takeOver(SCOPE, held.op_id, held.executor);
637
+ }
638
+
639
+ /** Before acting, every unknown call is closed by evidence or left for a person (§3.1). */
640
+ private async reconcile(): Promise<void> {
641
+ const open = await this.ops.unresolved(SCOPE);
642
+ const blocking: IntentRow[] = [];
643
+ for (const i of open) {
644
+ if (i.kind === "upload") {
645
+ await this.ops.closeIntent(i.id, "unknown", "uploads never block");
646
+ continue;
647
+ }
648
+ if (i.kind === "migrate") {
649
+ const files = (JSON.parse(i.detail) as { files: string[] }).files;
650
+ const applied = new Set(await appliedNames(this.d.cf, this.progress.databaseId as string));
651
+ if (files.every((f) => applied.has(f))) {
652
+ await this.ops.closeIntent(i.id, "done", "d1_migrations lists every file");
653
+ continue;
654
+ }
655
+ // An unapplied file re-runs safely: it's atomic and recorded, and a late duplicate fails on its own.
656
+ await this.ops.closeIntent(i.id, "not_executed", "d1_migrations lacks a file; re-running is safe");
657
+ continue;
658
+ }
659
+ if (i.kind === "create") {
660
+ const { kind, name } = JSON.parse(i.detail) as { kind: string; name: string };
661
+ const found = kind === "d1" ? await this.d.cf.findD1(name) : await this.d.cf.findQueue(name);
662
+ if (found) await this.ops.closeIntent(i.id, "done", "an exact-name lookup found it");
663
+ else blocking.push(i);
664
+ continue;
665
+ }
666
+ if (i.kind === "activate") {
667
+ const deps = await this.d.cf.deployments(this.names.worker);
668
+ const landed = deps.find((d) => d.message?.includes(i.tag));
669
+ if (landed) {
670
+ await this.ops.closeIntent(i.id, "done", "the deployment list shows its tag");
671
+ continue;
672
+ }
673
+ }
674
+ blocking.push(i);
675
+ }
676
+ // A class-changing deploy is its own upload: the version it made is the release's. Rebuilt from every
677
+ // landed one, unresolved until a moment ago or closed by an answer whose progress write a crash lost:
678
+ // a resume that didn't know it would put maintenance back, a rollback across a class change Cloudflare
679
+ // refuses (codex code r15, r20).
680
+ if (!this.progress.released) {
681
+ const classDeploys = (await this.ops.intents(SCOPE, this.opId)).filter(
682
+ (i) =>
683
+ i.kind === "activate" &&
684
+ i.state === "done" &&
685
+ (JSON.parse(i.detail) as { classChange?: boolean }).classChange,
686
+ );
687
+ if (classDeploys.length) {
688
+ const deps = await this.d.cf.deployments(this.names.worker);
689
+ const landed = classDeploys
690
+ .map((i) => deps.find((d) => d.message?.includes(i.tag))?.versions[0]?.version_id)
691
+ .find(Boolean);
692
+ if (landed) {
693
+ this.progress.appVersion = landed;
694
+ this.progress.appFor = sha256(JSON.stringify(this.plan));
695
+ this.progress.released = true;
696
+ this.progress.liveMigrations = this.plan.config.migrations ?? [];
697
+ delete this.progress.maintenanceVersion;
698
+ await this.ops.step(SCOPE, this.opId, "released", this.progress);
699
+ }
700
+ }
701
+ }
702
+ if (blocking.length)
703
+ throw new DeployRefused(
704
+ `calls whose outcome nobody knows block this store:\n${blocking
705
+ .map((i) => ` ${i.id} ${i.kind} at "${i.step}" (${i.tag})`)
706
+ .join(
707
+ "\n",
708
+ )}\nEach could still land. If you're sure one never took effect: kedge deploy --resolve-intent <id> --not-executed (a call Cloudflare already accepted could still land after it)`,
709
+ );
710
+ }
711
+
712
+ private async run(): Promise<void> {
713
+ const p = this.progress;
714
+ const cf = this.d.cf;
715
+ await this.step("provision");
716
+ p.databaseId = await this.own(
717
+ "d1",
718
+ this.names.database,
719
+ () => cf.findD1(this.names.database),
720
+ () => cf.createD1(this.names.database),
721
+ );
722
+ p.queueId = await this.own(
723
+ "queue",
724
+ this.names.queue,
725
+ () => cf.findQueue(this.names.queue),
726
+ () => cf.createQueue(this.names.queue),
727
+ );
728
+ const workerExists = await this.ownWorker();
729
+ if (p.first === undefined) {
730
+ // First means never deployed, by the store's history, not merely no Worker today: a deleted Worker
731
+ // over a surviving database would get new secrets that its stored keys and factors don't match,
732
+ // and new Durable Objects that forget what the journal and clock knew (codex code r17).
733
+ const deployed = (await this.ops.deployments()).some((r) => r.after_half);
734
+ if (deployed && !workerExists)
735
+ await this.refuse(
736
+ `${this.names.worker} has been deployed, but no Worker by that name exists now: a deploy would make new secrets and Durable Objects over the surviving database. Restore the Worker (its secrets and objects) before deploying; doctor --remote reports it`,
737
+ );
738
+ p.first = !workerExists;
739
+ }
740
+ await this.step("provisioned");
741
+ this.writeDatabaseId(p.databaseId);
742
+ if (!this.superseded) await this.freeze();
743
+ this.superseded = false;
744
+ await this.step("planned");
745
+
746
+ // §6.2 steps 3–5: what's applied, what's pending, and whether the running Worker survives it.
747
+ const applied = await appliedNames(cf, p.databaseId);
748
+ const problems = appliedProblems(this.d.lock, applied, await this.ops.appliedHashes());
749
+ if (problems.length) await this.refuse(`the store's migrations don't match the lock:\n ${problems.join("\n ")}`);
750
+ const appliedSet = new Set(applied);
751
+ const pendingEntries: LockEntry[] = this.d.lock.entries.filter((e) => !appliedSet.has(e.file));
752
+ p.pending ??= pendingEntries.map((e) => e.file);
753
+ const staged = (f: string) => readFileSync(this.dir(this.opId, "migrations", f), "utf8");
754
+ if (!p.classified) {
755
+ try {
756
+ p.classified = classify({
757
+ cwd: this.d.cwd,
758
+ lock: this.d.lock,
759
+ pending: pendingEntries,
760
+ manifest: this.d.manifest,
761
+ plugins: this.d.plugins,
762
+ text: staged,
763
+ });
764
+ } catch (e) {
765
+ await this.refuse((e as Error).message);
766
+ }
767
+ }
768
+ const needed = (p.classified ?? []).some((c) => c.mode === "cutover") ? "cutover" : "n1";
769
+ // A superseding plan can't turn an N−1 deploy into a cutover: the live Worker has had no
770
+ // maintenance and the window was never checked (codex code r5).
771
+ if (p.mode === "n1" && needed === "cutover")
772
+ await this.refuse(
773
+ "the superseding plan holds a cutover, but this deploy began N−1 with the live Worker serving: end it, then deploy the cutover with --maintenance-window",
774
+ );
775
+ p.mode ??= needed;
776
+ const rows = await this.ops.deployments();
777
+ // Once the "before" half exists this operation may itself be what serves (maintenance), so what
778
+ // served before is read from that half, not from Cloudflare.
779
+ const { row: servingRow, versionId: servingVersion } =
780
+ p.first || p.deploymentId ? { row: null, versionId: null } : await this.serving(rows);
781
+ if (!p.deploymentId) this.compatible(servingRow);
782
+ if (p.mode === "cutover" && !p.first && !p.deploymentId) {
783
+ const w = this.plan.window;
784
+ 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)",
787
+ );
788
+ }
789
+ await this.step("classified");
790
+
791
+ // §6.5: the "before" half, with the bookmark that belongs to the code that ran before.
792
+ if (!p.deploymentId) {
793
+ const t = await this.triggersOf(workerExists);
794
+ const prior = servingRow ? (JSON.parse(servingRow.after_half ?? "{}") as Half) : null;
795
+ const before: Half = {
796
+ bookmark: await cf.bookmark(p.databaseId),
797
+ lockSha: sha256(
798
+ JSON.stringify(this.d.lock.entries.filter((e) => appliedSet.has(e.file)).map((e) => [e.file, e.sha256])),
799
+ ),
800
+ versionId: servingVersion,
801
+ release: prior?.release ?? null,
802
+ doMigrations: prior?.doMigrations ?? null,
803
+ consumers: prior?.consumers ?? null,
804
+ doStateFormat: prior?.doStateFormat ?? null,
805
+ crons: t.crons,
806
+ settings: t.settings,
807
+ // The code that ran before, beside the bookmark taken before this deploy's migrations: its commit,
808
+ // packages and config are the serving manifest's (codex code r19).
809
+ commit: prior?.commit,
810
+ packages: prior?.packages,
811
+ configSha: prior?.configSha,
812
+ config: prior?.config,
813
+ mode: p.mode,
814
+ window: this.plan.window,
815
+ };
816
+ p.deploymentId = await this.ops.addDeployment(this.opId, p.mode, before);
817
+ await this.step("before");
818
+ }
819
+ const before = JSON.parse(
820
+ (rows.find((r) => r.id === p.deploymentId) ?? (await this.ops.deployments()).find((r) => r.id === p.deploymentId))
821
+ ?.before_half ?? "{}",
822
+ ) as Half;
823
+
824
+ // Maintenance from before the release never comes back once it has served (codex code r4, r15, r16: the
825
+ // third time, so `released` is a fact of its own that a supersede keeps). After it, only a cutover still
826
+ // pending needs maintenance, built from the lifecycle that serves now.
827
+ const cutoverPending = (p.classified ?? []).some((c) => c.mode === "cutover" && !appliedSet.has(c.file));
828
+ if (p.mode === "cutover" && !p.first && !p.appVersion && (!p.released || cutoverPending))
829
+ await this.maintenance(before, p.released ? (p.liveMigrations ?? []) : (before.doMigrations ?? []));
830
+ await this.migrate();
831
+ await this.release(before);
832
+ // What's recorded as deployed must be what serves, whichever step a resume came back at.
833
+ await this.activate("activate", p.appVersion as string);
834
+ await this.markReleased();
835
+
836
+ const after: Half = {
837
+ lockSha: this.plan.lockSha,
838
+ commit: this.plan.commit,
839
+ packages: this.plan.packages,
840
+ configSha: sha256(JSON.stringify(this.plan.config)),
841
+ config: this.plan.config,
842
+ versionId: p.appVersion as string,
843
+ release: this.plan.release,
844
+ doMigrations: this.plan.config.migrations ?? [],
845
+ consumers: this.plan.config.queues?.consumers ?? [],
846
+ doStateFormat: this.plan.doStateFormat,
847
+ crons: await cf.schedules(this.names.worker),
848
+ settings: await cf.settings(this.names.worker),
849
+ };
850
+ await this.step("after");
851
+ await this.ops.completeDeployment(p.deploymentId, after);
852
+ await this.ops.finish(SCOPE, this.opId, "deployed", [this.plan]);
853
+ rmSync(this.dir(this.opId), { recursive: true, force: true });
854
+ this.log(
855
+ `deployed ${this.names.worker} at version ${after.versionId} (Kedge ${this.plan.release}, ${p.mode}); restore point ${before.bookmark}`,
856
+ );
857
+ }
858
+
859
+ /**
860
+ * The first deploy writes the database id over the starter's placeholder:
861
+ * one known literal, safe where general JSONC editing isn't (§6.1). A real
862
+ * id that differs from ops's refuses.
863
+ */
864
+ private writeDatabaseId(id: string): void {
865
+ const file = join(this.d.cwd, "wrangler.jsonc");
866
+ if (!existsSync(file)) return;
867
+ const text = readFileSync(file, "utf8");
868
+ if (this.names.databaseId === PLACEHOLDER_ID) {
869
+ if (text.includes(PLACEHOLDER_ID)) {
870
+ writeFileSync(file, text.replace(PLACEHOLDER_ID, id));
871
+ this.log(`wrangler.jsonc now names database ${id}: commit it`);
872
+ }
873
+ } else if (this.names.databaseId !== id)
874
+ throw new DeployRefused(`wrangler.jsonc names database ${this.names.databaseId}, but this store's is ${id}`);
875
+ }
876
+
877
+ // ---- §6.3: maintenance ------------------------------------------------------------------------------
878
+ /** The release serves: recorded before anything else can run, and kept by every later plan. */
879
+ private async markReleased(): Promise<void> {
880
+ const p = this.progress;
881
+ if (p.released && p.maintenanceVersion === undefined) return;
882
+ p.released = true;
883
+ p.liveMigrations = this.plan.config.migrations ?? [];
884
+ delete p.maintenanceVersion;
885
+ await this.ops.step(SCOPE, this.opId, "released", p);
886
+ }
887
+
888
+ private async maintenance(before: Half, migrations: NonNullable<WranglerJson["migrations"]>): Promise<void> {
889
+ const p = this.progress;
890
+ const cf = this.d.cf;
891
+ if (!p.maintenanceVersion) {
892
+ // Abort has to stay executable, so it's checked first: the target must outlive this cutover's publications.
893
+ const versions = await cf.versions(this.names.worker);
894
+ const n = versions.findIndex((v) => v.id === before.versionId);
895
+ const recent = (
896
+ await this.ops.query("SELECT COUNT(*) AS n FROM intent WHERE kind = 'upload' AND created_at > ?", [
897
+ this.now() - 7 * 86_400_000,
898
+ ])
899
+ )[0];
900
+ const budget = CUTOVER_PUBLICATIONS + 3 * Number(recent?.n ?? 0) + OUTSIDE_MARGIN;
901
+ const headroom = n < 0 ? -1 : 99 - n;
902
+ if (headroom <= budget)
903
+ await this.refuse(
904
+ `a cutover could push the serving version out of Cloudflare's 100-version rollback window (headroom ${headroom}, budget ${budget}): redeploy the same release first to freshen it`,
905
+ );
906
+ const dir = this.dir(this.opId, "maintenance");
907
+ mkdirSync(dir, { recursive: true });
908
+ const classes = declaredClasses(migrations as never);
909
+ writeFileSync(join(dir, "index.js"), maintenanceModule(classes));
910
+ // Only the bindings whose classes the stubs export: a release's new class arrives with the release.
911
+ const bindings = (
912
+ (this.plan.config.durable_objects as { bindings?: { name: string; class_name: string }[] } | undefined)
913
+ ?.bindings ?? []
914
+ ).filter((b) => classes.includes(b.class_name));
915
+ const config = {
916
+ ...this.plan.config,
917
+ // The admin's assets stay (p0.9 §2.5): an open admin shows the maintenance page, not a broken one.
918
+ ...(this.plan.config.assets
919
+ ? { assets: { ...this.plan.config.assets, directory: this.dir(this.opId, "assets") } }
920
+ : {}),
921
+ durable_objects: { bindings },
922
+ main: join(dir, "index.js"),
923
+ // Exactly the serving classes: maintenance changes no lifecycle, so it blocks no rollback.
924
+ migrations,
925
+ triggers: { crons: before.crons ?? [] },
926
+ };
927
+ delete (config as Record<string, unknown>).build;
928
+ writeFileSync(join(dir, "wrangler.json"), JSON.stringify(config));
929
+ await this.step("maintenance-upload");
930
+ p.maintenanceVersion = await this.intent("maintenance-upload", "upload", this.tag("maintenance"), {}, () =>
931
+ cf.uploadVersion({ config: join(dir, "wrangler.json"), message: `kedge maintenance ${this.opId}` }),
932
+ );
933
+ await this.step("maintenance-uploaded");
934
+ }
935
+ // Downtime starts only inside the window, however long preparing took or whenever a resume runs
936
+ // (codex code r18); once maintenance has been sent, recovering it needs no window.
937
+ if (!p.downtime) {
938
+ const w = this.plan.window;
939
+ if (!w || this.now() < w.from || this.now() > w.to)
940
+ throw new DeployRefused(
941
+ "the maintenance window isn't open now: nothing is down yet, so resume inside it, or give a new one with kedge deploy --resume --maintenance-window <from>/<to>",
942
+ );
943
+ p.downtime = true;
944
+ await this.step("maintenance-activate");
945
+ }
946
+ await this.activate("maintenance-activate", p.maintenanceVersion);
947
+ }
948
+
949
+ /** One activation: an intent tagged in the deployment's message, single attempt (§3.1). */
950
+ private async activate(step: string, versionId: string): Promise<void> {
951
+ const deps = await this.d.cf.deployments(this.names.worker);
952
+ if (deps[0]?.versions.length === 1 && deps[0].versions[0]?.version_id === versionId) return; // already serving
953
+ await this.step(step);
954
+ const tag = this.tag("activate");
955
+ await this.intent(step, "activate", tag, { versionId }, () =>
956
+ this.d.cf.activate(this.names.worker, versionId, tag),
957
+ );
958
+ }
959
+
960
+ // ---- §6.2 step 6: migrations from the staged bytes --------------------------------------------------
961
+ private async migrate(): Promise<void> {
962
+ const p = this.progress;
963
+ const cf = this.d.cf;
964
+ const applied = new Set(await appliedNames(cf, p.databaseId as string));
965
+ const pending = (p.pending ?? []).filter((f) => !applied.has(f));
966
+ for (const f of [...applied]) {
967
+ const e = this.d.lock.entries.find((x) => x.file === f);
968
+ if (e) await this.ops.recordApplied(f, e.sha256);
969
+ }
970
+ if (pending.length === 0) return;
971
+ await this.step("migrate");
972
+ const dir = this.dir(this.opId);
973
+ for (const f of pending)
974
+ if (sha256(readFileSync(join(dir, "migrations", f))) !== this.plan.files[f])
975
+ throw new DeployRefused(`the staged ${f} doesn't match the plan`);
976
+ const config = join(dir, "migrate.wrangler.json");
977
+ writeFileSync(
978
+ config,
979
+ JSON.stringify({
980
+ name: this.names.worker,
981
+ compatibility_date: this.plan.config?.compatibility_date ?? this.wrangler.compatibility_date,
982
+ d1_databases: [
983
+ {
984
+ binding: "DB",
985
+ database_name: this.names.database,
986
+ database_id: p.databaseId,
987
+ migrations_dir: join(dir, "migrations"),
988
+ },
989
+ ],
990
+ }),
991
+ );
992
+ try {
993
+ await this.intent("migrate", "migrate", this.tag("migrate"), { files: pending }, () =>
994
+ cf.applyMigrations({ config, database: this.names.database }),
995
+ );
996
+ } finally {
997
+ const now = new Set(await appliedNames(cf, p.databaseId as string));
998
+ for (const f of pending) if (now.has(f)) await this.ops.recordApplied(f, this.plan.files[f] as string);
999
+ }
1000
+ }
1001
+
1002
+ // ---- §6.2 step 7: the release --------------------------------------------------------------------------
1003
+ private async release(before: Half): Promise<void> {
1004
+ const p = this.progress;
1005
+ const cf = this.d.cf;
1006
+ const dir = this.dir(this.opId);
1007
+ const config = {
1008
+ ...this.plan.config,
1009
+ main: join(dir, "bundle", "index.js"),
1010
+ no_bundle: true,
1011
+ // The staged copy, by absolute path: the release config lives elsewhere than wrangler.jsonc (p0.9 §3.3).
1012
+ ...(this.plan.config.assets ? { assets: { ...this.plan.config.assets, directory: join(dir, "assets") } } : {}),
1013
+ };
1014
+ config.d1_databases = (config.d1_databases ?? []).map((d) =>
1015
+ d.binding === "DB" ? { ...d, database_id: p.databaseId } : d,
1016
+ );
1017
+ delete (config as Record<string, unknown>).build;
1018
+ for (const d of config.d1_databases ?? []) if (d.migrations_dir) d.migrations_dir = join(dir, "migrations");
1019
+ const configFile = join(dir, "release.wrangler.json");
1020
+ writeFileSync(configFile, JSON.stringify(config));
1021
+ // `wrangler deploy` where an upload can't do it: Cloudflare needs it for a class change, and only it
1022
+ // applies queue consumer settings (codex code r13). Unrecorded consumers (an adopted store) count as changed.
1023
+ const classChange =
1024
+ p.first ||
1025
+ classesKey(before.doMigrations ?? []) !== classesKey(this.plan.config.migrations ?? []) ||
1026
+ JSON.stringify(before.consumers ?? null) !== JSON.stringify(this.plan.config.queues?.consumers ?? []);
1027
+ const planKey = sha256(JSON.stringify(this.plan));
1028
+ if (p.appVersion && p.appFor !== planKey) delete p.appVersion;
1029
+ if (!p.appVersion) {
1030
+ // Secrets travel with a tracked version, generated only on a first deploy and only where missing (§6.1).
1031
+ const have = p.first ? new Set(await cf.secretNames(this.names.worker)) : new Set<string>();
1032
+ const generate = p.first ? CORE_SECRETS.filter((s) => !have.has(s)) : [];
1033
+ let secretsFile: string | undefined;
1034
+ if (generate.length) {
1035
+ secretsFile = join(dir, "secrets.json");
1036
+ const secret = this.d.secret ?? (() => randomBytes(32).toString("base64url"));
1037
+ writeFileSync(
1038
+ secretsFile,
1039
+ JSON.stringify(Object.fromEntries(generate.map((s) => [s, this.d.env?.[s] ?? secret()]))),
1040
+ {
1041
+ mode: 0o600,
1042
+ },
1043
+ );
1044
+ chmodSync(secretsFile, 0o600);
1045
+ }
1046
+ try {
1047
+ await this.step("upload");
1048
+ const tag = this.tag(classChange ? "deploy" : "upload");
1049
+ if (classChange) {
1050
+ // Cloudflare requires `wrangler deploy` for a class change; its hidden retry is §12's stated gap.
1051
+ p.appVersion = await this.intent("upload", "activate", tag, { classChange: true }, () =>
1052
+ cf.deployWithClasses({ config: configFile, message: tag, ...(secretsFile ? { secretsFile } : {}) }),
1053
+ );
1054
+ } else {
1055
+ p.appVersion = await this.intent("upload", "upload", tag, {}, () =>
1056
+ cf.uploadVersion({ config: configFile, message: tag, ...(secretsFile ? { secretsFile } : {}) }),
1057
+ );
1058
+ }
1059
+ } finally {
1060
+ if (secretsFile) rmSync(secretsFile, { force: true });
1061
+ }
1062
+ p.appFor = planKey;
1063
+ await this.step("uploaded");
1064
+ }
1065
+ if (!classChange) await this.activate("activate", p.appVersion as string);
1066
+ await this.markReleased();
1067
+ await this.triggers(this.plan.config.triggers?.crons ?? [], { logpush: false, invocationLogs: false });
1068
+ }
1069
+
1070
+ /** Crons and settings, each a single-attempt call read back before it counts (§3.1). */
1071
+ private async triggers(crons: readonly string[], settings: WorkerSettings): Promise<void> {
1072
+ const cf = this.d.cf;
1073
+ const want = [...crons].sort();
1074
+ if (JSON.stringify(await cf.schedules(this.names.worker)) !== JSON.stringify(want)) {
1075
+ await this.step("triggers");
1076
+ await this.intent("triggers", "triggers", this.tag("crons"), { crons: want }, () =>
1077
+ cf.putSchedules(this.names.worker, want),
1078
+ );
1079
+ if (JSON.stringify(await cf.schedules(this.names.worker)) !== JSON.stringify(want))
1080
+ throw new DeployRefused("the cron schedules didn't read back as set");
1081
+ }
1082
+ const s = await cf.settings(this.names.worker);
1083
+ if (s.logpush !== settings.logpush || s.invocationLogs !== settings.invocationLogs) {
1084
+ await this.step("settings");
1085
+ await this.intent("settings", "triggers", this.tag("settings"), settings, () =>
1086
+ cf.patchSettings(this.names.worker, settings),
1087
+ );
1088
+ const back = await cf.settings(this.names.worker);
1089
+ if (back.invocationLogs !== settings.invocationLogs)
1090
+ throw new DeployRefused("the Worker's settings didn't read back as set");
1091
+ }
1092
+ }
1093
+
1094
+ // ---- §6.2: a plan that can't finish is superseded, not edited ---------------------------------------------
1095
+ /**
1096
+ * `kedge deploy --supersede <op-id>`, from the corrected commit after `kedge
1097
+ * generate --supersede`: a new plan under the same operation, refused unless
1098
+ * every applied migration is in the new lock with the same hash and order.
1099
+ * The "before" half stays: it's still the store's real starting point.
1100
+ */
1101
+ async supersede(opId: string): Promise<void> {
1102
+ 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);
1104
+ this.ops = ops;
1105
+ const held = await ops.current(SCOPE);
1106
+ if (!held || held.op_id !== opId) throw new DeployRefused(`no operation ${opId} holds this store`);
1107
+ const row = await ops.resume(SCOPE, opId);
1108
+ this.opId = opId;
1109
+ const old = JSON.parse(row.plan) as Plan;
1110
+ // Reconcile reads the plan a landed release was built from: the old one, until the new one is checked (codex code r20).
1111
+ this.plan = old;
1112
+ this.progress = JSON.parse(row.progress) as Progress;
1113
+ try {
1114
+ await this.reconcile();
1115
+ const applied = await appliedNames(this.d.cf, this.progress.databaseId as string);
1116
+ const lock = this.d.lock.entries;
1117
+ const problems = applied.flatMap((f, i) =>
1118
+ lock[i]?.file !== f
1119
+ ? [`${f} is applied but the new lock has ${lock[i]?.file ?? "nothing"} at its place`]
1120
+ : old.files?.[f] !== undefined && lock[i]?.sha256 !== old.files[f]
1121
+ ? [`${f} is applied, and the new lock changes its bytes`]
1122
+ : [],
1123
+ );
1124
+ if (problems.length)
1125
+ throw new DeployRefused(`the corrected lock doesn't keep what's applied:\n ${problems.join("\n ")}`);
1126
+ const next = new Map(lock.map((e) => [e.file, e.sha256]));
1127
+ const changed = (await uncertainMigrations(ops, opId)).flatMap((i) =>
1128
+ (JSON.parse(i.detail) as { files: string[] }).files.filter((f) => next.get(f) !== old.files?.[f]),
1129
+ );
1130
+ if (changed.length)
1131
+ throw new DeployRefused(
1132
+ `${changed.join(", ")} ${changed.length === 1 ? "was" : "were"} submitted and could still land: the corrected lock may retry ${changed.length === 1 ? "it" : "them"} unchanged, not replace ${changed.length === 1 ? "it" : "them"}. Fix forward with a new migration, or a person who knows it never ran says so with --resolve-intent <id> --not-executed`,
1133
+ );
1134
+ this.plan = await this.buildPlan(
1135
+ { vars: old.vars, adopt: old.adopt, ...(old.window ? { window: old.window } : {}) },
1136
+ this.progress.databaseId ?? null,
1137
+ this.dir(opId),
1138
+ );
1139
+ // A replacement answers to the rules any plan does, before it's saved (codex code r11): descended
1140
+ // from what serves, and proven against the release that served before this operation began.
1141
+ if (old.allowOlder) this.plan.allowOlder = true;
1142
+ const stale = await this.staleness(this.plan);
1143
+ if (stale) throw new DeployRefused(stale);
1144
+ const started = this.progress.deploymentId
1145
+ ? (await ops.deployments()).find((r) => r.id === this.progress.deploymentId)
1146
+ : undefined;
1147
+ if (started) this.compatibleWith((JSON.parse(started.before_half) as Half).release);
1148
+ else this.compatible((await this.serving(await ops.deployments())).row);
1149
+ // Classified before it's saved, too: a refused replacement must leave the operation as it was, its
1150
+ // own plan and recovery intact (codex code r25).
1151
+ const pendingNow = this.d.lock.entries.filter((e) => !applied.includes(e.file));
1152
+ let classified: Classified[];
1153
+ try {
1154
+ classified = classify({
1155
+ cwd: this.d.cwd,
1156
+ lock: this.d.lock,
1157
+ pending: pendingNow,
1158
+ manifest: this.d.manifest,
1159
+ plugins: this.d.plugins,
1160
+ text: (f) => readFileSync(this.dir(opId, "migrations", f), "utf8"),
1161
+ });
1162
+ } catch (e) {
1163
+ throw new DeployRefused((e as Error).message);
1164
+ }
1165
+ if (this.progress.mode === "n1" && classified.some((c) => c.mode === "cutover"))
1166
+ throw new DeployRefused(
1167
+ "the superseding plan holds a cutover, but this deploy began N−1 with the live Worker serving: end it, then deploy the cutover with --maintenance-window",
1168
+ );
1169
+ const kept: Progress = { ...this.progress };
1170
+ delete kept.pending;
1171
+ delete kept.classified;
1172
+ delete kept.appVersion;
1173
+ await ops.supersede(SCOPE, opId, this.plan, kept);
1174
+ this.progress = kept;
1175
+ // What's pending is recomputed from the new lock; the deployment and its mode stay.
1176
+ await ops.step(SCOPE, opId, "superseded", this.progress);
1177
+ this.superseded = true;
1178
+ this.log(`superseded ${opId}'s plan with ${this.plan.commit.slice(0, 10)}`);
1179
+ await this.run();
1180
+ } catch (e) {
1181
+ await this.end(e);
1182
+ throw e;
1183
+ }
1184
+ }
1185
+
1186
+ // ---- a person's word: --resolve-intent, --record-active, --abort-cutover --------------------------------
1187
+ async resolveIntent(id: string, notExecuted: boolean): Promise<void> {
1188
+ if (!notExecuted)
1189
+ throw new DeployRefused(
1190
+ "--resolve-intent needs --not-executed: it's a statement that the call never took effect",
1191
+ );
1192
+ const { ops } = await openOps(this.d.cf, this.names, this.now);
1193
+ const i = await ops.intent(id);
1194
+ // Also a migration reconcile closed for its absence: absence permits a retry, only a person can say it never ran.
1195
+ const restatable = i?.kind === "migrate" && i.state === "not_executed" && !i.resolved_by?.startsWith("stated by");
1196
+ if (!i || (!["open", "unknown"].includes(i.state) && !restatable))
1197
+ throw new DeployRefused(`${id} isn't an unresolved call`);
1198
+ const held = await ops.current(i.scope);
1199
+ if (held?.executor) throw new DeployRefused(`${held.op_id} is running; stop it first`);
1200
+ await ops.closeIntent(id, "not_executed", `stated by ${ops.label}`);
1201
+ this.log(
1202
+ `${id} marked not executed. If Cloudflare had accepted it, it could still land; kedge doctor --remote reports what serves.`,
1203
+ );
1204
+ }
1205
+
1206
+ async recordActive(reason: string): Promise<void> {
1207
+ if (!reason) throw new DeployRefused("--record-active needs --reason");
1208
+ const { ops } = await openOps(this.d.cf, this.names, this.now);
1209
+ this.ops = ops;
1210
+ // The operation first, then the reads it records (codex code r12).
1211
+ this.opId = await ops.start(SCOPE, "record-active", { reason }, "record");
1212
+ try {
1213
+ await this.recordActiveChecked(reason);
1214
+ } catch (e) {
1215
+ if (!(e instanceof LostOwnership)) await this.abandonRecording(`failed: ${(e as Error).name}`).catch(() => {});
1216
+ throw e;
1217
+ }
1218
+ }
1219
+
1220
+ private async recordActiveChecked(reason: string): Promise<void> {
1221
+ const ops = this.ops;
1222
+ const rows = await ops.deployments();
1223
+ const deps = await this.d.cf.deployments(this.names.worker);
1224
+ const v = deps[0]?.versions;
1225
+ if (v?.length !== 1) throw new DeployRefused("the Worker serves no single version");
1226
+ const last = rows.find((r) => r.after_half);
1227
+ if (!last)
1228
+ throw new DeployRefused(
1229
+ "no deployment is recorded yet: a store deployed before P0.8 enters with --adopt-existing",
1230
+ );
1231
+ const prior = JSON.parse(last.after_half as string) as Half;
1232
+ await this.recordActiveRun(reason, prior, (v[0] as { version_id: string }).version_id);
1233
+ }
1234
+
1235
+ private async recordActiveRun(reason: string, prior: Half, versionId: string): Promise<void> {
1236
+ const ops = this.ops;
1237
+ const id = await ops.addDeployment(this.opId, "recorded", {
1238
+ ...prior,
1239
+ note: `recorded by ${ops.label}: ${reason}`,
1240
+ });
1241
+ await ops.completeDeployment(id, {
1242
+ ...prior,
1243
+ versionId,
1244
+ crons: await this.d.cf.schedules(this.names.worker),
1245
+ settings: await this.d.cf.settings(this.names.worker),
1246
+ note: reason,
1247
+ });
1248
+ await ops.finish(SCOPE, this.opId, "recorded", [{ reason }]);
1249
+ this.log(`recorded version ${versionId} as ${prior.release}`);
1250
+ }
1251
+
1252
+ async abortCutover(): Promise<void> {
1253
+ const { ops } = await openOps(this.d.cf, this.names, this.now);
1254
+ this.ops = ops;
1255
+ const held = await ops.current(SCOPE);
1256
+ if (!held) throw new DeployRefused("no operation to abort");
1257
+ const row = await ops.resume(SCOPE, held.op_id);
1258
+ this.opId = row.op_id;
1259
+ this.plan = JSON.parse(row.plan) as Plan;
1260
+ this.progress = JSON.parse(row.progress) as Progress;
1261
+ try {
1262
+ const p = this.progress;
1263
+ if (p.mode !== "cutover")
1264
+ throw new DeployRefused("only a cutover is aborted; an N−1 deploy's previous Worker still serves");
1265
+ // A submitted migration absent from d1_migrations may be retried forward, but it isn't proven
1266
+ // not to land: if it lands after the rollback, its fence stops the Worker abort put back (codex code r5).
1267
+ // One that applied is caught below with the fence it installed.
1268
+ const submitted = await uncertainMigrations(ops, this.opId);
1269
+ if (submitted.length)
1270
+ throw new DeployRefused(
1271
+ `a migration was submitted under this operation (${submitted.map((i) => i.id).join(", ")}) and could still land: the only way out is forward (kedge deploy --resume), or a person who knows it never ran says so with --resolve-intent <id> --not-executed`,
1272
+ );
1273
+ const applied = new Set(await appliedNames(this.d.cf, p.databaseId as string));
1274
+ if ((p.pending ?? []).some((f) => applied.has(f)))
1275
+ throw new DeployRefused(
1276
+ "a pending migration has applied, and the fence with it: the only way out is forward (fix it, then kedge generate --supersede)",
1277
+ );
1278
+ const dep = (await ops.deployments()).find((r) => r.id === p.deploymentId);
1279
+ const before = JSON.parse(dep?.before_half ?? "{}") as Half;
1280
+ if (!before.versionId) throw new DeployRefused("no previous version to return to");
1281
+ const versions = await this.d.cf.versions(this.names.worker);
1282
+ if (!versions.some((v) => v.id === before.versionId))
1283
+ throw new DeployRefused(
1284
+ `version ${before.versionId} has left Cloudflare's rollback window: the path is forward`,
1285
+ );
1286
+ await this.reconcile();
1287
+ await this.activate("abort", before.versionId);
1288
+ await this.triggers(before.crons ?? [], before.settings ?? { logpush: false, invocationLogs: false });
1289
+ await ops.completeDeployment(p.deploymentId as string, {
1290
+ ...before,
1291
+ commit: servingCommit((await ops.deployments()).filter((r) => r.id !== p.deploymentId)),
1292
+ note: "cutover aborted",
1293
+ });
1294
+ await ops.finish(SCOPE, this.opId, "aborted", [this.plan]);
1295
+ this.log(`aborted: ${this.names.worker} serves ${before.versionId} again`);
1296
+ } catch (e) {
1297
+ await ops.stop(SCOPE, this.opId).catch(() => {});
1298
+ throw e;
1299
+ }
1300
+ }
1301
+
1302
+ // ---- `kedge secret put|delete`: a keyring rotation as a tracked operation (§3) -----------------------
1303
+ async secret(name: string, value: string | null): Promise<void> {
1304
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name))
1305
+ 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);
1307
+ this.ops = ops;
1308
+ const rows = await ops.deployments();
1309
+ this.plan = { commit: "secret", adopt: [], vars: {} } as unknown as Plan;
1310
+ this.opId = await ops.start(SCOPE, "secret", { name, delete: value === null }, "start");
1311
+ try {
1312
+ await this.reconcile();
1313
+ const { row } = await this.serving(rows);
1314
+ if (!row) throw new DeployRefused("nothing is deployed yet");
1315
+ // Wrangler's `versions secret` copies the newest uploaded version, not the serving one, so an
1316
+ // upload that was never activated would ship with the rotation under the old manifest (codex code r3).
1317
+ const serving = (await this.d.cf.deployments(this.names.worker))[0]?.versions;
1318
+ const newest = [...(await this.d.cf.versions(this.names.worker))].sort((a, b) =>
1319
+ byCodePoint(b.created, a.created),
1320
+ )[0];
1321
+ if (serving?.length !== 1 || serving[0]?.version_id !== newest?.id)
1322
+ throw new DeployRefused(
1323
+ `the newest uploaded version (${newest?.id ?? "none"}) isn't the one serving alone (${serving?.map((v) => v.version_id).join(", ") ?? "none"}): kedge secret would ship it. Finish or roll back that deploy first`,
1324
+ );
1325
+ const prior = JSON.parse(row.after_half as string) as Half;
1326
+ this.progress.deploymentId = await ops.addDeployment(this.opId, "secret", {
1327
+ ...prior,
1328
+ note: `${value === null ? "deleted" : "set"} ${name}`,
1329
+ });
1330
+ await this.step("version");
1331
+ const tag = this.tag("secret");
1332
+ const versionId = await this.intent("version", "upload", tag, { name }, () =>
1333
+ this.d.cf.versionSecret(this.names.worker, name, value, tag),
1334
+ );
1335
+ this.progress.secretVersion = versionId;
1336
+ await this.step("uploaded");
1337
+ await this.activate("activate", versionId);
1338
+ await ops.completeDeployment(this.progress.deploymentId, {
1339
+ ...prior,
1340
+ versionId,
1341
+ crons: await this.d.cf.schedules(this.names.worker),
1342
+ settings: await this.d.cf.settings(this.names.worker),
1343
+ note: `${value === null ? "deleted" : "set"} ${name}`,
1344
+ });
1345
+ await ops.finish(SCOPE, this.opId, "secret", [{ name }]);
1346
+ this.log(`${value === null ? "deleted" : "set"} ${name}: version ${versionId} serves`);
1347
+ } catch (e) {
1348
+ await this.end(e);
1349
+ throw e;
1350
+ }
1351
+ }
1352
+
1353
+ /**
1354
+ * A stopped `kedge secret`, continued by `kedge deploy --resume` or `--take-over`. Its value
1355
+ * was never stored, so it can't be uploaded again: once its calls are closed by evidence,
1356
+ * an activation that landed completes the manifest, and anything short of that releases the
1357
+ * store with the serving version unchanged, for `kedge secret` to be run again.
1358
+ */
1359
+ private async recoverSecret(row: OperationRow): Promise<void> {
1360
+ this.opId = row.op_id;
1361
+ this.plan = JSON.parse(row.plan) as Plan;
1362
+ this.progress = JSON.parse(row.progress) as Progress;
1363
+ const ops = this.ops;
1364
+ const what = JSON.parse(row.plan) as { name?: string; delete?: boolean };
1365
+ try {
1366
+ await this.reconcile();
1367
+ const intents = await ops.intents(SCOPE, this.opId);
1368
+ const landed = intents.find((i) => i.kind === "activate" && i.state === "done");
1369
+ let versionId = landed ? (JSON.parse(landed.detail) as { versionId?: string }).versionId : undefined;
1370
+ if (!versionId) {
1371
+ // Its upload, by the id it recorded or by its tag in the version list, is activated to finish: left
1372
+ // newer than what serves, it would refuse every later rotation (codex code r5).
1373
+ const tags = new Set(intents.filter((i) => i.kind === "upload").map((i) => i.tag));
1374
+ const own =
1375
+ this.progress.secretVersion ??
1376
+ (await this.d.cf.versions(this.names.worker)).find((v) => v.message && tags.has(v.message))?.id;
1377
+ if (own) {
1378
+ await this.activate("activate", own);
1379
+ versionId = own;
1380
+ }
1381
+ }
1382
+ // The manifest this rotation started from: the latest finished one but its own.
1383
+ const prior = (await ops.deployments()).find((r) => r.after_half && r.id !== this.progress.deploymentId);
1384
+ const before = prior ? (JSON.parse(prior.after_half as string) as Half) : null;
1385
+ if (this.progress.deploymentId && before)
1386
+ await ops.completeDeployment(this.progress.deploymentId, {
1387
+ ...before,
1388
+ ...(versionId
1389
+ ? {
1390
+ versionId,
1391
+ crons: await this.d.cf.schedules(this.names.worker),
1392
+ settings: await this.d.cf.settings(this.names.worker),
1393
+ }
1394
+ : {}),
1395
+ note: versionId ? `${what.delete ? "deleted" : "set"} ${what.name}` : `${what.name}: not applied`,
1396
+ });
1397
+ await ops.finish(SCOPE, this.opId, versionId ? "secret" : "secret not applied", [what]);
1398
+ this.log(
1399
+ versionId
1400
+ ? `${what.name} was applied: version ${versionId} serves`
1401
+ : `${what.name} wasn't applied; nothing changed: run kedge secret again`,
1402
+ );
1403
+ } catch (e) {
1404
+ await this.end(e);
1405
+ throw e;
1406
+ }
1407
+ }
1408
+
1409
+ // ---- `kedge migrate --remote`: the shared runner without a Worker deploy (§3) ---------------------------
1410
+ /** A fresh run, or (from `kedge deploy --resume`) the held one, which this checkout's lock must match. */
1411
+ async migrateRemote(held?: OperationRow): Promise<void> {
1412
+ // A resumed run keeps the executor that claimed it.
1413
+ if (!held) this.ops = (await openOps(this.d.cf, this.names, this.now)).ops;
1414
+ const ops = this.ops;
1415
+ const db = await ops.resource("d1", this.names.database);
1416
+ if (!db?.id) throw new DeployRefused("no database is recorded for this store: kedge deploy makes it");
1417
+ this.progress = { databaseId: db.id };
1418
+ const files = Object.fromEntries(this.d.lock.entries.map((e) => [e.file, e.sha256]));
1419
+ if (held) {
1420
+ this.opId = held.op_id;
1421
+ this.plan = JSON.parse(held.plan) as Plan;
1422
+ this.progress = { ...(JSON.parse(held.progress) as Progress), databaseId: db.id };
1423
+ if (JSON.stringify((this.plan as unknown as { files: unknown }).files) !== JSON.stringify(files)) {
1424
+ await ops.stop(SCOPE, this.opId);
1425
+ throw new DeployRefused("this checkout's kedge.lock.json isn't the one the held migrate ran with");
1426
+ }
1427
+ } else {
1428
+ this.plan = { commit: this.d.git.head(), adopt: [], vars: {}, files } as unknown as Plan;
1429
+ this.opId = await ops.start(SCOPE, "migrate", this.plan, "start");
1430
+ }
1431
+ try {
1432
+ await this.reconcile();
1433
+ const dir = this.dir(this.opId, "migrations");
1434
+ mkdirSync(dir, { recursive: true });
1435
+ for (const e of this.d.lock.entries) {
1436
+ const bytes = readFileSync(join(this.d.cwd, "migrations", e.file));
1437
+ if (sha256(bytes) !== e.sha256)
1438
+ throw new DeployRefused(`${e.file} doesn't match kedge.lock.json; run kedge doctor`);
1439
+ writeFileSync(join(dir, e.file), bytes);
1440
+ }
1441
+ const applied = await appliedNames(this.d.cf, db.id);
1442
+ const problems = appliedProblems(this.d.lock, applied, await ops.appliedHashes());
1443
+ if (problems.length)
1444
+ throw new DeployRefused(`the store's migrations don't match the lock:\n ${problems.join("\n ")}`);
1445
+ const set = new Set(applied);
1446
+ const pending = this.d.lock.entries.filter((e) => !set.has(e.file));
1447
+ if (pending.length === 0) {
1448
+ await ops.finish(SCOPE, this.opId, "nothing pending", [this.plan]);
1449
+ this.log("migrations are up to date");
1450
+ return;
1451
+ }
1452
+ const classified = classify({
1453
+ cwd: this.d.cwd,
1454
+ lock: this.d.lock,
1455
+ pending,
1456
+ manifest: this.d.manifest,
1457
+ plugins: this.d.plugins,
1458
+ text: (f) => readFileSync(join(dir, f), "utf8"),
1459
+ });
1460
+ if (classified.some((c) => c.mode === "cutover"))
1461
+ throw new DeployRefused(
1462
+ "a pending migration is a cutover: use kedge deploy, which runs it behind the maintenance Worker; applying it here would fence the live Worker's writes",
1463
+ );
1464
+ const m = this.d.manifest;
1465
+ this.plan.release = m?.version ?? "workspace";
1466
+ this.plan.upgradesFrom = m?.upgradesFrom ?? null;
1467
+ this.compatible((await this.serving(await ops.deployments())).row);
1468
+ const bookmark = await this.d.cf.bookmark(db.id);
1469
+ this.progress.pending = pending.map((e) => e.file);
1470
+ await this.step("bookmark");
1471
+ this.log(`restore point before migrating: ${bookmark}`);
1472
+ await this.migrate();
1473
+ await ops.finish(SCOPE, this.opId, "migrated", [{ ...this.plan, bookmark }]);
1474
+ this.log(`applied ${pending.length} migration${pending.length === 1 ? "" : "s"}`);
1475
+ } catch (e) {
1476
+ await this.end(e);
1477
+ throw e;
1478
+ } finally {
1479
+ rmSync(this.dir(this.opId), { recursive: true, force: true });
1480
+ }
1481
+ }
1482
+
1483
+ // ---- §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);
1486
+ this.ops = ops;
1487
+ // The operation first, then every read it decides on: read before, a deploy finishing in between
1488
+ // would be recorded over with what it replaced (codex code r12).
1489
+ this.opId = await ops.start(SCOPE, "adopt", { release }, "adopt");
1490
+ try {
1491
+ await this.adoptChecked(release, texts);
1492
+ } catch (e) {
1493
+ if (!(e instanceof LostOwnership)) await this.abandonRecording(`failed: ${(e as Error).name}`).catch(() => {});
1494
+ throw e;
1495
+ }
1496
+ }
1497
+
1498
+ private async adoptChecked(release: string, texts: (file: string) => string): Promise<void> {
1499
+ const ops = this.ops;
1500
+ if ((await ops.deployments()).length)
1501
+ throw new DeployRefused("--adopt-existing is for a store with no recorded deployment");
1502
+ const cf = this.d.cf;
1503
+ const dbId = await cf.findD1(this.names.database);
1504
+ if (!dbId) throw new DeployRefused(`no database named ${this.names.database}`);
1505
+ if (!(await cf.findWorker(this.names.worker))) throw new DeployRefused(`no Worker named ${this.names.worker}`);
1506
+ const deps = await cf.deployments(this.names.worker);
1507
+ const v = deps[0]?.versions;
1508
+ if (v?.length !== 1) throw new DeployRefused("the Worker serves no single version");
1509
+ const applied = await appliedNames(cf, dbId);
1510
+ const problems = appliedProblems(this.d.lock, applied, new Map());
1511
+ if (problems.length)
1512
+ throw new DeployRefused(`the store's migrations aren't a prefix of the lock:\n ${problems.join("\n ")}`);
1513
+ // d1_migrations keeps names, not bytes: the live schema is what can be compared (§6.1).
1514
+ const expected = schemaOf(applied.map(texts)).objects.filter((o) => o.type !== "constraint");
1515
+ const live = await cf.queryD1(
1516
+ dbId,
1517
+ "SELECT type, name, tbl_name AS tbl, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' AND name <> 'd1_migrations'",
1518
+ );
1519
+ const norm = (s: unknown) =>
1520
+ String(s ?? "")
1521
+ .replace(/\s+/g, " ")
1522
+ .trim();
1523
+ const want = new Map(expected.map((o) => [`${o.type}:${o.name}`, norm(o.sql)]));
1524
+ const have = new Map(live.map((r) => [`${r.type}:${r.name}`, norm(r.sql)]));
1525
+ const diff = [
1526
+ ...[...want].filter(([k, s]) => have.get(k) !== s).map(([k]) => `${k} differs or is missing`),
1527
+ ...[...have.keys()].filter((k) => !want.has(k)).map((k) => `${k} isn't in the lock's migrations`),
1528
+ ];
1529
+ if (diff.length)
1530
+ throw new DeployRefused(
1531
+ `the live schema isn't what the lock's applied migrations make:\n ${diff.slice(0, 20).join("\n ")}`,
1532
+ );
1533
+ await this.adoptRun(release, dbId, applied, (v[0] as { version_id: string }).version_id);
1534
+ }
1535
+
1536
+ private async adoptRun(release: string, dbId: string, applied: readonly string[], versionId: string): Promise<void> {
1537
+ const ops = this.ops;
1538
+ const cf = this.d.cf;
1539
+ await ops.recordResource("d1", this.names.database, dbId, "ours");
1540
+ const qid = await cf.findQueue(this.names.queue);
1541
+ if (qid) await ops.recordResource("queue", this.names.queue, qid, "ours");
1542
+ await ops.recordResource("worker", this.names.worker, null, "ours");
1543
+ for (const f of applied)
1544
+ await ops.recordApplied(f, (this.d.lock.entries.find((e) => e.file === f) as LockEntry).sha256);
1545
+ const half: Half = {
1546
+ lockSha: sha256(
1547
+ JSON.stringify(this.d.lock.entries.filter((e) => applied.includes(e.file)).map((e) => [e.file, e.sha256])),
1548
+ ),
1549
+ versionId,
1550
+ release,
1551
+ doMigrations: this.wrangler.migrations ?? [],
1552
+ doStateFormat: null,
1553
+ crons: await cf.schedules(this.names.worker),
1554
+ settings: await cf.settings(this.names.worker),
1555
+ note: `adopted by ${ops.label} as Kedge ${release}: its migration history's bytes can't be proven, only the schema they made`,
1556
+ };
1557
+ const id = await ops.addDeployment(this.opId, "adopted", { ...half, bookmark: await cf.bookmark(dbId) });
1558
+ await ops.completeDeployment(id, half);
1559
+ await ops.finish(SCOPE, this.opId, "adopted", [{ release }]);
1560
+ this.log(`adopted ${this.names.worker} as Kedge ${release}`);
1561
+ }
1562
+ }
1563
+
1564
+ /**
1565
+ * `kedge generate --supersede` (§6.2): the lock with its applied prefix kept
1566
+ * byte for byte and the proven-unapplied suffix removed, so the ordinary
1567
+ * composer regenerates the suffix from the corrected sources. D1 recorded
1568
+ * nothing of a file that failed, so reusing its name is safe.
1569
+ */
1570
+ export function supersededLock(lock: Lock, applied: readonly string[]): { lock: Lock; removed: string[] } {
1571
+ const problems = appliedProblems(lock, applied, new Map());
1572
+ if (problems.length)
1573
+ throw new DeployRefused(`the store's migrations aren't a prefix of the lock:\n ${problems.join("\n ")}`);
1574
+ const keep = lock.entries.slice(0, applied.length);
1575
+ const dropped = lock.entries.slice(applied.length);
1576
+ // A plugin's generation is tracked in the lock's plugin state too; regenerating one isn't this path's.
1577
+ const generated = dropped.filter((e) => /^plugin:[^:]+:(generated|entity|install)/.test(e.source));
1578
+ if (generated.length)
1579
+ throw new DeployRefused(
1580
+ `the unapplied suffix holds a plugin's generated migrations (${generated.map((e) => e.file).join(", ")}): fix the plugin's declaration and supersede with a new generation instead`,
1581
+ );
1582
+ return { lock: { ...lock, entries: keep }, removed: dropped.map((e) => e.file) };
1583
+ }
1584
+
1585
+ /** The admin build an assets directory holds, from the build.json its build wrote (p0.9 §2.4); null when none. */
1586
+ export function adminBuildIn(assetsDir: string): string | null {
1587
+ const file = join(assetsDir, "app", "build.json");
1588
+ return existsSync(file) ? ((JSON.parse(readFileSync(file, "utf8")) as { build?: string }).build ?? null) : null;
1589
+ }
1590
+
1591
+ /**
1592
+ * Stages the admin's assets (p0.9 §3.3): a copy of the configured directory,
1593
+ * and the build its `app/build.json` names, defined into the Worker's code as
1594
+ * `KEDGE_ADMIN_BUILD`. Null when the store serves no admin.
1595
+ */
1596
+ export function stageAssets(
1597
+ cwd: string,
1598
+ config: WranglerJson,
1599
+ to: string,
1600
+ ): { build: string | null; define: Record<string, string> } | null {
1601
+ const a = config.assets as { directory?: string } | undefined;
1602
+ if (!a?.directory) return null;
1603
+ const from = resolve(cwd, a.directory);
1604
+ if (!existsSync(from))
1605
+ throw new DeployRefused(`wrangler.jsonc's assets directory ${a.directory} doesn't exist: run pnpm install`);
1606
+ rmSync(to, { recursive: true, force: true });
1607
+ cpSync(from, to, { recursive: true, dereference: true });
1608
+ const build = adminBuildIn(to);
1609
+ return { build, define: build ? { KEDGE_ADMIN_BUILD: JSON.stringify(build) } : {} };
1610
+ }