ruact 0.0.4 → 0.0.6

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -48,30 +48,28 @@ module Ruact
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
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
51
+ # import (`_makeServerFunction`, `_makeQuery`). A server function /
52
+ # query method named `revalidate` (or `use_query`) would emit a
53
53
  # clashing `export const` / duplicate export next to the existing
54
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.
55
+ # at route-draw so the failure surfaces during boot, not at first request.
57
56
  # Story 9.5 added `_makeQuery` (the v2 query import) and `useQuery` (the
58
- # query hook re-export) to this list.
57
+ # query hook re-export) to this list. Story 9.9 removed `_makeRef` (the
58
+ # demolished v1 runtime export).
59
59
  RESERVED_BY_RUACT = %w[
60
60
  _makeQuery
61
- _makeRef
62
61
  _makeServerFunction
63
62
  revalidate
64
63
  useQuery
65
64
  ].to_set.freeze
66
65
 
67
66
  class << self
68
- # @param symbol [Symbol, String] the Ruby identifier registered via
69
- # `ruact_action` / `ruact_query` (Phase 2 stories 8.1 and 9.1).
67
+ # @param symbol [Symbol, String] the Ruby identifier of a routed server
68
+ # function (a mutation action name or a `Ruact::Query` method name).
70
69
  # @return [String] the corresponding JS identifier.
71
70
  # @raise [Ruact::ConfigurationError] when +symbol+ does not match the
72
71
  # allowed shape, is all-underscores, or maps to a JS reserved word —
73
- # caught at controller load time so misnamed routes never reach
74
- # production.
72
+ # caught at route-draw time so misnamed routes never reach production.
75
73
  # @example
76
74
  # Ruact::ServerFunctions::NameBridge.to_js_identifier(:create_post)
77
75
  # # => "createPost"
@@ -83,12 +81,12 @@ module Ruact
83
81
 
84
82
  unless str.match?(VALID_SYMBOL)
85
83
  raise Ruact::ConfigurationError,
86
- "ruact_action / ruact_query symbol :#{symbol} must match /^[a-z_][a-z0-9_]*$/"
84
+ "ruact server-function name :#{symbol} must match /^[a-z_][a-z0-9_]*$/"
87
85
  end
88
86
 
89
87
  if str.match?(UNDERSCORE_ONLY)
90
88
  raise Ruact::ConfigurationError,
91
- "ruact_action / ruact_query symbol :#{symbol} cannot be composed " \
89
+ "ruact server-function name :#{symbol} cannot be composed " \
92
90
  "entirely of underscores (no semantic content)"
93
91
  end
94
92
 
@@ -98,16 +96,16 @@ module Ruact
98
96
 
99
97
  if RESERVED_JS_IDENTIFIERS.include?(js_id)
100
98
  raise Ruact::ConfigurationError,
101
- "ruact_action / ruact_query symbol :#{symbol} maps to JS reserved " \
102
- "word \"#{js_id}\" — pick a different Ruby symbol (e.g. :#{symbol}_action)"
99
+ "ruact server-function name :#{symbol} maps to JS reserved " \
100
+ "word \"#{js_id}\" — pick a different name (e.g. :#{symbol}_fn)"
103
101
  end
104
102
 
105
103
  if RESERVED_BY_RUACT.include?(js_id)
106
104
  raise Ruact::ConfigurationError,
107
- "ruact_action / ruact_query symbol :#{symbol} maps to \"#{js_id}\", " \
105
+ "ruact server-function name :#{symbol} maps to \"#{js_id}\", " \
108
106
  "which is already exported by the ruact runtime from " \
109
107
  "`@/.ruact/server-functions` and would emit a duplicate export. " \
110
- "Pick a different Ruby symbol (e.g. :#{symbol}_action)."
108
+ "Pick a different name (e.g. :#{symbol}_fn)."
111
109
  end
112
110
 
113
111
  js_id
