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.
@@ -26,6 +26,7 @@ module Ruact
26
26
  autoload :SnapshotWriter, "ruact/server_functions/snapshot_writer"
27
27
  autoload :Codegen, "ruact/server_functions/codegen"
28
28
  autoload :RouteSource, "ruact/server_functions/route_source"
29
+ autoload :QuerySource, "ruact/server_functions/query_source"
29
30
  autoload :ErrorRendering, "ruact/server_functions/error_rendering"
30
31
  autoload :EndpointController, "ruact/server_functions/endpoint_controller"
31
32
  autoload :StandaloneContext, "ruact/server_functions/standalone_context"
@@ -45,13 +46,23 @@ module Ruact
45
46
  # AC2 — transparency over silence: the exposed names are ALWAYS logged so a
46
47
  # routed non-GET action never becomes a callable server function silently.
47
48
  #
49
+ # Story 9.5 — the `entries` array now carries BOTH mutation actions (from
50
+ # {RouteSource}) and read queries (from {QuerySource}); they share ONE
51
+ # merged JS namespace. {.detect_merged_namespace_collisions!} catches a
52
+ # route×query clash (each source already catches its own intra-kind
53
+ # collisions); the rename-override macro `ruact_function_name` on the
54
+ # mutation side (or renaming the query method) resolves it.
55
+ #
48
56
  # @param route_set [#routes] the Rails route set.
49
57
  # @param root [Pathname] the app root (for `tmp/cache` + `app/javascript`).
50
58
  # @param logger [#info, nil] logger for the exposure line; defaults to
51
59
  # `Rails.logger` when Rails is loaded, else nil.
52
- # @return [Array<Hash>] the exposed v2 entries.
60
+ # @return [Array<Hash>] the exposed v2 entries (actions + queries).
53
61
  def self.write_v2_snapshot!(route_set:, root:, logger: default_logger)
54
- entries = RouteSource.collect(route_set)
62
+ actions = RouteSource.collect(route_set)
63
+ queries = QuerySource.collect(route_set)
64
+ entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
65
+ detect_merged_namespace_collisions!(entries)
55
66
 
56
67
  json_path = root.join("tmp/cache/ruact/server-functions.next.json")
57
68
  ts_path = root.join("app/javascript/.ruact/server-functions.next.ts")
@@ -71,5 +82,30 @@ module Ruact
71
82
  def self.default_logger
72
83
  defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
73
84
  end
85
+
86
+ # Story 9.5 (Task 2) — the merged JS namespace covers route (action)
87
+ # entries AND query entries. {RouteSource} already rejects action×action
88
+ # collisions and {QuerySource} rejects query×query; this final pass catches
89
+ # a route×query clash — two distinct origins (one a mutation route, one a
90
+ # query method) mapping to the same `js_identifier` would emit two
91
+ # `export const <id>` lines and crash the generated module at load. Fail
92
+ # loudly at boot naming BOTH origins. The escape hatch is the
93
+ # `ruact_function_name :<action>, as: "<id>"` rename macro on the mutation
94
+ # controller (Story 9.3) or renaming the colliding query method.
95
+ #
96
+ # @param entries [Array<Hash>] merged action + query entries.
97
+ # @raise [Ruact::ConfigurationError]
98
+ def self.detect_merged_namespace_collisions!(entries)
99
+ entries.group_by { |entry| entry["js_identifier"] }.each do |js_id, group|
100
+ next if group.size < 2
101
+
102
+ origins = group.map { |entry| "#{entry['controller']}##{entry['action']}" }
103
+ raise Ruact::ConfigurationError,
104
+ "server-function naming collision: #{origins.join(' and ')} " \
105
+ "both map to JS identifier \"#{js_id}\" — disambiguate with " \
106
+ "`ruact_function_name :<action>, as: \"<other-name>\"` on the mutation " \
107
+ "controller, or rename the query method."
108
+ end
109
+ end
74
110
  end
75
111
  end
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.3"
4
+ VERSION = "0.0.4"
5
5
  end
