0.0
The project is in a healthy, maintained state
Scaffolds a recordings/recordables/events content spine into a Rails app, and keeps the snapshot copy-forward logic that is easy to get silently wrong.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.1, < 9
~> 2.6
 Project Readme

recordables

Gem Version CI

Versioned, immutable content for Rails. Every edit writes a new snapshot instead of overwriting a row, so document history, "restore this version", and a single activity feed across every content type all come from the same three tables.

Built on Rails' own delegated_type.

The idea

A blog post is normally one row that you UPDATE. The old text is gone, and nothing in the database knows a post and a message are the same kind of thing. This splits it in three:

recording  ──points at──▶  recordable     the content (Article, Note, Comment)
    │
    └──has many──▶  events                every change, and which snapshot was
                                          current when it happened
  • recordings — the spine. Foreign keys and position only. No text columns, no status — it stays cheap to index and paginate however large it grows, and never becomes a state machine (see "What recordable is for" below).
  • recordables — the content. Immutable: an edit inserts a row, so none of them carry updated_at.
  • events — append-only, and the reason history and the activity feed are the same data. Survives its Recording being destroyed — see "Destroying a recordable" below.

What recordable is for

Adopt it for content people draft and revise — an article, a comment, a template someone edits over time and might want to compare or restore an earlier version of. That's genuinely what this gem is for, and the whole point of the three tables above.

Don't adopt it for workflow state. A Task moving initial → in_progress → completed, an order moving pending → shipped, isn't content with a history worth version-browsing — it's a state machine, and a plain status enum column with an ordinary update! is the right tool. Modeling a status transition as revise() (a whole new row, a new id, every associated record needing to be repointed) buys none of what versioning is for and costs real complexity: every existing reference to the row goes stale the instant the status changes, #reload silently starts returning the pre-transition version (see Testing, below), and every join or belongs_to pointed at it needs its own repoint story.

If a type doesn't need "what did this used to say" or "restore an earlier draft," it almost certainly doesn't need recordable — just a column.

