ruact 0.0.5 → 0.0.7

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.
Files changed (135) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +74 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +79 -425
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +71 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/manifest_resolver.rb +149 -0
  41. data/lib/ruact/railtie.rb +70 -203
  42. data/lib/ruact/render_pipeline.rb +8 -1
  43. data/lib/ruact/server.rb +28 -6
  44. data/lib/ruact/server_functions/codegen.rb +49 -188
  45. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  46. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  47. data/lib/ruact/server_functions/error_payload.rb +1 -1
  48. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  49. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  50. data/lib/ruact/server_functions/query_source.rb +35 -8
  51. data/lib/ruact/server_functions/route_source.rb +3 -4
  52. data/lib/ruact/server_functions/snapshot.rb +12 -139
  53. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  54. data/lib/ruact/server_functions.rb +21 -25
  55. data/lib/ruact/signed_references.rb +162 -0
  56. data/lib/ruact/string_distance.rb +72 -0
  57. data/lib/ruact/validation_errors_collector.rb +139 -0
  58. data/lib/ruact/version.rb +1 -1
  59. data/lib/ruact/view_helper.rb +109 -0
  60. data/lib/ruact.rb +20 -19
  61. data/lib/tasks/ruact.rake +10 -53
  62. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  63. data/spec/ruact/client_manifest_spec.rb +36 -0
  64. data/spec/ruact/component_contract_spec.rb +119 -0
  65. data/spec/ruact/configuration_spec.rb +51 -34
  66. data/spec/ruact/controller_request_spec.rb +264 -0
  67. data/spec/ruact/controller_spec.rb +70 -328
  68. data/spec/ruact/doctor_spec.rb +201 -0
  69. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  70. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  71. data/spec/ruact/errors_spec.rb +0 -45
  72. data/spec/ruact/html_converter_spec.rb +50 -0
  73. data/spec/ruact/install_generator_spec.rb +591 -4
  74. data/spec/ruact/manifest_resolver_spec.rb +159 -0
  75. data/spec/ruact/query_request_spec.rb +109 -1
  76. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  77. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  78. data/spec/ruact/server_function_name_spec.rb +1 -1
  79. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  80. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  81. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  82. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  83. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  84. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  85. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  86. data/spec/ruact/server_spec.rb +8 -9
  87. data/spec/ruact/signed_references_spec.rb +164 -0
  88. data/spec/ruact/string_distance_spec.rb +38 -0
  89. data/spec/ruact/validation_errors_spec.rb +116 -0
  90. data/spec/ruact/view_helper_spec.rb +79 -0
  91. data/spec/spec_helper.rb +15 -5
  92. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  93. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  94. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  95. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  96. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  97. data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
  98. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  99. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  100. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  101. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  102. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  103. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  104. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  105. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  106. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  107. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  108. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  109. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  110. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  119. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  120. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  121. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  122. metadata +58 -15
  123. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  124. data/lib/ruact/server_action.rb +0 -131
  125. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  126. data/lib/ruact/server_functions/registry.rb +0 -148
  127. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  128. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  129. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  130. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  131. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  132. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  133. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  134. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  135. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -49,30 +49,33 @@ const RESERVED_JS_IDENTIFIERS = new Set([
49
49
  ]);
50
50
 
51
51
  // Story 8.2 R12 (2026-05-17) — names ALREADY bound at module top by the
52
- // codegen itself: the runtime imports (`_makeRef`, `_makeServerFunction`,
53
- // `_makeQuery`) and the re-exports (`revalidate`, `useQuery`). A snapshot that
54
- // declared any as a `js_identifier` would emit a duplicate binding and crash
55
- // at module-load time. Mirrors Ruby `NameBridge::RESERVED_BY_RUACT`.
56
- // Story 9.5 added `_makeQuery` + `useQuery`.
52
+ // codegen itself: the runtime imports (`_makeServerFunction`, `_makeQuery`) and
53
+ // the re-exports (`revalidate`, `useQuery`). A snapshot that declared any as a
54
+ // `js_identifier` would emit a duplicate binding and crash at module-load time.
55
+ // Mirrors Ruby `NameBridge::RESERVED_BY_RUACT`. Story 9.5 added `_makeQuery` +
56
+ // `useQuery`; Story 9.9 removed the demolished v1 `_makeRef`.
57
57
  const RESERVED_BY_RUACT = new Set([
58
58
  "_makeQuery",
59
- "_makeRef",
60
59
  "_makeServerFunction",
61
60
  "revalidate",
62
61
  "useQuery",
63
62
  ]);
