@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.
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|