ruact 0.0.2 → 0.0.3
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,1680 @@
|
|
|
1
|
+
# Server Functions API — React-side accessor shape
|
|
2
|
+
|
|
3
|
+
| Field | Value |
|
|
4
|
+
| --- | --- |
|
|
5
|
+
| Date | 2026-05-12 |
|
|
6
|
+
| Status | Accepted |
|
|
7
|
+
| Story | 8.0 — Server functions API design spike (planning artifact in the workspace monorepo at `_bmad-output/implementation-artifacts/8-0-server-functions-api-design-spike.md` — link omitted because this file ships in the published gem and the planning path is workspace-only) |
|
|
8
|
+
| Inspected | `react@19.2.0` (latest 19.x line; gem pins `react ^19.0.0` per Story 8.0 AC1's `react@19.0.0` floor), `next@15.4.0-canary`, `eslint-plugin-react-hooks@v6` (the v5 → v6 bump landed in 2026-05; AC1 specified `5.x` at story-creation time — the upgrade was a no-op for the rule-of-hooks behaviour the spike inspected), `vite@6.x`. **AC1 floor versions (`react@19.0.0`, `hooks@5.x`) were NOT separately re-inspected on a side-by-side basis;** the spike accepts the drift because the inspected behaviour (named-import resolution, rule-of-hooks compliance, `<form action>` semantics) is stable across the 19.0.0 ↔ 19.2.0 patch range and across the hooks v5 ↔ v6 release (per the React + plugin changelogs). Re-inspect if a future regression contradicts this assumption. |
|
|
9
|
+
| Locks | Stories 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 9.1, 9.2, 9.3, 9.4, 9.5 |
|
|
10
|
+
| Append-only | Yes — see "When to revisit" below |
|
|
11
|
+
|
|
12
|
+
## Context
|
|
13
|
+
|
|
14
|
+
Epic 8 (`ruact_action`) and Epic 9 (`ruact_query`) both need a way for a React
|
|
15
|
+
component to obtain a reference to a server-side function declared in a Rails
|
|
16
|
+
controller. Before this decision, Story 8.1's AC1 carried a placeholder for the
|
|
17
|
+
accessor surface (a deferred `server_actions[:create_post]`-style indexed
|
|
18
|
+
lookup with the call shape left undetermined) which was implicitly inherited by
|
|
19
|
+
every downstream story that needs the same reference.
|
|
20
|
+
|
|
21
|
+
A symmetric, ergonomic accessor must be picked once, shared by actions and
|
|
22
|
+
queries, and locked before implementation begins so that 11 downstream stories
|
|
23
|
+
consume a stable contract instead of re-litigating the API in each PR.
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
**Option C — Named imports from a generated TypeScript module.**
|
|
28
|
+
|
|
29
|
+
A bundled extension to `vite-plugin-ruact` reads `Ruact.action_registry` and
|
|
30
|
+
`Ruact.query_registry` at Rails boot, and emits one virtual module:
|
|
31
|
+
`app/javascript/.ruact/server-functions.ts`. React components import each
|
|
32
|
+
function by name. Each export carries a per-function TypeScript signature
|
|
33
|
+
derived from the Ruby declaration.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// app/javascript/.ruact/server-functions.ts (auto-generated)
|
|
37
|
+
export declare function createPost(args: { title: string; body: string }):
|
|
38
|
+
Promise<{ id: number; slug: string }>;
|
|
39
|
+
export declare function categories():
|
|
40
|
+
Promise<Array<{ id: number; name: string }>>;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
// app/javascript/components/PostForm.tsx (hand-written)
|
|
45
|
+
import { createPost, categories } from "@/.ruact/server-functions";
|
|
46
|
+
|
|
47
|
+
export function PostForm() {
|
|
48
|
+
return <form action={createPost}>...</form>;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Same accessor mechanics for actions and queries. No hook. No context provider.
|
|
53
|
+
No prop drilling. The import IS the accessor.
|
|
54
|
+
|
|
55
|
+
> **Note (2026-05-13, append-only):** the `export declare function` sketch
|
|
56
|
+
> above is superseded. The real codegen (Story 8.0a) emits runtime exports
|
|
57
|
+
> via `_makeRef("<symbol>")` with conservative TypeScript signatures
|
|
58
|
+
> (actions: `(args?: Record<string, unknown>) => Promise<unknown>`; queries:
|
|
59
|
+
> `() => Promise<unknown>`), not `export declare`. The `<form action={fn}>`
|
|
60
|
+
> sketch above also under-specifies the React-form path: when React invokes
|
|
61
|
+
> a server reference via `<form action>`, it passes a single `FormData`
|
|
62
|
+
> argument — Story 8.1 / 8.2 own how that `FormData` is unwrapped into Rails
|
|
63
|
+
> `params`. Query transport is POST (not GET); `useQuery(ref, params?)`
|
|
64
|
+
> returns `{ data, loading, error }` (not Suspense-only). See the Decision
|
|
65
|
+
> log entries from 2026-05-13 for the full resolutions.
|
|
66
|
+
|
|
67
|
+
## Alternatives considered
|
|
68
|
+
|
|
69
|
+
The decision was driven by a scoring matrix built in Task 2 of the spike
|
|
70
|
+
across nine axes: TypeScript support, bundle size, ESLint friction, hook-
|
|
71
|
+
rule compliance, nested-layout future-readiness, regeneration triggers,
|
|
72
|
+
debugging clarity, Rails-dev-learning-React curve, React-dev-learning-
|
|
73
|
+
ruact curve. The matrix lived in a scratch file (`/tmp/8-0-matrix.md`)
|
|
74
|
+
that was not committed to version control — the Task 2 checklist in the
|
|
75
|
+
Story 8.0 spike artifact (workspace-only path `_bmad-output/implementation-artifacts/8-0-server-functions-api-design-spike.md`)
|
|
76
|
+
records the axes; the per-option summary below preserves the qualitative
|
|
77
|
+
outcome. If the decision is ever re-litigated, rebuild the matrix from
|
|
78
|
+
scratch — do not trust a recovered scratch file. Each rejected option
|
|
79
|
+
below cites at least one pitfall from the spike's Context Bundle.
|
|
80
|
+
|
|
81
|
+
### Option A — Prop drilling from layout
|
|
82
|
+
|
|
83
|
+
Layout receives `server_actions` / `server_queries` from a gem-injected helper
|
|
84
|
+
and passes them as props down the tree.
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
function App({ server_actions }) {
|
|
88
|
+
return <PostForm createPost={server_actions.createPost} />;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Pros:** zero codegen; no hooks; props are the most universal React idiom.
|
|
93
|
+
**Cons:** every nested layout has to re-inject; refactoring a system function's
|
|
94
|
+
location through a tree is the textbook "props vs context" anti-pattern; per-call
|
|
95
|
+
TypeScript types degrade as drilling depth grows.
|
|
96
|
+
**Rejected** citing pitfall #4 (the accessor shape constrains the rendering model).
|
|
97
|
+
Phase 3's nested layouts would force a re-design.
|
|
98
|
+
|
|
99
|
+
### Option B — `useServerActions()` / `useServerQueries()` hooks
|
|
100
|
+
|
|
101
|
+
The gem installs a Context Provider at the root of the layout; components call a
|
|
102
|
+
hook to retrieve the reference object.
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
function PostForm() {
|
|
106
|
+
const actions = useServerActions();
|
|
107
|
+
return <form action={actions.createPost}>...</form>;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Pros:** survives nested layouts via Context inheritance; no codegen; hot reload
|
|
112
|
+
trivially correct.
|
|
113
|
+
**Cons:** Rule of Hooks (`eslint-plugin-react-hooks` v6) requires top-level call,
|
|
114
|
+
so invoking from an event handler needs the two-line dance
|
|
115
|
+
(`const actions = useServerActions(); ...; <button onClick={() => actions.x()} />`);
|
|
116
|
+
hook returns one shared `actions` object whose typed shape is computed from a
|
|
117
|
+
single declaration, weakening per-function type inference.
|
|
118
|
+
**Rejected** citing pitfall #2 (hook-rule friction) and pitfall #3 (weaker per-function
|
|
119
|
+
type safety than C).
|
|
120
|
+
|
|
121
|
+
### Option C — Named imports from a generated module — **CHOSEN**
|
|
122
|
+
|
|
123
|
+
See "Decision" above. Validated end-to-end in `/tmp/8-0-sandbox/` (V1–V4):
|
|
124
|
+
codegen + `tsc --noEmit` + typo-detection + naming-bridge edge cases all pass.
|
|
125
|
+
|
|
126
|
+
**Pros:** strongest per-function TypeScript surface of any option (each
|
|
127
|
+
export is independently typed; rename triggers `TS2724` with a suggestion
|
|
128
|
+
across every call site); zero hook-rule friction (it's a plain import,
|
|
129
|
+
callable from event handlers, top-level, or anywhere); tree-shakable
|
|
130
|
+
(unused functions are dead-code-eliminated by Vite); jump-to-definition
|
|
131
|
+
works in any TS-aware editor; refactoring a Ruby symbol surfaces at
|
|
132
|
+
typecheck and at module-resolve time (not silently at runtime).
|
|
133
|
+
**Cons:** requires a codegen step (Story 8.0a — Vite plugin + Railtie
|
|
134
|
+
hook + rake task; the implementation surface is non-trivial and adds a
|
|
135
|
+
second source of truth that the Ruby↔JS parity test must keep aligned);
|
|
136
|
+
generated module is build-time state, so it has a regeneration-trigger
|
|
137
|
+
matrix (`config.to_prepare` in dev; rake task in CI/prod) that the spike
|
|
138
|
+
had to design explicitly; introduces a new file path (`app/javascript/.ruact/`)
|
|
139
|
+
that host apps must gitignore.
|
|
140
|
+
**Chosen** citing pitfall #3 (type safety) and pitfall #4 (nested-layout
|
|
141
|
+
future-readiness) — the codegen cost is paid up-front, in one place, by
|
|
142
|
+
the gem maintainer, in exchange for the strongest ergonomic + TS surface
|
|
143
|
+
across all eleven downstream stories.
|
|
144
|
+
|
|
145
|
+
### Option D — `useServerFunction("name")`
|
|
146
|
+
|
|
147
|
+
A single hook keyed by symbolic name.
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
function PostForm() {
|
|
151
|
+
const createPost = useServerFunction("createPost");
|
|
152
|
+
return <form action={createPost}>...</form>;
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Pros:** simpler than B (one hook, no two-flavor split for actions vs queries);
|
|
157
|
+
no codegen.
|
|
158
|
+
**Cons:** string-keyed lookup defeats TypeScript almost completely (no per-function
|
|
159
|
+
parameter or return types without per-name overload tables); typos surface only at
|
|
160
|
+
runtime; jump-to-definition does not work; refactoring a Ruby symbol silently
|
|
161
|
+
breaks call sites.
|
|
162
|
+
**Rejected** citing pitfall #3 (type safety cuts both ways). The simplicity
|
|
163
|
+
argument does not compensate for the TS regression — and the entire reason ruact
|
|
164
|
+
ships TS support in Phase 2 (Story 6.1) is to make this kind of shape possible.
|
|
165
|
+
|
|
166
|
+
### Option E — Global `window.__ruactServerActions`
|
|
167
|
+
|
|
168
|
+
Considered and discarded immediately: pollutes global scope, not tree-shakable,
|
|
169
|
+
no TypeScript surface, conflates accessor with serialization, breaks SSR/test
|
|
170
|
+
isolation. One sentence and out.
|
|
171
|
+
**Rejected** citing pitfall #3 (type safety) and pitfall #4 (the global
|
|
172
|
+
accessor binds to a specific layout/runtime instance, fragmenting if
|
|
173
|
+
Phase 3 introduces nested layouts or per-route shells — same future-
|
|
174
|
+
coupling cost that disqualified Option A, only worse since `window`
|
|
175
|
+
mutations cannot be partitioned per shell).
|
|
176
|
+
|
|
177
|
+
## Applied
|
|
178
|
+
|
|
179
|
+
### Action use case (Story 8.2 — "Create Post" form)
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
# app/controllers/posts_controller.rb
|
|
183
|
+
class PostsController < ApplicationController
|
|
184
|
+
ruact_action :create_post do |params|
|
|
185
|
+
Post.create!(title: params[:title], body: params[:body])
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
// app/javascript/components/PostForm.tsx
|
|
192
|
+
//
|
|
193
|
+
// SUPERSEDED — see the 2026-05-16 + 2026-05-17 addenda below. The
|
|
194
|
+
// direct `useActionState(createPost, …)` shape does NOT typecheck
|
|
195
|
+
// against the codegen-emitted intersection signature under React 19's
|
|
196
|
+
// strict types. The canonical pattern is the explicit wrapper that
|
|
197
|
+
// forwards FormData to the action and shapes the resulting state.
|
|
198
|
+
import { createPost } from "@/.ruact/server-functions";
|
|
199
|
+
import { useActionState } from "react";
|
|
200
|
+
|
|
201
|
+
type PostState = { id: number; slug: string } | null;
|
|
202
|
+
|
|
203
|
+
async function postAction(_prev: PostState, formData: FormData): Promise<PostState> {
|
|
204
|
+
return (await createPost(formData)) as PostState;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function PostForm() {
|
|
208
|
+
const [state, formAction, pending] = useActionState<PostState, FormData>(
|
|
209
|
+
postAction,
|
|
210
|
+
null,
|
|
211
|
+
);
|
|
212
|
+
return (
|
|
213
|
+
<form action={formAction}>
|
|
214
|
+
<input name="title" required />
|
|
215
|
+
<textarea name="body" required />
|
|
216
|
+
<button disabled={pending}>Create</button>
|
|
217
|
+
{state && <p>Created post #{state.id} (slug: {state.slug})</p>}
|
|
218
|
+
</form>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Query use case (Story 9.2 — "populate dropdown")
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
# app/controllers/posts_controller.rb
|
|
227
|
+
class PostsController < ApplicationController
|
|
228
|
+
ruact_query :categories do
|
|
229
|
+
Category.all
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
// app/javascript/components/CategoryPicker.tsx
|
|
236
|
+
import { useQuery } from "ruact";
|
|
237
|
+
import { categories } from "@/.ruact/server-functions";
|
|
238
|
+
|
|
239
|
+
export function CategoryPicker() {
|
|
240
|
+
const items = useQuery(categories); // suspends; throws Ruact.SuspenseError on stale read
|
|
241
|
+
return (
|
|
242
|
+
<select>
|
|
243
|
+
{items.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
244
|
+
</select>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
> **Note (2026-05-13, append-only):** the `useQuery(categories)` sketch
|
|
250
|
+
> above is the Suspense-based shape the spike originally drafted. Per
|
|
251
|
+
> the 2026-05-13 Review-patch clarifications (see Decision log), Story
|
|
252
|
+
> 9.2's hook signature is `useQuery(reference, params?) → { data, loading,
|
|
253
|
+
> error }`. The corrected sketch is:
|
|
254
|
+
>
|
|
255
|
+
> ```tsx
|
|
256
|
+
> const { data: items, loading, error } = useQuery(categories);
|
|
257
|
+
> if (loading) return <Spinner />;
|
|
258
|
+
> if (error) return <p>Could not load categories: {error.message}</p>;
|
|
259
|
+
> return <select>{items.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</select>;
|
|
260
|
+
> ```
|
|
261
|
+
>
|
|
262
|
+
> Suspense-aware queries (`use(promise)` + `<Suspense>` boundaries) are
|
|
263
|
+
> reserved for a Phase 3 ADR addendum once React 19's stable `use()`
|
|
264
|
+
> semantics + adoption signal land.
|
|
265
|
+
|
|
266
|
+
The accessor mechanics are identical (one named import, no hook on the
|
|
267
|
+
import path, one type per function). Actions integrate with native React
|
|
268
|
+
`<form action>` + `useActionState`; queries integrate with ruact's
|
|
269
|
+
`useQuery` (Story 9.2). The symmetric-coverage invariant is preserved.
|
|
270
|
+
|
|
271
|
+
## Implementation surface
|
|
272
|
+
|
|
273
|
+
| # | Machinery | Owning story | "Done" signal |
|
|
274
|
+
|---|---|---|---|
|
|
275
|
+
| 1 | `Ruact.action_registry` + `Ruact.query_registry` (Ruby) — empty storage stubs + module-level accessors | **Storage: Implemented in Story 8.0a** (empty `Ruact::ServerFunctions::Registry` instances, `register` / `entries` / `clear!` + collision detection); **Population: Story 8.1 (actions) + Story 9.1 (queries)** — the DSL macros write into the storage 8.0a defined | `Ruact.action_registry # => Ruact::ServerFunctions::Registry` (empty at 8.0a merge); after Story 8.1's `ruact_action` evaluates, `Ruact.action_registry.entries[:create_post]` returns a populated `RegistryEntry` |
|
|
276
|
+
| 2 | DSL macros `ruact_action :name do |params| ... end` and `ruact_query :name do ... end` | Story 8.1 + 9.1 | Defining a macro at controller-class load time both registers the symbol and defines the matching method (visibility and CSRF rules per Story 8.2 / 9.4) |
|
|
277
|
+
| 3 | Server-function endpoint (single Rails route mounted by the gem; resolves by symbolic name from registries 1+2; no per-function entries in `routes.rb`) | Story 8.1 | `POST /__ruact/fn/:name` returns the action OR query result (POST-for-everything per 2026-05-13 Decision-log clarification #3 — supersedes the original GET/POST split sketched in this row); both reuse `Ruact::Controller` security/CSRF |
|
|
278
|
+
| 4 | `vite-plugin-ruact` extension that emits `app/javascript/.ruact/server-functions.ts` from a Rails-side dump (JSON written by a Railtie initializer) | **Implemented in Story 8.0a** | Generated file present, `tsc --noEmit` green on a freshly-installed playground |
|
|
279
|
+
| 5 | Rails `config.to_prepare` hook that triggers regeneration of #4 in dev | **Implemented in Story 8.0a** | `bin/rails server`, edit a controller's `ruact_action`, file at `app/javascript/.ruact/server-functions.ts` updates without restart |
|
|
280
|
+
| 6 | `bin/rails ruact:server_functions:generate` rake task (manual + CI/production hook) | **Implemented in Story 8.0a** | Task succeeds on a clean checkout; file is byte-identical to dev-mode output |
|
|
281
|
+
| 7 | `rails generate ruact:install` updates: add `app/javascript/.ruact/.gitkeep`, add `app/javascript/.ruact/server-functions.ts` to `.gitignore`, run the generate rake task once | **Implemented in Story 8.0a** (originally tagged Story 8.1; landed early because the codegen surface lives in 8.0a) | Fresh `rails new` + `rails generate ruact:install` results in a working playground that can call a stub action without further setup |
|
|
282
|
+
| 8 | Naming-bridge implementation in #4 (Ruby → JS identifier) | **Implemented in Story 8.0a** | The 6 edge cases enumerated in "Naming bridge" below all behave per spec |
|
|
283
|
+
| 9 | `useQuery(reference, params?)` hook (consumes the named import) | Story 9.2 | `const { data, loading, error } = useQuery(categories)` works inside any function component; see Decision-log entry "2026-05-13 — Review-patch clarifications" for the supersession of the original Suspense-only sketch |
|
|
284
|
+
|
|
285
|
+
Every machinery item has a story assignment. As of 2026-05-13, rows #1
|
|
286
|
+
(storage layer), #4–#8 are **implemented in Story 8.0a** (`gem` commit
|
|
287
|
+
`862f07c`); rows #2, #3, #9 remain assigned to Stories 8.1 / 9.1 / 9.2.
|
|
288
|
+
Empty registries from row #1 are populated by the DSL macros in row #2.
|
|
289
|
+
|
|
290
|
+
## Naming bridge
|
|
291
|
+
|
|
292
|
+
### Rule
|
|
293
|
+
|
|
294
|
+
Ruby symbol → JS identifier:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
def to_js_identifier(symbol)
|
|
298
|
+
s = symbol.to_s
|
|
299
|
+
raise Ruact::ConfigurationError,
|
|
300
|
+
"ruact_action / ruact_query symbol :#{symbol} must match /^[a-z_][a-z0-9_]*$/" \
|
|
301
|
+
unless s.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
302
|
+
leading_underscore = s.start_with?("_")
|
|
303
|
+
body = leading_underscore ? s[1..] : s
|
|
304
|
+
camel = body.gsub(/_+(.)/) { $1.upcase }
|
|
305
|
+
leading_underscore ? "_#{camel}" : camel
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
The rule is implementable in 5 lines (above; the validation guard adds 2). Failure
|
|
310
|
+
mode is loud: invalid symbols raise `Ruact::ConfigurationError` at controller-class
|
|
311
|
+
load time (boot), not at runtime, not silently.
|
|
312
|
+
|
|
313
|
+
### Edge cases (validated in `/tmp/8-0-sandbox/`)
|
|
314
|
+
|
|
315
|
+
| Ruby symbol | JS identifier | Notes |
|
|
316
|
+
|---|---|---|
|
|
317
|
+
| `:create_post` | `createPost` | Standard snake_case → camelCase |
|
|
318
|
+
| `:categories` | `categories` | Single word — pass-through |
|
|
319
|
+
| `:_internal_dump` | `_internalDump` | Leading underscore preserved |
|
|
320
|
+
| `:foo__bar` | `fooBar` | Consecutive underscores collapse |
|
|
321
|
+
| `:RECALCULATE` | (rejected at boot) | `Ruact::ConfigurationError` — SCREAMING_SNAKE not allowed |
|
|
322
|
+
| `:CreatePost` | (rejected at boot) | `Ruact::ConfigurationError` — must start with lowercase letter or underscore |
|
|
323
|
+
| `:_` / `:__` | (rejected at boot) | `Ruact::ConfigurationError` — underscore-only symbols carry no semantic content (added 2026-05-13 via Story 8.0 review patch) |
|
|
324
|
+
| `:class` / `:export` / `:await` | (rejected at boot) | `Ruact::ConfigurationError` — the translated JS identifier collides with an ES2020+ reserved word (added 2026-05-13 via Story 8.0 review patch). Escape hatch: prefix with underscore (`:_class` → `"_class"`, accepted). |
|
|
325
|
+
|
|
326
|
+
The "rejected at boot" choice (vs silent normalization) is deliberate: ruby_symbol
|
|
327
|
+
→ JS-identifier conversion is one-way, so accepting `:CreatePost` and emitting
|
|
328
|
+
`createPost` would create two co-equal Ruby names for the same JS symbol —
|
|
329
|
+
collision-prone, hard to reverse-engineer in stack traces. The cost of the
|
|
330
|
+
strict rule is one early failure for the 1% of devs who use unusual casing,
|
|
331
|
+
which is the right trade.
|
|
332
|
+
|
|
333
|
+
The 2026-05-13 reserved-word + underscore-only additions follow the same
|
|
334
|
+
principle: a symbol that would produce JS that fails `tsc --noEmit` or trips
|
|
335
|
+
common ESLint configurations fails LOUDLY at controller-class load time
|
|
336
|
+
instead of silently shipping. The implementation lives in
|
|
337
|
+
`gem/lib/ruact/server_functions/name_bridge.rb` — `RESERVED_JS_IDENTIFIERS`
|
|
338
|
+
is the canonical list (ES2020+ keyword set + strict-mode reserved +
|
|
339
|
+
contextual reserved at module top level: `await`, `async`).
|
|
340
|
+
|
|
341
|
+
### Identifier collision
|
|
342
|
+
|
|
343
|
+
If two different Ruby symbols map to the same JS identifier (e.g., `:foo_bar`
|
|
344
|
+
and `:foo__bar` both → `fooBar`), the codegen step raises
|
|
345
|
+
`Ruact::ConfigurationError` listing both controllers. Validated in the spike
|
|
346
|
+
sandbox.
|
|
347
|
+
|
|
348
|
+
## When to revisit
|
|
349
|
+
|
|
350
|
+
This decision is **append-only**. When revisiting, add a dated addendum below
|
|
351
|
+
"Decision log"; do not rewrite the original.
|
|
352
|
+
|
|
353
|
+
Revisit if any of:
|
|
354
|
+
|
|
355
|
+
1. **React introduces an official `useServerReference` hook** (or equivalent
|
|
356
|
+
accessor in the React core) in a stable release.
|
|
357
|
+
*Action:* revisit, log a new ADR addendum, decide whether to migrate (or to
|
|
358
|
+
provide both shapes during a transition).
|
|
359
|
+
2. **The Server Components specification adds new ergonomics** (e.g., the React
|
|
360
|
+
team standardizes a reference-passing mechanism beyond `'use server'` import).
|
|
361
|
+
*Action:* same as #1.
|
|
362
|
+
3. **≥ 3 external issues** (post-v0.1.0) request a different ergonomic from
|
|
363
|
+
real users.
|
|
364
|
+
*Action:* revisit; the threshold-of-3 is the bar to avoid one-voice
|
|
365
|
+
over-rotation.
|
|
366
|
+
4. **A Phase 3 epic explicitly addresses ergonomics revisit** (e.g., a planned
|
|
367
|
+
"ruact 1.0 ergonomics polish" epic).
|
|
368
|
+
*Action:* this ADR is a required input to that epic.
|
|
369
|
+
|
|
370
|
+
Deviation from the chosen shape during Epic 8/9 implementation requires an ADR
|
|
371
|
+
addendum in this same file before the deviating PR is merged. The file is
|
|
372
|
+
append-only, never rewritten — so the history of the contract is preserved
|
|
373
|
+
even if the contract itself evolves.
|
|
374
|
+
|
|
375
|
+
## Decision log
|
|
376
|
+
|
|
377
|
+
### 2026-05-12 — Initial decision (Option C)
|
|
378
|
+
|
|
379
|
+
Chosen via the matrix in Task 2 of Story 8.0. Validated in
|
|
380
|
+
`/tmp/8-0-sandbox/` (Task 3): codegen + `tsc --noEmit` + typo-detection +
|
|
381
|
+
all 6 naming-bridge edge cases pass. Implementation surface enumerated; new
|
|
382
|
+
Story 8.0a created for the Vite plugin extension that emits the generated
|
|
383
|
+
module. No deviation from the spike's draft personal-opinion candidate, but
|
|
384
|
+
the matrix scoring is what locked the decision (not the gut feel).
|
|
385
|
+
|
|
386
|
+
### 2026-05-13 — Implementation (Story 8.0a)
|
|
387
|
+
|
|
388
|
+
Implementation surface rows #4, #5, #6, and #8 landed (plus #7's install-
|
|
389
|
+
generator extension, which was originally scoped to Story 8.1 but the
|
|
390
|
+
codegen scaffolding belongs alongside the rest of the 8.0a surface).
|
|
391
|
+
Ruby-side modules live under `gem/lib/ruact/server_functions/` (`NameBridge`,
|
|
392
|
+
`Registry`, `RegistryEntry`, `Snapshot`, `SnapshotWriter`, `Codegen`); the
|
|
393
|
+
Vite-plugin sidecar is `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs`
|
|
394
|
+
with a vitest harness alongside; the placeholder runtime at
|
|
395
|
+
`gem/vendor/javascript/ruact-server-functions-runtime/` ships an
|
|
396
|
+
intentionally-failing `_makeRef` so absent Story 8.1 wiring fails loudly at
|
|
397
|
+
call time. The JSON bridge lands at `tmp/cache/ruact/server-functions.json`,
|
|
398
|
+
the TS module at `app/javascript/.ruact/server-functions.ts`; both are
|
|
399
|
+
write-if-changed and gitignored. Cross-implementation parity (Ruby ↔ JS
|
|
400
|
+
codegens emit byte-identical output) is enforced by a vitest test that
|
|
401
|
+
shells out to `ruby -Ilib -rruact/server_functions/codegen -e ...` on a
|
|
402
|
+
literal fixture (`server-functions-codegen.test.mjs` → "Story 8.0a — Ruby
|
|
403
|
+
parity"). Empty registries are valid: 8.0a merges them as `[]` and Stories
|
|
404
|
+
8.1 / 9.1 populate them later. **No deviation from the locked contract**:
|
|
405
|
+
the import specifier remains `"ruact/server-functions-runtime"`; the
|
|
406
|
+
runtime alias is auto-registered by the Vite plugin's `config` hook against
|
|
407
|
+
the bundled placeholder package. See
|
|
408
|
+
Story 8.0a (workspace-only path `_bmad-output/implementation-artifacts/8-0a-vite-plugin-server-functions-codegen.md`)
|
|
409
|
+
for the full task breakdown and AC mapping.
|
|
410
|
+
|
|
411
|
+
### 2026-05-13 — Review-patch clarifications (Story 8.0 review pass)
|
|
412
|
+
|
|
413
|
+
The Story 8.0 code-review surfaced four patch findings that target stale
|
|
414
|
+
sketches in the ADR body. These clarifications are append-only — the body
|
|
415
|
+
sketches stay so the history of the contract is preserved, but the
|
|
416
|
+
following points override them where they disagree:
|
|
417
|
+
|
|
418
|
+
1. **Runtime/type contract (supersedes `## Decision` sketch lines 35–41).**
|
|
419
|
+
The codegen emits **runtime** exports, not `export declare function`.
|
|
420
|
+
Each entry is `export const <jsId>: <signature> = _makeRef("<rubySym>");`.
|
|
421
|
+
The default signatures locked by Story 8.0a are:
|
|
422
|
+
- Actions: `(args?: Record<string, unknown>) => Promise<unknown>`
|
|
423
|
+
- Queries: `() => Promise<unknown>`
|
|
424
|
+
These are the **direct-callable surface** — they describe how the ref
|
|
425
|
+
appears to JS event handlers and to `await someRef(args)` call sites.
|
|
426
|
+
Per-function precision (e.g. `(args: { title: string }) => Promise<{ id: number }>`)
|
|
427
|
+
is **not** generated from the Ruby DSL in Phase 2 — devs annotate manually
|
|
428
|
+
if they want stronger types. Evolution to Ruby-side type metadata is
|
|
429
|
+
reserved for a Phase 3 ADR addendum.
|
|
430
|
+
|
|
431
|
+
2. **`<form action={fn}>` semantics + FormData → Rails params.** React
|
|
432
|
+
invokes a server reference passed to `<form action>` with a single
|
|
433
|
+
`FormData` argument, not the `args: { title, body }` shape the body
|
|
434
|
+
sketch implies. Story 8.1 owns the controller-side unwrapping
|
|
435
|
+
(`FormData` → `params`); Story 8.2 owns the React-side path with
|
|
436
|
+
`useActionState` and the error overlay. **Open design decision deferred
|
|
437
|
+
to Story 8.2:** the conservative `Record<string, unknown>` signature
|
|
438
|
+
above is NOT structurally compatible with `FormData` in TypeScript —
|
|
439
|
+
`<form action={createPost}>` will fail `tsc --noEmit` against the
|
|
440
|
+
8.0a-emitted module as-is. Story 8.2 decides whether to (a) widen the
|
|
441
|
+
codegen signature to `(args?: FormData | Record<string, unknown>) =>
|
|
442
|
+
Promise<unknown>`, (b) export a sibling `.formAction` method on each
|
|
443
|
+
ref typed `(formData: FormData) => Promise<unknown>`, or (c) require
|
|
444
|
+
an explicit cast at the form's call site (`<form action={createPost
|
|
445
|
+
as (fd: FormData) => Promise<unknown>}>`). 8.0a does not pre-commit
|
|
446
|
+
to one — picking it here would lock the 8.2 design without the
|
|
447
|
+
implementation context that disambiguates the trade-offs.
|
|
448
|
+
|
|
449
|
+
3. **Query transport is POST for everything (supersedes Implementation
|
|
450
|
+
Surface row #3's `GET /__ruact/fn/:name?args=...`).** Both actions and
|
|
451
|
+
queries POST to `/__ruact/fn/:name` with params in the request body —
|
|
452
|
+
CSRF symmetric, no URL-replay, no intermediate caching. Trade-off
|
|
453
|
+
accepted: queries lose HTTP-level cacheability, which is irrelevant for
|
|
454
|
+
the internal RPC use case.
|
|
455
|
+
|
|
456
|
+
4. **`useQuery` shape (supersedes Implementation Surface row #9's "Suspense-
|
|
457
|
+
only" wording).** Story 9.2's hook signature is
|
|
458
|
+
`useQuery(reference, params?) → { data, loading, error }`. Suspense-aware
|
|
459
|
+
queries (`use(promise)` + `<Suspense>` boundaries) are reserved for a
|
|
460
|
+
Phase 3 ADR addendum once React 19's stable `use()` semantics + adoption
|
|
461
|
+
signal land.
|
|
462
|
+
|
|
463
|
+
5. **Query params contract.** The hook accepts `useQuery(ref, params?)`
|
|
464
|
+
while the 8.0a-emitted query ref's direct-callable signature is
|
|
465
|
+
`() => Promise<unknown>` (no params on the callable surface). The two
|
|
466
|
+
shapes coexist because **the hook does NOT invoke the ref as a
|
|
467
|
+
function**; it reads the ref's `$$id` metadata and POSTs to
|
|
468
|
+
`/__ruact/fn/:id` with `params` in the request body itself. The ref
|
|
469
|
+
stays callable for parity with action refs (so devtools / tooling can
|
|
470
|
+
treat both uniformly), but for queries the canonical access path is
|
|
471
|
+
the hook, not the call. **Open design decision deferred to Story 9.2:**
|
|
472
|
+
whether to (a) keep the no-args callable signature and have `useQuery`
|
|
473
|
+
read `$$id` (current intent), (b) widen the callable to
|
|
474
|
+
`(args?: Record<string, unknown>) => Promise<unknown>` and have
|
|
475
|
+
`useQuery` invoke the ref directly (symmetric with actions, simpler
|
|
476
|
+
runtime), or (c) introduce a typed `ServerRef<TParams, TResult>`
|
|
477
|
+
metadata wrapper. 8.0a's codegen does not pre-commit; option (b)
|
|
478
|
+
would require widening the emitted signature and is the most likely
|
|
479
|
+
pick if symmetry wins, but 9.2's implementation context will decide.
|
|
480
|
+
|
|
481
|
+
These clarifications were registered through Story 8.0's Review Findings
|
|
482
|
+
section (the original four `[Review][Patch]` items + the 2026-05-13 Re-run
|
|
483
|
+
batch). The Decision log is append-only — do not rewrite the body sketches
|
|
484
|
+
above. If a future story conflicts with these clarifications, add a new
|
|
485
|
+
dated entry here.
|
|
486
|
+
|
|
487
|
+
### 2026-05-16 — Story 8.2 — codegen signature widening for `<form action>`
|
|
488
|
+
|
|
489
|
+
**Resolves:** the 2026-05-13 clarification #2 "Open design decision deferred
|
|
490
|
+
to Story 8.2" (FormData wire shape vs. action signature). Story 8.2 picks
|
|
491
|
+
**option (a) — widen the codegen-emitted action signature** to accept
|
|
492
|
+
either a `FormData` instance or a plain `Record<string, unknown>` argument.
|
|
493
|
+
|
|
494
|
+
**New action signature (Ruby + JS codegens in lockstep):**
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
export const createPost: (args?: FormData | Record<string, unknown>) => Promise<unknown> =
|
|
498
|
+
_makeRef("create_post");
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Query signatures are UNCHANGED — `() => Promise<unknown>`. The widening
|
|
502
|
+
applies to actions only because only actions are reachable via
|
|
503
|
+
`<form action={fn}>`; queries are read-only and reach the wire through
|
|
504
|
+
`useQuery` (Story 9.2), not a `<form>`.
|
|
505
|
+
|
|
506
|
+
**Why option (a) over (b) — sibling `.formAction` property:**
|
|
507
|
+
|
|
508
|
+
| Axis | (a) widen primary export | (b) sibling `.formAction` |
|
|
509
|
+
| --- | --- | --- |
|
|
510
|
+
| Call-site ceremony | None — `<form action={createPost}>` works | Two import shapes (`createPost.formAction` for forms; `createPost` for direct calls) |
|
|
511
|
+
| TS type surface | One signature, one accepted-types union | Two signatures must stay in sync per ref |
|
|
512
|
+
| Codegen complexity | Single ternary update | Per-ref dual export (doubles emitted line count) |
|
|
513
|
+
| Discoverability | Auto-complete suggests one symbol | Auto-complete branches on `.formAction` |
|
|
514
|
+
| Failure mode | Direct caller passing a plain object — narrowed at runtime by `instanceof FormData` | Passing `.formAction(plainObj)` typechecks but breaks at runtime |
|
|
515
|
+
|
|
516
|
+
The two TS-friendliness concerns option (b) was meant to mitigate — call
|
|
517
|
+
sites that pass plain objects shouldn't be widened to accept FormData —
|
|
518
|
+
were judged less load-bearing than the codegen + import + auto-complete
|
|
519
|
+
duplication cost of option (b). Direct callers retain the option of
|
|
520
|
+
explicit narrowing at the call site (`createPost(args as Record<string, unknown>)`)
|
|
521
|
+
if they want stricter typing inside their own code.
|
|
522
|
+
|
|
523
|
+
**Why not option (c) — explicit cast at the form site:**
|
|
524
|
+
|
|
525
|
+
Rejected as documented in the body. The whole point of the codegen layer
|
|
526
|
+
is to eliminate call-site ceremony; a project-wide `<form action={createPost as (fd: FormData) => Promise<void>}>`
|
|
527
|
+
convention defeats the design.
|
|
528
|
+
|
|
529
|
+
**`useActionState` integration:**
|
|
530
|
+
|
|
531
|
+
React 19's `useActionState(action, initialState)` calls the action as
|
|
532
|
+
`(prevState, formData) => state`. The widened single-arg signature does
|
|
533
|
+
NOT structurally match this two-arg shape. The runtime supports the
|
|
534
|
+
two-arg invocation (Story 8.2 AC3 — `_makeRef` accepts up to two
|
|
535
|
+
positional args and picks the FormData-typed candidate) so a wrapper is
|
|
536
|
+
not required at runtime, but TypeScript-strict consumers must wrap when
|
|
537
|
+
binding to `useActionState`:
|
|
538
|
+
|
|
539
|
+
```tsx
|
|
540
|
+
import { createPost } from "@/.ruact/server-functions";
|
|
541
|
+
import { useActionState } from "react";
|
|
542
|
+
|
|
543
|
+
export function PostForm() {
|
|
544
|
+
const [state, formAction, pending] = useActionState(
|
|
545
|
+
(_prevState, formData: FormData) => createPost(formData),
|
|
546
|
+
null,
|
|
547
|
+
);
|
|
548
|
+
return (
|
|
549
|
+
<form action={formAction}>
|
|
550
|
+
<input name="title" />
|
|
551
|
+
<button disabled={pending}>Create</button>
|
|
552
|
+
</form>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The wrap-per-call pattern is the documented contract. If a future React
|
|
558
|
+
release converges `<form action>` and `useActionState` on a single call
|
|
559
|
+
shape, this addendum should be revisited. The codegen is not generating
|
|
560
|
+
overload signatures for the two-arg shape in Phase 2; that's reserved
|
|
561
|
+
for a future iteration if call-site friction becomes load-bearing.
|
|
562
|
+
|
|
563
|
+
**Worked typecheck example (SUPERSEDED — see 2026-05-17 R1 addendum):**
|
|
564
|
+
|
|
565
|
+
> **2026-05-17 update:** the single-signature widening below does NOT
|
|
566
|
+
> actually let `<form action={createPost}>` typecheck — `Promise<unknown>`
|
|
567
|
+
> is invariant to `Promise<void>` so React 19's `(formData: FormData) =>
|
|
568
|
+
> void | Promise<void>` prop rejects the assignment. The 2026-05-17 R1
|
|
569
|
+
> addendum below refines to a TypeScript intersection that satisfies
|
|
570
|
+
> both call sites. The block below stays as historical record.
|
|
571
|
+
|
|
572
|
+
```tsx
|
|
573
|
+
// @/.ruact/server-functions emits (pre-R1 — STALE):
|
|
574
|
+
// export const createPost: (args?: FormData | Record<string, unknown>) => Promise<unknown>
|
|
575
|
+
//
|
|
576
|
+
// 1. Direct call with a plain object (event handler) — STILL typechecks.
|
|
577
|
+
await createPost({ title: "Hi" });
|
|
578
|
+
|
|
579
|
+
// 2. Direct call with FormData — STILL typechecks.
|
|
580
|
+
const fd = new FormData();
|
|
581
|
+
fd.append("title", "Hi");
|
|
582
|
+
await createPost(fd);
|
|
583
|
+
|
|
584
|
+
// 3. <form action={createPost}> — REJECTED by tsc under strict mode
|
|
585
|
+
// because Promise<unknown> is not assignable to Promise<void>. See
|
|
586
|
+
// the 2026-05-17 R1 addendum below for the intersection refinement
|
|
587
|
+
// that makes this site typecheck.
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**`revalidate()` re-export (new in Story 8.2):**
|
|
591
|
+
|
|
592
|
+
The codegen now appends a fixed re-export AFTER all per-function exports
|
|
593
|
+
(also emitted when the registry is empty — the helper is unconditional):
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
export { revalidate } from "ruact/server-functions-runtime";
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
`revalidate(path?)` is implemented in the runtime; it reads
|
|
600
|
+
`globalThis.__ruact_revalidate` (published by `ruact-router.js#setupRouter`)
|
|
601
|
+
and triggers a Flight refetch of the supplied path or the current URL.
|
|
602
|
+
See the Story 8.2 file for the runtime API spec and the docs page for
|
|
603
|
+
end-user-facing documentation.
|
|
604
|
+
|
|
605
|
+
**Impact on Story 9.x:**
|
|
606
|
+
|
|
607
|
+
Open clarification #5 ("Query params contract") is unaffected. The
|
|
608
|
+
action-side widening here does NOT pre-commit Story 9.2 to one option
|
|
609
|
+
or the other for queries — query signatures remain narrow
|
|
610
|
+
(`() => Promise<unknown>`) until Story 9.2 picks (a) / (b) / (c) in its
|
|
611
|
+
own ADR addendum. The byte-parity tests track action and query branches
|
|
612
|
+
independently.
|
|
613
|
+
|
|
614
|
+
### 2026-05-17 — Story 8.2 code-review patch R1 — intersection-type refinement
|
|
615
|
+
|
|
616
|
+
**Resolves:** option (a) as documented in the 2026-05-16 addendum above
|
|
617
|
+
was wrong on the typecheck side. `(args?: FormData | Record<string, unknown>)
|
|
618
|
+
=> Promise<unknown>` is NOT structurally assignable to React 19's
|
|
619
|
+
`<form action>` prop type `(formData: FormData) => void | Promise<void>`
|
|
620
|
+
— Promise generics are invariant, so `Promise<unknown>` is not
|
|
621
|
+
assignable to `Promise<void>` even via the void-discard rule. Empirical
|
|
622
|
+
typecheck probe (`playgrounds/demo/spec/typecheck/form-action-direct.tsx`)
|
|
623
|
+
surfaced the error:
|
|
624
|
+
|
|
625
|
+
```
|
|
626
|
+
Type 'Promise<unknown>' is not assignable to type 'Promise<void>'.
|
|
627
|
+
Type 'unknown' is not assignable to type 'void'.
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
The original 2026-05-16 decision shipped without a direct
|
|
631
|
+
`<form action={createPost}>` typecheck site in the playground (only the
|
|
632
|
+
`useActionState` wrapper pattern was tested, which sidesteps the issue
|
|
633
|
+
by routing through a closure with a `void` return). Code review caught
|
|
634
|
+
this gap and asked for a real `<form action={createPost}>` site that
|
|
635
|
+
typechecks WITHOUT a cast.
|
|
636
|
+
|
|
637
|
+
**Refined option (a) → (a′) — intersection type.** The codegen now emits:
|
|
638
|
+
|
|
639
|
+
```ts
|
|
640
|
+
export const createPost:
|
|
641
|
+
((args?: FormData | Record<string, unknown>) => Promise<unknown>)
|
|
642
|
+
& ((formData: FormData) => Promise<void>) =
|
|
643
|
+
_makeRef("create_post");
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
The intersection's first call signature satisfies direct callers (event
|
|
647
|
+
handlers, `useActionState` wrappers); the second satisfies React 19's
|
|
648
|
+
`<form action>` prop. Same export, both call sites typecheck. Runtime
|
|
649
|
+
behavior is unchanged — `_makeRef` always resolves with the JSON-decoded
|
|
650
|
+
value at runtime; the `Promise<void>` overload is a TYPE-ONLY surface,
|
|
651
|
+
selected only when React invokes the function from a `<form action>`
|
|
652
|
+
prop (where React discards the return value anyway).
|
|
653
|
+
|
|
654
|
+
**`_makeRef` declaration follows the intersection.** The `_makeRef`
|
|
655
|
+
return type in `gem/vendor/javascript/ruact-server-functions-runtime/index.d.ts`
|
|
656
|
+
is now also an intersection so the codegen's emitted annotation is
|
|
657
|
+
satisfied by the RHS without a cast. Declaration-only; the JS
|
|
658
|
+
implementation is untouched.
|
|
659
|
+
|
|
660
|
+
**Why not function overloads?** TypeScript supports declaration-style
|
|
661
|
+
function overloads (`function foo(x): Y; function foo(): Z;`) but NOT
|
|
662
|
+
on `export const` bindings. Intersection of two call signatures on a
|
|
663
|
+
type literal is the idiomatic TS equivalent and is what every
|
|
664
|
+
production-grade overloaded-callable library (Lodash, Ramda, etc.) emits
|
|
665
|
+
under its types.
|
|
666
|
+
|
|
667
|
+
**Reserved-name protection (review patch R2, same date).** With
|
|
668
|
+
`revalidate` now unconditionally re-exported by the codegen from BOTH
|
|
669
|
+
the empty-registry and populated-registry branches, a controller
|
|
670
|
+
declaring `ruact_action :revalidate` would emit a duplicate `export
|
|
671
|
+
const revalidate` next to the helper re-export and crash at module
|
|
672
|
+
load. `NameBridge.RESERVED_BY_RUACT = %w[revalidate].to_set` rejects
|
|
673
|
+
the symbol at controller-class load time with a clear error message.
|
|
674
|
+
Escape hatches: `:revalidate_post` (suffix) and `:_revalidate`
|
|
675
|
+
(leading underscore) both pass.
|
|
676
|
+
|
|
677
|
+
**`<form action={...}>` empirical validation steps (replicable):**
|
|
678
|
+
|
|
679
|
+
1. `cd playgrounds/demo && npx tsc --noEmit -p tsconfig.json`
|
|
680
|
+
2. The probe at `spec/typecheck/form-action-direct.tsx` declares three
|
|
681
|
+
patterns:
|
|
682
|
+
- `<form action={demoEcho}>` directly (no cast, no wrapper)
|
|
683
|
+
- `await demoEcho({ message: "Hi" })` direct call returning `unknown`
|
|
684
|
+
- `useActionState<unknown, FormData>((_prev, fd) => demoEcho(fd), null)`
|
|
685
|
+
3. All three patterns typecheck under `strict: true`. If any single
|
|
686
|
+
pattern fails, the codegen's intersection emission has regressed.
|
|
687
|
+
|
|
688
|
+
The 2026-05-16 addendum body (option (a) narrative + worked example)
|
|
689
|
+
stays as the historical record — replace any direct `(args?: ...)`
|
|
690
|
+
quotation in future stories with the intersection form. The
|
|
691
|
+
`useActionState` wrapper pattern documented in 2026-05-16 remains the
|
|
692
|
+
canonical approach for `useActionState` because the intersection's
|
|
693
|
+
single-arg signature is NOT structurally compatible with the two-arg
|
|
694
|
+
shape React's hook expects.
|
|
695
|
+
|
|
696
|
+
### 2026-05-17 — Story 8.3 — standalone host execution context
|
|
697
|
+
|
|
698
|
+
**Status:** Resolved (Story 8.3 landed standalone host shape + dispatcher branch + CSRF policy + `current_user_resolver` config).
|
|
699
|
+
|
|
700
|
+
**Scope:** Closes 2026-05-13 clarification #4 ("Standalone host execution context — deferred to Story 8.3"). Captures (a) the `Ruact::ServerAction` extend pattern + why it was chosen over `class << self`-style or `include`-style; (b) the `StandaloneContext` attribute set + why `render`/`redirect_to`/`head` are excluded; (c) the `current_user_resolver` lambda contract; (d) why the registry's `controller` field name was kept despite the semantic widening; (e) the CSRF policy migration to the gem side for the standalone branch.
|
|
701
|
+
|
|
702
|
+
**No code-shape changes to the locked accessor.** `import { createPost } from "@/.ruact/server-functions"` still works identically; the codegen output is byte-identical regardless of host shape. The 2026-05-16/17 intersection signature still applies. The only new piece on the Ruby side is `Ruact::ServerAction` (an extend module) + `StandaloneDispatcher` (a branch inside `EndpointController#dispatch_action`).
|
|
703
|
+
|
|
704
|
+
**(a) Why `extend Ruact::ServerAction`?**
|
|
705
|
+
|
|
706
|
+
```ruby
|
|
707
|
+
module CreatePost
|
|
708
|
+
extend Ruact::ServerAction
|
|
709
|
+
|
|
710
|
+
ruact_action :create_post do |params|
|
|
711
|
+
...
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
Considered alternatives:
|
|
717
|
+
|
|
718
|
+
1. `class << self; extend Ruact::ServerAction; ...; end` — verbose, idiomatically used for adding class methods to a class, not for marking a top-level module as a server-action host.
|
|
719
|
+
2. `include Ruact::ServerAction` — would put `ruact_action` on instances; standalone modules don't have instances (no `.new` semantics, the block runs against a per-request `StandaloneContext`). Confusing.
|
|
720
|
+
3. `Ruact::ServerAction.declare(MyModule) { ... }` — declarative API outside the module body. Loses the parallel with `Ruact::Controller`'s `include`-then-`ruact_action`-in-class-body shape that controllers use.
|
|
721
|
+
4. **Chosen:** `extend Ruact::ServerAction` puts the `ruact_action` macro on the module's singleton class. The DSL inside the module body reads naturally; the `ruact_action` registration call sees `self == TheModule` and registers `controller: self`.
|
|
722
|
+
|
|
723
|
+
The `extend` choice also lets the `EndpointController.standalone_host?(host)` predicate use a positive check: `host.is_a?(Module) && !host.is_a?(Class) && host.singleton_class.include?(Ruact::ServerAction)`. The check is robust against a host that happens to be `extend`ed by both `Ruact::ServerAction` AND some other module — order doesn't matter; what matters is that `Ruact::ServerAction` is in the singleton ancestry.
|
|
724
|
+
|
|
725
|
+
**(b) `StandaloneContext` attribute set:**
|
|
726
|
+
|
|
727
|
+
Exposed: `params`, `current_user` (resolver-backed, memoized), `session`, `cookies`, `headers`, `request`.
|
|
728
|
+
|
|
729
|
+
Excluded — `render` / `redirect_to` / `head` raise `NoMethodError` with a documented hint. Reasoning: those are controller-context methods. A dev who writes `render(json: ..., status: ...)` inside a standalone block has a wrong mental model — the standalone path's response contract is "return a value, or raise `Ruact::ActionError(status:, body:)`". Letting `render` partially work (e.g. write to `response`) would create a second response-shape source-of-truth inside the dispatcher; the loud `NoMethodError` makes the contract unambiguous. The error message names the alternatives so the fix is immediate.
|
|
730
|
+
|
|
731
|
+
`StandaloneContext` is NOT a `Data.define` — it carries lazy/memoized state (`current_user`'s resolution flag, the read-tracking flag for the dev-only "unread current_user" warning) that doesn't fit Data's immutability contract.
|
|
732
|
+
|
|
733
|
+
**(c) `current_user_resolver` lambda contract:**
|
|
734
|
+
|
|
735
|
+
- **Argument:** receives `request.env` (Hash). Documented contract — the lambda MUST NOT receive the full `ActionDispatch::Request` (would let the dev mutate request state in ways the dispatcher can't track).
|
|
736
|
+
- **Return:** the authenticated user (any Ruby object) or `nil`.
|
|
737
|
+
- **Lookup precedence:** (1) `request.env['ruact.current_user']` if the key is PRESENT (even if `nil`); (2) the configured lambda. The env-key path is for hosts that set the current user via upstream Rack middleware (Devise's Warden uses `env['warden'].user`; the resolver can read it OR the dev can set `env['ruact.current_user']` from a custom middleware to bypass the resolver entirely).
|
|
738
|
+
- **Memoization:** the `StandaloneContext` caches the first `current_user` call for the duration of one dispatch; repeated reads don't re-invoke the resolver. Documented in YARD.
|
|
739
|
+
- **Failure mode:** when neither path produces a value AND the block actually reads `current_user`, raises `Ruact::CurrentUserNotConfiguredError` at dispatch time (NOT at boot — only fires for blocks that actually depend on `current_user`). The error message names both worked examples (Devise + hand-rolled) so the dev fixes the configuration without leaving the stack trace.
|
|
740
|
+
|
|
741
|
+
**(d) Registry `controller` field name retained:**
|
|
742
|
+
|
|
743
|
+
`Ruact::ServerFunctions::RegistryEntry.controller` was `controller:` (a Class) pre-Story-8.3. Story 8.3 widens the SEMANTIC: the field now stores either a Class (controller host) or a Module (standalone host). The FIELD NAME stays `:controller` for two reasons:
|
|
744
|
+
|
|
745
|
+
1. **Back-compat with existing specs / fixtures.** `RegistryEntry`'s `Data.define(:ruby_symbol, :js_identifier, :kind, :controller, :block)` is referenced by name in `Snapshot.functions_payload`, `Snapshot.read_for_codegen`, and dozens of spec assertions. Renaming would be a churn-heavy mechanical migration with no behavioural benefit.
|
|
746
|
+
2. **Snapshot output is unchanged.** The JSON snapshot reads `js_identifier` / `ruby_symbol` / `kind` from each entry; it does NOT serialize `controller`. The codegen output is byte-identical regardless of host shape — so the field name choice is invisible outside the gem's own code.
|
|
747
|
+
|
|
748
|
+
Future stories that need to disambiguate may add an explicit `host_kind: :class | :module` field; that's an additive change, not a rename. For now, "host" is the semantic concept and `controller` is the field that stores it.
|
|
749
|
+
|
|
750
|
+
**(e) CSRF policy migration to the gem side for the standalone branch:**
|
|
751
|
+
|
|
752
|
+
Story 8.1 deliberately skipped `verify_authenticity_token` on `EndpointController` (the gem-mounted controller backing `POST /__ruact/fn/:name`) precisely because the host controller's `protect_from_forgery` would re-enforce it inside `host_class.dispatch`. For standalone actions there is no host chain. The dispatcher must enforce CSRF itself OR explicitly opt out per-app.
|
|
753
|
+
|
|
754
|
+
**Decision:** enforce by default. The gem's `EndpointController` carries a `protect_from_forgery with: :exception, if: :dispatching_standalone?` declaration. This installs:
|
|
755
|
+
|
|
756
|
+
- The forgery_protection_strategy = :exception (idiomatic Rails wiring; using a bare `before_action :verify_authenticity_token` would crash because the strategy class is otherwise nil).
|
|
757
|
+
- A conditional `before_action :verify_authenticity_token` that fires only when `dispatching_standalone?` returns true (the resolved entry's host is a Module extending `Ruact::ServerAction`).
|
|
758
|
+
|
|
759
|
+
The condition is resolved EARLY via a `prepend_before_action :resolve_ruact_entry!` that runs before the conditional CSRF callback. The controller-action branch keeps `skip_forgery_protection`-equivalent behavior (the `if:` condition is false, the callback skips, and the host controller's own `protect_from_forgery` handles CSRF as before).
|
|
760
|
+
|
|
761
|
+
Rails' `verified_request?` short-circuits when `allow_forgery_protection = false` globally, so API-mode hosts accept standalone POSTs without a token — same observable behavior as controller-hosted actions in API mode.
|
|
762
|
+
|
|
763
|
+
**Why not handle CSRF inside `StandaloneDispatcher`?** Doing it as a Rails callback lets the framework's existing instrumentation, exception classes, and logging all participate. `ActionController::InvalidAuthenticityToken` is the canonical exception class; raising it inline from a custom dispatcher would lose that. The conditional `before_action` is the smallest possible change to the gem's request-cycle wiring that preserves user-visible parity with the controller-hosted matrix.
|
|
764
|
+
|
|
765
|
+
**Append-only invariant preserved.** No code-shape changes to the locked accessor. The Story 8.0 ADR's "single accessor mechanism" decision is intact; this addendum adds a second host shape that funnels through the same accessor.
|
|
766
|
+
|
|
767
|
+
### 2026-05-18 — Story 8.4 — structured server-action error payload
|
|
768
|
+
|
|
769
|
+
**Context.** Story 8.1 (controller-hosted dispatch), Story 8.2 (`<form action={fn}>` runtime), and Story 8.3 (standalone dispatcher) all let a raised exception bubble back to Rails' default `ActionDispatch::ShowExceptions` middleware on the unhappy path — producing an HTML error page that the runtime received as `RuactActionError({ status, body: "<html>..." })`. Story 8.4 closes NFR30 by introducing a structured wire body so the dev overlay can render a meaningful diagnostic view AND the production React component can render its own UI from the same shape.
|
|
770
|
+
|
|
771
|
+
**Decision.** Add an OUTERMOST `rescue_from StandardError` (and explicit `rescue_from ActionController::InvalidAuthenticityToken`) on `EndpointController` that renders a JSON Hash with a `_ruact_server_action_error: true` discriminator. The Story 8.0 accessor lock is UNTOUCHED; the new wire surface lives entirely in the `body` field of the existing `RuactActionError`, which the runtime treats as opaque JSON.
|
|
772
|
+
|
|
773
|
+
**(a) Wire shape — backward-compatible body refinement.**
|
|
774
|
+
|
|
775
|
+
| Field | Dev mode | Prod mode |
|
|
776
|
+
| --- | --- | --- |
|
|
777
|
+
| `_ruact_server_action_error` (bool) | ✅ | ✅ |
|
|
778
|
+
| `action_name` (string) | ✅ | ✅ |
|
|
779
|
+
| `error_class` (string) | ✅ | ✅ |
|
|
780
|
+
| `message` (string) | ✅ | ✅ |
|
|
781
|
+
| `app_frames` (array of string) | ✅ | absent (key not present) |
|
|
782
|
+
| `gem_frames` (array of string) | ✅ | absent |
|
|
783
|
+
| `suggestion` (string \| null) | ✅ | absent |
|
|
784
|
+
| `validation_errors` (array of string) | only for `ActiveRecord::RecordInvalid` | absent |
|
|
785
|
+
|
|
786
|
+
The discriminator field (`_ruact_server_action_error: true`) is the load-bearing piece: the React overlay uses it to decide whether to render the structured branch or fall back to the existing `<pre>{error.message}</pre>` rendering. Existing callers that read `body.error` (the Story 8.3 malformed-JSON case) or treat the body as opaque keep working — no field rename, no key removal.
|
|
787
|
+
|
|
788
|
+
The dev/prod gate is `Ruact.config.dev_error_payload_enabled`, default `nil` (the endpoint resolves nil to `Rails.env.development? || Rails.env.test?`). Setting `c.dev_error_payload_enabled = false` inside `Ruact.configure` forces production-shape errors locally — useful for verifying what the React component receives in prod.
|
|
789
|
+
|
|
790
|
+
**(b) Host `rescue_from` precedence rule.**
|
|
791
|
+
|
|
792
|
+
The endpoint's `rescue_from StandardError` is the OUTERMOST catch — it only fires for exceptions the host did NOT handle. A host that declares `rescue_from ActiveRecord::RecordInvalid, with: :handle_record_invalid` continues to render its own response unchanged. The structured payload is the FALLBACK, not the override. This invariant is what lets hosts opt into the gem's diagnostics for unhandled classes while keeping their own error UI for owned domain errors.
|
|
793
|
+
|
|
794
|
+
**(c) Suggestion table is a gem-published surface.**
|
|
795
|
+
|
|
796
|
+
`Ruact::ServerFunctions::ErrorSuggestion::SUGGESTIONS` is a frozen Hash keyed by error-class-name strings. Today it carries the two NFR30 mandated mappings (`ActiveRecord::RecordInvalid`, `ActionController::InvalidAuthenticityToken`). The table is NOT host-configurable for now: extending it requires an ADR amendment + a constant update. Rationale: making it runtime-configurable would require a Configuration knob that lives in the seal contract (Story 7.3), expanding the public surface for a feature without a cross-host demand signal. A future story that adds (e.g.) `ActiveRecord::StaleObjectError` opens this paragraph.
|
|
797
|
+
|
|
798
|
+
**(d) Status code mapping.**
|
|
799
|
+
|
|
800
|
+
| Exception | HTTP status |
|
|
801
|
+
| --- | --- |
|
|
802
|
+
| `ActiveRecord::RecordInvalid` | `422` |
|
|
803
|
+
| `ActionController::InvalidAuthenticityToken` | `403` |
|
|
804
|
+
| any other `StandardError` | `500` |
|
|
805
|
+
|
|
806
|
+
**Breaking change vs Story 8.3 R2:** standalone CSRF rejections previously returned `422` (Rails' default `InvalidAuthenticityToken` → 422 mapping in `ShowExceptions`); they now return `403`. The change is intentional — 403 Forbidden is semantically the right code for "you are not authorised to make this request" (CSRF mismatch == missing/invalid credentials), while 422 is reserved for "the request was syntactically valid but the entity could not be processed" (validation errors). The Story 8.3 R2 spec assertion is updated in lockstep; production hosts that branched on the 422 status for CSRF-specific handling MUST migrate to either the 403 status or the `error_class === "ActionController::InvalidAuthenticityToken"` field on the structured body.
|
|
807
|
+
|
|
808
|
+
**(e) `BacktraceCleaner.split` semantics.**
|
|
809
|
+
|
|
810
|
+
`Ruact::ServerFunctions::BacktraceCleaner.split(error.backtrace)` returns `{ app: [...], gem: [...] }` by classifying each frame on a prefix match against `Ruact.gem_path` (a memoised gem-root accessor added to `lib/ruact.rb`). The implementation is ~10 LoC with zero ActiveSupport dependency — `ActiveSupport::BacktraceCleaner`'s silencer/filter API was deemed heavyweight for the single-purpose need; the lean implementation also loads cleanly in AR-less specs. The app/gem split is what powers the overlay's "App backtrace shown by default, gem frames behind a toggle" UX (NFR30). Frame caps (`MAX_FRAMES_PER_BUCKET = 25`) keep the wire payload bounded; the full backtrace is in `Rails.logger.error` regardless.
|
|
811
|
+
|
|
812
|
+
**(f) Pure-function ErrorPayload module.**
|
|
813
|
+
|
|
814
|
+
`Ruact::ServerFunctions::ErrorPayload.build(action_name:, error:, mode:)` has zero I/O — no `Rails.env` read, no `Ruact.config` read. The caller (`EndpointController#__ruact_render_action_error`) resolves `mode` and passes it in. That keeps the module trivially unit-testable without stubbing Rails env, and isolates "what the wire shape is" from "what env are we in". Same design choice as the standalone dispatcher's `Result` value object (Story 8.3).
|
|
815
|
+
|
|
816
|
+
**Append-only invariant preserved.** The Story 8.0 accessor lock is untouched. The `RuactActionError` constructor signature on the runtime side is untouched (still `{ name, status, body, response }`). The new wire surface is entirely a refinement of what `body` can contain, gated by a discriminator that pre-existing callers do not read.
|
|
817
|
+
|
|
818
|
+
### 2026-05-23 — Story 8.5 — file uploads + `max_upload_bytes` guard
|
|
819
|
+
|
|
820
|
+
**Context.** Story 8.1 (controller-hosted dispatch) and Story 8.3 (standalone dispatcher) both already routed `multipart/form-data` bodies through `request.request_parameters`; React 19's `<form action={fn}>` auto-FormData (Story 8.2) already includes `<input type="file">` entries in the FormData; the runtime's `pickWirePayload` / `buildFetchInit` (Story 8.1) already POST FormData as `multipart/form-data` with the browser-set boundary. So the "wire path for file uploads" was, on paper, already in place. Story 8.5 closes FR84 by (a) pinning the round-trip behavior with explicit gem-side specs so a future refactor can't silently regress UploadedFile delivery, and (b) introducing a controller-boundary size guard so an oversized request gets the Story 8.4 structured 413 instead of timing out at the multipart parser.
|
|
821
|
+
|
|
822
|
+
**Decision.**
|
|
823
|
+
|
|
824
|
+
**(a) No accessor surface change.** The Story 8.0 ADR lock is intact. The React side continues to import `{ createPost } from "@/.ruact/server-functions"` and pass FormData; nothing new is exported. The runtime is unmodified — `pickWirePayload` already routes FormData to the multipart branch, `buildFetchInit` already lets the browser set the boundary header. This story's delta lives ENTIRELY on the Ruby side.
|
|
825
|
+
|
|
826
|
+
**(b) New `Ruact.config.max_upload_bytes` attribute.** Integer (bytes). Default `10 * 1024 * 1024` (10 MB). Set to `nil` to disable the gem-side guard (the host's reverse proxy / middleware then owns the operational cap). Carries the standard `Ruact::Configuration` seal contract (Story 7.3) — direct post-boot mutation raises `Ruact::ConfigurationError`.
|
|
827
|
+
|
|
828
|
+
**(c) New `Ruact::UploadTooLargeError` exception class.** Inherits from `Ruact::Error` so the Story 8.4 `rescue_from StandardError` chain on `EndpointController` catches it cleanly. Carries `received_bytes` and `limit_bytes` attr_readers so the structured payload can surface both numbers without re-parsing the message string. The class lives in the gem's public namespace (`lib/ruact/errors.rb` next to `ActionError`/`CurrentUserNotConfiguredError`) because docs reference it by name and the `ErrorSuggestion::SUGGESTIONS` table gets a new keyed entry.
|
|
829
|
+
|
|
830
|
+
**(d) Pre-parse `Content-Length` guard.** `EndpointController` gains `prepend_before_action :__ruact_enforce_upload_limit!` ABOVE the existing `:resolve_ruact_entry!` callback (so the guard fires FIRST in the chain). The check uses `request.content_length` — NOT body inspection — so it fires BEFORE Rack's multipart parser runs. That's the cheapest possible reject: a 100 MB upload's headers are parsed and we 413 it without touching the body. Short-circuits: `max_upload_bytes = nil`; content type not `multipart/form-data` / `application/x-www-form-urlencoded`; `Content-Length` absent (chunked transfer). The dispatch-independence of the guard is why it lives as a callback, not inside `dispatch_action`.
|
|
831
|
+
|
|
832
|
+
**(e) Why `Content-Length`, not body inspection?** Body inspection (e.g., `Rack::Request#tempfile_for_each_part`) would defeat the purpose of "reject before parsing" — the parser would already be buffering bytes to disk. `Content-Length` is the cheapest pre-parse reject; the cost is two carve-outs: (1) chunked-transfer clients bypass the guard because `Content-Length` is absent (rare for browsers, common for some HTTP libraries), and (2) the reported `received_bytes` is the wire Content-Length including multipart boundary overhead, NOT the parsed file size. A 9.5 MB file uploaded via multipart reports `received_bytes ≈ 9.7 MB`. The 10 MB default has headroom for the boundary overhead in the common case; docs call out the edge.
|
|
833
|
+
|
|
834
|
+
**(f) Operational cap belongs to the reverse proxy.** `max_upload_bytes` is a controller-level "fail fast at the boundary" knob, NOT a memory-safety guarantee. Rack's multipart parser will still buffer bodies up to its own limits before the controller callback could possibly run on a request without a `Content-Length` header. The docs page (`website/docs/api/server-actions.md` "File uploads" section) explicitly recommends `client_max_body_size` in nginx / `LimitRequestBody` in Apache for the operational cap, plus Active Storage Direct Upload / presigned S3 URLs for large files. The `max_upload_bytes` knob is the "polite reject for the common case", not the load-balancer.
|
|
835
|
+
|
|
836
|
+
**(g) Status code mapping extends Story 8.4's table.**
|
|
837
|
+
|
|
838
|
+
| Exception | HTTP status |
|
|
839
|
+
| --- | --- |
|
|
840
|
+
| `ActiveRecord::RecordInvalid` | `422` |
|
|
841
|
+
| `ActionController::InvalidAuthenticityToken` | `403` |
|
|
842
|
+
| `Ruact::UploadTooLargeError` | `413` (new in 8.5) |
|
|
843
|
+
| any other `StandardError` | `500` |
|
|
844
|
+
|
|
845
|
+
**(h) `ErrorPayload.build` gains a dev-only `upload_limit` block.** When `error.class.name == "Ruact::UploadTooLargeError"` AND `mode == :development`, the payload includes `upload_limit: { received_bytes: <int>, limit_bytes: <int> }` alongside the four baseline keys and the existing dev-mode fields (`app_frames`, `gem_frames`, `suggestion`). The production-mode payload is unchanged (still the four baseline keys only) — `upload_limit` is gated the same way as `app_frames` / `suggestion`. The `_ruact_server_action_error: true` discriminator is preserved.
|
|
846
|
+
|
|
847
|
+
**(i) `ErrorSuggestion::SUGGESTIONS` gains one entry.** `"Ruact::UploadTooLargeError" => "Upload exceeded the configured size limit. Increase Ruact.config.max_upload_bytes or use Active Storage Direct Upload / a presigned S3 URL for large files."` — the table is frozen at class-load time; runtime mutation continues to be unsupported per the Story 8.4 surface decision.
|
|
848
|
+
|
|
849
|
+
**(j) Guard fires BEFORE CSRF (standalone branch).** Because `prepend_before_action :__ruact_enforce_upload_limit!` is added LAST among the prepend_before_action calls in the source, it lands at the FRONT of the chain — running before `:resolve_ruact_entry!` AND before the conditional `verify_authenticity_token` callback. An oversized standalone request without a CSRF token returns 413, not 403. This is correct (cheaper reject; the attacker learns nothing about CSRF state from a 413) and pinned by `endpoint_controller_upload_spec.rb`'s Pitfall #4 example.
|
|
850
|
+
|
|
851
|
+
**(k) `__ruact_render_action_error` action_name fallback.** Because the upload guard runs BEFORE `:resolve_ruact_entry!`, `@__ruact_name_sym` is nil when a 413 fires. The renderer falls back to `request.path_parameters[:name]` so the structured payload's `action_name` still carries the URL-routed name (`"upload_post"`) instead of `"(unknown)"`.
|
|
852
|
+
|
|
853
|
+
**Append-only invariant preserved.** The Story 8.0 accessor lock is untouched. The runtime is unchanged (no new exports, no signature changes to `RuactActionError`, no new helper on `pickWirePayload` / `buildFetchInit`). The new wire surface is a refinement of what the existing `body` field can carry (one new optional dev-only key, `upload_limit`), gated by a discriminator pre-existing callers do not read.
|
|
854
|
+
|
|
855
|
+
**Playground demo carve-out.** The playground demo + Active Storage end-to-end exercise (epic AC2/AC6) are deferred to Story 8.5a — a dedicated `rails new`-generated playground at `playgrounds/epic-8-server-actions/`. The existing `playgrounds/demo/` has no ActiveRecord / SQLite / `config/database.yml`, and retrofitting the DB layer would obscure this story's actual delta. The gem-side request-cycle specs in `endpoint_controller_upload_spec.rb` (controller-hosted + standalone branches, AC1/AC3/AC4 + Pitfalls #1/#4/#12) provide the empirical proof for the gem boundary; the Active Storage attach round-trip lives in 8.5a.
|
|
856
|
+
|
|
857
|
+
### 2026-06-02 — Story 9-0 — Server Functions API redesign (route-driven) — supersedes the authoring + dispatch layers
|
|
858
|
+
|
|
859
|
+
**Trigger.** Correct Course run, 2026-06-02 (workspace artifact:
|
|
860
|
+
`_bmad-output/planning-artifacts/sprint-change-proposal-2026-06-02.md`). Story 9.1's
|
|
861
|
+
seven re-run review rounds — nearly all spent on cross-registry collision /
|
|
862
|
+
rollback / atomicity machinery — exposed that the block-DSL + synthetic-endpoint
|
|
863
|
+
substrate fights itself, and that `POST /__ruact/fn/:name` is a parallel routing
|
|
864
|
+
mechanism that contradicts the project thesis ("Rails routes are the single
|
|
865
|
+
source of truth"). Approved scope: **Major**. Epic 9 was repurposed as "Server
|
|
866
|
+
Functions (route-driven redesign)" (Scheme A — Epic 8 stays `done` as the
|
|
867
|
+
historical record of the v1 design; no downstream epic renumber). This addendum
|
|
868
|
+
is the Story 9-0 re-spike deliverable: it locks the v2 contract the redesigned
|
|
869
|
+
Epic 9 stories implement. Per the append-only rule, the superseded sections
|
|
870
|
+
below remain in the body for history.
|
|
871
|
+
|
|
872
|
+
**What survives (reaffirmed).**
|
|
873
|
+
|
|
874
|
+
- **Option C accessor — UNCHANGED.** Named imports from the generated module:
|
|
875
|
+
`import { createPost, categories } from "@/.ruact/server-functions"`. The
|
|
876
|
+
entire React-side import surface is untouched; everything in this addendum is
|
|
877
|
+
about the Ruby authoring layer and the wire dispatch layer.
|
|
878
|
+
- **NameBridge** snake_case → camelCase rule + reserved-word guards — now applied
|
|
879
|
+
to route-derived action names and query-method names instead of DSL symbols.
|
|
880
|
+
- **`useQuery(reference, params?) → { data, loading, error }`** (the Story 9.2
|
|
881
|
+
shape from clarification #4) — consumes query-class references unchanged.
|
|
882
|
+
- **Salvaged subsystems** (migrate, do not rewrite): structured error payload +
|
|
883
|
+
`ErrorSuggestion` table + `BacktraceCleaner` (Story 8.4); `max_upload_bytes`
|
|
884
|
+
guard + `UploadTooLargeError` + 413 mapping (Story 8.5); the runtime fetch
|
|
885
|
+
core (`_makeRef`'s FormData branching, CSRF meta-tag injection, text-first
|
|
886
|
+
parsing, `RuactActionError`, `redirect: "error"` handling); `revalidate()`;
|
|
887
|
+
serialization via `ruact_props` / `Ruact::Serializable`.
|
|
888
|
+
|
|
889
|
+
**What is superseded.**
|
|
890
|
+
|
|
891
|
+
- The `ruact_action` / `ruact_query` controller block-DSL macros (Stories 8.1 /
|
|
892
|
+
9.1). Implementation Surface row #2 is void.
|
|
893
|
+
- The gem-mounted synthetic endpoint `POST /__ruact/fn/:name` (Implementation
|
|
894
|
+
Surface row #3) AND the 2026-05-13 clarification #3 ("POST for everything").
|
|
895
|
+
Mutations ride real REST routes; queries ride explicitly mounted GET routes
|
|
896
|
+
(Decision 2). Queries regain HTTP GET semantics; reads no longer carry CSRF.
|
|
897
|
+
- The dual `Ruact.action_registry` / `Ruact.query_registry` and the
|
|
898
|
+
cross-registry collision detector (`Snapshot.functions_payload`'s
|
|
899
|
+
cross-registry branch, `__ruact_check_cross_dsl_clobber!`). Single-namespace
|
|
900
|
+
validation moves into codegen, over the route table + query classes.
|
|
901
|
+
- **Standalone actions (Story 8.3, PRD FR63) — DROPPED from the v0.1.0 MVP.**
|
|
902
|
+
Mutations belong to controllers; the standalone module host loses its
|
|
903
|
+
rationale (its `current_user_resolver` pattern is also superseded by
|
|
904
|
+
Decision 2's host-controller dispatch). Revisit post-signal (≥ 1 external
|
|
905
|
+
issue requesting it). `Ruact::ServerAction`, `StandaloneDispatcher`,
|
|
906
|
+
`StandaloneContext`, `current_user_resolver` are removed with it.
|
|
907
|
+
|
|
908
|
+
**Decision 1 — Mutations are normal controller actions.**
|
|
909
|
+
|
|
910
|
+
```ruby
|
|
911
|
+
class PostsController < ApplicationController
|
|
912
|
+
include Ruact::Server # the ONLY marker — no per-action declaration
|
|
913
|
+
|
|
914
|
+
def new; end # GET → page (implicit render via default_render)
|
|
915
|
+
def show; @post = Post.find(params[:id]); end # GET → page
|
|
916
|
+
|
|
917
|
+
def create # non-GET routed action → callable server function
|
|
918
|
+
@post = Post.create!(title: params[:title])
|
|
919
|
+
@post.cover.attach(params[:cover]) if params[:cover].present?
|
|
920
|
+
redirect_to @post
|
|
921
|
+
end
|
|
922
|
+
end
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
- **Verb rule, no per-action marker:** on a controller that includes
|
|
926
|
+
`Ruact::Server`, every **non-GET routed** action (POST/PATCH/PUT/DELETE,
|
|
927
|
+
RESTful or custom member/collection routes) is exposed as a callable server
|
|
928
|
+
function in codegen. GET routes are pages, reached by navigation — never
|
|
929
|
+
emitted as callables.
|
|
930
|
+
- **`routes.rb` carries nothing ruact-specific for mutations.** Standard
|
|
931
|
+
`resources :posts`. The Rails route table is the single source of truth.
|
|
932
|
+
- **Data flows via instance variables** — standard Rails. No `render json:`
|
|
933
|
+
required, no `respond_to`, no block params.
|
|
934
|
+
- **The full host controller chain runs natively** (`before_action`,
|
|
935
|
+
`rescue_from`, Pundit, `protect_from_forgery`) because these ARE controller
|
|
936
|
+
actions. The Story 8.4 structured-error rendering and the Story 8.5 upload
|
|
937
|
+
guard migrate from `EndpointController` into the `Ruact::Server` concern
|
|
938
|
+
(`rescue_from` + `prepend_before_action`), preserving the wire contract
|
|
939
|
+
(discriminator, status table, dev/prod payload split) byte-for-byte where
|
|
940
|
+
possible.
|
|
941
|
+
|
|
942
|
+
**Decision 2 — Queries are classes under `app/queries/`, mounted explicitly, dispatched through a host controller.**
|
|
943
|
+
|
|
944
|
+
```ruby
|
|
945
|
+
# app/queries/catalog_query.rb
|
|
946
|
+
class CatalogQuery < ApplicationQuery # ApplicationQuery < Ruact::Query
|
|
947
|
+
def categories
|
|
948
|
+
Category.active.pluck(:id, :name).map { |id, name| { value: id, label: name } }
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def my_categories
|
|
952
|
+
current_user.categories.pluck(:id, :name)
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def search_users(q:, limit: 10)
|
|
956
|
+
User.search(q).limit(limit)
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
```ruby
|
|
962
|
+
# config/routes.rb — explicit mount (decision A = option b)
|
|
963
|
+
Rails.application.routes.draw do
|
|
964
|
+
ruact_queries CatalogQuery # draws named GET routes, visible in `rails routes`
|
|
965
|
+
resources :posts
|
|
966
|
+
end
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
- **Authoring:** plain public `def` methods on a `Ruact::Query` subclass — each
|
|
970
|
+
method is one query. No block DSL, no mandatory unused params. Method keyword
|
|
971
|
+
args are the query's parameters (consumed by the Story 9.3 sanitization
|
|
972
|
+
contract).
|
|
973
|
+
- **Transport (A = b):** the `ruact_queries` router macro draws one **named GET
|
|
974
|
+
route per public method** (default path scheme `GET /q/<jsIdentifier>`,
|
|
975
|
+
prefix configurable) pointing at the gem's internal dispatch controller. The
|
|
976
|
+
route table knows every query — thesis-aligned, no hidden endpoint.
|
|
977
|
+
- **Security context (B = ii):** the internal dispatch controller **inherits
|
|
978
|
+
from the host's parent controller** (`Ruact.config.query_parent_controller`,
|
|
979
|
+
default `"ApplicationController"` — the Devise `parent_controller` pattern).
|
|
980
|
+
The request therefore runs the host's REAL callback chain
|
|
981
|
+
(`authenticate_user!`, tenant scoping, Pundit) **before** the gem
|
|
982
|
+
instantiates the query class and injects the context. The dev never sees this
|
|
983
|
+
controller. This replaces old Story 9.4's controller-hosted-block contract
|
|
984
|
+
AND Story 8.3's resolver-lambda pattern.
|
|
985
|
+
- **Context injection:** `Ruact::Query#initialize(context)` receives a context
|
|
986
|
+
object exposing `current_user`, `params`, `request`, `session` — delegating
|
|
987
|
+
to the dispatching controller instance. Queries are unit-testable as
|
|
988
|
+
`CatalogQuery.new(fake_context).categories` with no Rails boot.
|
|
989
|
+
- **Per-request instance** (like controllers) → thread-safe (NFR8).
|
|
990
|
+
|
|
991
|
+
**Decision 3 — Dual-bucket response negotiation on the same action.**
|
|
992
|
+
|
|
993
|
+
One mutation action serves both interaction models, discriminated by how it was
|
|
994
|
+
called, reading the same instance variables:
|
|
995
|
+
|
|
996
|
+
| Bucket | Caller | `Accept` | Response |
|
|
997
|
+
| --- | --- | --- | --- |
|
|
998
|
+
| 1 — form / navigation | `<form>` submit, link navigation | `text/x-component` | Flight re-render or Flight redirect — **the existing Story 3.3 / 3.4 mechanism, unchanged** |
|
|
999
|
+
| 2 — imperative | `await createPost(formData)` via the generated ref | `application/json` | Exposed ivars serialized (Decision 5); `redirect_to` surfaces as `{ "$redirect": "<path>" }` for the caller to follow (precedent: Story 8.1's `redirect: "error"` runtime handling) |
|
|
1000
|
+
|
|
1001
|
+
Bucket 1 requires no new code — it is Phase 1 behavior. Bucket 2 is the genuine
|
|
1002
|
+
Epic-8 delta and is what the generated refs target (real route + verb instead of
|
|
1003
|
+
`/__ruact/fn/:name`).
|
|
1004
|
+
|
|
1005
|
+
**Decision 4 — Codegen reads the route table + query classes.**
|
|
1006
|
+
|
|
1007
|
+
- **Sources:** (a) `Rails.application.routes` filtered to non-GET routes whose
|
|
1008
|
+
controller includes `Ruact::Server`; (b) `Ruact::Query` subclasses under
|
|
1009
|
+
`app/queries/` (their public instance methods). The JSON bridge →
|
|
1010
|
+
`server-functions.ts` pipeline (Story 8.0a) survives with its sources swapped;
|
|
1011
|
+
write-if-changed and `config.to_prepare` regeneration are retained.
|
|
1012
|
+
- **Naming (decision D):** action names derive from the route —
|
|
1013
|
+
`posts#create` → `createPost` (action verb + singularized resource;
|
|
1014
|
+
collection routes keep the resource plural where natural, e.g.
|
|
1015
|
+
`posts#publish_all` → `publishAllPosts`). Query names derive from the method
|
|
1016
|
+
name via NameBridge (`search_users` → `searchUsers`). Any collision in the
|
|
1017
|
+
merged JS namespace fails codegen loudly at boot (same failure mode as
|
|
1018
|
+
today's collision detector) and requires an explicit per-action/per-method
|
|
1019
|
+
rename via an override macro (exact macro name owned by the implementing
|
|
1020
|
+
story; the override is the escape hatch, not the default).
|
|
1021
|
+
- **Signatures:** actions keep the Story 8.2 intersection type (FormData +
|
|
1022
|
+
args-object callable); queries keep `() => Promise<unknown>` /
|
|
1023
|
+
`(params) => Promise<unknown>`. Per-function return-type precision remains a
|
|
1024
|
+
Phase 3 candidate (unchanged from clarification #1).
|
|
1025
|
+
|
|
1026
|
+
**Decision 5 — Bucket-2 return payload (decision C).**
|
|
1027
|
+
|
|
1028
|
+
The imperative response body serializes **all exposed instance variables**
|
|
1029
|
+
(everything not `@_`-prefixed, the same filter Rails uses for view assigns) as
|
|
1030
|
+
a JSON object keyed by ivar name without the `@` (`{ "post": {...} }`),
|
|
1031
|
+
each value serialized through the existing `ruact_props` /
|
|
1032
|
+
`Ruact::Serializable` / `strict_serialization` rules. No single-ivar magic
|
|
1033
|
+
unwrap — predictable over clever. An action that sets no ivars and does not
|
|
1034
|
+
redirect returns `204 No Content` → the ref resolves `null` (matching the
|
|
1035
|
+
runtime's existing empty-body contract from Story 8.1).
|
|
1036
|
+
|
|
1037
|
+
**Open items the implementing stories must resolve (not contract-level).**
|
|
1038
|
+
|
|
1039
|
+
1. Per-query callback opt-out (e.g. a public query on an app whose
|
|
1040
|
+
`ApplicationController` forces `authenticate_user!`) — sketch: class-level
|
|
1041
|
+
macro on `Ruact::Query` forwarded to the internal controller's
|
|
1042
|
+
`skip_before_action`. Owned by the query-dispatch story.
|
|
1043
|
+
2. Query route path scheme + prefix configuration default (`/q/...` proposed).
|
|
1044
|
+
3. The exact rename-override macro name for JS-identifier collisions
|
|
1045
|
+
(Decision 4).
|
|
1046
|
+
4. Whether `include Ruact::Server` is implied by `ruact_render` usage or always
|
|
1047
|
+
explicit (explicit proposed — one line, no magic).
|
|
1048
|
+
|
|
1049
|
+
**Append-only invariant preserved.** The Option C accessor lock (named imports
|
|
1050
|
+
from `app/javascript/.ruact/server-functions.ts`) is untouched — React code
|
|
1051
|
+
written against Epics 8/9 imports does not change its import statements. The
|
|
1052
|
+
superseded body sections and prior decision-log entries remain verbatim above;
|
|
1053
|
+
where they conflict with this addendum, this addendum governs. Deviations
|
|
1054
|
+
during the redesigned Epic 9 implementation require a further dated addendum
|
|
1055
|
+
here before merge.
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
### 2026-06-05 — Story 9.1 — `Ruact::Server` concern lands (Phase A salvage transplant) — in-story decisions
|
|
1059
|
+
|
|
1060
|
+
**Scope.** Implements the Phase A salvage transplant from the 2026-06-02
|
|
1061
|
+
addendum: the `Ruact::Server` concern (`gem/lib/ruact/server.rb`, loaded by
|
|
1062
|
+
the Railtie's `ruact.load_controller` initializer alongside
|
|
1063
|
+
`Ruact::Controller`) becomes the v2 home of the Story 8.4 structured-error
|
|
1064
|
+
chain and the Story 8.5 upload guard, installed on the host controller's own
|
|
1065
|
+
callback chain. The v1 `EndpointController` stays alive untouched as the
|
|
1066
|
+
strangler-fig safety net (removed in Story 9.9). At this story the concern is
|
|
1067
|
+
a pure marker + salvage host: it registers nothing, emits nothing to codegen
|
|
1068
|
+
(Story 9.3), and performs no dual-bucket response negotiation (Story 9.2).
|
|
1069
|
+
|
|
1070
|
+
**Shared core.** Both homes include
|
|
1071
|
+
`Ruact::ServerFunctions::ErrorRendering` — the extracted 8.4/8.5 bodies
|
|
1072
|
+
(structured renderer, status mapping, payload-mode resolution, logging,
|
|
1073
|
+
upload guard). Behavioral differences are expressed ONLY through three
|
|
1074
|
+
private hooks (`__ruact_error_action_name`,
|
|
1075
|
+
`__ruact_render_structured_error?`, `__ruact_upload_guard_applicable?`),
|
|
1076
|
+
whose defaults preserve v1 endpoint semantics. The wire contract is therefore
|
|
1077
|
+
byte-for-byte identical across both homes by construction. Story 9.9 deletes
|
|
1078
|
+
the endpoint home; the module survives as the concern's implementation.
|
|
1079
|
+
|
|
1080
|
+
**Decisions taken in this story (delegated by the epic / 2026-06-02 addendum):**
|
|
1081
|
+
|
|
1082
|
+
1. **Predicate name + semantics (AC2).** The function-call discrimination
|
|
1083
|
+
point is the private helper **`Ruact::Server#__ruact_function_call?`**:
|
|
1084
|
+
true iff the raw `Accept` request header contains `application/json`
|
|
1085
|
+
(the exact shape the 8.1 runtime sends on every `_makeRef` fetch).
|
|
1086
|
+
Deliberately NOT `request.format` — Rails' format negotiation is
|
|
1087
|
+
influenced by path extensions (`/posts.json`) and `params[:format]`,
|
|
1088
|
+
neither of which may flip the bucket. Story 9.2 MUST reuse this helper
|
|
1089
|
+
verbatim as the dual-bucket discriminator; it lives in one place only.
|
|
1090
|
+
2. **D1 — structured-render gating.** The concern's rescue handler renders
|
|
1091
|
+
the structured payload only when `__ruact_function_call?` is true;
|
|
1092
|
+
otherwise it re-raises, so non-function-call requests keep stock Rails
|
|
1093
|
+
error behavior (AC1 byte-for-byte). ONE documented exception:
|
|
1094
|
+
`Ruact::UploadTooLargeError` renders the structured 413 for EVERY request
|
|
1095
|
+
shape — the guard only exists on requests that opted into the concern,
|
|
1096
|
+
and a meaningful 413 beats a re-raised 500 for native multipart form
|
|
1097
|
+
submits. Pinned by `server_upload_request_spec.rb` ("D1 — a native form
|
|
1098
|
+
submit…").
|
|
1099
|
+
3. **D2 — upload-guard verb gate.** On host controllers the guard skips
|
|
1100
|
+
GET/HEAD requests (`__ruact_upload_guard_applicable?`); the v1 endpoint
|
|
1101
|
+
was POST-only so this carve-out is new surface, required by the AC1
|
|
1102
|
+
byte-for-byte rule for page actions. All non-GET actions are guarded
|
|
1103
|
+
regardless of bucket (AC4 says "non-GET action", not "function call").
|
|
1104
|
+
4. **Open item 4 (2026-06-02 addendum) — RESOLVED: explicit include.**
|
|
1105
|
+
`include Ruact::Server` is always explicit — one line, no magic, never
|
|
1106
|
+
implied by `ruact_render` usage. The concern is also independent of
|
|
1107
|
+
`Ruact::Controller` (neither includes the other); hosts include both.
|
|
1108
|
+
|
|
1109
|
+
**Spec re-anchoring.** The 8.4/8.5 behavior matrix moved to
|
|
1110
|
+
`spec/ruact/server_spec.rb` + `server_rescue_request_spec.rb` +
|
|
1111
|
+
`server_upload_request_spec.rb` (tagged `:story_9_1`), mounted on REAL host
|
|
1112
|
+
routes on the shared Story-7.9 Rails app — no `/__ruact/fn/` anywhere, no
|
|
1113
|
+
dependency on the v1 spec files that Story 9.9 demolishes. The superseded
|
|
1114
|
+
`endpoint_controller_rescue_spec.rb` / `endpoint_controller_upload_spec.rb`
|
|
1115
|
+
were removed in the same commit; the v1 endpoint's observable contract
|
|
1116
|
+
remains covered by `dispatch_request_spec.rb` / `csrf_request_spec.rb` until
|
|
1117
|
+
9.9.
|
|
1118
|
+
|
|
1119
|
+
### 2026-06-07 — Story 9.1 — code-review patches (D1 verb gate, inherited precedence, standalone load path)
|
|
1120
|
+
|
|
1121
|
+
Three patches from the Story 9.1 code review (gem PR #2), amending the
|
|
1122
|
+
2026-06-05 in-story decisions. All three were resolved with Luiz on
|
|
1123
|
+
2026-06-06; spec-pinned and landed on the same PR as follow-up commits
|
|
1124
|
+
(GitHub Flow — no amend/force-push).
|
|
1125
|
+
|
|
1126
|
+
1. **D1 amended — GET/HEAD excluded from structured error rendering.**
|
|
1127
|
+
`__ruact_render_structured_error?` now requires a non-GET/HEAD verb in
|
|
1128
|
+
addition to the function-call predicate. Function calls are non-GET by
|
|
1129
|
+
the verb rule (epic contract decision #1), so a GET/HEAD carrying
|
|
1130
|
+
`Accept: application/json` (a `fetch()` against a page action, an API
|
|
1131
|
+
probe) is NOT a function call — an error there keeps stock Rails
|
|
1132
|
+
behavior instead of being swallowed into the structured payload.
|
|
1133
|
+
**`__ruact_function_call?` itself is UNCHANGED** (raw `Accept` header,
|
|
1134
|
+
verb-agnostic) — Story 9.2 still reuses it verbatim as the dual-bucket
|
|
1135
|
+
discriminator; the verb gate lives only in the error-rendering scope.
|
|
1136
|
+
The `UploadTooLargeError` exception is unaffected (the guard already
|
|
1137
|
+
skips GET/HEAD per D2, so the combination is unreachable).
|
|
1138
|
+
2. **Inherited host `rescue_from` precedence.** The 2026-06-05 "host wins"
|
|
1139
|
+
claim only held for handlers declared in the host's own class body AFTER
|
|
1140
|
+
the include — handlers inherited from a parent class sat EARLIER in
|
|
1141
|
+
`rescue_handlers` and lost Rails' most-recently-registered walk to the
|
|
1142
|
+
concern's `StandardError` entry. The concern now moves its two entries to
|
|
1143
|
+
the FRONT of the array at include time
|
|
1144
|
+
(`self.rescue_handlers = (rescue_handlers - inherited) + inherited`), so
|
|
1145
|
+
every host handler — inherited or declared after the include — stays more
|
|
1146
|
+
recent and keeps precedence. The concern-internal Pitfall #1 order
|
|
1147
|
+
(StandardError before InvalidAuthenticityToken) is preserved; the parent
|
|
1148
|
+
class's own registry is untouched (class_attribute write lands on the
|
|
1149
|
+
child). Idempotent under repeated include along an inheritance chain.
|
|
1150
|
+
3. **Standalone load path.** `lib/ruact/server.rb` now does
|
|
1151
|
+
`require_relative "../ruact"` so a direct `require "ruact/server"`
|
|
1152
|
+
resolves everything the salvaged chains touch at request time
|
|
1153
|
+
(`Ruact.config` is defined in `ruact.rb`, not `configuration.rb`;
|
|
1154
|
+
`Ruact::UploadTooLargeError`; the ErrorPayload pipeline). Acyclic by
|
|
1155
|
+
construction: the gem root never requires `server.rb` back (the bare
|
|
1156
|
+
`require "ruact"` path stays ActionController-free; the Railtie loads the
|
|
1157
|
+
concern). Pinned by a subprocess spec (`ruby -I lib -e 'require
|
|
1158
|
+
"ruact/server"'`).
|
|
1159
|
+
|
|
1160
|
+
### 2026-06-08 — Story 9.1 — code-review patches, round 2 (predicate split, GET/HEAD upload-error gate, CSRF-ordering invariant)
|
|
1161
|
+
|
|
1162
|
+
Three further patches from the Story 9.1 code review (gem PR #2), refining the
|
|
1163
|
+
2026-06-07 round. Spec-pinned (red→green), landed as a follow-up commit on the
|
|
1164
|
+
same PR (GitHub Flow — no amend/force-push).
|
|
1165
|
+
|
|
1166
|
+
1. **Predicate split — raw header vs. semantic function-call.** The 2026-06-07
|
|
1167
|
+
round left `__ruact_function_call?` keyed purely on the raw `Accept` header
|
|
1168
|
+
(verb-agnostic) while the verb gate lived only inside
|
|
1169
|
+
`__ruact_render_structured_error?`. But the addendum also designated
|
|
1170
|
+
`__ruact_function_call?` as THE helper Story 9.2 reuses — so the helper
|
|
1171
|
+
encoded the wrong contract (a GET carrying `Accept: application/json` would
|
|
1172
|
+
read as a function call). Now split in two:
|
|
1173
|
+
- `__ruact_json_accept?` — the raw, verb-agnostic header check.
|
|
1174
|
+
- `__ruact_function_call?` — the SEMANTIC predicate:
|
|
1175
|
+
`__ruact_json_accept? && !(request.get? || request.head?)`.
|
|
1176
|
+
The verb rule (function calls are non-GET, epic contract decision #1) now
|
|
1177
|
+
lives in ONE place — the predicate itself — so Story 9.2 inherits the
|
|
1178
|
+
correct contract verbatim. This SUPERSEDES the 2026-06-07 note that
|
|
1179
|
+
"`__ruact_function_call?` itself is UNCHANGED"; the raw check survives under
|
|
1180
|
+
its new name.
|
|
1181
|
+
2. **GET/HEAD upload-error gate.** `__ruact_render_structured_error?` now checks
|
|
1182
|
+
the verb FIRST (`return false if request.get? || request.head?`), so the
|
|
1183
|
+
`UploadTooLargeError` exception to the re-raise rule is also verb-gated. The
|
|
1184
|
+
guard never produces that error on a GET/HEAD (it skips them — D2), so the
|
|
1185
|
+
only way one reaches the handler on a GET is a manual `raise` inside a page
|
|
1186
|
+
action — which must keep stock Rails behavior (AC1 byte-for-byte), not be
|
|
1187
|
+
swallowed into a structured 413. The non-GET case is unchanged: a
|
|
1188
|
+
`UploadTooLargeError` from a native multipart form submit (Bucket 1, no JSON
|
|
1189
|
+
Accept) still renders a meaningful 413.
|
|
1190
|
+
3. **CSRF-ordering invariant made executable (AC4 / Pitfall #4).** The upload
|
|
1191
|
+
guard is `prepend_before_action`, so it normally beats
|
|
1192
|
+
`verify_authenticity_token`. But a host can re-order CSRF ahead of it with
|
|
1193
|
+
`protect_from_forgery prepend: true`, after which an oversized tokenless
|
|
1194
|
+
multipart request is 403'd before the intended 413 — silently breaking AC4.
|
|
1195
|
+
Rather than document this as the host's responsibility, the concern now
|
|
1196
|
+
detects the inversion in the compiled callback chain
|
|
1197
|
+
(`__ruact_csrf_precedes_upload_guard?` — pure, unit-testable inspection) and
|
|
1198
|
+
fails loudly with a `Ruact::ConfigurationError` from
|
|
1199
|
+
`__ruact_verify_upload_guard_precedence!` (a new no-op hook on
|
|
1200
|
+
`ErrorRendering`, overridden by the concern, invoked before the guard's
|
|
1201
|
+
verb short-circuit). Because CSRF verification is a no-op for GET/HEAD, the
|
|
1202
|
+
guard still runs on page loads, so a misordered controller fails on its
|
|
1203
|
+
first request in development. Note the limit: an oversized tokenless POST to
|
|
1204
|
+
an already-misordered controller is still 403'd at request time (CSRF runs
|
|
1205
|
+
first; the guard cannot run) — the detection makes the misconfiguration
|
|
1206
|
+
impossible to ship unnoticed, it does not silently repair the ordering.
|
|
1207
|
+
|
|
1208
|
+
### 2026-06-08 — Story 9.1 — code-review patches, round 3 (ordering invariant on the POST path, ConfigurationError stays loud, conditional-CSRF detector, Accept parsing)
|
|
1209
|
+
|
|
1210
|
+
Five further patches from the Story 9.1 code review (gem PR #2), refining the
|
|
1211
|
+
round-2 work. Spec-pinned (red→green), landed as a follow-up commit on the same
|
|
1212
|
+
PR (GitHub Flow — no amend/force-push).
|
|
1213
|
+
|
|
1214
|
+
1. **Ordering invariant now covers the oversized tokenless POST.** Round 2
|
|
1215
|
+
detected the `protect_from_forgery prepend: true` inversion from inside the
|
|
1216
|
+
upload guard, which only runs when reachable — so the exact broken shape
|
|
1217
|
+
(an oversized multipart POST without a CSRF token) still 403'd before the
|
|
1218
|
+
413, because CSRF runs first and aborts the chain before the guard.
|
|
1219
|
+
`__ruact_verify_upload_guard_precedence!` is now ALSO invoked at the top of
|
|
1220
|
+
`ErrorRendering#__ruact_render_action_error`, so the rescue chain
|
|
1221
|
+
re-asserts the invariant on the request shape the guard never sees. Net
|
|
1222
|
+
effect: an inverted controller fails loudly (`Ruact::ConfigurationError`)
|
|
1223
|
+
on EVERY request — GET page loads (guard runs, CSRF no-ops), tokenless
|
|
1224
|
+
POSTs (CSRF raises → rescue re-checks), and valid-token POSTs (guard runs)
|
|
1225
|
+
alike. The misconfiguration can serve no request, which supersedes the
|
|
1226
|
+
round-2 note that "an oversized tokenless POST … is still 403'd at request
|
|
1227
|
+
time". Pinned by a request spec for an oversized tokenless POST on the
|
|
1228
|
+
inverted host.
|
|
1229
|
+
2. **`Ruact::ConfigurationError` is never rendered as a structured error.**
|
|
1230
|
+
With the ordering verifier raising `Ruact::ConfigurationError` and the
|
|
1231
|
+
concern's `rescue_from StandardError` chain rendering structured JSON for
|
|
1232
|
+
JSON function calls, a valid-token `Accept: application/json` POST on an
|
|
1233
|
+
inverted host could fold the configuration failure into an ordinary
|
|
1234
|
+
`_ruact_server_action_error` 500. `__ruact_render_structured_error?` now
|
|
1235
|
+
returns false for `Ruact::ConfigurationError` (any source), so configuration
|
|
1236
|
+
invariants stay loud setup failures. Pinned by a non-GET JSON request spec.
|
|
1237
|
+
3. **Detector narrowed to UNCONDITIONAL CSRF callbacks.**
|
|
1238
|
+
`__ruact_csrf_precedes_upload_guard?` reduced callbacks to raw filter names
|
|
1239
|
+
and ignored `if`/`unless`/`only`/`except`, so
|
|
1240
|
+
`protect_from_forgery prepend: true, if: -> { false }` (CSRF can never run)
|
|
1241
|
+
still raised. The detector now inspects the compiled before-callback's
|
|
1242
|
+
`@if`/`@unless` arrays (Rails compiles `only:`/`except:` into these) and
|
|
1243
|
+
only flags an unconditional CSRF callback. The genuine misconfig is
|
|
1244
|
+
unconditional, so it is still caught; false positives on conditional CSRF
|
|
1245
|
+
are eliminated. Pinned by `if: -> { false }` and `only:` specs.
|
|
1246
|
+
4. **`Accept` header parsed into media ranges.** `__ruact_json_accept?` used
|
|
1247
|
+
`include?("application/json")`, which matched `application/jsonp` and
|
|
1248
|
+
`application/json;q=0` and was case-sensitive. It now parses comma-separated
|
|
1249
|
+
media ranges, matches `application/json` case-insensitively on token
|
|
1250
|
+
boundaries, and requires a positive q-value. This matters because
|
|
1251
|
+
`__ruact_function_call?` feeds Story 9.2's discriminator — a loose match
|
|
1252
|
+
would route ordinary requests into Ruact's structured payload. Pinned by
|
|
1253
|
+
`application/jsonp`, `application/json;q=0`, `Application/JSON`, and
|
|
1254
|
+
`application/json;q=0.5` specs.
|
|
1255
|
+
5. **CHANGELOG corrected.** The `[Unreleased]` `Ruact::Server` note now states
|
|
1256
|
+
that function calls are non-GET/HEAD JSON-Accept requests and that the
|
|
1257
|
+
structured 413 applies to non-GET guarded requests (not "every caller
|
|
1258
|
+
shape"), matching the verb-gated round-2/round-3 behavior.
|
|
1259
|
+
|
|
1260
|
+
### 2026-06-08 — Story 9.1 — code-review patches, round 4 (applicability-aware CSRF detector, nil-limit no-op, stricter Accept parser, v1 smoke spec)
|
|
1261
|
+
|
|
1262
|
+
Four further patches from the Story 9.1 code review (gem PR #2), refining the
|
|
1263
|
+
round-3 work. Spec-pinned (red→green), landed as a follow-up commit on the same
|
|
1264
|
+
PR (GitHub Flow — no amend/force-push).
|
|
1265
|
+
|
|
1266
|
+
1. **CSRF-order detector evaluates applicability (supersedes round 3's
|
|
1267
|
+
"unconditional only").** Round 3 narrowed `__ruact_csrf_precedes_upload_guard?`
|
|
1268
|
+
to UNCONDITIONAL CSRF callbacks to kill false positives, but that created a
|
|
1269
|
+
false NEGATIVE: `protect_from_forgery with: :exception, prepend: true,
|
|
1270
|
+
only: [:create]` (on `create`) or `if: -> { true }` still runs CSRF ahead of
|
|
1271
|
+
the guard yet went unflagged. The detector now evaluates the CSRF callback's
|
|
1272
|
+
compiled `@if`/`@unless` conditions against the controller for the current
|
|
1273
|
+
request/action (`__ruact_callback_applies?` / `__ruact_condition_met?` —
|
|
1274
|
+
Symbol → `send`, Proc → arity-aware `instance_exec`, `ActionFilter` →
|
|
1275
|
+
`match?(self)`). An ACTIVE condition is caught; an inactive one
|
|
1276
|
+
(`only: [:other]`, `if: -> { false }`) is not. The detector is no longer
|
|
1277
|
+
purely static (it reads `action_name` / request-derived state), but both
|
|
1278
|
+
call sites — the guard and the rescue path — run inside a live request.
|
|
1279
|
+
Pinned: `only: [:create]` on `create` (flagged) vs on `index` (not flagged),
|
|
1280
|
+
`if: -> { true }` (flagged), plus the round-3 inactive cases.
|
|
1281
|
+
2. **Ordering verifier is a no-op when `max_upload_bytes` is `nil`.** A nil cap
|
|
1282
|
+
is the documented carve-out that disables the gem-side guard, so there is no
|
|
1283
|
+
413-before-CSRF invariant to enforce — failing an inverted host that has
|
|
1284
|
+
intentionally opted out is wrong. `__ruact_verify_upload_guard_precedence!`
|
|
1285
|
+
now returns early when `Ruact.config.max_upload_bytes.nil?`. Pinned: a GET
|
|
1286
|
+
page load on an inverted host with `max_upload_bytes = nil` renders normally
|
|
1287
|
+
instead of raising `Ruact::ConfigurationError`.
|
|
1288
|
+
3. **Stricter, quote-aware Accept parser.** The round-3 parser still accepted
|
|
1289
|
+
out-of-range q-values (`q=2`, `q=1.5`) and split on commas without respecting
|
|
1290
|
+
quoted-strings (`application/json;note="a,b";q=0` split before the q-value
|
|
1291
|
+
and defaulted to 1.0). The q-value must now lie within `0.0 < q <= 1.0`, and
|
|
1292
|
+
media ranges / parameters are split with a quote-aware tokenizer
|
|
1293
|
+
(`__ruact_split_unquoted`). This matters because `__ruact_function_call?`
|
|
1294
|
+
feeds Story 9.2's discriminator — a malformed or explicitly-refused JSON
|
|
1295
|
+
Accept must not misclassify a request. Pinned: `q=2`, `q=1.5`, and a quoted
|
|
1296
|
+
comma before `q=0` (all rejected); quoted comma with `q=0.9` (accepted).
|
|
1297
|
+
4. **v1 endpoint upload-limit smoke spec.** The v1 endpoint stays alive as the
|
|
1298
|
+
strangler-fig safety net until Story 9.9 and still shares the salvaged upload
|
|
1299
|
+
guard, but the deep upload matrix was re-anchored on the v2 concern (and
|
|
1300
|
+
`endpoint_controller_upload_spec.rb` removed). A minimal observable-contract
|
|
1301
|
+
smoke spec now pins the v1 413 path (oversized multipart
|
|
1302
|
+
`POST /__ruact/fn/:name` → 413 + `_ruact_server_action_error` +
|
|
1303
|
+
`upload_limit`) in `dispatch_request_spec.rb`, so the safety net cannot
|
|
1304
|
+
regress before demolition. Not the old implementation-coupled matrix — just
|
|
1305
|
+
the wire-visible contract.
|
|
1306
|
+
|
|
1307
|
+
### 2026-06-08 — Story 9.1 — code-review patches, round 5 (escape-aware Accept tokenizer, strict qvalue grammar, GET/HEAD verifier no-op)
|
|
1308
|
+
|
|
1309
|
+
Three further patches from the Story 9.1 code review (gem PR #2), refining the
|
|
1310
|
+
round-4 work. Spec-pinned (red→green), landed as a follow-up commit on the same
|
|
1311
|
+
PR (GitHub Flow — no amend/force-push).
|
|
1312
|
+
|
|
1313
|
+
1. **Escape-aware Accept tokenizer + unterminated-range rejection.** Round 4's
|
|
1314
|
+
quote-aware split toggled quote state on every `"` and ignored HTTP
|
|
1315
|
+
quoted-pair (`\"`) escaping and unterminated quoted strings:
|
|
1316
|
+
`application/json;note="a\",b";q=0` read as JSON-acceptable (the escaped
|
|
1317
|
+
quote ended the quoted-string early, the comma split the range, and the
|
|
1318
|
+
`q=0` was lost → default 1.0). `__ruact_split_unquoted` now honors backslash
|
|
1319
|
+
escapes inside quoted spans, and `__ruact_json_media_range?` rejects a range
|
|
1320
|
+
whose quotes are unbalanced (`__ruact_balanced_quotes?`) rather than parsing
|
|
1321
|
+
it as default-quality JSON. Pinned: escaped quote before `q=0` (rejected),
|
|
1322
|
+
escaped quote with positive q (accepted), unterminated quote hiding `q=0`
|
|
1323
|
+
(rejected).
|
|
1324
|
+
2. **Strict RFC 7231 qvalue grammar.** Round 4's `Float`-range check accepted
|
|
1325
|
+
malformed q-values (`q=.5`, `q=01`, `q=1e-1`, `q=0.1234`). The value is now
|
|
1326
|
+
validated against the qvalue grammar (`QVALUE_FORMAT` — `0` / `0.`+≤3 digits
|
|
1327
|
+
/ `1` / `1.`+≤3 zeros) before conversion; anything else is a rejecting 0.0.
|
|
1328
|
+
Pinned: leading-dot, leading-zero, exponent, and over-precision values (all
|
|
1329
|
+
rejected).
|
|
1330
|
+
3. **Ordering verifier is a no-op on GET/HEAD (supersedes round-2 GET
|
|
1331
|
+
surfacing).** D2 says the upload guard never fires on GET/HEAD, and AC1 says
|
|
1332
|
+
GET page behavior stays byte-for-byte — but the round-2/3 verifier still ran
|
|
1333
|
+
on those verbs, so `protect_from_forgery prepend: true, only: [:index]` on a
|
|
1334
|
+
GET `index` raised `Ruact::ConfigurationError` even though no upload guard
|
|
1335
|
+
could fire. `__ruact_verify_upload_guard_precedence!` now returns early when
|
|
1336
|
+
`__ruact_upload_guard_applicable?` is false. The loud failure is preserved
|
|
1337
|
+
for guarded (non-GET) requests — including the oversized tokenless POST via
|
|
1338
|
+
the rescue path (round 3) — so the misordering still cannot ship unnoticed;
|
|
1339
|
+
it simply surfaces on the first NON-GET (function-call) request instead of
|
|
1340
|
+
on a page load. This SUPERSEDES the round-2 note that GET page loads fail
|
|
1341
|
+
immediately on a misordered host. Pinned: GET on an unconditionally-inverted
|
|
1342
|
+
host renders (200); GET whose CSRF is scoped to the GET action (`only:`)
|
|
1343
|
+
renders (200); non-GET inverted specs still fail loudly; unit GET/HEAD
|
|
1344
|
+
no-raise + nil-limit non-GET no-raise.
|
|
1345
|
+
|
|
1346
|
+
### 2026-06-08 — Story 9.1 — contract simplification after review round 5
|
|
1347
|
+
|
|
1348
|
+
The repeated review loop on Story 9.1 exposed two over-engineered edges:
|
|
1349
|
+
request-time callback-order introspection for the upload guard, and a hand-
|
|
1350
|
+
rolled Accept parser that kept accreting RFC 7231 edge handling. The final
|
|
1351
|
+
decision is to simplify both contracts rather than keep patching them.
|
|
1352
|
+
|
|
1353
|
+
1. **Accept is now exact.** The v2 concern treats only `Accept: application/json`
|
|
1354
|
+
as a JSON-Accept request. Exact header match, no qvalue parsing, no media-
|
|
1355
|
+
range splitting, no quoted-string handling. This matches the generated
|
|
1356
|
+
runtime shape and removes the parser surface entirely.
|
|
1357
|
+
2. **Upload-order verification is documented, not enforced at runtime.** The
|
|
1358
|
+
concern still installs the upload guard, but it no longer introspects the
|
|
1359
|
+
callback chain to detect `protect_from_forgery prepend: true`. Hosts are
|
|
1360
|
+
expected to include `Ruact::Server` after `protect_from_forgery`; the order
|
|
1361
|
+
is documented in the changelog and story file instead of being enforced via
|
|
1362
|
+
request-time callback inspection.
|
|
1363
|
+
|
|
1364
|
+
Pinned by the simplified round-6 follow-up specs: exact `Accept:
|
|
1365
|
+
application/json` is the only function-call discriminator, and the upload guard
|
|
1366
|
+
still works on correctly ordered hosts while the misordered-host behavior is no
|
|
1367
|
+
longer special-cased.
|
|
1368
|
+
|
|
1369
|
+
### 2026-06-09 — Story 9.2 — dual-bucket response negotiation on the same controller action
|
|
1370
|
+
|
|
1371
|
+
Phase B's contract story: one non-GET `Ruact::Server` action serves both
|
|
1372
|
+
form/navigation submits (Bucket 1) and imperative `await fn()` calls (Bucket 2),
|
|
1373
|
+
discriminated by `__ruact_function_call?` (locked in 9.1 — `Accept: application/json`,
|
|
1374
|
+
non-GET). 9.1 built the predicate; 9.2 builds the response side.
|
|
1375
|
+
|
|
1376
|
+
**D1 — Bucket-2 success seam (`default_render`).** `Ruact::Server` overrides
|
|
1377
|
+
`default_render`: `return super unless __ruact_function_call?` — so Bucket 1
|
|
1378
|
+
falls through to the host's `Ruact::Controller#default_render` (Flight re-render)
|
|
1379
|
+
and Rails, byte-for-byte unchanged. On Bucket 2 it serializes the action's
|
|
1380
|
+
exposed ivars as a JSON object (or `head :no_content`). The two concerns are
|
|
1381
|
+
siblings composed on the host; the super-chain works because `Ruact::Server` is
|
|
1382
|
+
included on the action's controller (subclass) while `Ruact::Controller` sits on
|
|
1383
|
+
a parent (`ApplicationController`), so `Server#default_render` precedes
|
|
1384
|
+
`Controller#default_render` in the ancestry and `super` reaches it. Documented
|
|
1385
|
+
include-order assumption: `include Ruact::Server` AFTER (or below in the
|
|
1386
|
+
ancestry) `Ruact::Controller`.
|
|
1387
|
+
|
|
1388
|
+
**Exposed ivars = Rails `view_assigns`, VERBATIM (decided with Luiz 2026-06-09 —
|
|
1389
|
+
"closest to how Rails does it").** No custom exclusion list. An early probe
|
|
1390
|
+
suggested `@marked_for_same_origin_verification` leaks, but that was a probe
|
|
1391
|
+
error: Rails 8.1.3 sets the PROTECTED `@_marked_for_same_origin_verification`
|
|
1392
|
+
(`request_forgery_protection.rb:447`, in `base.rb:322` protected ivars), so a
|
|
1393
|
+
real request's `view_assigns` is already clean. What remains is exactly what the
|
|
1394
|
+
action assigned — including `@current_user` IF the action calls the memoized
|
|
1395
|
+
helper (dev-controlled; same mental model as "what the view sees"). Keyed by
|
|
1396
|
+
ivar name without `@`; a single ivar stays keyed (no magic unwrap, decision C).
|
|
1397
|
+
|
|
1398
|
+
**Bucket-2 value serialization — `Ruact::ServerFunctions::BucketTwoPayload`
|
|
1399
|
+
(pure).** Mirrors the policy of `Flight::Serializer#serialize_unknown`
|
|
1400
|
+
(`Ruact::Serializable` → `ruact_props` only; under `strict_serialization` a
|
|
1401
|
+
non-Serializable domain object raises `Ruact::SerializationError`; otherwise a
|
|
1402
|
+
vetted `as_json` fallback with self-/raise-guards), but emits PLAIN JSON-ready
|
|
1403
|
+
values (Hash/Array/scalar) — `render json:` does the encoding, so JSON
|
|
1404
|
+
primitives incl. Time/Date pass through (not Flight-encoded). Pure function: the
|
|
1405
|
+
caller passes the resolved `strict` flag (= `Ruact.config.strict_serialization`),
|
|
1406
|
+
mirroring the `ErrorPayload` caller/builder split (NFR26 / `Ruact/NoIoInFlight`
|
|
1407
|
+
untouched). Policy MIRRORED (not extracted from `flight/serializer.rb`) to avoid
|
|
1408
|
+
refactoring the Flight hot path; the Flight serializer remains the canonical
|
|
1409
|
+
policy reference.
|
|
1410
|
+
|
|
1411
|
+
**D2 — `redirect_to` → `$redirect`.** `Ruact::Server` overrides `redirect_to`:
|
|
1412
|
+
`return super unless __ruact_function_call?` (Bucket 1 → Controller's Flight
|
|
1413
|
+
redirect row / Rails 302, unchanged). On Bucket 2 it renders
|
|
1414
|
+
`json: { "$redirect" => <path> }`. Same-origin targets collapse to a path
|
|
1415
|
+
(mirroring `Ruact::Controller#redirect_to`); external origins keep the absolute
|
|
1416
|
+
URL. **Server-only: the runtime does NOT follow `$redirect` today** — emitting it
|
|
1417
|
+
is 9.2; the client following it (and the route/verb re-target away from
|
|
1418
|
+
`/__ruact/fn/:name`) is Story 9.3's runtime work.
|
|
1419
|
+
|
|
1420
|
+
**D3 — `Vary: Accept` (the ADR had NOT specified it — 9.2 owns it).** A
|
|
1421
|
+
`prepend_before_action :__ruact_set_vary_on_accept!` appends `Accept` to `Vary`
|
|
1422
|
+
for non-GET requests (idempotent, preserves host-set `Vary`). It is prepended
|
|
1423
|
+
BEFORE the upload guard in source so the guard still lands first (Story 8.5
|
|
1424
|
+
"guard wins the race" invariant preserved). Consequence: `Vary` is present on
|
|
1425
|
+
the 200 ivar-JSON, 204, `$redirect`, Bucket-1 Flight, structured-500, and
|
|
1426
|
+
403-CSRF shapes — the cacheable dual-representations — but NOT on the 413 upload
|
|
1427
|
+
rejection (the guard aborts the chain before the Vary callback). Acceptable: the
|
|
1428
|
+
413 is a non-cacheable error, not a dual representation.
|
|
1429
|
+
|
|
1430
|
+
**Error routing (AC5).** A `Ruact::SerializationError` raised inside
|
|
1431
|
+
`default_render`'s Bucket-2 serialization propagates to the 9.1
|
|
1432
|
+
`__ruact_render_action_error` chain → `__ruact_render_structured_error?` is true
|
|
1433
|
+
(non-GET function call) → 500 structured payload. No new `rescue_from`.
|
|
1434
|
+
|
|
1435
|
+
**CSRF (AC7 / NFR27).** Entirely the host's `protect_from_forgery` — valid token
|
|
1436
|
+
→ success; missing/invalid → 403 via the 9.1 chain; API-mode (forgery off) →
|
|
1437
|
+
accepted. No gem-side CSRF.
|
|
1438
|
+
|
|
1439
|
+
#### 2026-06-09 — Story 9.2 review (round 3) — Vary callback limitation accepted
|
|
1440
|
+
|
|
1441
|
+
The `Vary: Accept` mechanism is callback-based (`before_action` +
|
|
1442
|
+
`after_action`, see D3 / review rounds 1–2). One residual gap was identified
|
|
1443
|
+
and ACCEPTED (Luiz) rather than patched further: a host `before_action` that
|
|
1444
|
+
BOTH reassigns `Vary` AND performs the response in the same callback (e.g.
|
|
1445
|
+
`response.headers["Vary"] = "Cookie"; redirect_to "/login"`) yields a final
|
|
1446
|
+
response without `Accept` (Ruact's before-action set it, the host clobbered it,
|
|
1447
|
+
and Rails skips after-actions on a before-halt). Rationale: the combination is
|
|
1448
|
+
contrived (real auth callbacks redirect without reassigning `Vary`); a callback
|
|
1449
|
+
cannot unconditionally guarantee the final header, and a Rack-level mechanism
|
|
1450
|
+
was judged not worth the complexity for this edge. This mirrors the Story 9.1
|
|
1451
|
+
lesson — stop patching the edges of a mechanism once the remaining cases stop
|
|
1452
|
+
earning their complexity.
|
|
1453
|
+
|
|
1454
|
+
#### 2026-06-09 — Story 9.3 — Route-driven codegen for mutations + runtime re-target
|
|
1455
|
+
|
|
1456
|
+
Phase B. The codegen SOURCE swaps from the v1 registries to the Rails route
|
|
1457
|
+
table, and the runtime SWAPS from the synthetic `POST /__ruact/fn/:name` to real
|
|
1458
|
+
REST routes + verbs. Resolves the ADR open items left by Story 9-0 (namespace
|
|
1459
|
+
scheme, rename-override macro) and the `$redirect` client-follow 9.2 deferred.
|
|
1460
|
+
|
|
1461
|
+
**Source (AC1).** `Ruact::ServerFunctions::RouteSource.collect(route_set)` reads
|
|
1462
|
+
`Rails.application.routes`, keeps only non-GET/HEAD verbs (POST/PUT/PATCH/DELETE)
|
|
1463
|
+
whose controller `include Ruact::Server`, and emits version-2 snapshot entries:
|
|
1464
|
+
`{ js_identifier, kind: "action", http_method, path, segments, controller, action }`.
|
|
1465
|
+
GET routes are pages — never callables. The `update` PATCH/PUT pair collapses to
|
|
1466
|
+
one entry (`http_method: "PATCH"`, Rails' primary). Pure: the host predicate +
|
|
1467
|
+
override lookup are injected (default = constant resolution) so the derivation
|
|
1468
|
+
table is unit-testable without booting controllers.
|
|
1469
|
+
|
|
1470
|
+
**Naming derivation table (AC3 — LOCKED).**
|
|
1471
|
+
`js_identifier = lowerCamel(action) + Namespace*(Pascal) + Resource(Pascal)`,
|
|
1472
|
+
where `lowerCamel` is the existing `NameBridge` camelization (leading underscore
|
|
1473
|
+
preserved). Resource word: SINGULAR for the RESTful writes
|
|
1474
|
+
(`create`/`update`/`destroy`) and for any member route (path carries `:id`);
|
|
1475
|
+
PLURAL for a custom collection route.
|
|
1476
|
+
|
|
1477
|
+
| Route (`verb controller#action`) | js_identifier |
|
|
1478
|
+
|---|---|
|
|
1479
|
+
| `POST posts#create` | `createPost` |
|
|
1480
|
+
| `PATCH/PUT posts#update` | `updatePost` (PATCH) |
|
|
1481
|
+
| `DELETE posts#destroy` | `destroyPost` |
|
|
1482
|
+
| `GET posts#index/show/new/edit` | — (skipped; pages) |
|
|
1483
|
+
| `POST posts#publish` (member) | `publishPost` |
|
|
1484
|
+
| `POST posts#publish_all` (collection) | `publishAllPosts` |
|
|
1485
|
+
| `POST session#create` (`resource :session`) | `createSession` |
|
|
1486
|
+
| `DELETE account#destroy` (singular) | `destroyAccount` |
|
|
1487
|
+
| `POST admin/posts#create` (namespaced) | `createAdminPost` |
|
|
1488
|
+
| `POST admin/reports/posts#create` | `createAdminReportsPost` |
|
|
1489
|
+
|
|
1490
|
+
**Namespace = PREFIX, not flat (D3 — resolves 9-0 open item #2).** Namespace
|
|
1491
|
+
segments are PascalCased and inserted between verb and resource. Rationale:
|
|
1492
|
+
flattening guarantees `admin/posts#create` and `posts#create` collide on
|
|
1493
|
+
`createPost`, forcing rename-overrides for the common admin case; prefixing keeps
|
|
1494
|
+
the merged JS namespace collision-free by construction.
|
|
1495
|
+
|
|
1496
|
+
**Rename-override macro (D4 — resolves 9-0 open item #3).**
|
|
1497
|
+
`ruact_function_name :action, as: "jsIdentifier"` — a class macro on the
|
|
1498
|
+
`Ruact::Server` host (`Ruact::Server::ClassMethods`). Populates a per-controller
|
|
1499
|
+
`__ruact_function_name_overrides` map (action name → js identifier) that
|
|
1500
|
+
`RouteSource` consults before collision detection. The target is validated at
|
|
1501
|
+
class-load time against the JS-identifier shape + reserved-word /
|
|
1502
|
+
`RESERVED_BY_RUACT` rules (a bad override fails at boot, never at codegen).
|
|
1503
|
+
|
|
1504
|
+
**Collision detection (AC4).** Two distinct routes mapping to the same
|
|
1505
|
+
js_identifier (after overrides) raise `Ruact::ConfigurationError` at boot naming
|
|
1506
|
+
BOTH origins (`posts#create and comments#create both map to JS identifier
|
|
1507
|
+
"createPost"`), mirroring the v1 cross-registry collision raise.
|
|
1508
|
+
|
|
1509
|
+
**Transparency (AC2).** `Ruact::ServerFunctions.write_v2_snapshot!` always logs
|
|
1510
|
+
`[ruact] codegen: exposing <comma-list>` — a routed non-GET action never becomes
|
|
1511
|
+
a callable silently (the verb-rule's mitigation, epic Decision #1).
|
|
1512
|
+
|
|
1513
|
+
**Runtime re-target (AC7, D6).** New runtime export `_makeServerFunction({ method,
|
|
1514
|
+
path, segments })` for v2; v1 `_makeRef(name)` is BYTE-BEHAVIOR-IDENTICAL (still
|
|
1515
|
+
POSTs `/__ruact/fn/:name`). Both factor through a shared `ruactInvoke({ method,
|
|
1516
|
+
url, args, label })` core — same FormData branching, CSRF meta injection,
|
|
1517
|
+
`credentials: "same-origin"`, `redirect: "error"`, text-first parsing,
|
|
1518
|
+
`RuactActionError`. Adding a NEW export (not widening `_makeRef`) is what makes
|
|
1519
|
+
AC6 "no shared-runtime leak" true by construction: the v1 path consumes
|
|
1520
|
+
`_makeRef`, the v2 path consumes `_makeServerFunction`.
|
|
1521
|
+
|
|
1522
|
+
**Path-param interpolation (D7).** Member/`:id` routes need the id in the URL
|
|
1523
|
+
(the v1 synthetic endpoint never did). The descriptor carries the path template
|
|
1524
|
+
+ segment names; at call time the runtime reads each segment BY NAME from the
|
|
1525
|
+
single FormData/object argument (`FormData.get("id")` / `args.id`),
|
|
1526
|
+
URL-encodes it, and substitutes into the path — the full argument is still sent
|
|
1527
|
+
as the body (Rails reads `params[:id]` from the path; the duplicate is ignored).
|
|
1528
|
+
The single-arg shape (8.2 `<form action>` / `useActionState`) is unchanged. A
|
|
1529
|
+
missing required segment throws a clear `TypeError` before any fetch.
|
|
1530
|
+
|
|
1531
|
+
**`$redirect` client-follow (D2 / AC8 — resolves the 9.2 deferral).** When a
|
|
1532
|
+
Bucket-2 mutation returns `{ "$redirect": "<path>" }`, the v2 runtime navigates
|
|
1533
|
+
via `globalThis.__ruact_navigate` (the same global handoff `revalidate()` uses),
|
|
1534
|
+
falling back to `window.location.assign(path)` when no router is installed, and
|
|
1535
|
+
resolves `null`. Applied ONLY on the v2 path — the v1 endpoint never emits
|
|
1536
|
+
`$redirect`, so `_makeRef` is unaffected. Publishing `__ruact_navigate` from
|
|
1537
|
+
`ruact-router.js#setupRouter` is playground wiring → Story 9.8.
|
|
1538
|
+
|
|
1539
|
+
**Codegen render (Ruby + JS byte-identical).** `Codegen.render` dispatches on
|
|
1540
|
+
`snapshot.version`: version 1 → the untouched v1 renderer; version 2 →
|
|
1541
|
+
`Codegen::V2.render` (separate module so the v1 singleton class stays within its
|
|
1542
|
+
size budget), emitting `_makeServerFunction({ method, path, segments })` with the
|
|
1543
|
+
SAME Story-8.2 intersection signature on every action. The JS-side `renderV2`
|
|
1544
|
+
mirrors it; the parity vitest ("Story 9.3 — route-driven (v2) render + parity")
|
|
1545
|
+
asserts byte equality against the Ruby renderer.
|
|
1546
|
+
|
|
1547
|
+
**Single-writer / `.next` parallel target (AC5).** The v2 codegen writes to a
|
|
1548
|
+
PARALLEL bridge `tmp/cache/ruact/server-functions.next.json` →
|
|
1549
|
+
`app/javascript/.ruact/server-functions.next.ts`, rendered by the Ruby codegen
|
|
1550
|
+
(Vite does not watch `.next`). Per AC5 this target is for parity tests +
|
|
1551
|
+
inspection ONLY — never imported by application code — so the real
|
|
1552
|
+
`server-functions.ts` (v1, rendered by Vite from the v1 bridge) is untouched
|
|
1553
|
+
(AC6). **Scoping decision (refines create-time D5):** in Story 9.3 the v2 codegen
|
|
1554
|
+
ALWAYS writes the parallel target — it never takes over the real file. The
|
|
1555
|
+
Decision-#6 ownership flip (an app with ZERO v1 declarations → route-driven
|
|
1556
|
+
codegen takes over the real `server-functions.ts`) is Story 9.8's job; the
|
|
1557
|
+
`Snapshot.v1_declarations?` primitive is provided + tested here for 9.8 to
|
|
1558
|
+
consume. This is the literal reading of AC5's "writes to a parallel target …
|
|
1559
|
+
never imported," and is strangler-safe (no cross-app behavior change in 9.3).
|
|
1560
|
+
|
|
1561
|
+
**Strangler invariant.** The v1 `POST /__ruact/fn/:name` endpoint, registries,
|
|
1562
|
+
`ruact_action`/`ruact_query` DSL, and v1 codegen all stay fully alive and
|
|
1563
|
+
untouched. Demolition is Story 9.9; playground migration is Story 9.8.
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
### 2026-06-09 — Story 9.4 — `Ruact::Query` base class + `ruact_queries` route macro — in-story decisions
|
|
1567
|
+
|
|
1568
|
+
**Scope.** Implements the v2 QUERY DISPATCH layer locked by the 2026-06-02
|
|
1569
|
+
addendum (Decision 2): `Ruact::Query` base class, the `ruact_queries` mapper
|
|
1570
|
+
macro, the internal per-query-class dispatch controller, constructor context
|
|
1571
|
+
injection, the per-query callback opt-out, and the two new configuration
|
|
1572
|
+
attributes. Resolves 2026-06-02 **open item 1** (callback opt-out) and **open
|
|
1573
|
+
item 2** (route prefix). Codegen of query entries, the `useQuery` hook, and the
|
|
1574
|
+
strict FR88 kwargs sanitization are Story 9.5; dedup is 9.6. The v1
|
|
1575
|
+
`Ruact.query_registry` is untouched (legacy, removed by 9.9) — the v2 class
|
|
1576
|
+
model never reads or populates it.
|
|
1577
|
+
|
|
1578
|
+
**Decisions taken in this story (delegated by the epic / 2026-06-02 addendum):**
|
|
1579
|
+
|
|
1580
|
+
1. **Open item 1 — RESOLVED: `ruact_skip_before_action` (D1, AC4).** Class
|
|
1581
|
+
macro on `Ruact::Query` subclasses mirroring Rails' `skip_before_action`
|
|
1582
|
+
signature (`ruact_skip_before_action :authenticate_user!, only: :categories,
|
|
1583
|
+
raise: false`). Recorded per-class (NOT inherited — a skip describes the
|
|
1584
|
+
declaring class's own queries) and applied verbatim to that class's
|
|
1585
|
+
generated dispatch controller at route-draw time. Scoping is structural:
|
|
1586
|
+
each query class gets its OWN controller (decision 2 below), so a skip can
|
|
1587
|
+
never leak to sibling query classes; `only:`/`except:` further scope to
|
|
1588
|
+
individual query methods (one action per method). Unknown callbacks raise at
|
|
1589
|
+
route-draw unless `raise: false` — stock Rails semantics.
|
|
1590
|
+
|
|
1591
|
+
2. **Dispatch-controller shape (D2): one generated controller subclass PER
|
|
1592
|
+
query class.** Built lazily by the routing macro (when `ruact_queries`
|
|
1593
|
+
runs inside `routes.draw`, the host's constants exist), inheriting
|
|
1594
|
+
`Ruact.config.query_parent_controller.constantize`, named under
|
|
1595
|
+
`Ruact::ServerFunctions::QueryDispatch` (e.g. `…::CatalogQueryController`)
|
|
1596
|
+
so string route targets and `rails routes` stay legible — the dev never
|
|
1597
|
+
sees it. One action per public query method (what makes per-action
|
|
1598
|
+
`skip_before_action` possible). Regeneration is idempotent
|
|
1599
|
+
(`remove_const` + rebuild on every draw) and the query class is
|
|
1600
|
+
re-constantized per request, so dev-mode code reloading never serves a
|
|
1601
|
+
stale class. The single-shared-controller alternative (query identity in a
|
|
1602
|
+
route default + conditional skips) was rejected: more complex, less
|
|
1603
|
+
Rails-idiomatic, and per-action callback scoping degrades.
|
|
1604
|
+
|
|
1605
|
+
**Namespace preservation (code-review round 4 refinement).** The generated
|
|
1606
|
+
controller's constant path MIRRORS the query class's namespace rather than
|
|
1607
|
+
flattening it: `Admin::CatalogQuery` →
|
|
1608
|
+
`Ruact::ServerFunctions::QueryDispatch::Admin::CatalogQueryController`
|
|
1609
|
+
(route target `…/query_dispatch/admin/catalog_query`). The controller
|
|
1610
|
+
constant is therefore an injective function of the query class's
|
|
1611
|
+
fully-qualified name, so two distinct query classes can never map to the
|
|
1612
|
+
same generated constant — a const overwrite / route cross-wire is
|
|
1613
|
+
impossible by construction, across any number of RouteSets or mounted
|
|
1614
|
+
engines sharing the global dispatch module. (The initial implementation
|
|
1615
|
+
flattened `::` out and tried to detect the resulting collisions; three
|
|
1616
|
+
review rounds of detection patches converged on removing the flattening
|
|
1617
|
+
instead — no collision to detect.)
|
|
1618
|
+
|
|
1619
|
+
3. **Context source (D3): delegate to the dispatching controller, not a
|
|
1620
|
+
resolver lambda.** `Ruact::ServerFunctions::QueryContext` wraps the
|
|
1621
|
+
controller instance; `current_user` resolves through the controller's own
|
|
1622
|
+
method — public OR private (hand-rolled hosts commonly define it private)
|
|
1623
|
+
— because the controller inherits the host parent, that IS the host's
|
|
1624
|
+
Devise/Pundit/hand-rolled method (FR89). A host chain with no
|
|
1625
|
+
`current_user` raises a `NoMethodError` naming `query_parent_controller`
|
|
1626
|
+
and the fix. Mirrors `StandaloneContext`'s SHAPE (plain accessors,
|
|
1627
|
+
per-request instance, NFR8); the Story-8.3 `current_user_resolver` lambda
|
|
1628
|
+
plays no part (it is superseded with the standalone path).
|
|
1629
|
+
|
|
1630
|
+
4. **Route path derivation (D4) + open item 2 — RESOLVED: `/q` prefix.** Path
|
|
1631
|
+
= `"#{Ruact.config.query_route_prefix}/#{NameBridge.to_js_identifier(method)}"`
|
|
1632
|
+
(`search_users` → `GET /q/searchUsers`), route name
|
|
1633
|
+
`:"ruact_query_<jsIdentifier>"` — every query visible in `rails routes`.
|
|
1634
|
+
New config attrs (both under the 7.3 freeze contract):
|
|
1635
|
+
`query_route_prefix` (String, default `"/q"`, must start with `/`, no
|
|
1636
|
+
trailing slash — validated at writer time) and `query_parent_controller`
|
|
1637
|
+
(non-empty String, default `"ApplicationController"`, constantized lazily
|
|
1638
|
+
at route-draw — never at configure time, when the constant may not exist).
|
|
1639
|
+
NameBridge reserved-word/shape failures and duplicate route names across
|
|
1640
|
+
query classes both fail loudly at route-draw.
|
|
1641
|
+
|
|
1642
|
+
5. **GET error-chain gate (D5, AC5).** The generated controller includes the
|
|
1643
|
+
salvaged `ErrorRendering` chain with the same handler front-loading as
|
|
1644
|
+
`Ruact::Server` (host/parent `rescue_from` keeps precedence) and overrides
|
|
1645
|
+
`__ruact_render_structured_error?`: the mutation concern's gate returns
|
|
1646
|
+
false for GET/HEAD (GET *pages* keep stock Rails errors), but every query
|
|
1647
|
+
dispatch request is a GET *function call* — the override renders the
|
|
1648
|
+
structured 8.4 payload (same 422/403/413/500 mapping) for everything
|
|
1649
|
+
except `Ruact::ConfigurationError`, which still re-raises (a
|
|
1650
|
+
misconfiguration stays a loud setup failure). No upload guard callback is
|
|
1651
|
+
registered (GET bodies) and no `skip_forgery_protection` is added (Rails
|
|
1652
|
+
never CSRF-verifies GET — confirmed by spec, not by dead code).
|
|
1653
|
+
|
|
1654
|
+
6. **Return-value serialization (D6): `BucketTwoPayload.serialize_value`.**
|
|
1655
|
+
The per-value branch of the Bucket-2 policy was extracted as a public
|
|
1656
|
+
`serialize_value(value, strict:)` and is now the single policy for BOTH
|
|
1657
|
+
the 9.2 ivar hash and the 9.4 query return value (`ruact_props` /
|
|
1658
|
+
`Serializable` / `strict_serialization`, primitives pass through,
|
|
1659
|
+
Hash/Array recursion). The controller resolves `strict_serialization` and
|
|
1660
|
+
passes it in (serializer stays pure, NFR26). The response body is encoded
|
|
1661
|
+
explicitly (`ActiveSupport::JSON.encode`) so scalar String and `nil`
|
|
1662
|
+
returns render valid JSON — **`nil` → `null` with 200** (not 204): simpler
|
|
1663
|
+
for a read; the 9.5 `useQuery` hook treats `data: null` as "no rows".
|
|
1664
|
+
|
|
1665
|
+
7. **Param passing boundary (D7).** 9.4 passes GET query params as the
|
|
1666
|
+
keyword arguments the query method declares, by name, best-effort (values
|
|
1667
|
+
arrive as Strings). The strict FR88 sanitization contract
|
|
1668
|
+
(string/number/boolean/null allowlist, reject objects, 400 on invalid) is
|
|
1669
|
+
**Story 9.5's**, coupled to the `useQuery` wire format.
|
|
1670
|
+
|
|
1671
|
+
8. **Install mechanism (D8).** `Ruact::Routing` is included into
|
|
1672
|
+
`ActionDispatch::Routing::Mapper` at require time (`ruact/routing`,
|
|
1673
|
+
required by the Railtie's `ruact.load_controller` initializer — before the
|
|
1674
|
+
host's routes file loads). This is a NEW mechanism for the gem: a Mapper
|
|
1675
|
+
*extension* the host calls explicitly, not a mounted route like the v1
|
|
1676
|
+
`app.routes.prepend` endpoint — decision A (explicit mount) made flesh.
|
|
1677
|
+
|
|
1678
|
+
**Deferred (noted, not ACs here):** install-generator scaffolding for
|
|
1679
|
+
`app/queries/application_query.rb`; codegen + `useQuery` + FR88 kwargs (9.5);
|
|
1680
|
+
dedup (9.6); docs rewrite (9.7); playground migration (9.8).
|