{"id":13877998,"url":"https://github.com/waymondo/hoardable","last_synced_at":"2025-10-08T17:15:58.426Z","repository":{"id":50150287,"uuid":"517427523","full_name":"waymondo/hoardable","owner":"waymondo","description":"ActiveRecord versioning and soft-deletion with Postgres using uni-temporal inherited tables","archived":false,"fork":false,"pushed_at":"2025-02-27T04:45:58.000Z","size":224,"stargazers_count":44,"open_issues_count":1,"forks_count":2,"subscribers_count":5,"default_branch":"main","last_synced_at":"2025-03-29T15:05:02.409Z","etag":null,"topics":["activerecord","database","postgres","postgresql","rails","ruby","soft-delete","versioning"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/waymondo.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2022-07-24T20:17:18.000Z","updated_at":"2025-02-27T04:46:02.000Z","dependencies_parsed_at":"2023-12-23T23:35:09.921Z","dependency_job_id":"33936906-7557-43b5-9f92-2b14c405f9a7","html_url":"https://github.com/waymondo/hoardable","commit_stats":{"total_commits":102,"total_committers":2,"mean_commits":51.0,"dds":0.009803921568627416,"last_synced_commit":"3277ce956b4fc09a563746f80018a20b731a48cf"},"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/waymondo%2Fhoardable","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/waymondo%2Fhoardable/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/waymondo%2Fhoardable/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/waymondo%2Fhoardable/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/waymondo","download_url":"https://codeload.github.com/waymondo/hoardable/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247361632,"owners_count":20926643,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activerecord","database","postgres","postgresql","rails","ruby","soft-delete","versioning"],"created_at":"2024-08-06T08:01:37.093Z","updated_at":"2025-10-08T17:15:53.399Z","avatar_url":"https://github.com/waymondo.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"# Hoardable ![gem version](https://img.shields.io/gem/v/hoardable?style=flat-square)\n\nHoardable is an ActiveRecord extension for Ruby 3+, Rails 7+, and PostgreSQL 9+ that allows for\nversioning and soft-deletion of records through the use of _uni-temporal inherited tables_.\n\n[Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern\nwhere each row of a table contains data along with one or more time ranges. In the case of this gem,\neach database row has a time range that represents the row’s valid time range - hence\n\"uni-temporal\".\n\n[Table inheritance](https://www.postgresql.org/docs/current/ddl-inherit.html) is a feature of\nPostgreSQL that allows one table to inherit all columns from a parent. The descendant table’s schema\nwill stay in sync with its parent; if a new column is added to or removed from the parent, the\nschema change is reflected on its descendants.\n\nWith these concepts combined, `hoardable` offers a model versioning and soft deletion system for\nRails. Versions of records are stored in separate, inherited tables along with their valid time\nranges and contextual data.\n\n[👉 Documentation](https://www.rubydoc.info/gems/hoardable)\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem \"hoardable\"\n```\n\nRun `bundle install`, and then run:\n\n```\nbin/rails g hoardable:install\nbin/rails db:migrate\n```\n\n### Model installation\n\nInclude `Hoardable::Model` into an ActiveRecord model you would like to hoard versions of:\n\n```ruby\nclass Post \u003c ActiveRecord::Base\n  include Hoardable::Model\nend\n```\n\nRun the generator command to create a database migration and migrate it:\n\n```\nbin/rails g hoardable:migration Post\nbin/rails db:migrate\n```\n\n_*Note*:_ Creating an inherited table does not inherit the indexes from the parent table. If you\nneed to query versions often, you should add appropriate indexes to the `_versions` tables.\n\n## Usage\n\n### Overview\n\nOnce you include `Hoardable::Model` into a model, it will dynamically generate a \"Version\" subclass\nof that model. As we continue our example from above:\n\n```ruby\nPost #=\u003e Post(id: integer, ..., hoardable_id: integer)\nPostVersion #=\u003e PostVersion(id: integer, ..., hoardable_id: integer, _data: jsonb, _during: tsrange, _event_uuid: uuid, _operation: enum)\nPost.version_class #=\u003e same as `PostVersion`\n```\n\nA `Post` now `has_many :versions`. With the default configuration, whenever an update or deletion of\na `post` occurs, a version is created:\n\n```ruby\npost = Post.create!(title: \"Title\")\npost.versions.size # =\u003e 0\npost.update!(title: \"Revised Title\")\npost.reload.versions.size # =\u003e 1\npost.versions.first.title # =\u003e \"Title\"\npost.destroy!\npost.trashed? # true\npost.versions.size # =\u003e 2\nPost.find(post.id) # raises ActiveRecord::RecordNotFound\n```\n\nEach `PostVersion` has access to the same attributes, relationships, and other model behavior that\n`Post` has, but as a read-only record:\n\n```ruby\npost.versions.last.update!(title: \"Rewrite history\") #=\u003e raises ActiveRecord::ReadOnlyRecord\n```\n\nIf you ever need to revert to a specific version, you can call `version.revert!` on it.\n\n```ruby\npost = Post.create!(title: \"Title\")\npost.update!(title: \"Whoops\")\nversion = post.reload.versions.last\nversion.title # -\u003e \"Title\"\nversion.revert!\npost.title # =\u003e \"Title\"\n```\n\nIf you would like to untrash a specific version of a record you deleted, you can call\n`version.untrash!` on it. This will re-insert the model in the parent class’s table with the\noriginal primary key.\n\n```ruby\npost = Post.create!(title: \"Title\")\npost.destroy!\npost.versions.size # =\u003e 1\nPost.find(post.id) # raises ActiveRecord::RecordNotFound\ntrashed_post = post.versions.trashed.last\ntrashed_post.untrash!\nPost.find(post.id) # #\u003cPost\u003e\n```\n\nSource and version records pull from the same ID sequence. This allows for uniquely identifying\nrecords from each other. Both source record and version have an automatically managed `hoardable_id`\nattribute that always represents the primary key value of the original source record:\n\n```ruby\npost = Post.create!(title: \"Title\")\npost.id # =\u003e 1\npost.hoardable_id # =\u003e 1\npost.version? # =\u003e false\npost.update!(title: \"New Title\")\nversion = post.reload.versions.last\nversion.id # =\u003e 2\nversion.hoardable_id # =\u003e 1\nversion.version? # =\u003e true\n```\n\n### Querying and temporal lookup\n\nIncluding `Hoardable::Model` into your source model modifies `default_scope` to make sure you only\never query the parent table and not the inherited ones:\n\n```ruby\nPost.where(state: :draft).to_sql # =\u003e SELECT posts.* FROM ONLY posts WHERE posts.status = 'draft'\n```\n\nNote the `FROM ONLY` above. If you are executing raw SQL, you will need to include the `ONLY`\nkeyword if you do not wish to return versions in your results. This includes `JOIN`-ing on this\ntable as well.\n\n```ruby\nUser.joins(:posts).to_sql # =\u003e SELECT users.* FROM users INNER JOIN ONLY posts ON posts.user_id = users.id\n```\n\nLearn more about table inheritance in [the PostgreSQL documentation](https://www.postgresql.org/docs/current/ddl-inherit.html).\n\nSince a `PostVersion` is an `ActiveRecord` class, you can query them like another model resource:\n\n```ruby\npost.versions.where(state: :draft)\n```\n\nBy default, `hoardable` will keep copies of records you have destroyed. You can query them\nspecifically with:\n\n```ruby\nPostVersion.trashed.where(user_id: user.id)\nPost.version_class.trashed.where(user_id: user.id) # \u003c- same as above\n```\n\nIf you want to look-up the version of a record at a specific time, you can use the `.at` method:\n\n```ruby\npost.at(1.day.ago) # =\u003e #\u003cPostVersion\u003e\n# or you can use the scope on the version model class\npost.versions.at(1.day.ago) # =\u003e #\u003cPostVersion\u003e\nPostVersion.at(1.day.ago).find_by(hoardable_id: post.id) # =\u003e same as above\n```\n\nThe source model class also has an `.at` method:\n\n```ruby\nPost.at(1.day.ago) # =\u003e [#\u003cPost\u003e, #\u003cPost\u003e]\n```\n\nThis will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were\nvalid at that time, all cast as instances of `Post`. Updates to the versions table are forbidden in\nthis case by a database trigger.\n\nThere is also `Hoardable.at` for more complex and experimental temporal resource querying. See\n[Relationships](#relationships) for more.\n\n### Tracking contextual data\n\nYou’ll often want to track contextual data about the creation of a version. There are 2 options that\ncan be provided for tracking this:\n\n- `:whodunit` - an identifier for who/what is responsible for creating the version\n- `:meta` - any other contextual information you’d like to store along with the version\n\nThis information is stored in a `jsonb` column. Each value can be the data type of your choosing.\n\nOne convenient way to assign contextual data to these is by defining a proc in an initializer, i.e.:\n\n```ruby\n# config/initializers/hoardable.rb\nHoardable.whodunit = -\u003e { Current.user\u0026.id }\n\n# somewhere in your app code\nCurrent.set(user: User.find(123)) do\n  post.update!(status: :live)\n  post.reload.versions.last.hoardable_whodunit # =\u003e 123\nend\n```\n\nAnother useful pattern would be to use `Hoardable.with` to set the context around a block. For\nexample, you could have the following in your `ApplicationController`:\n\n```ruby\nclass ApplicationController \u003c ActionController::Base\n  around_action :use_hoardable_context\n\n  private\n\n  def use_hoardable_context\n    Hoardable.with(whodunit: current_user.id, meta: { request_uuid: request.uuid }) do\n      yield\n    end\n  end\nend\n```\n\n[ActiveRecord changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes)\nare also automatically captured along with the `operation` that caused the version (`update` or\n`delete`). These values are available as:\n\n```ruby\nversion.changes # =\u003e { \"title\"=\u003e [\"Title\", \"New Title\"] }\nversion.hoardable_operation # =\u003e \"update\"\n```\n\n### Overriding the temporal range\n\nWhen calculating the temporal range for a given version, the default upper bound is `Time.now.utc`.\n\nYou can, however, use the `Hoardable.travel_to` class method to specify a custom upper bound for the time range. This allows\nyou to specify the datetime that a particular change should be recorded at by passing a block:\n\n```ruby\nHoardable.travel_to(2.weeks.ago) do\n  post.destroy!\nend\n```\n\nNote: If the provided datetime pre-dates the calculated lower bound then an `InvalidTemporalUpperBoundError` will be raised.\n\n### Model Callbacks\n\nSometimes you might want to do something with a version after it gets inserted to the database. You\ncan access it in `after_versioned` callbacks on the source record as `hoardable_version`. These\nhappen within `ActiveRecord#save`'s transaction.\n\nThere are also `after_reverted` and `after_untrashed` callbacks available as well, which are called\non the source record after a version is reverted or untrashed.\n\n```ruby\nclass User\n  include Hoardable::Model\n  after_versioned :track_versioned_event\n  after_reverted :track_reverted_event\n  after_untrashed :track_untrashed_event\n\n  private\n\n  def track_versioned_event\n    track_event(:user_versioned, hoardable_version)\n  end\n\n  def track_reverted_event\n    track_event(:user_reverted, self)\n  end\n\n  def track_untrashed_event\n    track_event(:user_untrashed, self)\n  end\nend\n```\n\n### Configuration\n\nThe configurable options are:\n\n```ruby\nHoardable.enabled # =\u003e true\nHoardable.version_updates # =\u003e true\nHoardable.save_trash # =\u003e true\n```\n\n`Hoardable.enabled` globally controls whether versions will be ever be created.\n\n`Hoardable.version_updates` globally controls whether versions get created on record updates.\n\n`Hoardable.save_trash` globally controls whether to create versions upon source record deletion.\nWhen this is set to `false`, all versions of a source record will be deleted when the record is\ndestroyed.\n\nIf you would like to temporarily set a config value, you can use `Hoardable.with`:\n\n```ruby\nHoardable.with(enabled: false) do\n  post.update!(title: \"replace title without creating a version\")\nend\n```\n\nYou can also configure these settings per `ActiveRecord` class using `hoardable_config`:\n\n```ruby\nclass Comment \u003c ActiveRecord::Base\n  include Hoardable::Model\n  hoardable_config version_updates: false\nend\n```\n\nIf you want to temporarily set the `hoardable_config` for a specific model, you can use\n`with_hoardable_config`:\n\n```ruby\nComment.with_hoardable_config(version_updates: true) do\n  comment.update!(text: \"Edited\")\nend\n```\n\nModel-level configuration overrides global configuration.\n\n### Single Table Inheritance\n\nHoardable works for [Single Table\nInheritance](https://guides.rubyonrails.org/association_basics.html#single-table-inheritance-sti). You\nwill need to include `Hoardable::Model` in each child model you'd like to version, as that is what\ngenerates the model's version class. The migration generator only needs to be run for the parent\nmodel, as the versions will similarly be stored in a single table.\n\n## Relationships\n\n### `belongs_to`\n\nSometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child\nrecord’s foreign key will point to the non-existent trashed version of the parent. If you would like\nto have `belongs_to` resolve to the trashed parent model in this case, you can give it the option of\n`trashable: true`:\n\n```ruby\nclass Post\n  include Hoardable::Model\n  has_many :comments, dependent: nil\nend\n\nclass Comment\n  include Hoardable::Associations # \u003c- This includes is not required if this model already includes `Hoardable::Model`\n  belongs_to :post, trashable: true\nend\n\npost = Post.create!(title: \"Title\")\ncomment = post.comments.create!(body: \"Comment\")\npost.destroy!\ncomment.post # =\u003e #\u003cPostVersion\u003e\n```\n\n### `has_many` \u0026 `has_one`\n\nSometimes you'll have a Hoardable record that `has_one` or `has_many` other Hoardable records and\nyou’ll want to know the state of both the parent record and the children at a certain point in time.\nYou can accomplish this by adding `hoardable: true` to the `has_many` relationship and using the\n`Hoardable.at` method:\n\n```ruby\nclass Post\n  include Hoardable::Model\n  has_many :comments, hoardable: true\nend\n\nclass Comment\n  include Hoardable::Model\nend\n\npost = Post.create!(title: \"Title\")\ncomment1 = post.comments.create!(body: \"Comment\")\ncomment2 = post.comments.create!(body: \"Comment\")\ndatetime = DateTime.current\n\ncomment2.destroy!\npost.update!(title: \"New Title\")\npost_id = post.id # 1\n\nHoardable.at(datetime) do\n  post = Post.find(post_id)\n  post.title # =\u003e \"Title\"\n  post.comments.size # =\u003e 2\n  post.version? # =\u003e true\n  post.id # =\u003e 2\n  post.hoardable_id # =\u003e 1\nend\n```\n\n_*Note*:_ `Hoardable.at` is experimental and potentially not performant for querying very large data\nsets.\n\n### Cascading Untrashing\n\nSometimes you’ll trash something that `has_many :children, dependent: :destroy` and if you untrash\nthe parent record, you’ll want to also untrash the children. Whenever a hoardable versions are\ncreated, it will share a unique event UUID for all other versions created in the same database\ntransaction. That way, when you `untrash!` a record, you could find and `untrash!` records that were\ntrashed with it:\n\n```ruby\nclass Comment \u003c ActiveRecord::Base\n  include Hoardable::Model\nend\n\nclass Post \u003c ActiveRecord::Base\n  include Hoardable::Model\n  has_many :comments, hoardable: true, dependent: :destroy\n\n  after_untrashed do\n    Comment\n      .version_class\n      .trashed\n      .with_hoardable_event_uuid(hoardable_event_uuid)\n      .find_each(\u0026:untrash!)\n  end\nend\n```\n\n### Action Text\n\nHoardable provides support for ActiveRecord models with `has_rich_text`. First, you must create a\ntemporal table for `ActionText::RichText`:\n\n```\nbin/rails g hoardable:migration ActionText::RichText\nbin/rails db:migrate\n```\n\nThen in your model include `Hoardable::Model` and provide the `hoardable: true` keyword to\n`has_rich_text`:\n\n```ruby\nclass Post \u003c ActiveRecord::Base\n  include Hoardable::Model # or `Hoardable::Associations` if you don't need `PostVersion`\n  has_rich_text :content, hoardable: true # or `has_hoardable_rich_text :content`\nend\n```\n\nNow the `rich_text_content` relationship will be managed as a Hoardable `has_one` relationship:\n\n```ruby\npost = Post.create!(content: '\u003cdiv\u003eHello World\u003c/div\u003e')\ndatetime = DateTime.current\npost.update!(content: '\u003cdiv\u003eGoodbye Cruel World\u003c/div\u003e')\npost.content.versions.size # =\u003e 1\npost.content.to_plain_text # =\u003e 'Goodbye Cruel World'\nHoardable.at(datetime) do\n  post.content.to_plain_text # =\u003e 'Hello World'\nend\n```\n\n## Known gotchas\n\n### Rails fixtures\n\nRails uses a method called\n[`disable_referential_integrity`](https://github.com/rails/rails/blob/06e9fbd954ab113108a7982357553fdef285bff1/activerecord/lib/active_record/connection_adapters/postgresql/referential_integrity.rb#L7)\nwhen inserting fixtures into the database. This disables PostgreSQL triggers, which Hoardable relies\non for assigning `hoardable_id` from the primary key’s value. If you would still like to use\nfixtures, you must specify the primary key’s value and `hoardable_id` to the same identifier value\nin the fixture.\n\n## Gem comparison\n\n#### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)\n\n`paper_trail` is maybe the most popular and fully featured gem in this space. It works for other\ndatabase types than PostgeSQL. Bby default it stores all versions of all versioned models in a\nsingle `versions` table. It stores changes in a `text`, `json`, or `jsonb` column. In order to\nefficiently query the `versions` table, a `jsonb` column should be used, which can take up a lot of\nspace to index. Unless you customize your configuration, all `versions` for all models types are in\nthe same table which is inefficient if you are only interested in querying versions of a single\nmodel. By contrast, `hoardable` stores versions in smaller, isolated, inherited tables with the same\ndatabase columns as their parents, which are more efficient for querying as well as auditing for\ntruncating and dropping. The concept of a temporal timeframe does not exist for a single version\nsince there is only a `created_at` timestamp.\n\n#### [`audited`](https://github.com/collectiveidea/audited)\n\n`audited` works in a similar manner as `paper_trail`. It stores all versions for all model types in\na single table, you must opt into using `jsonb` as the column type to store \"changes\", in case you\nwant to query them, and there is no concept of a temporal timeframe for a single version. It makes\nopinionated decisions about contextual data requirements and stores them as top level data types on\nthe `audited` table.\n\n#### [`discard`](https://github.com/jhawthorn/discard)\n\n`discard` only covers soft-deletion. The act of \"soft deleting\" a record is only captured through\nthe time-stamping of a `discarded_at` column on the records table. There is no other capturing of\nthe event that caused the soft deletion unless you implement it yourself. Once the \"discarded\"\nrecord is restored, the previous \"discarded\" awareness is lost. Since \"discarded\" records exist in\nthe same table as \"undiscarded\" records, you must explicitly omit the discarded records from queries\nacross your app to keep them from leaking in.\n\n#### [`paranoia`](https://github.com/rubysherpas/paranoia)\n\n`paranoia` also only covers soft-deletion. In their README, they recommend using `discard` instead\nof `paranoia` because of the fact they override ActiveRecord’s `delete` and `destroy` methods.\n`hoardable` employs callbacks to create trashed versions instead of overriding methods. Otherwise,\n`paranoia` works similarly to `discard` in that it keeps deleted records in the same table and tags\nthem with a `deleted_at` timestamp. No other information about the soft-deletion event is stored.\n\n#### [`logidze`](https://github.com/palkan/logidze)\n\n`logidze` is an interesting versioning alternative that leverages the power of PostgreSQL triggers.\nInstead of storing the previous versions or changes in a separate table, it stores them in a\nproprietary JSON format directly on the database row of the record itself. If does not support soft\ndeletion.\n\n## Testing\n\nHoardable is tested against a matrix of Ruby 3 versions and Rails 7 \u0026 8. To run tests locally, run:\n\n```\nrake\n```\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/waymondo/hoardable.\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwaymondo%2Fhoardable","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fwaymondo%2Fhoardable","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwaymondo%2Fhoardable/lists"}