hibiki_rails 0.2.0 → 0.4.0

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +234 -0
  3. data/README.md +67 -2
  4. data/app/assets/javascripts/hibiki.js +404 -20
  5. data/lib/generators/hibiki/rails/css_variant.rb +189 -0
  6. data/lib/generators/hibiki/rails/generator_helpers.rb +20 -1
  7. data/lib/generators/hibiki/rails/island/island_generator.rb +0 -8
  8. data/lib/generators/hibiki/rails/scaffold/USAGE +32 -0
  9. data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +73 -0
  10. data/lib/generators/hibiki/rails/scaffold_controller/USAGE +34 -0
  11. data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +252 -0
  12. data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +72 -0
  13. data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +60 -0
  14. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +330 -0
  15. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt +77 -0
  16. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt +40 -0
  17. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/member_channel.rb.tt +62 -0
  18. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +204 -0
  19. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/row.rb.tt +27 -0
  20. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_busy.html.erb.tt +103 -0
  21. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt +87 -0
  22. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_field_error.html.erb.tt +8 -0
  23. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_form.html.erb.tt +52 -0
  24. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +62 -0
  25. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +67 -0
  26. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +85 -0
  27. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/edit.html.erb.tt +13 -0
  28. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/index.html.erb.tt +47 -0
  29. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/new.html.erb.tt +12 -0
  30. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/show.html.erb.tt +27 -0
  31. data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +66 -0
  32. data/lib/generators/hibiki/rails/scaffold_helpers.rb +161 -0
  33. data/lib/generators/hibiki/rails/scaffold_model_injection.rb +102 -0
  34. data/lib/generators/hibiki/rails/scaffold_parent_injection.rb +185 -0
  35. data/lib/generators/hibiki/rails/scaffold_parent_notices.rb +96 -0
  36. data/lib/generators/hibiki/rails/scaffold_post_install.rb +176 -0
  37. data/lib/generators/hibiki/rails/scaffold_schema.rb +541 -0
  38. data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +166 -0
  39. data/lib/hibiki/rails/channel.rb +63 -8
  40. data/lib/hibiki/rails/graph_actor.rb +15 -1
  41. data/lib/hibiki/rails/helpers.rb +102 -14
  42. data/lib/hibiki/rails/reactive_form.rb +163 -0
  43. data/lib/hibiki/rails/version.rb +1 -1
  44. data/lib/hibiki/rails.rb +19 -1
  45. metadata +38 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62779990991fb78726bc665b7ad93a93dabee5b49892fa99f6486ad42dc41679
4
- data.tar.gz: 9cac4fa0143ab6b5f031d9ea0d9d213a1505108e1aa06894773d59f8594b6023
3
+ metadata.gz: 3b21e4afc4b4e74e30f3c32286a1a75b30052e55145d25210bb3cecfbb3bc24c
4
+ data.tar.gz: 5c26612918d2b81c3a6a6a134710331ba656b25e9441856627ddf6b3ee093e71
5
5
  SHA512:
