Project

dexiecable

0.0
The project is in a healthy, maintained state
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. Includes an ActiveRecord macro (syncs_to_dexie) for automatic change syncing.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project 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
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

Custom Channels

DexieCable 2.0 is a mixin. Create your own DexieChannel and include DexieCable:

class DexieChannel < ApplicationCable::Channel
  include DexieCable
end

Reuse 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
end

Public 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
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 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
end

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:

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
end

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:

module HasNotifications
  def subscribed_to(channel, params)
    channel.table("notifications").bulkAdd(notifications.map(&:as_json_for_dexie))
    super
  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 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"
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(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 }
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
Loading

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