Status belongs on the recordable, never on the Recording. If a recordable type genuinely does need a status (a document that's draft/published, say), that's a plain column on the recordable's own table, alongside its other content — not something this gem tracks on Recording on the type's behalf. Recording only ever answers "what changed, when, and by whom" — it has no opinion on what any particular snapshot's content means.

Getting started

gem "recordables"
bin/rails generate recordables:install
bin/rails generate recordables:bucket Project name:string
bin/rails generate recordables:type Article title:string
bin/rails db:migrate

Models read the way Rails reads:

class Recording < ApplicationRecord
  records :recordable, types: Recordable::TYPES
end

class Article < ApplicationRecord
  recordable
end

Versioning a document

doc = Recording.record(Article.new(title: "Spec v1"), actor: current_user, bucket: bucket)

doc.revise(actor: current_user, title: "Spec v2")
doc.revise(actor: current_user, title: "Spec v3")

doc.versions                      # every snapshot, with who and when
doc.recordable_at(2.days.ago)     # what it said then
doc.revert_to(first_snapshot, actor: current_user)

Three edits leave one recording and three snapshots. Restoring is a single foreign-key update — nothing is deleted, and the revert is itself recorded:

v1  created   by Jonas  Spec v1
v2  updated   by Jonas  Spec v2
v3  updated   by Jonas  Spec v3

One feed across every type

Event.newest_first.limit(50)
bucket.timeline

Adding a content type costs nothing here — generate it and it appears. No new query, no new branch in the feed code.

Rich text and attachments

This is the part worth having a library for. attributes only carries columns, so a naive snapshot copy drops ActionText bodies and Active Storage files without raising. You would ship it and find out months later that every edited document lost its images.

copy_content_to finds them by reflection and carries them across. Files are shared rather than duplicated — four versions of a document with a cover image produce four attachment rows and one blob, and Rails reference-counts blobs, so purging one version leaves the others readable.

It refuses rather than lose data

A snapshot cannot carry ordinary has_many / has_one associations, so revise raises instead of quietly dropping them:

Article owns notes, which a new snapshot cannot carry.
Model these as child recordings, or override #copy_content_to.

Children modelled the intended way — as child recordings — hang off the recording rather than the snapshot, so revising content never touches them.

What is generated, and what the gem keeps

Where it lives
Migrations, Recording, Event, Bucket, the concerns, each content type Generated into your app. You own and edit these; the gem never touches them again
records / recordable macros, revise, revert_to, destroy!, versions, recordable_at, copy_content_to and its guard Kept in the gem
recordable_belongs_to, immutable, has_children, repoint_on_revise, nested_recordable_attributes_for, Recordables::Testing Kept in the gem — all opt-in, none of it changes behavior for a model that doesn't call it

Soft-delete (a "trashed but still in the table" state) isn't something this gem provides — that's a separate concern from versioning, and conflating them was a mistake an earlier version of this gem made. If your app wants soft-delete, layer it on top of your own recordable type with your own scope; recordable won't fight you, and it won't manage that state for you either.

The rule: generate what's opinionated, keep what fails silently. Permissions, controllers and tree semantics are deliberately yours — that is why the generated files are plain Rails you can rewrite freely.

Destroying a recordable

Deleting is a real, hard delete — not a status flip. destroy! on a Recording removes the Recording and its current recordable row:

class Article < ApplicationRecord
  recordable
end

article.recording.destroy!(actor: current_user)

Article.exists?(article.id)  # false — really gone

What survives is the log: every Event this recordable ever produced (created, updated, reverted) stays in place, including a final destroyed event this call logs before removing anything. An event's recording_id is nullified rather than cascaded when its Recording goes away — the fact that an article was created, edited twice, and later deleted is itself part of the record this gem exists to keep, even once the content is gone. This matters for compliance flows too: deleting an account or an organization needs an actual purge path, not a soft-delete flag that keeps the content sitting in the table forever waiting for a "real" cleanup that never has to happen.

The same applies to who did something. An event's actor can be deleted independently of any recordable it acted on — a person leaves the account, say. actor_id is nullified rather than left dangling, but the event still needs to read sensibly afterward, so a name is snapshotted onto the event the moment it's created:

event.actor            # nil, once the actor row is gone
event.actor_name        # "Jonas" — captured when the event happened, survives regardless
event.actor_label       # actor&.name || actor_name — whichever is available right now

recordables:install asks which attribute to snapshot (--actor-label, defaults to name) — point it at whatever your actor's display name method is, e.g. --actor-label=display_name.

The other durability edge is belongs_to. Rails' association reader builds its own WHERE id = ... directly against the target class using whatever id was assigned — but revise() repoints a Recording at a brand new row the moment content changes, so a plain belongs_to :article holding an id from before that silently returns the stale, superseded row (or nil, if it's since been destroyed) instead of the current one. This is the sharpest edge in the whole pattern, and it fails silently: nothing raises, the wrong data just quietly comes back. Never hold onto or compare a recording_id directly for this reason — always resolve through the recordable's own stable id. recordable_belongs_to is the fix:

class Comment < ApplicationRecord
  recordable_belongs_to :article
end

It resolves through the append-only Event log instead of the raw foreign key — an event's recordable_id is set once, when that event happened, and never rewritten, so it still names the right lineage no matter how many times the target's been revised since.

Enforcing immutability

Nothing about recordable stops a template.update!(name: "x") from working even after you've adopted revise() everywhere else — it just silently mutates the row in place. No new snapshot, no history entry, no way to revert, and anything reading cache_version or updated_at through the Recording never sees the change. immutable turns that into a hard failure instead of a silent one:

class Article < ApplicationRecord
  recordable
  immutable
end

article.update!(title: "x")   # raises Recordables::ImmutableRecordable
article.update_column(:title, "x")  # raises too — callbacks alone don't catch this
Article.update_all(title: "x")      # raises at the class level

record/revise are unaffected — they only ever save a fresh, not-yet-persisted instance, never a second write against a row that's already there. destroy! on the Recording is also unaffected — it's the sanctioned way to remove an immutable recordable's row.

Children: a real has_many, or a child Recording?

Two different shapes both look like "this recordable owns other rows," and mixing them up either loses data or silently orphans it.

