solid_objects 0.7.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75595f12197f05985076afb8974ec528b85914db25da49c29b342b9fc531ad97
4
- data.tar.gz: d240311fe0bfbbd12386d582c4a8dcb560a2698385d65c1f0781340880cbd11c
3
+ metadata.gz: de78ca94f2c12e3c08d50c7b2512d6dbda67a47571540390c7494c6a828f1d35
4
+ data.tar.gz: b194c137ff3110cabb6cb2f7319819fdd64f314a9ae2113868438a89acce36b8
5
5
  SHA512:
6
- metadata.gz: e303049a41809dca76e99ed3b035cde028817e2f0a6a7fadc1fd90f41bde9e108b83bc06247d596722c135ffad4b9b2fda9e5fb75277a89e837acc0c3ade9b25
7
- data.tar.gz: 8f800ed5e80df86176ff1f7967da7f117363607c2993872670bfe0d85a0ece83db1eef8f1f4f72a74992182cae77f36bd80ea30ebc81b9c7a5bd03ba16a14785
6
+ metadata.gz: 78834ecb346469854b6d21d938fe3a6ed2689cd60cb037b22b3ce00025466c172ceb3dacdfcf94f88b0e0739e3dcac1c3e84896c6685bc4fe53d4db565ab8513
7
+ data.tar.gz: 31fbae44dce542e1fc07fe218bf504eb8627583ecc64895e8d86a5805340aeea47531b5f08a6e844809c31b513998c53c28b90993ae9cd40b864e868323eee27
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.3 - 2026-08-09
4
+
5
+ - Coordinate batched component refreshes by revision as well as scope and batch
6
+ name. Invalidations for one revision arrive as separate WebSocket messages, so
7
+ the microtask merge could not see them all, and each request aborted the one
8
+ before it. Only the last component updated. Same-revision requests now run
9
+ alongside each other and every frame is applied; only a strictly newer
10
+ revision supersedes an in-flight request. Frames already applied at a revision
11
+ are not applied twice.
12
+
13
+ ## 0.7.2 - 2026-08-09
14
+
15
+ - Render batched component partials as HTML regardless of the request format.
16
+ The batch endpoint is requested with a JSON `Accept` header, so Rails looked
17
+ for JSON templates, raised `ActionView::MissingTemplate`, and the batch
18
+ returned 404 for applications whose components are ordinary
19
+ `.html.erb` partials. The outer response is still JSON. Single-component
20
+ refresh was never affected and is unchanged.
21
+ - Pass `registrations:` to `component_authorization_context`: one registration
22
+ for a single refresh, all of them for a batch, so applications no longer have
23
+ to inspect `params[:tokens]`. Callbacks accepting only `controller:` keep
24
+ working unchanged.
25
+
3
26
  ## 0.7.1 - 2026-08-09
4
27
 
5
28
  - Retry a contended SQLite write outside a synchronous deadline. Asynchronous
@@ -1,5 +1,7 @@
1
1
  const pendingBatches = new Map()
2
2
  const activeBatches = new Map()
3
+ const appliedRevisions = new Map()
4
+ let requestSequence = 0
3
5
 
