@decocms/blocks-cli 7.67.1 → 7.68.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.67.1",
3
+ "version": "7.68.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": {
@@ -33,7 +33,7 @@
33
33
  "lint:unused": "knip"
34
34
  },
35
35
  "dependencies": {
36
- "@decocms/blocks": "7.67.1",
36
+ "@decocms/blocks": "7.68.0",
37
37
  "ts-morph": "^27.0.0",
38
38
  "tsx": "^4.22.5"
39
39
  },
@@ -9,6 +9,7 @@ import {
9
9
  revisionKey,
10
10
  snapshotKey,
11
11
  } from "@decocms/blocks/cms";
12
+ import { splitExactRedirects } from "@decocms/blocks/sdk/redirects";
12
13
  import { createKvRestClient, type KvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
13
14
  import { readDecofileFromDir } from "./lib/read-decofile";
14
15
  import { kvNamespaceIdFromToml, kvNamespaceIdFromWrangler } from "./lib/wrangler-config";
@@ -16,6 +17,7 @@ import {
16
17
  buildSnapshot,
17
18
  recordAndGcDeployment,
18
19
  setLiveDeployment,
20
+ syncRedirectsToKv,
19
21
  verifySnapshotInKv,
20
22
  writeSnapshotToKv,
21
23
  } from "./lib/kv-snapshot";
@@ -46,6 +48,14 @@ function makeClient(initial: Record<string, string> = {}) {
46
48
  Promise.resolve(
47
49
  [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
48
50
  ),
51
+ putMany: (entries) => {
52
+ for (const e of entries) store.set(e.key, e.value);
53
+ return Promise.resolve();
54
+ },
55
+ deleteMany: (keys) => {
56
+ for (const k of keys) store.delete(k);
57
+ return Promise.resolve();
58
+ },
49
59
  };
50
60
  return { client, store, putOrder };
51
61
  }
@@ -386,6 +396,24 @@ describe("readDecofileFromDir — CSV redirects", () => {
386
396
  expect(blocks.site).toBeDefined();
387
397
  });
388
398
 
399
+ it("splits CSV-derived exact redirects out of the snapshot", () => {
400
+ const blocksDir = makeSite(
401
+ {
402
+ "site.json": {
403
+ __resolveType: "site/apps/site.ts",
404
+ routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "static/r.csv" }],
405
+ },
406
+ },
407
+ { "r.csv": "from,to,type\n/old,/new,permanent\n/g/*,/h/*\n" },
408
+ );
409
+ const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
410
+ const split = splitExactRedirects(blocks);
411
+
412
+ expect(split.exact).toEqual([{ path: "/old", to: "/new", status: 301 }]);
413
+ expect(JSON.stringify(split.blocks)).not.toContain('"/old"');
414
+ expect(split.blocks["__csv_redirects__r.csv"]).toMatchObject({ redirects: [{ from: "/g/*" }] });
415
+ });
416
+
389
417
  it("lets a real block win over the synthetic one on a key collision", () => {
390
418
  const blocksDir = makeSite(
391
419
  {
@@ -410,3 +438,78 @@ describe("readDecofileFromDir — CSV redirects", () => {
410
438
  expect(Object.keys(blocks)).toEqual(["site"]);
411
439
  });
412
440
  });
441
+
442
+ describe("syncRedirectsToKv", () => {
443
+ /** In-memory client with the bulk ops the redirect sync uses. */
444
+ function makeBulkClient(initial: Record<string, string> = {}) {
445
+ const store = new Map(Object.entries(initial));
446
+ const client = {
447
+ get: (k: string) => Promise.resolve(store.get(k) ?? null),
448
+ put: (k: string, v: string) => {
449
+ store.set(k, v);
450
+ return Promise.resolve();
451
+ },
452
+ delete: (k: string) => {
453
+ store.delete(k);
454
+ return Promise.resolve();
455
+ },
456
+ list: (prefix?: string) =>
457
+ Promise.resolve([...store.keys()].filter((k) => !prefix || k.startsWith(prefix))),
458
+ putMany: (entries: Array<{ key: string; value: string }>) => {
459
+ for (const e of entries) store.set(e.key, e.value);
460
+ return Promise.resolve();
461
+ },
462
+ deleteMany: (keys: string[]) => {
463
+ for (const k of keys) store.delete(k);
464
+ return Promise.resolve();
465
+ },
466
+ };
467
+ return { client: client as unknown as KvRestClient, store };
468
+ }
469
+
470
+ const rule = (path: string, to: string, status: 301 | 302 = 301) => ({ path, to, status });
471
+
472
+ it("adds new rules under redirect:<id>:<path>", async () => {
473
+ const { client, store } = makeBulkClient();
474
+ const out = await syncRedirectsToKv(client, [rule("/old", "/new")], ID);
475
+
476
+ expect(out).toEqual({ written: 1, deleted: 0 });
477
+ expect(store.get(`redirect:${ID}:/old`)).toBe('{"to":"/new","status":301}');
478
+ });
479
+
480
+ it("updates a changed target in place", async () => {
481
+ const { client, store } = makeBulkClient({
482
+ [`redirect:${ID}:/old`]: '{"to":"/stale","status":302}',
483
+ });
484
+ await syncRedirectsToKv(client, [rule("/old", "/fresh")], ID);
485
+ expect(store.get(`redirect:${ID}:/old`)).toBe('{"to":"/fresh","status":301}');
486
+ });
487
+
488
+ it("removes rules that no longer exist", async () => {
489
+ // The reason this diffs against a prefix LIST: KV has no "replace
490
+ // everything under this prefix", so a deleted rule would redirect forever.
491
+ const { client, store } = makeBulkClient({
492
+ [`redirect:${ID}:/keep`]: '{"to":"/a","status":301}',
493
+ [`redirect:${ID}:/gone`]: '{"to":"/b","status":301}',
494
+ });
495
+ const out = await syncRedirectsToKv(client, [rule("/keep", "/a")], ID);
496
+
497
+ expect(out).toEqual({ written: 1, deleted: 1 });
498
+ expect(store.has(`redirect:${ID}:/gone`)).toBe(false);
499
+ expect(store.has(`redirect:${ID}:/keep`)).toBe(true);
500
+ });
501
+
502
+ it("never touches another deployment's keys", async () => {
503
+ const { client, store } = makeBulkClient({
504
+ "redirect:other-sha:/gone": '{"to":"/x","status":301}',
505
+ });
506
+ await syncRedirectsToKv(client, [], ID);
507
+ expect(store.has("redirect:other-sha:/gone")).toBe(true);
508
+ });
509
+
510
+ it("does not disturb the decofile keys", async () => {
511
+ const { client, store } = makeBulkClient({ [snapshotKey(ID)]: "{}" });
512
+ await syncRedirectsToKv(client, [rule("/a", "/b")], ID);
513
+ expect(store.has(snapshotKey(ID))).toBe(true);
514
+ });
515
+ });
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * CI has no Worker KV binding, so the fast-deploy sync/migrate scripts write to
5
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.
6
+ * GET/PUT/DELETE, prefix LIST (for per-deployment GC), and BULK put/delete (for
7
+ * the per-path redirect keys, which number in the tens of thousands) — are
8
+ * implemented.
7
9
  *
8
10
  * Auth/config via env (read by the scripts, passed to `createKvRestClient`):
9
11
  * - CF_ACCOUNT_ID Cloudflare account id (or CLOUDFLARE_ACCOUNT_ID)
@@ -37,6 +39,21 @@ export interface KvRestClient {
37
39
  delete(key: string): Promise<void>;
38
40
  /** List key names, optionally filtered by prefix. Follows pagination. */
39
41
  list(prefix?: string): Promise<string[]>;
42
+ /** Write many keys. Chunked to the API's per-request ceiling. A site can have
43
+ * tens of thousands of redirects; one PUT each would be as many round trips. */
44
+ putMany(entries: Array<{ key: string; value: string }>): Promise<void>;
45
+ /** Delete many keys. Same chunking. */
46
+ deleteMany(keys: string[]): Promise<void>;
47
+ }
48
+
49
+ /** Cloudflare's per-request ceiling for the bulk KV endpoints. */
50
+ const BULK_CHUNK = 10_000;
51
+
52
+ /** Split `items` into chunks of at most `size`. */
53
+ function chunk<T>(items: T[], size: number): T[][] {
54
+ const out: T[][] = [];
55
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
56
+ return out;
40
57
  }
41
58
 
42
59
  const DEFAULT_BASE = "https://api.cloudflare.com/client/v4";
@@ -108,6 +125,37 @@ export function createKvRestClient(config: KvRestConfig): KvRestClient {
108
125
  }
109
126
  },
110
127
 
128
+ async putMany(entries) {
129
+ for (const batch of chunk(entries, BULK_CHUNK)) {
130
+ const res = await fetchImpl(`${root}/bulk`, {
131
+ method: "PUT",
132
+ headers: { ...authHeaders, "Content-Type": "application/json" },
133
+ body: JSON.stringify(batch),
134
+ });
135
+ if (!res.ok) {
136
+ throw new Error(
137
+ `KV bulk PUT (${batch.length} keys) failed: ${res.status} ${await res.text()}`,
138
+ );
139
+ }
140
+ }
141
+ },
142
+
143
+ async deleteMany(keys) {
144
+ for (const batch of chunk(keys, BULK_CHUNK)) {
145
+ const res = await fetchImpl(`${root}/bulk`, {
146
+ method: "DELETE",
147
+ headers: { ...authHeaders, "Content-Type": "application/json" },
148
+ body: JSON.stringify(batch),
149
+ });
150
+ // 404 is fine — the keys are already gone (idempotent delete).
151
+ if (!res.ok && res.status !== 404) {
152
+ throw new Error(
153
+ `KV bulk DELETE (${batch.length} keys) failed: ${res.status} ${await res.text()}`,
154
+ );
155
+ }
156
+ }
157
+ },
158
+
111
159
  async list(prefix) {
112
160
  const names: string[] = [];
113
161
  let cursor: string | undefined;
@@ -12,9 +12,12 @@ import {
12
12
  computeRevision,
13
13
  DEPLOYMENTS_KEY,
14
14
  LIVE_KEY,
15
+ redirectKey,
16
+ redirectPrefix,
15
17
  revisionKey,
16
18
  snapshotKey,
17
19
  } from "@decocms/blocks/cms";
20
+ import type { ExactRedirect } from "@decocms/blocks/sdk/redirects";
18
21
  import type { KvRestClient } from "./cf-kv-rest";
19
22
 
20
23
  export interface Snapshot {
@@ -71,6 +74,42 @@ export async function verifySnapshotInKv(
71
74
  return { ok: true };
72
75
  }
73
76
 
77
+ /**
78
+ * Reconcile deployment `id`'s exact redirects in KV: write the ones that are
79
+ * new or changed, delete the ones that no longer exist.
80
+ *
81
+ * KV has no "replace everything under this prefix" operation, so the desired
82
+ * set is diffed against what is actually stored. The source of truth for
83
+ * "what's there now" is a prefix LIST rather than a manifest key — a manifest
84
+ * is one more thing that can drift out of sync with reality, and the list is
85
+ * free enough at build time.
86
+ *
87
+ * Updates are not detected by value: re-PUTting an unchanged key is one bulk
88
+ * write, while reading every current value back to compare would be one GET per
89
+ * key. So every desired rule is written, and only deletions need the diff.
90
+ */
91
+ export async function syncRedirectsToKv(
92
+ client: KvRestClient,
93
+ redirects: ExactRedirect[],
94
+ id: string,
95
+ ): Promise<{ written: number; deleted: number }> {
96
+ const desired = new Map(
97
+ redirects.map((r) => [redirectKey(id, r.path), JSON.stringify({ to: r.to, status: r.status })]),
98
+ );
99
+
100
+ const existing = await client.list(redirectPrefix(id));
101
+ const stale = existing.filter((key) => !desired.has(key));
102
+
103
+ if (desired.size > 0) {
104
+ await client.putMany([...desired].map(([key, value]) => ({ key, value })));
105
+ }
106
+ if (stale.length > 0) {
107
+ await client.deleteMany(stale);
108
+ }
109
+
110
+ return { written: desired.size, deleted: stale.length };
111
+ }
112
+
74
113
  /** Point `index:live` at deployment `id` (post-activation, from the deploy step). */
75
114
  export async function setLiveDeployment(client: KvRestClient, id: string): Promise<void> {
76
115
  await client.put(LIVE_KEY, id);
@@ -121,6 +160,11 @@ export async function recordAndGcDeployment(
121
160
  }
122
161
  await client.delete(snapshotKey(entry.id));
123
162
  await client.delete(revisionKey(entry.id));
163
+ // The per-path redirect keys are keyed by deployment too, so they are
164
+ // part of this snapshot and must go with it — otherwise every deploy
165
+ // leaks tens of thousands of orphans into the namespace forever.
166
+ const staleRedirects = await client.list(redirectPrefix(entry.id));
167
+ if (staleRedirects.length > 0) await client.deleteMany(staleRedirects);
124
168
  pruned.push(entry.id);
125
169
  }
126
170
  await client.put(DEPLOYMENTS_KEY, JSON.stringify([...keptOld, ...recent]));
@@ -30,8 +30,14 @@
30
30
  */
31
31
 
32
32
  import * as path from "node:path";
33
+ import { splitExactRedirects } from "@decocms/blocks/sdk/redirects";
33
34
  import { createKvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
34
- import { buildSnapshot, verifySnapshotInKv, writeSnapshotToKv } from "./lib/kv-snapshot";
35
+ import {
36
+ buildSnapshot,
37
+ syncRedirectsToKv,
38
+ verifySnapshotInKv,
39
+ writeSnapshotToKv,
40
+ } from "./lib/kv-snapshot";
35
41
  import { readDecofileFromDir } from "./lib/read-decofile";
36
42
 
37
43
  function parseArgs(argv: string[]) {
@@ -77,8 +83,15 @@ async function main() {
77
83
  process.exit(2);
78
84
  }
79
85
 
80
- const snap = buildSnapshot(blocks);
86
+ // Exact redirects live under their own `redirect:<id>:<path>` keys, not in
87
+ // the decofile — see splitExactRedirects. Must match what sync-blocks-to-kv
88
+ // writes, or a one-shot seed and a CI sync would disagree on the snapshot.
89
+ const split = splitExactRedirects(blocks);
90
+ const snap = buildSnapshot(split.blocks);
81
91
  console.log(`decofile: ${snap.count} blocks, revision ${snap.revision}, ${snap.snapshot.length} bytes`);
92
+ if (split.exact.length > 0) {
93
+ console.log(`redirects: ${split.exact.length} exact rule(s) as individual keys`);
94
+ }
82
95
 
83
96
  if (!opts.write) {
84
97
  console.log("\nDry-run only. Re-run with --write to populate KV.");
@@ -94,6 +107,7 @@ async function main() {
94
107
  }
95
108
 
96
109
  try {
110
+ await syncRedirectsToKv(client, split.exact, opts.deploymentId);
97
111
  await writeSnapshotToKv(client, snap, opts.deploymentId);
98
112
  const verify = await verifySnapshotInKv(client, snap.revision, opts.deploymentId);
99
113
  if (!verify.ok) {
@@ -46,11 +46,14 @@
46
46
 
47
47
  import { execSync } from "node:child_process";
48
48
  import * as path from "node:path";
49
+ import { redirectPrefix } from "@decocms/blocks/cms";
50
+ import { splitExactRedirects } from "@decocms/blocks/sdk/redirects";
49
51
  import { createKvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
50
52
  import {
51
53
  buildSnapshot,
52
54
  recordAndGcDeployment,
53
55
  setLiveDeployment,
56
+ syncRedirectsToKv,
54
57
  verifySnapshotInKv,
55
58
  writeSnapshotToKv,
56
59
  } from "./lib/kv-snapshot";
@@ -160,12 +163,20 @@ async function main() {
160
163
  process.exit(2);
161
164
  }
162
165
 
163
- const snap = buildSnapshot(blocks);
166
+ // Exact redirects get ONE KV key each and are removed from the decofile. A
167
+ // bulk-migration site can carry tens of thousands; inside the snapshot they
168
+ // are resident in every isolate twice (parsed graph + RedirectMap) for data
169
+ // that usually matches nothing. Globs stay — they must be scanned in order.
170
+ const split = splitExactRedirects(blocks);
164
171
  const purgePaths = opts.all ? ["/"] : purgePathsForChangedKeys(blocks, changedKeys);
172
+ const snap = buildSnapshot(split.blocks);
165
173
  console.log(`decofile: ${snap.count} blocks, revision ${snap.revision} → deployment ${opts.deploymentId}`);
174
+ if (split.exact.length > 0) {
175
+ console.log(`redirects: ${split.exact.length} exact rule(s) → ${redirectPrefix(opts.deploymentId)}*`);
176
+ }
166
177
 
167
178
  if (!opts.write) {
168
- console.log(`\nDry-run only. Would write decofile:${opts.deploymentId} + revision, GC to ${opts.retain}, purge: ${purgePaths.join(", ")}`);
179
+ console.log(`\nDry-run only. Would write decofile:${opts.deploymentId} + revision, ${split.exact.length} redirect key(s), GC to ${opts.retain}, purge: ${purgePaths.join(", ")}`);
169
180
  process.exit(0);
170
181
  }
171
182
 
@@ -178,6 +189,13 @@ async function main() {
178
189
  }
179
190
 
180
191
  try {
192
+ // Redirects FIRST: the decofile no longer carries them, so publishing the
193
+ // snapshot before the keys exist would leave a window where every migrated
194
+ // URL 404s instead of redirecting.
195
+ const red = await syncRedirectsToKv(client, split.exact, opts.deploymentId);
196
+ if (red.written || red.deleted) {
197
+ console.log(`redirects: ${red.written} written, ${red.deleted} removed.`);
198
+ }
181
199
  await writeSnapshotToKv(client, snap, opts.deploymentId);
182
200
  const verify = await verifySnapshotInKv(client, snap.revision, opts.deploymentId);
183
201
  if (!verify.ok) {