dexiecable 0.1.6 → 0.1.8

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: 9b4a75838a91bf8b9ef988b1b2ef14f90e2148df75d80b15127487bb3b9efb8e
4
- data.tar.gz: 99ca5b8a8cf6a4b12eca7d54753b0de8c35dc699c22bb172a62b7ca826fa9f6a
3
+ metadata.gz: 2bc64f029d73e3630a14e254852ea7e6b3cf2b00d369f144c0b40ebdd6d4de1f
4
+ data.tar.gz: ea3df61fd1319c5f8fcc8697e99a7954213b5c4f97748ae55e3e012e00f156ac
5
5
  SHA512:
6
- metadata.gz: d77d14a92756f3a05c4c038f93f542953036d3df2e010952659a4833a48997fef2fcf4d145360f03d684a40b8fc25715a3aaf8f5624ff0f6bc3934568ff5806c
7
- data.tar.gz: ae7f67b4bf0ab26883afd3ba7c17fc944b6f7fc66f0f862c0c6cec6ff9d494ed749b2436e47d80898dc86d11ed1c44235e679e68b4ff3e9c1b662552e2536b59
6
+ metadata.gz: f44765e378f1860f5a239cc58169c724bc164c9eada457241a7e70f8cfcbe8ef898e2357d0dfa6981f1a6b3b170f9852a5afdb06e0dee935f7deea0489e568c2
7
+ data.tar.gz: f1227ada19e9a7ccb9825bb9665ec8f7c57be8afc4b05e9f8dd35c07d8da46f92cfb9cebdb74d5b7e5ef3fe87e3a549e14f039ba4412e4f373706e6e4535a318
data/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # DexieCable
2
2
 
