dexiecable 2.0.1 → 3.0.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.
- checksums.yaml +4 -4
- data/README.md +99 -17
- data/lib/dexiecable/active_record_ext.rb +45 -0
- data/lib/dexiecable/concern.rb +66 -22
- data/lib/dexiecable/version.rb +1 -1
- 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: ef549275f108c037de78cad7dcabb5c3b3a0dfae666d6a56e413abd4bc1079f1
|
|
4
|
+
data.tar.gz: cb55611cd29a96e7fd79885b5e7575534f5f19e4ea417c745c1e8b13da9df59d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2be1d8dcf416c79e83c6227762877c339e9bdb227956108f5f8ce80327b189c25a1c7ac9f974b330e1bf1f50dd058b3aa09ad2399f1e2642d58ed43622243b85
|
|
7
|
+
data.tar.gz: 8c2f011b0381a44ce0c5bc55ca0eeecd0beda02a359f16022770862d54b2710f6076a7388437f11d3ec673a115c8b4595fb9862332e2f3cd055fd8aaede8110b
|
data/README.md
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# DexieCable
|
|
2
2
|
|
|
3
3
|
> [!NOTE]
|
|
4
|
-
> DexieCable is NOT
|
|
4
|
+
> By itself, DexieCable is NOT a local-first solution. It has no automatic capability to push client-side changes back to the server.
|
|
5
5
|
>
|
|
6
|
-
>
|
|
6
|
+
> An addon providing full synchronization based on event streams is currently in development. But for now, if you need full synchronization, you'll have to roll your own.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## Who's this for?
|
|
9
|
+
|
|
10
|
+
DexieCable is made for Ruby on Rails apps that manage their client-side state in [Dexie](https://dexie.org) and use Dexie live queries for reactive UI updates. It's an alternative to Turbo Streams for apps that opt to use component frameworks (React, Vue, Svelte, etc.) instead of Turbo.
|
|
11
|
+
|
|
12
|
+
## How does it work?
|
|
13
|
+
|
|
14
|
+
DexieCable gives your ActionCable channel a query DSL that mirrors the [Dexie.js API](https://dexie.org/docs), letting you push database mutations from the server to the client in real time. It also gives you a [`syncs_to_dexie`](#syncs_to_dexie-automatic-model-streaming) ActiveRecord macro for automatic change syncing.
|
|
9
15
|
|
|
10
16
|
Push Dexie table updates to a client from anywhere on the server:
|
|
11
17
|
|
|
@@ -26,6 +32,53 @@ class Notification < ApplicationRecord
|
|
|
26
32
|
end
|
|
27
33
|
```
|
|
28
34
|
|
|
35
|
+
## What's new in 2.0
|
|
36
|
+
|
|
37
|
+
### Custom Channels
|
|
38
|
+
|
|
39
|
+
DexieCable 2.0 is a mixin. Create your own `DexieChannel` and `include DexieCable`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class DexieChannel < ApplicationCable::Channel
|
|
43
|
+
include DexieCable
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Reuse the same subscription for multiple streams
|
|
48
|
+
|
|
49
|
+
On the client, subscribe to that channel and add streams as needed. `addStream` accepts a signed token or a plain string, and returns a function that removes the stream:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const subscription = subscribe(db);
|
|
53
|
+
const unsubscribe = subscription.addStream(userStreamToken);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## What's new in 3.0
|
|
57
|
+
|
|
58
|
+
### `subscribed_to` moved to the model
|
|
59
|
+
|
|
60
|
+
The `subscribed_to` hook now lives on the record rather than on the channel. Private streams declare their initial snapshot on the record the token was issued for, and receive the channel plus any params sent from the client:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
class User < ApplicationRecord
|
|
64
|
+
subscribed_to do |channel, params|
|
|
65
|
+
channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Public streams register a handler by name in your channel. The block runs in the channel's context, so `table` and `transmit` are available:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class DexieChannel < ApplicationCable::Channel
|
|
74
|
+
include DexieCable
|
|
75
|
+
|
|
76
|
+
subscribed_to "feed" do |params|
|
|
77
|
+
table("announcements").bulkAdd(Announcement.for_stream("feed").map(&:as_json_for_dexie))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
29
82
|
## Installation
|
|
30
83
|
|
|
31
84
|
### Ruby gem
|
|
@@ -101,6 +154,8 @@ Tokens never expire by default. Pass `expires_in:` or `expires_at:` to limit a t
|
|
|
101
154
|
DexieChannel.stream_token_for(current_user, expires_in: 1.day)
|
|
102
155
|
```
|
|
103
156
|
|
|
157
|
+
An expired token, or one whose record no longer exists, is rejected. Only plain strings are treated as public stream names.
|
|
158
|
+
|
|
104
159
|
Send that token to the client (render it in a view, return it from an endpoint, etc.) and add it to the subscription:
|
|
105
160
|
|
|
106
161
|
```js
|
|
@@ -129,22 +184,46 @@ Add custom actions or push initial data directly on your channel:
|
|
|
129
184
|
class DexieChannel < ApplicationCable::Channel
|
|
130
185
|
include DexieCable
|
|
131
186
|
|
|
132
|
-
# Push a snapshot when a stream is added.
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
table("notifications").bulkAdd(record.notifications.map(&:as_json_for_dexie))
|
|
138
|
-
when Conversation
|
|
139
|
-
table("messages").bulkAdd(record.messages.where("seq_id > ?", params[:last_seq_id]).map(&:as_json_for_dexie))
|
|
140
|
-
when String
|
|
141
|
-
table(record).bulkAdd(Announcement.for_stream(record).map(&:as_json_for_dexie))
|
|
142
|
-
end
|
|
187
|
+
# Push a snapshot when a public stream is added. The block receives the
|
|
188
|
+
# params sent from the client and runs in the channel's context, so
|
|
189
|
+
# `table(...)` and `transmit` are available.
|
|
190
|
+
subscribed_to "feed" do |params|
|
|
191
|
+
table("feed").bulkAdd(Announcement.for_stream("feed").map(&:as_json_for_dexie))
|
|
143
192
|
end
|
|
144
193
|
|
|
145
|
-
# Any public method is a custom action the client can perform.
|
|
194
|
+
# Any public method is a custom action the client can perform. Always
|
|
195
|
+
# resolve records through the connection's actor, never from the payload:
|
|
196
|
+
# `Message.find(data["id"])` would let any client mark anyone's message
|
|
197
|
+
# read.
|
|
146
198
|
def mark_as_read(data)
|
|
147
|
-
|
|
199
|
+
current_user.messages.find(data["id"]).update!(read: true)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Private streams declare their snapshot on the record itself, which is where the stream was opened for. The block runs in the record's context and receives the channel — so `channel.table(...)` transmits to just this subscriber — plus the params sent from the client:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
class User < ApplicationRecord
|
|
208
|
+
subscribed_to do |channel, params|
|
|
209
|
+
channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
class Conversation < ApplicationRecord
|
|
214
|
+
subscribed_to do |channel, params|
|
|
215
|
+
channel.table("messages").bulkAdd(messages.where("seq_id > ?", params[:last_seq_id]).map(&:as_json_for_dexie))
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Declare `subscribed_to` more than once to push several snapshots — the blocks run in declaration order. To share a snapshot across models, or take full control, override the instance method instead and call `super` to keep any registered blocks running:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
module HasNotifications
|
|
224
|
+
def subscribed_to(channel, params)
|
|
225
|
+
channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
|
|
226
|
+
super
|
|
148
227
|
end
|
|
149
228
|
end
|
|
150
229
|
```
|
|
@@ -161,7 +240,7 @@ Pass params from the client when adding a stream:
|
|
|
161
240
|
subscription.addStream(userStream, { last_seq_id: 100 });
|
|
162
241
|
```
|
|
163
242
|
|
|
164
|
-
`subscribed_to` runs after the stream is opened, and `table(...)` transmits to just this subscriber, so
|
|
243
|
+
`subscribed_to` runs after the stream is opened, and the snapshot it pushes (via `table(...)` on the channel, or `channel.table(...)` on the record) transmits to just this subscriber, so it arrives before any live mutation. Custom actions are triggered like any ActionCable action: `subscription.perform("mark_as_read", { id: 42 })`. The payload is client-controlled, so pass it through the authenticated actor — `current_user.messages.find(data["id"])`, not `Message.find(data["id"])`.
|
|
165
244
|
|
|
166
245
|
#### Public streams
|
|
167
246
|
|
|
@@ -228,6 +307,9 @@ Add to any ActiveRecord model. Optionally provide the broadcast target.
|
|
|
228
307
|
|
|
229
308
|
```ruby
|
|
230
309
|
class Message < ApplicationRecord
|
|
310
|
+
belongs_to :conversation
|
|
311
|
+
belongs_to :receiver
|
|
312
|
+
|
|
231
313
|
# Calls send(:receiver), then broadcasts: DexieChannel.broadcast_to(receiver, ...)
|
|
232
314
|
syncs_to_dexie via: :receiver
|
|
233
315
|
|
|
@@ -70,6 +70,51 @@ module DexieCable
|
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
|
+
|
|
74
|
+
# Declares what to push to a single subscriber when they subscribe to
|
|
75
|
+
# this record's stream, before any live mutation is delivered. The block
|
|
76
|
+
# runs in the record's context and receives the channel and the params
|
|
77
|
+
# sent by the client:
|
|
78
|
+
#
|
|
79
|
+
# class User < ApplicationRecord
|
|
80
|
+
# subscribed_to do |channel, params|
|
|
81
|
+
# channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
|
|
82
|
+
# end
|
|
83
|
+
# end
|
|
84
|
+
#
|
|
85
|
+
# Declare it more than once to push several snapshots; blocks run in
|
|
86
|
+
# declaration order.
|
|
87
|
+
def subscribed_to(&block)
|
|
88
|
+
raise ArgumentError, "subscribed_to requires a block" if block.nil?
|
|
89
|
+
|
|
90
|
+
dexie_subscribed_to_blocks << block
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The blocks registered with +subscribed_to+, inherited from
|
|
94
|
+
# superclasses.
|
|
95
|
+
def dexie_subscribed_to_blocks
|
|
96
|
+
@dexie_subscribed_to_blocks ||=
|
|
97
|
+
if superclass.respond_to?(:dexie_subscribed_to_blocks)
|
|
98
|
+
superclass.dexie_subscribed_to_blocks.dup
|
|
99
|
+
else
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Called when a client subscribes to this record's private stream. Runs
|
|
106
|
+
# every block registered with the +subscribed_to+ macro in the record's
|
|
107
|
+
# context. Override in your model for full control, calling +super+ if you
|
|
108
|
+
# still want the registered blocks to run:
|
|
109
|
+
#
|
|
110
|
+
# def subscribed_to(channel, params)
|
|
111
|
+
# channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
|
|
112
|
+
# super
|
|
113
|
+
# end
|
|
114
|
+
def subscribed_to(channel, params)
|
|
115
|
+
self.class.dexie_subscribed_to_blocks.each do |block|
|
|
116
|
+
instance_exec(channel, params, &block)
|
|
117
|
+
end
|
|
73
118
|
end
|
|
74
119
|
|
|
75
120
|
private
|
data/lib/dexiecable/concern.rb
CHANGED
|
@@ -7,12 +7,18 @@ module DexieCable
|
|
|
7
7
|
# class DexieChannel < ApplicationCable::Channel
|
|
8
8
|
# include DexieCable
|
|
9
9
|
#
|
|
10
|
-
# # Push initial data when a
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
10
|
+
# # Push initial data when a public stream is added. The block runs in
|
|
11
|
+
# # the channel's context, so +table+ and +transmit+ are available.
|
|
12
|
+
# subscribed_to "feed" do |params|
|
|
13
|
+
# table("announcements").bulkAdd(Announcement.for_stream("feed").map(&:as_json_for_dexie))
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Private streams push their initial data from the record itself:
|
|
18
|
+
#
|
|
19
|
+
# class User < ApplicationRecord
|
|
20
|
+
# def subscribed_to(channel, params)
|
|
21
|
+
# channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
|
|
16
22
|
# end
|
|
17
23
|
# end
|
|
18
24
|
#
|
|
@@ -41,6 +47,28 @@ module DexieCable
|
|
|
41
47
|
ScopedChannel.new(self, target)
|
|
42
48
|
end
|
|
43
49
|
|
|
50
|
+
# Register a handler for a public stream. When a client subscribes to
|
|
51
|
+
# +name+ (for example +subscription.addStream("feed")+), the block is
|
|
52
|
+
# evaluated in the channel's context with the params sent by the client:
|
|
53
|
+
#
|
|
54
|
+
# subscribed_to "feed" do |params|
|
|
55
|
+
# table("announcements").bulkAdd(Announcement.for_stream("feed"))
|
|
56
|
+
# end
|
|
57
|
+
def subscribed_to(name, &block)
|
|
58
|
+
dexie_subscribed_to_handlers[name.to_s] = block
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The registered public stream handlers. Subclasses inherit the handlers
|
|
62
|
+
# registered on their superclass.
|
|
63
|
+
def dexie_subscribed_to_handlers
|
|
64
|
+
@dexie_subscribed_to_handlers ||=
|
|
65
|
+
if superclass.respond_to?(:dexie_subscribed_to_handlers)
|
|
66
|
+
superclass.dexie_subscribed_to_handlers.dup
|
|
67
|
+
else
|
|
68
|
+
{}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
44
72
|
# Returns a signed token for +target+ (a record). Send it to the client,
|
|
45
73
|
# which passes it to +subscription.addStream+.
|
|
46
74
|
#
|
|
@@ -72,29 +100,35 @@ module DexieCable
|
|
|
72
100
|
end
|
|
73
101
|
|
|
74
102
|
def add_stream(data)
|
|
75
|
-
|
|
103
|
+
token = data["stream"].to_s
|
|
76
104
|
params = data.except("action", "stream").with_indifferent_access
|
|
77
105
|
|
|
78
|
-
if
|
|
106
|
+
if signed_token?(token)
|
|
107
|
+
# A token that no longer resolves (expired, revoked, deleted record) is
|
|
108
|
+
# rejected instead of being treated as a public stream name.
|
|
109
|
+
record = resolve_subscribe_target(token)
|
|
110
|
+
return unless record
|
|
111
|
+
|
|
79
112
|
stream_for record
|
|
80
|
-
subscribed_to(
|
|
113
|
+
record.subscribed_to(self, params) if record.respond_to?(:subscribed_to)
|
|
81
114
|
else
|
|
82
|
-
|
|
83
|
-
return if name.blank?
|
|
115
|
+
return if token.blank?
|
|
84
116
|
|
|
85
|
-
stream_from public_stream_name(
|
|
86
|
-
|
|
117
|
+
stream_from public_stream_name(token)
|
|
118
|
+
run_subscribed_to_handler(token, params)
|
|
87
119
|
end
|
|
88
120
|
end
|
|
89
121
|
|
|
90
122
|
def remove_stream(data)
|
|
91
|
-
|
|
123
|
+
token = data["stream"].to_s
|
|
124
|
+
|
|
125
|
+
if signed_token?(token)
|
|
126
|
+
record = resolve_subscribe_target(token)
|
|
127
|
+
return unless record
|
|
92
128
|
|
|
93
|
-
if record
|
|
94
129
|
stop_stream_from self.class.broadcasting_for(record)
|
|
95
130
|
else
|
|
96
|
-
|
|
97
|
-
stop_stream_from public_stream_name(name) if name.present?
|
|
131
|
+
stop_stream_from public_stream_name(token) if token.present?
|
|
98
132
|
end
|
|
99
133
|
end
|
|
100
134
|
|
|
@@ -102,13 +136,23 @@ module DexieCable
|
|
|
102
136
|
stop_all_streams
|
|
103
137
|
end
|
|
104
138
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
#
|
|
108
|
-
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# Runs the handler registered with +subscribed_to+ for a public stream, in
|
|
142
|
+
# the channel's context.
|
|
143
|
+
def run_subscribed_to_handler(name, params)
|
|
144
|
+
handler = self.class.dexie_subscribed_to_handlers[name]
|
|
145
|
+
instance_exec(params, &handler) if handler
|
|
109
146
|
end
|
|
110
147
|
|
|
111
|
-
|
|
148
|
+
# Distinguishes a signed stream token from a public stream name. A signed
|
|
149
|
+
# token keeps a valid signature even after it expires, which is what lets us
|
|
150
|
+
# reject expired tokens instead of falling back to a public stream.
|
|
151
|
+
def signed_token?(token)
|
|
152
|
+
return false if token.blank? || SignedGlobalID.verifier.nil?
|
|
153
|
+
|
|
154
|
+
SignedGlobalID.verifier.valid_message?(token)
|
|
155
|
+
end
|
|
112
156
|
|
|
113
157
|
def public_stream_name(name)
|
|
114
158
|
self.class.broadcasting_for("#{PUBLIC_STREAM_PREFIX}#{name}")
|
data/lib/dexiecable/version.rb
CHANGED