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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- 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 +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -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/railtie.rb +56 -200
- 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 +102 -0
- data/lib/ruact.rb +19 -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 +63 -326
- 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/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 +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -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 +55 -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
|
@@ -1777,3 +1777,405 @@ kwargs contract is enforced server-side. Append-only addendum to the
|
|
|
1777
1777
|
`server-functions.ts` is 9.8). Request de-duplication for `useQuery` is 9.6
|
|
1778
1778
|
(the hook fetches once per mount; a `useSyncExternalStore` refactor can layer
|
|
1779
1779
|
dedup later). The v1 substrate is untouched (demolition is 9.9).
|
|
1780
|
+
|
|
1781
|
+
### 2026-06-25 — Story 13.1 — Serialize-only invariant (FR97)
|
|
1782
|
+
|
|
1783
|
+
Epic 13 ("Server-Function Contract & Safety") opens by turning a *happy accident
|
|
1784
|
+
of architecture* into a **defended invariant**. The 2026-06-15 technical research
|
|
1785
|
+
report established that ruact is **serialize-only**: its Ruby side *emits* React
|
|
1786
|
+
Flight (`text/x-component`) but has **no inbound Flight deserializer**, so it sits
|
|
1787
|
+
structurally **outside** the React2Shell / **CVE-2025-55182** class — a Flight-
|
|
1788
|
+
deserialization RCE (CVSS 10, Dec 2025) in which an attacker-supplied Flight
|
|
1789
|
+
payload is deserialized into live server objects. Append-only addendum; supersedes
|
|
1790
|
+
nothing.
|
|
1791
|
+
|
|
1792
|
+
1. **The invariant (the rule).** ruact **may emit** Flight (`text/x-component`)
|
|
1793
|
+
from the Ruby server, but must **never deserialize externally-supplied Flight
|
|
1794
|
+
into live Ruby objects.** No inbound `*Deserializer`, no `parse_flight` /
|
|
1795
|
+
`from_flight` / `deserialize_flight` / `decode_flight` reading a request body,
|
|
1796
|
+
no Ruby call to a React Flight reader (`createFromNodeStream` /
|
|
1797
|
+
`createFromReadableStream` / `createFromFetch`). Reads stay GET-with-primitives (FR88); writes stay
|
|
1798
|
+
form/JSON over the host's own CSRF — neither path parses Flight inbound.
|
|
1799
|
+
|
|
1800
|
+
2. **Out of scope — the client `createFromFlightPayload`.** The generated
|
|
1801
|
+
client entry (`lib/generators/ruact/install/templates/application.jsx.tt`)
|
|
1802
|
+
calls `createFromFlightPayload` to deserialize the server's **own trusted**
|
|
1803
|
+
payload in the **browser**. That is normal RSC on the client — not the Ruby
|
|
1804
|
+
server, not attacker-controlled inbound Flight — and is explicitly **outside**
|
|
1805
|
+
this invariant. The tripwire scans Ruby source only and excludes the
|
|
1806
|
+
generators' client-side templates.
|
|
1807
|
+
|
|
1808
|
+
3. **Enforcement — a `Ruact::Doctor` tripwire, not a parser.** `check_serialize_only`
|
|
1809
|
+
greps ruact's own `lib/**/*.rb` (default root `Ruact.gem_path/lib`, injectable
|
|
1810
|
+
for specs) for a curated list of structural inbound-deserialization signals and
|
|
1811
|
+
**fails** on any unannotated hit, naming `file:line` + this invariant. The raw
|
|
1812
|
+
`text/x-component` token is deliberately **not** a signal — the gem legitimately
|
|
1813
|
+
*emits* that media type (`controller.rb` / `server.rb`), so matching it would
|
|
1814
|
+
false-fail the (invariant-holding) current tree. Today the check **passes
|
|
1815
|
+
silently**. AST parsing was rejected as over-engineering for a tripwire whose
|
|
1816
|
+
job is to *stay at zero* (consistent with the single-dependency / "make invalid
|
|
1817
|
+
states unconstructible, simply" discipline). The signal literals are assembled
|
|
1818
|
+
from fragments via `Array#join` and `doctor.rb` is excluded from its own scan
|
|
1819
|
+
by exact file path — not basename, so a differently-located future namesake is
|
|
1820
|
+
still scanned (mirrors the Story 5.1 F4 self-reference lesson).
|
|
1821
|
+
|
|
1822
|
+
4. **Escape hatch — `# ruact:allow-flight-deserialization <reason>`.** A line
|
|
1823
|
+
carrying that annotation is treated as guarded, so a deliberate, reviewed
|
|
1824
|
+
deserializer is allowed without failing. This is what makes the check a
|
|
1825
|
+
**guard** rather than a blanket ban.
|
|
1826
|
+
|
|
1827
|
+
5. **Middleware warn (non-failing).** `check_flight_middleware` introduces a new
|
|
1828
|
+
`:warn` doctor status (rendered `⚠`; a `:warn` does **not** flip the run to
|
|
1829
|
+
failure — the pass computation moved from `status == :pass` to an allowlist,
|
|
1830
|
+
`SUCCESS_STATUSES = %i[pass warn]`, so a `:warn` passes while any unexpected
|
|
1831
|
+
status fails loudly rather than being silently treated as a pass).
|
|
1832
|
+
It warns when a response-transforming middleware (`Rack::Deflater` + a small
|
|
1833
|
+
curated list) is mounted, since recompressing/mutating a streamed
|
|
1834
|
+
`text/x-component` body breaks the Flight wire contract / streaming
|
|
1835
|
+
(React-on-Rails ops lesson). Rack middleware is app-global, not per-route, so
|
|
1836
|
+
the warn cannot be scoped precisely to Flight routes — it points the operator
|
|
1837
|
+
at the remedy (exclude `text/x-component` from compression).
|
|
1838
|
+
|
|
1839
|
+
6. **Where it earns its keep.** The primary venue is the gem's own CI (the
|
|
1840
|
+
`:story_13_1` spec runs the tripwire against a fixture tree). In a host app,
|
|
1841
|
+
`rails ruact:doctor` scanning `Ruact.gem_path/lib` is defense-in-depth — a
|
|
1842
|
+
released gem won't contain a deserializer, so it passes; its value there is
|
|
1843
|
+
catching a hand-patched/vendored gem.
|
|
1844
|
+
|
|
1845
|
+
7. **Scope guards.** This story ships ONLY the ADR addendum + the two doctor
|
|
1846
|
+
checks + specs. No SignedGlobalID (13.2 / FR96), no error round-trip (13.3 /
|
|
1847
|
+
FR98), no TS emission (13.4 / FR99), no component contract (13.5 / FR100), no
|
|
1848
|
+
playground (13.6). The client-side React deserialization path is untouched.
|
|
1849
|
+
|
|
1850
|
+
### 2026-06-26 — Story 13.2 — SignedGlobalID record references (FR96)
|
|
1851
|
+
|
|
1852
|
+
The inbound-safety counterpart to the serialize-only invariant (13.1). The
|
|
1853
|
+
serialize-only guard keeps ruact from *deserializing* hostile Flight; this story
|
|
1854
|
+
keeps a controller from handing the client a *forgeable* record reference in the
|
|
1855
|
+
first place. Decisions:
|
|
1856
|
+
|
|
1857
|
+
1. **The canonical record-reference primitive is a SignedGlobalID, opt-in via an
|
|
1858
|
+
explicit helper.** `Ruact.signed_global_id(record, for:, expires_in:)` returns
|
|
1859
|
+
`record.to_sgid(for:, expires_in:).to_s` (a plain `String` → the Flight
|
|
1860
|
+
serializer carries it unchanged, no serializer branch). `Ruact.locate_signed(
|
|
1861
|
+
token, for:)` wraps `GlobalID::Locator.locate_signed`. Chosen over (a)
|
|
1862
|
+
hand-rolled `to_sgid` (no loud-omission guard, no symmetry) and (c)
|
|
1863
|
+
auto-serializing every `ActiveRecord::Base` (magic; conflicts with the
|
|
1864
|
+
`ruact_props` explicit-allowlist grain; can't carry a per-site `for:`). The
|
|
1865
|
+
opt-in is "the developer reaches for the helper," surfaced in docs as the
|
|
1866
|
+
canonical pattern.
|
|
1867
|
+
|
|
1868
|
+
2. **Inbound resolution is explicit, never automatic.** The action/query calls
|
|
1869
|
+
`Ruact.locate_signed` itself; the strict param allowlist (`query_dispatch.rb`)
|
|
1870
|
+
is untouched — no auto-detect-and-resolve of token-shaped strings (a param may
|
|
1871
|
+
legitimately be a string that merely looks like a token; auto-resolution would
|
|
1872
|
+
couple the param layer to ActiveRecord and surprise developers).
|
|
1873
|
+
|
|
1874
|
+
3. **Loud-omission invariant — never a silent insecure default.** Purpose and
|
|
1875
|
+
expiry resolve call-arg → config default → **raise** `Ruact::Error`. Purpose
|
|
1876
|
+
is always required (an unscoped token is never acceptable, so even an explicit
|
|
1877
|
+
`for: nil` raises). Expiry distinguishes *omission* from a deliberate choice
|
|
1878
|
+
via a private `UNSET` sentinel: pure omission with no configured default
|
|
1879
|
+
raises; an explicit `expires_in: nil` is honored as a reviewed non-expiring
|
|
1880
|
+
token. `expires_in:` must be an `ActiveSupport::Duration` (globalid calls
|
|
1881
|
+
`#from_now`).
|
|
1882
|
+
|
|
1883
|
+
4. **Tampered/expired/wrong-purpose → clean 400.** `locate_signed` returns `nil`
|
|
1884
|
+
on any verification failure; the helper raises `Ruact::InvalidSignedGlobalIDError`,
|
|
1885
|
+
mapped to **400** in `__ruact_status_for` (alongside `BadRequestError`). 400
|
|
1886
|
+
over 404 because the token is an invalid client *credential*, not a missing
|
|
1887
|
+
record — and verification fails before any DB lookup, so record existence is
|
|
1888
|
+
never revealed either way. No `ActiveRecord::RecordNotFound` leak; the
|
|
1889
|
+
rejection message never echoes the raw token.
|
|
1890
|
+
|
|
1891
|
+
5. **Single-dependency discipline preserved.** `globalid` ships with every Rails
|
|
1892
|
+
app but is not a gemspec dependency (only `nokogiri` is). It is **lazily
|
|
1893
|
+
required** inside the helpers on first call (mirroring the class-name-string
|
|
1894
|
+
matching in `ErrorRendering` that avoids requiring ActiveRecord at load), so
|
|
1895
|
+
the gem stays loadable in pure-Ruby contexts and the gemspec is unchanged.
|
|
1896
|
+
|
|
1897
|
+
6. **Scope guards.** This story ships ONLY the two helpers + the error class +
|
|
1898
|
+
400 mapping + two config keys + security docs + specs. No error round-trip
|
|
1899
|
+
(13.3 / FR98), no TS emission (13.4 / FR99), no component contract (13.5 /
|
|
1900
|
+
FR100), no playground (13.6). The Epic 10 scaffold generator does not exist
|
|
1901
|
+
yet — the docs worked-example is the canonical pattern it will later emit.
|
|
1902
|
+
|
|
1903
|
+
#### 2026-06-26 — Story 13.2 review patch (dev⇄Codex)
|
|
1904
|
+
|
|
1905
|
+
Three findings, all resolved before merge:
|
|
1906
|
+
- **(Patch)** `InvalidSignedGlobalIDError`'s YARD comment was inserted between
|
|
1907
|
+
`UploadTooLargeError`'s doc block and its class, detaching the upload docs →
|
|
1908
|
+
moved the new error + comment to sit right after `BadRequestError`.
|
|
1909
|
+
- **(Patch)** the production-mode no-leak spec asserted `not_to have_key("backtrace")`,
|
|
1910
|
+
but the dev-only payload keys are `app_frames` / `gem_frames` / `suggestion` →
|
|
1911
|
+
the spec now asserts the response is exactly the four baseline keys.
|
|
1912
|
+
- **(Decision, Luiz)** a **valid** token whose record was since **deleted** is
|
|
1913
|
+
out of AC2's three rejection cases (tampered/expired/wrong-purpose, which never
|
|
1914
|
+
reach the finder). **Resolved: do NOT normalize.** The finder's
|
|
1915
|
+
`ActiveRecord::RecordNotFound` propagates as the host's ordinary not-found
|
|
1916
|
+
concern (identical to a raw `Model.find`); the primitive owns only signature/
|
|
1917
|
+
expiry/purpose verification. Documented on `InvalidSignedGlobalIDError` and
|
|
1918
|
+
pinned by a spec.
|
|
1919
|
+
- YARD `{Ruact.signed_global_id}` / `{Ruact.locate_signed}` cross-references are
|
|
1920
|
+
unresolvable (the methods are mixed onto `Ruact`'s singleton via `extend`), so
|
|
1921
|
+
they are written as plain code spans, not doc links.
|
|
1922
|
+
|
|
1923
|
+
#### 2026-06-26 — Story 13.2 review patch R2 (dev⇄Codex)
|
|
1924
|
+
|
|
1925
|
+
One new finding (all three layers, same root): `expires_in: false` — and a
|
|
1926
|
+
configured default of `false` — silently minted a **non-expiring** token, because
|
|
1927
|
+
globalid treats a falsy `expires_in` as "no expiry". That was a second silent
|
|
1928
|
+
path around the "only an explicit `nil` is the reviewed non-expiring choice"
|
|
1929
|
+
contract. **Fixed:** `__ruact_resolve_expiry` now type-checks the resolved value
|
|
1930
|
+
— only `nil` or an `ActiveSupport::Duration` (anything responding to `#from_now`,
|
|
1931
|
+
what globalid actually calls) is allowed; `false`, a bare Integer, or any other
|
|
1932
|
+
type raises `Ruact::Error` loudly (closing both the call-arg and the configured-
|
|
1933
|
+
default path). Bonus: a bare Integer now gets a clear message instead of
|
|
1934
|
+
globalid's cryptic `NoMethodError: from_now`.
|
|
1935
|
+
|
|
1936
|
+
### 2026-06-26 — Story 13.3 — Inertia-style validation `errors` round-trip (FR98)
|
|
1937
|
+
|
|
1938
|
+
**Context.** Stories 8.4 / 9.1 handle the **exception** path: a `save!` /
|
|
1939
|
+
`create!` that raises `ActiveRecord::RecordInvalid` bubbles past the host's
|
|
1940
|
+
`rescue_from` into the structured **error payload** (`_ruact_server_action_error:
|
|
1941
|
+
true`, mapped to **422**, with a dev-only flat `full_messages` **array** under
|
|
1942
|
+
`validation_errors`) — for the dev overlay. FR98 is the complementary
|
|
1943
|
+
**non-exception happy-failure** path: the idiomatic `if record.save … else …`
|
|
1944
|
+
where `save` returns `false` and the action does NOT raise. That path needs a
|
|
1945
|
+
field-level `errors` **prop** (not an error payload) so a form re-renders with
|
|
1946
|
+
messages and a normal 200 / redirect — exactly how a Rails dev expects
|
|
1947
|
+
`record.invalid?` to behave. **13.3 EXTENDS, does not replace, the 8.4/9.1
|
|
1948
|
+
chain**; `error_payload.rb`'s dev-only `validation_errors` array is left
|
|
1949
|
+
untouched (different shape, different purpose).
|
|
1950
|
+
|
|
1951
|
+
**Decision — design (B), explicit opt-in helper.** The contract escalation in
|
|
1952
|
+
the story (auto-inject a reserved `errors` prop into EVERY response — variant
|
|
1953
|
+
(A) — vs. an explicit opt-in helper — variant (B)) was resolved by Luiz in
|
|
1954
|
+
favour of **(B)**. Rationale: (A) would reserve the prop name `errors` globally
|
|
1955
|
+
AND change Story 9.2's Bucket-2 `204 No Content` empty contract (a genuinely
|
|
1956
|
+
empty response would become `{"errors": {}}`); that is magic, in tension with
|
|
1957
|
+
the explicit-allowlist grain that 13.1/13.2 just hardened. (B) preserves the
|
|
1958
|
+
grain and the 9.2 contract.
|
|
1959
|
+
|
|
1960
|
+
**(a) Canonical shape — a pure normalizer.**
|
|
1961
|
+
`Ruact::ServerFunctions::ValidationErrors.normalize(source)` is a pure function
|
|
1962
|
+
(no Rails / `Ruact.config` / request reads — same caller/builder split as
|
|
1963
|
+
`ErrorPayload` / `BucketTwoPayload`) producing `Hash{String=>Array<String>}`:
|
|
1964
|
+
attribute names as strings (a `base`-level error keys under `"base"`), values
|
|
1965
|
+
arrays of human-readable **full messages** (`ActiveModel::Error#full_message`,
|
|
1966
|
+
e.g. `"Title can't be blank"`). It accepts an ActiveModel-ish record (responds
|
|
1967
|
+
to `#errors`), a raw `ActiveModel::Errors`, or a pre-shaped Hash, and is
|
|
1968
|
+
idempotent on already-canonical input; `nil` / a valid record → `{}`. Full
|
|
1969
|
+
messages (not partial `errors.messages`) are the default — self-describing, and
|
|
1970
|
+
faithful to the AC. Note this diverges from inertia-rails' single-string-per-
|
|
1971
|
+
attribute: FR98 mandates `string[]` because one attribute can carry multiple
|
|
1972
|
+
errors.
|
|
1973
|
+
|
|
1974
|
+
**(b) Opt-in collector helper.** `ruact_errors` is a single dual-purpose
|
|
1975
|
+
controller helper on the shared `Ruact::ValidationErrorsCollector` concern
|
|
1976
|
+
(included by both `Ruact::Server` and `Ruact::Controller`). `ruact_errors(@post)`
|
|
1977
|
+
normalizes + merges into a per-request collector (the private ivar
|
|
1978
|
+
`@__ruact_errors`, NOT a `view_assigns` ivar) and returns the normalized hash;
|
|
1979
|
+
the no-arg `ruact_errors` reads the always-present collector (`{}` default), for
|
|
1980
|
+
binding to a form component (`<PostForm errors={ruact_errors} />`, exposed to
|
|
1981
|
+
the view via `helper_method`). Because the shape derives from `record.errors`
|
|
1982
|
+
(empty on a valid record), one `ruact_errors(@post)` call after the save yields
|
|
1983
|
+
`{}` on success and the populated map on failure — the "always present, same
|
|
1984
|
+
code path" invariant, **per-action and explicit**, with no global prop.
|
|
1985
|
+
|
|
1986
|
+
**(c) Bucket-2 (imperative `await`).** When `__ruact_function_call?` and the
|
|
1987
|
+
collector was **touched** this request, `Ruact::Server#default_render` injects
|
|
1988
|
+
the collector under the reserved JSON key `"errors"` alongside the serialized
|
|
1989
|
+
ivars (even on an otherwise-empty assigns set, so an opted-in success surfaces
|
|
1990
|
+
`{"errors": {}}` and the client reads `result.errors` uniformly). The two
|
|
1991
|
+
collector internals and a stray dev ivar literally named `@errors` are dropped
|
|
1992
|
+
from the serialized assigns (`RESERVED_ASSIGN_KEYS` — Rails' `view_assigns` only
|
|
1993
|
+
filters a fixed set of `@_` ivars, not every `@__` one); the collector is the
|
|
1994
|
+
source of truth for the key. An **untouched** collector changes nothing — the
|
|
1995
|
+
9.2 `204 No Content` empty-Bucket-2 contract is preserved. No client-runtime
|
|
1996
|
+
change: the `errors` key rides `parseResponse` verbatim.
|
|
1997
|
+
|
|
1998
|
+
**(d) Bucket-1 redirect-back (native form / navigation).** The Inertia "redirect
|
|
1999
|
+
back with errors" pattern: on a Flight `redirect_to`
|
|
2000
|
+
(`Ruact::Controller#redirect_to`) where the collector was touched, the canonical
|
|
2001
|
+
errors are stashed in `flash[:ruact_errors]` (single-use, session-backed — the
|
|
2002
|
+
exact Inertia semantics). The re-rendered page (`ruact_render`) reads the flash
|
|
2003
|
+
back into the collector before the view evaluates, so `ruact_errors` returns the
|
|
2004
|
+
surviving errors as a prop on the re-render. Guarded on `respond_to?(:flash)`:
|
|
2005
|
+
a session-less API-only host degrades to the Bucket-2 body path rather than
|
|
2006
|
+
crashing. Bucket-2 does NOT redirect-to-convey-failure — errors ride the body.
|
|
2007
|
+
|
|
2008
|
+
**Append-only invariant preserved.** The Story 8.0 accessor lock is untouched
|
|
2009
|
+
(`import { createPost } from "@/.ruact/server-functions"` unchanged); FR98 adds
|
|
2010
|
+
a Ruby-side collector + a reserved response key, no codegen / runtime change.
|
|
2011
|
+
The raised-`RecordInvalid` → 422 path (8.4) is unchanged and still covered by
|
|
2012
|
+
`server_rescue_request_spec.rb`.
|
|
2013
|
+
|
|
2014
|
+
### 2026-06-26 — Story 13.4 — TS type emission for query `params` (FR99)
|
|
2015
|
+
|
|
2016
|
+
FR99 closes the **parameter/accessor `any` gap**: before this story a query that
|
|
2017
|
+
declares keyword arguments emitted `(params: Record<string, unknown>) =>
|
|
2018
|
+
Promise<unknown>` — the client got no named keys, no autocomplete, no
|
|
2019
|
+
missing-required error, no unknown-key error. The codegen now narrows that
|
|
2020
|
+
`params` object to the query method's **actual declared keyword arguments**.
|
|
2021
|
+
Append-only addendum; no locked accessor shape changes (the import surface —
|
|
2022
|
+
`import { searchUsers, useQuery } from "@/.ruact/server-functions"` — is
|
|
2023
|
+
untouched; only the inline TS type annotation on the emitted `export const`
|
|
2024
|
+
gets narrower).
|
|
2025
|
+
|
|
2026
|
+
**The reflection-honesty decision (the one real contract call) — RESOLVED (A).**
|
|
2027
|
+
Ruby is dynamically typed; `Method#parameters` returns **names + required/optional
|
|
2028
|
+
only** (`[[:keyreq, :term], [:key, :limit]]`) — never types, never default
|
|
2029
|
+
values. So the epic's illustrative `{ q: string; limit?: number }` (where
|
|
2030
|
+
`number` would come from `limit: 10`) is **not honestly reachable by pure
|
|
2031
|
+
reflection**. Decision (locked by Luiz): each param's **value type is the FR88
|
|
2032
|
+
wire-serializable union `string | number | boolean | null`** — the exact
|
|
2033
|
+
query-string contract the kwargs sanitizer (`query_dispatch.rb`) already enforces
|
|
2034
|
+
— while **keys and optionality are EXACT** (`:keyreq` → required prop, `:key` →
|
|
2035
|
+
optional prop). This kills the `Record<string, unknown>`/`any` gap truthfully:
|
|
2036
|
+
named keys (autocomplete), exact required-ness (**missing-required is a compile
|
|
2037
|
+
error**), and excess-property checking (**unknown-key is a compile error**).
|
|
2038
|
+
Rejected alternatives: (B) parsing default-value literals to infer scalar types —
|
|
2039
|
+
fragile, Ruby-version-dependent, a byte-stability hazard, and gives `term:` (no
|
|
2040
|
+
default) no type anyway; (C) an explicit param-type DSL (`param :limit, :integer`)
|
|
2041
|
+
for true scalar precision — a new feature surface, deferred to a future story.
|
|
2042
|
+
Per-param scalar narrowing is therefore explicitly **out of scope** here.
|
|
2043
|
+
|
|
2044
|
+
**Action scope boundary — actions stay byte-identical.** Actions are Rails
|
|
2045
|
+
controller methods that read `params` dynamically; there is **no reflectable
|
|
2046
|
+
declared-input source** (the action entry hash carries no kwargs), and the action
|
|
2047
|
+
signature is already a sensible `FormData | Record<string, unknown>` intersection
|
|
2048
|
+
(not bare `any`). Inventing an action-input declaration DSL is a new feature
|
|
2049
|
+
surface, out of scope. Action accessors — and therefore every action-only and
|
|
2050
|
+
empty `server-functions.ts` module — are emitted **byte-for-byte unchanged**
|
|
2051
|
+
(regression-pinned in the parity suite). Return types stay `Promise<unknown>`
|
|
2052
|
+
(per-function return precision is Phase 3; `useQuery<T>` remains the return-typing
|
|
2053
|
+
lever).
|
|
2054
|
+
|
|
2055
|
+
**`**keyrest` fails open.** A query declaring `**opts` keeps an open
|
|
2056
|
+
`Record<string, unknown>` (intersected with any named keys:
|
|
2057
|
+
`{ scope: <union> } & Record<string, unknown>`) so a legitimate dynamic key is
|
|
2058
|
+
never narrowed away. A `**keyrest`-only query is just `Record<string, unknown>` —
|
|
2059
|
+
byte-identical to its pre-13.4 `accepts_params: true` output.
|
|
2060
|
+
|
|
2061
|
+
**Pipeline + dual-codegen byte parity reaffirmed.** Per-kwarg metadata is derived
|
|
2062
|
+
in `QuerySource.build_entry` (`params`: an ordered `[{ "name", "required" }]`
|
|
2063
|
+
array + a `params_rest` Boolean, alongside the retained `accepts_params` Boolean,
|
|
2064
|
+
now derived as "named keys present OR keyrest"), carried verbatim through the
|
|
2065
|
+
`Snapshot` JSON (string keys, declaration order → byte-stable), and consumed
|
|
2066
|
+
**byte-identically** by BOTH the Ruby `Codegen::V2.render_query_export` and the JS
|
|
2067
|
+
`renderQueryExportV2`. The cross-implementation parity test
|
|
2068
|
+
(`server-functions-codegen.test.mjs`) is extended with a typed-query fixture
|
|
2069
|
+
(required + optional + keyrest + keyrest-only) asserting `jsOutput ===
|
|
2070
|
+
rubyOutput`. The new `params` metadata is a **trust boundary** (it becomes TS
|
|
2071
|
+
object keys): both renderers reject a malformed/corrupted snapshot (non-Array
|
|
2072
|
+
`params`, a non-object element, an empty/multi-line param name, a non-Boolean
|
|
2073
|
+
`required`) rather than emit broken — or injected — TS, and any param name that
|
|
2074
|
+
is not a valid bare JS identifier is JSON-quoted. The AC6 proof is a `tsc
|
|
2075
|
+
--noEmit` type-level test fixture in `vendor/javascript/vite-plugin-ruact/`
|
|
2076
|
+
(`type-tests/`, `typescript` added as a build-only devDependency) asserting that
|
|
2077
|
+
missing-required / unknown-key / wrong-typed / wrong-arity all fail to compile and
|
|
2078
|
+
a clean call type-checks — wired into a new `js` CI job. The runtime `_makeQuery`
|
|
2079
|
+
declaration needed **no change**: the emitted typed-`params` `export const`
|
|
2080
|
+
assigns to `_makeQuery`'s declared return without a cast (proven by a second
|
|
2081
|
+
type-test fixture).
|
|
2082
|
+
|
|
2083
|
+
### 2026-06-26 — Story 13.5 — Compile-time component contract, HEEx-style `attr`/`slot` (FR100)
|
|
2084
|
+
|
|
2085
|
+
FR100 closes the **component-boundary contract gap** — the consumer-side mirror
|
|
2086
|
+
of `ruact_props`. `ruact_props` validates the **producer** at class-load (a
|
|
2087
|
+
serializable model only exposes real methods, `serializable.rb`); until now the
|
|
2088
|
+
**consumer** — a `<Component prop={...} />` call site in an ERB view — was
|
|
2089
|
+
unchecked, so a missing required prop, a typo'd prop name (`postID` vs `postId`),
|
|
2090
|
+
or a slot misuse produced a **silent `undefined` in the browser** (the worst
|
|
2091
|
+
failure: discovered at runtime in production). 13.5 makes the call site a checked
|
|
2092
|
+
contract that fails **at preprocess time**, naming the component, file:line, the
|
|
2093
|
+
offending prop, and a "did you mean?" suggestion — before the page renders.
|
|
2094
|
+
|
|
2095
|
+
**Contract source = opt-in `.tsx` export → manifest `contract` field (design A,
|
|
2096
|
+
locked by Luiz).** A component opts in by exporting `__ruactContract` from its own
|
|
2097
|
+
module (HEEx spirit — the declaration lives next to the component, in the
|
|
2098
|
+
component's own language):
|
|
2099
|
+
|
|
2100
|
+
```ts
|
|
2101
|
+
export const __ruactContract = {
|
|
2102
|
+
props: { title: "required", subtitle: "optional" },
|
|
2103
|
+
slots: { header: "optional" }, // optional; { name: "required"|"optional" } or ["name", ...]
|
|
2104
|
+
passthrough: false, // optional; true allows undeclared props
|
|
2105
|
+
};
|
|
2106
|
+
```
|
|
2107
|
+
|
|
2108
|
+
The Vite plugin's existing component scanner (`buildManifest`) — which already
|
|
2109
|
+
opens every component file for `"use client"` + export names — extracts this
|
|
2110
|
+
**names-only** (a brace-balanced object scan + targeted regexes; **NOT** a TS-AST
|
|
2111
|
+
pass, **NOT** `eval`) into an optional `contract` field on the manifest entry.
|
|
2112
|
+
A component without the export emits **no** `contract` field (byte-additive;
|
|
2113
|
+
every existing manifest + reader is unaffected). A malformed/partial declaration
|
|
2114
|
+
is **warned + skipped** in the plugin, so the Ruby side sees "no contract" and
|
|
2115
|
+
fails open. The brace-balancer that bounds the object literal is string- and
|
|
2116
|
+
comment-aware (a `{` inside a comment or string never throws off the balance).
|
|
2117
|
+
A single `__ruactContract` describes the file's **one** component — a file that
|
|
2118
|
+
exports more than one PascalCase component has the contract **warned + skipped**
|
|
2119
|
+
(it cannot be attributed), reaffirming the one-component-per-file convention;
|
|
2120
|
+
per-export contract syntax is deferred. Rejected alternatives: **(B)** a Ruby-side DSL and **(C)** a JSON
|
|
2121
|
+
sidecar both invent a parallel registry that drifts from the `.tsx` (the source
|
|
2122
|
+
of truth); **(D)** a full TS-AST `Props` parse is heavy, version-fragile, and
|
|
2123
|
+
over-delivers (preprocess-time validation needs names + required + slots, not
|
|
2124
|
+
value types — and component prop value typing is a separate, later concern; the
|
|
2125
|
+
server-boundary value-typing win already landed in 13.4 / FR99).
|
|
2126
|
+
|
|
2127
|
+
**Check location = Ruby preprocess-time (forced, not a choice).** ERB
|
|
2128
|
+
`<Component .../>` tags are transformed by the **Ruby** ERB preprocessor
|
|
2129
|
+
(`erb_preprocessor.rb`), which already detects PascalCase tags, parses prop
|
|
2130
|
+
**names**, and computes each call site's line. A TypeScript/codegen-time check
|
|
2131
|
+
**cannot see ERB call sites at all** — they live in `.html.erb`, not `.tsx`. So
|
|
2132
|
+
the validator (`Ruact::ComponentContract`) is necessarily Ruby-side, hooked into
|
|
2133
|
+
the existing `COMPONENT_TAG_RE` transform loop. The only missing input was the
|
|
2134
|
+
template **file path**, now threaded through `ErbPreprocessorHook#call` as
|
|
2135
|
+
`template.identifier` (and `ErbPreprocessor.transform(source, identifier:,
|
|
2136
|
+
registry:)` — the contract registry is an **injectable seam** defaulting to the
|
|
2137
|
+
process-loaded, frozen `Ruact.manifest`, stubbable in specs per the Story 7.1
|
|
2138
|
+
explicit-context grain; no `Thread.current`, no per-call disk read).
|
|
2139
|
+
|
|
2140
|
+
**Granularity = NAME-level only (required / unknown / slot).** Prop **values** are
|
|
2141
|
+
arbitrary render-time Ruby expressions the preprocessor never evaluates
|
|
2142
|
+
(`postId={@post.id}` → it emits `"postId" => @post.id`), so a preprocess-time
|
|
2143
|
+
check can validate **names + presence + slots** — *not* value types. This is the
|
|
2144
|
+
same reflection-honesty discipline 13.4 used: deliver exactly what the static
|
|
2145
|
+
phase can truthfully see. The three checks map precisely to the epic's three
|
|
2146
|
+
failure cases: **missing required** prop/slot, **unknown** prop name (closed by
|
|
2147
|
+
default; `passthrough: true` reopens it), and **slot misuse**.
|
|
2148
|
+
|
|
2149
|
+
**Slots = minimal / name-level, deeper semantics deferred.** Children/slot passing
|
|
2150
|
+
is thin today (only `<Suspense>` is paired in the preprocessor; generic
|
|
2151
|
+
`<Component>…</Component>` children are not yet first-class). A slot is therefore
|
|
2152
|
+
expressed at the ERB call site as a **named prop attribute** (render-prop style:
|
|
2153
|
+
`<Card header={...} />`); the validator treats declared slot names as valid
|
|
2154
|
+
attributes and enforces **required** slots by attribute presence. A contract that
|
|
2155
|
+
declares no slots makes slot checking a **no-op**. A typed slot runtime / slot
|
|
2156
|
+
rendering / children-based slot capture is explicitly **deferred**.
|
|
2157
|
+
|
|
2158
|
+
**Opt-in / fail-open / byte-identical (the load-bearing regression).** A
|
|
2159
|
+
contract-less component gets **zero** validation and **byte-identical** emitted
|
|
2160
|
+
output (`<%= __ruact_component__("Name", { … }) %>`), and the no-component-tag
|
|
2161
|
+
fast path reads no registry — both regression-pinned. Validation runs **only**
|
|
2162
|
+
inside the `COMPONENT_TAG_RE` block, never on the fast path. When the manifest is
|
|
2163
|
+
absent (fresh app, pre-build) or has no contract for a component, validation is
|
|
2164
|
+
skipped. This follows the gem's whole grain — explicit allowlisting, never magic
|
|
2165
|
+
(`ruact_props`, the SGID helper, the strict query allowlist are all opt-in).
|
|
2166
|
+
|
|
2167
|
+
**Error surface.** The validator raises `Ruact::ComponentContractError`, a
|
|
2168
|
+
**subclass of `Ruact::PreprocessorError`** (so it flows through the same dev error
|
|
2169
|
+
overlay, NFR30 lineage) — the distinct subclass lets the preprocessor re-raise it
|
|
2170
|
+
**as-is** (its message already carries component + file:line + offending prop +
|
|
2171
|
+
suggestion) instead of re-wrapping it with the generic "at line N: snippet" tail
|
|
2172
|
+
the preprocessor appends to malformed-tag errors. The "did you mean?" suggestion
|
|
2173
|
+
reuses the **Damerau-Levenshtein** closest-match (Story 7.4), factored out of
|
|
2174
|
+
`ClientManifest` into a shared `Ruact::StringDistance` module so the prop-typo
|
|
2175
|
+
hint and the unknown-component hint share one algorithm.
|
|
2176
|
+
|
|
2177
|
+
**Scope boundary (this story does NOT do).** Prop value-type checking; a full
|
|
2178
|
+
TS-AST `Props` parse; a Ruby DSL / JSON sidecar; a new slot runtime; moving
|
|
2179
|
+
validation to render time. The **Epic 10 scaffold** consuming contracts and the
|
|
2180
|
+
**playground build-fails CI proof** are **Story 13.6** (`epics-phase-2.md`).
|
|
2181
|
+
ADR addendum (2026-06-26).
|