recordables
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:migrateModels read the way Rails reads:
class Recording < ApplicationRecord
records :recordable, types: Recordable::TYPES
end
class Article < ApplicationRecord
recordable
endVersioning 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.timelineAdding 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 goneWhat 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 nowrecordables: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
endIt 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 levelrecord/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
endIf 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
endrecording = task.recording # capture before — a Recording's id never changes
complete_the_task(task)
assert_equal "complete", current_recordable(recording).statusAdopting 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)
endCaveats
- Never put a
uniquenessvalidation 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.detailsis ajsoncolumn. Ruby 4 ships json 3.x, whoseJSON.parsemoved to keyword arguments while ActiveSupport 8.1 still calls it positionally. Pingem "json", "~> 2.7"until that is fixed upstream. -
Model.delete_allon a recordable type bypassesdestroy!, so the Events it would have logged never happen and any content-only associations it owns (rich text, attachments) can be left behind. Prefer loopingrecording.destroy!(actor:)per row, ordestroy_allif the type has no other before_destroy concerns to skip.
Development
bundle install
bundle exec rake testThe 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.