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 +4 -4
- data/CHANGELOG.md +23 -0
- data/app/assets/javascripts/solid_objects/component_batch_refresh.js +43 -7
- data/app/controllers/solid_objects/components_controller.rb +30 -8
- data/docs/realtime.md +19 -2
- data/docs/roadmap.md +27 -6
- data/lib/solid_objects/component_renderer.rb +1 -0
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/controllers/solid_objects/components_controller.rbs +14 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de78ca94f2c12e3c08d50c7b2512d6dbda67a47571540390c7494c6a828f1d35
|
|
4
|
+
data.tar.gz: b194c137ff3110cabb6cb2f7319819fdd64f314a9ae2113868438a89acce36b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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,
|
|
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(
|
|
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 =
|
|
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 =
|
|
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.
|
|
177
|
-
|
|
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,
|
|
25
|
-
diagnostics, and
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
|