Build Status Gem Version

About

  • Auto-syncs records in client-side JS (through a Model DSL) from changes (updates/destroy) in the backend Rails server through ActionCable.
  • Also supports streaming newly created records to client-side JS
  • Supports lost connection restreaming for both new records (create), and record-changes (updates/destroy).
  • Auto-updates DOM elements mapped to a record attribute, from changes (updates/destroy). (Optional LiveDOM Plugin)

live_record is intentionally designed for read-only one-way syncing from the backend server, and does not support pushing changes to the Rails server from the client-side JS. Updates from client-side then is intended to use the normal HTTP REST requests.

New Version 0.2! See Changelog below

Requirements

  • >= Ruby 2.2.2
  • >= Rails 5.0

Demo

Usage Example

  • say we have a Book model which has the following attributes:
    • title:string
    • author:string
    • is_enabled:boolean
  • on the JS client-side:

Subscribing to Record Creation

  // subscribe, and auto-receive newly created Book records from the Rails server
  LiveRecord.Model.all.Book.subscribe()

  // ...or only those which are enabled
  // LiveRecord.Model.all.Book.subscribe({where: {is_enabled_eq: true}})

  // now, we can just simply add a "create" callback, to apply our own logic whenever a new Book record is streamed from the backend
  LiveRecord.Model.all.Book.addCallback('after:create', function() {
    // let's say you have a code here that adds this new Book on the page 
    // `this` refers to the Book record that has been created
    console.log(this);
  })

Subscribing to Record Updates/Destroy

  // instantiate a Book object (only requirement is you pass the ID so it can be referenced when updates/destroy happen)
  var book = new LiveRecord.Model.all.Book({id: 1})

  // ...or you can also initialise with other attributes 
  // var book = new LiveRecord.Model.all.Book({id: 1, title: 'Harry Potter', created_at: '2017-08-02T12:39:49.238Z'})

  // then store this Book object into the JS store
  book.create();

  // the store is accessible through
  LiveRecord.Model.all.Book.all;

  // all records in the JS store are automatically subscribed to the backend LiveRecord::ChangesChannel, which meant syncing (update / destroy) changes from the backend

  // All attributes automatically updates itself so you'll be sure that the following line (for example) is always up-to-date
  console.log(book.updated_at())

  // you can also add a callback that will be invoked whenever the Book object has been updated (see all supported callbacks further below)
  // i.e. you might want to update DOM elements when the attributes have changed
  book.addCallback('after:update', function() {
    // `this` refers to the Book record that has been updated

    console.log(this.attributes);
    // this book record should have been updated with all other possible whitelisted attributes even if you just initally passed in only the ID; thus console.log above would output below
    // {id: 1, title: 'Harry Potter', author: 'J.K. Rowling', is_enabled: true, created_at: '2017-08-02T12:39:49.238Z', updated_at: '2017-08-02T12:39:49.238Z'}

    console.log(this.changes)
    // from above, you can also access what has changed, and would have an example output below
    // {title: ['Harry Potter', 'New Title'], updated_at: ['2017-08-02T12:39:49.238Z', 2017-08-02T13:00:00.047Z]}
  });

  // or you can add a Model-wide callback that will be invoked whenever ANY Book object has been updated
  LiveRecord.Model.all.Book.addCallback('after:update', function() {
    console.log(this);
  })
  • on the backend-side, you can handle attributes authorisation:
  # app/models/book.rb
  class Book < ApplicationRecord
    include LiveRecord::Model::Callbacks
    has_many :live_record_updates, as: :recordable, dependent: :destroy

    def self.live_record_whitelisted_attributes(book, current_user)
      # Add attributes to this array that you would like `current_user` to have access to when syncing this particular `book`
      # empty array means not-authorised
      if book.user == current_user
        [:title, :author, :created_at, :updated_at, :reference_id, :origin_address]
      elsif current_user.present?
        [:title, :author, :created_at, :updated_at]
      else
        []
      end
    end
  end
  • whenever a Book (or any other Model record that you specified) has been created / updated / destroyed, there exists an after_create_commit, after_update_commit and an after_destroy_commit ActiveRecord callback that will broadcast changes to all subscribed JS clients