@@ -57,6 +57,10 @@ module Ruact
57
57
  # `js_identifier`; shape: `js_identifier`, `kind` (always `"query"`),
58
58
  # `http_method` (always `"GET"`), `path`, `segments` (always `[]`),
59
59
  # `accepts_params` (Boolean — does the method declare kwargs?),
60
+ # `params` (Array<Hash> — Story 13.4: one `{ "name" => String,
61
+ # "required" => Boolean }` per declared `keyreq`/`key` keyword, in
62
+ # declaration order; empty when none), `params_rest` (Boolean —
63
+ # Story 13.4: does the method declare a `**keyrest`?),
60
64
  # `controller` (the query class name — for collision origins),
61
65
  # `action` (the Ruby method name).
62
66
  # @raise [Ruact::ConfigurationError] on a query×query naming collision.
@@ -84,27 +88,50 @@ module Ruact
84
88
  private
85
89
 
86
90
  def build_entry(route, action, query_class)
91
+ params, params_rest = param_metadata(query_class, action)
87
92
  {
88
93
  "js_identifier" => NameBridge.to_js_identifier(action),
89
94
  "kind" => "query",
90
95
  "http_method" => "GET",
91
96
  "path" => clean_path(route),
92
97
  "segments" => [],
93
- "accepts_params" => accepts_params?(query_class, action),
98
+ # Story 13.4 — keep `accepts_params` (derived) for back-compat: any
99
+ # named kwarg OR a `**keyrest` means the accessor takes params.
100
+ "accepts_params" => !params.empty? || params_rest,
101
+ "params" => params,
102
+ "params_rest" => params_rest,
94
103
  "controller" => query_class.name,
95
104
  "action" => action
96
105
  }
97
106
  end
98
107
 
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)
108
+ # Story 13.4 per-kwarg metadata driving the typed TS `params` object
109
+ # (AC1). Reflects `Method#parameters` (names + required/optional only
110
+ # Ruby exposes no types/defaults): `:keyreq` required prop, `:key` →
111
+ # optional prop (both in declaration order, the stable order
112
+ # `parameters` returns), `:keyrest` (`**opts`) → an open-record marker
113
+ # so a legitimate dynamic key is never narrowed away (fail open).
114
+ # Positional/block params are not FR88 query params and are ignored.
115
+ #
116
+ # @param query_class [Class]
117
+ # @param action [String]
118
+ # @return [Array(Array<Hash>, Boolean)] the per-kwarg descriptors and
119
+ # whether a `**keyrest` is present.
120
+ def param_metadata(query_class, action)
121
+ params = []
122
+ rest = false
123
+ query_class.instance_method(action).parameters.each do |(type, name)|
124
+ next unless KEYWORD_PARAM_TYPES.include?(type)
125
+
126
+ if type == :keyrest
127
+ rest = true
128
+ else
129
+ params << { "name" => name.to_s, "required" => type == :keyreq }
130
+ end
105
131
  end
132
+ [params, rest]
106
133
  rescue NameError
107
- false
134
+ [[], false]
108
135
  end
109
136
 
110
137
  # query×query collision — two mounted query classes whose methods map
@@ -9,9 +9,8 @@ module Ruact
9
9
  #
10
10
  # The route table is the single source of truth (FR61): every non-GET routed
11
11
  # action on a controller that includes {Ruact::Server} is a callable server
12
- # function. This module is the route-driven replacement for the v1 registry
13
- # source consumed by {Ruact::ServerFunctions::Snapshot} — it reads routes,
14
- # not `ruact_action` declarations.
12
+ # function. As of Story 9.9 this is the SOLE codegen source consumed by
13
+ # {Ruact::ServerFunctions::Snapshot} — it reads the drawn routes directly.
15
14
  #
16
15
  # Pure by construction: {.collect} takes the route set and two resolver
17
16
  # callables (host predicate + override lookup). The railtie passes the real
