@decocms/start 6.0.0 → 6.0.1

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/start",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -21,18 +21,20 @@ import { spawnSync } from "node:child_process";
21
21
  import * as fs from "node:fs";
22
22
  import * as os from "node:os";
23
23
  import * as path from "node:path";
24
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
24
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
25
25
 
26
26
  const GENERATOR = path.resolve(__dirname, "generate-invoke.ts");
27
27
 
28
28
  const FIXTURE_INVOKE_TS = `\
29
29
  import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
30
30
  import { getOrCreateCart, simulateCart } from "./actions/checkout";
31
+ import { createSession } from "./actions/session";
31
32
  import type { OrderForm } from "./types";
32
33
 
33
34
  export const invoke = {
34
35
  vtex: {
35
36
  actions: {
37
+ // Direct pass-through wrapper — most common shape.
36
38
  getOrCreateCart: createInvokeFn(
37
39
  (data: { orderFormId?: string }) => getOrCreateCart(data),
38
40
  ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
@@ -40,6 +42,13 @@ export const invoke = {
40
42
  simulateCart: createInvokeFn(
41
43
  (data: { postalCode: string }) => simulateCart(data),
42
44
  ),
45
+
46
+ // Adapting wrapper — payload gets wrapped into the action's
47
+ // props shape. The generator MUST preserve this wrap or the
48
+ // action call typechecks against the wrong props type.
49
+ createSession: createInvokeFn(
50
+ (data: Record<string, any>) => createSession({ data }),
51
+ ),
43
52
  },
44
53
  },
45
54
  } as const;
@@ -52,14 +61,25 @@ const FIXTURE_ACTIONS_CHECKOUT_TS = `\
52
61
  export async function getOrCreateCart(_data: any): Promise<any> { return null; }
53
62
  export async function simulateCart(_data: any): Promise<any> { return null; }
