ruact 0.0.2 → 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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # Story 9.4 (D3) — per-request execution context injected into a
6
+ # {Ruact::Query} subclass by the internal query dispatch controller. A
7
+ # fresh instance is built per request (NFR8) wrapping the DISPATCHING
8
+ # controller instance; because that controller inherits
9
+ # `Ruact.config.query_parent_controller`, every delegated reader resolves
10
+ # to the host's own machinery — `current_user` IS the host's method
11
+ # (Devise / Pundit / hand-rolled), not a gem-side resolver lambda.
12
+ #
13
+ # Mirrors the SHAPE of the Story-8.3 `StandaloneContext` (plain accessors,
14
+ # per-request instance) while sourcing everything from the controller —
15
+ # the resolver-lambda pattern is superseded by the 2026-06-02 ADR
16
+ # addendum (Decision 2).
17
+ class QueryContext
18
+ # @param controller [ActionController::Base] the dispatching controller
19
+ # instance (a generated subclass of `Ruact.config.query_parent_controller`).
20
+ def initialize(controller:)
21
+ @controller = controller
22
+ end
23
+
24
+ # The host's authenticated user. Resolved through the controller's own
25
+ # `current_user` — public OR private (hand-rolled apps commonly define
26
+ # it `private`). When the host chain defines no `current_user` at all,
27
+ # raises a NoMethodError that names the fix instead of a bare
28
+ # "undefined method" from deep inside a query body.
29
+ #
30
+ # @return [Object, nil]
31
+ # @raise [NoMethodError] when the parent controller chain defines no
32
+ # `current_user`.
33
+ def current_user
34
+ unless @controller.respond_to?(:current_user, true)
35
+ raise NoMethodError,
36
+ "ruact: the query dispatch controller (inheriting " \
37
+ "#{@controller.class.superclass.name}, via Ruact.config.query_parent_controller) " \
38
+ "does not define `current_user`. Define it on the parent controller, or point " \
39
+ "`query_parent_controller` at a controller that does."
40
+ end
41
+
42
+ @controller.send(:current_user)
43
+ end
44
+
45
+ # @return [ActionController::Parameters] the request params (query-string
46
+ # parameters on a GET query route).
47
+ def params
48
+ @controller.params
49
+ end
50
+
51
+ # @return [ActionDispatch::Request] the live request.
52
+ def request
53
+ @controller.request
54
+ end
55
+
56
+ # @return [ActionDispatch::Request::Session] the host middleware's session.
57
+ def session
58
+ @controller.session
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+
5
+ require_relative "error_rendering"
6
+ require_relative "bucket_two_payload"
7
+ require_relative "query_context"
8
+
9
+ module Ruact
10
+ module ServerFunctions
11
+ # Story 9.4 (D2) — generates the INTERNAL dispatch controller backing the
12
+ # routes the `ruact_queries` macro draws: one controller subclass PER
13
+ # {Ruact::Query} subclass, with one action per public query method. The
14
+ # per-class shape is what makes the AC4 callback opt-out scopable — a
15
+ # `ruact_skip_before_action` on one query class lands on that class's
16
+ # controller only, and `only:`/`except:` options scope it further to
17
+ # individual query methods (each method is one action).
18
+ #
19
+ # The controller inherits `Ruact.config.query_parent_controller`
20
+ # (default `ApplicationController`), resolved LAZILY here — at route-draw
21
+ # time, when the host's constants exist — never at gem-load or configure
22
+ # time. The host's REAL callback chain therefore runs before the query
23
+ # class is instantiated (FR89). The dev never sees this controller; it is
24
+ # named under this module (e.g.
25
+ # `Ruact::ServerFunctions::QueryDispatch::CatalogQueryController`) only so
26
+ # Rails' string-based route resolution (`to: "…/catalog_query#categories"`)
27
+ # and `rails routes` output stay legible.
28
+ #
29
+ # Regeneration is idempotent: every `ruact_queries` evaluation (boot AND
30
+ # every dev-mode routes reload) rebuilds the constant from the query
31
+ # class's CURRENT state, and the query class itself is re-constantized per
32
+ # request, so code reloading never serves a stale class.
33
+ module QueryDispatch
34
+ # Instance-level dispatch plumbing shared by every generated controller.
35
+ # Included into the generated subclass, so these definitions override
36
+ # anything the parent chain provides (notably the {Ruact::Server} gates,
37
+ # when the host's ApplicationController happens to include the mutation
38
+ # concern).
39
+ module Dispatching
40
+ # The Method#parameters types that mark keyword arguments (D7).
41
+ KEYWORD_PARAM_TYPES = %i[key keyreq].freeze
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
+
51
+ private
52
+
53
+ # The action body of every query action: fresh context + fresh query
54
+ # instance per request (NFR8), return value serialized through the
55
+ # SAME policy Bucket 2 applies to ivars (D6). Encoded explicitly so a
56
+ # scalar String/nil return still renders valid JSON (`"hi"` / `null`),
57
+ # which `render json:` alone would pass through raw.
58
+ def __ruact_dispatch_query(query_method)
59
+ query_class = self.class.__ruact_query_class
60
+ query = query_class.new(QueryContext.new(controller: self))
61
+ result = query.public_send(query_method, **__ruact_query_kwargs(query, query_method))
62
+ serialized = BucketTwoPayload.serialize_value(result, strict: Ruact.config.strict_serialization)
63
+ render json: ActiveSupport::JSON.encode(serialized)
64
+ end
65
+
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.
86
+ def __ruact_query_kwargs(query, query_method)
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
105
+
106
+ provided.each_with_object({}) do |(key, value), kwargs|
107
+ kwargs[key.to_sym] = value if declared.include?(key) || accepts_rest
108
+ end
109
+ end
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
+
136
+ # D5 — the {Ruact::Server} mutation gate returns false for GET/HEAD so
137
+ # GET *pages* keep stock Rails errors; query dispatch requests are GET
138
+ # *function calls*, so the structured 8.4 payload must render here.
139
+ # `Ruact::ConfigurationError` still re-raises — a misconfiguration is
140
+ # a loud setup failure, never a disguised runtime 500 (the same rule
141
+ # the mutation concern enforces).
142
+ def __ruact_render_structured_error?(error)
143
+ !error.is_a?(Ruact::ConfigurationError)
144
+ end
145
+ end
146
+
147
+ class << self
148
+ # Builds (or rebuilds) the dispatch controller for +query_class+ and
149
+ # installs it under this module's namespace, where the route target
150
+ # string from {.route_target_for} resolves to it.
151
+ #
152
+ # @param query_class [Class] a {Ruact::Query} subclass
153
+ # @return [Class] the generated controller
154
+ # @raise [Ruact::ConfigurationError] when the parent controller cannot
155
+ # be resolved or +query_class+ is anonymous
156
+ def controller_for(query_class)
157
+ *namespace_segments, base = constant_segments(query_class)
158
+ namespace = ensure_namespace(namespace_segments)
159
+ const_name = "#{base}Controller"
160
+ namespace.send(:remove_const, const_name) if namespace.const_defined?(const_name, false)
161
+ namespace.const_set(const_name, build_controller(query_class))
162
+ end
163
+
164
+ # The `to:` route target for +query_class+'s generated controller —
165
+ # the underscored constant path Rails camelizes back at dispatch time.
166
+ # The query class's namespace is PRESERVED (review round 4): a nested
167
+ # path, never flattened, so two classes whose names differ only in
168
+ # namespace boundary (`Admin::CatalogQuery` vs `AdminCatalogQuery`)
169
+ # map to DISTINCT controllers and can never cross-wire — collision is
170
+ # impossible by construction, across any number of RouteSets / engines.
171
+ #
172
+ # @param query_class [Class] a {Ruact::Query} subclass
173
+ # @return [String] e.g. `"ruact/server_functions/query_dispatch/admin/catalog_query"`
174
+ def route_target_for(query_class)
175
+ "ruact/server_functions/query_dispatch/#{path_segments(query_class).join('/')}"
176
+ end
177
+
178
+ private
179
+
180
+ # The underscored route-path segments for +query_class+
181
+ # (`Admin::CatalogQuery` → `["admin", "catalog_query"]`).
182
+ def path_segments(query_class)
183
+ base_segments(query_class).map(&:underscore)
184
+ end
185
+
186
+ # The generated controller's constant-name segments — derived from the
187
+ # SAME underscored path the route target uses, then `camelize`d
188
+ # (review round 5). Deriving both directions from one underscored form
189
+ # via the shared global inflector guarantees the route target Rails
190
+ # `camelize`s at dispatch time resolves to EXACTLY this constant,
191
+ # regardless of how the query class spelled an acronym or how the host
192
+ # configured `inflect.acronym` (`APIProbe::CatalogQuery` and the route
193
+ # `.../api_probe/catalog_query` both canonicalize identically). Using
194
+ # the raw class spelling instead would 404 acronym constants with the
195
+ # default inflector.
196
+ def constant_segments(query_class)
197
+ path_segments(query_class).map(&:camelize)
198
+ end
199
+
200
+ # The query class's fully-qualified name split into constant segments
201
+ # (`Admin::CatalogQuery` → `["Admin", "CatalogQuery"]`). The namespace
202
+ # is preserved so the generated controller lives at a nested,
203
+ # collision-free constant path under {QueryDispatch}.
204
+ def base_segments(query_class)
205
+ name = query_class.name
206
+ unless name
207
+ raise Ruact::ConfigurationError,
208
+ "ruact_queries cannot mount an anonymous Ruact::Query subclass — " \
209
+ "assign it to a constant (e.g. `class CatalogQuery < ApplicationQuery`)."
210
+ end
211
+
212
+ name.split("::")
213
+ end
214
+
215
+ # Walks (creating as needed) the nested module path under {QueryDispatch}
216
+ # that mirrors the query class's namespace, returning the innermost
217
+ # module the controller constant is set on. Idempotent — reuses existing
218
+ # modules so repeated draws (boot + dev reloads) never duplicate them.
219
+ def ensure_namespace(segments)
220
+ segments.reduce(self) do |mod, segment|
221
+ if mod.const_defined?(segment, false)
222
+ mod.const_get(segment, false)
223
+ else
224
+ mod.const_set(segment, Module.new)
225
+ end
226
+ end
227
+ end
228
+
229
+ # Lazy resolution of `Ruact.config.query_parent_controller` (AC2). Both
230
+ # failure shapes are configuration-time errors raised at route-draw —
231
+ # a typo'd name or a non-controller class must never reach a request.
232
+ def resolve_parent_controller
233
+ name = Ruact.config.query_parent_controller
234
+ parent = begin
235
+ name.constantize
236
+ rescue NameError
237
+ raise Ruact::ConfigurationError,
238
+ "ruact_queries: Ruact.config.query_parent_controller = #{name.inspect} does not " \
239
+ "resolve to a constant. Define that controller, or point query_parent_controller " \
240
+ "at an existing one in config/initializers/ruact.rb."
241
+ end
242
+
243
+ unless parent.is_a?(Class) && parent <= ActionController::Metal
244
+ raise Ruact::ConfigurationError,
245
+ "ruact_queries: Ruact.config.query_parent_controller = #{name.inspect} resolved to " \
246
+ "#{parent.inspect}, which is not an ActionController class."
247
+ end
248
+
249
+ parent
250
+ end
251
+
252
+ def build_controller(query_class)
253
+ query_class_name = query_class.name
254
+
255
+ # Mixins applied on the built class (not inside a `Class.new do … end`
256
+ # block) so YARD's static MixinHandler does not emit "Undocumentable
257
+ # mixin … for class" for an anonymous class body — the
258
+ # `--fail-on-warning` docs gate treats that as an error. Runtime is
259
+ # identical.
260
+ controller = Class.new(resolve_parent_controller)
261
+ controller.include(Ruact::ServerFunctions::ErrorRendering)
262
+ controller.include(Dispatching)
263
+
264
+ controller.define_singleton_method(:__ruact_query_class) { query_class_name.constantize }
265
+
266
+ # AC5 — the salvaged 8.4 error chain, with the same front-loading
267
+ # trick as Ruact::Server: handlers the parent chain registered
268
+ # (inherited OR declared later) stay more recent and keep precedence;
269
+ # the structured renderer only catches what the host did not.
270
+ inherited_handlers = controller.rescue_handlers
271
+ controller.rescue_from(StandardError, with: :__ruact_render_action_error)
272
+ controller.rescue_handlers = (controller.rescue_handlers - inherited_handlers) + inherited_handlers
273
+
274
+ define_query_actions(controller, query_class)
275
+ apply_skips(controller, query_class)
276
+ controller
277
+ end
278
+
279
+ # Review round 1 (finding 1) — a query method whose name already exists
280
+ # anywhere on the generated controller chain (`params`, `render`,
281
+ # `session`, `process`, the gem's own `__ruact_*` plumbing, …) would
282
+ # OVERRIDE that method when installed as an action, corrupting request
283
+ # handling (e.g. `def params` shadows `ActionController#params` and
284
+ # recurses through the dispatch path). Reject at route-draw with a
285
+ # legible error instead of failing at the first request.
286
+ def define_query_actions(controller, query_class)
287
+ query_class.public_instance_methods(false).each do |query_method|
288
+ if controller.method_defined?(query_method) || controller.private_method_defined?(query_method)
289
+ raise Ruact::ConfigurationError,
290
+ "ruact_queries: query method :#{query_method} on #{query_class.name} is already " \
291
+ "defined on the dispatch controller chain (#{controller.superclass.name} / " \
292
+ "ActionController / ruact plumbing) and would shadow it — rename the query method."
293
+ end
294
+
295
+ controller.define_method(query_method) do
296
+ __ruact_dispatch_query(query_method)
297
+ end
298
+ end
299
+ end
300
+
301
+ # AC4 / D1 — forwards every recorded `ruact_skip_before_action` to
302
+ # Rails' own `skip_before_action` on the generated controller. An
303
+ # unknown callback raises here (route-draw time) unless the query
304
+ # passed `raise: false`, mirroring stock Rails behavior.
305
+ def apply_skips(controller, query_class)
306
+ query_class.__ruact_skipped_callbacks.each do |callbacks, options|
307
+ controller.skip_before_action(*callbacks, **options)
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
@@ -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
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # In-memory storage for server-function entries. One instance backs
6
+ # `Ruact.action_registry`; another backs `Ruact.query_registry` — kept
7
+ # separate so the JSON snapshot can emit a `kind` field per entry without
8
+ # the call sites having to thread an extra parameter through. Cross-registry
9
+ # JS-identifier collisions are detected by {Ruact::ServerFunctions::Snapshot}
10
+ # at snapshot time (a single registry only sees its own entries).
11
+ #
12
+ # Thread-safety: not thread-safe by design. Registration happens at
13
+ # controller-class load time (`config.to_prepare` in dev, eager-load in
14
+ # production), single-threaded. Reads from {#entries} return a frozen
15
+ # snapshot of the internal hash so concurrent readers cannot observe a
16
+ # partial registration.
17
+ class Registry
18
+ # The only kinds the codegen knows how to emit. Story 8.1 owns `:action`,
19
+ # Story 9.1 owns `:query`. Any other value is rejected at registration
20
+ # time — silent acceptance would otherwise let an unknown kind fall
21
+ # through and be emitted as an action signature.
22
+ ALLOWED_KINDS = %i[action query].freeze
23
+
24
+ def initialize
25
+ @entries = {}
26
+ end
27
+
28
+ # Adds +symbol+ to the registry.
29
+ #
30
+ # @param symbol [Symbol] the Ruby identifier (snake_case).
31
+ # @param kind [Symbol] `:action` or `:query`. Other values raise.
32
+ # @param controller [Class, nil] the controller class registering the
33
+ # function. Used in collision-error messages.
34
+ # @yield the implementation body; stored verbatim for Story 8.1 / 9.1 to
35
+ # invoke. May be nil for Story 8.0a's bootstrap (registries are empty
36
+ # until 8.1 and 9.1 land).
37
+ # @return [Ruact::ServerFunctions::RegistryEntry] the entry just inserted.
38
+ # @raise [Ruact::ConfigurationError] when +symbol+ fails the naming-bridge
39
+ # rule, when +kind+ is not in {ALLOWED_KINDS}, or when a different Ruby
40
+ # symbol already maps to the same JS identifier in THIS registry. Cross-
41
+ # registry collisions (one action + one query sharing a JS identifier)
42
+ # are detected later by {Ruact::ServerFunctions::Snapshot.functions_payload}.
43
+ def register(symbol, kind:, controller: nil, &block)
44
+ validate_kind!(symbol, kind, controller)
45
+ js_identifier = translate_symbol(symbol, controller)
46
+ detect_collision!(symbol, js_identifier, controller)
47
+
48
+ entry = RegistryEntry.new(
49
+ ruby_symbol: symbol,
50
+ js_identifier: js_identifier,
51
+ kind: kind,
52
+ controller: controller,
53
+ block: block
54
+ )
55
+ @entries[symbol] = entry
56
+ entry
57
+ end
58
+
59
+ # @return [Hash{Symbol => Ruact::ServerFunctions::RegistryEntry}] frozen
60
+ # snapshot of the current entries, ordered by insertion.
61
+ def entries
62
+ @entries.dup.freeze
63
+ end
64
+
65
+ # Wipes the registry. Used by `config.to_prepare` (between dev reloads) and
66
+ # by tests that need a clean slate.
67
+ #
68
+ # @return [self]
69
+ def clear!
70
+ @entries.clear
71
+ self
72
+ end
73
+
74
+ # @return [Integer] number of registered entries.
75
+ def size
76
+ @entries.size
77
+ end
78
+
79
+ # @return [Boolean] whether the registry has no entries.
80
+ def empty?
81
+ @entries.empty?
82
+ end
83
+
84
+ private
85
+
86
+ def validate_kind!(symbol, kind, controller)
87
+ return if ALLOWED_KINDS.include?(kind)
88
+
89
+ raise Ruact::ConfigurationError,
90
+ "invalid server-function symbol :#{symbol} in #{describe_controller(controller)}: " \
91
+ "kind #{kind.inspect} is not one of #{ALLOWED_KINDS.inspect}"
92
+ end
93
+
94
+ # Wraps the NameBridge call to attach controller context to the raised
95
+ # error (the AC7 "invalid server-function symbol :SYMBOL in CONTROLLER"
96
+ # shape). NameBridge itself is controller-agnostic; the wrap lives at the
97
+ # registry boundary because that is where controller context exists.
98
+ def translate_symbol(symbol, controller)
99
+ NameBridge.to_js_identifier(symbol)
100
+ rescue Ruact::ConfigurationError => e
101
+ raise Ruact::ConfigurationError,
102
+ "invalid server-function symbol :#{symbol} in #{describe_controller(controller)} — #{e.message}"
103
+ end
104
+
105
+ def detect_collision!(symbol, js_identifier, controller)
106
+ # Re-run-3 (2026-05-15) — TWO failure shapes:
107
+ #
108
+ # (a) Different Ruby symbols, same JS identifier (`:foo_bar` and
109
+ # `:fooBar` both → "fooBar"). Filtered by `js_identifier ==`.
110
+ # (b) Same Ruby symbol declared on TWO different controllers
111
+ # (e.g., `ruact_action :create_post` in both `PostsController`
112
+ # AND `AdminPostsController`). Pre-batch this silently
113
+ # overwrote `@entries[symbol]` with the last-loaded one, so
114
+ # dispatch routed to whichever controller Zeitwerk happened
115
+ # to load last — non-deterministic in dev, surprise breakage
116
+ # when refactoring. Detect by checking the existing entry's
117
+ # `controller` against the one trying to register.
118
+ existing = @entries[symbol]
119
+ if existing && existing.controller != controller
120
+ raise Ruact::ConfigurationError,
121
+ "server-function naming collision: " \
122
+ ":#{symbol} is declared in BOTH " \
123
+ "#{describe_controller(existing.controller)} and " \
124
+ "#{describe_controller(controller)}. Each `ruact_action` " \
125
+ "symbol must be unique across the whole registry — pick a " \
126
+ "more specific name (e.g. :admin_create_post) on one side."
127
+ end
128
+
129
+ collision = @entries.values.find do |e|
130
+ e.js_identifier == js_identifier && e.ruby_symbol != symbol
131
+ end
132
+ return unless collision
133
+
134
+ raise Ruact::ConfigurationError,
135
+ "server-function naming collision: " \
136
+ ":#{symbol} (in #{describe_controller(controller)}) and " \
137
+ ":#{collision.ruby_symbol} (in #{describe_controller(collision.controller)}) " \
138
+ "both map to JS identifier \"#{js_identifier}\""
139
+ end
140
+
141
+ def describe_controller(controller)
142
+ return "unknown controller" if controller.nil?
143
+
144
+ controller.respond_to?(:name) && controller.name ? controller.name : controller.inspect
145
+ end
146
+ end
147
+ end
148
+ end