@@ -96,7 +95,7 @@ module Ruact
96
95
  # (after rename overrides) would emit two `export const` lines the same
97
96
  # name; fail loudly at boot naming BOTH origins so the dev knows exactly
98
97
  # which routes to disambiguate (via `ruact_function_name`). Mirrors the
99
- # cross-registry collision raise in {Ruact::ServerFunctions::Snapshot}.
98
+ # merged-namespace collision raise in {Ruact::ServerFunctions}.
100
99
  def detect_collisions!(entries)
101
100
  entries.group_by { |entry| entry["js_identifier"] }.each do |js_id, group|
102
101
  next if group.size < 2
@@ -5,98 +5,19 @@ require "time"
5
5
 
6
6
  module Ruact
7
7
  module ServerFunctions
8
- # Pure functions that build the JSON-shaped Hash representing both server-
9
- # function registries. Serialized to `tmp/cache/ruact/server-functions.json`
10
- # by {.generate!}; the Vite plugin reads that file and emits the TS module.
8
+ # Pure functions that build the JSON-shaped Hash representing the route-
9
+ # driven server-function bridge. Serialized to
10
+ # `tmp/cache/ruact/server-functions.json` by {.generate_v2!}; the Vite
11
+ # plugin reads that file and emits the TS module.
11
12
  #
12
- # The "functions" array is sorted by `ruby_symbol` for deterministic output
13
- # so that fingerprint comparisons (used by the write-if-changed guard) are
14
- # stable across runs. Cross-registry JS-identifier collisions are detected
15
- # here (the per-registry `Registry#register` only sees its own entries; a
16
- # `ruact_action :foo` colliding with a `ruact_query :foo` is invisible to
17
- # both registries in isolation).
13
+ # Story 9.9 the v1 registry path was demolished; only the route-driven
14
+ # (version-2) schema remains.
18
15
  module Snapshot
19
- # Bump only when the on-disk schema changes incompatibly. The Vite plugin
20
- # must be updated in lockstep.
21
- VERSION = 1
22
-
23
16
  # Story 9.3 — the route-driven snapshot schema. v2 entries are produced by
24
- # {Ruact::ServerFunctions::RouteSource} (route table), not the registries.
17
+ # {Ruact::ServerFunctions::RouteSource} (route table), not registries.
25
18
  VERSION_V2 = 2
26
19
 
27
20
  class << self
