From edbb911d6ca68339f4f1d39dae52c0e70ffbe36c Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 14 Sep 2026 15:23:17 -0300 Subject: [PATCH] feat(cli): scaffold migrated sites fast-deploy-ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migrated site could not fast-deploy without hand edits. The scaffold emitted a wrangler.jsonc with no `vars`, no observability, and a single KV binding — `SITES_KV` with the literal id "dev-sites-kv", which resolves to nothing anywhere — and a setup.ts that never called `setupTanstackFastDeploy()`, so even a hand-fixed wrangler would leave the Studio publish write-through silently no-opping. Now: - wrangler.jsonc declares the `DECO_KV` binding and `DECO_FAST_DEPLOY: "1"` together, plus `version_metadata` and a `deco-otel-tail` consumer. The tail consumer is not decoration: Cloudflare kills an isolate over the 128MB cap before any in-worker code can report it, so without it an exceededMemory incident is invisible. Emitted as real JSONC rather than JSON.stringify so the reasons survive next to the fields. - Namespace ids are left empty on purpose. The control-plane provisions the site's OWN namespaces and re-forces the ids at build time, so a wrong id in the repo can never reach production — and a plausible-looking fake id is worse than a blank. - setup.ts calls `setupTanstackFastDeploy()`, mirroring the smoke example. - A verify check asserts all three together. Two of three is the worst state: it looks configured, the publish reports success, and nothing reaches KV. Co-Authored-By: Claude Opus 5 (1M context) --- .../migrate/fast-deploy-scaffold.test.ts | 130 ++++++++++++++++++ .../scripts/migrate/phase-scaffold.ts | 56 ++++++-- .../scripts/migrate/phase-verify.ts | 40 ++++++ .../scripts/migrate/templates/setup.ts | 9 +- 4 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 packages/blocks-cli/scripts/migrate/fast-deploy-scaffold.test.ts diff --git a/packages/blocks-cli/scripts/migrate/fast-deploy-scaffold.test.ts b/packages/blocks-cli/scripts/migrate/fast-deploy-scaffold.test.ts new file mode 100644 index 00000000..64938ac1 --- /dev/null +++ b/packages/blocks-cli/scripts/migrate/fast-deploy-scaffold.test.ts @@ -0,0 +1,130 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseJsonc } from "../lib/jsonc"; +import { generateWranglerConfig } from "./phase-scaffold"; +import { checks } from "./phase-verify"; +import { generateSetup } from "./templates/setup"; +import type { MigrationContext } from "./types"; + +/** + * Fast Deploy is inert unless THREE things line up: the `DECO_KV` binding, + * `DECO_FAST_DEPLOY=1`, and `setup.ts` handing the KV resolver to + * `@decocms/blocks-admin` (which cannot import `@decocms/tanstack` itself). + * + * Two of three is the worst state: it looks configured, a Studio publish + * reports success, and nothing ever reaches KV. These tests pin the scaffold to + * emit all three and pin the verify check to actually catch a partial config. + */ + +const FIXTURE: MigrationContext = { + sourceDir: "", + siteName: "test-site", + platform: "custom", + vtexAccount: null, + gtmId: null, + importMap: {}, + discoveredNpmDeps: {}, + themeColors: {}, + tailwindConfig: { + colors: {}, + fontFamily: {}, + screens: {}, + safelist: [], + safelistPatterns: [], + plugins: [], + reviewItems: [], + animations: {}, + keyframes: {}, + }, + fontFamily: null, + googleFonts: [], + layout: null, + files: [], + sectionMetas: [], + islandClassifications: [], + loaderInventory: [], + patterns: {}, + scaffoldedFiles: [], + transformedFiles: [], + deletedFiles: [], + movedFiles: [], + manualReview: [], + dryRun: false, + verbose: false, +} as unknown as MigrationContext; + +const fastDeployCheck = checks.find((c) => c.name === "Fast Deploy wired end to end"); + +/** Write a site tree with the given wrangler + setup contents. */ +function makeSite(wrangler: string, setup: string): MigrationContext { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fd-scaffold-")); + fs.writeFileSync(path.join(dir, "wrangler.jsonc"), wrangler); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + fs.writeFileSync(path.join(dir, "src", "setup.ts"), setup); + return { ...FIXTURE, sourceDir: dir }; +} + +describe("scaffolded wrangler.jsonc", () => { + // Parse it rather than grep it: the template is hand-written JSONC, and a + // stray comma or comment that breaks parsing silently disables fast-deploy + // everywhere at once (the builder, the control-plane and the verify check + // below all read this file through a JSONC parser). + const cfg = parseJsonc(generateWranglerConfig(FIXTURE)) as { + kv_namespaces: Array<{ binding: string }>; + vars: Record; + tail_consumers?: unknown[]; + }; + + it("declares the DECO_KV binding and DECO_FAST_DEPLOY together", () => { + expect(cfg.kv_namespaces.map((n) => n.binding)).toContain("DECO_KV"); + expect(cfg.vars.DECO_FAST_DEPLOY).toBe("1"); + expect(cfg.vars.DECO_SITE_NAME).toBe("test-site"); + }); + + it("declares a tail consumer, without which exceededMemory is unobservable", () => { + // Cloudflare kills the isolate over the 128MB cap before any in-worker code + // could report it — the tail worker is the only channel that sees it. + expect(cfg.tail_consumers).toBeTruthy(); + }); +}); + +describe("scaffolded setup.ts", () => { + it("calls setupTanstackFastDeploy, or the Studio publish write-through no-ops", () => { + const setup = generateSetup(FIXTURE); + expect(setup).toContain("setupTanstackFastDeploy()"); + expect(setup).toMatch(/import \{[^}]*setupTanstackFastDeploy[^}]*\} from "@decocms\/tanstack"/); + }); +}); + +describe('verify check "Fast Deploy wired end to end"', () => { + const FULL_WRANGLER = `{ + // comment + trailing comma: real wrangler.jsonc, not JSON + "kv_namespaces": [{ "binding": "DECO_KV", "id": "" }], + "vars": { "DECO_FAST_DEPLOY": "1" }, +}`; + const FULL_SETUP = "setupTanstackFastDeploy();\n"; + + it("passes on a fully wired site", () => { + expect(fastDeployCheck?.fn(makeSite(FULL_WRANGLER, FULL_SETUP))).toBe(true); + }); + + it("fails when the DECO_KV binding is missing", () => { + const w = '{ "kv_namespaces": [], "vars": { "DECO_FAST_DEPLOY": "1" } }'; + expect(fastDeployCheck?.fn(makeSite(w, FULL_SETUP))).toBe(false); + }); + + it("fails when DECO_FAST_DEPLOY is not set", () => { + const w = '{ "kv_namespaces": [{ "binding": "DECO_KV", "id": "" }] }'; + expect(fastDeployCheck?.fn(makeSite(w, FULL_SETUP))).toBe(false); + }); + + it("fails when setup.ts never hands over the KV resolver", () => { + expect(fastDeployCheck?.fn(makeSite(FULL_WRANGLER, "// nothing\n"))).toBe(false); + }); + + it("is an error, not a warning — a silent half-config is the failure mode", () => { + expect(fastDeployCheck?.severity).toBe("error"); + }); +}); diff --git a/packages/blocks-cli/scripts/migrate/phase-scaffold.ts b/packages/blocks-cli/scripts/migrate/phase-scaffold.ts index 4af7d739..cc448c47 100644 --- a/packages/blocks-cli/scripts/migrate/phase-scaffold.ts +++ b/packages/blocks-cli/scripts/migrate/phase-scaffold.ts @@ -277,21 +277,47 @@ export { type Font as SiteThemeFont }; `; } -function generateWranglerConfig(ctx: MigrationContext): string { - return ( - JSON.stringify( - { - name: ctx.siteName, - main: "src/worker-entry.ts", - compatibility_date: "2025-05-01", - compatibility_flags: ["nodejs_compat", "no_handle_cross_request_promise_resolution"], - cache: { enabled: true }, - kv_namespaces: [{ binding: "SITES_KV", id: "dev-sites-kv" }], - }, - null, - 2, - ) + "\n" - ); +export function generateWranglerConfig(ctx: MigrationContext): string { + // Emitted as real JSONC (not JSON.stringify) so the comments survive — every + // field below is load-bearing and the next person to edit this file needs to + // know why. The ids are placeholders: the control-plane owns them (it + // provisions the site's OWN namespaces and overrides these at build time), + // so a wrong id here can never reach production. + return `{ + "name": "${ctx.siteName}", + "main": "src/worker-entry.ts", + "compatibility_date": "2025-05-01", + // no_handle_cross_request_promise_resolution: the framework's SWR/dedup + // caches hold in-flight promises across requests; without this flag the + // worker hangs. + "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"], + // Workers Cache — tiered cache IN FRONT of the worker. On a hit the worker + // never runs. Per-response cacheability is still driven by Cache-Control. + "cache": { "enabled": true }, + // Surfaces the build sha as service.version on every span and log line. + "version_metadata": { "binding": "CF_VERSION_METADATA" }, + // Tail worker. The ONLY channel that can see exceededMemory / exceededCpu: + // Cloudflare kills the isolate before any in-worker code could report them. + // Without this a memory incident is invisible — see docs/runbooks/. + "tail_consumers": [{ "service": "deco-otel-tail" }], + "kv_namespaces": [ + // Fast Deploy content store. Holds decofile: + the revision + // index, so a CMS publish goes live without a code deploy. The id is + // written by the control-plane at site creation and re-forced from + // CF_KV_NAMESPACE_ID on every build (per-site KV isolation). + { "binding": "DECO_KV", "id": "" }, + // A/B testing assignments. + { "binding": "SITES_KV", "id": "" } + ], + "vars": { + "DECO_SITE_NAME": "${ctx.siteName}", + "DECO_ENV_NAME": "production", + // Fast Deploy opt-in. Requires BOTH this flag AND the DECO_KV binding + // above; inert if either is missing. Set to "0" to disable. + "DECO_FAST_DEPLOY": "1" + } +} +`; } function generateGitignore(): string { diff --git a/packages/blocks-cli/scripts/migrate/phase-verify.ts b/packages/blocks-cli/scripts/migrate/phase-verify.ts index 85451d3b..4ea318d8 100644 --- a/packages/blocks-cli/scripts/migrate/phase-verify.ts +++ b/packages/blocks-cli/scripts/migrate/phase-verify.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { parseJsonc } from "../lib/jsonc"; import type { MigrationContext } from "./types"; import { logPhase } from "./types"; @@ -84,6 +85,45 @@ export const checks: Check[] = [ return true; }, }, + { + // Fast Deploy needs THREE things and is inert unless all three are present: + // the DECO_KV binding, DECO_FAST_DEPLOY=1, and setup.ts handing the KV + // resolver to blocks-admin. Two of three is the worst state — it looks + // configured, the Studio publish reports success, and nothing reaches KV. + name: "Fast Deploy wired end to end", + severity: "error", + fn: (ctx) => { + const missing: string[] = []; + const wranglerPath = path.join(ctx.sourceDir, "wrangler.jsonc"); + try { + const cfg = parseJsonc(fs.readFileSync(wranglerPath, "utf-8")) as { + kv_namespaces?: Array<{ binding?: string }>; + vars?: Record; + }; + const flag = cfg.vars?.DECO_FAST_DEPLOY; + if (!(cfg.kv_namespaces ?? []).some((n) => n?.binding === "DECO_KV")) { + missing.push("wrangler.jsonc kv_namespaces is missing the DECO_KV binding"); + } + if (flag !== "1" && flag !== "true") { + missing.push('wrangler.jsonc vars.DECO_FAST_DEPLOY is not "1"'); + } + } catch (e) { + missing.push(`wrangler.jsonc could not be parsed: ${(e as Error).message}`); + } + + const setupPath = path.join(ctx.sourceDir, "src", "setup.ts"); + const setup = fs.existsSync(setupPath) ? fs.readFileSync(setupPath, "utf-8") : ""; + if (!setup.includes("setupTanstackFastDeploy()")) { + missing.push("src/setup.ts does not call setupTanstackFastDeploy()"); + } + + if (missing.length > 0) { + for (const m of missing) console.log(` ${m}`); + return false; + } + return true; + }, + }, { name: "Old artifacts removed", severity: "error", diff --git a/packages/blocks-cli/scripts/migrate/templates/setup.ts b/packages/blocks-cli/scripts/migrate/templates/setup.ts index 6aa34830..589c4241 100644 --- a/packages/blocks-cli/scripts/migrate/templates/setup.ts +++ b/packages/blocks-cli/scripts/migrate/templates/setup.ts @@ -104,7 +104,7 @@ import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps-vtex";` : ""}${h import { registerLocationMatcher } from "./matchers/location";` : ""} import { blocks as generatedBlocks } from "../.deco/blocks.gen"; import { sectionMeta, syncComponents, loadingFallbacks, renderJsons } from "../.deco/sections.gen"; -import { PreviewProviders } from "@decocms/tanstack"; +import { PreviewProviders, setupTanstackFastDeploy } from "@decocms/tanstack"; // @ts-ignore Vite ?url import import appCss from "./styles/app.css?url"; @@ -131,6 +131,13 @@ createSiteSetup({ return null; }, }); + +// -- Fast Deploy -- +// Hands the KV binding resolver to @decocms/blocks-admin, which cannot import +// @decocms/tanstack itself (wrong direction in the package graph). Without this +// call a Studio publish silently no-ops instead of writing through to KV. +// Inert unless the worker has DECO_FAST_DEPLOY=1 + a DECO_KV binding. +setupTanstackFastDeploy(); ${isVtex ? ` // -- VTEX wiring -- setVtexFetch(createInstrumentedFetch("vtex"));