@decocms/blocks-cli 7.5.4 → 7.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.5.4",
3
+ "version": "7.7.0",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "lint:unused": "knip"
35
35
  },
36
36
  "dependencies": {
37
- "@decocms/blocks": "7.5.4",
37
+ "@decocms/blocks": "7.7.0",
38
38
  "ts-morph": "^27.0.0",
39
39
  "tsx": "^4.22.5"
40
40
  },
@@ -1,13 +1,54 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
1
4
  import { describe, expect, it, vi } from "vitest";
2
- import { computeRevision, KV_KEYS } from "@decocms/blocks/cms";
3
- import { createKvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
4
- import { buildSnapshot, verifySnapshotInKv, writeSnapshotToKv } from "./lib/kv-snapshot";
5
+ import {
6
+ computeRevision,
7
+ DEPLOYMENTS_KEY,
8
+ LIVE_KEY,
9
+ revisionKey,
10
+ snapshotKey,
11
+ } from "@decocms/blocks/cms";
12
+ import { createKvRestClient, type KvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
13
+ import { kvNamespaceIdFromToml, kvNamespaceIdFromWrangler } from "./lib/wrangler-config";
14
+ import {
15
+ buildSnapshot,
16
+ recordAndGcDeployment,
17
+ setLiveDeployment,
18
+ verifySnapshotInKv,
19
+ writeSnapshotToKv,
20
+ } from "./lib/kv-snapshot";
5
21
  import {
6
22
  changedBlockFiles,
7
23
  changedBlockKeys,
8
24
  purgePathsForChangedKeys,
9
25
  } from "./lib/sync-helpers";
10
26
 
27
+ const ID = "sha-abc123";
28
+
29
+ /** In-memory KvRestClient backed by a Map, recording put order. */
30
+ function makeClient(initial: Record<string, string> = {}) {
31
+ const store = new Map<string, string>(Object.entries(initial));
32
+ const putOrder: string[] = [];
33
+ const client: KvRestClient = {
34
+ get: (k) => Promise.resolve(store.get(k) ?? null),
35
+ put: (k, v) => {
36
+ putOrder.push(k);
37
+ store.set(k, v);
38
+ return Promise.resolve();
39
+ },
40
+ delete: (k) => {
41
+ store.delete(k);
42
+ return Promise.resolve();
43
+ },
44
+ list: (prefix) =>
45
+ Promise.resolve(
46
+ [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
47
+ ),
48
+ };
49
+ return { client, store, putOrder };
50
+ }
51
+
11
52
  describe("kvConfigFromEnv", () => {
12
53
  it("reads the three CF vars", () => {
13
54
  expect(
@@ -15,29 +56,128 @@ describe("kvConfigFromEnv", () => {
15
56
  ).toEqual({ accountId: "a", namespaceId: "n", token: "t" });
16
57
  });
17
58
 
18
- it("throws listing all missing vars", () => {
59
+ it("falls back to the CLOUDFLARE_* names CF Workers Builds injects", () => {
60
+ expect(
61
+ kvConfigFromEnv({
62
+ CLOUDFLARE_ACCOUNT_ID: "a",
63
+ CLOUDFLARE_API_TOKEN: "t",
64
+ CF_KV_NAMESPACE_ID: "n",
65
+ }),
66
+ ).toEqual({ accountId: "a", namespaceId: "n", token: "t" });
67
+ });
68
+
69
+ it("prefers explicit CF_* over CLOUDFLARE_*", () => {
70
+ expect(
71
+ kvConfigFromEnv({
72
+ CF_ACCOUNT_ID: "cf",
73
+ CLOUDFLARE_ACCOUNT_ID: "cloudflare",
74
+ CF_API_TOKEN: "t",
75
+ CF_KV_NAMESPACE_ID: "n",
76
+ }).accountId,
77
+ ).toBe("cf");
78
+ });
79
+
80
+ it("reads the namespace id from wrangler config when CF_KV_NAMESPACE_ID is unset", () => {
81
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wrangler-"));
82
+ fs.writeFileSync(
83
+ path.join(dir, "wrangler.jsonc"),
84
+ JSON.stringify({ kv_namespaces: [{ binding: "DECO_KV", id: "ns-from-config" }] }),
85
+ );
86
+ expect(
87
+ kvConfigFromEnv({ CF_ACCOUNT_ID: "a", CF_API_TOKEN: "t" }, { wranglerDir: dir }),
88
+ ).toEqual({ accountId: "a", namespaceId: "ns-from-config", token: "t" });
89
+ });
90
+
91
+ it("throws listing all missing config", () => {
19
92
  expect(() => kvConfigFromEnv({ CF_ACCOUNT_ID: "a" })).toThrow(
20
- /CF_KV_NAMESPACE_ID, CF_API_TOKEN/,
93
+ /CF_KV_NAMESPACE_ID.*CF_API_TOKEN/,
21
94
  );
22
95
  });
23
96
  });
24
97
 
98
+ describe("kvNamespaceIdFromWrangler / kvNamespaceIdFromToml", () => {
99
+ it("reads the DECO_KV id from wrangler.jsonc (with comments + trailing comma)", () => {
100
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wrangler-"));
101
+ fs.writeFileSync(
102
+ path.join(dir, "wrangler.jsonc"),
103
+ `{
104
+ // deco fast-deploy
105
+ "kv_namespaces": [
106
+ { "binding": "OTHER", "id": "nope" },
107
+ { "binding": "DECO_KV", "id": "abc123" },
108
+ ],
109
+ }`,
110
+ );
111
+ expect(kvNamespaceIdFromWrangler(dir)).toBe("abc123");
112
+ });
113
+
114
+ it("returns null when no wrangler config is present", () => {
115
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wrangler-"));
116
+ expect(kvNamespaceIdFromWrangler(dir)).toBeNull();
117
+ });
118
+
119
+ it("parses [[kv_namespaces]] blocks from wrangler.toml", () => {
120
+ const toml = `
121
+ name = "my-site"
122
+
123
+ [[kv_namespaces]]
124
+ binding = "OTHER"
125
+ id = "nope"
126
+
127
+ [[kv_namespaces]]
128
+ binding = "DECO_KV"
129
+ id = "toml-id"
130
+
131
+ [vars]
132
+ DECO_FAST_DEPLOY = "1"
133
+ `;
134
+ expect(kvNamespaceIdFromToml(toml)).toBe("toml-id");
135
+ expect(kvNamespaceIdFromToml(toml, "MISSING")).toBeNull();
136
+ });
137
+ });
138
+
25
139
  describe("createKvRestClient", () => {
26
140
  const config = { accountId: "acc", namespaceId: "ns", token: "tok" };
27
141
 
28
142
  it("PUTs to the values endpoint with auth + body", async () => {
29
143
  const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch;
30
144
  const client = createKvRestClient({ ...config, fetchImpl });
31
- await client.put(KV_KEYS.REVISION, "rev1");
145
+ const key = revisionKey(ID);
146
+ await client.put(key, "rev1");
32
147
 
33
148
  const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
34
149
  expect(url).toContain("/accounts/acc/storage/kv/namespaces/ns/values/");
35
- expect(url).toContain(encodeURIComponent(KV_KEYS.REVISION));
150
+ expect(url).toContain(encodeURIComponent(key));
36
151
  expect(init.method).toBe("PUT");
37
152
  expect(init.headers.Authorization).toBe("Bearer tok");
38
153
  expect(init.body).toBe("rev1");
39
154
  });
40
155
 
156
+ it("DELETE tolerates a 404 (idempotent)", async () => {
157
+ const fetchImpl = vi.fn(async () => new Response("nope", { status: 404 })) as unknown as typeof fetch;
158
+ const client = createKvRestClient({ ...config, fetchImpl });
159
+ await expect(client.delete("gone")).resolves.toBeUndefined();
160
+ });
161
+
162
+ it("LIST follows the pagination cursor and filters by prefix", async () => {
163
+ const pages = [
164
+ { result: [{ name: "decofile:a" }], result_info: { cursor: "c1" } },
165
+ { result: [{ name: "decofile:b" }], result_info: { cursor: "" } },
166
+ ];
167
+ let call = 0;
168
+ const fetchImpl = vi.fn(
169
+ async () => new Response(JSON.stringify(pages[call++]), { status: 200 }),
170
+ ) as unknown as typeof fetch;
171
+ const client = createKvRestClient({ ...config, fetchImpl });
172
+ await expect(client.list("decofile:")).resolves.toEqual(["decofile:a", "decofile:b"]);
173
+
174
+ const [url0] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
175
+ expect(url0).toContain("/keys?");
176
+ expect(url0).toContain("prefix=decofile");
177
+ const [url1] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[1];
178
+ expect(url1).toContain("cursor=c1");
179
+ });
180
+
41
181
  it("GET returns null on 404", async () => {
42
182
  const fetchImpl = vi.fn(async () => new Response("nope", { status: 404 })) as unknown as typeof fetch;
43
183
  const client = createKvRestClient({ ...config, fetchImpl });
@@ -67,33 +207,105 @@ describe("kv-snapshot helpers", () => {
67
207
  expect(snap.count).toBe(2);
68
208
  });
69
209
 
70
- it("writes snapshot before revision, then verifies round-trip", async () => {
71
- const store = new Map<string, string>();
72
- const order: string[] = [];
73
- const client = {
74
- get: (k: string) => Promise.resolve(store.get(k) ?? null),
75
- put: (k: string, v: string) => {
76
- order.push(k);
77
- store.set(k, v);
78
- return Promise.resolve();
79
- },
80
- };
210
+ it("writes the keyed snapshot before revision, then verifies round-trip", async () => {
211
+ const { client, putOrder } = makeClient();
81
212
  const snap = buildSnapshot(blocks);
82
- await writeSnapshotToKv(client, snap);
213
+ await writeSnapshotToKv(client, snap, ID);
83
214
 
84
- expect(order).toEqual([KV_KEYS.SNAPSHOT, KV_KEYS.REVISION]);
85
- await expect(verifySnapshotInKv(client, snap.revision)).resolves.toEqual({ ok: true });
215
+ expect(putOrder).toEqual([snapshotKey(ID), revisionKey(ID)]);
216
+ await expect(verifySnapshotInKv(client, snap.revision, ID)).resolves.toEqual({ ok: true });
86
217
  });
87
218
 
88
219
  it("verify fails when the revision mismatches", async () => {
89
- const store = new Map<string, string>([
90
- [KV_KEYS.SNAPSHOT, "{}"],
91
- [KV_KEYS.REVISION, "other"],
92
- ]);
93
- const client = { get: (k: string) => Promise.resolve(store.get(k) ?? null), put: () => Promise.resolve() };
94
- const res = await verifySnapshotInKv(client, "expected");
220
+ const { client } = makeClient({
221
+ [snapshotKey(ID)]: "{}",
222
+ [revisionKey(ID)]: "other",
223
+ });
224
+ const res = await verifySnapshotInKv(client, "expected", ID);
95
225
  expect(res.ok).toBe(false);
96
226
  });
227
+
228
+ it("setLiveDeployment writes index:live", async () => {
229
+ const { client, store } = makeClient();
230
+ await setLiveDeployment(client, ID);
231
+ expect(store.get(LIVE_KEY)).toBe(ID);
232
+ });
233
+ });
234
+
235
+ describe("recordAndGcDeployment", () => {
236
+ it("appends to index:deployments (newest last, deduped)", async () => {
237
+ const { client, store } = makeClient({
238
+ [DEPLOYMENTS_KEY]: JSON.stringify([{ id: "old", ts: 1 }]),
239
+ });
240
+ await recordAndGcDeployment(client, "new", 2, 10);
241
+ expect(JSON.parse(store.get(DEPLOYMENTS_KEY)!)).toEqual([
242
+ { id: "old", ts: 1 },
243
+ { id: "new", ts: 2 },
244
+ ]);
245
+ });
246
+
247
+ it("re-recording an existing id moves it to newest without duplicating", async () => {
248
+ const { client, store } = makeClient({
249
+ [DEPLOYMENTS_KEY]: JSON.stringify([
250
+ { id: "a", ts: 1 },
251
+ { id: "b", ts: 2 },
252
+ ]),
253
+ });
254
+ await recordAndGcDeployment(client, "a", 3, 10);
255
+ expect(JSON.parse(store.get(DEPLOYMENTS_KEY)!)).toEqual([
256
+ { id: "b", ts: 2 },
257
+ { id: "a", ts: 3 },
258
+ ]);
259
+ });
260
+
261
+ it("prunes snapshots beyond the retain window", async () => {
262
+ const entries = [
263
+ { id: "d1", ts: 1 },
264
+ { id: "d2", ts: 2 },
265
+ ];
266
+ const initial: Record<string, string> = { [DEPLOYMENTS_KEY]: JSON.stringify(entries) };
267
+ for (const e of entries) {
268
+ initial[snapshotKey(e.id)] = "{}";
269
+ initial[revisionKey(e.id)] = "r";
270
+ }
271
+ const { client, store } = makeClient(initial);
272
+
273
+ // retain=2 → adding d3 evicts the oldest (d1).
274
+ const { pruned } = await recordAndGcDeployment(client, "d3", 3, 2);
275
+ expect(pruned).toEqual(["d1"]);
276
+ expect(store.has(snapshotKey("d1"))).toBe(false);
277
+ expect(store.has(revisionKey("d1"))).toBe(false);
278
+ expect(store.has(snapshotKey("d2"))).toBe(true);
279
+ expect(JSON.parse(store.get(DEPLOYMENTS_KEY)!).map((e: { id: string }) => e.id)).toEqual([
280
+ "d2",
281
+ "d3",
282
+ ]);
283
+ });
284
+
285
+ it("never prunes the currently-live deployment even if it is old", async () => {
286
+ const entries = [
287
+ { id: "d1", ts: 1 },
288
+ { id: "d2", ts: 2 },
289
+ ];
290
+ const initial: Record<string, string> = {
291
+ [DEPLOYMENTS_KEY]: JSON.stringify(entries),
292
+ [LIVE_KEY]: "d1", // live points at the oldest
293
+ };
294
+ for (const e of entries) {
295
+ initial[snapshotKey(e.id)] = "{}";
296
+ initial[revisionKey(e.id)] = "r";
297
+ }
298
+ const { client, store } = makeClient(initial);
299
+
300
+ const { pruned } = await recordAndGcDeployment(client, "d3", 3, 2);
301
+ expect(pruned).toEqual([]); // d1 would be evicted but it's live → kept
302
+ expect(store.has(snapshotKey("d1"))).toBe(true);
303
+ expect(JSON.parse(store.get(DEPLOYMENTS_KEY)!).map((e: { id: string }) => e.id)).toEqual([
304
+ "d1",
305
+ "d2",
306
+ "d3",
307
+ ]);
308
+ });
97
309
  });
98
310
 
99
311
  describe("sync-helpers", () => {
@@ -2,17 +2,25 @@
2
2
  * Minimal Cloudflare KV REST API client for CI.
3
3
  *
4
4
  * CI has no Worker KV binding, so the fast-deploy sync/migrate scripts write to
5
- * KV over the REST API instead. Only the two operations the scripts need —
6
- * single-key GET and PUT — are implemented.
5
+ * KV over the REST API instead. Only the operations the scripts need — single-key
6
+ * GET/PUT/DELETE plus prefix LIST (for per-deployment GC) — are implemented.
7
7
  *
8
8
  * Auth/config via env (read by the scripts, passed to `createKvRestClient`):
9
- * - CF_ACCOUNT_ID Cloudflare account id
10
- * - CF_KV_NAMESPACE_ID target KV namespace id
11
- * - CF_API_TOKEN API token with "Workers KV Storage:Edit"
9
+ * - CF_ACCOUNT_ID Cloudflare account id (or CLOUDFLARE_ACCOUNT_ID)
10
+ * - CF_API_TOKEN API token, "Workers KV Storage:Edit" (or CLOUDFLARE_API_TOKEN)
11
+ * - CF_KV_NAMESPACE_ID target KV namespace id (or read from wrangler config)
12
+ *
13
+ * The `CLOUDFLARE_*` fallbacks + wrangler-config namespace lookup make the sync
14
+ * seamless inside Cloudflare Workers Builds, which injects `CLOUDFLARE_ACCOUNT_ID`
15
+ * / `CLOUDFLARE_API_TOKEN` (the default build token has Workers KV: Edit) and
16
+ * declares the namespace id in `wrangler.jsonc` — so no extra env wiring is
17
+ * needed there. Explicit `CF_*` still wins (e.g. the operator's k8s sync Job).
12
18
  *
13
19
  * `fetch` is injectable so the client is unit-testable without network.
14
20
  */
15
21
 
22
+ import { kvNamespaceIdFromWrangler } from "./wrangler-config";
23
+
16
24
  export interface KvRestConfig {
17
25
  accountId: string;
18
26
  namespaceId: string;
@@ -26,22 +34,36 @@ export interface KvRestConfig {
26
34
  export interface KvRestClient {
27
35
  get(key: string): Promise<string | null>;
28
36
  put(key: string, value: string): Promise<void>;
37
+ delete(key: string): Promise<void>;
38
+ /** List key names, optionally filtered by prefix. Follows pagination. */
39
+ list(prefix?: string): Promise<string[]>;
29
40
  }
30
41
 
31
42
  const DEFAULT_BASE = "https://api.cloudflare.com/client/v4";
32
43
 
33
- /** Resolve the three required env vars or throw a clear error. */
34
- export function kvConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Omit<KvRestConfig, "fetchImpl" | "baseUrl"> {
35
- const accountId = env.CF_ACCOUNT_ID;
36
- const namespaceId = env.CF_KV_NAMESPACE_ID;
37
- const token = env.CF_API_TOKEN;
44
+ /**
45
+ * Resolve the required KV config or throw a clear error. Accepts the `CF_*`
46
+ * names (explicit, win) or the `CLOUDFLARE_*` names CF Workers Builds injects.
47
+ * When `CF_KV_NAMESPACE_ID` is unset and `opts.wranglerDir` is given, the
48
+ * namespace id is read from that dir's wrangler config (`DECO_KV` binding).
49
+ */
50
+ export function kvConfigFromEnv(
51
+ env: NodeJS.ProcessEnv = process.env,
52
+ opts: { wranglerDir?: string } = {},
53
+ ): Omit<KvRestConfig, "fetchImpl" | "baseUrl"> {
54
+ const accountId = env.CF_ACCOUNT_ID || env.CLOUDFLARE_ACCOUNT_ID;
55
+ const token = env.CF_API_TOKEN || env.CLOUDFLARE_API_TOKEN;
56
+ let namespaceId = env.CF_KV_NAMESPACE_ID;
57
+ if (!namespaceId && opts.wranglerDir) {
58
+ namespaceId = kvNamespaceIdFromWrangler(opts.wranglerDir) ?? undefined;
59
+ }
38
60
  const missing = [
39
- !accountId && "CF_ACCOUNT_ID",
40
- !namespaceId && "CF_KV_NAMESPACE_ID",
41
- !token && "CF_API_TOKEN",
61
+ !accountId && "CF_ACCOUNT_ID (or CLOUDFLARE_ACCOUNT_ID)",
62
+ !namespaceId && "CF_KV_NAMESPACE_ID (or a DECO_KV binding in wrangler config)",
63
+ !token && "CF_API_TOKEN (or CLOUDFLARE_API_TOKEN)",
42
64
  ].filter(Boolean);
43
65
  if (missing.length) {
44
- throw new Error(`missing required env var(s): ${missing.join(", ")}`);
66
+ throw new Error(`missing required KV config: ${missing.join(", ")}`);
45
67
  }
46
68
  return { accountId: accountId!, namespaceId: namespaceId!, token: token! };
47
69
  }
@@ -74,5 +96,38 @@ export function createKvRestClient(config: KvRestConfig): KvRestClient {
74
96
  throw new Error(`KV PUT ${key} failed: ${res.status} ${await res.text()}`);
75
97
  }
76
98
  },
99
+
100
+ async delete(key) {
101
+ const res = await fetchImpl(`${root}/values/${encodeURIComponent(key)}`, {
102
+ method: "DELETE",
103
+ headers: authHeaders,
104
+ });
105
+ // 404 is fine — the key is already gone (idempotent delete).
106
+ if (!res.ok && res.status !== 404) {
107
+ throw new Error(`KV DELETE ${key} failed: ${res.status} ${await res.text()}`);
108
+ }
109
+ },
110
+
111
+ async list(prefix) {
112
+ const names: string[] = [];
113
+ let cursor: string | undefined;
114
+ do {
115
+ const qs = new URLSearchParams();
116
+ if (prefix) qs.set("prefix", prefix);
117
+ if (cursor) qs.set("cursor", cursor);
118
+ const suffix = qs.toString() ? `?${qs.toString()}` : "";
119
+ const res = await fetchImpl(`${root}/keys${suffix}`, { headers: authHeaders });
120
+ if (!res.ok) {
121
+ throw new Error(`KV LIST ${prefix ?? ""} failed: ${res.status} ${await res.text()}`);
122
+ }
123
+ const body = (await res.json()) as {
124
+ result?: Array<{ name: string }>;
125
+ result_info?: { cursor?: string };
126
+ };
127
+ for (const k of body.result ?? []) names.push(k.name);
128
+ cursor = body.result_info?.cursor || undefined;
129
+ } while (cursor);
130
+ return names;
131
+ },
77
132
  };
78
133
  }
@@ -1,24 +1,37 @@
1
1
  /**
2
2
  * Shared fast-deploy snapshot helpers for the CI scripts.
3
3
  *
4
- * Both `migrate-blocks-to-kv.ts` and `sync-blocks-to-kv.ts` write the same two
5
- * keys (`decofile:current` + `index:revision`). The revision is computed with
6
- * the SAME `computeRevision` the runtime uses (`src/cms/blockSource.ts`) so a
7
- * hydrating isolate computes a matching revision and the poller doesn't loop.
4
+ * Both `migrate-blocks-to-kv.ts` and `sync-blocks-to-kv.ts` write a snapshot
5
+ * keyed by DEPLOYMENT ID (`decofile:<id>` + `index:revision:<id>`) so every code
6
+ * deployment reads its own content. The revision is computed with the SAME
7
+ * `computeRevision` the runtime uses (`src/cms/blockSource.ts`) so a hydrating
8
+ * isolate computes a matching revision and the poller doesn't loop.
8
9
  */
9
10
 
10
- import { computeRevision, KV_KEYS } from "@decocms/blocks/cms";
11
+ import {
12
+ computeRevision,
13
+ DEPLOYMENTS_KEY,
14
+ LIVE_KEY,
15
+ revisionKey,
16
+ snapshotKey,
17
+ } from "@decocms/blocks/cms";
11
18
  import type { KvRestClient } from "./cf-kv-rest";
12
19
 
13
20
  export interface Snapshot {
14
- /** Serialized decofile written to `decofile:current`. */
21
+ /** Serialized decofile written to `decofile:<id>`. */
15
22
  snapshot: string;
16
- /** DJB2 revision written to `index:revision`. */
23
+ /** DJB2 revision written to `index:revision:<id>`. */
17
24
  revision: string;
18
25
  /** Block count, for logging. */
19
26
  count: number;
20
27
  }
21
28
 
29
+ /** One entry in the `index:deployments` GC bookkeeping list. */
30
+ export interface DeploymentEntry {
31
+ id: string;
32
+ ts: number;
33
+ }
34
+
22
35
  export function buildSnapshot(blocks: Record<string, unknown>): Snapshot {
23
36
  return {
24
37
  snapshot: JSON.stringify(blocks),
@@ -27,25 +40,93 @@ export function buildSnapshot(blocks: Record<string, unknown>): Snapshot {
27
40
  };
28
41
  }
29
42
 
30
- /** Write the snapshot + revision to KV. Snapshot first, then revision, so a
31
- * poller never sees a new revision pointing at an old snapshot. */
32
- export async function writeSnapshotToKv(client: KvRestClient, snap: Snapshot): Promise<void> {
33
- await client.put(KV_KEYS.SNAPSHOT, snap.snapshot);
34
- await client.put(KV_KEYS.REVISION, snap.revision);
43
+ /** Write the snapshot + revision for deployment `id`. Snapshot first, then
44
+ * revision, so a poller never sees a new revision pointing at an old snapshot. */
45
+ export async function writeSnapshotToKv(
46
+ client: KvRestClient,
47
+ snap: Snapshot,
48
+ id: string,
49
+ ): Promise<void> {
50
+ await client.put(snapshotKey(id), snap.snapshot);
51
+ await client.put(revisionKey(id), snap.revision);
35
52
  }
36
53
 
37
- /** Read both keys back and confirm the revision matches what we wrote. */
54
+ /** Read both keys for deployment `id` back and confirm the revision matches. */
38
55
  export async function verifySnapshotInKv(
39
56
  client: KvRestClient,
40
57
  expectedRevision: string,
58
+ id: string,
41
59
  ): Promise<{ ok: boolean; reason?: string }> {
42
60
  const [snapshot, revision] = await Promise.all([
43
- client.get(KV_KEYS.SNAPSHOT),
44
- client.get(KV_KEYS.REVISION),
61
+ client.get(snapshotKey(id)),
62
+ client.get(revisionKey(id)),
45
63
  ]);
46
- if (snapshot === null) return { ok: false, reason: `${KV_KEYS.SNAPSHOT} missing` };
64
+ if (snapshot === null) return { ok: false, reason: `${snapshotKey(id)} missing` };
47
65
  if (revision !== expectedRevision) {
48
- return { ok: false, reason: `${KV_KEYS.REVISION} is "${revision}", expected "${expectedRevision}"` };
66
+ return {
67
+ ok: false,
68
+ reason: `${revisionKey(id)} is "${revision}", expected "${expectedRevision}"`,
69
+ };
49
70
  }
50
71
  return { ok: true };
51
72
  }
73
+
74
+ /** Point `index:live` at deployment `id` (post-activation, from the deploy step). */
75
+ export async function setLiveDeployment(client: KvRestClient, id: string): Promise<void> {
76
+ await client.put(LIVE_KEY, id);
77
+ }
78
+
79
+ async function readDeployments(client: KvRestClient): Promise<DeploymentEntry[]> {
80
+ const raw = await client.get(DEPLOYMENTS_KEY);
81
+ if (!raw) return [];
82
+ try {
83
+ const parsed = JSON.parse(raw) as unknown;
84
+ if (Array.isArray(parsed)) {
85
+ return parsed.filter(
86
+ (e): e is DeploymentEntry =>
87
+ !!e && typeof e === "object" && typeof (e as DeploymentEntry).id === "string",
88
+ );
89
+ }
90
+ } catch {
91
+ // Corrupt bookkeeping key — start fresh rather than fail the sync.
92
+ }
93
+ return [];
94
+ }
95
+
96
+ /**
97
+ * Record deployment `id` in `index:deployments` (newest last, deduped) and GC
98
+ * snapshots beyond the last `retain`. The currently-live deployment
99
+ * (`index:live`) is never pruned even if it falls outside the window (protects
100
+ * a rollback to an older version). Returns the list of pruned ids.
101
+ */
102
+ export async function recordAndGcDeployment(
103
+ client: KvRestClient,
104
+ id: string,
105
+ ts: number,
106
+ retain: number,
107
+ ): Promise<{ pruned: string[] }> {
108
+ const list = (await readDeployments(client)).filter((e) => e.id !== id);
109
+ list.push({ id, ts });
110
+
111
+ const pruned: string[] = [];
112
+ if (list.length > retain) {
113
+ const live = await client.get(LIVE_KEY);
114
+ const excess = list.slice(0, list.length - retain);
115
+ const recent = list.slice(list.length - retain);
116
+ const keptOld: DeploymentEntry[] = [];
117
+ for (const entry of excess) {
118
+ if (entry.id === live) {
119
+ keptOld.push(entry); // never GC the live deployment
120
+ continue;
121
+ }
122
+ await client.delete(snapshotKey(entry.id));
123
+ await client.delete(revisionKey(entry.id));
124
+ pruned.push(entry.id);
125
+ }
126
+ await client.put(DEPLOYMENTS_KEY, JSON.stringify([...keptOld, ...recent]));
127
+ } else {
128
+ await client.put(DEPLOYMENTS_KEY, JSON.stringify(list));
129
+ }
130
+
131
+ return { pruned };
132
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Read the KV namespace id for a binding straight out of a site's wrangler
3
+ * config, so the fast-deploy sync scripts need no `CF_KV_NAMESPACE_ID` env when
4
+ * run at the repo root (e.g. inside Cloudflare Workers Builds). Supports
5
+ * `wrangler.jsonc` / `wrangler.json` (preferred) and `wrangler.toml`.
6
+ *
7
+ * The namespace id is the one value CF Workers Builds does NOT inject into the
8
+ * build env — but it's declared right here in the worker config, next to the
9
+ * `DECO_KV` binding.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { parseJsonc } from "./jsonc";
15
+
16
+ const DEFAULT_BINDING = "DECO_KV";
17
+
18
+ interface KvNamespaceEntry {
19
+ binding?: string;
20
+ id?: string;
21
+ }
22
+
23
+ /** Resolve the KV namespace id for `binding` from the wrangler config in `dir`,
24
+ * or `null` when no config / binding is found. */
25
+ export function kvNamespaceIdFromWrangler(dir: string, binding = DEFAULT_BINDING): string | null {
26
+ for (const file of ["wrangler.jsonc", "wrangler.json"]) {
27
+ const p = path.join(dir, file);
28
+ if (fs.existsSync(p)) {
29
+ try {
30
+ const parsed = parseJsonc<{ kv_namespaces?: KvNamespaceEntry[] }>(
31
+ fs.readFileSync(p, "utf-8"),
32
+ );
33
+ const id = findKvId(parsed.kv_namespaces, binding);
34
+ if (id) return id;
35
+ } catch {
36
+ // Malformed config — fall through to the next candidate / return null.
37
+ }
38
+ }
39
+ }
40
+ const toml = path.join(dir, "wrangler.toml");
41
+ if (fs.existsSync(toml)) {
42
+ return kvNamespaceIdFromToml(fs.readFileSync(toml, "utf-8"), binding);
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function findKvId(entries: KvNamespaceEntry[] | undefined, binding: string): string | null {
48
+ if (!Array.isArray(entries)) return null;
49
+ for (const e of entries) {
50
+ if (e && e.binding === binding && typeof e.id === "string" && e.id) return e.id;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** Parse `[[kv_namespaces]]` table-array blocks from a `wrangler.toml` string
56
+ * and return the id whose `binding` matches. Exported for unit tests. */
57
+ export function kvNamespaceIdFromToml(src: string, binding = DEFAULT_BINDING): string | null {
58
+ // Split on the [[kv_namespaces]] header; each following chunk is one block
59
+ // until the next table header ("\n[").
60
+ const blocks = src.split(/\[\[\s*kv_namespaces\s*\]\]/).slice(1);
61
+ for (const block of blocks) {
62
+ const body = block.split(/\n\s*\[/)[0];
63
+ if (matchTomlString(body, "binding") === binding) {
64
+ const id = matchTomlString(body, "id");
65
+ if (id) return id;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function matchTomlString(body: string, key: string): string | null {
72
+ const m = body.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*=\\s*["']([^"']+)["']`));
73
+ return m ? m[1] : null;
74
+ }
@@ -159,7 +159,7 @@ export function scaffold(ctx: MigrationContext): void {
159
159
  }
160
160
 
161
161
  // Migration tooling policy pointer rule (D1–D5 + priorities).
162
- // The canonical rule lives in decocms/deco-start; this is a tiny
162
+ // The canonical rule lives in decocms/blocks; this is a tiny
163
163
  // pointer that loads on every Cursor session in the migrated site
164
164
  // so agents working on the site know where the policy is and what
165
165
  // it means here. See MIGRATION_TOOLING_PLAN.md (Wave 12-H).
@@ -14,12 +14,12 @@ describe("generateMigrationPolicyPointerRule", () => {
14
14
  expect(body).toContain("`acme`");
15
15
  });
16
16
 
17
- it("links to the canonical rule and plan in decocms/deco-start", () => {
17
+ it("links to the canonical rule and plan in decocms/blocks", () => {
18
18
  expect(body).toContain(
19
- "https://github.com/decocms/deco-start/blob/main/.cursor/rules/migration-tooling-policy.mdc",
19
+ "https://github.com/decocms/blocks/blob/main/.cursor/rules/migration-tooling-policy.mdc",
20
20
  );
21
21
  expect(body).toContain(
22
- "https://github.com/decocms/deco-start/blob/main/MIGRATION_TOOLING_PLAN.md",
22
+ "https://github.com/decocms/blocks/blob/main/MIGRATION_TOOLING_PLAN.md",
23
23
  );
24
24
  });
25
25
 
@@ -38,7 +38,7 @@ describe("generateMigrationPolicyPointerRule", () => {
38
38
 
39
39
  it("does NOT restate the canonical rule body verbatim (pointer, not a copy)", () => {
40
40
  // Length budget: pointer must stay short to discourage drift.
41
- // The canonical rule in decocms/deco-start is ~110 lines / 4–5 KB;
41
+ // The canonical rule in decocms/blocks is ~110 lines / 4–5 KB;
42
42
  // the pointer must be substantially smaller than a copy.
43
43
  expect(body.length).toBeLessThan(3000);
44
44
  });
@@ -2,7 +2,7 @@
2
2
  * Cursor rule scaffolding for migrated sites.
3
3
  *
4
4
  * The canonical migration tooling policy (D1–D5, priorities, process)
5
- * lives in `decocms/deco-start`. We don't duplicate it into every site
5
+ * lives in `decocms/blocks`. We don't duplicate it into every site
6
6
  * — that would drift the moment the canonical changes. Instead the
7
7
  * migration scaffolds a tiny pointer rule, marked `alwaysApply: true`,
8
8
  * that loads on every agent session inside the migrated site and tells
@@ -35,11 +35,11 @@ alwaysApply: true
35
35
  ## Where to read
36
36
 
37
37
  - **Rule (always-applied) — full text:**
38
- https://github.com/decocms/deco-start/blob/main/.cursor/rules/migration-tooling-policy.mdc
38
+ https://github.com/decocms/blocks/blob/main/.cursor/rules/migration-tooling-policy.mdc
39
39
  - **Plan (living tracker, decisions + waves):**
40
- https://github.com/decocms/deco-start/blob/main/MIGRATION_TOOLING_PLAN.md
40
+ https://github.com/decocms/blocks/blob/main/MIGRATION_TOOLING_PLAN.md
41
41
  - **Migration skill (phase playbook):**
42
- https://github.com/decocms/deco-start/blob/main/.agents/skills/deco-to-tanstack-migration/SKILL.md
42
+ https://github.com/decocms/blocks/blob/main/.agents/skills/deco-to-tanstack-migration/SKILL.md
43
43
 
44
44
  ## What you need to know in this site
45
45
 
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Lives in the site repo (per-site, not centralised) because per
9
9
  * D6.3 we are NOT scaffolding caller stubs that pull in
10
- * `decocms/deco-start@vN` reusable workflows. The check is small
10
+ * `decocms/blocks@vN` reusable workflows. The check is small
11
11
  * enough that copy-paste-per-site is the right tradeoff.
12
12
  *
13
13
  * Bun version pinning matches the `packageManager` field in the
@@ -4,19 +4,21 @@
4
4
  * bundled decofile so the worker can serve content KV-first.
5
5
  *
6
6
  * Reads `.deco/blocks/*.json` (the same merge the block generator does),
7
- * writes `decofile:current` + `index:revision` to the site's KV namespace via
8
- * the REST API, then reads both keys back to verify. Run ONCE per site before
7
+ * writes `decofile:<id>` + `index:revision:<id>` (keyed by deployment id) to
8
+ * the site's KV namespace via the REST API, then reads both keys back to
9
+ * verify. Run ONCE per site to seed the first deployment's content before
9
10
  * flipping it to KV-first (i.e. before adding the `DECO_KV` binding +
10
11
  * deploying the fast-deploy framework version).
11
12
  *
12
13
  * Usage (from the site root):
13
14
  * # dry-run (default): reads blocks, prints what would be written, no writes
14
15
  * CF_ACCOUNT_ID=... CF_KV_NAMESPACE_ID=... CF_API_TOKEN=... \
15
- * npx -p @decocms/start deco-migrate-blocks-to-kv
16
+ * npx -p @decocms/start deco-migrate-blocks-to-kv --deployment-id "$COMMIT_SHA"
16
17
  * # apply:
17
- * ... npx -p @decocms/start deco-migrate-blocks-to-kv --write
18
+ * ... npx -p @decocms/start deco-migrate-blocks-to-kv --deployment-id "$COMMIT_SHA" --write
18
19
  *
19
20
  * Options:
21
+ * --deployment-id <id> Deployment id (git commit sha) to key the write under (required to write)
20
22
  * --blocks-dir <dir> Input blocks dir (default: .deco/blocks)
21
23
  * --write Perform the KV writes (otherwise dry-run, exit 0)
22
24
  * --help, -h Show this help
@@ -24,7 +26,7 @@
24
26
  * Env:
25
27
  * CF_ACCOUNT_ID, CF_KV_NAMESPACE_ID, CF_API_TOKEN (required with --write)
26
28
  *
27
- * Exit codes: 0 ok / dry-run; 2 error (bad dir, missing env, verify failed)
29
+ * Exit codes: 0 ok / dry-run; 2 error (bad dir, missing env/args, verify failed)
28
30
  */
29
31
 
30
32
  import * as path from "node:path";
@@ -41,6 +43,7 @@ function parseArgs(argv: string[]) {
41
43
  return {
42
44
  help: has("--help") || has("-h"),
43
45
  write: has("--write"),
46
+ deploymentId: val("--deployment-id", ""),
44
47
  blocksDir: val("--blocks-dir", ".deco/blocks"),
45
48
  };
46
49
  }
@@ -49,12 +52,17 @@ async function main() {
49
52
  const opts = parseArgs(process.argv.slice(2));
50
53
  if (opts.help) {
51
54
  console.log(
52
- "Usage: deco-migrate-blocks-to-kv [--blocks-dir .deco/blocks] [--write]\n" +
55
+ "Usage: deco-migrate-blocks-to-kv --deployment-id <sha> [--blocks-dir .deco/blocks] [--write]\n" +
53
56
  "Env: CF_ACCOUNT_ID, CF_KV_NAMESPACE_ID, CF_API_TOKEN",
54
57
  );
55
58
  process.exit(0);
56
59
  }
57
60
 
61
+ if (!opts.deploymentId && opts.write) {
62
+ console.error("error: --deployment-id <sha> is required to write to KV.");
63
+ process.exit(2);
64
+ }
65
+
58
66
  const blocksDir = path.resolve(process.cwd(), opts.blocksDir);
59
67
 
60
68
  let blocks: Record<string, unknown>;
@@ -79,15 +87,15 @@ async function main() {
79
87
 
80
88
  let client: ReturnType<typeof createKvRestClient>;
81
89
  try {
82
- client = createKvRestClient(kvConfigFromEnv());
90
+ client = createKvRestClient(kvConfigFromEnv(process.env, { wranglerDir: process.cwd() }));
83
91
  } catch (e) {
84
92
  console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
85
93
  process.exit(2);
86
94
  }
87
95
 
88
96
  try {
89
- await writeSnapshotToKv(client, snap);
90
- const verify = await verifySnapshotInKv(client, snap.revision);
97
+ await writeSnapshotToKv(client, snap, opts.deploymentId);
98
+ const verify = await verifySnapshotInKv(client, snap.revision, opts.deploymentId);
91
99
  if (!verify.ok) {
92
100
  console.error(`error: KV verify failed — ${verify.reason}`);
93
101
  process.exit(2);
@@ -97,7 +105,7 @@ async function main() {
97
105
  process.exit(2);
98
106
  }
99
107
 
100
- console.log(`\nwrote + verified decofile:current (rev ${snap.revision}) → KV.`);
108
+ console.log(`\nwrote + verified decofile:${opts.deploymentId} (rev ${snap.revision}) → KV.`);
101
109
  console.log("Next: add the DECO_KV binding in wrangler.toml and deploy the fast-deploy build.");
102
110
  }
103
111
 
@@ -1,28 +1,39 @@
1
1
  #!/usr/bin/env tsx
2
2
  /**
3
- * CI fast-deploy content sync: push the site's current decofile to KV when
4
- * content changed, WITHOUT a worker redeploy.
3
+ * CI fast-deploy content sync: push the site's current decofile to KV under a
4
+ * DEPLOYMENT ID, WITHOUT a worker redeploy.
5
5
  *
6
- * Because the runtime swaps whole snapshots (not per-block), this always writes
7
- * the FULL current decofile (`decofile:current`) and bumps `index:revision`.
8
- * The default mode first checks whether any `.deco/blocks/*.json` changed since
9
- * a base ref — if nothing changed, it exits 0 without writing (the "content
10
- * unchanged → nothing to do" path). `--all` skips that check and always syncs
11
- * (use post-deploy for bootstrap/paranoia).
6
+ * Content is keyed per deployment (`decofile:<id>` + `index:revision:<id>`, id =
7
+ * git commit sha) so each running version reads its own snapshot. Two roles:
12
8
  *
13
- * After writing, it optionally purges the edge cache for the changed pages.
9
+ * 1. Build-time seed (code deploy): `--write --all --deployment-id <sha>`
10
+ * writes this build's snapshot up-front, then appends `index:deployments`
11
+ * and GCs old snapshots. The version reads its content the instant it goes
12
+ * live — no window where new code sees old content.
13
+ * 2. Content-only fast deploy (operator): `--write --all --deployment-id <liveId>`
14
+ * against the currently-live id — the live isolate's revision poll picks it
15
+ * up in ~10s.
14
16
  *
15
- * Usage (in CI, on push to main):
17
+ * A separate, cheap `--set-live --deployment-id <sha>` run (post-activation, in
18
+ * the deploy step) flips the `index:live` pointer.
19
+ *
20
+ * The default (non-`--all`) mode first checks whether any `.deco/blocks/*.json`
21
+ * changed since a base ref — nothing changed ⇒ exit 0 without writing.
22
+ *
23
+ * Usage:
24
+ * # build-time seed (whole snapshot for this commit):
16
25
  * CF_ACCOUNT_ID=... CF_KV_NAMESPACE_ID=... CF_API_TOKEN=... \
17
- * npx -p @decocms/start deco-sync-blocks-to-kv --write \
18
- * --since "$GITHUB_BEFORE" --purge-url https://site.example --purge-token "$PURGE_TOKEN"
19
- * # bootstrap (always write the whole snapshot):
20
- * ... deco-sync-blocks-to-kv --write --all
26
+ * npx -p @decocms/start deco-sync-blocks-to-kv --write --all --deployment-id "$COMMIT_SHA"
27
+ * # post-deploy: flip the live pointer
28
+ * ... deco-sync-blocks-to-kv --set-live --deployment-id "$COMMIT_SHA"
21
29
  *
22
30
  * Options:
31
+ * --deployment-id <id> Deployment id (git commit sha) to key the write under (required to write)
32
+ * --set-live Only write `index:live` = <id> (no blocks read); implies a write
23
33
  * --all Always sync (skip the git-diff content check)
24
34
  * --since <ref> Base git ref for the diff (default: HEAD~1)
25
35
  * --blocks-dir <dir> Input blocks dir (default: .deco/blocks)
36
+ * --retain <n> Deployment snapshots to keep for GC (default: 10)
26
37
  * --purge-url <origin> Site origin to POST /_cache/purge after sync
27
38
  * --purge-token <tok> Purge bearer token (or PURGE_TOKEN env)
28
39
  * --write Perform writes (otherwise dry-run, exit 0)
@@ -30,16 +41,24 @@
30
41
  *
31
42
  * Env: CF_ACCOUNT_ID, CF_KV_NAMESPACE_ID, CF_API_TOKEN (required with --write)
32
43
  *
33
- * Exit codes: 0 ok / no-op / dry-run; 2 error (bad dir, missing env, verify failed)
44
+ * Exit codes: 0 ok / no-op / dry-run; 2 error (bad dir, missing env/args, verify failed)
34
45
  */
35
46
 
36
47
  import { execSync } from "node:child_process";
37
48
  import * as path from "node:path";
38
49
  import { createKvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
39
- import { buildSnapshot, verifySnapshotInKv, writeSnapshotToKv } from "./lib/kv-snapshot";
50
+ import {
51
+ buildSnapshot,
52
+ recordAndGcDeployment,
53
+ setLiveDeployment,
54
+ verifySnapshotInKv,
55
+ writeSnapshotToKv,
56
+ } from "./lib/kv-snapshot";
40
57
  import { readDecofileFromDir } from "./lib/read-decofile";
41
58
  import { changedBlockFiles, changedBlockKeys, purgePathsForChangedKeys } from "./lib/sync-helpers";
42
59
 
60
+ const DEFAULT_RETAIN = 10;
61
+
43
62
  function parseArgs(argv: string[]) {
44
63
  const has = (f: string) => argv.includes(f);
45
64
  const val = (f: string, d: string) => {
@@ -50,6 +69,9 @@ function parseArgs(argv: string[]) {
50
69
  help: has("--help") || has("-h"),
51
70
  all: has("--all"),
52
71
  write: has("--write"),
72
+ setLive: has("--set-live"),
73
+ deploymentId: val("--deployment-id", ""),
74
+ retain: Number(val("--retain", String(DEFAULT_RETAIN))) || DEFAULT_RETAIN,
53
75
  since: val("--since", "HEAD~1"),
54
76
  blocksDir: val("--blocks-dir", ".deco/blocks"),
55
77
  purgeUrl: val("--purge-url", ""),
@@ -78,10 +100,37 @@ async function purgeCache(origin: string, token: string, paths: string[]): Promi
78
100
  async function main() {
79
101
  const opts = parseArgs(process.argv.slice(2));
80
102
  if (opts.help) {
81
- console.log("Usage: deco-sync-blocks-to-kv [--all] [--since <ref>] [--write] [--purge-url <origin>]");
103
+ console.log(
104
+ "Usage: deco-sync-blocks-to-kv --deployment-id <sha> [--all] [--set-live] [--write] [--retain 10] [--purge-url <origin>]",
105
+ );
82
106
  process.exit(0);
83
107
  }
84
108
 
109
+ // A deployment id is mandatory for any write — content is always keyed.
110
+ if (!opts.deploymentId && (opts.write || opts.setLive)) {
111
+ console.error("error: --deployment-id <sha> is required to write to KV.");
112
+ process.exit(2);
113
+ }
114
+
115
+ // --set-live: cheap pointer flip only, no blocks read.
116
+ if (opts.setLive) {
117
+ let client: ReturnType<typeof createKvRestClient>;
118
+ try {
119
+ client = createKvRestClient(kvConfigFromEnv(process.env, { wranglerDir: process.cwd() }));
120
+ } catch (e) {
121
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
122
+ process.exit(2);
123
+ }
124
+ try {
125
+ await setLiveDeployment(client, opts.deploymentId);
126
+ } catch (e) {
127
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
128
+ process.exit(2);
129
+ }
130
+ console.log(`set index:live → ${opts.deploymentId}.`);
131
+ return;
132
+ }
133
+
85
134
  const blocksDir = path.resolve(process.cwd(), opts.blocksDir);
86
135
  const blocksDirRel = opts.blocksDir;
87
136
 
@@ -112,36 +161,38 @@ async function main() {
112
161
  }
113
162
 
114
163
  const snap = buildSnapshot(blocks);
115
- const purgePaths = opts.all
116
- ? ["/"]
117
- : purgePathsForChangedKeys(blocks, changedKeys);
118
- console.log(`decofile: ${snap.count} blocks, revision ${snap.revision}`);
164
+ const purgePaths = opts.all ? ["/"] : purgePathsForChangedKeys(blocks, changedKeys);
165
+ console.log(`decofile: ${snap.count} blocks, revision ${snap.revision} → deployment ${opts.deploymentId}`);
119
166
 
120
167
  if (!opts.write) {
121
- console.log(`\nDry-run only. Would write snapshot + revision and purge: ${purgePaths.join(", ")}`);
168
+ console.log(`\nDry-run only. Would write decofile:${opts.deploymentId} + revision, GC to ${opts.retain}, purge: ${purgePaths.join(", ")}`);
122
169
  process.exit(0);
123
170
  }
124
171
 
125
172
  let client: ReturnType<typeof createKvRestClient>;
126
173
  try {
127
- client = createKvRestClient(kvConfigFromEnv());
174
+ client = createKvRestClient(kvConfigFromEnv(process.env, { wranglerDir: process.cwd() }));
128
175
  } catch (e) {
129
176
  console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
130
177
  process.exit(2);
131
178
  }
132
179
 
133
180
  try {
134
- await writeSnapshotToKv(client, snap);
135
- const verify = await verifySnapshotInKv(client, snap.revision);
181
+ await writeSnapshotToKv(client, snap, opts.deploymentId);
182
+ const verify = await verifySnapshotInKv(client, snap.revision, opts.deploymentId);
136
183
  if (!verify.ok) {
137
184
  console.error(`error: KV verify failed — ${verify.reason}`);
138
185
  process.exit(2);
139
186
  }
187
+ const { pruned } = await recordAndGcDeployment(client, opts.deploymentId, Date.now(), opts.retain);
188
+ if (pruned.length) {
189
+ console.log(`GC: pruned ${pruned.length} old snapshot(s): ${pruned.join(", ")}`);
190
+ }
140
191
  } catch (e) {
141
192
  console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
142
193
  process.exit(2);
143
194
  }
144
- console.log(`synced decofile:current (rev ${snap.revision}) → KV.`);
195
+ console.log(`synced decofile:${opts.deploymentId} (rev ${snap.revision}) → KV.`);
145
196
 
146
197
  if (opts.purgeUrl && opts.purgeToken) {
147
198
  await purgeCache(opts.purgeUrl, opts.purgeToken, purgePaths);