28
- # Builds the snapshot Hash for both registries. Pure. See also
29
- # {.generate!} (writes to disk) and {.functions_payload} (fingerprint
30
- # surface).
31
- #
32
- # @param action_registry [Ruact::ServerFunctions::Registry]
33
- # @param query_registry [Ruact::ServerFunctions::Registry]
34
- # @param now [Time] timestamp to stamp into `generated_at` (UTC, ISO-8601).
35
- # @return [Hash] the serializable snapshot.
36
- # @raise [Ruact::ConfigurationError] when a JS identifier is registered
37
- # in both registries (cross-registry collision; see {.functions_payload}).
38
- def dump(action_registry, query_registry, now: Time.now.utc)
39
- {
40
- version: VERSION,
41
- generated_at: now.utc.iso8601,
42
- functions: functions_payload(action_registry, query_registry)
43
- }
44
- end
45
-
46
- # Returns the payload-only array of function entries, sorted by
47
- # `ruby_symbol`. Used both inside {.dump} and as the fingerprint surface
48
- # by {.generate!}'s short-circuit (so timestamp churn alone never causes
49
- # a rewrite). Detects cross-registry JS-identifier collisions and
50
- # raises before emitting — a `ruact_action :foo` and `ruact_query :foo`
51
- # would emit two `export const foo` lines at codegen, which `tsc` rejects.
52
- #
53
- # @return [Array<Hash>] each entry has string keys per the JSON contract.
54
- # @raise [Ruact::ConfigurationError] when the action and query registries
55
- # both contain entries that map to the same JS identifier.
56
- def functions_payload(action_registry, query_registry)
57
- combined = action_registry.entries.values + query_registry.entries.values
58
- detect_cross_registry_collision!(combined)
59
- combined.sort_by { |entry| entry.ruby_symbol.to_s }.map do |entry|
60
- {
61
- "ruby_symbol" => entry.ruby_symbol.to_s,
62
- "js_identifier" => entry.js_identifier,
63
- "kind" => entry.kind.to_s,
64
- "controller" => describe_controller(entry.controller)
65
- }
66
- end
67
- end
68
-
69
- # Builds the snapshot and writes it to +path+, but only if the
70
- # functions list differs from the on-disk snapshot. This is the central
71
- # short-circuit that prevents `config.to_prepare` from rewriting the
72
- # file on every request (Story 8.0a pitfall #1): the JSON's
73
- # `generated_at` is freshly stamped only when the registry actually
74
- # changed; otherwise the on-disk content stays byte-identical.
75
- #
76
- # The short-circuit compares **both** `version` and `functions` against
77
- # the on-disk snapshot — a schema bump (`VERSION` increment) forces a
78
- # rewrite even when the registry payload is unchanged, so the Vite
79
- # plugin never reads a stale-version snapshot after a gem upgrade.
80
- #
81
- # @param action_registry [Ruact::ServerFunctions::Registry]
82
- # @param query_registry [Ruact::ServerFunctions::Registry]
83
- # @param path [String, Pathname] absolute path to the snapshot JSON.
84
- # @param now [Time] timestamp used when (and only when) the file is rewritten.
85
- # @return [Boolean] true if the file was written; false if no change.
86
- def generate!(action_registry:, query_registry:, path:, now: Time.now.utc)
87
- new_functions = functions_payload(action_registry, query_registry)
88
-
89
- existing_version, existing_functions = read_existing_snapshot(path)
90
- return false if existing_version == VERSION && existing_functions == new_functions
91
-
92
- snapshot = {
93
- version: VERSION,
94
- generated_at: now.utc.iso8601,
95
- functions: new_functions
96
- }
97
- SnapshotWriter.write_if_changed!(path: path, content: "#{JSON.pretty_generate(snapshot)}\n")
98
- end
99
-
100
21
  # Story 9.3 — wraps route-derived +entries+ into a version-2 snapshot
101
22
  # Hash (the shape {Codegen.render} dispatches on). Pure.
102
23
  #
@@ -110,11 +31,11 @@ module Ruact
110
31
  }
111
32
  end
112
33
 
113
- # Story 9.3 — write-if-changed for the route-driven (v2) bridge. Mirrors
114
- # {.generate!}: `generated_at` is freshly stamped only when the entries
115
- # changed, so a stable route table never churns the file (and never
116
- # re-triggers downstream TS rendering). A schema mismatch (`version`)
117
- # forces a rewrite even when entries are unchanged.
34
+ # Story 9.3 — write-if-changed for the route-driven (v2) bridge.
35
+ # `generated_at` is freshly stamped only when the entries changed, so a
36
+ # stable route table never churns the file (and never re-triggers
37
+ # downstream TS rendering). A schema mismatch (`version`) forces a
38
+ # rewrite even when entries are unchanged.
118
39
  #
119
40
  # @param entries [Array<Hash>] from {RouteSource.collect}.
120
41
  # @param path [String, Pathname] absolute path to the v2 bridge JSON.
@@ -127,56 +48,8 @@ module Ruact
127
48
  SnapshotWriter.write_if_changed!(path: path, content: "#{JSON.pretty_generate(snapshot)}\n")
128
49
  end
129
50
 
