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 +4 -4
- data/README.md +104 -26
- data/lib/dexiecable/active_record_ext.rb +37 -26
- data/lib/dexiecable/concern.rb +3 -3
- data/lib/dexiecable/scoped_channel.rb +4 -4
- 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: 2bc64f029d73e3630a14e254852ea7e6b3cf2b00d369f144c0b40ebdd6d4de1f
|
|
4
|
+
data.tar.gz: ea3df61fd1319c5f8fcc8697e99a7954213b5c4f97748ae55e3e012e00f156ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f44765e378f1860f5a239cc58169c724bc164c9eada457241a7e70f8cfcbe8ef898e2357d0dfa6981f1a6b3b170f9852a5afdb06e0dee935f7deea0489e568c2
|
|
7
|
+
data.tar.gz: f1227ada19e9a7ccb9825bb9665ec8f7c57be8afc4b05e9f8dd35c07d8da46f92cfb9cebdb74d5b7e5ef3fe87e3a549e14f039ba4412e4f373706e6e4535a318
|
data/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# DexieCable
|
|
2
2
|
|
|
3
|
-
|
|
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,
|
|
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
|
-
|
|
61
|
+
Pass your Dexie database as the first argument to `subscribe()`:
|
|
61
62
|
|
|
62
63
|
```js
|
|
63
|
-
import {
|
|
64
|
+
import { subscribe } from "dexiecable";
|
|
64
65
|
import { db } from "./db";
|
|
65
|
-
import { createConsumer } from "@rails/actioncable";
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const sub = subscribe("UserChannel", { last_update: Date.now() });
|
|
67
|
+
subscribe(db, "UserChannel");
|
|
70
68
|
```
|
|
71
69
|
|
|
72
|
-
If you
|
|
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.[](
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
147
|
-
| `after_commit on: :update` | `channel.table(
|
|
148
|
-
| `after_commit on: :destroy` | `channel.table(
|
|
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
|
-
| `
|
|
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,
|
|
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
|
-
|
|
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,
|
|
13
|
-
# syncs_to_dexie via: UserChannel,
|
|
14
|
-
# syncs_to_dexie via: UserChannel,
|
|
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 +
|
|
19
|
-
# is given, each
|
|
20
|
-
# +via[
|
|
21
|
-
# Without +
|
|
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
|
|
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
|
|
27
|
-
#
|
|
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:,
|
|
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,
|
|
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,
|
|
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,
|
|
60
|
+
Array(resolve_channel(via, to)).each do |channel|
|
|
58
61
|
next unless channel
|
|
59
|
-
channel.table(resolve_table(table)).add(
|
|
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,
|
|
69
|
+
Array(resolve_channel(via, to)).each do |channel|
|
|
67
70
|
next unless channel
|
|
68
71
|
|
|
69
|
-
changes =
|
|
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,
|
|
80
|
-
if
|
|
81
|
-
|
|
82
|
-
Array(
|
|
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
|
|
89
|
-
case
|
|
90
|
-
when Proc then instance_exec(&
|
|
91
|
-
when Symbol then send(
|
|
92
|
-
else
|
|
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
|
data/lib/dexiecable/concern.rb
CHANGED
|
@@ -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
|
|
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.[](
|
|
14
|
-
ScopedChannel.new(self,
|
|
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,
|
|
6
|
-
@klass
|
|
7
|
-
@
|
|
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 @
|
|
15
|
+
@klass.broadcast_to @recipient, data
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
data/lib/dexiecable/version.rb
CHANGED