4
6
  class SolidObjectsBatchRefreshElement extends HTMLElement {
5
7
  connectedCallback() {
@@ -34,18 +36,26 @@ class SolidObjectsBatchRefreshElement extends HTMLElement {
34
36
  pendingBatches.set(key, merged)
35
37
  queueMicrotask(() => {
36
38
  pendingBatches.delete(key)
37
- requestBatch(group, batch, merged.sources)
39
+ requestBatch(group, batch, revision, merged.sources)
38
40
  })
39
41
  this.remove()
40
42
  }
41
43
  }
42
44
 
43
- async function requestBatch(group, batch, sources) {
44
- const previous = activeBatches.get(group)
45
- previous?.abort()
46
-
45
+ // Invalidations for one revision arrive in separate WebSocket messages, so the
46
+ // microtask merge cannot see them all. Requests are tracked per revision and a
47
+ // request is only cancelled by a strictly newer one; same-revision requests run
48
+ // alongside each other and every frame is applied.
49
+ async function requestBatch(group, batch, revision, sources) {
50
+ const parsed = parseRevision(revision)
51
+ supersedeOlderRequests(group, parsed)
52
+
53
+ // Same-revision requests run concurrently, so each needs its own entry.
54
+ // Sharing one key per revision would leave all but the last untracked and
55
+ // therefore impossible to supersede.
56
+ const key = `${group}:${revision}:${(requestSequence += 1)}`
47
57
  const controller = new AbortController()
48
- activeBatches.set(group, controller)
58
+ activeBatches.set(key, { controller, group, revision: parsed })
49
59
 
50
60
  try {
51
61
  const url = mergedUrl(sources)
@@ -68,10 +78,29 @@ async function requestBatch(group, batch, sources) {
68
78
  } catch (error) {
69
79
  if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
70
80
  } finally {
71
- if (activeBatches.get(group) === controller) activeBatches.delete(group)
81
+ if (activeBatches.get(key)?.controller === controller) activeBatches.delete(key)
72
82
  }
73
83
  }
74
84
 
85
+ function supersedeOlderRequests(group, revision) {
86
+ if (!revision) return
87
+
88
+ activeBatches.forEach((entry, key) => {
89
+ if (entry.group !== group) return
90
+ if (!olderRevision(entry.revision, revision)) return
91
+
92
+ entry.controller.abort()
93
+ activeBatches.delete(key)
94
+ })
95
+ }
96
+
97
+ function olderRevision(candidate, current) {
98
+ if (!candidate || !current) return false
99
+
100
+ return candidate[0] < current[0] ||
101
+ (candidate[0] === current[0] && candidate[1] < current[1])
102
+ }
103
+
75
104
  // Every notification for one batch and revision carries the same endpoint and
76
105
  // differs only by which components changed, so the union of their tokens is the
77
106
  // complete set to render.
@@ -93,6 +122,13 @@ function applyFrame(frame) {
93
122
  const target = document.getElementById(frame?.target)
94
123
  if (!target || !frame.html) return
95
124
  if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return
125
+ // Concurrent same-revision responses can carry the same frame. The target's
126
+ // own revision only advances once Turbo applies the stream, so what has
127
+ // already been applied is tracked here as well.
128
+ const applied = appliedRevisions.get(frame.target)
129
+ if (applied && !newerRevision(frame.revision, applied)) return
130
+
131
+ appliedRevisions.set(frame.target, frame.revision)
96
132
 
97
133
  const parsed = new DOMParser().parseFromString(frame.html, "text/html")
98
134
  const replacement = parsed.getElementById(frame.target)
@@ -43,10 +43,7 @@ module SolidObjects
43
43
  return head :conflict
44
44
  end
45
45
 
46
- authorization_context = SolidObjects
47
- .configuration
48
- .component_authorization_context
49
- .call(controller: self)
46
+ authorization_context = component_authorization_context(registrations)
50
47
  frames = registrations.map do |registration|
51
48
  rendered = ComponentRenderer.new(
52
49
  snapshot:,
@@ -112,10 +109,7 @@ module SolidObjects
112
109
  return head :conflict
113
110
  end
114
111
 
115
- authorization_context = SolidObjects
116
- .configuration
117
- .component_authorization_context
118
- .call(controller: self)
112
+ authorization_context = component_authorization_context([ registration ])
119
113
  rendered = ComponentRenderer.new(
120
114
  snapshot:,
121
115
  registration:,
@@ -138,6 +132,34 @@ module SolidObjects
138
132
  head :bad_request
139
133
  end
140
134
 
135
+ # Callbacks written before batching accept only `controller:`. Those keep
136
+ # working; a callback that also accepts `registrations:` receives one
137
+ # registration for a single refresh and all of them for a batch.
138
+ # @rbs (Array[ComponentRegistration]) -> untyped
139
+ def component_authorization_context(registrations)
140
+ callable = SolidObjects.configuration.component_authorization_context
141
+ return callable.call(controller: self) unless accepts_registrations?(callable)
142
+
143
+ callable.call(controller: self, registrations:)
144
+ end
145
+
146
+ # A lambda answers `parameters` directly; a callable object answers it
147
+ # through its `call` method.
148
+ # @rbs (untyped) -> bool
149
+ def accepts_registrations?(callable)
150
+ callable_parameters(callable).any? do |type, name|
151
+ type == :keyrest || (%i[key keyreq].include?(type) && name == :registrations)
152
+ end
153
+ end
154
+
155
+ # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
156
+ def callable_parameters(callable)
157
+ return callable.parameters if callable.respond_to?(:parameters)
158
+ return callable.method(:call).parameters if callable.respond_to?(:call)
159
+
160
+ []
161
+ end
162
+
141
163
  # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
142
164
  def registration_payload(registration)
143
165
  {
data/docs/realtime.md CHANGED
@@ -173,8 +173,11 @@ they are while giving the client a documented contract with per-frame revisions.
173
173
  ### What the protocol guarantees
174
174
 
175
175
  Only components whose dependencies changed are requested; the rest are never
176
- named in the batch. Duplicate notifications for the same batch and revision merge
177
- into one request, and a superseded request for the same batch is aborted. Each
176
+ named in the batch. Notifications for the same batch and revision that arrive in
177
+ one task merge into a single request. Notifications that arrive in separate
178
+ WebSocket messages issue their own requests and all of their frames are applied,
179
+ because cancelling a same-revision request would drop the components it carried.
180
+ Only a strictly newer revision supersedes an in-flight request. Each
178
181
  frame carries its own revision and cannot overwrite a target that already holds a
179
182
  newer one. Authorization is unchanged: every component in the batch passes the
180
183
  same `authorize_query` boundary an individual refresh uses, and the batch name is
@@ -290,6 +293,20 @@ commonly resolve it to `Current.user`:
290
293
  configuration.component_authorization_context = ->(controller:) { Current.user }
291
294
  ```
292
295
 
296
+ A callback may also accept `registrations:`, which receives one registration for
297
+ a single component refresh and every registration in the group for a batch
298
+ refresh. This avoids decoding `params[:tokens]` by hand when a policy depends on
299
+ which components were requested:
300
+
301
+ ```ruby
302
+ configuration.component_authorization_context = lambda do |controller:, registrations:|
303
+ Current.user if registrations.all? { |registration| registration.component_key == controller.session[:seat] }
304
+ end
305
+ ```
306
+
307
+ Callbacks that accept only `controller:` continue to work; the extra keyword is
308
+ passed only to callables that declare it.
309
+
293
310
  The three contexts are intentionally different:
294
311
 
295
312
  | Boundary | Authorization context |
data/docs/roadmap.md CHANGED
@@ -17,18 +17,26 @@
17
17
  - One-shot and recurring reminders with `:latest` or `:all` catch-up
18
18
  - Durable observable invalidations, scalar Turbo replacement, keyed ERB
19
19
  components, signed component locals, and authorized replace or morph refresh
20
+ - Batched component refreshes: components sharing a signed `batch:` collapse to
21
+ one browser request per revision, served as HTML frames in a JSON envelope
22
+ - Personalized state payload broadcasts computed per subscriber under that
23
+ subscriber's authorization context, fenced by actor revision
20
24
  - Reconciliation read APIs
21
25
  - Installation doctor, authorization reference, fit guide, and legacy-state
22
26
  migration cookbook
23
27
  - Handler Active Record write isolation, same-database commit actions, ambient
24
- transaction rejection, adapter lock/query deadlines, structured sync timeout
25
- diagnostics, and result recovery
28
+ transaction rejection, adapter lock/query deadlines, bounded SQLite lock
29
+ retries outside those deadlines, structured sync timeout diagnostics, and
30
+ result recovery
26
31
  - Bounded message/process pruning, actor-type opt-in instance expiration,
27
32
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
28
33
  helper
29
34
  - SQLite, PostgreSQL, and MySQL integration suites
30
35
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
31
36
  RuboCop policy, and a warning-free Brakeman scan
37
+ - A JavaScript suite covering the state payload and batched refresh browser
38
+ modules, run in CI with Node's test runner and jsdom, with every GitHub
39
+ Actions reference pinned to a commit SHA
32
40
 
33
41
  ## Partially implemented
34
42
 
@@ -36,14 +44,24 @@
36
44
  role or run periodic maintenance automatically.
37
45
  - Wake-up strategy: in-process signaling plus durable polling and injection are
38
46
  implemented; PostgreSQL `LISTEN/NOTIFY` and optional Redis adapters are not.
47
+ Signaling cannot cross process boundaries, so a commit in a web process does
48
+ not wake a broadcast executor in a worker process; that delivery waits up to
49
+ `polling_interval`, 100 ms by default. This is the largest remaining term in
50
+ reactive update latency, and neither batching nor state payloads reduce it.
39
51
  - Realtime: scalar and dependency-driven keyed ERB component replacement or
40
52
  morphing, personalized refresh authorization, revision fencing, coalescing,
41
- and reconnect convergence are implemented; application-directed Turbo
42
- append intents are not.
53
+ reconnect convergence, batched refreshes, and personalized state payloads are
54
+ implemented; application-directed Turbo append intents are not. Batch
55
+ coalescing happens in the browser rather than the broadcast executor, so one
56
+ commit still sends one Action Cable message per changed observable even
57
+ though it costs one browser request.
43
58
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
44
59
  distributed per-actor rate limits and global admission control do not.
45
60
  - Administration: actor and dead-letter views plus policy hooks exist; richer
46
61
  filtering, audit records, and bulk-safe tools do not.
62
+ - Browser module coverage: the state payload and batched refresh modules have
63
+ JavaScript tests; `component_refresh.js`, which drives individual morph
64
+ refreshes, does not.
47
65
  - Outboxes use portable status rows with polling indexes; future versions may
48
66
  introduce narrow ready/claimed membership tables for very large outboxes.
49
67
 
@@ -51,7 +69,8 @@
51
69
 
52
70
  1. Add automatic supervisor role replacement and periodic dead-process cleanup.
53
71
  2. Add PostgreSQL notification and optional Redis wake-up adapters with latency
54
- benchmarks and polling-race tests.
72
+ benchmarks and polling-race tests, removing the cross-process polling delay
73
+ rather than shrinking it with a smaller `polling_interval`.
55
74
  3. Add result lookup by request ID and broader deadlock retry classification.
56
75
  4. Add scheduled retention and stale-process maintenance.
57
76
  5. Add database/server-version checks and MySQL InnoDB verification at boot.
@@ -61,7 +80,9 @@
61
80
  8. Expand security scanning and run compatibility CI across supported Rails and
62
81
  Ruby versions.
63
82
  9. Benchmark all workloads under documented hardware/database settings and
64
- publish adapter-specific adoption measurements.
83
+ publish adapter-specific adoption measurements. Throughput, synchronous
84
+ latency, query counts, and the three reactive delivery paths are measured on
85
+ SQLite; adapter-specific and end-to-end browser measurements are not.
65
86
 
66
87
  No production-ready claim should be made until these hardening milestones have
67
88
  operational soak evidence.
@@ -32,6 +32,7 @@ module SolidObjects
32
32
  )
33
33
  view_context.render(
34
34
  partial: default_partial,
35
+ formats: [ :html ],
35
36
  locals: registration.locals.transform_keys(&:to_sym).merge(
36
37
  actor:,
37
38
  authorization_context:,
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.7.1"
4
+ VERSION = "0.7.3"
5
5
  end
@@ -21,6 +21,20 @@ module SolidObjects
21
21
  # @rbs (Hash[Symbol, untyped]) -> void
22
22
  def refresh: (Hash[Symbol, untyped]) -> void
23
23
 
24
+ # Callbacks written before batching accept only `controller:`. Those keep
25
+ # working; a callback that also accepts `registrations:` receives one
26
+ # registration for a single refresh and all of them for a batch.
27
+ # @rbs (Array[ComponentRegistration]) -> untyped
28
+ def component_authorization_context: (Array[ComponentRegistration]) -> untyped
29
+
30
+ # A lambda answers `parameters` directly; a callable object answers it
31
+ # through its `call` method.
32
+ # @rbs (untyped) -> bool
33
+ def accepts_registrations?: (untyped) -> bool
34
+
35
+ # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
36
+ def callable_parameters: (untyped) -> Array[[ Symbol, Symbol ]]
37
+
24
38
  # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
25
39
  def registration_payload: (ComponentRegistration) -> Hash[Symbol, untyped]
26
40
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.7.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson