@decocms/blocks-cli 7.6.0 → 7.8.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 +2 -2
- package/scripts/fast-deploy-kv.test.ts +239 -27
- package/scripts/generate-invoke.test.ts +78 -0
- package/scripts/generate-invoke.ts +17 -8
- package/scripts/generate-sections.test.ts +69 -0
- package/scripts/generate-sections.ts +6 -0
- package/scripts/lib/cf-kv-rest.ts +69 -14
- package/scripts/lib/kv-snapshot.ts +98 -17
- package/scripts/lib/wrangler-config.ts +74 -0
- package/scripts/migrate/phase-scaffold.ts +1 -1
- package/scripts/migrate/templates/cursor-rules.test.ts +4 -4
- package/scripts/migrate/templates/cursor-rules.ts +4 -4
- package/scripts/migrate/templates/lockfile-check-yml.ts +1 -1
- package/scripts/migrate-blocks-to-kv.ts +18 -10
- package/scripts/sync-blocks-to-kv.ts +77 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.8.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.
|
|
37
|
+
"@decocms/blocks": "7.8.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 {
|
|
3
|
-
|
|
4
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
90
|
-
[
|
|
91
|
-
[
|
|
92
|
-
|
|
93
|
-
const
|
|
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", () => {
|
|
@@ -190,3 +190,81 @@ describe("generate-invoke.ts — output shape", () => {
|
|
|
190
190
|
expect(generatedOutput).toContain("const result = await simulateCart(data);");
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
|
+
|
|
194
|
+
describe("generate-invoke.ts — default --apps-dir resolution", () => {
|
|
195
|
+
// Regression for the published-tarball layout: @decocms/apps-vtex 7.x
|
|
196
|
+
// ships its sources under src/ (`"files": ["src"]`), so invoke.ts lives
|
|
197
|
+
// at node_modules/@decocms/apps-vtex/src/invoke.ts — NOT at the package
|
|
198
|
+
// root. The old default only probed the root, never resolved on a site
|
|
199
|
+
// with npm-installed packages, and forced sites to pass
|
|
200
|
+
// `--apps-dir node_modules/@decocms/apps-vtex/src` by hand (granadobr's
|
|
201
|
+
// migration workaround). The default must probe <pkg>/invoke.ts first,
|
|
202
|
+
// then <pkg>/src/invoke.ts.
|
|
203
|
+
|
|
204
|
+
function makeSite(layout: "root" | "src"): { siteDir: string; cleanup: () => void } {
|
|
205
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-default-"));
|
|
206
|
+
const siteDir = path.join(tmp, "site");
|
|
207
|
+
const pkgDir = path.join(siteDir, "node_modules", "@decocms", "apps-vtex");
|
|
208
|
+
const appsDir = layout === "src" ? path.join(pkgDir, "src") : pkgDir;
|
|
209
|
+
fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true });
|
|
210
|
+
fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_INVOKE_TS);
|
|
211
|
+
fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS);
|
|
212
|
+
fs.writeFileSync(path.join(appsDir, "actions", "session.ts"), FIXTURE_ACTIONS_SESSION_TS);
|
|
213
|
+
fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS);
|
|
214
|
+
return { siteDir, cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it("resolves the published layout (node_modules/@decocms/apps-vtex/src/invoke.ts) without --apps-dir", () => {
|
|
218
|
+
const { siteDir, cleanup } = makeSite("src");
|
|
219
|
+
try {
|
|
220
|
+
const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
|
|
221
|
+
const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], {
|
|
222
|
+
cwd: siteDir,
|
|
223
|
+
encoding: "utf8",
|
|
224
|
+
});
|
|
225
|
+
expect(result.status, result.stderr).toBe(0);
|
|
226
|
+
const generated = fs.readFileSync(outFile, "utf8");
|
|
227
|
+
// Relative `./actions/*` imports must still be rewritten to package
|
|
228
|
+
// subpaths — the exports map points them back into src/, so the src/
|
|
229
|
+
// nesting must not leak into the emitted specifiers.
|
|
230
|
+
expect(generated).toContain('from "@decocms/apps-vtex/actions/checkout"');
|
|
231
|
+
expect(generated).not.toContain("apps-vtex/src/");
|
|
232
|
+
expect(generated).toContain("export const vtexActions");
|
|
233
|
+
} finally {
|
|
234
|
+
cleanup();
|
|
235
|
+
}
|
|
236
|
+
}, 30_000);
|
|
237
|
+
|
|
238
|
+
it("still resolves invoke.ts at the package root (legacy dev-checkout layout) without --apps-dir", () => {
|
|
239
|
+
const { siteDir, cleanup } = makeSite("root");
|
|
240
|
+
try {
|
|
241
|
+
const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
|
|
242
|
+
const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], {
|
|
243
|
+
cwd: siteDir,
|
|
244
|
+
encoding: "utf8",
|
|
245
|
+
});
|
|
246
|
+
expect(result.status, result.stderr).toBe(0);
|
|
247
|
+
const generated = fs.readFileSync(outFile, "utf8");
|
|
248
|
+
expect(generated).toContain('from "@decocms/apps-vtex/actions/checkout"');
|
|
249
|
+
expect(generated).toContain("export const vtexActions");
|
|
250
|
+
} finally {
|
|
251
|
+
cleanup();
|
|
252
|
+
}
|
|
253
|
+
}, 30_000);
|
|
254
|
+
|
|
255
|
+
it("fails with a clear error when @decocms/apps-vtex cannot be found", () => {
|
|
256
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-missing-"));
|
|
257
|
+
try {
|
|
258
|
+
const outFile = path.join(tmp, "src", "server", "invoke.gen.ts");
|
|
259
|
+
const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], {
|
|
260
|
+
cwd: tmp,
|
|
261
|
+
encoding: "utf8",
|
|
262
|
+
});
|
|
263
|
+
expect(result.status).not.toBe(0);
|
|
264
|
+
expect(result.stderr).toContain("Could not find @decocms/apps-vtex");
|
|
265
|
+
expect(result.stderr).toContain("--apps-dir");
|
|
266
|
+
} finally {
|
|
267
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
}, 30_000);
|
|
270
|
+
});
|
|
@@ -55,17 +55,26 @@ function resolveAppsDir(): string {
|
|
|
55
55
|
const explicit = arg("apps-dir", "");
|
|
56
56
|
if (explicit) return path.resolve(cwd, explicit);
|
|
57
57
|
|
|
58
|
-
// Try common locations
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
58
|
+
// Try common locations: the installed @decocms/apps-vtex package first,
|
|
59
|
+
// then a raw apps-start checkout's vtex/ subdirectory as a legacy fallback
|
|
60
|
+
// for anyone still developing against the pre-split monorepo.
|
|
61
|
+
//
|
|
62
|
+
// For each root, invoke.ts may sit directly at the root (legacy dev
|
|
63
|
+
// checkouts) or under src/ — the published @decocms/apps-vtex tarball
|
|
64
|
+
// ships its sources under src/ (`"files": ["src"]`, `"main":
|
|
65
|
+
// "./src/index.ts"`), so on any site with npm-installed 7.x packages the
|
|
66
|
+
// file lives at node_modules/@decocms/apps-vtex/src/invoke.ts. The src/
|
|
67
|
+
// nesting doesn't affect the emitted imports: relative `./actions/*`
|
|
68
|
+
// specifiers are rewritten to `@decocms/apps-vtex/actions/*`, which the
|
|
69
|
+
// package's exports map points back into src/.
|
|
70
|
+
const roots = [
|
|
64
71
|
path.resolve(cwd, "node_modules/@decocms/apps-vtex"),
|
|
65
72
|
path.resolve(cwd, "../apps-start/vtex"),
|
|
66
73
|
];
|
|
67
|
-
for (const
|
|
68
|
-
|
|
74
|
+
for (const root of roots) {
|
|
75
|
+
for (const c of [root, path.join(root, "src")]) {
|
|
76
|
+
if (fs.existsSync(path.join(c, "invoke.ts"))) return c;
|
|
77
|
+
}
|
|
69
78
|
}
|
|
70
79
|
throw new Error("Could not find @decocms/apps-vtex. Use --apps-dir to specify its location.");
|
|
71
80
|
}
|
|
@@ -281,6 +281,75 @@ describe("generate-sections --registry", () => {
|
|
|
281
281
|
}, 30_000);
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
describe("generate-sections neverDefer convention", () => {
|
|
285
|
+
// Regression: the scanner recognized `export const neverDefer = true` and
|
|
286
|
+
// emitted `neverDefer: true` on the section's sectionMeta entry, but the
|
|
287
|
+
// SectionMetaEntry interface the SAME file declares omitted the field —
|
|
288
|
+
// so every generated file containing a neverDefer section failed the
|
|
289
|
+
// site's typecheck (TS2353 excess property), and sites hand-patched the
|
|
290
|
+
// interface only to have the next regeneration wipe the patch (miess's
|
|
291
|
+
// .deco/sections.gen.ts carried exactly that TODO). The emitted interface
|
|
292
|
+
// must match SectionMetaEntry in @decocms/blocks/cms.
|
|
293
|
+
let tmpDir: string;
|
|
294
|
+
let sectionsDir: string;
|
|
295
|
+
let outFile: string;
|
|
296
|
+
|
|
297
|
+
beforeEach(() => {
|
|
298
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-neverdefer-"));
|
|
299
|
+
sectionsDir = path.join(tmpDir, "sections");
|
|
300
|
+
outFile = path.join(tmpDir, "out", "sections.gen.ts");
|
|
301
|
+
fs.mkdirSync(sectionsDir, { recursive: true });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
afterEach(() => {
|
|
305
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("declares neverDefer on the emitted SectionMetaEntry interface and the generated file typechecks + imports", () => {
|
|
309
|
+
// Mirrors miess's src/sections/Product/SearchResult.tsx.
|
|
310
|
+
fs.writeFileSync(
|
|
311
|
+
path.join(sectionsDir, "SearchResult.tsx"),
|
|
312
|
+
"export const neverDefer = true;\nexport default function SearchResult() { return null; }\n",
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
|
|
316
|
+
expect(code).toBe(0);
|
|
317
|
+
|
|
318
|
+
const generated = fs.readFileSync(outFile, "utf-8");
|
|
319
|
+
// Entry carries the convention…
|
|
320
|
+
expect(generated).toMatch(/"site\/sections\/SearchResult\.tsx": \{ neverDefer: true \}/);
|
|
321
|
+
// …and the interface declares the field (optional boolean, matching
|
|
322
|
+
// SectionMetaEntry in @decocms/blocks/cms/applySectionConventions.ts).
|
|
323
|
+
expect(generated).toContain("neverDefer?: boolean;");
|
|
324
|
+
|
|
325
|
+
// The actual failure mode was a TYPECHECK error (excess property on the
|
|
326
|
+
// Record<string, SectionMetaEntry> literal), which a runtime import
|
|
327
|
+
// through tsx/esbuild would never catch — so run tsc on the output.
|
|
328
|
+
const tscResult = cp.spawnSync(
|
|
329
|
+
"npx",
|
|
330
|
+
["tsc", "--noEmit", "--strict", "--skipLibCheck", outFile],
|
|
331
|
+
{ encoding: "utf8" },
|
|
332
|
+
);
|
|
333
|
+
expect(tscResult.status, tscResult.stdout + tscResult.stderr).toBe(0);
|
|
334
|
+
|
|
335
|
+
// And keep the importability guarantee from the earlier template
|
|
336
|
+
// regression: the file must load as valid TS/ESM with the expected
|
|
337
|
+
// exports.
|
|
338
|
+
const checkerFile = path.join(tmpDir, "check-import.mjs");
|
|
339
|
+
fs.writeFileSync(
|
|
340
|
+
checkerFile,
|
|
341
|
+
[
|
|
342
|
+
`const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`,
|
|
343
|
+
`if (m.sectionMeta?.["site/sections/SearchResult.tsx"]?.neverDefer !== true) {`,
|
|
344
|
+
` throw new Error("neverDefer entry missing from sectionMeta");`,
|
|
345
|
+
`}`,
|
|
346
|
+
].join("\n"),
|
|
347
|
+
);
|
|
348
|
+
const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" });
|
|
349
|
+
expect(importResult.status, importResult.stderr).toBe(0);
|
|
350
|
+
}, 60_000);
|
|
351
|
+
});
|
|
352
|
+
|
|
284
353
|
describe("generate-sections output hygiene (non-registry)", () => {
|
|
285
354
|
let tmpDir: string;
|
|
286
355
|
let sectionsDir: string;
|
|
@@ -194,8 +194,14 @@ for (let i = 0; i < nonSyncFallbacks.length; i++) {
|
|
|
194
194
|
lines.push("");
|
|
195
195
|
|
|
196
196
|
// Metadata map
|
|
197
|
+
// Keep this emitted interface in sync with SectionMetaEntry in
|
|
198
|
+
// @decocms/blocks/cms (applySectionConventions.ts) — every convention the
|
|
199
|
+
// scanner can set on an entry must be declared here, or the generated file
|
|
200
|
+
// fails the site's typecheck (excess-property error) and sites end up
|
|
201
|
+
// hand-patching a file that the next regeneration wipes.
|
|
197
202
|
lines.push("export interface SectionMetaEntry {");
|
|
198
203
|
lines.push(" eager?: boolean;");
|
|
204
|
+
lines.push(" neverDefer?: boolean;");
|
|
199
205
|
lines.push(" cache?: string;");
|
|
200
206
|
lines.push(" layout?: boolean;");
|
|
201
207
|
lines.push(" sync?: boolean;");
|
|
@@ -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
|
|
6
|
-
*
|
|
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
|
-
* -
|
|
11
|
-
* -
|
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
31
|
-
* poller never sees a new revision pointing at an old snapshot. */
|
|
32
|
-
export async function writeSnapshotToKv(
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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(
|
|
44
|
-
client.get(
|
|
61
|
+
client.get(snapshotKey(id)),
|
|
62
|
+
client.get(revisionKey(id)),
|
|
45
63
|
]);
|
|
46
|
-
if (snapshot === null) return { ok: false, reason: `${
|
|
64
|
+
if (snapshot === null) return { ok: false, reason: `${snapshotKey(id)} missing` };
|
|
47
65
|
if (revision !== expectedRevision) {
|
|
48
|
-
return {
|
|
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/
|
|
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/
|
|
17
|
+
it("links to the canonical rule and plan in decocms/blocks", () => {
|
|
18
18
|
expect(body).toContain(
|
|
19
|
-
"https://github.com/decocms/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
40
|
+
https://github.com/decocms/blocks/blob/main/MIGRATION_TOOLING_PLAN.md
|
|
41
41
|
- **Migration skill (phase playbook):**
|
|
42
|
-
https://github.com/decocms/
|
|
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/
|
|
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
|
|
8
|
-
* the REST API, then reads both keys back to
|
|
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
|
|
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
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
|
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);
|