130
- # Story 9.3 — the Decision-#6 ownership primitive. True when the app has
131
- # ANY v1 declaration (`ruact_action` / `ruact_query`). Story 9.8 consults
132
- # this to decide whether route-driven codegen takes over the real
133
- # `server-functions.ts`; in Story 9.3 the v2 codegen always writes the
134
- # parallel `.next` target regardless, so this is informational here.
135
- #
136
- # @return [Boolean]
137
- def v1_declarations?(action_registry, query_registry)
138
- !(action_registry.entries.empty? && query_registry.entries.empty?)
139
- end
140
-
141
51
  private
142
52
 
143
- def detect_cross_registry_collision!(entries)
144
- by_js_id = entries.group_by(&:js_identifier).select { |_, group| group.size >= 2 }
145
- return if by_js_id.empty?
146
-
147
- # If both rows are the same Ruby symbol it is a within-registry duplicate
148
- # (caught by Registry#register's own collision detection). A genuine
149
- # cross-registry collision is any group whose entries span more than
150
- # one kind — i.e. one action + one query share the same js_identifier.
151
- cross = by_js_id.find do |_, group|
152
- kinds = group.map(&:kind).uniq
153
- kinds.size > 1
154
- end
155
- return unless cross
156
-
157
- js_id, group = cross
158
- # AC7 exact shape: `:foo_bar (in FooController) and :foo__bar (in BarController)`
159
- # — no `kind:` annotation. The kind differentiation is implicit in
160
- # the cross-registry collision being detected at all.
161
- parts = group.map do |entry|
162
- ":#{entry.ruby_symbol} (in #{describe_controller(entry.controller)})"
163
- end
164
- # AC7 shape: "[ruact] error: server-function naming collision: ..."
165
- # The rake task wraps the bare message with "[ruact] error: " — keep
166
- # the prefix in sync with the within-registry message in
167
- # Registry#detect_collision! so the rake stdout reads identically
168
- # for both kinds of collision.
169
- raise Ruact::ConfigurationError,
170
- "server-function naming collision: " \
171
- "#{parts.join(' and ')} both map to JS identifier \"#{js_id}\""
172
- end
173
-
174
- def describe_controller(controller)
175
- return nil if controller.nil?
176
-
177
- controller.respond_to?(:name) && controller.name ? controller.name : controller.inspect
178
- end
179
-
180
53
  # Reads `(version, functions)` from the on-disk snapshot. Returns
181
54
  # `[nil, nil]` when the file is missing, vanished between the stat and