@@ -155,17 +155,47 @@ module QueryRequestSpecSupport # rubocop:disable Style/OneClassPerFile
155
155
 
156
156
  # Custom parent for the AC2 configurability unit assertion.
157
157
  class AltParentController < ActionController::Base; end
158
+
159
+ # Story 9.5 (FR88) fixture — a required kwarg (`q`), an optional kwarg with
160
+ # a default (`limit`), and a separate required-only method. Mounted on the
161
+ # shared app alongside CatalogQuery so the FR88 sanitization is exercised
162
+ # end-to-end through the host chain.
163
+ class SearchQuery < ApplicationQuery
164
+ # `q` is the natural query parameter name and is asserted verbatim in the
165
+ # FR88 rejection messages below.
166
+ def search(q:, limit: "10") # rubocop:disable Naming/MethodParameterName
167
+ { "q" => q, "limit" => limit }
168
+ end
169
+
170
+ def tags(filter:)
171
+ { "filter" => filter }
172
+ end
173
+
174
+ # A `**rest` query: it opts into arbitrary kwargs, so extra params are NOT
175
+ # "unknown" — but the FR88 type allowlist still rejects arrays/objects.
176
+ def flexible(q:, **rest) # rubocop:disable Naming/MethodParameterName
177
+ { "q" => q, "rest" => rest.transform_keys(&:to_s) }
178
+ end
179
+
180
+ # For the null-wire contract: `useQuery` sends `null` as a bare key (`?opt`),
181
+ # which Rack parses as `nil` (distinct from `?opt=` → `""`).
182
+ def nullable(opt: "default")
183
+ { "opt" => opt }
184
+ end
185
+ end
158
186
  end
159
187
 
160
188
  if defined?(ControllerRequestSpecSupport) &&
161
189
  !ControllerRequestSpecSupport.instance_variable_get(:@__ruact_query_routes_appended)
162
190
  ControllerRequestSpecSupport.instance_variable_set(:@__ruact_query_routes_appended, true)
163
191
  ControllerRequestSpecSupport.app_class.routes.append do
164
- ruact_queries QueryRequestSpecSupport::CatalogQuery, QueryRequestSpecSupport::PublicCatalogQuery
192
+ ruact_queries QueryRequestSpecSupport::CatalogQuery,
193
+ QueryRequestSpecSupport::PublicCatalogQuery,
194
+ QueryRequestSpecSupport::SearchQuery
165
195
  end
166
196
  end
167
197
 
168
- RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do
198
+ RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do # rubocop:disable RSpec/MultipleDescribes
169
199
  include Rack::Test::Methods
170
200
 
171
201
  let(:app_class) { ControllerRequestSpecSupport.app_class }
@@ -444,3 +474,125 @@ RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do
444
474
  end
445
475
  end
446
476
  end
