ruact 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -1,34 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Story 8.1AC12 end-to-end dispatch overhead benchmark.
3
+ # Story 9.9NFR21 dispatch-overhead benchmark, re-pointed at REAL routes.
4
4
  #
5
- # Compares `ruact_action :create_post` against a plain controller action
6
- # that does the SAME `Post.create!` work per AC12's literal text:
5
+ # The v1 substrate (the synthetic `POST /__ruact/fn/:name` endpoint + the
6
+ # `ruact_action` DSL) was demolished, so this bench now exercises the
7
+ # route-driven (v2) contract:
7
8
  #
8
- # "the script compares `ruact_action :create_post` to a plain
9
- # controller action that does the same `Post.create!` and prints
10
- # both numbers"
11
- # "the median ruact_action overhead is < 20ms per call"
9
+ # - A non-GET REST route (`POST /posts`) on a controller that does
10
+ # `include Ruact::Server` Bucket-2 JSON dispatch (the generated accessor
11
+ # sends `Accept: application/json`), compared against a plain controller
12
+ # doing the SAME `Post.create!`. The only delta is the concern's callback
13
+ # chain (the structured-error `rescue_from` + the upload guard).
14
+ # - The multipart (`<form action>`) shape on the same route.
15
+ # - A query route (`GET /q/<jsId>`) drawn by `ruact_queries`.
12
16
  #
13
- # An in-memory SQLite + ActiveRecord schema (created at boot) backs the
14
- # `Post` model so the two endpoints exercise IDENTICAL write paths
15
- # the only delta is the gem's dispatch wrapper (registry lookup +
16
- # path_parameters swap + thread-local sentinel + the wrapper method).
17
+ # NFR21 budget: server-function dispatch adds < 20ms over a standard Rails
18
+ # request. NOTE (Story 9.9 D2): the v2 path is a REAL Rails route through the
19
+ # full router + the `Ruact::Server` concern a different code path than the
20
+ # deleted v1 synthetic endpoint, so there is no apples-to-apples per-call delta
21
+ # against the historical v1 baseline. The honest gate is "p50/p95 < 20ms holds."
17
22
  #
18
- # Run with:
23
+ # v1 baseline (historical, Story 9.1, 2026-06-05): ruact_action JSON dispatch
24
+ # p50 0.973ms / p95 1.344ms; plain p50 0.771ms / p95 1.058ms; overhead +0.202ms
25
+ # p50 / +0.286ms p95; multipart p50 1.05ms / p95 1.494ms — all PASS < 20ms.
19
26
  #
27
+ # Run with:
20
28
  # bundle exec ruby bench/server_functions_dispatch_bench.rb
21
29
  #
22
- # Output: warm-up + benchmark-ips comparison + 1000-request absolute
23
- # numbers + median per-call overhead in ms. AC12 target: < 20 ms.
24
- #
25
- # CI/nightly:
26
- # `.github/workflows/server-functions-bench.yml` runs this script on
27
- # `schedule: cron: "0 6 * * *"` (nightly) and on any PR touching
28
- # `lib/ruact/server_functions/**`. The workflow does NOT gate merge —
29
- # it posts a comment with the numbers so accidental 10× regressions
30
- # surface in PR feedback (per AC12: "at minimum, a non-blocking
31
- # nightly job").
30
+ # CI/nightly: `.github/workflows/server-functions-bench.yml` runs this on a
31
+ # nightly cron + any PR touching `lib/ruact/server_functions/**`. Non-blocking.
32
32
 
33
33
  require "bundler/setup"
34
34
  require "benchmark/ips"
@@ -40,8 +40,9 @@ require "rack/test"
40
40
 
41
41
  require "ruact"
42
42
  require "ruact/controller"
43
- require "ruact/server_functions/endpoint_controller"
44
- require "ruact/server_action"
43
+ require "ruact/server"
44
+ require "ruact/routing"
45
+ require "ruact/query"
45
46
 
46
47
  ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
47
48
  ActiveRecord::Schema.verbose = false
@@ -57,6 +58,10 @@ class Post < ActiveRecord::Base
57
58
  validates :title, presence: true