182
55
  # the read (TOCTOU race fix — `File.exist?` removed; we catch `ENOENT`
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # Story 13.3 (FR98) — pure normalizer for the Inertia-style validation
6
+ # `errors` round-trip. Turns whatever a host action has on hand after a save
7
+ # attempt — an ActiveModel-ish record (responds to `#errors`), a raw
8
+ # `ActiveModel::Errors`, or a pre-shaped Hash — into ONE canonical wire
9
+ # shape — e.g. `"title" => ["Title can't be blank"]` and
10
+ # `"base" => ["is invalid overall"]` collected into one
11
+ # `Hash{String=>Array<String>}` — attribute names as strings (a
12
+ # `base`-level error keys under `"base"`), values arrays of human-readable
13
+ # **full messages**. A valid record (empty `#errors`) yields `{}`, never
14
+ # `nil`/`undefined`, so a single client code path handles both success and
15
+ # failure (the "always present" invariant of AC1).
16
+ #
17
+ # Pure: no `Rails` constant references at load, no `Ruact.config` / request
18
+ # reads — duck-typing only (mirrors the caller/builder split of
19
+ # {ErrorPayload} / {BucketTwoPayload}). The caller (`Ruact::Server#ruact_errors`)
20
+ # owns the per-request collector and the bucket/flash wiring; this module
21
+ # only answers "what is the canonical shape of this source?".
22
+ module ValidationErrors
23
+ class << self
24
+ # Normalize a validation-error source into the canonical Hash shape.
25
+ #
26
+ # Accepts, in precedence order:
27
+ # - `nil` → `{}`
28
+ # - a Hash already shaped `{attr => [msgs]}` → keys coerced to String,
29
+ # scalar values wrapped in a one-element Array, every message
30
+ # coerced to String; idempotent on already-canonical input.
31
+ # - an object responding to `#group_by_attribute` (a raw
32
+ # `ActiveModel::Errors`) → grouped into `{attr => [full_message]}`.
33
+ # - an object responding to `#errors` (an ActiveModel-ish record) →
34
+ # normalized from its `#errors`.
35
+ # - anything else → `{}` (no errors discoverable).
36
+ #
37
+ # @param source [nil, Hash, #group_by_attribute, #errors] the error source
38
+ # @return [Hash{String=>Array<String>}] the canonical, always-present shape
39
+ def normalize(source)
40
+ return {} if source.nil?
41
+ return normalize_hash(source) if source.is_a?(Hash)
42
+ return group_errors(source) if source.respond_to?(:group_by_attribute)
43
+ return normalize(source.errors) if source.respond_to?(:errors)
44
+
45
+ {}
46
+ end
47
+
48
+ private
49
+
50
+ # A raw `ActiveModel::Errors`: group its `Error` objects by attribute and
51
+ # render each as a full message (`"Title can't be blank"`).
52
+ def group_errors(errors)
53
+ errors.group_by_attribute.each_with_object({}) do |(attribute, attribute_errors), acc|
54
+ acc[attribute.to_s] = attribute_errors.map(&:full_message)
55
+ end
56
+ end
57
+
58
+ # A pre-shaped Hash: stringify keys, wrap scalar values in a one-element
59
+ # array, stringify every message. `Array()` makes a `nil` value collapse
60
+ # to `[]` and is a no-op on an existing Array (idempotent on canonical
61
+ # input).
62
+ def normalize_hash(hash)
63
+ hash.each_with_object({}) do |(key, value), acc|
64
+ acc[key.to_s] = Array(value).map(&:to_s)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,52 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Namespace for the server-functions subsystem (Story 8.0a — codegen surface that
4
- # emits app/javascript/.ruact/server-functions.ts from the gem-side registries).
3
+ # Namespace for the server-functions subsystem (Story 8.0a — codegen surface
4
+ # that emits app/javascript/.ruact/server-functions.ts from the Rails route
5
+ # table). Story 9.9 demolished the v1 registry path; the route table is now the
6
+ # sole source of truth.
5
7
  #
6
8
  # - {Ruact::ServerFunctions::NameBridge} — Ruby symbol → JS identifier translation
7
9
  # (single source of truth; the Vite plugin reads the already-translated identifier
8
10
  # from the JSON snapshot and emits it as-is).
9
- # - {Ruact::ServerFunctions::RegistryEntry} — immutable record for a single
10
- # registered server function.
11
- # - {Ruact::ServerFunctions::Registry} — storage + register/clear + collision
12
- # detection. Populated by Story 8.1 (`ruact_action`) and Story 9.1 (`ruact_query`).
13
- # - {Ruact::ServerFunctions::Snapshot} — pure function: registries → JSON-shaped Hash.
11
+ # - {Ruact::ServerFunctions::RouteSource} — collects mutation (non-GET) actions
12
+ # from the drawn route table.
13
+ # - {Ruact::ServerFunctions::QuerySource} — collects read queries from the
14
+ # `ruact_queries`-drawn GET routes.
15
+ # - {Ruact::ServerFunctions::Snapshot} — pure function: route entries → JSON-shaped Hash.
14
16
  # - {Ruact::ServerFunctions::SnapshotWriter} — atomic, write-if-changed file I/O.
15
17
  # - {Ruact::ServerFunctions::Codegen} — snapshot Hash → TypeScript module string.
16
- #
17
- # Empty registries are valid (Story 8.0a ships them empty; 8.1 and 9.1 populate).
18
18
  require "json"
19
19
 
20
20
  module Ruact
21
21
  module ServerFunctions
22
22
  autoload :NameBridge, "ruact/server_functions/name_bridge"
23
- autoload :RegistryEntry, "ruact/server_functions/registry_entry"
24
- autoload :Registry, "ruact/server_functions/registry"
25
23
  autoload :Snapshot, "ruact/server_functions/snapshot"
26
24
  autoload :SnapshotWriter, "ruact/server_functions/snapshot_writer"
27
25
  autoload :Codegen, "ruact/server_functions/codegen"
28
26
  autoload :RouteSource, "ruact/server_functions/route_source"
29
27
  autoload :QuerySource, "ruact/server_functions/query_source"
30
28
  autoload :ErrorRendering, "ruact/server_functions/error_rendering"
31
- autoload :EndpointController, "ruact/server_functions/endpoint_controller"
32
- autoload :StandaloneContext, "ruact/server_functions/standalone_context"
33
- autoload :StandaloneDispatcher, "ruact/server_functions/standalone_dispatcher"
29
+ autoload :ValidationErrors, "ruact/server_functions/validation_errors"
34
30
  autoload :QueryContext, "ruact/server_functions/query_context"
35
31
  autoload :QueryDispatch, "ruact/server_functions/query_dispatch"
36
32
 
37
- # Story 9.3 — orchestrates the route-driven (v2) codegen target. Reads the
38
- # route table via {RouteSource}, writes the version-2 bridge to the PARALLEL
39
- # `.next` path (write-if-changed), and renders the inspection TS via the
40
- # Ruby {Codegen} (Vite does not watch `.next`). Per AC5 the `.next` target is
41
- # for parity tests + inspection only never imported by application code —
42
- # so the real `server-functions.ts` (v1, rendered by Vite) is untouched
43
- # (AC6). The Decision-#6 ownership flip (zero v1 declarations → v2 owns the
44
- # real file) is Story 9.8's job.
33
+ # Story 9.3 / 9.9 — orchestrates the route-driven (v2) codegen. Reads the
34
+ # route table via {RouteSource} + {QuerySource}, writes the version-2 bridge
35
+ # to the REAL path (write-if-changed), and renders the TS via the Ruby
36
+ # {Codegen}. As of Story 9.9 this is the SOLE writer of the real bridge —
37
+ # the v1 registry path and the parallel `.next` target were demolished
38
+ # (epic decision #6: route-driven codegen owns `server-functions.ts`
39
+ # unconditionally; the module path `@/.ruact/server-functions` never changed,
40
+ # so React imports are unchanged by construction).
45
41
  #
46
42
  # AC2 — transparency over silence: the exposed names are ALWAYS logged so a
47
43
  # routed non-GET action never becomes a callable server function silently.
48
44
  #
49
- # Story 9.5 — the `entries` array now carries BOTH mutation actions (from
45
+ # Story 9.5 — the `entries` array carries BOTH mutation actions (from
50
46
  # {RouteSource}) and read queries (from {QuerySource}); they share ONE
51
47
  # merged JS namespace. {.detect_merged_namespace_collisions!} catches a
52
48
  # route×query clash (each source already catches its own intra-kind
@@ -64,8 +60,8 @@ module Ruact
64
60
  entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
65
61
  detect_merged_namespace_collisions!(entries)
66
62
 
67
- json_path = root.join("tmp/cache/ruact/server-functions.next.json")
68
- ts_path = root.join("app/javascript/.ruact/server-functions.next.ts")
63
+ json_path = root.join("tmp/cache/ruact/server-functions.json")
64
+ ts_path = root.join("app/javascript/.ruact/server-functions.ts")
69
65
 
70
66
  # Read back from the on-disk bridge (not a fresh dump) so a stable route
71
67
  # table never churns the timestamp baked into the rendered TS header.