Upkeep Rails

0. Quick Intro

Upkeep Rails keeps ordinary Rails pages fresh when the data, request inputs, or identity values they used change.

A successful HTML GET renders through Rails as usual. Upkeep records the templates, records, relations, request values, and identity values that shaped the response. Later, an Active Record commit emits facts about what changed. Upkeep matches those facts to affected rendered frames and delivers ordinary Turbo Stream updates over ActionCable.

The design goal is Rails-shaped DX: controllers load state, views render ERB, models commit writes, and Upkeep derives the reactive boundary from the Rails surfaces it observes. There is no query catalog and no watch or track DSL.

For the deeper runtime model, see How Upkeep Works.

1. Upkeep vs Vanilla Turbo

With vanilla Turbo Streams, write paths often need to name the UI they refresh:

# app/controllers/cards_controller.rb
class CardsController < ApplicationController
  def create
    @board = Board.find(params[:board_id])
    @card = @board.cards.new(card_params)

    if @card.save
      @open_card_count = @board.cards.open.count

      respond_to do |format|
        format.turbo_stream
      end
    else
      render :new, status: :unprocessable_entity
    end
  end
end
<%# app/views/cards/create.turbo_stream.erb %>
<%= turbo_stream.append "cards",
  partial: "cards/card",
  locals: { card: @card } %>

<%= turbo_stream.update "open_card_count", @open_card_count %>

That follows the standard Turbo Stream shape and avoids a page visit for the submitting browser. The tradeoff is that the write path is still coupled to the current UI. Adding another dependent page, sidebar, filter, or counter usually means revisiting stream templates, controller assignments, callbacks, or broadcasts.

With Upkeep, the controller can acknowledge the successful write without naming stream targets:

class CardsController < ApplicationController
  def create
    board = Board.find(params[:board_id])
    board.cards.create!(card_params)

    head :no_content
  end
end

The submitting Turbo request gets a successful empty response, so it does not perform a page visit. The GET that rendered the page already recorded the rendered dependencies. When the commit lands, Upkeep selects affected subscribers and sends Turbo Streams to the browsers that need them. Validation and error rendering stay ordinary application code; the live update path does not need to name DOM targets.

Concern Vanilla Turbo Upkeep
Write path Names stream targets, partials, counters, or pages. Commits domain changes.
Read path Ordinary Rails render. Ordinary Rails render, captured during HTML GETs.
Browser update Turbo Streams or page refresh. Turbo Streams or page refresh.
Boundary App declares it in stream templates, callbacks, or broadcasts. Upkeep derives it from rendered Rails surfaces when it can prove safety.
Unsafe shape App decides how broad to broadcast. Upkeep raises or warns and refuses the live boundary.

2. Install

Add the gem:

gem "upkeep-rails"

Run the installer:

bin/rails generate upkeep:install
bin/rails db:migrate

The generator creates subscription tables, writes config/initializers/upkeep.rb, mounts ActionCable when needed, pins Turbo and ActionCable for importmap apps, and imports the browser bootstrap from app/javascript/application.js.

The browser bootstrap is vendored into the host app at app/javascript/upkeep/subscription.js. After upgrading upkeep-rails, rerun the installer or compare that file with the generated template.

Requirements: Ruby 3.2+, Rails 7.1+, and Turbo 2.0+.

3. Configure Runtime

The generated initializer is the normal place to configure Upkeep:

# config/initializers/upkeep.rb
Upkeep::Rails.configure do |config|
  app_config = Rails.application.config.upkeep

  config.enabled = app_config.fetch(:enabled, true)
  config.subscription_store = app_config.fetch(:subscription_store, Rails.env.test? ? :memory : :active_record)
  config.delivery_adapter = app_config.fetch(:delivery_adapter, Rails.env.production? ? :active_job : :async)
  config.delivery_queue = app_config.fetch(:delivery_queue, :upkeep_realtime)
end

Use :active_record for durable subscription storage in production. The generated migration creates the required tables. Use :memory for most request and system tests, and keep at least one app or CI path on :active_record when you want to exercise durable rows, schema checks, reload, and cross-process lookup.