6
- metadata.gz: bf6340bdef27fd3c320fff3a8d7fb90967d883783a965e61a90ccbae5d536b7bdcdfcba536d827089d3b389af1c07439ff7031ee11792c95a579752c8b235706
7
- data.tar.gz: a9337451b21ecd955590683648f8a6d824bafa703ce096937387cff0fafaedf5ee5a959089558f7bf02037b00d39e40fd9fcab1bf3176cd976dcd1b957ab6443
6
+ metadata.gz: acf106a0cda97184c5bd9a988bab71efb8be65c76e1f5b08785f96795d7a9dc21f67a5511a9a10ffa7b5fee49d498891ce7516b926eec1868d76820fd7c42c48
7
+ data.tar.gz: 5e8d7b8aba5eaf643e5c6f82dcde5c824bd8f9eb1594163dd872bc0a304a6b37a796069385ec8fdc78e8bfe48dd7d9345d4f60a20b77fe3f72760d02e9b0157e
data/CHANGELOG.md ADDED
@@ -0,0 +1,234 @@
1
+ # Changelog
2
+
3
+ The gem and the npm package are released in lockstep and share these version
4
+ numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
+ so importmap and bundler apps always resolve identical client code.
6
+
7
+ ## 0.4.0 — 2026-08-03
8
+
9
+ ### Added
10
+
11
+ **Loading and connection state, stamped by the client.** The first growth of the
12
+ `data-hibiki-*` protocol since 0.3.0, and the first attributes in it that no Ruby
13
+ helper emits — the client writes them at runtime and app CSS reads them:
14
+
15
+ ```
16
+ island root data-hibiki-busy present while an action is in flight
17
+ aria-busy="true" the same fact, for assistive tech
18
+ data-hibiki-state connecting | ready | offline | stalled
19
+ firing control data-hibiki-busy on the control that started it
20
+ ```
21
+
22
+ Everything an app wants out of that is a descendant selector —
23
+ `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row and
24
+ per-button feedback needs no server state and no `{#if loading}` branch. The
25
+ Ruby surface is unchanged: no new option on `on`, `hibiki_island` or `reactive`.
26
+
27
+ **Actions are acknowledged once their batch has run**, and the ack is what clears
28
+ the indicator. It cannot be "clear on the next render": the core's equality gate
29
+ (hibiki 0.2.0) lets an ordinary action produce zero bytes — paging to the page
30
+ you are already on, a search that does not change the query, a destroy of a row
31
+ another tab already deleted — so a render-based rule hangs forever on gestures
32
+ users make all the time. The ack sits in an `ensure`, so a raising action stops
33
+ the spinner too, and a subscription that has gone away acks `dropped: true` from
34
+ the cable thread instead, which is what lets the client tell "late" from "never".
35
+
36
+ A page running the 0.3.0 client sends no sequence number and gets no ack, so
37
+ nothing about this reaches an app that has not upgraded both halves.
38
+
39
+ **Actions performed before the subscription confirms are queued, not dropped.**
40
+ ActionCable's `Subscription#perform` silently returns false on a socket that is
41
+ not open yet, and on the Turbo-broadcast path that window is about three
42
+ serialised round trips — a click in it used to vanish. The queue covers the first
43
+ connect window only: after a reconnect the server rebuilds the graph with default
44
+ state, so replaying intent formed against the old one is worse than dropping it,
45
+ and the island reads `offline` for the whole gap instead.
46
+
47
+ Three class properties on `ChannelController` are the entire tuning surface —
48
+ `busyDelay` (150 ms before a trip is worth mentioning), `busyGrace` (60 ms for a
49
+ broadcast still in flight after its ack), `busyCeiling` (10 s before a trip is
50
+ declared stalled rather than cleared silently). Deliberately not Stimulus values
51
+ and not helper options; an app that wants different numbers subclasses and
52
+ re-registers.
53
+
54
+ The scaffold generators wire five sites to all of this — the counts line, the
55
+ pagination bar, the infinite-scroll sentinel, the destroy button and the
56
+ inline-edit Save — through a generated `_busy.html.erb` the app owns.
57
+
58
+ **`hibiki:rails:scaffold` and `hibiki:rails:scaffold_controller`** — a reactive
59
+ CRUD resource generated the way `rails g scaffold` generates a plain one.
60
+
61
+ ```sh
62
+ bin/rails g hibiki:rails:scaffold Book title:string author:references
63
+ bin/rails g hibiki:rails:scaffold_controller Book # an existing model
64
+ ```
65
+
66
+ The generated index is live: search, filter, sort and pagination are signal
67
+ state rather than page loads, rows edit in place, and a write from anywhere —
68
+ another tab, another user, the plain controller, a console — repaints every open
69
+ list. Everything is derived from the model's schema (columns, types, `belongs_to`
70
+ reflections, validators) or from the same `NAME field:type` argument list Rails'
71
+ own scaffold takes.
72
+
73
+ Stock `rails g scaffold` is untouched; these live under their own namespace.
74
+
75
+ Live per-field validation is derived only from rules the form can actually
76
+ evaluate before a round trip: presence, length, and numericality bounds that
77
+ carry no `allow_nil:`/`allow_blank:` exemption for the value in hand. A
78
+ validator gated on `if:`, `unless:` or `on:` depends on the record rather than
79
+ the field, so it contributes nothing live — it still runs at commit and still
80
+ lands in `#errors`, which the same per-field slots mirror. The clauses are
81
+ generated once; add validators and re-run `scaffold_controller` to pick them up.
82
+
83
+ **Field order is yours, and choosing it costs nothing.** With no field list the
84
+ columns follow the schema, which for an app built from `schema.rb` means
85
+ alphabetical. Passing them explicitly picks the order — and against a model that
86
+ already exists the generator still reads that model for everything else, so the
87
+ live clauses, a number field's `min:`/`max:` and a `belongs_to`'s display label
88
+ all survive the choice:
89
+
90
+ ```sh
91
+ bin/rails g hibiki:rails:scaffold_controller Author name:string bio:text age:integer
92
+ ```
93
+
94
+ A field the model has no column for is still generated, from the argument list
95
+ alone, and named in the post-install output — it may be a column whose migration
96
+ is still to come, and a silently missing field is the worse failure.
97
+
98
+ **A unique index with no uniqueness validator is called out.** A database
99
+ constraint is not a validator, and the generated form can only mirror what the
100
+ model checks — so without one, a duplicate raises `ActiveRecord::RecordNotUnique`
101
+ on the graph thread instead of showing a field error, and the round trip
102
+ completes having saved nothing. The generator names the column and the exact
103
+ `validates` line, for single and composite indexes alike.
104
+
105
+ Options: `--css=daisyui|tailwind|none` (detected when absent),
106
+ `--infinite-scroll`, `--skip-pagination`, `--skip-search`, `--page-size=N`,
107
+ `--skip-routes`.
108
+
109
+ Three notes on what it does to an app you already have.
110
+
111
+ **The model is modified** — one `delegate` per `belongs_to`, plus the
112
+ `after_commit` broadcast the whole thing hangs off.
113
+
114
+ **So is each model a `belongs_to` points at.** It gains the `has_many` half
115
+ Rails' own scaffold never writes (without it the generated destroy button raises
116
+ `InvalidForeignKey`) and a ping of its own, because a row prints the parent's
117
+ label rather than its id — rename an author and every open books index would
118
+ otherwise keep the old name. `dependent:` follows the association: `:destroy`
119
+ when it is required, `:nullify` when it is `optional: true`. That ping is
120
+ collection-grained, so an open *show* page keeps the old label until reload.
121
+
122
+ Both injections are idempotent and announced, and anything you already declared
123
+ is left alone — including a `dependent:` you chose yourself.
124
+
125
+ And **restart the server afterwards**: `app/forms/` is likely new, and Rails
126
+ computes autoload paths from the `app/*` glob at boot.
127
+
128
+ ### Fixed
129
+
130
+ **The model injection missed every namespaced model.** Thor anchors
131
+ `inject_into_class` on the class name as the file spells it, and the generator
132
+ passed the demodulized one — so `app/models/admin/book.rb`, which Rails writes as
133
+ `class Admin::Book < ApplicationRecord`, never matched. Silently: Thor rewrote
134
+ the file byte-identical and the generator reported a modification. The delegate
135
+ never landed, so the show page raised on arrival.
136
+
137
+ ## 0.3.0 — 2026-07-28
138
+
139
+ ### Security
140
+
141
+ **Channel lifecycle methods were client-invocable on Rails 7.1 and 7.2.**
142
+ Upgrade if you run hibiki_rails on either. Rails 8.x apps were never affected.
143
+
144
+ ActionCable builds a channel's client-invocable actions from the public methods
145
+ the class adds. The gem subtracted its lifecycle hooks through ActionCable's
146
+ `internal_methods` hook — but **that hook only exists on Rails 8.x**; on 7.1 and
147
+ 7.2 `action_methods` never consults it, so the override was silently inert and
148
+ the hooks stayed exposed.
149
+
150
+ Reachable by any client that can open a subscription, against its own
151
+ connection's graph. There is no cross-connection or cross-user data exposure;
152
+ the impact is resource exhaustion:
153
+
154
+ - `perform("build_graph")` — exposed on **every** affected app, since
155
+ `#build_graph` is always public. It re-runs the graph build outside
156
+ `Hibiki.root`, so the effects it creates are unowned and the dispose on
157
+ unsubscribe never reaches them, while the previous root is still held. Each
158
+ call leaks; repeated calls grow memory without bound.
159
+ - `perform("subscribed")` — exposed only where an app defined `#subscribed`
160
+ public, which the ActiveRecord guide's own `after_commit` example did until
161
+ this release. It allocates a second `GraphActor` — a new **thread** — and
162
+ overwrites the reference to the first, so the original is never stopped.
163
+ Repeated calls exhaust the process's threads.
164
+
165
+ Fixed by subtracting in `action_methods` itself, which works on every supported
166
+ version:
167
+
168
+ ```ruby
169
+ HIDDEN_ACTIONS = %w[build_graph subscribed unsubscribed].freeze
170
+ def action_methods = super - HIDDEN_ACTIONS
171
+ ```
172
+
173
+ No application change is required. Writing `#subscribed` and `#unsubscribed`
174
+ private is still the better habit, because it also protects the methods this
175
+ list does not know about — the guides now show them that way.
176
+
177
+ ### Added
178
+
179
+ - `on(action, event:)` takes an **event list**, so one element can answer
180
+ several events: `on(:load_more, event: %i[click visible])`.
181
+ - **`input`** joins the delegated events, with a per-control **`debounce:`**.
182
+ `:input` carries 250 ms unless told otherwise (`debounce: 0` opts out); the
183
+ value is stamped into the markup rather than being an invisible client
184
+ default.
185
+ - **`visible`**, a pseudo-event backed by an `IntersectionObserver`, so a
186
+ load-more control can double as an infinite-scroll sentinel. It fires once
187
+ per observation and re-attaches to the replacement element after each
188
+ fragment swap.
189
+ - **`confirm:`** on `on(...)` — a `window.confirm` gate. `data-turbo-confirm`
190
+ does nothing on a hibiki control, since it is not a Turbo-driven form.
191
+ - **`reset: false`** on a submit, to keep a form's inputs. The default resets
192
+ them synchronously, before the server has replied, which is right for an
193
+ "add" form and destroys an edit form's contents on a failed commit.
194
+ - **Subscribe params**: `hibiki_island(channel, cid:, params: { record_id: })`
195
+ reach the channel as `params[:key]`, which is how a channel learns which
196
+ record its page is about. They are client-supplied and untrusted — use one
197
+ only to look up a record inside a scope the channel chooses, and never
198
+ interpolate one into a streamable name. They cannot override `channel` or
199
+ `cid`.
200
+ - The client has its own test suite (`spec/js`, vitest + happy-dom) and a CI
201
+ job, plus a pinned Rails 8.1 matrix leg.
202
+
203
+ ### Changed
204
+
205
+ - **A changed checkbox now sends its checked state as a boolean**, and a
206
+ multi-select sends an array of its selected values. Previously both sent
207
+ `control.value` — the value *attribute* — so checking and unchecking a
208
+ checkbox produced byte-identical payloads. **This is a payload shape
209
+ change**: an action reading a `change` payload for a checkbox now receives
210
+ `true`/`false` rather than `"1"`. The form-submit path is unaffected.
211
+ - `transmit_value` is **equality-gated**: an effect re-runs whenever any signal
212
+ it read changed, so a bumped version token used to re-send every reactive
213
+ value's text even when byte-identical. The block still runs unconditionally,
214
+ so dependency collection is unchanged — only the transmit is skipped. If you
215
+ render a reactive placeholder *inside* a broadcast-replaced fragment, render
216
+ it with its current value: the swap resets the DOM text and the gate now
217
+ suppresses the re-send that used to heal it.
218
+ - Each graph job runs inside `Rails.application.executor`, so
219
+ `CurrentAttributes` are reset between jobs, autoloads are safe off the main
220
+ thread, and each job gets its own query cache.
221
+ - Graph-thread errors are **logged** in development and test.
222
+ `ActiveSupport::ErrorReporter` has no subscribers by default, so in a stock
223
+ app these previously vanished entirely — no line, no stack, just a fragment
224
+ that stopped updating. Production behaviour is unchanged.
225
+
226
+ ## 0.2.0 — 2026-07-21
227
+
228
+ - Reactive values: `reactive` / `reactive_attrs` / `transmit_value`, matched
229
+ document-wide so a value can render outside the island that computes it.
230
+ - `Components::` namespacing for the Phlex generator.
231
+
232
+ ## 0.1.0 — 2026-07-18
233
+
234
+ Initial release.
data/README.md CHANGED
@@ -59,6 +59,40 @@ bin/rails g hibiki:rails:stimulus counter static_pages
59
59
 