58
59
  end
59
60
 
61
+ # No host ApplicationController in this minimal bench — point query dispatch at
62
+ # ActionController::Base so `ruact_queries` resolves its parent controller.
63
+ Ruact.configure { |c| c.query_parent_controller = "ActionController::Base" }
64
+
60
65
  class BenchApp < Rails::Application
61
66
  config.eager_load = false
62
67
  config.consider_all_requests_local = false
@@ -68,84 +73,101 @@ class BenchApp < Rails::Application
68
73
  config.hosts.clear if config.respond_to?(:hosts)
69
74
  end
70
75
 
71
- class BenchController < ActionController::Base
72
- include Ruact::Controller
76
+ # v2 mutation host — a normal controller that includes Ruact::Server. The
77
+ # `create` action is a non-GET REST route; the generated accessor calls it with
78
+ # `Accept: application/json`, so the concern serves Bucket-2 JSON.
79
+ class PostsController < ActionController::Base
80
+ include Ruact::Server
73
81
 
74
- ruact_action(:create_post) do |params|
75
- post = Post.create!(title: params[:title], body: params[:body])
76
- { id: post.id, title: post.title }
82
+ def create
83
+ payload = JSON.parse(request.raw_post)
84
+ @post = Post.create!(title: payload["title"], body: payload["body"])
85
+ render(json: { id: @post.id, title: @post.title })
77
86
  end
78
87
 
79
- def plain_create_post
88
+ # Multipart (`<form action={fn}>`) dispatch on the SAME Ruact::Server host:
89
+ # the request still flows through the concern (upload guard + error gate +
90
+ # Accept negotiation) — reads Rack-parsed multipart params.
91
+ def create_multipart
92
+ @post = Post.create!(title: params[:title], body: params[:body])
93
+ render(json: { id: @post.id, title: @post.title })
94
+ end
95
+ end
96
+
97
+ # Plain baseline — same Post.create! work, no concern. `create_multipart` reads
98
+ # Rack-parsed multipart params (the `<form action>` wire shape).
99
+ class PlainController < ActionController::Base
100
+ def create
80
101
  payload = JSON.parse(request.raw_post)
81
102
  post = Post.create!(title: payload["title"], body: payload["body"])
82
103
  render(json: { id: post.id, title: post.title })
83
104
  end
84
- end
85
105
 
86
- BenchApp.routes.append do
87
- post "/plain", to: "bench#plain_create_post"
106
+ def create_multipart
107
+ post = Post.create!(title: params[:title], body: params[:body])
108
+ render(json: { id: post.id, title: post.title })
109
+ end
88
110
  end
89
111
 
90
- # Story 8.3standalone host module backing the AC10 scenario. Declared
91
- # BEFORE app initialization so the Railtie's `config.to_prepare` snapshot
92
- # writer sees the entry (parity with the controller-hosted side).
93
- module BenchStandaloneHost
94
- extend Ruact::ServerAction
95
-
96
- ruact_action :bench_action do |params|
97
- post = Post.create!(title: params[:title], body: params[:body])
98
- { id: post.id, title: post.title }
112
+ # v2 read host a Ruact::Query mounted via ruact_queries GET /q/<jsId>.
113
+ class CatalogQuery < Ruact::Query
114
+ def recent
115
+ Post.order(created_at: :desc).limit(5).pluck(:title)
99
116
  end
100
117
  end
101
118
 
119
+ BenchApp.routes.append do
120
+ post "/posts", to: "posts#create"
121
+ post "/posts_mp", to: "posts#create_multipart"
122
+ post "/plain", to: "plain#create"
123
+ post "/plain_mp", to: "plain#create_multipart"
124
+ ruact_queries CatalogQuery
125
+ end
126
+
102
127
  BenchApp.instance.initialize!
103
- # Story 8.3 bench needs API mode so CSRF doesn't reject the bench
104
- # requests (no session middleware in this minimal Rack::Test setup).
105
- Ruact::ServerFunctions::EndpointController.allow_forgery_protection = false
128
+ # API mode so CSRF doesn't reject the bench requests (no session middleware in
129
+ # this minimal Rack::Test setup).
130
+ PostsController.allow_forgery_protection = false
106
131
 
