Content versioning is a critical aspect of modern web applications that manage user-generated content, documents, or any data that changes over time. As a Rails developer, I’ve implemented numerous versioning systems across various projects, and I’ve discovered that proper versioning isn’t just about storing old copies—it’s about creating a comprehensive system that enhances user experience, provides accountability, and protects valuable data.
Technique 1: Using PaperTrail for Basic Versioning
PaperTrail provides a straightforward approach to versioning with minimal setup. It’s my go-to solution for quick implementation.
# Gemfile
gem 'paper_trail'
# Run bundle install and migrations
# rails generate paper_trail:install
# rails db:migrate
# In your model
class Article < ApplicationRecord
has_paper_trail
end
With this simple setup, PaperTrail automatically tracks changes to your model. I’ve found its API particularly intuitive:
article = Article.create(title: "First version")
article.versions.count # => 1
article.update(title: "Second version")
article.versions.count # => 2
# Accessing versions
article.versions.each do |version|
puts "#{version.created_at}: #{version.reify.title}"
end
# Restoring to previous version
article = article.versions.last.reify
article.save
When I needed more control, I discovered you can customize what PaperTrail tracks:
class Article < ApplicationRecord
has_paper_trail only: [:title, :content],
skip: [:metadata],
meta: { editor_id: :editor_id_for_paper_trail }
def editor_id_for_paper_trail
Current.user&.id
end
end
Technique 2: Building a Custom Versioning System
For projects with specific requirements, I’ve built custom versioning systems. This provides complete control over the implementation.
# Database migrations
class CreateVersions < ActiveRecord::Migration[6.1]
def change
create_table :content_versions do |t|
t.references :versionable, polymorphic: true, index: true
t.jsonb :content_data
t.integer :version_number
t.references :user
t.text :change_summary
t.timestamps
end
add_index :content_versions, [:versionable_type, :versionable_id, :version_number],
unique: true, name: 'index_versions_on_versionable_and_version_number'
end
end
The corresponding models:
# app/models/concerns/versionable.rb
module Versionable
extend ActiveSupport::Concern
included do
has_many :versions, as: :versionable, class_name: 'ContentVersion', dependent: :destroy
after_update :create_version
end
def create_version(user, summary = nil)
return if previous_changes.empty?
versions.create!(
content_data: serialized_content,
version_number: next_version_number,
user: user,
change_summary: summary
)
end
def revert_to(version_number, user)
target_version = versions.find_by!(version_number: version_number)
transaction do
create_version(user, "Auto-saved before reverting to version #{version_number}")
restore_from_version(target_version)
save!
end
end
def next_version_number
versions.maximum(:version_number).to_i + 1
end
private
def serialized_content
attributes.except("id", "created_at", "updated_at")
end
def restore_from_version(version)
version.content_data.each do |key, value|
write_attribute(key, value) if has_attribute?(key)
end
end
end
# app/models/content_version.rb
class ContentVersion < ApplicationRecord
belongs_to :versionable, polymorphic: true
belongs_to :user, optional: true
validates :version_number, presence: true, uniqueness: { scope: [:versionable_id, :versionable_type] }
validates :content_data, presence: true
scope :ordered, -> { order(version_number: :desc) }
def diff_from_previous
previous = ContentVersion.where(versionable: versionable)
.where("version_number < ?", version_number)
.ordered
.first
return {} unless previous
diff = {}
content_data.each do |key, value|
if previous.content_data[key] != value
diff[key] = {
from: previous.content_data[key],
to: value
}
end
end
diff
end
end
Technique 3: Implementing Git-Like Branching for Content
For complex content workflows, I’ve implemented branching mechanisms similar to Git:
# Additional fields in the migration
class AddBranchingToContentVersions < ActiveRecord::Migration[6.1]
def change
add_column :content_versions, :branch_name, :string, default: 'main'
add_column :content_versions, :parent_id, :integer
add_index :content_versions, :parent_id
end
end
With the model extensions:
# app/models/content_version.rb (extension)
class ContentVersion < ApplicationRecord
belongs_to :parent, class_name: 'ContentVersion', optional: true
has_many :children, class_name: 'ContentVersion', foreign_key: 'parent_id'
scope :for_branch, ->(branch) { where(branch_name: branch) }
def create_branch(name, user)
ContentVersion.create!(
versionable: versionable,
content_data: content_data,
version_number: 1,
branch_name: name,
parent_id: id,
user: user,
change_summary: "Created branch '#{name}' from version #{version_number}"
)
end
def merge_to_main(user)
return if branch_name == 'main'
latest_main = ContentVersion.where(versionable: versionable, branch_name: 'main').ordered.first
merged_content = merge_content(content_data, latest_main.content_data)
ContentVersion.create!(
versionable: versionable,
content_data: merged_content,
version_number: latest_main.next_version_number,
branch_name: 'main',
parent_id: id,
user: user,
change_summary: "Merged from branch '#{branch_name}'"
)
end
private
def merge_content(source, target)
# Simple last-writer-wins merge strategy
# For more complex merges, implement a proper diff/patch algorithm
target.merge(source)
end
end
Technique 4: Conflict Resolution with Three-Way Merge
When dealing with concurrent edits, I implement a three-way merge system:
class ContentVersion < ApplicationRecord
def three_way_merge(other_version)
base_version = find_common_ancestor(other_version)
return simple_merge(other_version) unless base_version
merged_content = {}
all_keys = (content_data.keys + other_version.content_data.keys + base_version.content_data.keys).uniq
all_keys.each do |key|
current_value = content_data[key]
other_value = other_version.content_data[key]
base_value = base_version.content_data[key]
if current_value == other_value
merged_content[key] = current_value
elsif current_value == base_value
merged_content[key] = other_value
elsif other_value == base_value
merged_content[key] = current_value
else
# Conflict detected
merged_content[key] = {
conflict: true,
current: current_value,
other: other_value,
base: base_value
}
end
end
merged_content
end
def find_common_ancestor(other_version)
# Simplified algorithm to find the closest common ancestor
my_ancestors = ancestors_list
their_ancestors = other_version.ancestors_list
my_ancestors.find { |v| their_ancestors.include?(v) }
end
def ancestors_list
result = []
current = self
while current
result << current
current = current.parent
end
result
end
end
Technique 5: Efficient Storage with Delta-Based Versioning
To optimize storage requirements, I implement delta-based versioning rather than storing complete copies:
# Migration for delta versions
class CreateDeltaVersions < ActiveRecord::Migration[6.1]
def change
create_table :delta_versions do |t|
t.references :versionable, polymorphic: true, index: true
t.integer :version_number
t.jsonb :delta_data
t.jsonb :metadata
t.references :user
t.timestamps
end
end
end
# Model implementation
class DeltaVersion < ApplicationRecord
belongs_to :versionable, polymorphic: true
belongs_to :user, optional: true
def self.create_delta_version(versionable, previous_data, current_data, user)
delta = compute_delta(previous_data, current_data)
create!(
versionable: versionable,
version_number: next_version_for(versionable),
delta_data: delta,
user: user
)
end
def self.compute_delta(old_data, new_data)
delta = {}
# Fields removed
old_data.keys.each do |key|
delta[key] = nil unless new_data.key?(key)
end
# Fields added or changed
new_data.each do |key, value|
delta[key] = value if !old_data.key?(key) || old_data[key] != value
end
delta
end
def self.reconstruct_version(versionable, target_version)
versions = where(versionable: versionable)
.where("version_number <= ?", target_version)
.order(:version_number)
data = {}
versions.each do |version|
version.delta_data.each do |key, value|
if value.nil?
data.delete(key)
else
data[key] = value
end
end
end
data
end
private
def self.next_version_for(versionable)
where(versionable: versionable).maximum(:version_number).to_i + 1
end
end
Technique 6: Visualizing Changes with HTML Diff
I’ve implemented diff visualization to help users understand changes between versions:
# Gemfile
gem 'diffy'
# Usage in a helper or service
module DiffHelper
def html_diff(text1, text2)
Diffy::Diff.new(text1, text2, include_plus_and_minus_in_html: true).to_s(:html)
end
def json_diff(json1, json2)
result = {}
all_keys = (json1.keys + json2.keys).uniq
all_keys.each do |key|
if json1[key].is_a?(String) && json2[key].is_a?(String)
result[key] = html_diff(json1[key], json2[key])
elsif json1[key] != json2[key]
result[key] = {
from: json1[key],
to: json2[key]
}
end
end
result
end
end
Technique 7: Versioned Attachments with Active Storage
Handling file versioning poses unique challenges. Here’s my approach:
class VersionedAttachment < ApplicationRecord
has_one_attached :file
belongs_to :attachable, polymorphic: true
belongs_to :content_version, optional: true
def self.create_for_version(attachable, version, uploaded_file, description)
transaction do
attachment = create!(
attachable: attachable,
content_version: version,
description: description
)
attachment.file.attach(uploaded_file)
attachment
end
end
end
# In the Versionable concern
module Versionable
extend ActiveSupport::Concern
included do
has_many :versioned_attachments, as: :attachable, dependent: :destroy
end
def attachments_for_version(version_number)
version = versions.find_by(version_number: version_number)
VersionedAttachment.where(content_version: version)
end
def add_attachment_to_version(version_number, file, description)
version = versions.find_by!(version_number: version_number)
VersionedAttachment.create_for_version(self, version, file, description)
end
end
Technique 8: Audit Trails with Context Preservation
A robust versioning system must include contextual information about changes:
# Migration
class AddContextToContentVersions < ActiveRecord::Migration[6.1]
def change
add_column :content_versions, :edit_duration, :integer
add_column :content_versions, :editor_location, :string
add_column :content_versions, :edit_session_id, :string
add_column :content_versions, :client_info, :jsonb
end
end
# Implementation in a controller
class DocumentsController < ApplicationController
def update
@document = Document.find(params[:id])
edit_start = Time.zone.parse(params[:edit_started_at]) rescue nil
edit_duration = edit_start ? (Time.current - edit_start).to_i : nil
if @document.update(document_params)
@document.versions.last.update(
edit_duration: edit_duration,
editor_location: request.location&.data&.dig('city'),
edit_session_id: session.id,
client_info: {
user_agent: request.user_agent,
ip: request.remote_ip,
referrer: request.referrer
}
)
redirect_to @document, notice: 'Document updated successfully'
else
render :edit
end
end
end
# Adding search capabilities to find versions by context
class ContentVersion < ApplicationRecord
scope :with_long_edits, ->(threshold = 5.minutes.to_i) { where("edit_duration > ?", threshold) }
scope :from_location, ->(location) { where("editor_location ILIKE ?", "%#{location}%") }
scope :from_ip, ->(ip) { where("client_info->>'ip' = ?", ip) }
scope :with_browser, ->(browser) { where("client_info->>'user_agent' ILIKE ?", "%#{browser}%") }
end
Each of these techniques addresses different aspects of content versioning. In my experience, most applications need a combination of these approaches. For a blog platform, basic PaperTrail might suffice. For collaborative document editing, you’ll need delta-based storage, conflict resolution, and diff visualization.
The beauty of Rails is how flexibly these techniques can be mixed and matched. I’ve combined PaperTrail with custom diff visualization for simpler projects, and built fully custom systems with branching and merging for complex workflows.
When implementing any versioning system, don’t forget about performance implications. Retrieving and comparing large versions can be resource-intensive. I recommend implementing background jobs for version comparisons, adding appropriate database indexes, and considering partial loading of version histories.
Content versioning isn’t just a technical feature—it provides confidence and security to your users. Knowing their work is protected and that they can track changes empowers them to experiment and collaborate freely. That’s the true value of a well-designed versioning system.