60
60
  This creates a minimal working reactive component in the given view path.
61
61
 
62
+ ### Generating a whole CRUD resource
63
+
64
+ For a full reactive resource — a live index with search, filtering, sorting and
65
+ pagination, plus edit-in-place — use the scaffold generators:
66
+
67
+ ```sh
68
+ # Model, migration, route and the reactive resource, like rails g scaffold
69
+ bin/rails g hibiki:rails:scaffold Book title:string author:references
70
+
71
+ # Or, for a model you already have — the schema is read for you
72
+ bin/rails g hibiki:rails:scaffold_controller Book
73
+
74
+ # Same, but you pick the field order; everything else still comes from the model
75
+ bin/rails g hibiki:rails:scaffold_controller Book title:string author:references
76
+ ```
77
+
78
+ Your plain `rails g scaffold` is untouched. The generated markup is styled to
79
+ match your app (DaisyUI, Tailwind, or unstyled — detected automatically, or
80
+ forced with `--css=`). Run `bin/rails g hibiki:rails:scaffold --help` for the
81
+ rest of the options.
82
+
83
+ Listing the fields yourself only chooses their order and which ones appear — the
84
+ model still answers everything else, so the live validation, a number field's
85
+ `min:`/`max:` and a `belongs_to`'s display label all survive the choice.
86
+
87
+ Your models are edited, not just read: the one being scaffolded gains the
88
+ `after_commit` broadcast that makes writes from anywhere reach an open list, and
89
+ each model a `belongs_to` points at gains the `has_many` half plus a ping of its
90
+ own, so renaming a parent repaints the lists that print its name. Both are
91
+ idempotent, announced, and leave anything you already declared alone.
92
+
93
+ Restart the server afterwards: `app/forms/` is new, and Rails works out its
94
+ autoload paths at boot.
95
+
62
96
  ### Render the reactive component