64
63
 
65
64
  // Story 9.3 — the route-driven snapshot schema version + its verb allowlist.
66
- // A version-2 snapshot renders `_makeServerFunction({...})` calls; `render`
67
- // dispatches on `version` so the v1 path stays byte-for-byte untouched.
68
- // Mirrors Ruby `Codegen::VERSION_V2` / `V2_HTTP_METHODS`.
65
+ // A version-2 snapshot renders `_makeServerFunction({...})` calls; as of Story
66
+ // 9.9 it is the only supported version. Mirrors Ruby `Codegen::VERSION_V2` /
67
+ // `V2_HTTP_METHODS`.
69
68
  export const VERSION_V2 = 2;
70
69
  const V2_HTTP_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
71
- // Story 9.5 — queries are GET-only (the `POST /__ruact/fn/:id` query mechanism
72
- // is voided by the 2026-06-02 ADR addendum). Mirrors Ruby
73
- // `Codegen::V2::QUERY_HTTP_METHODS`.
70
+ // Story 9.5 — queries are GET-only (the 2026-06-02 ADR addendum restored HTTP
71
+ // GET semantics for queries). Mirrors Ruby `Codegen::V2::QUERY_HTTP_METHODS`.
74
72
  const V2_QUERY_HTTP_METHODS = new Set(["GET"]);
75
73
 
