DexieCable
[!NOTE] By itself, DexieCable is NOT a local-first solution. It has no automatic capability to push client-side changes back to the server.
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.
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 ActiveRecord macro for automatic change syncing.
Push Dexie table updates to a client from anywhere on the server:
class NotificationsController < ApplicationController
def create
notification = current_user.notifications.create!(notification_params)
DexieChannel[current_user].table("notifications").add(notification)
end
end
Or sync model changes automatically with the syncs_to_dexie macro (more info below)
class Notification < ApplicationRecord
syncs_to_dexie via: :user
end
What's new in 2.0
DexieCable 2.0 is a mixin. Create your own DexieChannel and include DexieCable:
class DexieChannel < ApplicationCable::Channel
include DexieCable
end
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:
const subscription = subscribe(db);
const unsubscribe = subscription.addStream(userStreamToken);
You can then use the new subscribed_to hook to push initial data before any live mutation arrives. The first argument is the record the token was issued for, or the plain stream name for a public stream:
class DexieChannel < ApplicationCable::Channel
include DexieCable
def subscribed_to(record, params)
case record
when "feed"
table(record).bulkAdd(Announcement.for_stream(record).map(&:as_json_for_dexie))
when User
table("notifications").bulkAdd(record.notifications.map(&:as_json_for_dexie))
end
end
end
Installation
Ruby gem
Add to your Gemfile:
gem "dexiecable"
Then bundle install. The Railtie automatically extends ActiveRecord::Base with syncs_to_dexie.
npm package
npm install dexiecable
# or
yarn add dexiecable
Pass your Dexie database as the first argument to subscribe():
import { subscribe } from "dexiecable";
import { db } from "./db";
const subscription = subscribe(db);
// Stream tokens come from the server: DexieChannel.stream_token_for(target)
subscription.addStream(streamToken);
A consumer is lazily created on the first subscribe() call. If you need to access or set the consumer explicitly, use getConsumer() and setConsumer():
import { getConsumer, setConsumer, createConsumer } from "dexiecable";
// Get the consumer (creates one lazily if needed)
const consumer = getConsumer();
// Or set a custom one
setConsumer(createConsumer("wss://example.com/cable"));
Usage
DexieChannel
Create a DexieChannel in your app and include DexieCable in it. Every Dexie broadcast goes through this channel:
# app/channels/dexie_channel.rb
class DexieChannel < ApplicationCable::Channel
include DexieCable
end
Stream tokens are signed with the application secret, so a client can only subscribe to streams the server has issued for it:
DexieChannel.stream_token_for(target)
For an ActiveRecord model it returns a signed GlobalID (Rails' signed_id):
DexieChannel.stream_token_for(current_user)
# => "signed global id"
Tokens never expire by default. Pass expires_in: or expires_at: to limit a token's lifetime:
DexieChannel.stream_token_for(current_user, expires_in: 1.day)
An expired token, or one whose record no longer exists, is rejected. Only plain strings are treated as public stream names.
Send that token to the client (render it in a view, return it from an endpoint, etc.) and add it to the subscription:
const subscription = subscribe(db);
const stopStreaming = subscription.addStream(userStream);
addStream returns a function that removes the stream, so you can clean up later:
stopStreaming(); // equivalent to subscription.removeStream(userStream)
addStream/removeStream perform add_stream/remove_stream on DexieChannel, which verifies the token and then stream_from/stop_stream_from the decoded identifier. removeAllStreams() performs remove_all_streams, stopping every current stream. Handy on logout:
subscription.removeAllStreams();
Customizing DexieChannel
Add custom actions or push initial data directly on your channel:
# app/channels/dexie_channel.rb
class DexieChannel < ApplicationCable::Channel
include DexieCable
# Push a snapshot when a stream is added. `record` is a record for private
# streams, or the stream name (String) for public streams.
def subscribed_to(record, params)
case record
when User
table("notifications").bulkAdd(record.notifications.map(&:as_json_for_dexie))
when Conversation
table("messages").bulkAdd(record..where("seq_id > ?", params[:last_seq_id]).map(&:as_json_for_dexie))
when String
table(record).bulkAdd(Announcement.for_stream(record).map(&:as_json_for_dexie))
end
end
# Any public method is a custom action the client can perform.
def mark_as_read(data)
Message.find(data["id"]).update!(read: true)
end
end
The client subscribes to DexieChannel by default:
const subscription = subscribe(db);
Pass params from the client when adding a stream:
subscription.addStream(userStream, { last_seq_id: 100 });
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 }).
Public streams
For data that's public (a global feed, announcements, etc.), skip the signature. Use a string target. It's namespaced under public: automatically:
DexieChannel["feed"].table("announcements").add(announcement)
# or, on a model:
class Announcement < ApplicationRecord
syncs_to_dexie via: "feed"
end
Then subscribe by name. No token required:
const stopPublicStream = subscription.addStream("feed");
stopPublicStream(); // equivalent to subscription.removeStream("feed")
Public streams are namespaced under public:, so this path can never reach a signed (private) stream.
DexieChannel[target] returns a scoped channel for broadcasting to one recipient:
DexieChannel[current_user].table("notifications").add(notification)
Chaining Dexie operations
Any Dexie.js write operation triggers an immediate broadcast:
# Single insert
DexieChannel[current_user].table("messages").add(id: 1, text: "hello")
# Bulk insert
DexieChannel[current_user].table("messages").bulkAdd()
# Update (using modify)
DexieChannel[current_user]
.table("messages")
.where(:id).equals(msg.id)
.modify(read: true)
# Update (using update)
DexieChannel[current_user]
.table("messages")
.update(msg.id, text: "updated text")
# Delete
DexieChannel[current_user]
.table("messages")
.where(:room_id).equals(room.id)
.delete()
The full query chain is serialized as JSON and sent over ActionCable. The JS client replays every method call against the local Dexie database in order.
syncs_to_dexie: automatic model streaming
Add to any ActiveRecord model. Optionally provide the broadcast target.
class Message < ApplicationRecord
belongs_to :conversation
belongs_to :receiver
# Calls send(:receiver), then broadcasts: DexieChannel.broadcast_to(receiver, ...)
syncs_to_dexie via: :receiver
# String = public stream (subscribe via addStream("public"))
syncs_to_dexie via: "public"
# Procs are also supported. If an array is returned, multiple broadcasts are made
# conversation.users.each { |u| DexieChannel.broadcast_to(u, ...) }
syncs_to_dexie via: -> { conversation.users }
end
Broadcasts go out over DexieChannel, the channel DexieCable provides. On the client, subscribe to it and add the stream token returned by DexieChannel.stream_token_for(target):
const subscription = subscribe(db);
subscription.addStream(streamIdentifier);
Internally, syncs_to_dexie sets up the following ActiveRecord callbacks:
| Event | Action |
|---|---|
after_commit on: :create |
channel.table(table).add(as_json_for_dexie) |
after_commit on: :update |
channel.table(table).update(id, as_json_for_dexie.slice(*saved_changes.keys)) |
after_commit on: :destroy |
channel.table(table).delete(id) |
Options
| Option | Default | Description |
|---|---|---|
via: |
the record itself | The stream target. Symbol → calls send (a record, signed). String → public stream name. Proc → evaluated in record context. Returns a single recipient or collection. |
table: |
model's table_name |
Override the Dexie table name. A Proc is evaluated in the record's context. |
only: |
[:create, :update, :destroy] |
Limit which events trigger a sync |
with: |
:as_json_for_dexie |
Method name (Symbol) or Proc for serializing records |
if: |
(none) | Symbol (method name) or Proc. Only sync when it returns truthy |
unless: |
(none) | Symbol (method name) or Proc. Skip sync when it returns truthy |
You can combine multiple syncs_to_dexie declarations, each with different conditions:
class Message < ApplicationRecord
syncs_to_dexie via: -> { sender },
if: :published?
syncs_to_dexie unless: -> { draft? }
end
Customizing the synced payload
Override as_json_for_dexie in your model, or use the with option to specify a different method or Proc:
class Message < ApplicationRecord
# Using the default as_json_for_dexie override:
syncs_to_dexie via: :sender
def as_json_for_dexie
super.merge(room_name: room.name)
end
# Or use a custom serializer method:
syncs_to_dexie via: :admin,
with: :admin_payload
def admin_payload
attributes.slice("id", "body", "flagged")
end
# Or a Proc:
syncs_to_dexie with: -> { { id: id, summary: body.truncate(100) } }
end
How it works
sequenceDiagram
participant Model as ActiveRecord Model
participant Channel as DexieCable Channel
participant WS as ActionCable WebSocket
participant JS as dexiecable.js
participant DB as Dexie.js (IndexedDB)
Model->>Channel: after_commit
Channel->>Channel: build Query DSL
Channel->>WS: broadcast JSON { table, ops }
WS->>JS: received(data)
JS->>DB: replay ops chain
DB-->>JS: result
The Ruby side builds a JSON payload like:
{
"table": "messages",
"ops": [
{ "method": "where", "params": ["room_id"] },
{ "method": "equals", "params": [5] },
{ "method": "add", "params": [{ "id": 1, "text": "hello" }] }
]
}
The JS side replays it as:
dexie.messages.where("room_id").equals(5).add({ id: 1, text: "hello" })
License
MIT