ruact 0.0.3 → 0.0.4
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2 -0
- data/docs/internal/decisions/server-functions-api.md +99 -0
- data/lib/ruact/errors.rb +11 -0
- data/lib/ruact/server_functions/codegen.rb +14 -0
- data/lib/ruact/server_functions/codegen_v2.rb +46 -10
- data/lib/ruact/server_functions/error_rendering.rb +2 -0
- data/lib/ruact/server_functions/name_bridge.rb +11 -6
- data/lib/ruact/server_functions/query_dispatch.rb +73 -8
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions.rb +38 -2
- data/lib/ruact/version.rb +1 -1
- data/spec/ruact/query_request_spec.rb +154 -2
- data/spec/ruact/server_functions/codegen_spec.rb +82 -3
- data/spec/ruact/server_functions/name_bridge_spec.rb +24 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +67 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +34 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +176 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +9 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +56 -13
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +95 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +7 -1
- metadata +4 -1
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// import path are part of the locked API — do NOT change without coordinated
|
|
17
17
|
// codegen + Vite-plugin updates.
|
|
18
18
|
|
|
19
|
+
// Story 9.5 — `useQuery` is a React hook, so the runtime now depends on React.
|
|
20
|
+
// React is declared as a `peerDependency` (every host already has it; the
|
|
21
|
+
// runtime never bundles its own copy). This is the runtime's first React
|
|
22
|
+
// import — the mutation path (`_makeRef` / `_makeServerFunction`) stays
|
|
23
|
+
// React-free.
|
|
24
|
+
import { useState, useEffect } from "react";
|
|
25
|
+
|
|
19
26
|
const RUNTIME_VERSION = 1;
|
|
20
27
|
|
|
21
28
|
// Re-run-5 (2026-05-15) — module-level runtime configuration. Hosts
|
|
@@ -149,6 +156,91 @@ export function _makeServerFunction(descriptor) {
|
|
|
149
156
|
};
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Story 9.5 — the read-side (query) accessor. The codegen emits
|
|
161
|
+
* `_makeQuery({ path, kind: "query" })` for every method on a mounted
|
|
162
|
+
* `Ruact::Query` subclass. The returned callable issues a **GET** to the
|
|
163
|
+
* named query route (`GET /q/<jsId>`), serializing `params` into the query
|
|
164
|
+
* string.
|
|
165
|
+
*
|
|
166
|
+
* Reads are CSRF-free (NFR27 / the 2026-06-02 ADR addendum voids the old
|
|
167
|
+
* `POST /__ruact/fn/:id` query mechanism and restores HTTP GET semantics):
|
|
168
|
+
* no request body, no `X-CSRF-Token`. It shares `parseResponse` /
|
|
169
|
+
* `RuactActionError` / `redirect: "error"` with the mutation path so the
|
|
170
|
+
* success + failure shapes are identical.
|
|
171
|
+
*
|
|
172
|
+
* Usually consumed through {useQuery}, but the returned function is a plain
|
|
173
|
+
* `(params?) => Promise<unknown>` so it also works in imperative code.
|
|
174
|
+
*
|
|
175
|
+
* FR88 wire format: only primitives (string / number / boolean / null) are
|
|
176
|
+
* encoded into the query string; arrays and objects throw a `TypeError`
|
|
177
|
+
* client-side too — though the server-side sanitization is authoritative.
|
|
178
|
+
*
|
|
179
|
+
* @param {{ path: string, kind?: string }} descriptor
|
|
180
|
+
* @returns {(params?: Record<string, unknown>) => Promise<unknown>}
|
|
181
|
+
*/
|
|
182
|
+
export function _makeQuery(descriptor) {
|
|
183
|
+
const { path } = descriptor || {};
|
|
184
|
+
return function ruactQueryCall(params) {
|
|
185
|
+
const url = buildQueryUrl(path, params);
|
|
186
|
+
return ruactQueryGet(url, path);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Story 9.5 — React hook for reading a server query. Pass a query reference
|
|
192
|
+
* (the codegen-emitted `_makeQuery` accessor) and optional params; the hook
|
|
193
|
+
* issues `GET /q/<jsId>` on mount (and whenever the serialized params change)
|
|
194
|
+
* and returns `{ data, loading, error }`:
|
|
195
|
+
*
|
|
196
|
+
* - `loading` is true until the first resolution;
|
|
197
|
+
* - `data` carries the JSON-decoded response on success (and the last
|
|
198
|
+
* successful value while a subsequent refetch is in flight);
|
|
199
|
+
* - `error` carries the structured `RuactActionError` (or a transport
|
|
200
|
+
* `Error`) on failure, and is reset to `null` on a successful refetch.
|
|
201
|
+
*
|
|
202
|
+
* A superseded in-flight response (params changed, or the component
|
|
203
|
+
* unmounted) is dropped — the hook never sets state for a stale request.
|
|
204
|
+
*
|
|
205
|
+
* Request de-duplication across components is Story 9.6; this hook fetches
|
|
206
|
+
* once per mount.
|
|
207
|
+
*
|
|
208
|
+
* @param {(params?: Record<string, unknown>) => Promise<unknown>} reference
|
|
209
|
+
* @param {Record<string, unknown>} [params]
|
|
210
|
+
* @returns {{ data: unknown, loading: boolean, error: unknown }}
|
|
211
|
+
*/
|
|
212
|
+
export function useQuery(reference, params) {
|
|
213
|
+
const [state, setState] = useState({ data: undefined, loading: true, error: null });
|
|
214
|
+
|
|
215
|
+
// Re-run the effect by VALUE, not identity: an inline `{ q: input }` literal
|
|
216
|
+
// is a fresh object every render, which would refetch on every render if used
|
|
217
|
+
// directly as a dependency. Serializing collapses equal params to one key.
|
|
218
|
+
const paramsKey = params == null ? "" : JSON.stringify(params);
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
let active = true;
|
|
222
|
+
setState((prev) => ({ data: prev.data, loading: true, error: null }));
|
|
223
|
+
// Wrap in Promise.resolve so a synchronous throw inside `reference`
|
|
224
|
+
// (e.g. a non-primitive param rejected by `buildQueryUrl`) lands on the
|
|
225
|
+
// error branch instead of escaping the effect.
|
|
226
|
+
Promise.resolve()
|
|
227
|
+
.then(() => reference(params))
|
|
228
|
+
.then((data) => {
|
|
229
|
+
if (active) setState({ data, loading: false, error: null });
|
|
230
|
+
})
|
|
231
|
+
.catch((error) => {
|
|
232
|
+
if (active) setState((prev) => ({ data: prev.data, loading: false, error }));
|
|
233
|
+
});
|
|
234
|
+
return () => {
|
|
235
|
+
active = false;
|
|
236
|
+
};
|
|
237
|
+
// `params` is intentionally tracked via the serialized `paramsKey`.
|
|
238
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
239
|
+
}, [reference, paramsKey]);
|
|
240
|
+
|
|
241
|
+
return state;
|
|
242
|
+
}
|
|
243
|
+
|
|
152
244
|
// Story 8.2 — picks the argument the wire request should serialize from
|
|
153
245
|
// `_makeRef`'s call-args, following the rules documented in the JSDoc
|
|
154
246
|
// above. Exported through `__internals` for the vitest suite (AC10) — it
|
|
@@ -218,6 +310,8 @@ export const __internals = {
|
|
|
218
310
|
pickWirePayload,
|
|
219
311
|
interpolatePath,
|
|
220
312
|
followRedirectIfPresent,
|
|
313
|
+
buildQueryUrl,
|
|
314
|
+
buildQueryFetchInit,
|
|
221
315
|
};
|
|
222
316
|
|
|
223
317
|
// v1 (Story 8.1) — POST to the synthetic endpoint. A thin wrapper over the
|
|
@@ -265,6 +359,88 @@ async function ruactInvoke({ method, url, args, label }) {
|
|
|
265
359
|
return parseResponse(response);
|
|
266
360
|
}
|
|
267
361
|
|
|
362
|
+
// Story 9.5 — the GET fetch core for queries. Mirrors `ruactInvoke`'s
|
|
363
|
+
// structure (loud transport error, structured `RuactActionError` on !ok,
|
|
364
|
+
// shared `parseResponse`) but for a read: GET, no body, no CSRF.
|
|
365
|
+
async function ruactQueryGet(url, label) {
|
|
366
|
+
let response;
|
|
367
|
+
try {
|
|
368
|
+
response = await fetch(url, buildQueryFetchInit());
|
|
369
|
+
} catch (err) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`ruact query ${label} request failed: ${err?.message ?? err}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (!response.ok) {
|
|
375
|
+
const body = await safeParseBody(response);
|
|
376
|
+
throw new RuactActionError({ name: label, status: response.status, body, response });
|
|
377
|
+
}
|
|
378
|
+
return parseResponse(response);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Story 9.5 — serialize the params object into the GET query string. FR88
|
|
382
|
+
// wire format: only primitives (string / number / boolean / null) are
|
|
383
|
+
// encoded. Arrays and objects throw a `TypeError` client-side for immediate
|
|
384
|
+
// local feedback; the server-side sanitization in `query_dispatch.rb` is the
|
|
385
|
+
// authoritative gate. `null` is encoded as an empty value (`key=`).
|
|
386
|
+
function buildQueryUrl(path, params) {
|
|
387
|
+
if (params == null) return path;
|
|
388
|
+
if (typeof params !== "object" || Array.isArray(params)) {
|
|
389
|
+
throw new TypeError(
|
|
390
|
+
`ruact useQuery for ${path}: params must be a plain object of ` +
|
|
391
|
+
"string / number / boolean / null values",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const search = new URLSearchParams();
|
|
395
|
+
// `null` is sent as a BARE key (`?q`, no `=`) — Rack parses that as `nil`,
|
|
396
|
+
// whereas `?q=` parses as `""`. This keeps `null` distinguishable from the
|
|
397
|
+
// empty string at the Ruby query-method boundary (the server allowlist
|
|
398
|
+
// accepts both `nil` and `String`). Bare keys are collected separately
|
|
399
|
+
// because `URLSearchParams` always emits `key=`; their order relative to
|
|
400
|
+
// the `=`-valued params is irrelevant server-side.
|
|
401
|
+
const bareKeys = [];
|
|
402
|
+
for (const [key, value] of Object.entries(params)) {
|
|
403
|
+
if (value === undefined) continue;
|
|
404
|
+
if (value === null) {
|
|
405
|
+
bareKeys.push(encodeURIComponent(key));
|
|
406
|
+
} else if (
|
|
407
|
+
typeof value === "string" ||
|
|
408
|
+
typeof value === "number" ||
|
|
409
|
+
typeof value === "boolean"
|
|
410
|
+
) {
|
|
411
|
+
search.append(key, String(value));
|
|
412
|
+
} else {
|
|
413
|
+
throw new TypeError(
|
|
414
|
+
`ruact useQuery for ${path}: param "${key}" must be a string, number, ` +
|
|
415
|
+
"boolean, or null — arrays and objects are rejected",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const qs = [search.toString(), ...bareKeys].filter((s) => s.length > 0).join("&");
|
|
420
|
+
return qs.length === 0 ? path : `${path}?${qs}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Story 9.5 — fetch init for a query GET. No body, no `X-CSRF-Token` (reads
|
|
424
|
+
// are CSRF-free). `defaultHeaders` still apply (e.g. an API-mode
|
|
425
|
+
// `Authorization: Bearer …`), with the gem's own `Accept` winning. Keeps
|
|
426
|
+
// `redirect: "error"` so an auth `redirect_to "/login"` surfaces as a loud
|
|
427
|
+
// failure rather than resolving with the login page HTML.
|
|
428
|
+
function buildQueryFetchInit() {
|
|
429
|
+
const RESERVED = new Set(["accept"]);
|
|
430
|
+
const extra =
|
|
431
|
+
typeof runtimeOptions.defaultHeaders === "function"
|
|
432
|
+
? runtimeOptions.defaultHeaders()
|
|
433
|
+
: runtimeOptions.defaultHeaders;
|
|
434
|
+
const headers = {};
|
|
435
|
+
if (extra && typeof extra === "object") {
|
|
436
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
437
|
+
if (!RESERVED.has(key.toLowerCase())) headers[key] = value;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
headers.Accept = "application/json";
|
|
441
|
+
return { method: "GET", credentials: "same-origin", redirect: "error", headers };
|
|
442
|
+
}
|
|
443
|
+
|
|
268
444
|
// Story 9.3 (D7) — substitute dynamic path segments (`:id`, …) from the single
|
|
269
445
|
// call argument. Values are read BY NAME (FormData.get / object property); the
|
|
270
446
|
// argument is still sent as the body. A missing required segment fails loudly
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruact-server-functions-runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2). Provides `_makeRef(name)` (
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2 + 9.5). Provides `_makeRef(name)` / `_makeServerFunction(descriptor)` (mutations: POST/PUT/PATCH/DELETE with CSRF + JSON / FormData), `revalidate(path?)` (Flight refetch), and `_makeQuery(descriptor)` + `useQuery(ref, params?)` (reads: GET /q/<jsId>, CSRF-free, FR88 primitive params → { data, loading, error }).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"test": "vitest run"
|
|
18
18
|
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18"
|
|
21
|
+
},
|
|
19
22
|
"devDependencies": {
|
|
23
|
+
"@testing-library/react": "^16.1.0",
|
|
24
|
+
"jsdom": "^25.0.1",
|
|
25
|
+
"react": "^19.0.0",
|
|
26
|
+
"react-dom": "^19.0.0",
|
|
20
27
|
"vitest": "^2.1.9"
|
|
21
28
|
}
|
|
22
29
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// Story 9.5 — vitest coverage for the read-side runtime: the `useQuery` React
|
|
4
|
+
// hook (loading/data/error transitions, param passing, value-stable refetch)
|
|
5
|
+
// and the `_makeQuery` GET wire format (query-string encoding, FR88 primitive
|
|
6
|
+
// allowlist, CSRF-free init). Runs under jsdom so `@testing-library/react`'s
|
|
7
|
+
// `renderHook` can drive the hook through React's effect lifecycle.
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
11
|
+
|
|
12
|
+
import { useQuery, _makeQuery, RuactActionError, __internals } from "./index.js";
|
|
13
|
+
|
|
14
|
+
let originalFetch;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
originalFetch = globalThis.fetch;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
globalThis.fetch = originalFetch;
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function mockFetchOk(jsonBody, { status = 200, contentType = "application/json" } = {}) {
|
|
26
|
+
const response = {
|
|
27
|
+
ok: true,
|
|
28
|
+
status,
|
|
29
|
+
headers: { get: (n) => (n.toLowerCase() === "content-type" ? contentType : null) },
|
|
30
|
+
text: vi.fn().mockResolvedValue(typeof jsonBody === "string" ? jsonBody : JSON.stringify(jsonBody)),
|
|
31
|
+
};
|
|
32
|
+
globalThis.fetch = vi.fn().mockResolvedValue(response);
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mockFetchError(status, bodyText) {
|
|
37
|
+
const response = {
|
|
38
|
+
ok: false,
|
|
39
|
+
status,
|
|
40
|
+
headers: { get: () => "text/plain" },
|
|
41
|
+
text: vi.fn().mockResolvedValue(bodyText),
|
|
42
|
+
};
|
|
43
|
+
globalThis.fetch = vi.fn().mockResolvedValue(response);
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("Story 9.5 — useQuery hook contract", () => {
|
|
48
|
+
it("starts loading, then resolves to { data, loading: false, error: null }", async () => {
|
|
49
|
+
const ref = vi.fn().mockResolvedValue({ items: [1, 2] });
|
|
50
|
+
const { result } = renderHook(() => useQuery(ref));
|
|
51
|
+
|
|
52
|
+
expect(result.current.loading).toBe(true);
|
|
53
|
+
expect(result.current.data).toBeUndefined();
|
|
54
|
+
|
|
55
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
56
|
+
expect(result.current.data).toEqual({ items: [1, 2] });
|
|
57
|
+
expect(result.current.error).toBe(null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("transitions loading → error on a rejected reference", async () => {
|
|
61
|
+
const err = new RuactActionError({ name: "/q/categories", status: 500, body: "boom", response: {} });
|
|
62
|
+
const ref = vi.fn().mockRejectedValue(err);
|
|
63
|
+
const { result } = renderHook(() => useQuery(ref));
|
|
64
|
+
|
|
65
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
66
|
+
expect(result.current.error).toBe(err);
|
|
67
|
+
expect(result.current.data).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("passes params to the query reference", async () => {
|
|
71
|
+
const ref = vi.fn().mockResolvedValue("ok");
|
|
72
|
+
const params = { q: "ruby", limit: 5 };
|
|
73
|
+
renderHook(() => useQuery(ref, params));
|
|
74
|
+
|
|
75
|
+
await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
|
|
76
|
+
expect(ref).toHaveBeenCalledWith(params);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not refetch when params are value-equal across renders", async () => {
|
|
80
|
+
const ref = vi.fn().mockResolvedValue("ok");
|
|
81
|
+
const { rerender } = renderHook(({ p }) => useQuery(ref, p), {
|
|
82
|
+
initialProps: { p: { q: "a" } },
|
|
83
|
+
});
|
|
84
|
+
await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
|
|
85
|
+
|
|
86
|
+
rerender({ p: { q: "a" } }); // fresh object literal, identical value
|
|
87
|
+
await Promise.resolve();
|
|
88
|
+
expect(ref).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("refetches when params change by value", async () => {
|
|
92
|
+
const ref = vi.fn().mockResolvedValue("ok");
|
|
93
|
+
const { rerender } = renderHook(({ p }) => useQuery(ref, p), {
|
|
94
|
+
initialProps: { p: { q: "a" } },
|
|
95
|
+
});
|
|
96
|
+
await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
|
|
97
|
+
|
|
98
|
+
rerender({ p: { q: "b" } });
|
|
99
|
+
await waitFor(() => expect(ref).toHaveBeenCalledTimes(2));
|
|
100
|
+
expect(ref).toHaveBeenLastCalledWith({ q: "b" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("surfaces a synchronous throw from the reference as an error (not an unhandled rejection)", async () => {
|
|
104
|
+
const ref = () => {
|
|
105
|
+
throw new TypeError("params must be a plain object");
|
|
106
|
+
};
|
|
107
|
+
const { result } = renderHook(() => useQuery(ref, [1, 2]));
|
|
108
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
109
|
+
expect(result.current.error).toBeInstanceOf(TypeError);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("Story 9.5 — useQuery against _makeQuery end-to-end (GET wire)", () => {
|
|
114
|
+
it("issues GET /q/<id>?<params> and resolves the JSON body through the hook", async () => {
|
|
115
|
+
mockFetchOk([{ value: 1, label: "Books" }]);
|
|
116
|
+
const categories = _makeQuery({ path: "/q/categories", kind: "query" });
|
|
117
|
+
|
|
118
|
+
const { result } = renderHook(() => useQuery(categories, { q: "bo" }));
|
|
119
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
120
|
+
|
|
121
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
122
|
+
const [url, init] = globalThis.fetch.mock.calls[0];
|
|
123
|
+
expect(url).toBe("/q/categories?q=bo");
|
|
124
|
+
expect(init.method).toBe("GET");
|
|
125
|
+
expect(result.current.data).toEqual([{ value: 1, label: "Books" }]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("carries the structured RuactActionError into the hook's error on a 4xx", async () => {
|
|
129
|
+
mockFetchError(400, "bad");
|
|
130
|
+
const search = _makeQuery({ path: "/q/search", kind: "query" });
|
|
131
|
+
|
|
132
|
+
const { result } = renderHook(() => useQuery(search));
|
|
133
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
134
|
+
|
|
135
|
+
expect(result.current.error).toBeInstanceOf(RuactActionError);
|
|
136
|
+
expect(result.current.error.status).toBe(400);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("Story 9.5 — _makeQuery / buildQueryUrl wire format (FR88)", () => {
|
|
141
|
+
const { buildQueryUrl, buildQueryFetchInit } = __internals;
|
|
142
|
+
|
|
143
|
+
it("omits the query string entirely when no params are given", () => {
|
|
144
|
+
expect(buildQueryUrl("/q/categories", undefined)).toBe("/q/categories");
|
|
145
|
+
expect(buildQueryUrl("/q/categories", null)).toBe("/q/categories");
|
|
146
|
+
expect(buildQueryUrl("/q/categories", {})).toBe("/q/categories");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("encodes string / number / boolean primitives", () => {
|
|
150
|
+
expect(buildQueryUrl("/q/search", { q: "a b", limit: 5, active: true })).toBe(
|
|
151
|
+
"/q/search?q=a+b&limit=5&active=true",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("encodes null as a BARE key (Rack parses `?q` as nil, distinct from `?q=` empty string)", () => {
|
|
156
|
+
expect(buildQueryUrl("/q/search", { q: null })).toBe("/q/search?q");
|
|
157
|
+
// value-bearing params keep `key=value`; a null alongside is a bare key
|
|
158
|
+
expect(buildQueryUrl("/q/search", { limit: 5, q: null })).toBe("/q/search?limit=5&q");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects an array value (FR88 — arrays are not primitives)", () => {
|
|
162
|
+
expect(() => buildQueryUrl("/q/search", { q: [1, 2] })).toThrow(/arrays and objects are rejected/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects an object value", () => {
|
|
166
|
+
expect(() => buildQueryUrl("/q/search", { q: { deep: 1 } })).toThrow(/arrays and objects are rejected/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("rejects a top-level array of params", () => {
|
|
170
|
+
expect(() => buildQueryUrl("/q/search", [1, 2])).toThrow(/plain object/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("builds a GET init with Accept JSON, no body, no CSRF, redirect: error", () => {
|
|
174
|
+
const init = buildQueryFetchInit();
|
|
175
|
+
expect(init.method).toBe("GET");
|
|
176
|
+
expect(init.body).toBeUndefined();
|
|
177
|
+
expect(init.headers.Accept).toBe("application/json");
|
|
178
|
+
expect(init.headers["X-CSRF-Token"]).toBeUndefined();
|
|
179
|
+
expect(init.redirect).toBe("error");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -49,12 +49,18 @@ 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:
|
|
53
|
-
//
|
|
54
|
-
// declared
|
|
55
|
-
//
|
|
56
|
-
// `
|
|
57
|
-
const RESERVED_BY_RUACT = new Set([
|
|
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`.
|
|
57
|
+
const RESERVED_BY_RUACT = new Set([
|
|
58
|
+
"_makeQuery",
|
|
59
|
+
"_makeRef",
|
|
60
|
+
"_makeServerFunction",
|
|
61
|
+
"revalidate",
|
|
62
|
+
"useQuery",
|
|
63
|
+
]);
|
|
58
64
|
|
|
59
65
|
// Story 9.3 — the route-driven snapshot schema version + its verb allowlist.
|
|
60
66
|
// A version-2 snapshot renders `_makeServerFunction({...})` calls; `render`
|
|
@@ -62,6 +68,10 @@ const RESERVED_BY_RUACT = new Set(["_makeRef", "_makeServerFunction", "revalidat
|
|
|
62
68
|
// Mirrors Ruby `Codegen::VERSION_V2` / `V2_HTTP_METHODS`.
|
|
63
69
|
export const VERSION_V2 = 2;
|
|
64
70
|
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`.
|
|
74
|
+
const V2_QUERY_HTTP_METHODS = new Set(["GET"]);
|
|
65
75
|
|
|
66
76
|
/**
|
|
67
77
|
* Absolute path to the placeholder runtime bundled inside the gem. Used as the
|
|
@@ -305,16 +315,18 @@ function validateSnapshotV2(snapshot) {
|
|
|
305
315
|
}
|
|
306
316
|
seen.add(fn.js_identifier);
|
|
307
317
|
|
|
308
|
-
if (fn.kind !== "action") {
|
|
318
|
+
if (fn.kind !== "action" && fn.kind !== "query") {
|
|
309
319
|
throw new Error(
|
|
310
320
|
`ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" has invalid ` +
|
|
311
|
-
`kind ${JSON.stringify(fn.kind)} (v2 entries are
|
|
321
|
+
`kind ${JSON.stringify(fn.kind)} (v2 entries are "action" or "query").`,
|
|
312
322
|
);
|
|
313
323
|
}
|
|
314
|
-
|
|
324
|
+
// Story 9.5 — actions carry a mutation verb; queries are GET-only.
|
|
325
|
+
const allowedMethods = fn.kind === "query" ? V2_QUERY_HTTP_METHODS : V2_HTTP_METHODS;
|
|
326
|
+
if (!allowedMethods.has(fn.http_method)) {
|
|
315
327
|
throw new Error(
|
|
316
328
|
`ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" has invalid ` +
|
|
317
|
-
`http_method ${JSON.stringify(fn.http_method)} (must be
|
|
329
|
+
`http_method ${JSON.stringify(fn.http_method)} (must be one of ${JSON.stringify([...allowedMethods])}).`,
|
|
318
330
|
);
|
|
319
331
|
}
|
|
320
332
|
if (typeof fn.path !== "string" || !fn.path.startsWith("/")) {
|
|
@@ -369,11 +381,21 @@ function validateSnapshotV2(snapshot) {
|
|
|
369
381
|
function renderV2(snapshot) {
|
|
370
382
|
validateSnapshotV2(snapshot);
|
|
371
383
|
const { version, generated_at, functions } = snapshot;
|
|
384
|
+
|
|
385
|
+
const hasQuery = functions.some((fn) => fn.kind === "query");
|
|
386
|
+
const hasAction = functions.some((fn) => fn.kind === "action");
|
|
387
|
+
|
|
388
|
+
// Story 9.5 — import only what is used. Keep `_makeServerFunction` in the
|
|
389
|
+
// empty case so the no-functions module stays byte-identical to Story 9.3.
|
|
390
|
+
const imports = [];
|
|
391
|
+
if (hasAction || functions.length === 0) imports.push("_makeServerFunction");
|
|
392
|
+
if (hasQuery) imports.push("_makeQuery");
|
|
393
|
+
|
|
372
394
|
let out = "";
|
|
373
395
|
out += "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n";
|
|
374
396
|
out += `// Source: Rails route table (version ${version})\n`;
|
|
375
397
|
out += `// Generated at: ${generated_at}\n`;
|
|
376
|
-
out += `import {
|
|
398
|
+
out += `import { ${imports.join(", ")} } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
|
|
377
399
|
|
|
378
400
|
if (functions.length === 0) {
|
|
379
401
|
out += "\n// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n";
|
|
@@ -386,12 +408,17 @@ function renderV2(snapshot) {
|
|
|
386
408
|
}
|
|
387
409
|
out += "\n";
|
|
388
410
|
out += `export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
|
|
411
|
+
// Story 9.5 — `useQuery` re-export ONLY when queries are present (keeps the
|
|
412
|
+
// action-only / empty modules byte-identical to Story 9.3).
|
|
413
|
+
if (hasQuery) out += `export { useQuery } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
|
|
389
414
|
return out;
|
|
390
415
|
}
|
|
391
416
|
|
|
392
417
|
function renderExportV2(fn) {
|
|
393
|
-
|
|
394
|
-
|
|
418
|
+
if (fn.kind === "query") return renderQueryExportV2(fn);
|
|
419
|
+
|
|
420
|
+
// The same intersection signature as v1 actions (Story 8.2). Mirrors
|
|
421
|
+
// `Ruact::ServerFunctions::Codegen::ACTION_SIGNATURE`.
|
|
395
422
|
const signature =
|
|
396
423
|
"((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>)";
|
|
397
424
|
const method = JSON.stringify(String(fn.http_method));
|
|
@@ -404,6 +431,22 @@ function renderExportV2(fn) {
|
|
|
404
431
|
);
|
|
405
432
|
}
|
|
406
433
|
|
|
434
|
+
// Story 9.5 — a query export binds a `_makeQuery` accessor carrying its GET
|
|
435
|
+
// descriptor `{ path, kind: "query" }`; `useQuery(<id>, …)` consumes it. The
|
|
436
|
+
// signature accepts params only when the query method declares kwargs (FR88).
|
|
437
|
+
// Mirrors `Ruact::ServerFunctions::Codegen::V2.render_query_export`.
|
|
438
|
+
function renderQueryExportV2(fn) {
|
|
439
|
+
const signature = fn.accepts_params
|
|
440
|
+
? "(params: Record<string, unknown>) => Promise<unknown>"
|
|
441
|
+
: "() => Promise<unknown>";
|
|
442
|
+
const pathLit = JSON.stringify(String(fn.path));
|
|
443
|
+
const descriptor = `{ path: ${pathLit}, kind: "query" }`;
|
|
444
|
+
return (
|
|
445
|
+
`export const ${fn.js_identifier}: ${signature} =\n` +
|
|
446
|
+
` _makeQuery(${descriptor});\n`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
407
450
|
function renderExport(fn) {
|
|
408
451
|
// Story 8.2 (refined 2026-05-17 per review patch R1) — action signature
|
|
409
452
|
// is an intersection of two call signatures so `<form action={fn}>`
|
|
@@ -864,3 +864,98 @@ describe("Story 9.3 — route-driven (v2) render + parity", () => {
|
|
|
864
864
|
expect(jsOutput).toBe(rubyOutput);
|
|
865
865
|
});
|
|
866
866
|
});
|
|
867
|
+
|
|
868
|
+
describe("Story 9.5 — v2 query entries render + parity", () => {
|
|
869
|
+
const queryFixture = {
|
|
870
|
+
version: 2,
|
|
871
|
+
generated_at: "2026-06-10T00:00:00Z",
|
|
872
|
+
functions: [
|
|
873
|
+
{
|
|
874
|
+
js_identifier: "createPost",
|
|
875
|
+
kind: "action",
|
|
876
|
+
http_method: "POST",
|
|
877
|
+
path: "/posts",
|
|
878
|
+
segments: [],
|
|
879
|
+
controller: "posts",
|
|
880
|
+
action: "create",
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
js_identifier: "categories",
|
|
884
|
+
kind: "query",
|
|
885
|
+
http_method: "GET",
|
|
886
|
+
path: "/q/categories",
|
|
887
|
+
segments: [],
|
|
888
|
+
accepts_params: false,
|
|
889
|
+
controller: "CatalogQuery",
|
|
890
|
+
action: "categories",
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
js_identifier: "searchUsers",
|
|
894
|
+
kind: "query",
|
|
895
|
+
http_method: "GET",
|
|
896
|
+
path: "/q/searchUsers",
|
|
897
|
+
segments: [],
|
|
898
|
+
accepts_params: true,
|
|
899
|
+
controller: "CatalogQuery",
|
|
900
|
+
action: "search_users",
|
|
901
|
+
},
|
|
902
|
+
],
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
it("emits _makeQuery refs with GET descriptors + the useQuery re-export", () => {
|
|
906
|
+
const out = render(queryFixture);
|
|
907
|
+
expect(out).toContain('import { _makeServerFunction, _makeQuery } from "ruact/server-functions-runtime";');
|
|
908
|
+
expect(out).toContain(
|
|
909
|
+
'export const categories: () => Promise<unknown> =\n _makeQuery({ path: "/q/categories", kind: "query" });',
|
|
910
|
+
);
|
|
911
|
+
expect(out).toContain(
|
|
912
|
+
'export const searchUsers: (params: Record<string, unknown>) => Promise<unknown> =\n' +
|
|
913
|
+
' _makeQuery({ path: "/q/searchUsers", kind: "query" });',
|
|
914
|
+
);
|
|
915
|
+
expect(out).toContain('export { useQuery } from "ruact/server-functions-runtime";');
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("accepts GET for a query entry but rejects a non-GET verb", () => {
|
|
919
|
+
expect(() =>
|
|
920
|
+
render({
|
|
921
|
+
version: 2,
|
|
922
|
+
generated_at: "x",
|
|
923
|
+
functions: [
|
|
924
|
+
{ js_identifier: "categories", kind: "query", http_method: "POST", path: "/q/categories", segments: [] },
|
|
925
|
+
],
|
|
926
|
+
}),
|
|
927
|
+
).toThrow(/invalid.*http_method/i);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("rejects an unknown kind (action/query only)", () => {
|
|
931
|
+
expect(() =>
|
|
932
|
+
render({
|
|
933
|
+
version: 2,
|
|
934
|
+
generated_at: "x",
|
|
935
|
+
functions: [
|
|
936
|
+
{ js_identifier: "x", kind: "mutation", http_method: "GET", path: "/q/x", segments: [] },
|
|
937
|
+
],
|
|
938
|
+
}),
|
|
939
|
+
).toThrow(/"action" or "query"/);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("Ruby's render and JS's render produce byte-identical query output", () => {
|
|
943
|
+
const jsOutput = render(queryFixture);
|
|
944
|
+
const gemLibPath = path.resolve(
|
|
945
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
946
|
+
"..",
|
|
947
|
+
"..",
|
|
948
|
+
"..",
|
|
949
|
+
"lib",
|
|
950
|
+
);
|
|
951
|
+
const fixtureJson = JSON.stringify(queryFixture);
|
|
952
|
+
const script =
|
|
953
|
+
'require "ruact/server_functions/codegen"; ' +
|
|
954
|
+
'require "json"; ' +
|
|
955
|
+
`print Ruact::ServerFunctions::Codegen.render(JSON.parse('${fixtureJson}'))`;
|
|
956
|
+
const rubyOutput = execFileSync("ruby", ["-I", gemLibPath, "-e", script], {
|
|
957
|
+
encoding: "utf8",
|
|
958
|
+
});
|
|
959
|
+
expect(jsOutput).toBe(rubyOutput);
|
|
960
|
+
});
|
|
961
|
+
});
|
|
@@ -9,7 +9,13 @@ import { defineConfig } from "vitest/config";
|
|
|
9
9
|
export default defineConfig({
|
|
10
10
|
test: {
|
|
11
11
|
environment: "node",
|
|
12
|
-
|
|
12
|
+
// The runtime's `index.test.mjs` is node-environment + dependency-free, so
|
|
13
|
+
// it rides along here for a single parity-plus-runtime run. Its jsdom +
|
|
14
|
+
// React hook tests (`usequery.test.mjs`, Story 9.5) need
|
|
15
|
+
// `@testing-library/react` from the runtime package's own node_modules and
|
|
16
|
+
// run via that package's `npm test` — they are intentionally NOT globbed
|
|
17
|
+
// in here (a `*.test.mjs` glob would pull them in and fail to resolve).
|
|
18
|
+
include: ["**/*.test.mjs", "../ruact-server-functions-runtime/index.test.mjs"],
|
|
13
19
|
globals: false,
|
|
14
20
|
},
|
|
15
21
|
});
|