74
+ // Story 13.4 — the VALUE type of every typed query param: the FR88 query-string
75
+ // wire union (keys + optionality are exact; per-param scalar precision is not
76
+ // reflection-honest and is deferred). Mirrors Ruby `Codegen::QUERY_PARAM_VALUE_TYPE`.
77
+ const QUERY_PARAM_VALUE_TYPE = "string | number | boolean | null";
78
+
76
79
  /**
77
80
  * Absolute path to the placeholder runtime bundled inside the gem. Used as the
78
81
  * target of the `ruact/server-functions-runtime` Vite alias so host apps
@@ -148,90 +151,21 @@ function validateMetadata(snapshot) {
148
151
  }
149
152
  }
150
153
 
151
- function validateSnapshot(snapshot) {
152
- validateMetadata(snapshot);
153
- const { functions } = snapshot;
154
-
155
- if (!Array.isArray(functions)) {
156
- throw new Error(
157
- `ruact server-function codegen: snapshot.functions must be an array, got ${typeof functions}`,
158
- );
159
- }
160
-
161
- const seen = new Set();
162
- for (const fn of functions) {
163
- if (!fn || typeof fn !== "object" || Array.isArray(fn)) {
164
- throw new Error(
165
- `ruact server-function codegen: snapshot.functions entry is not an object: ${JSON.stringify(fn)}`,
166
- );
167
- }
168
- if (typeof fn.ruby_symbol !== "string" || fn.ruby_symbol.length === 0) {
169
- throw new Error(
170
- "ruact server-function codegen: snapshot.functions entry has missing or " +
171
- `empty ruby_symbol (got ${JSON.stringify(fn.ruby_symbol)}); the bridge ` +
172
- "JSON is corrupted — regenerate via `bin/rails ruact:server_functions:generate`.",
173
- );
174
- }
175
- if (fn.kind !== "action" && fn.kind !== "query") {
176
- throw new Error(
177
- "ruact server-function codegen: snapshot.functions entry has invalid kind " +
178
- `${JSON.stringify(fn.kind)} (must be "action" or "query") for ` +
179
- `ruby_symbol=${JSON.stringify(fn.ruby_symbol)}`,
180
- );
181
- }
182
- if (typeof fn.js_identifier !== "string" || !VALID_JS_IDENTIFIER.test(fn.js_identifier)) {
183
- throw new Error(
184
- "ruact server-function codegen rejected a snapshot entry: " +
185
- `ruby_symbol=${JSON.stringify(fn.ruby_symbol)} ` +
186
- `js_identifier=${JSON.stringify(fn.js_identifier)} is not a valid JS identifier ` +
187
- "(must match /^[A-Za-z_$][A-Za-z0-9_$]*$/). The snapshot JSON is " +
188
- "corrupted or was hand-edited — regenerate via " +
189
- "`bin/rails ruact:server_functions:generate`.",
190
- );
191
- }
192
- if (RESERVED_JS_IDENTIFIERS.has(fn.js_identifier)) {
193
- throw new Error(
194
- `ruact server-function codegen: js_identifier "${fn.js_identifier}" is a reserved ` +
195
- `JS word — ruby_symbol=${JSON.stringify(fn.ruby_symbol)} would emit an invalid ` +
196
- "TS module. Ruby NameBridge should have rejected this; regenerate via " +
197
- "`bin/rails ruact:server_functions:generate`.",
198
- );
199
- }
200
- if (RESERVED_BY_RUACT.has(fn.js_identifier)) {
201
- throw new Error(
202
- `ruact server-function codegen: js_identifier "${fn.js_identifier}" is reserved by ` +
203
- "the ruact runtime/codegen surface (would clash with the module's `revalidate` " +
204
- `re-export or \`_makeRef\` import) — ruby_symbol=${JSON.stringify(fn.ruby_symbol)} ` +
205
- "cannot be exported. NameBridge should have rejected this; regenerate via " +
206
- "`bin/rails ruact:server_functions:generate`.",
207
- );
208
- }
209
- if (seen.has(fn.js_identifier)) {
210
- throw new Error(
211
- `ruact server-function codegen: duplicate js_identifier "${fn.js_identifier}" in ` +
212
- "snapshot — two entries would emit conflicting `export const` declarations. " +
213
- "The snapshot JSON is corrupted or was hand-edited — regenerate via " +
214
- "`bin/rails ruact:server_functions:generate`.",
215
- );
216
- }
217
- seen.add(fn.js_identifier);
218
- }
219
- }
220
-
221
154
  /**
222
- * Renders the snapshot Hash into the TS module text. MUST stay byte-identical
223
- * to {Ruact::ServerFunctions::Codegen.render}.
155
+ * Renders the route-driven (version-2) snapshot Hash into the TS module text.
156
+ * MUST stay byte-identical to {Ruact::ServerFunctions::Codegen.render}. Story
157
+ * 9.9 demolished the v1 (registry / `_makeRef`) render path; only version 2 is
158
+ * supported.
224
159
  *
225
160
  * @param {{ version: number, generated_at: string, functions: Array<{
226
- * ruby_symbol: string, js_identifier: string, kind: string, controller?: string|null
161
+ * js_identifier: string, kind: string, http_method?: string, path?: string,
162
+ * segments?: string[]
227
163
  * }> }} snapshot
228
164
  * @returns {string}
229
165
  */