A child Recording (has_children) is for content that's genuinely part of the parent's lineage — a Task under its Routine, a line item under an order. A plain foreign key would orphan every child the moment the parent is revised (a new row, a new id) — the fix is letting the child's own Recording carry the parent link instead, via parent_id, which survives revisions because a Recording's own id never changes:

class Routine < ApplicationRecord
  recordable
  has_children :tasks
end

routine.tasks                              # current children, ordered
routine.add_task(actor: current_user, name: "Stretch")

A real has_many (repoint_on_revise) is for join rows that point at a recordable by a plain foreign key, and need to follow a revision rather than travel with the child-Recording tree — a tagging table, an assignment. revise() alone would leave every one of those rows FK'd to the old, now-superseded id. repoint_on_revise moves them to the new one in the same transaction, and also defines copy_content_to as a no-op — these associations were never content to copy forward, they're pointers at the row, so the default UncopyableAssociation guard doesn't apply here:

class RoutineTemplate < ApplicationRecord
  recordable
  repoint_on_revise :routine_template_people
end

If in doubt: does the association exist to organize the recordable's own content (child Recording), or does something external point at it (repoint_on_revise)?

Nested forms

accepts_nested_attributes_for can't work on a has_children association — it writes straight to association rows at assign_attributes time, but a new child needs its own Recording, which needs the parent to already be saved or revised. nested_recordable_attributes_for does the same job (a form posts an array of {id:, ...fields, _destroy:} hashes) through revise()/destroy! instead of a raw write:

class Routine < ApplicationRecord
  recordable
  has_children :tasks
  nested_recordable_attributes_for :tasks
end
<%= form_with model: @routine do |f| %>
  <%= f.fields_for :tasks, @routine.tasks_for_form do |task_fields| %>
    ...
  <% end %>
<% end %>

Two-phase, matching how a controller/interactor already has to split "assign" from "save" here: tasks_attributes= stores the submitted rows without writing anything, then apply_tasks_attributes!(actor:) — called once the routine itself has a current Recording — creates, revises, or destroys each one.

Testing

The most common mistake this whole pattern invites: calling #reload on a recordable after an action that revised it.

task = create_a_task
complete_the_task(task)
task.reload.status   # => "initial", not "complete"

revise() gives an edit a brand new row — a new primary key — and repoints the Recording at it. task still holds the old primary key, so #reload re-fetches by that key: not an error, not nil, just exactly the row the action was supposed to replace. A test asserting on task after the action silently checks what things looked like before.

class ActiveSupport::TestCase
  include Recordables::Testing
end
recording = task.recording      # capture before — a Recording's id never changes
complete_the_task(task)
assert_equal "complete", current_recordable(recording).status

Adopting recordable on an existing table

Scaffolds the migration that creates each existing row's first Recording (and its created Event), backfilled from the row's own created_at:

bin/rails generate recordables:backfill RoutineTemplate
# generated: db/migrate/..._backfill_routine_templates_recordings.rb
RoutineTemplate.find_each do |routine_template|
  Recording.record(routine_template, actor: routine_template.actor, created_at: routine_template.created_at)
end

Caveats

  • Never put a uniqueness validation on a recordable. Old snapshots still hold the old value, so the second edit would fail.
  • Every edit inserts a row. Cheap for text; decide on retention before putting large uploads through it.
  • events.details is a json column. Ruby 4 ships json 3.x, whose JSON.parse moved to keyword arguments while ActiveSupport 8.1 still calls it positionally. Pin gem "json", "~> 2.7" until that is fixed upstream.
  • Model.delete_all on a recordable type bypasses destroy!, so the Events it would have logged never happen and any content-only associations it owns (rich text, attachments) can be left behind. Prefer looping recording.destroy!(actor:) per row, or destroy_all if the type has no other before_destroy concerns to skip.

Development

bundle install
bundle exec rake test

The suite boots a small Rails application in test/dummy, so Action Text, Active Storage and the generators are exercised for real rather than stubbed. The suite covers the snapshot lifecycle, rich text and attachment copy-forward, blob sharing, the uncopyable-association guard, and all three generators.

License

MIT.