3
- Run [Dexie.js](https://dexie.org) IndexedDB operations from your Rails ActionCable channels.
3
+ > [!NOTE]
4
+ > DexieCable is NOT a local-first solution, because it lacks the capability to automatically sync updates back to the server (this might be added later). For now, think of it as a real-time local-cache solution. Also, check out this [blog post introducing DexieCable](https://dev.to/buhrmi/real-time-rails-without-turbo-modern-reactive-uis-with-inertia-and-dexiecable-4lge).
4
5
 
5
- DexieCable augments ActionCable channels with 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` ActiveRecord macro for automatic change syncing.
6
+ DexieCable augments ActionCable channels with 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-syncing) ActiveRecord macro for automatic change syncing.
6
7
 
7
8
  You can run any Dexie table update directly inside a channel:
8
9
 
@@ -29,11 +30,11 @@ class NotificationsController < ApplicationController
29
30
  end
30
31
  ```
31
32
 
32
- An even more convenient way is to use the `syncs_to_dexie` macro:
33
+ An even more convenient way is to use the `syncs_to_dexie` macro (more info [below](#syncs_to_dexie--automatic-model-syncing))
33
34
 
34
35
  ```ruby
35
36
  class Notification < ApplicationRecord
36
- syncs_to_dexie via: UserChannel, subject: :user
37
+ syncs_to_dexie via: UserChannel, to: :user
37
38
  end
38
39
  ```
39
40
 
@@ -57,19 +58,26 @@ npm install dexiecable
57
58
  yarn add dexiecable
58
59
  ```
59
60
 
60
- Configure it with your Dexie database and ActionCable consumer before subscribing:
61
+ Pass your Dexie database as the first argument to `subscribe()`:
61
62
 
62
63
  ```js
63
- import { configure, subscribe } from "dexiecable";
64
+ import { subscribe } from "dexiecable";
64
65
  import { db } from "./db";
65
- import { createConsumer } from "@rails/actioncable";
66
66
 
67
- configure({ db, consumer: createConsumer() });
68
-
69
- const sub = subscribe("UserChannel", { last_update: Date.now() });
67
+ subscribe(db, "UserChannel");
70
68
  ```
71
69
 
72
- If you omit `consumer`, one is created for you automatically.
70
+ A consumer is lazily created on the first `subscribe()` call. If you need to access or set the consumer explicitly, use `getConsumer()` and `setConsumer()`:
71
+
72
+ ```js
73
+ import { getConsumer, setConsumer, createConsumer } from "dexiecable";
74
+
75
+ // Get the consumer (creates one lazily if needed)
76
+ const consumer = getConsumer();
77
+
78
+ // Or set a custom one
79
+ setConsumer(createConsumer("wss://example.com/cable"));
80
+ ```
73
81
 
74
82
  ## Usage
75
83
 
@@ -89,7 +97,7 @@ This gives you:
89
97
 
90
98
  | Method | Description |
91
99
  |---|---|
92
- | `self.[](subject)` | Returns a `ScopedChannel` bound to a subject. `UserChannel[current_user]` |
100
+ | `self.[](to)` | Returns a `ScopedChannel` bound to a recipient. `UserChannel[current_user]` |
93
101
  | `table(name)` | Starts a query chain. `table("messages")` |
94
102
 
95
103
  ### Chaining Dexie operations
@@ -125,36 +133,39 @@ The full query chain is serialized as JSON and sent over ActionCable. The JS cli
125
133
 
126
134
  ### `syncs_to_dexie` — automatic model syncing
127
135
 
128
- Add to any ActiveRecord model. `subject` is what gets passed to `broadcast_to` as the stream target:
136
+ Add to any ActiveRecord model. Just provide the channel class and the broadcast target.
129
137
 
130
138
  ```ruby
131
139
  class Message < ApplicationRecord
132
- # Calls send(:receiver), then broadcasts: broadcast_to(receiver, ...)
133
- syncs_to_dexie via: UserChannel, subject: :receiver
140
+ # Calls send(:receiver), then broadcasts: UserChannel.broadcast_to(receiver, ...)
141
+ syncs_to_dexie via: UserChannel, to: :receiver
134
142
 
135
- # String used directly: broadcast_to("global_feed", ...)
136
- syncs_to_dexie via: UserChannel, subject: "global_feed"
143
+ # String used directly: UserChannel.broadcast_to("global_feed", ...)
144
+ syncs_to_dexie via: UserChannel, to: "global_feed"
137
145
 
138
146
  # Procs are also supported. If an array is returned, multiple broadcasts are made
139
- # conversation.users.each { |u| broadcast_to(u, ...) }
140
- syncs_to_dexie via: UserChannel, subject: -> { conversation.users }
147
+ # conversation.users.each { |u| UserChannel.broadcast_to(u, ...) }
148
+ syncs_to_dexie via: UserChannel, to: -> { conversation.users }
141
149
  end
142
150
  ```
143
151
 
152
+ Internally, `syncs_to_dexie` sets up the following ActiveRecord callbacks:
153
+
144
154
  | Event | Action |
145
155
  |---|---|
146
- | `after_commit on: :create` | `channel.table("messages").add(record.as_json_for_dexie)` |
147
- | `after_commit on: :update` | `channel.table("messages").put(record.as_json_for_dexie)` |
148
- | `after_commit on: :destroy` | `channel.table("messages").delete(record.id)` |
156
+ | `after_commit on: :create` | `channel.table(table).add(as_json_for_dexie)` |
157
+ | `after_commit on: :update` | `channel.table(table).update(id, as_json_for_dexie.slice(*saved_changes.keys))` |
158
+ | `after_commit on: :destroy` | `channel.table(table).delete(id)` |
149
159
 
150
160
  #### Options
151
161
 
152
162
  | Option | Default | Description |
153
163
  |---|---|---|
154
164
  | `via:` | *(required)* | A DexieCable channel class |
155
- | `subject:` | *(none)* | The stream target passed to `broadcast_to`. Symbol → calls `send`. String → used as-is. Proc → evaluated in record context. Returns a single subject or collection. |
165
+ | `to:` | *(none)* | The stream target passed to `broadcast_to`. Symbol → calls `send`. String → used as-is. Proc → evaluated in record context. Returns a single recipient or collection. |
156
166
  | `table:` | model's `table_name` | Override the Dexie table name. A Proc is evaluated in the record's context. |
157
167
  | `only:` | `[:create, :update, :destroy]` | Limit which events trigger a sync |
168
+ | `with:` | `:as_json_for_dexie` | Method name (Symbol) or Proc for serializing records |
158
169
  | `if:` | *(none)* | Symbol (method name) or Proc — only sync when it returns truthy |
159
170
  | `unless:` | *(none)* | Symbol (method name) or Proc — skip sync when it returns truthy |
160
171
 
@@ -162,7 +173,7 @@ You can combine multiple `syncs_to_dexie` declarations, each with different cond
162
173
 
163
174
  ```ruby
164
175
  class Message < ApplicationRecord
165
- syncs_to_dexie via: UserChannel, subject: -> { sender },
176
+ syncs_to_dexie via: UserChannel, to: -> { sender },
166
177
  if: :published?
167
178
 
168
179
  syncs_to_dexie via: AdminChannel,
@@ -172,15 +183,28 @@ end
172
183
 
173
184
  #### Customizing the synced payload
174
185
 
175
- Override `as_json_for_dexie` in your model:
186
+ Override `as_json_for_dexie` in your model, or use the `with` option to specify a different method or Proc:
176
187
 
177
188
  ```ruby
178
189
  class Message < ApplicationRecord
179
- syncs_to_dexie via: UserChannel, subject: :sender
190
+ # Using the default as_json_for_dexie override:
191
+ syncs_to_dexie via: UserChannel, to: :sender
180
192
 
181
193
  def as_json_for_dexie
182
194
  super.merge(room_name: room.name)
183
195
  end
196
+
197
+ # Or use a custom serializer method:
198
+ syncs_to_dexie via: AdminChannel, to: :admin,
199
+ with: :admin_payload
200
+
201
+ def admin_payload
202
+ attributes.slice("id", "body", "flagged")
203
+ end
204
+
205
+ # Or a Proc:
206
+ syncs_to_dexie via: PublicChannel,
207
+ with: -> { { id: id, summary: body.truncate(100) } }
184
208
  end
185
209
  ```
186
210
 
@@ -221,6 +245,60 @@ The JS side replays it as:
221
245
  dexie.messages.where("room_id").equals(5).add({ id: 1, text: "hello" })
222
246
  ```
223
247
 
248
+ ## Recipies
249
+
250
+ ### Use sequence IDs to avoid data loss
251
+
252
+ A common pattern to avoid data loss during transient disconnections is using sequence IDs to bridge the offline gap. When a connection drops, updates continue on the server. Sending the client’s latest known sequence ID upon reconnect allows the backend to query and stream only the records missed while offline.
253
+
254
+ To enable this, DexieCable ships its own version of the ActionCable client with one key
255
+ extension: **channel params can be functions**. When a param value is a
256
+ function, it is called and awaited at subscribe time — use this to submit the latest known sequence ID on connection:
257
+
258
+ ```js
259
+ import { subscribe } from "dexiecable";
260
+ import { db, getLastSeqId } from './database';
261
+
262
+ const roomId = 123;
263
+
264
+ subscribe(db, {
265
+ channel: "RoomChannel",
266
+ room_id: roomId,
267
+ seq_id: () => getLastSeqId(roomId) // evaluated fresh on each reconnect
268
+ });
269
+ ```
270
+
271
+ Send missed messages on reconnection:
272
+
273
+ ```ruby
274
+ class RoomChannel < ApplicationChannel:Base
275
+ def subscribed
276
+ stream_from "room:#{params[:room_id]}"
277
+ missed_messages = room.messages.where("seq_id > ?", params[:seq_id])
278
+ table("messages").bulkAdd(missed_messages)
279
+ end
280
+ end
281
+ ```
282
+
283
+ You might want to have look at the [Sequenced](https://github.com/derrickreimer/sequenced) gem to automatically add sequence IDs to your records.
284
+
285
+ ### Multi-user environments
286
+
287
+ In multi-user or multi-tenant applications, you can isolate records by binding different subscription channels to separate Dexie database instances. This prevents local data leaks between user accounts and keeps private user data separate from public or shared feeds.
288
+
289
+ ```js
290
+
291
+ import Dexie from 'dexie'
292
+ import { subscribe } from 'dexiecable'
293
+
294
+ const userDB = new Dexie("user_"+userId)
295
+ const sharedDB = new Dexie("shared")
296
+
297
+ subscribe(userDB, 'UserChannel')
298
+ subscribe(sharedDB, 'PublicChannel')
299
+
300
+ ```
301
+
224
302
  ## License
225
303
 
226
304
  MIT
@@ -9,43 +9,46 @@ module DexieCable
9
9
  # DexieCable channel.
10
10
  #
11
11
  # class Message < ApplicationRecord
12
- # syncs_to_dexie via: UserChannel, subject: :sender
13
- # syncs_to_dexie via: UserChannel, subject: "global_feed"
14
- # syncs_to_dexie via: UserChannel, subject: -> { conversation.users }
12
+ # syncs_to_dexie via: UserChannel, to: :sender
13
+ # syncs_to_dexie via: UserChannel, to: "global_feed"
14
+ # syncs_to_dexie via: UserChannel, to: -> { conversation.users }
15
15
  # syncs_to_dexie via: PublicChannel
16
16
  # end
17
17
  #
18
- # @param via [Class] A DexieCable channel class. When +subject+
19
- # is given, each subject is mapped through
20
- # +via[subject]+ to produce scoped channels.
21
- # Without +subject+, +via+ is used directly as an
18
+ # @param via [Class] A DexieCable channel class. When +to+
19
+ # is given, each recipient is mapped through
20
+ # +via[to]+ to produce scoped channels.
21
+ # Without +to+, +via+ is used directly as an
22
22
  # unscoped channel.
23
- # @param subject [Proc, Symbol, String] A Proc evaluated in record
23
+ # @param to [Proc, Symbol, String] A Proc evaluated in record
24
24
  # context, a Symbol to call via +send+, or a String
25
25
  # used directly as the stream name for +broadcast_to+.
26
- # Must return a single subject or collection of
27
- # subjects.
26
+ # Must return a single recipient or collection of
27
+ # recipients.
28
28
  # @param table [String, Symbol, Proc] Override the Dexie table name
29
29
  # (defaults to the model's table_name). A Proc is
30
30
  # evaluated in the record's context.
31
31
  # @param only [Array<Symbol>] Limit which lifecycle events sync.
32
32
  # Default: [:create, :update, :destroy].
33
+ # @param with [Symbol, Proc] Method name or proc to use for
34
+ # serializing records (defaults to :as_json_for_dexie).
33
35
  # @param if [Symbol, Proc] Only sync if the given method or proc
34
36
  # returns truthy (evaluated in the record's context).
35
37
  # @param unless [Symbol, Proc] Skip sync if the given method or proc
36
38
  # returns truthy (evaluated in the record's context).
37
- def syncs_to_dexie(via:, subject: nil, table: nil, only: nil, **options)
39
+ def syncs_to_dexie(via:, to: nil, table: nil, only: nil, with: nil, **options)
38
40
  events = Array(only || %i[create update destroy])
39
41
  conditions = options.slice(:if, :unless)
42
+ serializer = with || :as_json_for_dexie
40
43
 
41
44
  @dexie_sync_configs ||= []
42
- @dexie_sync_configs << { via: via, subject: subject, table: table, only: events, **conditions }
45
+ @dexie_sync_configs << { via: via, to: to, table: table, only: events, with: serializer, **conditions }
43
46
 
44
47
  if events.include?(:destroy)
45
48
  before_destroy :dexie_sync_before_destroy
46
49
 
47
50
  after_commit on: :destroy, **conditions do
48
- Array(resolve_channel(via, subject)).each do |channel|
51
+ Array(resolve_channel(via, to)).each do |channel|
49
52
  next unless channel
50
53
  channel.table(resolve_table(table)).delete(dexie_destroy_id)
51
54
  end
@@ -54,19 +57,19 @@ module DexieCable
54
57
 
55
58
  if events.include?(:create)
56
59
  after_commit on: :create, **conditions do
57
- Array(resolve_channel(via, subject)).each do |channel|
60
+ Array(resolve_channel(via, to)).each do |channel|
58
61
  next unless channel
59
- channel.table(resolve_table(table)).add(as_json_for_dexie)
62
+ channel.table(resolve_table(table)).add(serialize_record(serializer))
60
63
  end
61
64
  end
62
65
  end
63
66
 
64
67
  if events.include?(:update)
65
68
  after_commit on: :update, **conditions do
66
- Array(resolve_channel(via, subject)).each do |channel|
69
+ Array(resolve_channel(via, to)).each do |channel|
67
70
  next unless channel
68
71
 
69
- changes = as_json_for_dexie.slice(*saved_changes.keys)
72
+ changes = serialize_record(serializer).slice(*saved_changes.keys)
70
73
  channel.table(resolve_table(table)).update(id, changes)
71
74
  end
72
75
  end
@@ -76,20 +79,20 @@ module DexieCable
76
79
 
77
80
  private
78
81
 
79
- def resolve_channel(via, subject = nil)
80
- if subject
81
- subjects = resolve_subject(subject)
82
- Array(subjects).map { |s| via[s] }
82
+ def resolve_channel(via, to = nil)
83
+ if to
84
+ recipients = resolve_recipient(to)
85
+ Array(recipients).map { |r| via[r] }
83
86
  else
84
87
  via
85
88
  end
86
89
  end
87
90
 
88
- def resolve_subject(subject)
89
- case subject
90
- when Proc then instance_exec(&subject)
91
- when Symbol then send(subject)
92
- else subject
91
+ def resolve_recipient(to)
92
+ case to
93
+ when Proc then instance_exec(&to)
94
+ when Symbol then send(to)
95
+ else to
93
96
  end
94
97
  end
95
98
 
@@ -101,6 +104,14 @@ module DexieCable
101
104
  end
102
105
  end
103
106
 
107
+ def serialize_record(serializer)
108
+ case serializer
109
+ when Proc then instance_exec(&serializer)
110
+ when Symbol then send(serializer)
111
+ else serializer
112
+ end
113
+ end
114
+
104
115
  # Override in your model to customise the payload synced to Dexie.
105
116
  def as_json_for_dexie
106
117
  as_json
@@ -6,12 +6,12 @@ module DexieCable
6
6
  included do
7
7
  public :transmit
8
8
 
9
- # Open a scoped channel for broadcasting to a specific subject.
9
+ # Open a scoped channel for broadcasting to a specific recipient.
10
10
  #
11
11
  # UserChannel[current_user].table("notifications").add(notification)
12
12
  #
13
- def self.[](subject)
14
- ScopedChannel.new(self, subject)
13
+ def self.[](recipient)
14
+ ScopedChannel.new(self, recipient)
15
15
  end
16
16
 
17
17
  # Build a query against a Dexie table, transmitted to all subscribers
@@ -2,9 +2,9 @@
2
2
 
3
3
  module DexieCable
4
4
  class ScopedChannel
5
- def initialize(klass, subject)
6
- @klass = klass
7
- @subject = subject
5
+ def initialize(klass, recipient)
6
+ @klass = klass
7
+ @recipient = recipient
8
8
  end
9
9
 
10
10
  def table(name)
@@ -12,7 +12,7 @@ module DexieCable
12
12
  end
13
13
 
14
14
  def transmit(data)
15
- @klass.broadcast_to @subject, data
15
+ @klass.broadcast_to @recipient, data
16
16
  end
17
17
  end
18
18
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DexieCable
4
- VERSION = "0.1.6"
4
+ VERSION = "0.1.8"
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: 0.1.6
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Buhrmester