230
166
  export function render(snapshot) {
231
- // Story 9.3 dispatch on snapshot version. A version-2 (route-driven)
232
- // snapshot renders `_makeServerFunction({...})` calls; the v1 path below is
233
- // untouched. The version peek is shape-guarded so a corrupt non-object
234
- // snapshot still falls through to validateSnapshot's loud failure.
167
+ // The version peek is shape-guarded so a corrupt non-object snapshot still
168
+ // falls through to the loud validation failure below.
235
169
  if (
236
170
  snapshot &&
237
171
  typeof snapshot === "object" &&
@@ -241,34 +175,15 @@ export function render(snapshot) {
241
175
  return renderV2(snapshot);
242
176
  }
243
177
 
244
- validateSnapshot(snapshot);
245
- const { version, generated_at, functions } = snapshot;
246
- let out = "";
247
- out += "// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.\n";
248
- out += `// Source: tmp/cache/ruact/server-functions.json (version ${version})\n`;
249
- out += `// Generated at: ${generated_at}\n`;
250
- out += `import { _makeRef } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
251
-
252
- if (functions.length === 0) {
253
- out += "\n// (no server functions registered yet — Stories 8.1 / 9.1 populate)\n";
254
- // `noUnusedLocals` would otherwise flag the `_makeRef` import. The `void`
255
- // discard pattern keeps the import alive at zero runtime cost; once an
256
- // action / query is registered the export below references `_makeRef`
257
- // directly and this line is omitted.
258
- out += "void _makeRef;\n";
259
- } else {
260
- out += "\n";
261
- for (const fn of functions) {
262
- out += renderExport(fn);
263
- }
264
- }
265
- // Story 8.2 — `revalidate()` re-export. Emitted in both branches
266
- // (empty + populated registry) because the helper is unconditional;
267
- // see codegen.rb's REVALIDATE_REEXPORT and the 2026-05-16 Decision-log
268
- // entry for the rationale.
269
- out += "\n";
270
- out += `export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
271
- return out;
178
+ // Any non-v2 snapshot is corrupt as of Story 9.9. validateMetadata still
179
+ // produces a precise message for the common shape errors; otherwise reject
180
+ // the unsupported version explicitly.
181
+ validateMetadata(snapshot);
182
+ throw new Error(
183
+ `ruact server-function codegen: unsupported snapshot version ${JSON.stringify(snapshot.version)} ` +
184
+ `(only the route-driven version ${VERSION_V2} is supported as of Story 9.9); the bridge ` +
185
+ "JSON is corrupted — regenerate via `bin/rails ruact:server_functions:generate`.",
186
+ );
272
187
  }
273
188
 
274
189
  /**
@@ -368,6 +283,56 @@ function validateSnapshotV2(snapshot) {
368
283
  "snapshot JSON is corrupted.",
369
284
  );
370
285
  }
286
+ // Story 13.4 — the structured query `params` metadata is a trust boundary
287
+ // (it becomes TS object keys). Mirrors Ruby `validate_params!`.
288
+ if (fn.kind === "query") validateQueryParams(fn);
289
+ }
290
+ }
291
+
292
+ function validateQueryParams(fn) {
293
+ // Story 13.4 review R1 — a non-Boolean truthy `params_rest` would otherwise
294
+ // silently flip a query to the open `& Record<string, unknown>` shape.
295
+ if (
296
+ fn.params_rest !== undefined &&
297
+ fn.params_rest !== null &&
298
+ typeof fn.params_rest !== "boolean"
299
+ ) {
300
+ throw new Error(
301
+ `ruact server-function codegen: v2 query entry "${fn.js_identifier}" has a non-boolean ` +
302
+ `params_rest ${JSON.stringify(fn.params_rest)}; snapshot JSON is corrupted.`,
303
+ );
304
+ }
305
+
306
+ const params = fn.params;
307
+ if (params === undefined || params === null) return; // falls back to accepts_params
308
+
309
+ if (!Array.isArray(params)) {
310
+ throw new Error(
311
+ `ruact server-function codegen: v2 query entry "${fn.js_identifier}" has invalid ` +
312
+ `params ${JSON.stringify(params)} (must be an array of { name, required } objects).`,
313
+ );
314
+ }
315
+ for (const param of params) {
316
+ if (!param || typeof param !== "object" || Array.isArray(param)) {
317
+ throw new Error(
318
+ `ruact server-function codegen: v2 query entry "${fn.js_identifier}" has a params ` +
319
+ `element that is not an object: ${JSON.stringify(param)}; snapshot JSON is corrupted.`,
320
+ );
321
+ }
322
+ if (typeof param.name !== "string" || param.name.length === 0 || containsLineTerminator(param.name)) {
323
+ throw new Error(
324
+ `ruact server-function codegen: v2 query entry "${fn.js_identifier}" has a params ` +
325
+ `name ${JSON.stringify(param.name)} that is not a non-empty single-line string; ` +
326
+ "snapshot JSON is corrupted.",
327
+ );
328
+ }
329
+ if (typeof param.required !== "boolean") {
330
+ throw new Error(
331
+ `ruact server-function codegen: v2 query entry "${fn.js_identifier}" params name ` +
332
+ `${JSON.stringify(param.name)} has a non-boolean \`required\` ${JSON.stringify(param.required)}; ` +
333
+ "snapshot JSON is corrupted.",
334
+ );
335
+ }
371
336
  }
372
337
  }
373
338
 
@@ -436,9 +401,7 @@ function renderExportV2(fn) {
436
401
  // signature accepts params only when the query method declares kwargs (FR88).
437
402
  // Mirrors `Ruact::ServerFunctions::Codegen::V2.render_query_export`.
438
403
  function renderQueryExportV2(fn) {
439
- const signature = fn.accepts_params
440
- ? "(params: Record<string, unknown>) => Promise<unknown>"
441
- : "() => Promise<unknown>";
404
+ const signature = querySignatureV2(fn);
442
405
  const pathLit = JSON.stringify(String(fn.path));
443
406
  const descriptor = `{ path: ${pathLit}, kind: "query" }`;
444
407
  return (
@@ -447,26 +410,38 @@ function renderQueryExportV2(fn) {
447
410
  );
448
411
  }
449
412
 
450
- function renderExport(fn) {
451
- // Story 8.2 (refined 2026-05-17 per review patch R1) action signature
452
- // is an intersection of two call signatures so `<form action={fn}>`
453
- // typechecks DIRECTLY against React 19's `(formData: FormData) =>
454
- // void | Promise<void>` while direct callers keep the `Promise<unknown>`
455
- // return surface for `await createPost(...)`. Mirrors
456
- // `Ruact::ServerFunctions::Codegen::ACTION_SIGNATURE` byte-for-byte.
457
- const signature =
458
- fn.kind === "query"
459
- ? "() => Promise<unknown>"
460
- : "((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>)";
461
- // JSON.stringify produces a JSON string literal — escaping backslashes,
462
- // double quotes, and control characters — so an arbitrary `ruby_symbol`
463
- // (from a corrupted or hand-edited snapshot) cannot break out of the
464
- // `_makeRef("<here>")` argument.
465
- const rubySymLiteral = JSON.stringify(String(fn.ruby_symbol));
466
- return (
467
- `export const ${fn.js_identifier}: ${signature} =\n` +
468
- ` _makeRef(${rubySymLiteral});\n`
413
+ // Story 13.4 — picks the query accessor's `params` signature. With structured
414
+ // per-kwarg metadata (`fn.params`), build a typed object literal (required +
415
+ // optional exact, value type the FR88 wire union); empty params + no rest → the
416
+ // bare `()` signature; a `**keyrest` keeps the open `Record<string, unknown>`
417
+ // (intersected with any named keys). Falls back to the pre-13.4 `accepts_params`
418
+ // boolean when no `params` metadata is present. MUST mirror Ruby `query_signature`.
419
+ function querySignatureV2(fn) {
420
+ const params = fn.params;
421
+ if (!Array.isArray(params)) {
422
+ return fn.accepts_params
423
+ ? "(params: Record<string, unknown>) => Promise<unknown>"
424
+ : "() => Promise<unknown>";
425
+ }
426
+ const rest = Boolean(fn.params_rest);
427
+ if (params.length === 0 && !rest) return "() => Promise<unknown>";
428
+ return buildParamsSignatureV2(params, rest);
429
+ }
430
+
431
+ function buildParamsSignatureV2(params, rest) {
432
+ const props = params.map(
433
+ (p) => `${formatParamKey(String(p.name))}${p.required ? "" : "?"}: ${QUERY_PARAM_VALUE_TYPE}`,
469
434
  );
435
+ if (props.length === 0) return "(params: Record<string, unknown>) => Promise<unknown>"; // keyrest-only
436
+ let object = `{ ${props.join("; ")} }`;
437
+ if (rest) object += " & Record<string, unknown>";
438
+ return `(params: ${object}) => Promise<unknown>`;
439
+ }
440
+
441
+ // Quote any param key that is not a valid bare TS identifier (so a corrupted
442
+ // snapshot cannot break out of the object literal). Mirrors Ruby `format_param_key`.
443
+ function formatParamKey(name) {
444
+ return VALID_JS_IDENTIFIER.test(name) ? name : JSON.stringify(name);
470
445
  }
471
446
 
472
447
  /**