54
63
  `;
64
+ const FIXTURE_ACTIONS_SESSION_TS = `\
65
+ export interface CreateSessionProps { data: Record<string, any>; }
66
+ export async function createSession(_props: CreateSessionProps): Promise<any> { return null; }
67
+ `;
55
68
  const FIXTURE_TYPES_TS = `export type OrderForm = unknown;\n`;
56
69
 
57
70
  describe("generate-invoke.ts — output shape", () => {
71
+ // The fixture is read-only across tests: every assertion runs against
72
+ // the same generated `invoke.gen.ts`. Running the generator once in
73
+ // `beforeAll` keeps the test fast (each `npx tsx` spawn is ~3-5s) and
74
+ // sidesteps the vitest 5s default per-test timeout that this suite was
75
+ // tipping over once we grew the fixture.
58
76
  let appsDir: string;
59
77
  let siteDir: string;
60
78
  let outFile: string;
79
+ let generatedOutput: string;
80
+ let generatorStatus: number | null;
61
81
 
62
- beforeEach(() => {
82
+ beforeAll(() => {
63
83
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-"));
64
84
  appsDir = path.join(tmp, "apps");
65
85
  siteDir = path.join(tmp, "site");
@@ -70,11 +90,23 @@ describe("generate-invoke.ts — output shape", () => {
70
90
  path.join(appsDir, "vtex", "actions", "checkout.ts"),
71
91
  FIXTURE_ACTIONS_CHECKOUT_TS,
72
92
  );
93
+ fs.writeFileSync(
94
+ path.join(appsDir, "vtex", "actions", "session.ts"),
95
+ FIXTURE_ACTIONS_SESSION_TS,
96
+ );
73
97
  fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS);
74
98
  outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
75
- });
76
99
 
77
- afterEach(() => {
100
+ const result = spawnSync(
101
+ "npx",
102
+ ["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile],
103
+ { cwd: siteDir, encoding: "utf8" },
104
+ );
105
+ generatorStatus = result.status;
106
+ generatedOutput = fs.readFileSync(outFile, "utf8");
107
+ }, 30_000);
108
+
109
+ afterAll(() => {
78
110
  // Best-effort cleanup; tmp dirs leak otherwise.
79
111
  try {
80
112
  fs.rmSync(path.dirname(appsDir), { recursive: true, force: true });
@@ -83,86 +115,81 @@ describe("generate-invoke.ts — output shape", () => {
83
115
  }
84
116
  });
85
117
 
86
- function runGenerator(): { stdout: string; stderr: string; status: number | null } {
87
- const result = spawnSync(
88
- "npx",
89
- [
90
- "tsx",
91
- GENERATOR,
92
- "--apps-dir",
93
- appsDir,
94
- "--out-file",
95
- outFile,
96
- ],
97
- {
98
- cwd: siteDir,
99
- encoding: "utf8",
100
- },
101
- );
102
- return {
103
- stdout: result.stdout ?? "",
104
- stderr: result.stderr ?? "",
105
- status: result.status,
106
- };
107
- }
118
+ it("runs to completion against a minimal fixture", () => {
119
+ // Sanity check — every subsequent assertion is wasted if the
120
+ // generator process bombed.
121
+ expect(generatorStatus).toBe(0);
122
+ });
108
123
 
109
124
  it("imports the framework helpers needed for Set-Cookie propagation", () => {
110
- const { status } = runGenerator();
111
- expect(status).toBe(0);
112
-
113
- const out = fs.readFileSync(outFile, "utf8");
114
125
  // Both imports must be present — without them, forwardResponseCookies
115
126
  // doesn't compile in the site, and the regression we're fixing
116
127
  // resurfaces silently when someone deletes one of them.
117
- expect(out).toContain('from "@tanstack/react-start/server"');
118
- expect(out).toMatch(/getResponseHeaders\s*,?\s*\n?\s*setResponseHeader/);
119
- expect(out).toContain('import { RequestContext } from "@decocms/start/sdk/requestContext"');
128
+ expect(generatedOutput).toContain('from "@tanstack/react-start/server"');
129
+ expect(generatedOutput).toMatch(/getResponseHeaders\s*,?\s*\n?\s*setResponseHeader/);
130
+ expect(generatedOutput).toContain(
131
+ 'import { RequestContext } from "@decocms/start/sdk/requestContext"',
132
+ );
120
133
  });
121
134
 
122
135
  it("emits the forwardResponseCookies helper exactly once", () => {
123
- runGenerator();
124
- const out = fs.readFileSync(outFile, "utf8");
125
- // Match the declaration, not call sites. There's only one helper.
126
- const declMatches = out.match(/function forwardResponseCookies\(\)/g);
136
+ const declMatches = generatedOutput.match(/function forwardResponseCookies\(\)/g);
127
137
  expect(declMatches).toHaveLength(1);
128
138
 
129
139
  // The helper must read from RequestContext.responseHeaders and call
130
140
  // setResponseHeader. Locking the bridge ends-to-ends.
131
- expect(out).toContain("RequestContext.current");
132
- expect(out).toContain("ctx.responseHeaders.getSetCookie");
133
- expect(out).toContain('setResponseHeader("set-cookie"');
141
+ expect(generatedOutput).toContain("RequestContext.current");
142
+ expect(generatedOutput).toContain("ctx.responseHeaders.getSetCookie");
143
+ expect(generatedOutput).toContain('setResponseHeader("set-cookie"');
134
144
  });
135
145
 
136
146
  it("calls forwardResponseCookies() inside every generated handler", () => {
137
- runGenerator();
138
- const out = fs.readFileSync(outFile, "utf8");
139
-
140
147
  // Each .handler(async ({ data }) ...) block produced by the
141
148
  // generator must contain a `forwardResponseCookies()` call. We
142
149
  // count handlers vs. call sites — excluding the declaration site
143
150
  // (`function forwardResponseCookies()`), which also matches the
144
151
  // bare `forwardResponseCookies()` token.
145
- const handlerCount = (out.match(/\.handler\(async \(\{ data \}\)/g) ?? []).length;
146
- const allOccurrences = (out.match(/\bforwardResponseCookies\(\)/g) ?? []).length;
147
- const declSites = (out.match(/function\s+forwardResponseCookies\(\)/g) ?? []).length;
152
+ const handlerCount = (generatedOutput.match(/\.handler\(async \(\{ data \}\)/g) ?? []).length;
153
+ const allOccurrences = (generatedOutput.match(/\bforwardResponseCookies\(\)/g) ?? []).length;
154
+ const declSites = (generatedOutput.match(/function\s+forwardResponseCookies\(\)/g) ?? [])
155
+ .length;
148
156
  const callSites = allOccurrences - declSites;
149
157
 
150
- expect(handlerCount).toBe(2);
158
+ expect(handlerCount).toBe(3);
151
159
  expect(declSites).toBe(1);
152
- expect(callSites).toBe(2);
160
+ expect(callSites).toBe(3);
153
161
  });
154
162
 
155
163
  it("calls forwardResponseCookies AFTER the action awaits (so RequestContext is populated)", () => {
156
- runGenerator();
157
- const out = fs.readFileSync(outFile, "utf8");
158
-
159
164
  // The action must complete (await result) before we read the
160
165
  // captured Set-Cookies — otherwise we'd forward an empty set
161
- // every time. Verifying ordering via regex is brittle but the
162
- // surface is small enough.
166
+ // every time. The expression body after `await` can be any call
167
+ // shape the wrapper produces (`fn(data)` or `fn({ data })`),
168
+ // so we match across the whole expression up to the semicolon.
163
169
  const pattern =
164
- /const result = await \w+\(data\);\s+forwardResponseCookies\(\);\s+return (?:unwrapResult\(result\)|result);/g;
165
- const orderedCalls = out.match(pattern) ?? [];
166
- expect(orderedCalls.length).toBe(2);
170
+ /const result = await [^;]+;\s+forwardResponseCookies\(\);\s+return (?:unwrapResult\(result\)|result);/g;
171
+ const orderedCalls = generatedOutput.match(pattern) ?? [];
172
+ expect(orderedCalls.length).toBe(3);
173
+ });
174
+
175
+ it("preserves adapting wrappers verbatim (does not collapse to actionFn(data))", () => {
176
+ // Regression for the createSession-shape wrapper: the generator
177
+ // previously hard-coded `${importedFn}(data)` in every handler,
178
+ // silently dropping the wrap that wrappers like
179
+ // createSession: createInvokeFn((data) => createSession({ data }))
180
+ // perform to bridge the external invoke shape to the internal
181
+ // action shape. Sites that regenerated against this hit
182
+ // `TS2345: Property 'data' is missing in type '{ [x: string]: any; }'`
183
+ // at the regenerated call site of every adapting wrapper.
184
+ expect(generatedOutput).toContain("const result = await createSession({ data });");
185
+ // And the broken shortcut must NOT appear for this action.
186
+ expect(generatedOutput).not.toMatch(/const result = await createSession\(data\);/);
187
+
188
+ // Direct pass-through wrappers are unaffected: their body is
189
+ // already `actionFn(data)`, so emitting verbatim produces the same
190
+ // output that the previous shortcut produced. Lock that too — a
191
+ // future refactor that breaks pass-throughs would be just as bad.
192
+ expect(generatedOutput).toContain("const result = await getOrCreateCart(data);");
193
+ expect(generatedOutput).toContain("const result = await simulateCart(data);");
167
194
  });
168
195
  });
@@ -375,23 +375,39 @@ for (const action of actions) {
375
375
  const varName = `$${action.name}`;
376
376
 
377
377
  if (action.importedFn) {
378
- // All @decocms/apps action functions take a single props object.
379
- // Pass the validated `data` object directly — never destructure into
380
- // positional arguments, which breaks when function signatures change.
378
+ // Emit the wrapper body verbatim. The arrow function in
379
+ // @decocms/apps/vtex/invoke.ts is the contract that maps the external
380
+ // invoke shape (what storefront callers send) to the internal action
381
+ // shape (what vtex/actions/* expects). Most wrappers are direct
382
+ // pass-throughs (`actionFn(data)`) but some adapt the payload
383
+ // (e.g. `createSession({ data })` wraps a flat session payload into
384
+ // CreateSessionProps). Hard-coding `${importedFn}(data)` silently
385
+ // dropped the wrap, producing typecheck errors at the call site of
386
+ // every adapting wrapper.
387
+ //
388
+ // Invariant: when action.importedFn is set, action.callBody was
389
+ // non-empty and contained `${importedFn}(` (that's how importedFn was
390
+ // discovered in the first place — see the importMap scan above).
391
+ // Block bodies clear callBody to "", which forces importedFn to ""
392
+ // and routes to the stub branch below.
393
+ //
394
+ // The arrow's parameter is `data` by convention across every wrapper
395
+ // in vtex/invoke.ts, and the generated handler destructures `{ data }`
396
+ // from the validator output, so callBody's `data` references resolve
397
+ // to the handler's local `data` without any rename.
381
398
  //
382
399
  // forwardResponseCookies() runs AFTER the action awaits, so any
383
400
  // Set-Cookie that vtexFetchWithCookies captured onto
384
401
  // RequestContext.responseHeaders gets promoted to the actual HTTP
385
- // response. Safe no-op when the action didn't touch
386
- // responseHeaders (e.g. masterData reads), so it's applied
387
- // unconditionally — the alternative (a static allow-list of
388
- // cookie-bearing actions) silently misses any new actions that
389
- // start propagating cookies.
402
+ // response. Safe no-op when the action didn't touch responseHeaders
403
+ // (e.g. masterData reads), so it's applied unconditionally — the
404
+ // alternative (a static allow-list of cookie-bearing actions)
405
+ // silently misses any new actions that start propagating cookies.
390
406
  if (action.unwrap) {
391
407
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
392
408
  .inputValidator((data: ${action.inputType}) => data)
393
409
  .handler(async ({ data }): Promise<any> => {
394
- const result = await ${action.importedFn}(data);
410
+ const result = await ${action.callBody};
395
411
  forwardResponseCookies();
396
412
  return unwrapResult(result);
397
413
  });\n`;
@@ -399,7 +415,7 @@ for (const action of actions) {
399
415
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
400
416
  .inputValidator((data: ${action.inputType}) => data)
401
417
  .handler(async ({ data }): Promise<any> => {
402
- const result = await ${action.importedFn}(data);
418
+ const result = await ${action.callBody};
403
419
  forwardResponseCookies();
404
420
  return result;
405
421
  });\n`;