63
97
 
64
98
  The generated components are just Rails partials (or Phlex components, if you used the Phlex generator), so you can render one anywhere like any other partial:
@@ -73,11 +107,42 @@ Congratulations! Now you have your first reactive component!
73
107
 
74
108
  Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
75
109
 
110
+ Release notes and upgrade advice: [CHANGELOG.md](CHANGELOG.md). **If you run Rails 7.1 or 7.2, read the 0.3.0 entry** — it fixes channel lifecycle methods that were client-invocable on those versions.
111
+
76
112
  ## Development
77
113
 
78
114
  ```
79
- bundle exec rake # specs + rubocop (same as CI)
115
+ bundle exec rake # Ruby specs + rubocop
116
+ bun install && bun run test # the client's own specs
80
117
  ```
81
118
 
82
- The spec suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`).
119
+ Both are what CI runs. The Ruby suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`); the JS suite (`spec/js/`) drives the real Stimulus controller in happy-dom against a stubbed Action Cable consumer.
120
+
121
+ The gem and the npm package are **released in lockstep**: `app/assets/javascripts/hibiki.js` is the single copy — the engine puts it on the asset path and `package.json` points `main`/`module`/`exports` at it — so importmap and bundler apps must never be able to resolve different client code. Bump `lib/hibiki/rails/version.rb` and `package.json` in the same commit, and publish both. The version table lives in [the JS client docs](https://planetaska.github.io/hibiki/the-js-client/).
122
+
123
+ ## Contributing
124
+
125
+ Bug reports and pull requests are welcome at <https://github.com/planetaska/hibiki-rails>.
126
+
127
+ A few things that make a change easier to accept:
128
+
129
+ - **`bundle exec rake` and `bun run test` both green.** They are what CI runs, and the client half is easy to forget — most changes here touch one side, but the wire protocol is shared by both.
130
+ - **A regression spec first** for anything that was a bug. `spec/js/` covers the client, `spec/generators/` the generated output.
131
+ - **Generated code follows `rubocop-rails-omakase`**, which is what a stock Rails app lints with — not this gem's own style. The templates are written to satisfy the app's linter, not ours.
132
+ - **The `data-hibiki-*` attributes are a private contract** between the Ruby helpers and the vendored JS, and the two halves ship in one version — so a change to either side belongs in one commit with the other. Two of them, `data-hibiki-busy` and `data-hibiki-state`, are written by the client rather than by a helper, and apps select on them from CSS; those need a CHANGELOG entry even when no Ruby changes.
133
+
134
+ ### Running against a checkout
135
+
136
+ The gem side needs nothing special: point an app's Gemfile at your clone with `gem "hibiki_rails", path: "../hibiki-rails"`.
137
+
138
+ The npm side has one trap. `bun link` from an app resolves the linked package's own imports from the **symlink's realpath**, not from the app — so `hibiki.js` looks for `@rails/actioncable` inside your checkout rather than inside the app that linked it, and if the checkout has no `node_modules` the import fails at build time with nothing pointing at the cause.
139
+
140
+ ```sh
141
+ cd hibiki-rails && bun install # before linking, not after
142
+ ```
143
+
144
+ This affects only development against a clone. Anyone installing the published package resolves normally and never sees it.
145
+
146
+ ## License
83
147
 
148
+ [MIT](LICENSE.txt)