477
+
478
+ RSpec.describe "Story 9.5: FR88 query kwargs sanitization", :story_9_5 do
479
+ include Rack::Test::Methods
480
+
481
+ let(:app_class) { ControllerRequestSpecSupport.app_class }
482
+ let(:app) { app_class.instance }
483
+
484
+ let(:login_headers) { { "HTTP_X_TEST_LOGIN" => "1" } }
485
+
486
+ before do
487
+ Rails.logger = Logger.new(IO::NULL)
488
+ ControllerRequestSpecSupport.boot!
489
+ end
490
+
491
+ def structured_error(response)
492
+ expect(response.status).to eq(400)
493
+ body = JSON.parse(response.body)
494
+ expect(body.fetch("_ruact_server_action_error")).to be(true)
495
+ expect(body.fetch("error_class")).to eq("Ruact::BadRequestError")
496
+ body
497
+ end
498
+
499
+ describe "AC3 happy path — declared primitives pass" do
500
+ it "passes a required + optional kwarg by name (values arrive as strings)" do
501
+ get "/q/search?q=ruby&limit=5", {}, login_headers
502
+ expect(last_response.status).to eq(200)
503
+ expect(JSON.parse(last_response.body)).to eq("q" => "ruby", "limit" => "5")
504
+ end
505
+
506
+ it "omits an absent optional kwarg so the method default applies" do
507
+ get "/q/search?q=ruby", {}, login_headers
508
+ expect(last_response.status).to eq(200)
509
+ expect(JSON.parse(last_response.body)).to eq("q" => "ruby", "limit" => "10")
510
+ end
511
+
512
+ it "accepts boolean-ish / numeric-ish primitive strings on the wire" do
513
+ get "/q/search?q=true&limit=0", {}, login_headers
514
+ expect(last_response.status).to eq(200)
515
+ expect(JSON.parse(last_response.body)).to eq("q" => "true", "limit" => "0")
516
+ end
517
+ end
518
+
519
+ describe "AC3 — array param rejected (only string/number/boolean/null allowed)" do
520
+ it "400s with a descriptive error naming the key and the allowlist" do
521
+ get "/q/search?q[]=a&q[]=b", {}, login_headers
522
+ body = structured_error(last_response)
523
+ expect(body.fetch("message")).to match(/:q must be a string, number, boolean, or null/)
524
+ expect(body.fetch("message")).to match(/arrays and objects are rejected/)
525
+ end
526
+ end
527
+
528
+ describe "AC3 — object (hash) param rejected" do
529
+ it "400s with a descriptive error naming the offending key" do
530
+ get "/q/search?q[deep]=1", {}, login_headers
531
+ body = structured_error(last_response)
532
+ expect(body.fetch("message")).to match(/:q must be a string, number, boolean, or null/)
533
+ end
534
+ end
535
+
536
+ describe "AC3 — missing required kwarg → 400 naming the missing parameter" do
537
+ it "400s naming :q when it is absent" do
538
+ get "/q/search?limit=5", {}, login_headers
539
+ body = structured_error(last_response)
540
+ expect(body.fetch("message")).to match(/missing required parameter\(s\) :q/)
541
+ expect(body.fetch("action_name")).to eq("search")
542
+ end
543
+
544
+ it "400s naming :filter on a required-only method" do
545
+ get "/q/tags", {}, login_headers
546
+ body = structured_error(last_response)
547
+ expect(body.fetch("message")).to match(/missing required parameter\(s\) :filter/)
548
+ end
549
+ end
550
+
551
+ describe "AC3 — unknown param → 400 (rejected, not silently dropped)" do
552
+ it "400s naming the unknown parameter" do
553
+ get "/q/search?q=ruby&bogus=1", {}, login_headers
554
+ body = structured_error(last_response)
555
+ expect(body.fetch("message")).to match(/unknown parameter :bogus/)
556
+ end
557
+
558
+ it "rejects before running the query even when the declared param is also present" do
559
+ get "/q/search?q=ruby&bogus=1", {}, login_headers
560
+ expect(last_response.status).to eq(400)
561
+ end
562
+ end
563
+
564
+ describe "AC3 — a `**rest` query opts into extra kwargs, but the type allowlist still applies" do
565
+ it "accepts extra primitive params (not 'unknown' for a **rest signature)" do
566
+ get "/q/flexible?q=ruby&extra=1&flag=true", {}, login_headers
567
+ expect(last_response.status).to eq(200)
568
+ expect(JSON.parse(last_response.body)).to eq(
569
+ "q" => "ruby", "rest" => { "extra" => "1", "flag" => "true" }
570
+ )
571
+ end
572
+
573
+ it "STILL rejects an array param on a **rest query (FR88 type boundary holds)" do
574
+ get "/q/flexible?q=ruby&bad[]=1", {}, login_headers
575
+ body = structured_error(last_response)
576
+ expect(body.fetch("message")).to match(/:bad must be a string, number, boolean, or null/)
577
+ end
578
+
579
+ it "STILL rejects an object param on a **rest query" do
580
+ get "/q/flexible?q=ruby&bad[x]=1", {}, login_headers
581
+ body = structured_error(last_response)
582
+ expect(body.fetch("message")).to match(/:bad must be a string, number, boolean, or null/)
583
+ end
584
+ end
585
+
586
+ describe "null wire contract — a bare key (`?opt`) is delivered as nil, not empty string" do
587
+ it "delivers nil (the bare-key wire form useQuery sends for null)" do
588
+ get "/q/nullable?opt", {}, login_headers
589
+ expect(last_response.status).to eq(200)
590
+ expect(JSON.parse(last_response.body)).to eq("opt" => nil)
591
+ end
592
+
593
+ it "delivers an empty string for `?opt=` (distinct from null)" do
594
+ get "/q/nullable?opt=", {}, login_headers
595
+ expect(JSON.parse(last_response.body)).to eq("opt" => "")
596
+ end
597
+ end
598
+ end
@@ -377,13 +377,13 @@ module Ruact
377
377
  .to raise_error(Ruact::ConfigurationError, /absent from path/)
