feat(migrator): invoke as server functions, app blocks as client code, the page setup, and stubs for v7 runtime
Stacked on #706 (feat/migrator-compat-imports). The migration script now writes what the M1 report still left as "call X here": /deco/invoke as server functions, v7's app blocks as client code, the page setup, and stubs for the v7 runtime that has no port.
What the script does now
/deco/invoke → one server function per key (scripts/invoke.ts)
- Every key the site invokes becomes one server function. It calls the handler with
(props, request), as v7's endpoint did, and sends{}when there are no props.- TanStack Start:
src/server/invoke.functions.ts. Loaders areGETwithmarkPrivate(), so the worker never shares them; actions arePOST. - Next.js:
src/server/invoke.actions.ts, a"use server"file.
- TanStack Start:
- Each call site imports its own function:
invoke.site.loaders.spin(props)becomes$siteLoadersSpin({ data: props }), or$siteLoadersSpin(props)on Next.js. - It reads every call shape v7 sites use: chains across lines, bracket keys (
["can-spin"]), flat keys (invoke["vtex/loaders/x.ts"]),invoke(key, props)andRuntime.invoke. site/<path>resolves tosrc/<path>. An app key vendors the app's module when that module's default export is the handler.- v7's proxy module (
src/runtime.ts) is deleted once nothing imports it. - A site's own invoke tree built from server functions is site code and is left alone. A computed key is reported and left as it is.
- Inputs and results are typed
any, as v7's invoke was. The report asks for a zod validator and a typed result for each function.
VTEX invoke keys from the VTEX app's own invoke map: --vtex-invoke keep|v8 (scripts/vtexInvoke.ts, PO decision 7, 2026-10-15)
- v7's
setupAppsregistered every named export of the VTEX app's modules under<module>/<export>, soinvoke.vtex.actions.checkout.addItemsToCart(props)reachedaddItemsToCartinvtex/actions/checkout, which has no handler file of its own. Each migration chooses what those keys become, with an explicit flag (main.ts --vtex-invoke,migrate({ vtexInvoke })), never env. There is no default (PO: "each migration could choose this"): when the site calls such keys and no choice was given, the migrator stops before writing anything, lists the keys and both choices, and asks to run it again with one (VtexInvokeChoiceNeeded; the command line prints that message without the usage). A site that calls none needs no flag. keep: the module is vendored and the server function calls that export (import { addItemsToCart as handler0 } from "../vendor/vtex/actions/checkout"), so the call works as in v7.v8: no v7 code. The keys the v8 client has an operation for (getOrCreateCart,addItemsToCart,updateCartItems,addCouponToCart,updateOrderFormAttachment,simulateCart,getSellersByRegion) become functions over the site'ssrc/clients/vtex.tsinsrc/server/vtex-invoke.server.ts. They send the shopper's cookies and forward VTEX'sSet-Cookieas v7 did: Intelligent Search's session cookies stay on the server, and the Domain becomes the site's. The report lists each one's differences from v7's handler (noallowedOutdatedData/noSplitItem/RnbBehavior, SKU ids sent as numbers, no http→https image rewrite). Every other key becomes theunportedVtexInvokestub, which rejects with the key, plus a report line.- On Next.js the cart calls stay stubs too: a server action can only set cookies through
cookies(), which percent-encodescheckout.vtex.com=__ofid=…. The report says to use a route handler that appends the client'ssetCookies.
Stubs (scripts/stubs.ts writes src/migration-stubs.ts; every stub is @deprecated)
unportedInvoke(key, props)rejects. It replacesctx.invokein a v7 loader and any key that no handler file answers. For asite/key, v7 failed with "handler not found" too.appState(name)throws. It replacesctx.<app>andctx.getAppState(…).- Both stubs behave as reading
ctxalready does in v8, so the section keeps its saved props. - Only a real v7 loader's
ctxis rewritten: the second parameter must be the request. React Query'sonError(err, vars, ctx)is not touched; a dry run caught that case. useSectionandusePartialSectionreturn v7 7.x's own placeholder URL, so pages render as they did in v7.hx-*attributes are reported. They did nothing in v7 on TanStack Start or Next.js, and they do nothing now.
App blocks → client code (scripts/apps.ts)
deco-vtex,deco-shopify,deco-wakeanddeco-resendbecomesrc/clients/<app>.ts, using v7'sconfigure()rules:- VTEX's sales channel defaults to
"1". - Wake's checkout URL gets v7's default.
- Shopify uses the Storefront API version the installed app spoke.
- VTEX's sales channel defaults to
- Encrypted credentials are read from the environment variable v7 fell back to (
VTEX_APP_KEY,SHOPIFY_STOREFRONT_TOKEN,WAKE_TOKEN,RESEND_API_KEY). Their values are never written. - The saved block stays until those variables are set; the report says to delete it then.
- Nuvemshop, HTMX and analytics app blocks are reported.
Page setup (scripts/scaffold.ts), following /next/tanstack-start-descriptors and /next/nextjs
src/cms.ts:createCMSwith the binding'scontentClient, so drafts work.src/open-page.server.ts: resolves every section, then runsloadSectionsat the page's URL. On TanStack Start it callsmatchPage(url)first andcommitSegmentCookie()last, and setsX-Deco-Degradedwhen a section loader fails. It addsrequestDevicewhen the site callsuseDevice.- TanStack Start:
src/page.functions.tsis the page's server function. It takes the page's path, so a client-side navigation loads the page, not/_serverFn/….src/page-view.tsxrenders the page throughsrc/views.ts.src/routes/$.tsxis the catch-all route; v7's home route is rewritten the same way.
- Next.js:
[[...slug]]/page.tsxbinds the draft (bindDraft) and renders<SegmentCookie />. When the site already has a catch-all, it is rewritten in place, keeping its parameter name and route group. - v7's page routes (
cmsRouteConfig,createDecoPage) are replaced, v7's admin routes are deleted, and any other existing file is kept and reported.
Also
- The block map's
BlockDescriptor.propsis nowRecord<string, any>, so TanStack Start can serialize a page's loader data. - Updated docs:
SKILL.md(10,233 bytes, under the 10 KB limit),reference/{script-steps,manual-steps,server-functions,import-map}.mdandCLAUDE.md.
Tests
invoke.test.ts(new, 9 tests): every invoke call shape; Next.js actions; app-key vendoring; the stub for a missing key and the report for a computed key; a site's own invoke tree left alone; the ctx stubs, including React Query'sonError;useSection; the app clients; scaffold keep/replace; a Next.js catch-all in a route group.bindings.test.ts: the TanStack Start and Next.js fixtures now include invoke calls,ctx.invoke, a VTEX app block, v7's page and admin routes, anduseSection. Everything generated type-checks against this repo's packages.invoke.test.ts,--vtex-invoke(3 tests):keepvendors and imports by export.v8on TanStack writes the module, stubs the unmapped key, and type-checks the generated files against@decocms/apps-vtex; the cart call then runs over the real v8 client with a fake fetch, and VTEX's cookies are forwarded (IS cookie kept back, the site's Domain).v8on Next.js maps only the cookie-less calls. Four mutations fail them: the mode switch, the IS filter, the Domain rewrite and the Next.js exclusion.- Skill total: 171 tests.
- Dry runs: on throwaway copies of three v7 storefronts, all three finished without errors. The generated
cms.ts,open-page.server.ts,page.functions.ts,page-view.tsx,invoke.functions.ts,clients/*.tsandviews.tstype-check against this branch's packages.
Gates
| Gate | Result |
|---|---|
bun run check | pass |
bun run build | pass |
skills:check and skills:readme:check | pass |
| Plain-Node import of every built export | 64/64 (compat-v7 27/27); deco --help lists --check |
bun run test (2 GB cap) | 2,723 passed, 2 failed, 95 skipped |
The failures are DS-2, which also fails on main (its fix is in its own PR), and a schema.test.ts timeout under full-suite load. Run alone, those two files pass 68 of 69, and the one failure is DS-2.
Not in this PR
- Invoke-map keys of apps other than VTEX: they are still
unportedInvokeand reported. - The docs site's
/nextpages still tell sites to copy these helpers; a docs PR should follow.
Validation on two v7 sites (re-run at f2e03a65)
The migrator ran on fresh v7 trees of deco-sites/storefront-tanstack (eef304e) and deco-sites/demo-storefront (928488a), with the packages packed from this branch. The re-run includes PO decisions 1 to 9; both sites are Shopify, so they call no VTEX invoke-map keys and need no --vtex-invoke. The new report lines show up there: the two section loaders v7 never ran (CategoryBanner and Wishlist on each site; the demo's hand migration already ran them), the withABTesting hint, and the CI deco schema --check step (manual on both: no workflow on one, no one-line install step on the other). The manual work was then ported from each hand migration, and the result was compared with that hand migration.
That comparison found v7 behaviour the generated page setup had dropped. Fixed here:
- Section wrapper: every section renders in v7's
<section id data-manifest-key>around v7'sSectionErrorBoundary, in a newsrc/section-error-boundary.tsx. - Route options: the TanStack routes keep what the site passed
cmsRouteConfig, plusvalidateSearch/loaderDeps, the route cache times and v7's full head (robots, Open Graph, Twitter Card, canonical, JSON-LD). - Page SEO: built as v7 did, with the saved
siteblock's SEO defaults and templates. - Next.js metadata: gains canonical and robots.
- QueryClient: the router's module-scope
QueryClientmoves intogetRouter. - SEO type (from M1, merged): the page's
seois typed as v7'sPageSeo, so an SEO block fits it.
| storefront | demo | |
|---|---|---|
| Lines changed, v7 to hand migration | 7,197 | 8,027 |
| Left to a human after the old script | 5,092 | 5,951 |
| Left to a human after this script | 2,508 (35%) | 2,901 (36%) |
deco check, tsc | 0 / 0 | 0 / 0 |
| Build under 2 GB (peak) | ok (1.4 GB) | ok (1.3 GB) |
| Header smoke | 6/6 | 6/6 |
| Image tags vs hand | 155/155 equal | 165/165 equal |
<head> vs hand | equal on 6 pages | equal on 6 pages, except the port in the home page's og:url |
| Section wrappers vs hand | equal except v7's style attribute, which the hand migrations dropped | same |
Line counts ignore whitespace and exclude content, lockfiles, generated files, docs and the parity harness.
Review round (2026-10-15)
325e19e9: the--vtex-invoke v8stubs are run, and must reject naming the key (unportedVtexInvokeandunportedInvoke);--vtex-invoke v7must exit 1 with the flag's message. A stub that resolved, or a dropped flag check, used to pass.- Gates at
325e19e9:bun run check,bun run build, skills checks, a plain-Node import of all 64 built exports anddeco --helppass.bun run test: 2,720 pass, 95 skipped; DS-2 (fixed on main by #709) fails, plus 5 s timeouts under full-suite load whose files pass alone (213 of 214, the one failure DS-2).
--vtex-invoke has no default (2026-10-15, c92d94f5, 3aae719e)
migrate()reads the site's invoke calls first (vtexInvokeMapKeysUsed, read only). Keys of v7's VTEX invoke map and no--vtex-invoke: it throwsVtexInvokeChoiceNeededbefore any step writes, with the keys and both choices (keep: copy v7's handlers so each call works as it did;v8: the v8 client where it has the operation, else a stub that rejects, in the report).main.tsprints it without the usage and exits 1. A site that calls none, or runs on no binding the script knows (its calls are left as they are), needs no flag.rewriteInvokesno longer assumeskeepeither.- Tests (
invoke.test.ts+3):migrate()andmain.tson a site with three keys stop with the list and every file stays byte-identical; a site without such keys needs no flag; thekeeptest passeskeepexplicitly. Dropping the check fails two of them (the run then writes files and fails later). Skill: 177 tests. Docs:SKILL.mdflag row (10,232 bytes),script-steps.md,server-functions.md. - Merged up first: P4
68167723(preload hints only on the app's pages) through C47fad06ae(A/B legacy page carries none) and M187740538. - Gates at
3aae719e:bun run check,bun run build,skills:check,skills:readme:checkpass.bun run test: 2,718 pass, 16 fail, 95 skipped, on a machine at load 27–33 with 13 of 15 GB used by other work: all timeouts plus DS-2. The 9 failing files alone:run,serve,cliconformance,observability,syntax.cli, compatsectionsandtests/releasepass in full;schema.testpasses with the one slow test rerun alone;delivery.clifails only DS-2 (fixed on main by #709).
Main refresh (2026-10-17, 5131a3c1)
- Merged M1
ceba1386into this branch (normal merge, no conflicts), then merged up the stack to M2 #707. - Gates at the top of the stack (M2
5131a3c1):bun run check,bun run build,skills:check,skills:readme:checkpass.bun run test(2 GB memory cap): 122 files pass, 1 skipped; 2,745 tests pass, 95 skipped, 0 fail. DS-2 now passes (fixed on main by #709); no timeouts this run. - CI on this PR's new head: all checks pass.
🤖 Generated with Claude Code
https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
Summary by cubic
The v7→v8 migration script now writes what the M1 report left as "call X here": /deco/invoke calls become server functions, v7's app blocks become client code, the page is scaffolded for the binding, and stubs cover the v7 runtime v8 lacks. A --vtex-invoke flag decides how the VTEX app's own invoke keys are migrated.
Invoke, app blocks, stubs and page setup
- Each
invokekey becomes one server function (src/server/invoke.functions.tson TanStack Start with GET loaders marked private and POST actions;src/server/invoke.actions.tson Next.js), imported at each call site. - VTEX, Shopify, Wake and Resend app blocks become
src/clients/<app>.tswith v7's defaults; credentials come from the env vars v7 fell back to, never their values. src/migration-stubs.tsprovides@deprecatedstand-ins (unportedInvoke,appState,useSection) so remaining v7 uses compile and stay findable.- The page setup (
src/cms.ts,src/open-page.server.ts, the page route) replaces v7's page routes and renders sections, route options, page SEO and the router'sQueryClientas v7 did, including v7's section wrapper and error boundary on TanStack. BlockDescriptor.propsis nowRecord<string, any>so TanStack Start can serialize a page's loader data, and the final scripts step addsdeco schema --checkto the site's CI.
VTEX invoke keys
--vtex-invokehas no default: when the site calls keys of v7's VTEX invoke map and no flag is given, migration stops before writing anything and prints the keys with both choices. A site with no such keys needs no flag.--vtex-invoke keepvendors v7's handler so calls likeinvoke.vtex.actions.checkout.addItemsToCartwork as they did.--vtex-invoke v8maps the keys the v8 VTEX client has operations for to functions insrc/server/vtex-invoke.server.tsthat forward VTEX's cookies; other keys become a rejectingunportedVtexInvokestub naming the key, plus a report line. Next.js cart calls stay stubs.- Only calls with a static key count as invoking the VTEX map; calls without a static key are skipped when deciding whether the flag is needed.