Production apps should use Active Job for delivery so planning, rerendering, and broadcasting do not run in the writer's request:

Upkeep::Rails.configure do |config|
  config.delivery_adapter = Rails.env.production? ? :active_job : :async
  config.delivery_queue = :upkeep_realtime
end

Configure the app's Active Job backend normally, such as Solid Queue, Sidekiq, or GoodJob. ActionCable still needs a shared adapter in multi-process deployments because a job worker may not be the process holding the browser's WebSocket. Redis, Solid Cable, and PostgreSQL are shared ActionCable adapters.

For local debugging, set config.delivery_adapter = :inline to run delivery immediately.

4. Configure Identity

Pages that depend on a user, account, tenant, locale, or other viewer-specific value need an explicit identity bridge.

The render side tells Upkeep which value the HTML render read. The subscribe side tells Upkeep how the ActionCable connection proves the same value when the browser subscribes.

# config/initializers/upkeep.rb
Upkeep::Rails.configure do |config|
  config.identify :viewer, current: ["Current", :user] do
    subscribe { |connection| connection.current_user }
  end
end

The matching cable connection must expose that identity:

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = User.find_by(id: request.session[:user_id])
    end
  end
end

Choose the source keyword from the API your render path actually reads:

Render path reads Declare Subscribe side returns
Current.user current: ["Current", :user] the same user, usually connection.current_user
Devise or Warden user reads warden: :user the same Devise user, usually connection.current_user
session[:user_id] session: :user_id connection.session[:user_id]
cookies[:account_id] cookie: :account_id connection.cookies[:account_id]

By default, nil means a declared identity boundary is absent. That keeps logged-out pages anonymous-public even when a layout checks Current.user or session[:user_id]. If your app has another "not signed in" sentinel, declare it:

Upkeep::Rails.configure do |config|
  config.identify :viewer, session: :user_id do
    absent_if { |value| value.nil? || value == false }
    subscribe { |connection| connection.session[:user_id] }
  end
end

If a page reads an undeclared non-absent CurrentAttributes or Warden identity, Upkeep refuses live registration instead of guessing who may receive updates.

5. Opaque Values, DX, and Refactors

Upkeep only registers live boundaries it can prove. It needs to know which future write facts can make a rendered result stale, which target can be rerendered or patched, and which observed identity inputs decide whether the result can be shared.

Opaque means application code used something real, but Rails did not expose enough structure for Upkeep to answer those questions. Common examples are raw SQL predicates, raw joins, raw from sources, unknown table aliases, opaque order expressions, and render locals that cannot be rebuilt later.

The DX is intentionally fail-fast:

  • development and test raise by default
  • production warns and skips live registration by default
  • refused_boundary.upkeep is emitted for instrumentation
  • Upkeep does not widen to a broad unsafe dependency

You can choose the behavior explicitly:

Upkeep::Rails.configure do |config|
  config.refused_boundary_behavior = :raise # or :warn
end

Most refactors are normal Rails cleanup. Prefer hash conditions and symbolic orders when they express the query:

# Before: opaque SQL string order
Story.order("stories.created_at DESC")

# After: structural Rails order
Story.order(created_at: :desc)

Use Arel when the query needs an operator or correlated condition that hash syntax cannot express:

# Before: opaque SQL string predicate
Story.where("score >= 0")

# After: structural Arel predicate
Story.where(Story.arel_table[:score].gteq(0))
# Before: opaque correlated SQL
HiddenStory.where(Arel.sql("hidden_stories.story_id = stories.id"))

# After: structural correlated predicate
HiddenStory.where(
  HiddenStory.arel_table[:story_id].eq(Story.arel_table[:id])
)

For replay values, keep frame locals and render options to records, relations, arrays, hashes, literals, and observed request, session, or cookie values. Avoid passing procs, IO handles, open clients, or process-local objects into live render boundaries.

The rule of thumb: when Rails and Arel can describe the table, column, predicate, order, and value shape, Upkeep can usually reason about it. When the shape is hidden inside a string or arbitrary Ruby object, Upkeep refuses the live boundary and tells you where to make the shape explicit.