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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05e9525058173a0392999802c6243cac3eb5c0406a28cb405963a9069d20b098
4
- data.tar.gz: 3394af2d34854137a4032dd672bb0e1ebc6073e907872b168a5e31e9ffea1daf
3
+ metadata.gz: ef549275f108c037de78cad7dcabb5c3b3a0dfae666d6a56e413abd4bc1079f1
4
+ data.tar.gz: cb55611cd29a96e7fd79885b5e7575534f5f19e4ea417c745c1e8b13da9df59d
5
5
  SHA512:
6
- metadata.gz: 936fa88cb0cd6dc55665e9214d54064eb914863552bf8b84e58e1ca4d616751863f9572740ceb00c7b09503ed9996fe9ec52640a65279b2bc5249498d3671684
7
- data.tar.gz: 1b9a73d2e21d3ac4e5138feeb1c13c958f9b4eccffeb7968c0f7fbc28a45cd24b8dff7f6ab4e2f976730536dcee3fc30758cae5aaa04c4bd8dd3940ec83b5ef6
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 meant to be a local-first solution. It has no automatic capability to sync updates back to the server. For now, think of it as an alternative to Turbo Streams built with component frameworks (Vue, React, Svelte, etc) in mind.
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
- > Full synchronization utilizing event streams will arrive in DexieCable 3.0.
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
- DexieCable gives your ActionCable channel a query DSL that mirrors the Dexie.js API, 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.
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. `record` is a record for private
133
- # streams, or the stream name (String) for public streams.
134
- def subscribed_to(record, params)
135
- case record
136
- when User
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
- Message.find(data["id"]).update!(read: true)
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 the snapshot arrives before any live mutation. Custom actions are triggered like any ActionCable action: `subscription.perform("mark_as_read", { id: 42 })`.
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
@@ -7,12 +7,18 @@ module DexieCable
7
7
  # class DexieChannel < ApplicationCable::Channel
8
8
  # include DexieCable
9
9
  #
10
- # # Push initial data when a private stream is added.
11
- # def subscribed_to(record, params)
12
- # case record
13
- # when User
14
- # table("notifications").bulkAdd(record.notifications.map(&:as_json_for_dexie))
15
- # end
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
- record = resolve_subscribe_target(data["stream"])
103
+ token = data["stream"].to_s
76
104
  params = data.except("action", "stream").with_indifferent_access
77
105
 
78
- if record
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(record, params)
113
+ record.subscribed_to(self, params) if record.respond_to?(:subscribed_to)
81
114
  else
82
- name = data["stream"].to_s
83
- return if name.blank?
115
+ return if token.blank?
84
116
 
85
- stream_from public_stream_name(name)
86
- subscribed_to(name, params)
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
- record = resolve_subscribe_target(data["stream"])
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
- name = data["stream"].to_s
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
- # Override to push initial data when a stream is added. +record+ is the
106
- # resolved target — a record for private streams, or the stream name for
107
- # public streams — and +params+ are any extra params sent from the client.
108
- def subscribed_to(_record, _params)
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
- private
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}")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DexieCable
4
- VERSION = "2.0.1"
4
+ VERSION = "3.0.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dexiecable
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Buhrmester