class AddVersionsToArticles < ActiveRecord::Migration[7.1]
def change
add_column :articles, :versions, :jsonb, null: false, default: []
add_index :articles, :versions, using: :gin
end
end
module Versionable
extend ActiveSupport::Concern
MAX_VERSIONS = 25
included do
before_update :capture_version, if: :versioned_changes?
end
class_methods do
def has_versions(*attrs)
self.versioned_attributes = attrs.map(&:to_s)
end
end
included do
class_attribute :versioned_attributes, default: []
end
def versioned_changes?
(changed & versioned_attributes).any?
end
def capture_version
snapshot = {
"version" => (versions.last&.fetch("version", 0).to_i) + 1,
"attributes" => attributes.slice(*versioned_attributes),
"recorded_at" => Time.current.iso8601
}
self.versions = (versions + [snapshot]).last(MAX_VERSIONS)
end
def diff_with(from_version, to_version)
from = find_version(from_version)
to = find_version(to_version)
return {} if from.nil? || to.nil?
keys = from["attributes"].keys | to["attributes"].keys
keys.each_with_object({}) do |key, diff|
old_value = from["attributes"][key]
new_value = to["attributes"][key]
diff[key] = [old_value, new_value] if old_value != new_value
end
end
def restore_version(number)
snapshot = find_version(number)
raise ActiveRecord::RecordNotFound, "version #{number}" if snapshot.nil?
assign_attributes(snapshot["attributes"])
end
private
def find_version(number)
versions.find { |v| v["version"].to_i == number.to_i }
end
end
class Article < ApplicationRecord
include Versionable
has_versions :title, :body, :status
enum status: { draft: 0, published: 1, archived: 2 }
validates :title, presence: true
def latest_version
versions.last&.fetch("version", 0).to_i
end
def history
versions.sort_by { |v| v["version"].to_i }
end
end
This snippet shows a lightweight alternative to gems like PaperTrail: instead of tracking every column change through callbacks that touch a separate schema, each versioned record accumulates a compact history of JSON snapshots in a single jsonb column. The pattern suits cases where an approximate audit trail is enough and adding a full versions table feels heavy, and where storing the whole record shape as it existed at a point in time is more useful than a normalized change log.
In migration, a versions column is added as jsonb defaulting to an empty array, with a GIN index so individual snapshots can be queried later. Keeping the history inline means a record and its history load together in one row, avoiding a join at the cost of an ever-growing column — which is why the concern caps retention.
The Versionable concern is the core. It is designed to be mixed into any model that declares which attributes matter via versioned_attributes. On before_update, capture_version runs only when a watched attribute actually changed, using ActiveRecord's dirty tracking (saved_change_to_attribute? is avoided in favor of the pre-save changed? state so the snapshot reflects the values about to be written). Each snapshot records a monotonically increasing version, the attribute slice, and a timestamp, then trims to the most recent MAX_VERSIONS. Because the write happens inside the same UPDATE via self.versions =, the snapshot and the change are atomic — there is no window where a version is missing or duplicated.
The diff_with helper reconstructs what changed between any two stored versions by comparing their attribute hashes, returning a map of field => [old, new]. restore_version shows the payoff: a snapshot can be replayed back onto the record.
In Article model, the concern is wired up with has_versions :title, :body, :status, keeping the model itself declarative. The main trade-offs are unbounded growth without a cap, the loss of per-version foreign keys, and that jsonb diffs are computed in Ruby rather than SQL. For moderate-volume records needing a readable, self-contained history, it is a pragmatic fit.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.