ruact 0.0.3 → 0.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce264ae503bd6e5c3239399e6bc39491724eec59b8f166a7e69923e40e2e0a10
4
- data.tar.gz: 6ae79fc064960c97e8345a9030778dc61489c1179ea70123aad28a0de0806bf3
3
+ metadata.gz: 2f8e264e2e404e5257d0fe7457cef545f4104288dc16db01e1c8e5762d361f99
4
+ data.tar.gz: 4a2ed748edb281b7d8bb0c20ce5e404db6ce7872f82c8db1545349ab709677d3
5
5
  SHA512:
6
- metadata.gz: 660f2dbc8b3783eef80a3f935aa39a4afa34f02190578ada873fdd3074fe4182e77e9d5c486cd220edc68d89c8ff04581ab412eacd0bf3115c481841a4df9049
7
- data.tar.gz: b4ddd9632c82d7a7d10e4113730bf4fedbd3b8b0007f917c4dc49fb880362e7b0b6a1bb7c2f953355d0926c39a893a7c377a693d0f8512330cfe226315e682cf
6
+ metadata.gz: 36eb5942d50d3761874bd912a9cfae5ad3538500b8e08a79b196310db8db66bc86fa8a6fc78262c8a2e73ba659feaa1dd821c344fe89ecbddf247c2e05924153
7
+ data.tar.gz: f8902075c57bb47e42f404d43a864829719bddc4f497371097f0c1576ef1565312b45e628f1d7a3e9a3811db497e1aa8325f8e935da4e7b4941ae2bdc468d72e
data/CHANGELOG.md CHANGED
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`useQuery` request de-duplication** ([Story 9.6](../_bmad-output/implementation-artifacts/9-6-automatic-request-deduplication-for-usequery.md)). Identical concurrent `useQuery` calls now share ONE network request: when three components mount `useQuery(categories)` with the same params while a request is in flight, exactly one `GET /q/categories` is issued — all of them receive the same resolved `data`, and an error propagates to every sharer. The dedup key derives from the **query reference identity + the serialized params**, with order-independent param serialization (`{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` share a request; different params do not). Scope is **in-flight only** — there is no TTL cache and no stale-while-revalidate: once a request settles its shared entry is dropped, so a fresh mount refetches. Runtime-only change (`useQuery` hook); the `_makeQuery` GET accessor, codegen, and the Ruby query dispatch are unchanged. Runtime package version `0.3.0` → `0.4.0`; coverage in `usequery.test.mjs`.
13
+
14
+ - **Queries in codegen + `useQuery` hook + FR88 kwargs sanitization** ([Story 9.5](../_bmad-output/implementation-artifacts/9-5-queries-in-codegen-usequery-re-pointed-kwargs-params.md)). The read-side of the route-driven redesign is complete: `import { categories, useQuery } from "@/.ruact/server-functions"` and `useQuery(categories)` / `useQuery(searchUsers, { q: input })` now work against `Ruact::Query` classes — same mental model as mutations, zero new concepts. **Codegen.** A new `Ruact::ServerFunctions::QuerySource` derives v2 query entries from the **drawn route table** (the GET routes the `ruact_queries` macro mounted under the generated query-dispatch namespace) — route-truth-consistent with dispatch, so only classes actually mounted in `routes.rb` are exposed (no over-exposure, and no `app/queries` force-load needed). Query entries join the **same merged JS namespace** as mutations; collisions fail loudly at boot naming both origins (route×query is caught at the codegen combine point; `query×query` by the source; the `ruact_function_name` rename macro on the mutation controller — or renaming the query method — is the escape hatch). The emitted module binds each query to `_makeQuery({ path, kind: "query" })` with a per-query TS signature (`() => Promise<unknown>` when the method declares no kwargs, `(params: Record<string, unknown>) => Promise<unknown>` when it does) and re-exports `useQuery` from the runtime (only when ≥1 query exists, so action-only/empty v2 modules stay byte-identical to Story 9.3). The Ruby↔JS byte-equality parity test covers query entries. **Runtime.** New `useQuery(reference, params?)` React hook → `{ data, loading, error }` (loading until first resolution; structured `RuactActionError` into `error`; superseded in-flight responses dropped; refetch on value-changed params); new `_makeQuery` GET helper issues `GET /q/<jsId>` with params in the query string — **no body, no CSRF** (reads are CSRF-free; the stale `POST /__ruact/fn/:id` query mechanism is voided). The runtime gains `react` as a **peerDependency** (its first React import; the mutation path stays React-free); package version `0.2.0` → `0.3.0`. **FR88 sanitization.** `query_dispatch.rb#__ruact_query_kwargs` now enforces the authoritative kwargs allowlist on `request.query_parameters`: only `string | number | boolean | null` are accepted — arrays (`?q[]=`) and objects (`?q[k]=`) are **rejected with a descriptive error naming the key and the allowlist**; a **missing required kwarg → 400** naming it; an **unknown param → 400** (rejected, not silently dropped). All raise the new `Ruact::BadRequestError` → HTTP 400 via the Story 8.4 structured payload. 9.5 writes ONLY the parallel `.next` codegen target (ownership flip is 9.8); `useQuery` request de-duplication is 9.6; the v1 substrate is untouched (demolition is 9.9). New files `lib/ruact/server_functions/query_source.rb`; new specs `query_source_spec.rb`, `:story_9_5` cases in `codegen_spec.rb` / `railtie_integration_spec.rb` / `query_request_spec.rb`; new runtime vitest `usequery.test.mjs` + query parity cases. ADR addendum (2026-06-10).
15
+
12
16
  - **`Ruact::Query` base class + `ruact_queries` route macro** ([Story 9.4](../_bmad-output/implementation-artifacts/9-4-ruact-query-base-class-and-ruact-queries-route-macro.md)). Server QUERIES are now plain classes under `app/queries/` (`class CatalogQuery < ApplicationQuery`, `ApplicationQuery < Ruact::Query`) — each public method defined directly on the subclass is one query — mounted with one line in `routes.rb`: `ruact_queries CatalogQuery` draws one **named GET route per public method** (`def search_users` → `GET /q/searchUsers`, named `ruact_query_searchUsers`), all visible in `rails routes` (no hidden endpoint; the route table stays the single source of truth). The prefix is configurable via the new `Ruact.config.query_route_prefix` (default `"/q"`). Dispatch goes through an internal gem controller — one generated subclass per query class — inheriting the new `Ruact.config.query_parent_controller` (default `"ApplicationController"`, constantized lazily at route-draw), so the host's REAL callback chain (`authenticate_user!`, tenant scoping, Pundit) runs **before** the query class is instantiated. The query instance is fresh per request and receives its context via the constructor: `current_user` / `params` / `request` / `session` delegate to the dispatching controller (so `current_user` IS the host's own method), and `CatalogQuery.new(fake_context).categories` is unit-testable with no Rails boot. Per-query callback opt-out via the new `ruact_skip_before_action` class macro (mirrors Rails' `skip_before_action` signature incl. `only:`/`except:`/`raise: false`; scoped to the declaring query class by construction). Queries are GET — no CSRF; the method's return value is serialized through the same `ruact_props` / `Ruact::Serializable` / `strict_serialization` policy as Bucket 2 (new `BucketTwoPayload.serialize_value` extraction; `nil` → JSON `null` with 200), and a query raise renders the salvaged structured-error payload with the same 422/403/413/500 mapping (the GET gate is overridden for query dispatch — `Ruact::ConfigurationError` still re-raises loudly). Keyword arguments are passed best-effort from GET query params by name (the strict FR88 sanitization wire contract ships with `useQuery` in Story 9.5; codegen of query entries is also 9.5). New files `lib/ruact/query.rb`, `lib/ruact/routing.rb`, `lib/ruact/server_functions/query_dispatch.rb`, `lib/ruact/server_functions/query_context.rb`. New specs `query_spec.rb`, `query_context_spec.rb`, `query_request_spec.rb` + extensions to `configuration_spec.rb` / `bucket_two_payload_spec.rb`, all tagged `:story_9_4`.
13
17
 
14
18
  - **Route-driven codegen for mutations + runtime re-target** ([Story 9.3](../_bmad-output/implementation-artifacts/9-3-route-driven-codegen-for-mutations-and-runtime-re-target.md)). The generated server-functions module is now derived from the **Rails route table** instead of the v1 registries: every non-GET routed action (POST/PUT/PATCH/DELETE) on a controller that `include Ruact::Server` becomes a callable server function — `resources :posts` is the only declaration, no `routes.rb` additions, no synthetic endpoint. New `Ruact::ServerFunctions::RouteSource` collects entries from the route set; the locked naming derivation table (recorded in the ADR) covers the RESTful writes (`posts#create` → `createPost`), custom member routes (`posts#publish` → `publishPost`), custom collection routes (`posts#publish_all` → `publishAllPosts`), singular resources (`resource :session` → `createSession`), and namespaced controllers with a **prefix** scheme (`admin/posts#create` → `createAdminPost`) so the merged JS namespace is collision-free by construction. Collisions fail loudly at boot naming both origins; the new `ruact_function_name :action, as: "jsId"` macro on `Ruact::Server` is the per-action rename escape hatch. The build always logs `[ruact] codegen: exposing …` (transparency over silence). The runtime gains `_makeServerFunction({ method, path, segments })` — it targets the real route + verb (e.g. `POST /posts`, `PUT /posts/:id`), interpolating `:id`-style path segments by name from the single FormData/object argument, and follows a Bucket-2 `{ "$redirect": "<path>" }` response client-side (via `globalThis.__ruact_navigate`, `window.location.assign` fallback) — the client half of the contract Story 9.2 emitted. The salvaged 8.1/8.2 behaviors (FormData branching, CSRF meta injection, text-first parsing, `RuactActionError`, `redirect: "error"`, the intersection action signature, `revalidate()`) are preserved via a shared `ruactInvoke` core; the v1 `_makeRef` accessor is byte-behavior-identical. The v2 codegen writes to a **parallel** target (`server-functions.next.{json,ts}`) for parity tests + inspection only — never imported by application code — so the v1 `server-functions.ts` is untouched (the per-app cutover to route-driven codegen is Story 9.8). The Ruby↔JS codegen byte-equality parity test is retained and extended for v2. New specs: `route_source_spec.rb`, `server_function_name_spec.rb`, v2 cases in `codegen_spec.rb` / `snapshot_spec.rb` / `railtie_integration_spec.rb` (tagged `:story_9_3`); +11 runtime vitest characterization tests; +5 codegen parity vitest cases.
@@ -1678,3 +1678,102 @@ model never reads or populates it.
1678
1678
  **Deferred (noted, not ACs here):** install-generator scaffolding for
1679
1679
  `app/queries/application_query.rb`; codegen + `useQuery` + FR88 kwargs (9.5);
1680
1680
  dedup (9.6); docs rewrite (9.7); playground migration (9.8).
1681
+
1682
+ ---
1683
+
1684
+ ## 2026-06-10 — Story 9.5 — Queries in codegen, `useQuery` re-pointed, kwargs sanitized
1685
+
1686
+ Closes the read-side of the route-driven redesign: queries now appear in the
1687
+ v2 codegen module, `useQuery` issues a real `GET /q/<jsId>`, and the FR88
1688
+ kwargs contract is enforced server-side. Append-only addendum to the
1689
+ 2026-06-02 (route-driven) and 2026-06-09 (Story 9.4) entries.
1690
+
1691
+ 1. **Query codegen source = the drawn route table (route-truth), not the set
1692
+ of `Ruact::Query` subclasses (AC1).** `Ruact::ServerFunctions::QuerySource`
1693
+ enumerates drawn GET routes whose controller path is under
1694
+ `ruact/server_functions/query_dispatch/` (the generated dispatch
1695
+ controllers from Story 9.4) and emits one v2 query entry each. This is the
1696
+ sibling of `RouteSource` (mutations) and was chosen over enumerating
1697
+ `Ruact::Query.subclasses` deliberately: a host exposes a query ONLY by
1698
+ mounting it with `ruact_queries` in `routes.rb`, so the route table is the
1699
+ single source of truth. Reading subclasses would over-expose query classes
1700
+ that are defined but never mounted (and `useQuery` would 404 on their
1701
+ non-existent routes). **Consequence:** the "force-load `app/queries/` at
1702
+ `config.to_prepare`" gap flagged in the story is *moot* — mounting a class
1703
+ in `routes.rb` already autoloads it, so no force-load was added (avoiding
1704
+ the over-exposure a blind force-load-then-enumerate would cause). `QuerySource`
1705
+ is pure: the route set + a `query_class_for` resolver are injected (the
1706
+ railtie passes the real `__ruact_query_class` lookup; specs inject a lambda).
1707
+
1708
+ 2. **Entry shape carries `accepts_params` for the TS signature (AC1).** A query
1709
+ entry is `{ js_identifier, kind: "query", http_method: "GET", path,
1710
+ segments: [], accepts_params, controller, action }`. `accepts_params` is
1711
+ true iff the query method declares any keyword argument
1712
+ (`:key`/`:keyreq`/`:keyrest`) and drives the emitted signature:
1713
+ `(params: Record<string, unknown>) => Promise<unknown>` when true,
1714
+ `() => Promise<unknown>` when false. `NameBridge.to_js_identifier` is reused
1715
+ verbatim (single source of truth for snake→camel), so the codegen jsId is
1716
+ byte-identical to the route the `ruact_queries` macro drew.
1717
+
1718
+ 3. **Merged JS namespace + collision detection (AC1, Task 2).** Action entries
1719
+ (`RouteSource`) and query entries (`QuerySource`) share ONE namespace.
1720
+ `RouteSource` rejects action×action and `QuerySource` rejects query×query
1721
+ intra-kind; `ServerFunctions.detect_merged_namespace_collisions!` (run at
1722
+ the `write_v2_snapshot!` combine point) rejects route×query. All three name
1723
+ BOTH origins (`posts#categories and CatalogQuery#categories`). **Escape
1724
+ hatch:** the `ruact_function_name :<action>, as: "<id>"` rename macro on the
1725
+ *mutation controller* (Story 9.3) resolves a route×query clash; a
1726
+ query×query clash is resolved by renaming a query method. The query side has
1727
+ no rename macro because `routing.rb#draw_query_routes` derives the jsId
1728
+ purely from the method name via `NameBridge` (kept read-only this story) —
1729
+ renaming the method is the single, consistent lever, and keeps the route the
1730
+ macro draws and the codegen jsId in lockstep by construction.
1731
+
1732
+ 4. **`useQuery` issues `GET /q/<jsId>` — the stale `POST /__ruact/fn/:id`
1733
+ mechanism is VOIDED (AC2).** The 2026-05-13 ADR clarification #5 ("hook reads
1734
+ `$$id`, POSTs `/__ruact/fn/:id`") is dead: Story 9.4 mounts queries as real
1735
+ named GET routes and the 2026-06-02 addendum restored HTTP GET semantics for
1736
+ reads (CSRF-free). The codegen emits `export const <id> = _makeQuery({ path,
1737
+ kind: "query" })`; `useQuery(reference, params?) → { data, loading, error }`
1738
+ invokes that reference inside a `useEffect`, tracking state and dropping a
1739
+ superseded in-flight response (params changed / unmounted). The reference is
1740
+ also directly callable for imperative use. `useQuery` and `_makeQuery` are
1741
+ re-exported from `@/.ruact/server-functions` by the codegen — `useQuery` only
1742
+ when the snapshot has ≥1 query entry, which keeps action-only and empty v2
1743
+ modules **byte-identical to Story 9.3** (minimal-churn import list:
1744
+ `_makeServerFunction` and/or `_makeQuery` are imported only when used).
1745
+
1746
+ 5. **`useQuery` wire format = primitives in the query string; server validation
1747
+ is authoritative (AC3, FR88).** The client serializes `params` into the GET
1748
+ query string: `string`/`number`/`boolean` → `key=value`, `null` → `key=`
1749
+ (empty), and arrays/objects throw a client-side `TypeError` for immediate
1750
+ feedback. The SERVER is the authority: `query_dispatch.rb#__ruact_query_kwargs`
1751
+ reads `request.query_parameters` (the raw client params — NOT `params`, which
1752
+ carries Rails' `controller`/`action`/`format` defaults that must not count as
1753
+ "unknown") and enforces, in order: (a) **allowlist** — a value Rack parsed as
1754
+ an Array (`?q[]=`) or Hash (`?q[k]=`) is rejected (a scalar arrives as a
1755
+ String, the only non-`nil` primitive on the wire); (b) **unknown param** — a
1756
+ key matching no declared kwarg (and no `**rest`) is rejected, not dropped;
1757
+ (c) **missing required** — a `keyreq` the client omitted. All three raise the
1758
+ new `Ruact::BadRequestError` (`< Ruact::Error`) → **HTTP 400** via a new case
1759
+ in `__ruact_status_for`, rendered through the same Story 8.4 structured
1760
+ payload (`_ruact_server_action_error: true`, `error_class`, `message` naming
1761
+ the offending key + allowlist). Values are delivered to the query method as
1762
+ Strings (GET semantics, unchanged from 9.4's best-effort) — FR88 governs the
1763
+ *shape* on the wire, not type coercion, which stays the method's concern.
1764
+
1765
+ 6. **React becomes a `peerDependency` of the runtime package.** `useQuery` is
1766
+ the runtime's first React import (`useState`/`useEffect`). React is declared
1767
+ as a `peerDependency` (`">=18"`) — every host already has it; the runtime
1768
+ never bundles its own copy. The mutation path (`_makeRef` /
1769
+ `_makeServerFunction`) stays React-free. Package version `0.2.0` → `0.3.0`.
1770
+ The hook is covered by jsdom + `@testing-library/react` vitest
1771
+ (`usequery.test.mjs`); those dev-only deps live in the runtime package and
1772
+ run via its own `npm test` (the vite-plugin parity run globs only the
1773
+ node-environment `index.test.mjs`).
1774
+
1775
+ 7. **Scope guards (unchanged from the story plan).** 9.5 writes ONLY the
1776
+ parallel `.next` codegen target (the ownership flip to the real
1777
+ `server-functions.ts` is 9.8). Request de-duplication for `useQuery` is 9.6
1778
+ (the hook fetches once per mount; a `useSyncExternalStore` refactor can layer
1779
+ dedup later). The v1 substrate is untouched (demolition is 9.9).
data/lib/ruact/errors.rb CHANGED
@@ -63,6 +63,17 @@ module Ruact
63
63
  end
64
64
  end
65
65
 
66
+ # Story 9.5 (FR88) — raised by the query dispatch controller when a
67
+ # `useQuery` request's parameters violate the kwargs allowlist: a complex
68
+ # value (array / object) where only `string | number | boolean | null` is
69
+ # accepted, a missing required keyword, or an unknown parameter that matches
70
+ # no declared kwarg. Inherits from `Ruact::Error` so the Story 8.4
71
+ # `rescue_from StandardError` chain on the dispatch controller catches it
72
+ # cleanly; `__ruact_status_for` maps it to HTTP 400 (Bad Request). The
73
+ # message names the offending key and the allowlist so the React dev can
74
+ # fix the call site without reading the server source.
75
+ class BadRequestError < Error; end
76
+
66
77
  # Story 8.5 — raised by `EndpointController#__ruact_enforce_upload_limit!`
67
78
  # when an inbound multipart / urlencoded request's `Content-Length` exceeds
68
79
  # `Ruact.config.max_upload_bytes`. The exception inherits from
@@ -62,12 +62,26 @@ module Ruact
62
62
  "& ((formData: FormData) => Promise<void>)"
63
63
  QUERY_SIGNATURE = "() => Promise<unknown>"
64
64
 
65
+ # Story 9.5 — a query method that declares keyword arguments (FR88
66
+ # params) gets the param-accepting signature; one with no kwargs keeps
67
+ # the bare {QUERY_SIGNATURE}. Queries are read-only (never reachable via
68
+ # `<form action>`), so neither widens to the action intersection.
69
+ QUERY_PARAMS_SIGNATURE = "(params: Record<string, unknown>) => Promise<unknown>"
70
+
65
71
  # Story 8.2 — fixed re-export appended AFTER the per-function block.
66
72
  # Emitted in BOTH branches (empty + populated registry) so
67
73
  # `import { revalidate } from "@/.ruact/server-functions"` works on
68
74
  # day one of any host app. Ruby + JS codegens emit byte-identically.
69
75
  REVALIDATE_REEXPORT = "export { revalidate } from #{RUNTIME_IMPORT};\n".freeze
70
76
 
77
+ # Story 9.5 — the `useQuery` hook re-export, appended (after
78
+ # {REVALIDATE_REEXPORT}) ONLY when the v2 snapshot carries query entries.
79
+ # Gating on query presence keeps the action-only and empty v2 modules
80
+ # byte-identical to their Story 9.3 output (minimal churn); a host that
81
+ # has no queries cannot call `useQuery` on anything anyway. Ruby + JS
82
+ # codegens emit this byte-identically.
83
+ USEQUERY_REEXPORT = "export { useQuery } from #{RUNTIME_IMPORT};\n".freeze
84
+
71
85
  # JS identifier shape — same as `NameBridge::VALID_SYMBOL` but expressed
72
86
  # in JS-identifier terms (leading letter / underscore / `$`, then alnum
73
87
  # / underscore / `$`). The codegen validates every entry it consumes
@@ -20,23 +20,39 @@ module Ruact
20
20
  # LINE_TERMINATORS, VALID_JS_IDENTIFIER) are reused from {Codegen} via
21
21
  # lexical scope.
22
22
  module V2
23
- # Verbs a v2 entry may carry (mirrors {RouteSource::MUTATION_VERBS}).
23
+ # Verbs a v2 ACTION entry may carry (mirrors {RouteSource::MUTATION_VERBS}).
24
24
  HTTP_METHODS = %w[POST PUT PATCH DELETE].to_set.freeze
25
25
 
26
+ # Story 9.5 — the verb a v2 QUERY entry may carry. Queries are reads,
27
+ # mounted by {Ruact::Routing#ruact_queries} as named GET routes; the
28
+ # 2026-06-02 ADR addendum voided the old `POST /__ruact/fn/:id` query
29
+ # mechanism, so a query entry is GET-only.
30
+ QUERY_HTTP_METHODS = %w[GET].to_set.freeze
31
+
26
32
  class << self
27
33
  # @param version [Integer, String]
28
34
  # @param generated_at [String]
29
- # @param functions [Array<Hash>] route-derived entries.
35
+ # @param functions [Array<Hash>] route-derived entries (actions AND,
36
+ # Story 9.5, queries).
30
37
  # @return [String] TS module bytes (single trailing newline).
31
38
  # @raise [Ruact::ConfigurationError] on any trust-boundary violation.
32
39
  def render(version, generated_at, functions)
33
40
  validate_functions!(functions)
34
41
 
42
+ has_query = functions.any? { |entry| fetch(entry, "kind").to_s == "query" }
43
+ has_action = functions.any? { |entry| fetch(entry, "kind").to_s == "action" }
44
+
45
+ # Import only what is used. Keep `_makeServerFunction` in the empty
46
+ # case so the no-functions module stays byte-identical to Story 9.3.
47
+ imports = []
48
+ imports << "_makeServerFunction" if has_action || functions.empty?
49
+ imports << "_makeQuery" if has_query
50
+
35
51
  io = +""
36
52
  io << "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n"
37
53
  io << "// Source: Rails route table (version #{version})\n"
38
54
  io << "// Generated at: #{generated_at}\n"
39
- io << "import { _makeServerFunction } from #{RUNTIME_IMPORT};\n"
55
+ io << "import { #{imports.join(', ')} } from #{RUNTIME_IMPORT};\n"
40
56
 
41
57
  if functions.empty?
42
58
  io << "\n// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n"
@@ -48,12 +64,15 @@ module Ruact
48
64
 
49
65
  io << "\n"
50
66
  io << REVALIDATE_REEXPORT
67
+ io << USEQUERY_REEXPORT if has_query
51
68
  io
52
69
  end
53
70
 
54
71
  private
55
72
 
56
73
  def render_export(entry)
74
+ return render_query_export(entry) if fetch(entry, "kind").to_s == "query"
75
+
57
76
  js_id = fetch(entry, "js_identifier")
58
77
  method = fetch(entry, "http_method")
59
78
  path = fetch(entry, "path")
@@ -66,6 +85,20 @@ module Ruact
66
85
  "export const #{js_id}: #{ACTION_SIGNATURE} =\n _makeServerFunction(#{descriptor});\n"
67
86
  end
68
87
 
88
+ # Story 9.5 — a query export binds a `_makeQuery` accessor carrying
89
+ # its GET descriptor `{ path, kind: "query" }`. `useQuery(<id>, …)`
90
+ # consumes it (the SUPERSEDED `POST /__ruact/fn/:id` mechanism is
91
+ # gone — reads go to `GET /q/<jsId>`). The signature accepts params
92
+ # only when the query method declares kwargs (FR88).
93
+ def render_query_export(entry)
94
+ js_id = fetch(entry, "js_identifier")
95
+ path = fetch(entry, "path")
96
+ signature = fetch(entry, "accepts_params") ? QUERY_PARAMS_SIGNATURE : QUERY_SIGNATURE
97
+
98
+ descriptor = "{ path: #{JSON.dump(path)}, kind: \"query\" }"
99
+ "export const #{js_id}: #{signature} =\n _makeQuery(#{descriptor});\n"
100
+ end
101
+
69
102
  def validate_functions!(functions)
70
103
  unless functions.is_a?(Array)
71
104
  raise Ruact::ConfigurationError,
@@ -84,8 +117,9 @@ module Ruact
84
117
 
85
118
  js_id = fetch(entry, "js_identifier")
86
119
  validate_identifier!(js_id, seen)
87
- validate_kind!(js_id, fetch(entry, "kind").to_s)
88
- validate_method!(js_id, fetch(entry, "http_method"))
120
+ kind = fetch(entry, "kind").to_s
121
+ validate_kind!(js_id, kind)
122
+ validate_method!(js_id, kind, fetch(entry, "http_method"))
89
123
  path = fetch(entry, "path")
90
124
  validate_path!(js_id, path)
91
125
  validate_segments!(js_id, path, fetch(entry, "segments"))
@@ -112,19 +146,21 @@ module Ruact
112
146
  end
113
147
 
114
148
  def validate_kind!(js_id, kind)
115
- return if kind == "action"
149
+ return if %w[action query].include?(kind)
116
150
 
117
151
  raise Ruact::ConfigurationError,
118
152
  "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
119
- "kind #{kind.inspect} (v2 entries are always \"action\")."
153
+ "kind #{kind.inspect} (v2 entries are \"action\" or \"query\")."
120
154
  end
121
155
 
122
- def validate_method!(js_id, method)
123
- return if HTTP_METHODS.include?(method)
156
+ # Story 9.5 — actions carry a mutation verb; queries are GET-only.
157
+ def validate_method!(js_id, kind, method)
158
+ allowed = kind == "query" ? QUERY_HTTP_METHODS : HTTP_METHODS
159
+ return if allowed.include?(method)
124
160
 
125
161
  raise Ruact::ConfigurationError,
126
162
  "ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
127
- "http_method #{method.inspect} (must be one of #{HTTP_METHODS.to_a.inspect})."
163
+ "http_method #{method.inspect} (must be one of #{allowed.to_a.inspect})."
128
164
  end
129
165
 
130
166
  def validate_path!(js_id, path)
@@ -119,6 +119,7 @@ module Ruact
119
119
  end
120
120
 
121
121
  # Story 8.4 — Status mapping per AC1:
122
+ # - `Ruact::BadRequestError` → 400 (Story 9.5 — FR88 kwargs rejection)
122
123
  # - `ActiveRecord::RecordInvalid` → 422
123
124
  # - `ActionController::InvalidAuthenticityToken` → 403
124
125
  # - `Ruact::UploadTooLargeError` → 413
@@ -127,6 +128,7 @@ module Ruact
127
128
  # at load time (parity with {ErrorSuggestion}).
128
129
  def __ruact_status_for(error)
129
130
  case error.class.name
131
+ when "Ruact::BadRequestError" then 400
130
132
  when "ActiveRecord::RecordInvalid" then 422
131
133
  when "ActionController::InvalidAuthenticityToken" then 403
132
134
  when "Ruact::UploadTooLargeError" then 413
@@ -47,16 +47,21 @@ module Ruact
47
47
 
48
48
  # Story 8.2 (2026-05-17 review patches R2 + R12) — names already
49
49
  # bound at the top of `app/javascript/.ruact/server-functions.ts`,
50
- # either by the helper re-export (`revalidate`) or the runtime
51
- # import (`_makeRef`). A `ruact_action :revalidate` or
52
- # `ruact_action :_make_ref` would emit a clashing `export const`
53
- # next to the existing binding and crash at module-load time.
54
- # The rule fires at controller-class load so the failure
55
- # surfaces during boot, not at first request.
50
+ # either by a helper re-export (`revalidate`, `useQuery`) or a runtime
51
+ # import (`_makeRef`, `_makeServerFunction`, `_makeQuery`). A
52
+ # `ruact_action :revalidate` / a query method `use_query` would emit a
53
+ # clashing `export const` / duplicate export next to the existing
54
+ # binding and crash the generated module at load time. The rule fires
55
+ # at controller-class load / route-draw so the failure surfaces during
56
+ # boot, not at first request.
57
+ # Story 9.5 added `_makeQuery` (the v2 query import) and `useQuery` (the
58
+ # query hook re-export) to this list.
56
59
  RESERVED_BY_RUACT = %w[
60
+ _makeQuery
57
61
  _makeRef
58
62
  _makeServerFunction
59
63
  revalidate
64
+ useQuery
60
65
  ].to_set.freeze
61
66
 
62
67
  class << self
@@ -40,6 +40,14 @@ module Ruact
40
40
  # The Method#parameters types that mark keyword arguments (D7).
41
41
  KEYWORD_PARAM_TYPES = %i[key keyreq].freeze
42
42
 
43
+ # Story 9.5 (FR88) — the wire is GET query-string values, so every
44
+ # primitive the client sends arrives as a String (`?limit=5` →
45
+ # `"5"`); `nil` is the only non-String primitive Rack can produce for
46
+ # a scalar param. Arrays (`?q[]=`) and Hashes (`?q[k]=`) are the
47
+ # rejected complex shapes. Membership here = "this is a primitive the
48
+ # allowlist accepts".
49
+ PRIMITIVE_PARAM_CLASSES = [String, NilClass].freeze
50
+
43
51
  private
44
52
 
45
53
  # The action body of every query action: fresh context + fresh query
@@ -55,19 +63,76 @@ module Ruact
55
63
  render json: ActiveSupport::JSON.encode(serialized)
56
64
  end
57
65
 
58
- # D7 minimal, best-effort param passing: only the keyword arguments
59
- # the query method declares, read by name from the GET query params
60
- # (values arrive as Strings). The strict FR88 sanitization contract
61
- # (primitive allowlist, reject objects, 400 on invalid) is Story 9.5,
62
- # coupled to the `useQuery` wire format.
66
+ # Story 9.5 (FR88) the AUTHORITATIVE kwargs sanitization. The query
67
+ # method's declared keyword arguments are the contract; the client's
68
+ # `useQuery(ref, params)` call sends those as GET query-string values.
69
+ # Reads the RAW client params from `request.query_parameters` (not
70
+ # `params`, which is polluted with Rails' `controller`/`action`/
71
+ # `format` routing defaults — those must not count as "unknown
72
+ # parameters"). Enforces, in order:
73
+ #
74
+ # 1. **Allowlist** — every provided value must be a primitive
75
+ # (`string | number | boolean | null`; on the wire: String or
76
+ # nil). An Array (`?q[]=`) or Hash (`?q[k]=`) is rejected with a
77
+ # descriptive `Ruact::BadRequestError` naming the offending key
78
+ # and the allowlist → 400.
79
+ # 2. **Unknown param** — a provided key matching no declared kwarg
80
+ # (and the method has no `**rest`) is rejected, not silently
81
+ # dropped → 400.
82
+ # 3. **Missing required** — a declared `keyreq` the client did not
83
+ # send → 400 naming the missing parameter.
84
+ #
85
+ # Returns the symbol-keyed kwargs hash to splat into the query method.
63
86
  def __ruact_query_kwargs(query, query_method)
64
- query.method(query_method).parameters.each_with_object({}) do |(type, name), kwargs|
65
- next unless KEYWORD_PARAM_TYPES.include?(type)
87
+ signature = query.method(query_method).parameters
88
+ required = signature.filter_map { |type, name| name.to_s if type == :keyreq }
89
+ optional = signature.filter_map { |type, name| name.to_s if type == :key }
90
+ accepts_rest = signature.any? { |type, _name| type == :keyrest }
91
+ declared = required + optional
92
+
93
+ provided = request.query_parameters
94
+
95
+ provided.each do |key, value|
96
+ __ruact_validate_query_param!(query_method, key, value, declared, accepts_rest)
97
+ end
98
+
99
+ missing = required.reject { |name| provided.key?(name) }
100
+ unless missing.empty?
101
+ raise Ruact::BadRequestError,
102
+ "ruact query :#{query_method} is missing required parameter(s) " \
103
+ "#{missing.map { |n| n.to_sym.inspect }.join(', ')}."
104
+ end
66
105
 
67
- kwargs[name] = params[name.to_s] if params.key?(name.to_s)
106
+ provided.each_with_object({}) do |(key, value), kwargs|
107
+ kwargs[key.to_sym] = value if declared.include?(key) || accepts_rest
68
108
  end
69
109
  end
70
110
 
111
+ # FR88 per-param gate — see {#__ruact_query_kwargs}. Order matters:
112
+ # the unknown-param check precedes the type check so an unknown
113
+ # complex param is reported as "unknown" (the more actionable error).
114
+ #
115
+ # `accepts_rest` (the method declares `**rest`) relaxes ONLY the
116
+ # named-parameter restriction — a `**rest` signature is the author's
117
+ # explicit opt-in to arbitrary kwargs, so no provided key is "unknown".
118
+ # The TYPE allowlist below STILL runs for every param including the
119
+ # rest-captured ones, so the FR88 security boundary (reject
120
+ # arrays/objects) holds regardless of `**rest`.
121
+ def __ruact_validate_query_param!(query_method, key, value, declared, accepts_rest)
122
+ unless declared.include?(key) || accepts_rest
123
+ raise Ruact::BadRequestError,
124
+ "ruact query :#{query_method} received unknown parameter #{key.to_sym.inspect} " \
125
+ "— it matches no keyword argument of the query method."
126
+ end
127
+
128
+ return if PRIMITIVE_PARAM_CLASSES.any? { |klass| value.is_a?(klass) }
129
+
130
+ raise Ruact::BadRequestError,
131
+ "ruact query :#{query_method} parameter #{key.to_sym.inspect} must be a " \
132
+ "string, number, boolean, or null — arrays and objects are rejected " \
133
+ "(got #{value.class})."
134
+ end
135
+
71
136
  # D5 — the {Ruact::Server} mutation gate returns false for GET/HEAD so
72
137
  # GET *pages* keep stock Rails errors; query dispatch requests are GET
73
138
  # *function calls*, so the structured 8.4 payload must render here.
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "ruact/server_functions/name_bridge"
5
+
6
+ module Ruact
7
+ module ServerFunctions
8
+ # Story 9.5 — derives v2 QUERY entries for the route-driven codegen, the
9
+ # read-side sibling of {RouteSource} (which derives the non-GET mutation
10
+ # actions). Queries come from {Ruact::Query} subclasses mounted via the
11
+ # `ruact_queries` routing macro (Story 9.4).
12
+ #
13
+ # ## Why read the drawn route table, not all Ruact::Query subclasses
14
+ #
15
+ # The route table is the single source of truth (FR61): a host exposes a
16
+ # query ONLY by mounting its class with `ruact_queries` in `routes.rb`.
17
+ # Enumerating every `Ruact::Query` subclass would over-expose query classes
18
+ # that are defined but never mounted (and would 404 when `useQuery` fetched
19
+ # their non-existent routes). Reading the routes the `ruact_queries` macro
20
+ # actually drew keeps codegen route-truth-consistent with dispatch by
21
+ # construction — and means there is no `app/queries` force-load gap to
22
+ # paper over (mounting a class in `routes.rb` already autoloads it).
23
+ #
24
+ # Every generated query dispatch controller lives under the
25
+ # {QUERY_CONTROLLER_PREFIX} namespace (see
26
+ # {QueryDispatch.route_target_for}), so the GET routes this module consumes
27
+ # are unambiguous: any drawn route whose controller path starts with that
28
+ # prefix is a mounted query method.
29
+ #
30
+ # Pure by construction: {.collect} takes the route set and a resolver
31
+ # callable (controller-path → the backing {Ruact::Query} subclass). The
32
+ # railtie passes the real constant-resolving implementation; unit specs
33
+ # inject a lambda so the derivation is testable without booting controllers.
34
+ #
35
+ # @see RouteSource the mutation (action) sibling
36
+ # @see Ruact::Routing#ruact_queries the macro that draws the routes read here
37
+ module QuerySource
38
+ # The controller-path prefix every generated query dispatch controller
39
+ # lives under (mirrors {QueryDispatch.route_target_for}). A drawn GET
40
+ # route whose controller starts with this prefix is a mounted query.
41
+ QUERY_CONTROLLER_PREFIX = "ruact/server_functions/query_dispatch/"
42
+
43
+ # `Method#parameters` types that mark keyword arguments — the FR88 query
44
+ # parameters (mirrors {QueryDispatch::Dispatching::KEYWORD_PARAM_TYPES}).
45
+ KEYWORD_PARAM_TYPES = %i[key keyreq keyrest].freeze
46
+
47
+ class << self
48
+ # Collects v2 query entries from +route_set+.
49
+ #
50
+ # @param route_set [#routes] anything exposing `#routes` (an
51
+ # `ActionDispatch::Routing::RouteSet`, or `Rails.application.routes`).
52
+ # @param query_class_for [#call, nil] `controller_path(String) ->
53
+ # (Class | nil)` — resolves a query dispatch controller path to the
54
+ # {Ruact::Query} subclass it backs. Defaults to real constant
55
+ # resolution (reads the generated controller's `__ruact_query_class`).
56
+ # @return [Array<Hash>] query entries (string keys) sorted by
57
+ # `js_identifier`; shape: `js_identifier`, `kind` (always `"query"`),
58
+ # `http_method` (always `"GET"`), `path`, `segments` (always `[]`),
59
+ # `accepts_params` (Boolean — does the method declare kwargs?),
60
+ # `controller` (the query class name — for collision origins),
61
+ # `action` (the Ruby method name).
62
+ # @raise [Ruact::ConfigurationError] on a query×query naming collision.
63
+ def collect(route_set, query_class_for: nil)
64
+ query_class_for ||= method(:default_query_class_for)
65
+
66
+ entries = []
67
+ route_set.routes.each do |route|
68
+ controller = route.defaults[:controller]
69
+ action = route.defaults[:action]
70
+ next if controller.nil? || action.nil?
71
+ next unless controller.to_s.start_with?(QUERY_CONTROLLER_PREFIX)
72
+
73
+ query_class = query_class_for.call(controller.to_s)
74
+ next if query_class.nil?
75
+
76
+ entries << build_entry(route, action.to_s, query_class)
77
+ end
78
+
79
+ entries = entries.sort_by { |entry| entry["js_identifier"] }
80
+ detect_collisions!(entries)
81
+ entries
82
+ end
83
+
84
+ private
85
+
86
+ def build_entry(route, action, query_class)
87
+ {
88
+ "js_identifier" => NameBridge.to_js_identifier(action),
89
+ "kind" => "query",
90
+ "http_method" => "GET",
91
+ "path" => clean_path(route),
92
+ "segments" => [],
93
+ "accepts_params" => accepts_params?(query_class, action),
94
+ "controller" => query_class.name,
95
+ "action" => action
96
+ }
97
+ end
98
+
99
+ # Does the query method declare any keyword arguments (FR88 params)?
100
+ # Drives the emitted TS signature: `(params) => Promise<unknown>` when
101
+ # true, `() => Promise<unknown>` when false (AC1).
102
+ def accepts_params?(query_class, action)
103
+ query_class.instance_method(action).parameters.any? do |(type, _name)|
104
+ KEYWORD_PARAM_TYPES.include?(type)
105
+ end
106
+ rescue NameError
107
+ false
108
+ end
109
+
110
+ # query×query collision — two mounted query classes whose methods map
111
+ # to the SAME JS identifier (e.g. `CatalogQuery#search_users` and
112
+ # `PeopleQuery#search_users`). Fail loudly at boot naming both origins.
113
+ # The route×query side of the merged namespace is detected at the
114
+ # codegen combine point (see {ServerFunctions.write_v2_snapshot!}).
115
+ def detect_collisions!(entries)
116
+ entries.group_by { |entry| entry["js_identifier"] }.each do |js_id, group|
117
+ next if group.size < 2
118
+
119
+ origins = group.map { |entry| "#{entry['controller']}##{entry['action']}" }
120
+ raise Ruact::ConfigurationError,
121
+ "server-function naming collision: #{origins.join(' and ')} " \
122
+ "both map to JS identifier \"#{js_id}\" — rename one of the query methods."
123
+ end
124
+ end
125
+
126
+ # `/q/categories(.:format)` → `/q/categories`. Mirrors
127
+ # {RouteSource#clean_path}: drops the trailing format optional and any
128
+ # remaining optional `( … )` group.
129
+ def clean_path(route)
130
+ spec = route.path.spec.to_s
131
+ spec = spec.delete_suffix("(.:format)")
132
+ spec.gsub(/\([^)]*\)/, "")
133
+ end
134
+
135
+ # Real resolver — used in the railtie/rake paths. The generated query
136
+ # dispatch controller exposes `__ruact_query_class` (a singleton method
137
+ # set by {QueryDispatch.controller_for}); resolve the controller
138
+ # constant from its path and read that back.
139
+ def default_query_class_for(controller)
140
+ klass = "#{controller}_controller".camelize.safe_constantize
141
+ return nil unless klass.respond_to?(:__ruact_query_class)
142
+
143
+ klass.__ruact_query_class
144
+ rescue StandardError
145
+ nil
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end