Setup

  1. Add the following to your Gemfile:

    gem 'live_record', '~> 0.2.1'
    
  2. Run:

    bundle install
    
  3. Install by running:

    rails generate live_record:install
    

    rails generate live_record:install --live_dom=false if you do not need the LiveDOM plugin; --live_dom=true by default

  4. Run migration to create the live_record_updates table, which is going to be used for client reconnection resyncing:

  rake db:migrate
  1. Update your app/channels/application_cable/connection.rb, and add current_user method, unless you already have it:

    module ApplicationCable
      class Connection < ActionCable::Connection::Base
        identified_by :current_user
    
        def current_user
          # write something here if you have a current_user, or you may just leave this blank. Example below when using `devise` gem:
          # User.find_by(id: cookies.signed[:user_id])
        end
      end
    end
    
  2. Update your model files (only those you would want to be synced), and insert the following public method:

    automatically updated if you use Rails scaffold or model generator

    Example 1 - Simple Usage

    # app/models/book.rb (example 1)
    class Book < ApplicationRecord
      include LiveRecord::Model::Callbacks
      has_many :live_record_updates, as: :recordable, dependent: :destroy
    
      def self.live_record_whitelisted_attributes(book, current_user)
        # Add attributes to this array that you would like current_user to have access to when syncing.
        # Defaults to empty array, thereby blocking everything by default, only unless explicitly stated here so.
        [:title, :author, :created_at, :updated_at]
      end
    end
    

    Example 2 - Advanced Usage

    # app/models/book.rb (example 1)
    class Book < ApplicationRecord
      include LiveRecord::Model::Callbacks
      has_many :live_record_updates, as: :recordable, dependent: :destroy
    
      def self.live_record_whitelisted_attributes(book, current_user)
        # Notice that from above, you also have access to `book` (the record currently requested by the client to be synced),
        # and the `current_user`, the current user who is trying to sync the `book` record.
        if book.user == current_user
          [:title, :author, :created_at, :updated_at, :reference_id, :origin_address]
        elsif current_user.present?
          [:title, :author, :created_at, :updated_at]
        else
          []
        end
      end
    end
    
  3. For each Model you want to sync, insert the following in your Javascript files.

    automatically updated if you use Rails scaffold or controller generator

    Example 1 - Model

    // app/assets/javascripts/books.js
    LiveRecord.Model.create(
      {
        modelName: 'Book' // should match the Rails model name
        plugins: {
          LiveDOM: true // remove this if you do not need `LiveDOM`
        }
      }
    )
    

    Example 2 - Model + Callbacks

    // app/assets/javascripts/books.js
    LiveRecord.Model.create(
      {
        modelName: 'Book',
        callbacks: {
          'on:connect': [
            function() {
              console.log(this); // `this` refers to the current `Book` record that has just connected for syncing
            }
          ],
          'after:update': [
            function() {
              console.log(this); // `this` refers to the current `Book` record that has just been updated with changes synced from the backend
            }
          ]
        }
      }
    )
    

    #### Model Callbacks supported:

    • on:connect
    • on:disconnect
    • on:response_error
    • before:create
    • after:create
    • before:update
    • after:update
    • before:destroy
    • after:destroy

    Each callback should map to an array of functions

  * `on:response_error` supports a function argument: The "Error Code". i.e.

    ### Example 3 - Handling Response Error

    ```js
    LiveRecord.Model.create(
      {
        modelName: 'Book',
        callbacks: {
          'on:response_error': [
            function(errorCode) {
              console.log(errorCode); // errorCode is a string, representing the type of error. See Response Error Codes below:
            }
          ]
        }
      }
    )
    ```

    #### Response Error Codes:
    * `"forbidden"` - Current User is not authorized to sync record changes. Happens when Model's `live_record_whitelisted_attributes` method returns empty array.
    * `"bad_request"` - Happens when `LiveRecord.Model.create({modelName: 'INCORRECTMODELNAME'})`
  1. Load the records into the JS Model-store through JSON REST (i.e.):

    Example 1 - Using Default Loader (Requires JQuery)

    Your controller must also support responding with JSON in addition to HTML. If you used scaffold or controller generator, this should already work immediately.

    <!-- app/views/books/index.html.erb -->
    <script>
      // `loadRecords` asynchronously loads all records (using the current URL) to the store, through a JSON AJAX request.
      // in this example, `loadRecords` will load JSON from the current URL which is /books
      LiveRecord.helpers.loadRecords({modelName: 'Book'})
    </script>
    
    <!-- app/views/books/index.html.erb -->
    <script>
      // `loadRecords` you may also specify a URL to loadRecords (`url` defaults to `window.location.href` which is the current page) 
      LiveRecord.helpers.loadRecords({modelName: 'Book', url: '/some/url/that/returns_books_as_a_json'})
    </script>
    
    <!-- app/views/posts/index.html.erb -->
    <script>
      // You may also pass in a callback for synchronous logic
      LiveRecord.helpers.loadRecords({
        modelName: 'Book',
        onLoad: function(records) {
          // ...
        },
        onError: function(jqxhr, textStatus, error) {
          // ...
        }
      })
    </script>
    

    Example 2 - Using Custom Loader

    // do something here that will fetch Book record attributes...
    // as an example, say you already have the following attributes:
    var book1Attributes = { id: 1, title: 'Noli Me Tangere', author: 'José Rizal' }
    var book2Attributes = { id: 2, title: 'ABNKKBSNPLAko?!', author: 'Bob Ong' }
    
    // then we instantiate a Book object
    var book1 = new LiveRecord.Model.all.Book(book1Attributes);
    // then we push this Book object to the Book store, which then automatically subscribes them to changes in the backend
    book1.create();
    
    var book2 = new LiveRecord.Model.all.Book(book2Attributes);
    book2.create();
    
    // you can also add Instance callbacks specific only to this Object (supported callbacks are the same as the Model callbacks)
    book2.addCallback('after:update', function() {
      // do something when book2 has been updated after syncing
    })
    
  2. To automatically receive new Book records, you may subscribe:

    // subscribe
    subscription = LiveRecord.Model.all.Book.subscribe();
    
    // ...or subscribe only to certain conditions (i.e. when `is_enabled` attribute value is `true`)
    // For the list of supported operators (like `..._eq`), see JS API `MODEL.subscribe(CONFIG)` below
    // subscription = LiveRecord.Model.all.Book.subscribe({where: {is_enabled_eq: true}});
    
    // now, we can just simply add a "create" callback, to apply our own logic whenever a new Book record is streamed from the backend
    LiveRecord.Model.all.Book.addCallback('after:create', function() {
      // let's say you have a code here that adds this new Book on the page 
      // `this` refers to the Book record that has been created
      console.log(this);
    })
    
    // you may also add callbacks specific to this `subscription`, as you may want to have multiple subscriptions. Then, see JS API `MODEL.subscribe(CONFIG)` below for information
    
    // then unsubscribe, as you wish
    LiveRecord.Model.all.Book.unsubscribe(subscription);
    

    Ransack Search Queries (Optional)

  * If you need more complex queries to pass into the `.subscribe(where: { ... })` above, [ransack](https://github.com/activerecord-hackery/ransack) gem is supported.
  * For example you can then do:
    ```js
    // querying upon the `belongs_to :user`
    subscription = LiveRecord.Model.all.Book.subscribe({where: {user_is_admin_eq: true, is_enabled: true}});

    // or querying "OR" conditions
    subscription = LiveRecord.Model.all.Book.subscribe({where: {title_eq: 'I am Batman', content_eq: 'I am Batman', m: 'or'}});
    ```

  #### Model File (w/ Ransack) Example

  ```ruby
  # app/models/book.rb
  class Book < ApplicationRecord
    include LiveRecord::Model::Callbacks
    has_many :live_record_updates, as: :recordable, dependent: :destroy

    def self.live_record_whitelisted_attributes(book, current_user)
      [:title, :is_enabled]
    end

    private

    # see ransack gem for more details: https://github.com/activerecord-hackery/ransack#authorization-whitelistingblacklisting
    # you can write your own columns here, but you may just simply allow ALL COLUMNS to be searchable, because the `live_record_whitelisted_attributes` method above will be also called anyway, and therefore just simply handle whitelisting there.
    # therefore you can actually remove the whole `self.ransackable_attributes` method below

    ## LiveRecord passes the `current_user` into `auth_object`, so you can access `current_user` inside below
    # def self.ransackable_attributes(auth_object = nil)
    #   column_names + _ransackers.keys
    # end
  end
  ```

### Reconnection Streaming (when client got disconnected)

* Only requirement is that you should have a `created_at` attribute on your Models, which by default should already be there. However, to speed up queries, I highly suggest to add index on `created_at` with the following

```bash
# this will create a file under db/migrate folder, then edit that file (see the ruby code below)
rails generate migration add_created_at_index_to_MODELNAME
```

```ruby
# db/migrate/2017**********_add_created_at_index_to_MODELNAME.rb
class AddCreatedAtIndexToMODELNAME < ActiveRecord::Migration[5.0] # or 5.1, etc
  def change
    add_index :TABLENAME, :created_at
  end
end
```

Plugins

LiveDOM (Requires JQuery)

  • enabled by default, unless explicitly removed.
  • LiveDOM allows DOM elements' text content to be automatically updated, whenever the mapped record-attribute has been updated.

text content is safely escaped using JQuery's .text() function

Example 1 (Mapping to a Record-Attribute: after:update)

<span data-live-record-update-from='Book-24-title'>Harry Potter</span>
  • data-live-record-update-from format should be MODELNAME-RECORDID-RECORDATTRIBUTE
  • whenever LiveRecord.all.Book.all[24] has been updated/synced from backend, "Harry Potter" text above changes accordingly.
  • this does not apply to only <span> elements. You can use whatever elements you like.

Example 2 (Mapping to a Record: after:destroy)

<section data-live-record-destroy-from='Book-31'>This example element is a container for the Book-31 record which can also contain children elements</section>
  • data-live-record-destroy-from format should be MODELNAME-RECORDID
  • whenever LiveRecord.all.Book.all[31] has been destroyed/synced from backend, the <section> element above is removed, and thus all of its children elements.
  • this does not apply to only <section> elements. You can use whatever elements you like.

  • You may combine data-live-record-destroy-from and data-live-record-update-from within the same element.

JS API

LiveRecord.Model.all

  • Object of which properties are the models

LiveRecord.Model.create(CONFIG)

  • CONFIG (Object)
    • modelName: (String, Required)
    • callbacks: (Object)
      • on:connect: (Array of functions)
      • on:disconnect: (Array of functions)
      • on:response_error: (Array of functions; function argument = ERROR_CODE (String))
      • before:create: (Array of functions)
      • after:create: (Array of functions)
      • before:update: (Array of functions)
      • after:update: (Array of functions)
      • before:destroy: (Array of functions)
      • after:destroy: (Array of functions)
    • plugins: (Object)
      • LiveDOM: (Boolean)
  • creates a MODEL and stores it into LiveRecord.Model.all array
  • returns the newly created MODEL

MODEL.all

  • Object of which properties are IDs of the records

MODEL.subscribe(CONFIG)

  • CONFIG (Object, Optional)
    • where: (Object)
      • ATTRIBUTENAME_OPERATOR: (Any Type)
    • callbacks: (Object)
      • on:connect: (function Object)
      • on:disconnect: (function Object)
      • before:create: (function Object)
      • after:create: (function Object)
  • subscribes to the LiveRecord::PublicationsChannel, which then automatically receives new records from the backend.
  • you can also pass in callbacks (see above). These callbacks is only applicable to this subscription, and is independent of the Model and Instance callbacks.
  • ATTRIBUTENAME_OPERATOR means something like (for example): is_enabled_eq, where is_enabled is the ATTRIBUTENAME and eq is the OPERATOR.

    • you can have as many ATTRIBUTENAME_OPERATOR as you like, but keep in mind that the logic applied to them is "AND", and not "OR". For "OR" conditions, use ransack

    List of Default Supported Query Operators

    the following list only applies if you are NOT using the ransack gem. If you need more complex queries, ransack is supported and so see Setup's step 9 above

    • eq equals; i.e. is_enabled_eq: true
    • not_eq not equals; i.e. title_not_eq: 'Harry Potter'
    • lt less than; i.e. created_at_lt: '2017-12-291T13:47:59.238Z'
    • lteq less than or equal to; i.e. created_at_lteq: '2017-12-291T13:47:59.238Z'
    • gt greater than; i.e. created_at_gt: '2017-12-291T13:47:59.238Z'
    • gteq greater than or equal to; i.e. created_at_gteq: '2017-12-291T13:47:59.238Z'
    • in in Array; i.e. id_in: [2, 56, 19, 68]
    • not_in in Array; i.e. id_not_in: [2, 56, 19, 68]

MODEL.unsubscribe(SUBSCRIPTION)

  • unsubscribes to the LiveRecord::PublicationsChannel, thereby will not be receiving new records anymore.

new LiveRecord.Model.all.MODELNAME(ATTRIBUTES)

  • ATTRIBUTES (Object)
  • returns a MODELINSTANCE of the the Model having ATTRIBUTES attributes

MODELINSTANCE.modelName()

  • returns the model name (i.e. 'Book')

MODELINSTANCE.attributes

  • the attributes object

MODELINSTANCE.ATTRIBUTENAME()

  • returns the attribute value of corresponding to ATTRIBUTENAME. (i.e. bookInstance.id(), bookInstance.created_at())

MODELINSTANCE.subscribe()

  • subscribes to the LiveRecord::ChangesChannel. This instance should already be subscribed by default after being stored, unless there is a on:response_error or manually unsubscribed() which then you should manually call this subscribe() function after correctly handling the response error, or whenever desired.
  • returns the subscription object (the ActionCable subscription object itself)

MODELINSTANCE.unsubscribe()

  • unsubscribes to the LiveRecord::ChangesChannel, thereby will not be receiving changes (updates/destroy) anymore.

MODELINSTANCE.isSubscribed()

  • returns true or false accordingly if the instance is subscribed

MODELINSTANCE.subscription

  • the subscription object (the ActionCable subscription object itself)

MODELINSTANCE.create()

  • stores the instance to the store, and then subscribe() to the LiveRecord::ChangesChannel for syncing
  • returns the instance

MODELINSTANCE.update(ATTRIBUTES)

  • ATTRIBUTES (Object)
  • updates the attributes of the instance
  • returns the instance

MODELINSTANCE.destroy()

  • removes the instance from the store, and then unsubscribe()
  • returns the instance

MODELINSTANCE.changes

  • you can ONLY access this inside the function callback for before:update and after:update, and is automatically cleared after
  • returns an object having the same format as Rails's own changes
  • i.e. {title: ['Harry Potter', 'New Title'], updated_at: ['2017-08-02T12:39:49.238Z', 2017-08-02T13:00:00.047Z]}

MODELINSTANCE.addCallback(CALLBACKKEY, CALLBACKFUNCTION)

  • CALLBACKKEY (String) see supported callbacks above
  • CALLBACKFUNCTION (function Object)
  • returns the function Object if successfuly added, else returns false if callback already added

MODELINSTANCE.removeCallback(CALLBACKKEY, CALLBACKFUNCTION)

  • CALLBACKKEY (String) see supported callbacks above
  • CALLBACKFUNCTION (function Object) the function callback that will be removed
  • returns the function Object if successfully removed, else returns false if callback is already removed

FAQ

  • How to remove the view templates being overriden by LiveRecord when generating a controller or scaffold?

    • amongst other things, rails generate live_record:install will override the default scaffold view templates: show.html.erb and index.html.erb; to revert back, just simply delete the following files (though you'll need to manually update or regenerate the view files that were already generated prior to deleting the following files):
    • lib/templates/erb/scaffold/index.html.erb
    • lib/templates/erb/scaffold/show.html.erb
  • How to support more complex queries / "where" conditions when subscribing to new records creation?

TODOs

Contributing

  • pull requests and forks are very much welcomed! :) Let me know if you find any bug! Thanks

License

  • MIT

Changelog