ruact 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +74 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +79 -425
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +71 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +70 -203
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +109 -0
- data/lib/ruact.rb +20 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +70 -328
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/manifest_resolver_spec.rb +159 -0
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +15 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +58 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- 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 (`
|
|
52
|
-
# `
|
|
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
|
|
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
|
|
69
|
-
#
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
102
|
-
"word \"#{js_id}\" — pick a different
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
13
|
-
#
|
|
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
|
-
#
|
|
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
|
|
9
|
-
# function
|
|
10
|
-
# by {.
|
|
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
|
-
#
|
|
13
|
-
#
|
|
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
|
|
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.
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
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
|
|
4
|
-
# emits app/javascript/.ruact/server-functions.ts from the
|
|
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::
|
|
10
|
-
#
|
|
11
|
-
# - {Ruact::ServerFunctions::
|
|
12
|
-
#
|
|
13
|
-
# - {Ruact::ServerFunctions::Snapshot} — pure function:
|
|
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 :
|
|
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
|
|
38
|
-
# route table via {RouteSource}, writes the version-2 bridge
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
|
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.
|
|
68
|
-
ts_path = root.join("app/javascript/.ruact/server-functions.
|
|
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.
|