dexiecable
v3.0.0
Published
Client-side companion for the DexieCable Ruby gem. Receives Dexie.js mutations from ActionCable and replays them against a local IndexedDB database.
Readme
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.
Who's this for?
DexieCable is made for Ruby on Rails apps that manage their client-side state in Dexie 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.
How does it work?
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
endOr sync model changes automatically with the syncs_to_dexie macro (more info below)
class Notification < ApplicationRecord
syncs_to_dexie via: :user
endWhat's new in 2.0
Custom Channels
DexieCable 2.0 is a mixin. Create your own DexieChannel and include DexieCable:
class DexieChannel < ApplicationCable::Channel
include DexieCable
endReuse the same subscription for multiple streams
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);What's new in 3.0
subscribed_to moved to the model
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:
class User < ApplicationRecord
subscribed_to do |channel, params|
channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
end
endPublic streams register a handler by name in your channel. The block runs in the channel's context, so table and transmit are available:
class DexieChannel < ApplicationCable::Channel
include DexieCable
subscribed_to "feed" do |params|
table("announcements").bulkAdd(Announcement.for_stream("feed").map(&:as_json_for_dexie))
end
endInstallation
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 dexiecablePass 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
endStream 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 public stream is added. The block receives the
# params sent from the client and runs in the channel's context, so
# `table(...)` and `transmit` are available.
subscribed_to "feed" do |params|
table("feed").bulkAdd(Announcement.for_stream("feed").map(&:as_json_for_dexie))
end
# Any public method is a custom action the client can perform. Always
# resolve records through the connection's actor, never from the payload:
# `Message.find(data["id"])` would let any client mark anyone's message
# read.
def mark_as_read(data)
current_user.messages.find(data["id"]).update!(read: true)
end
endPrivate 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:
class User < ApplicationRecord
subscribed_to do |channel, params|
channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
end
end
class Conversation < ApplicationRecord
subscribed_to do |channel, params|
channel.table("messages").bulkAdd(messages.where("seq_id > ?", params[:last_seq_id]).map(&:as_json_for_dexie))
end
endDeclare 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:
module HasNotifications
def subscribed_to(channel, params)
channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
super
end
endThe 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 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"]).
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"
endThen 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(messages)
# 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 }
endBroadcasts 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? }
endCustomizing 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) } }
endHow 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: resultThe 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