107
132
  include Rack::Test::Methods
108
133
 
109
134
  def app = BenchApp.instance
110
135
 
111
- # Counter ensures titles stay unique under the AR validation; without
112
- # this, repeated calls would all attempt to insert the same row and
113
- # the bench would measure the validation-failure path, not the
114
- # create-success path.
115
136
  counter = 0
116
137
  body_for = lambda do
117
138
  counter += 1
118
139
  { title: "Post #{counter}", body: "body" }.to_json
119
140
  end
120
- headers = { "CONTENT_TYPE" => "application/json" }
141
+ json_headers = { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json" }
121
142
 
122
- post("/__ruact/fn/create_post", body_for.call, headers)
143
+ post("/posts", body_for.call, json_headers)
123
144
  unless last_response.status == 200
124
- raise "ruact dispatch broken (status=#{last_response.status} body=#{last_response.body})"
145
+ raise "ruact server dispatch broken (status=#{last_response.status} body=#{last_response.body})"
125
146
  end
126
147
 
127
- post("/plain", body_for.call, headers)
148
+ post("/plain", body_for.call, json_headers)
128
149
  raise "plain dispatch broken (status=#{last_response.status})" unless last_response.status == 200
129
150
 
151
+ get("/q/recent", {}, { "HTTP_ACCEPT" => "application/json" })
152
+ raise "query dispatch broken (status=#{last_response.status})" unless last_response.status == 200
153
+
130
154
  Benchmark.ips do |x|
131
155
  x.config(time: 3, warmup: 1)
132
156
 
133
- x.report("ruact_action dispatch (Post.create!)") do
134
- post("/__ruact/fn/create_post", body_for.call, headers)
157
+ x.report("Ruact::Server dispatch (POST /posts, Post.create!)") do
158
+ post("/posts", body_for.call, json_headers)
135
159
  end
136
160
 
137
161
  x.report("plain controller action (Post.create!)") do
138
- post("/plain", body_for.call, headers)
162
+ post("/plain", body_for.call, json_headers)
139
163
  end
140
164
 
141
165
  x.compare!
142
166
  end
143
167
 
144
- # Re-run-5 (2026-05-15) — AC12 asks for MEDIAN per-call overhead, not
145
- # mean. Sample N individual requests so we can compute the median and
146
- # the percentile spread. The median is the load-bearing number — a few
147
- # slow outliers (GC pause, OS scheduler) would otherwise inflate the
148
- # mean and produce misleading regression alerts.
168
+ # NFR21 asks for the MEDIAN per-call number. Sample N individual requests so we
169
+ # can compute the median + the percentile spread; the median is load-bearing (a
170
+ # few GC/scheduler outliers would inflate the mean).
149
171
  def time_one_seconds
150
172
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
151
173
  yield
@@ -162,8 +184,8 @@ def percentile(sorted, pct)
162
184
  end
163
185
 
164
186
  SAMPLES = 1000
165
- ruact_samples = sample_times(SAMPLES) { post("/__ruact/fn/create_post", body_for.call, headers) }
166
- plain_samples = sample_times(SAMPLES) { post("/plain", body_for.call, headers) }
187
+ ruact_samples = sample_times(SAMPLES) { post("/posts", body_for.call, json_headers) }
188
+ plain_samples = sample_times(SAMPLES) { post("/plain", body_for.call, json_headers) }
167
189
 
168
190
  ruact_p50 = percentile(ruact_samples, 50) * 1000
169
191
  plain_p50 = percentile(plain_samples, 50) * 1000
@@ -177,20 +199,17 @@ overhead_p95 = (ruact_p95 - plain_p95).round(3)
177
199
 
178
200
  puts ""
179
201
  puts "#{SAMPLES} requests (Post.create! body):"
180
- puts " ruact_action dispatch: total=#{(ruact_total * 1000).round(1)}ms p50=#{ruact_p50.round(3)}ms p95=#{ruact_p95.round(3)}ms"
202
+ puts " Ruact::Server dispatch: total=#{(ruact_total * 1000).round(1)}ms p50=#{ruact_p50.round(3)}ms p95=#{ruact_p95.round(3)}ms"
181
203
  puts " plain controller action: total=#{(plain_total * 1000).round(1)}ms p50=#{plain_p50.round(3)}ms p95=#{plain_p95.round(3)}ms"
182
204
  puts " per-call overhead (p50): +#{overhead_p50}ms"
183
205
  puts " per-call overhead (p95): +#{overhead_p95}ms"
184
206
  puts ""
185
- puts "AC12 target: MEDIAN ruact_action overhead < 20 ms per call (#{overhead_p50 < 20 ? 'PASS' : 'FAIL'})"
207
+ puts "NFR21 target: MEDIAN Ruact::Server dispatch p50/p95 < 20 ms per call " \
208
+ "(p50 #{ruact_p50.round(3)}ms #{ruact_p50 < 20 ? 'PASS' : 'FAIL'}, " \
209
+ "p95 #{ruact_p95.round(3)}ms #{ruact_p95 < 20 ? 'PASS' : 'FAIL'})"
186
210
 
187
211
  # ----------------------------------------------------------------------------
188
- # Story 8.2 — `<form action={fn}>` multipart dispatch overhead.
189
- #
190
- # AC11: median multipart per-call overhead stays within 1.2× of the JSON
191
- # baseline above. Multipart parsing is heavier than JSON parsing — Rails'
192
- # multipart parser allocates a temp file per part and walks the boundary
193
- # stream — but for sub-1KB bodies the cost should be negligible.
212
+ # Multipart (`<form action={fn}>`) dispatch overhead on the same route.
194
213
  # ----------------------------------------------------------------------------
195
214
 
196
215
  require "securerandom"
@@ -216,10 +235,9 @@ multipart_counter = 0
216
235
  multipart_post = lambda do
217
236
  multipart_counter += 1
218
237
  data, boundary = multipart_body("MP Post #{multipart_counter}", "multipart body")
219
- post("/__ruact/fn/create_post", data, multipart_headers.call(boundary))
238
+ post("/posts_mp", data, multipart_headers.call(boundary))
220
239
  end
221
240
 
222
- # Warm-up to ensure multipart parser is loaded
223
241
  multipart_post.call
224
242
  raise "multipart broken (status=#{last_response.status})" unless last_response.status == 200
225
243
 
@@ -227,83 +245,32 @@ multipart_samples = sample_times(SAMPLES) { multipart_post.call }
227
245
  mp_p50 = percentile(multipart_samples, 50) * 1000
228
246
  mp_p95 = percentile(multipart_samples, 95) * 1000
229
247
  mp_total_ms = (multipart_samples.sum * 1000).round(1)
230
- overhead_mp_vs_json_p50 = (mp_p50 - ruact_p50).round(3)
231
- mp_factor = ruact_p50.zero? ? 0.0 : (mp_p50 / ruact_p50).round(3)
232
248
 
233
249
  puts ""
234
- puts "#{SAMPLES} multipart requests (Story 8.2 — `<form action>` shape):"
235
- puts " multipart dispatch: total=#{mp_total_ms}ms p50=#{mp_p50.round(3)}ms p95=#{mp_p95.round(3)}ms"
236
- puts " vs. JSON ruact baseline: +#{overhead_mp_vs_json_p50}ms (factor=#{mp_factor}×)"
250
+ puts "#{SAMPLES} multipart requests (`<form action>` shape) — Ruact::Server route /posts_mp:"
251
+ puts " Ruact::Server multipart dispatch: total=#{mp_total_ms}ms p50=#{mp_p50.round(3)}ms p95=#{mp_p95.round(3)}ms"
237
252
  puts ""
238
- puts "AC11 target: multipart median <= 1.2× JSON median (#{mp_factor <= 1.2 ? 'PASS' : 'FAIL'})"
253
+ puts "NFR21 target: multipart p50/p95 < 20 ms per call " \
254
+ "(p50 #{mp_p50.round(3)}ms #{mp_p50 < 20 ? 'PASS' : 'FAIL'}, " \
255
+ "p95 #{mp_p95.round(3)}ms #{mp_p95 < 20 ? 'PASS' : 'FAIL'})"
239
256
 
240
257
  # ----------------------------------------------------------------------------
241
- # Story 8.3standalone-host dispatch overhead (AC10).
242
- #
243
- # The standalone path SKIPS Rails' `process_action` callback chain + the
244
- # host controller allocation, so it can be slightly faster than the
245
- # controller-hosted JSON baseline OR slightly slower if the StandaloneContext
246
- # setup adds overhead. The AC10 band catches accidental 10× regressions
247
- # while accepting normal noise: 0.95× ≤ standalone_p50 / json_p50 ≤ 1.05×.
258
+ # Query dispatch overhead GET /q/<jsId> (a ruact_queries-mounted read).
248
259
  # ----------------------------------------------------------------------------
249
260
 
250
- standalone_counter = 0
251
- standalone_body_for = lambda do
252
- standalone_counter += 1
253
- { title: "Standalone Post #{standalone_counter}", body: "standalone body" }.to_json
254
- end
255
-
256
- post("/__ruact/fn/bench_action", standalone_body_for.call, headers)
257
- unless last_response.status == 200
258
- raise "standalone dispatch broken (status=#{last_response.status} body=#{last_response.body})"
259
- end
261
+ query_get = -> { get("/q/recent", {}, { "HTTP_ACCEPT" => "application/json" }) }
262
+ query_get.call
263
+ raise "query broken (status=#{last_response.status})" unless last_response.status == 200
260
264
 
261
- standalone_samples = sample_times(SAMPLES) { post("/__ruact/fn/bench_action", standalone_body_for.call, headers) }
262
- standalone_p50 = percentile(standalone_samples, 50) * 1000
263
- standalone_p95 = percentile(standalone_samples, 95) * 1000
264
- standalone_total_ms = (standalone_samples.sum * 1000).round(1)
265
- overhead_standalone_vs_json_p50 = (standalone_p50 - ruact_p50).round(3)
266
- standalone_factor = ruact_p50.zero? ? 0.0 : (standalone_p50 / ruact_p50).round(3)
265
+ query_samples = sample_times(SAMPLES) { query_get.call }
266
+ q_p50 = percentile(query_samples, 50) * 1000
267
+ q_p95 = percentile(query_samples, 95) * 1000
268
+ q_total_ms = (query_samples.sum * 1000).round(1)
267
269
 
268
270
  puts ""
269
- puts "#{SAMPLES} standalone-host requests (Story 8.3 — extend Ruact::ServerAction):"
270
- puts " standalone dispatch: total=#{standalone_total_ms}ms p50=#{standalone_p50.round(3)}ms p95=#{standalone_p95.round(3)}ms"
271
- puts " vs. JSON controller baseline: #{'+' if overhead_standalone_vs_json_p50 >= 0}#{overhead_standalone_vs_json_p50}ms (factor=#{standalone_factor}×)"
272
- puts ""
273
- puts "AC10 target: standalone median within 0.95×..1.05× of JSON baseline " \
274
- "(#{(0.95..1.05).cover?(standalone_factor) ? 'PASS' : 'WARN — outside band; see results.md (laptop noise dominates; 10× is the regression alert)'})"
275
-
276
- # ----------------------------------------------------------------------------
277
- # Story 8.4 — error-path overhead.
278
- #
279
- # The new endpoint-level `rescue_from StandardError` chain only fires on the
280
- # unhappy path, so it does NOT touch the happy-path numbers above. This
281
- # section measures the rescue-from cost itself: an action that always raises
282
- # `RuntimeError("forced")` end-to-end through the new handler. Captures p50
283
- # and p95 of the error path so future regressions (e.g., adding an expensive
284
- # serializer step inside `ErrorPayload.build`) surface in nightly numbers.
285
- #
286
- # No regression assertion against the happy-path scenarios above — they're
287
- # untouched because the new chain only fires on raise.
288
- # ----------------------------------------------------------------------------
289
-
290
- class BenchController
291
- ruact_action(:forced_failure) { |_params| raise "forced bench failure" }
292
- end
293
-
294
- # Warm-up — exercise the rescue_from chain once so the path is loaded.
295
- post("/__ruact/fn/forced_failure", "{}", headers)
296
- unless last_response.status == 500
297
- raise "error-path warm-up did not return 500 (got status=#{last_response.status})"
298
- end
299
-
300
- forced_samples = sample_times(SAMPLES) { post("/__ruact/fn/forced_failure", "{}", headers) }
301
- forced_p50 = percentile(forced_samples, 50) * 1000
302
- forced_p95 = percentile(forced_samples, 95) * 1000
303
- forced_total_ms = (forced_samples.sum * 1000).round(1)
304
- forced_overhead_p50 = (forced_p50 - ruact_p50).round(3)
305
-
271
+ puts "#{SAMPLES} query requests (GET /q/recent):"
272
+ puts " query dispatch: total=#{q_total_ms}ms p50=#{q_p50.round(3)}ms p95=#{q_p95.round(3)}ms"
306
273
  puts ""
307
- puts "#{SAMPLES} error-path requests (Story 8.4 rescue_from + ErrorPayload.build):"
308
- puts " error-path dispatch: total=#{forced_total_ms}ms p50=#{forced_p50.round(3)}ms p95=#{forced_p95.round(3)}ms"
309
- puts " vs. JSON happy-path: #{'+' if forced_overhead_p50 >= 0}#{forced_overhead_p50}ms (informational only no regression band)"
274
+ puts "NFR21 target: query p50/p95 < 20 ms per call " \
275
+ "(p50 #{q_p50.round(3)}ms #{q_p50 < 20 ? 'PASS' : 'FAIL'}, " \
276
+ "p95 #{q_p95.round(3)}ms #{q_p95 < 20 ? 'PASS' : 'FAIL'})"
@@ -119,3 +119,32 @@ scenarios above are the load-bearing gates.
119
119
  If a future change pushes the error path above ~5 ms p50 without a clear
120
120
  reason (e.g., a backtrace-cleaning algorithmic regression, an expensive
121
121
  suggestion lookup, or a serialiser change), surface it for review.
122
+
123
+ ## Story 9.9 re-point (NFR21 — route-driven dispatch)
124
+
125
+ The v1 synthetic endpoint + `ruact_action` DSL were demolished in Story 9.9, so
126
+ the bench is re-pointed at REAL routes: a non-GET REST route (`POST /posts`) on a
127
+ `include Ruact::Server` controller (Bucket-2 JSON), the multipart `<form action>`
128
+ shape on a plain route, and a `ruact_queries`-mounted query route (`GET /q/recent`).
129
+
130
+ NOTE (D2): the v2 path is a real Rails route through the full router + the
131
+ `Ruact::Server` concern — a different code path than the deleted v1 synthetic
132
+ endpoint, so the per-call delta vs. plain is NOT apples-to-apples with the v1
133
+ baseline above. The honest NFR21 gate is "p50/p95 < 20 ms holds."
134
+
135
+ | Metric | Value |
136
+ | --- | --- |
137
+ | Target | server-function dispatch p50/p95 < 20 ms per call |
138
+ | Status | PASS |
139
+
140
+ | Scenario | p50 | p95 |
141
+ | --- | --- | --- |
142
+ | `Ruact::Server` JSON dispatch (`POST /posts`, `Post.create!`) | ~0.79 ms | ~1.2 ms |
143
+ | Plain controller action (same `Post.create!`) | ~0.78 ms | ~1.22 ms |
144
+ | Per-call overhead (p50) | ~+0.01 ms | — |
145
+ | Multipart (`<form action>` shape) | ~0.86 ms | ~1.37 ms |
146
+ | Query (`GET /q/recent`, `ruact_queries`) | ~4.23 ms | ~5.90 ms |
147
+
148
+ Local reference run (2026-06-15, rbenv Ruby 3.4.5, Rails 8.1, 1000 samples).
149
+ The query path is heavier than the mutation path (it boots the gem's internal
150
+ query dispatch controller + sanitizes params) but stays comfortably < 20 ms.