378
378
  end
379
379
 
380
- it "rejects a v2 entry with a non-action kind" do
380
+ it "rejects a v2 entry with an unknown kind (Story 9.5 — action/query only)" do
381
381
  evil = v2_snapshot.merge(functions: [
382
- { "js_identifier" => "createPost", "kind" => "query",
382
+ { "js_identifier" => "createPost", "kind" => "mutation",
383
383
  "http_method" => "POST", "path" => "/posts", "segments" => [] }
384
384
  ])
385
385
  expect { described_class.render(evil) }
386
- .to raise_error(Ruact::ConfigurationError, /v2 entries are always/)
386
+ .to raise_error(Ruact::ConfigurationError, /v2 entries are "action" or "query"/)
387
387
  end
388
388
 
389
389
  it "rejects a v2 entry whose js_identifier is reserved" do
@@ -424,6 +424,85 @@ module Ruact
424
424
  .to raise_error(Ruact::ConfigurationError, /absent from path/)
425
425
  end
426
426
  end
427
+
428
+ describe ".render — v2 query entries (Story 9.5)", :story_9_5 do
429
+ def query_entry(js_id, path, accepts_params:)
430
+ {
431
+ "js_identifier" => js_id, "kind" => "query", "http_method" => "GET",
432
+ "path" => path, "segments" => [], "accepts_params" => accepts_params,
433
+ "controller" => "CatalogQuery", "action" => js_id
434
+ }
435
+ end
436
+
437
+ it "emits a no-param query as _makeQuery with the () signature + useQuery re-export" do
438
+ out = described_class.render(version: 2, generated_at: "t", functions: [
439
+ query_entry("categories", "/q/categories", accepts_params: false)
440
+ ])
441
+ expect(out).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
442
+ expect(out).to include("export const categories: () => Promise<unknown> =")
443
+ expect(out).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
444
+ expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
445
+ expect(out).to include('export { revalidate } from "ruact/server-functions-runtime";')
446
+ end
447
+
448
+ it "emits a param-declaring query with the (params) signature" do
449
+ out = described_class.render(version: 2, generated_at: "t", functions: [
450
+ query_entry("searchUsers", "/q/searchUsers", accepts_params: true)
451
+ ])
452
+ expect(out).to include("export const searchUsers: (params: Record<string, unknown>) => Promise<unknown> =")
453
+ expect(out).to include('_makeQuery({ path: "/q/searchUsers", kind: "query" });')
454
+ end
455
+
456
+ it "imports both accessors and re-exports useQuery for a mixed action+query snapshot" do
457
+ out = described_class.render(version: 2, generated_at: "t", functions: [
458
+ { "js_identifier" => "createPost", "kind" => "action",
459
+ "http_method" => "POST", "path" => "/posts", "segments" => [] },
460
+ query_entry("categories", "/q/categories", accepts_params: false)
461
+ ])
462
+ expect(out).to include('import { _makeServerFunction, _makeQuery } from "ruact/server-functions-runtime";')
463
+ expect(out).to include("_makeServerFunction({ method: \"POST\"")
464
+ expect(out).to include("_makeQuery({ path: \"/q/categories\", kind: \"query\" })")
465
+ expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
466
+ end
467
+
468
+ it "accepts GET for a query entry (queries are GET-only)" do
469
+ expect do
470
+ described_class.render(version: 2, generated_at: "t", functions: [
471
+ query_entry("categories", "/q/categories", accepts_params: false)
472
+ ])
473
+ end.not_to raise_error
474
+ end
475
+
476
+ it "rejects a non-GET verb on a query entry" do
477
+ evil = { "js_identifier" => "categories", "kind" => "query", "http_method" => "POST",
478
+ "path" => "/q/categories", "segments" => [] }
479
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
480
+ .to raise_error(Ruact::ConfigurationError, /invalid http_method/)
481
+ end
482
+
483
+ it "rejects a query entry whose js_identifier is the useQuery re-export name" do
484
+ evil = { "js_identifier" => "useQuery", "kind" => "query", "http_method" => "GET",
485
+ "path" => "/q/useQuery", "segments" => [], "accepts_params" => false }
486
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
487
+ .to raise_error(Ruact::ConfigurationError, /reserved/)
488
+ end
489
+
490
+ it "rejects a query entry whose js_identifier is the _makeQuery import name" do
491
+ evil = { "js_identifier" => "_makeQuery", "kind" => "query", "http_method" => "GET",
492
+ "path" => "/q/_makeQuery", "segments" => [], "accepts_params" => false }
493
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
494
+ .to raise_error(Ruact::ConfigurationError, /reserved/)
495
+ end
496
+
497
+ it "does NOT re-export useQuery for an action-only snapshot (byte-stable with 9.3)" do
498
+ out = described_class.render(version: 2, generated_at: "t", functions: [
499
+ { "js_identifier" => "createPost", "kind" => "action",
500
+ "http_method" => "POST", "path" => "/posts", "segments" => [] }
501
+ ])
502
+ expect(out).not_to include("useQuery")
503
+ expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
504
+ end
505
+ end
427
506
  end
428
507
  end
429
508
  end
@@ -181,6 +181,30 @@ module Ruact
181
181
  it "R12 — accepts :_make_ref_action (suffix escape hatch keeps working)" do
182
182
  expect(described_class.to_js_identifier(:_make_ref_action)).to eq("_makeRefAction")
183
183
  end
184
+
185
+ # Story 9.5 — `_makeQuery` (the v2 query import) and `useQuery` (the
186
+ # query hook re-export) are new top-level bindings in the generated
187
+ # module; a query/action method mapping to either would redeclare the
188
+ # import / duplicate the export and crash at module load.
189
+ it "Story 9.5 — rejects :use_query because it collides with the useQuery re-export" do
190
+ expect { described_class.to_js_identifier(:use_query) }
191
+ .to raise_error(Ruact::ConfigurationError) do |error|
192
+ expect(error.message).to include(":use_query")
193
+ expect(error.message).to include("duplicate export")
194
+ end
195
+ end
196
+
197
+ it "Story 9.5 — rejects :_make_query because it collides with the codegen's runtime import" do
198
+ expect { described_class.to_js_identifier(:_make_query) }
199
+ .to raise_error(Ruact::ConfigurationError) do |error|
200
+ expect(error.message).to include(":_make_query")
201
+ expect(error.message).to include("duplicate export")
202
+ end
203
+ end
204
+
205
+ it "Story 9.5 — accepts :use_query_results (suffix escape hatch keeps working)" do
206
+ expect(described_class.to_js_identifier(:use_query_results)).to eq("useQueryResults")
207
+ end
184
208
  end
185
209
  end
186
210
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruact/server_functions"
5
+ require "ruact/server_functions/query_source"
6
+
7
+ module Ruact
8
+ module ServerFunctions
9
+ # Plain query classes — only `instance_method(...).parameters` (kwargs
10
+ # presence) and `.name` (collision origin) are read. Defined at module
11
+ # level so they are not example-group-leaky constants.
12
+ class CatalogQ
13
+ def categories; end
14
+
15
+ def search_users(term:, limit: 10); end
16
+ end
17
+
18
+ class PeopleQ
19
+ def search_users(term:); end
20
+ end
21
+
22
+ # Story 9.5 — query introspection: the drawn route table (filtered to the
23
+ # generated query dispatch controllers) → v2 query entries. Pure: route set
24
+ # and the query-class resolver are injected, so the derivation is testable
25
+ # with no Rails boot and no controllers (a fake route set + plain query
26
+ # classes).
27
+ RSpec.describe QuerySource, :story_9_5 do
28
+ # Minimal route double exposing the surface QuerySource reads:
29
+ # `defaults[:controller]`, `defaults[:action]`, `path.spec.to_s`.
30
+ def query_route(controller, action, path)
31
+ spec = Object.new
32
+ spec.define_singleton_method(:to_s) { "#{path}(.:format)" }
33
+ path_obj = Object.new
34
+ path_obj.define_singleton_method(:spec) { spec }
35
+ route = Object.new
36
+ route.define_singleton_method(:defaults) { { controller: controller, action: action } }
37
+ route.define_singleton_method(:path) { path_obj }
38
+ route
39
+ end
40
+
41
+ def route_set(routes)
42
+ rs = Object.new
43
+ rs.define_singleton_method(:routes) { routes }
44
+ rs
45
+ end
46
+
47
+ let(:prefix) { QuerySource::QUERY_CONTROLLER_PREFIX }
48
+
49
+ def resolver(map)
50
+ ->(controller) { map[controller] }
51
+ end
52
+
53
+ describe ".collect" do
54
+ it "emits one query entry per mounted query route (route-truth)" do
55
+ rs = route_set([
56
+ query_route("#{prefix}catalog_q", "categories", "/q/categories"),
57
+ query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")
58
+ ])
59
+ entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
60
+
61
+ expect(entries.map { |e| e["js_identifier"] }).to eq(%w[categories searchUsers])
62
+ expect(entries).to all(include("kind" => "query", "http_method" => "GET", "segments" => []))
63
+ end
64
+
65
+ it "derives jsId via NameBridge and carries the clean path" do
66
+ rs = route_set([query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")])
67
+ entry = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ)).first
68
+ expect(entry["js_identifier"]).to eq("searchUsers")
69
+ expect(entry["path"]).to eq("/q/searchUsers")
70
+ expect(entry["controller"]).to eq("Ruact::ServerFunctions::CatalogQ")
71
+ expect(entry["action"]).to eq("search_users")
72
+ end
73
+
74
+ it "flags accepts_params true when the method declares kwargs, false otherwise" do
75
+ rs = route_set([
76
+ query_route("#{prefix}catalog_q", "categories", "/q/categories"),
77
+ query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")
78
+ ])
79
+ entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
80
+ by_id = entries.to_h { |e| [e["js_identifier"], e] }
81
+ expect(by_id["categories"]["accepts_params"]).to be(false)
82
+ expect(by_id["searchUsers"]["accepts_params"]).to be(true)
83
+ end
84
+
85
+ it "ignores non-query routes (controller not under the dispatch namespace)" do
86
+ rs = route_set([
87
+ query_route("posts", "create", "/posts"),
88
+ query_route("#{prefix}catalog_q", "categories", "/q/categories")
89
+ ])
90
+ entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
91
+ expect(entries.map { |e| e["js_identifier"] }).to eq(%w[categories])
92
+ end
93
+
94
+ it "skips a query route whose class cannot be resolved (nil resolver result)" do
95
+ rs = route_set([query_route("#{prefix}gone_q", "categories", "/q/categories")])
96
+ expect(described_class.collect(rs, query_class_for: resolver({}))).to eq([])
97
+ end
98
+
99
+ it "raises a query×query collision naming both origins" do
100
+ rs = route_set([
101
+ query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers"),
102
+ query_route("#{prefix}people_q", "search_users", "/q/searchUsers")
103
+ ])
104
+ map = { "#{prefix}catalog_q" => CatalogQ, "#{prefix}people_q" => PeopleQ }
105
+ expect { described_class.collect(rs, query_class_for: resolver(map)) }
106
+ .to raise_error(Ruact::ConfigurationError, /CatalogQ#search_users and .*PeopleQ#search_users/m)
107
+ end
108
+ end
109
+ end
110
+
111
+ # Story 9.5 (Task 2) — the merged JS namespace (route entries + query
112
+ # entries share it). The route×query side is detected at the codegen
113
+ # combine point.
114
+ RSpec.describe ".detect_merged_namespace_collisions!", :story_9_5 do
115
+ def action_entry(js_id, controller, action)
116
+ { "js_identifier" => js_id, "kind" => "action", "controller" => controller, "action" => action }
117
+ end
118
+
119
+ def query_entry(js_id, controller, action)
120
+ { "js_identifier" => js_id, "kind" => "query", "controller" => controller, "action" => action }
121
+ end
122
+
123
+ it "raises on a route×query collision naming both origins + the rename escape hatch" do
124
+ entries = [
125
+ action_entry("categories", "posts", "categories"),
126
+ query_entry("categories", "CatalogQuery", "categories")
127
+ ]
128
+ expect { ServerFunctions.detect_merged_namespace_collisions!(entries) }
129
+ .to raise_error(Ruact::ConfigurationError,
130
+ /posts#categories and CatalogQuery#categories.*ruact_function_name/m)
131
+ end
132
+
133
+ it "does not raise when the rename makes the identifiers distinct" do
134
+ entries = [
135
+ action_entry("listCategories", "posts", "categories"), # renamed via ruact_function_name
136
+ query_entry("categories", "CatalogQuery", "categories")
137
+ ]
138
+ expect { ServerFunctions.detect_merged_namespace_collisions!(entries) }.not_to raise_error
139
+ end
140
+ end
141
+ end
142
+ end
@@ -125,6 +125,73 @@ module Ruact
125
125
  end
126
126
  end
127
127
 
128
+ RSpec.describe "Ruact::ServerFunctions.write_v2_snapshot! — queries (Story 9.5)", :story_9_5 do
129
+ # `ruact_queries` + query dispatch live behind these requires (loaded
130
+ # explicitly here as the railtie would at boot).
131
+ require "ruact/routing"
132
+ require "ruact/query"
133
+
134
+ around do |example|
135
+ Dir.mktmpdir { |dir| @tmpdir = dir and example.run }
136
+ end
137
+
138
+ before do
139
+ stub_const("V2QueryParentController", Class.new(ActionController::Base))
140
+ Ruact.configure { |c| c.query_parent_controller = "V2QueryParentController" }
141
+ stub_const("V2CatalogQuery", Class.new(Ruact::Query) do
142
+ def categories; end
143
+ def search(term:); end
144
+ end)
145
+ end
146
+
147
+ def route_set
148
+ route_set = ActionDispatch::Routing::RouteSet.new
149
+ route_set.draw { ruact_queries V2CatalogQuery }
150
+ route_set
151
+ end
152
+
153
+ def write!(routes = route_set)
154
+ Ruact::ServerFunctions.write_v2_snapshot!(route_set: routes, root: Pathname.new(@tmpdir))
155
+ end
156
+
157
+ let(:next_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.next.ts") }
158
+
159
+ it "emits query entries (route-truth) merged into the v2 snapshot" do
160
+ entries = write!
161
+ expect(entries.map { |e| e["js_identifier"] }).to match_array(%w[categories search])
162
+ expect(entries).to all(include("kind" => "query", "http_method" => "GET"))
163
+ end
164
+
165
+ it "renders _makeQuery refs + the useQuery re-export into the .next TS" do
166
+ write!
167
+ ts = File.read(next_ts)
168
+ expect(ts).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
169
+ expect(ts).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
170
+ expect(ts).to include("export const categories: () => Promise<unknown> =")
171
+ expect(ts).to include("export const search: (params: Record<string, unknown>) => Promise<unknown> =")
172
+ expect(ts).to include('export { useQuery } from "ruact/server-functions-runtime";')
173
+ end
174
+
175
+ it "raises a route×query collision when an action and a query share a js_identifier" do
176
+ stub_const("CategoriesController", Class.new(ActionController::Base) { include Ruact::Server })
177
+ stub_const("CollideQuery", Class.new(Ruact::Query) { def categories; end })
178
+ rs = ActionDispatch::Routing::RouteSet.new
179
+ rs.draw do
180
+ # POST /categories#create would derive js_identifier "createCategory" — to
181
+ # force a clash, use a custom collection route named to collide head-on.
182
+ post "categories", to: "categories#categories"
183
+ ruact_queries CollideQuery
184
+ end
185
+ # Rename the action's js_identifier to exactly "categories" so it collides
186
+ # with the query method.
187
+ CategoriesController.define_singleton_method(:__ruact_function_name_overrides) do
188
+ { "categories" => "categories" }
189
+ end
190
+ expect { Ruact::ServerFunctions.write_v2_snapshot!(route_set: rs, root: Pathname.new(@tmpdir)) }
191
+ .to raise_error(Ruact::ConfigurationError, /both map to JS identifier "categories".*ruact_function_name/m)
192
+ end
193
+ end
194
+
128
195
  RSpec.describe "Ruact::Railtie registry-clear hook (Story 8.1)", :story_8_1 do
129
196
  # The Railtie attaches a `before_class_unload` callback that clears both
130
197
  # registries before Zeitwerk tears down constants — this prevents removed
@@ -87,6 +87,40 @@ export function _makeServerFunction(descriptor: {
87
87
  ) => Promise<unknown>) &
88
88
  ((formData: RuactFormData) => Promise<void>);
89
89
 
90
+ /**
91
+ * Story 9.5 — the read-side (query) accessor. The codegen emits
92
+ * `_makeQuery({ path, kind: "query" })` for every method on a mounted
93
+ * `Ruact::Query` subclass. The returned callable issues a GET to the named
94
+ * query route (`GET /q/<jsId>`), serializing `params` into the query string
95
+ * (FR88: string / number / boolean / null only). Reads are CSRF-free: no
96
+ * body, no `X-CSRF-Token`.
97
+ *
98
+ * Usually consumed through {@link useQuery}, but callable directly in
99
+ * imperative code. The emitted module narrows the param surface per query
100
+ * (`() => Promise<unknown>` when the Ruby method declares no kwargs;
101
+ * `(params: Record<string, unknown>) => Promise<unknown>` when it does).
102
+ */
103
+ export function _makeQuery(descriptor: {
104
+ path: string;
105
+ kind?: string;
106
+ }): (params?: Record<string, unknown>) => Promise<unknown>;
107
+
108
+ /**
109
+ * Story 9.5 — React hook for reading a server query. Pass a query reference
110
+ * (the codegen-emitted `_makeQuery` accessor) and optional params; issues
111
+ * `GET /q/<jsId>` on mount (and whenever the serialized params change) and
112
+ * returns `{ data, loading, error }`. `loading` is true until the first
113
+ * resolution; `error` carries the structured {@link RuactActionError} on
114
+ * failure. A superseded in-flight response is dropped.
115
+ *
116
+ * Request de-duplication across components is Story 9.6; this hook fetches
117
+ * once per mount.
118
+ */
119
+ export function useQuery<T = unknown>(
120
+ reference: (params?: Record<string, unknown>) => Promise<unknown>,
121
+ params?: Record<string, unknown>,
122
+ ): { data: T | undefined; loading: boolean; error: unknown };
123
+
90
124
  /**
91
125
  * Story 8.2 — issues a Flight refetch of the supplied path (or the
92
126
  * current URL when omitted) and swaps the React tree in place. Mirrors