recordables 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9c0cb9bc821c580bd8f8f9e0e321ad0433b6905b98420ebacc0c2b960556017a
4
+ data.tar.gz: 9dc7a3de73258300b9b09ab6ab68c8ce3e8909e120e5e0480c7038cea52bc2c6
5
+ SHA512:
6
+ metadata.gz: bd212dc151fbc5013e6f5f76d72da3eaadaedd955d97aa0c34572bc6f323dc309dbcacbc2aa6ce8d9888efdeec14bf9e45b7f19635206eab5c036e08936aa0ab
7
+ data.tar.gz: 71de19f67e01f321d3c86d20368bcf04462af941cbc5472333cfa6f3768ecafc878ec4ace7a281fed1ad04825e452f485d55d03481f58ee2cb25cf3507f130a4
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Fixed
10
+
11
+ - `revise` silently discarded edits to rich text: changes were merged into the column
12
+ attributes before `copy_content_to` ran, so the copy overwrote them with the previous
13
+ body. Changes are now applied after the copy and always win.
14
+ - `recordables:install --actor` accepted any string, generating models with a broken
15
+ `class_name:` reference (e.g. `--actor="bad name"`) that only failed at runtime, far
16
+ from the mistake. It now validates the same way `recordables:type` and
17
+ `recordables:bucket` already did.
18
+
19
+ ### Added
20
+
21
+ - `records` and `recordable` class macros on `ActiveRecord::Base`.
22
+ - `recordables:install` generator — migration, `Recording`, `Event`, the `Recordable`
23
+ concern, and optionally `Bucket` / `Bucketable` (`--skip-buckets` to omit).
24
+ - `recordables:type` generator — an immutable content type, registered in `Recordable::TYPES`.
25
+ - `recordables:bucket` generator — a container type, registered in `Bucketable::TYPES`.
26
+ - `revise`, `revert_to`, `versions`, `recordable_at` for snapshot versioning and history.
27
+ - `copy_content_to` carries ActionText rich text and Active Storage attachments onto a new
28
+ snapshot, discovered by reflection rather than per-type configuration.
29
+ - `Recordables::Recordable::UncopyableAssociation` — raised instead of silently dropping
30
+ ordinary `has_many` / `has_one` associations a snapshot cannot carry.
31
+ - A dummy Rails application under `test/dummy`, so the suite exercises Action Text,
32
+ Active Storage and the generators against a real Rails environment.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Jonas Medeiros
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # recordables
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/recordables.svg)](https://rubygems.org/gems/recordables)
4
+ [![CI](https://github.com/jonasmedeiros/recordables/actions/workflows/ci.yml/badge.svg)](https://github.com/jonasmedeiros/recordables/actions/workflows/ci.yml)
5
+
6
+ Versioned, immutable content for Rails. Every edit writes a new snapshot instead of
7
+ overwriting a row, so document history, "restore this version", and a single activity feed
8
+ across every content type all come from the same three tables.
9
+
10
+ Built on Rails' own `delegated_type`.
11
+
12
+ ## The idea
13
+
14
+ A blog post is normally one row that you `UPDATE`. The old text is gone, and nothing in the
15
+ database knows a post and a message are the same *kind* of thing. This splits it in three:
16
+
17
+ ```
18
+ recording ──points at──▶ recordable the content (Article, Note, Comment)
19
+
20
+ └──has many──▶ events every change, and which snapshot was
21
+ current when it happened
22
+ ```
23
+
24
+ - **`recordings`** — the spine. Foreign keys, status, position. No text columns, so it stays
25
+ cheap to index and paginate however large it grows.
26
+ - **recordables** — the content. Immutable: an edit inserts a row, so none of them carry
27
+ `updated_at`.
28
+ - **`events`** — append-only, and the reason history and the activity feed are the same data.
29
+
30
+ ## Getting started
31
+
32
+ ```ruby
33
+ gem "recordables"
34
+ ```
35
+
36
+ ```bash
37
+ bin/rails generate recordables:install
38
+ bin/rails generate recordables:bucket Project name:string
39
+ bin/rails generate recordables:type Article title:string
40
+ bin/rails db:migrate
41
+ ```
42
+
43
+ Models read the way Rails reads:
44
+
45
+ ```ruby
46
+ class Recording < ApplicationRecord
47
+ records :recordable, types: Recordable::TYPES
48
+ end
49
+
50
+ class Article < ApplicationRecord
51
+ recordable
52
+ end
53
+ ```
54
+
55
+ ## Versioning a document
56
+
57
+ ```ruby
58
+ doc = Recording.record(Article.new(title: "Spec v1"), actor: current_user, bucket: bucket)
59
+
60
+ doc.revise(actor: current_user, title: "Spec v2")
61
+ doc.revise(actor: current_user, title: "Spec v3")
62
+
63
+ doc.versions # every snapshot, with who and when
64
+ doc.recordable_at(2.days.ago) # what it said then
65
+ doc.revert_to(first_snapshot, actor: current_user)
66
+ ```
67
+
68
+ Three edits leave one recording and three snapshots. Restoring is a single foreign-key
69
+ update — nothing is deleted, and the revert is itself recorded:
70
+
71
+ ```
72
+ v1 created by Jonas Spec v1
73
+ v2 updated by Jonas Spec v2
74
+ v3 updated by Jonas Spec v3
75
+ ```
76
+
77
+ ## One feed across every type
78
+
79
+ ```ruby
80
+ Event.newest_first.limit(50)
81
+ bucket.timeline
82
+ ```
83
+
84
+ Adding a content type costs nothing here — generate it and it appears. No new query, no new
85
+ branch in the feed code.
86
+
87
+ ## Rich text and attachments
88
+
89
+ This is the part worth having a library for. `attributes` only carries columns, so a naive
90
+ snapshot copy drops ActionText bodies and Active Storage files **without raising**. You would
91
+ ship it and find out months later that every edited document lost its images.
92
+
93
+ `copy_content_to` finds them by reflection and carries them across. Files are shared rather
94
+ than duplicated — four versions of a document with a cover image produce four attachment rows
95
+ and one blob, and Rails reference-counts blobs, so purging one version leaves the others
96
+ readable.
97
+
98
+ ## It refuses rather than lose data
99
+
100
+ A snapshot cannot carry ordinary `has_many` / `has_one` associations, so `revise` raises
101
+ instead of quietly dropping them:
102
+
103
+ ```
104
+ Article owns notes, which a new snapshot cannot carry.
105
+ Model these as child recordings, or override #copy_content_to.
106
+ ```
107
+
108
+ Children modelled the intended way — as child *recordings* — hang off the recording rather
109
+ than the snapshot, so revising content never touches them.
110
+
111
+ ## What is generated, and what the gem keeps
112
+
113
+ | | Where it lives |
114
+ |---|---|
115
+ | Migrations, `Recording`, `Event`, `Bucket`, the concerns, each content type | **Generated into your app.** You own and edit these; the gem never touches them again |
116
+ | `records` / `recordable` macros, `revise`, `revert_to`, `versions`, `recordable_at`, `copy_content_to` and its guard | **Kept in the gem** |
117
+
118
+ The rule: **generate what's opinionated, keep what fails silently.** Permissions, controllers
119
+ and tree semantics are deliberately yours — that is why the generated files are plain Rails
120
+ you can rewrite freely.
121
+
122
+ ## Caveats
123
+
124
+ - Never put a `uniqueness` validation on a recordable. Old snapshots still hold the old
125
+ value, so the second edit would fail.
126
+ - Every edit inserts a row. Cheap for text; decide on retention before putting large uploads
127
+ through it.
128
+ - `events.details` is a `json` column. Ruby 4 ships json 3.x, whose `JSON.parse` moved to
129
+ keyword arguments while ActiveSupport 8.1 still calls it positionally. Pin
130
+ `gem "json", "~> 2.7"` until that is fixed upstream.
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ bundle install
136
+ bundle exec rake test
137
+ ```
138
+
139
+ The suite boots a small Rails application in `test/dummy`, so Action Text, Active Storage
140
+ and the generators are exercised for real rather than stubbed. 41 tests cover the snapshot
141
+ lifecycle, rich text and attachment copy-forward, blob sharing, the uncopyable-association
142
+ guard, and all three generators.
143
+
144
+ ## License
145
+
146
+ MIT.
@@ -0,0 +1,36 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "recordables/generator_helpers"
4
+
5
+ module Recordables
6
+ module Generators
7
+ class BucketGenerator < Rails::Generators::NamedBase
8
+ include ActiveRecord::Generators::Migration
9
+ include Recordables::GeneratorHelpers
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Generate a bucket type — a container that owns recordings and defines access."
14
+
15
+ def validate!
16
+ validate_name!
17
+ end
18
+
19
+ def create_model
20
+ template "model.rb.tt", File.join("app/models", class_path, "#{file_name}.rb")
21
+ end
22
+
23
+ def create_bucket_migration
24
+ migration_template "migration.rb.tt", "db/migrate/create_#{table_name}.rb"
25
+ end
26
+
27
+ def register_type
28
+ register_type_in "app/models/concerns/bucketable.rb", "TYPES"
29
+ end
30
+
31
+ private
32
+
33
+ def generator_example = "bin/rails generate recordables:bucket Project name:string"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,10 @@
1
+ class Create<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ <% attributes.each do |attribute| -%>
5
+ t.<%= attribute.type %> :<%= attribute.name %>, null: false
6
+ <% end -%>
7
+ t.timestamps
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ class <%= class_name %> < ApplicationRecord
2
+ include Bucketable
3
+ <% if attributes.any? -%>
4
+
5
+ validates <%= attributes.map { |attribute| ":#{attribute.name}" }.join(", ") %>, presence: true
6
+ <% end -%>
7
+ end
@@ -0,0 +1,58 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "recordables/generator_helpers"
4
+
5
+ module Recordables
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ desc "Generate the recordings/events spine, and optionally the bucket container."
13
+
14
+ class_option :actor, type: :string, default: "User",
15
+ desc: "Model that creates recordings and appears on events"
16
+ class_option :buckets, type: :boolean, default: true,
17
+ desc: "Generate Bucket, the container that owns recordings"
18
+
19
+ def validate!
20
+ return if actor_class.match?(Recordables::GeneratorHelpers::CONSTANT_NAME)
21
+
22
+ raise Rails::Generators::Error,
23
+ "Invalid --actor #{options[:actor].inspect}. Give a single CamelCase class name, " \
24
+ "for example: --actor=Person"
25
+ end
26
+
27
+ def create_spine_migration
28
+ migration_template "create_recordables_tables.rb.tt", "db/migrate/create_recordables_tables.rb"
29
+ end
30
+
31
+ def create_models
32
+ template "recording.rb.tt", "app/models/recording.rb"
33
+ template "event.rb.tt", "app/models/event.rb"
34
+ template "recordable.rb.tt", "app/models/concerns/recordable.rb"
35
+ return unless buckets?
36
+
37
+ template "bucket.rb.tt", "app/models/bucket.rb"
38
+ template "bucketable.rb.tt", "app/models/concerns/bucketable.rb"
39
+ end
40
+
41
+ def report
42
+ say "\nNext:", :green
43
+ say " bin/rails generate recordables:bucket Project name:string" if buckets?
44
+ say " bin/rails generate recordables:type Article title:string"
45
+ end
46
+
47
+ private
48
+
49
+ def buckets? = options[:buckets]
50
+
51
+ def actor_class = options[:actor].camelize
52
+
53
+ def actor_table = actor_class.tableize
54
+
55
+ def migration_version = "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,13 @@
1
+ class Bucket < ApplicationRecord
2
+ delegated_type :bucketable, types: Bucketable::TYPES, inverse_of: :bucket
3
+
4
+ has_many :recordings, dependent: :destroy
5
+ has_many :events, through: :recordings
6
+
7
+ def self.open(bucketable)
8
+ bucketable.save! if bucketable.new_record?
9
+ find_or_create_by!(bucketable: bucketable)
10
+ end
11
+
12
+ def timeline = events.includes(:actor, :recordable, :recording).newest_first
13
+ end
@@ -0,0 +1,11 @@
1
+ module Bucketable
2
+ extend ActiveSupport::Concern
3
+
4
+ TYPES = %w[].freeze
5
+
6
+ included do
7
+ has_one :bucket, as: :bucketable, dependent: :destroy
8
+ end
9
+
10
+ def bucket! = Bucket.open(self)
11
+ end
@@ -0,0 +1,42 @@
1
+ class CreateRecordablesTables < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ <% if buckets? -%>
4
+ create_table :buckets do |t|
5
+ t.references :bucketable, polymorphic: true, null: false, index: false
6
+ t.timestamps
7
+
8
+ t.index [:bucketable_type, :bucketable_id], unique: true
9
+ end
10
+
11
+ <% end -%>
12
+ create_table :recordings do |t|
13
+ <% if buckets? -%>
14
+ t.references :bucket, null: false, foreign_key: true
15
+ <% end -%>
16
+ t.references :parent, foreign_key: { to_table: :recordings }
17
+ t.references :creator, null: false, foreign_key: { to_table: :<%= actor_table %> }
18
+ t.references :recordable, polymorphic: true, null: false, index: false
19
+ t.integer :status, null: false, default: 0
20
+ t.integer :position
21
+ t.timestamps
22
+
23
+ t.index [:recordable_type, :recordable_id]
24
+ <% if buckets? -%>
25
+ t.index [:bucket_id, :recordable_type, :created_at]
26
+ <% end -%>
27
+ t.index [:parent_id, :position]
28
+ end
29
+
30
+ create_table :events do |t|
31
+ t.references :recording, null: false, foreign_key: true
32
+ t.references :recordable, polymorphic: true, null: false, index: false
33
+ t.references :actor, null: false, foreign_key: { to_table: :<%= actor_table %> }
34
+ t.string :action, null: false
35
+ t.json :details, null: false, default: {}
36
+ t.datetime :created_at, null: false
37
+
38
+ t.index [:recording_id, :created_at]
39
+ t.index [:action, :created_at]
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,8 @@
1
+ class Event < ApplicationRecord
2
+ belongs_to :recording
3
+ belongs_to :recordable, polymorphic: true
4
+ belongs_to :actor, class_name: "<%= actor_class %>"
5
+
6
+ scope :newest_first, -> { order(created_at: :desc, id: :desc) }
7
+ scope :of_type, ->(type) { where(recordable_type: type) }
8
+ end
@@ -0,0 +1,3 @@
1
+ module Recordable
2
+ TYPES = %w[].freeze
3
+ end
@@ -0,0 +1,19 @@
1
+ class Recording < ApplicationRecord
2
+ records :recordable, types: Recordable::TYPES, inverse_of: :recordings
3
+
4
+ enum :status, { active: 0, archived: 1, trashed: 2 }
5
+
6
+ <% if buckets? -%>
7
+ belongs_to :bucket
8
+ <% end -%>
9
+ belongs_to :creator, class_name: "<%= actor_class %>"
10
+ belongs_to :parent, class_name: "Recording", optional: true
11
+
12
+ has_many :children, class_name: "Recording", foreign_key: :parent_id, dependent: :destroy
13
+ has_many :events, dependent: :destroy
14
+
15
+ delegate :commentable?, :publishable?, :nestable?, :summary, to: :recordable
16
+
17
+ scope :newest_first, -> { order(created_at: :desc, id: :desc) }
18
+ scope :sorted, -> { order(:position, :id) }
19
+ end
@@ -0,0 +1,10 @@
1
+ class Create<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ <% attributes.each do |attribute| -%>
5
+ t.<%= attribute.type %> :<%= attribute.name %>, null: false
6
+ <% end -%>
7
+ t.datetime :created_at, null: false
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ class <%= class_name %> < ApplicationRecord
2
+ recordable
3
+ <% if attributes.any? -%>
4
+
5
+ validates <%= attributes.map { |attribute| ":#{attribute.name}" }.join(", ") %>, presence: true
6
+ <% end -%>
7
+
8
+ def summary = <%= summary_expression %>
9
+ end
@@ -0,0 +1,44 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "recordables/generator_helpers"
4
+
5
+ module Recordables
6
+ module Generators
7
+ class TypeGenerator < Rails::Generators::NamedBase
8
+ include ActiveRecord::Generators::Migration
9
+ include Recordables::GeneratorHelpers
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Generate an immutable recordable type and register it with the Recordable concern."
14
+
15
+ def validate!
16
+ validate_name!
17
+ end
18
+
19
+ def create_model
20
+ template "model.rb.tt", File.join("app/models", class_path, "#{file_name}.rb")
21
+ end
22
+
23
+ def create_type_migration
24
+ migration_template "migration.rb.tt", "db/migrate/create_#{table_name}.rb"
25
+ end
26
+
27
+ def register_type
28
+ register_type_in "app/models/concerns/recordable.rb", "TYPES"
29
+ end
30
+
31
+ private
32
+
33
+ def generator_example = "bin/rails generate recordables:type Article title:string"
34
+
35
+ def summary_expression
36
+ attribute = attributes.find { |candidate| candidate.type == :string } ||
37
+ attributes.find { |candidate| candidate.type == :text }
38
+ return "model_name.human" unless attribute
39
+
40
+ attribute.type == :text ? "#{attribute.name}.truncate(40)" : attribute.name
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ module Recordables
2
+ module GeneratorHelpers
3
+ CONSTANT_NAME = /\A[A-Z][A-Za-z0-9]*(::[A-Z][A-Za-z0-9]*)*\z/
4
+
5
+ def self.included(base)
6
+ base.argument :attributes, type: :array, default: [], banner: "field:type field:type"
7
+ end
8
+
9
+ def validate_name!
10
+ return if class_name.match?(CONSTANT_NAME)
11
+
12
+ raise Rails::Generators::Error,
13
+ "Invalid name #{name.inspect}. Give a single CamelCase class name, " \
14
+ "then the fields — for example: #{generator_example}"
15
+ end
16
+
17
+ private
18
+
19
+ def register_type_in(concern, constant)
20
+ unless File.exist?(File.join(destination_root, concern))
21
+ say_status :skip, "#{concern} not found — run recordables:install first", :red
22
+ return
23
+ end
24
+
25
+ gsub_file concern, /#{constant} = %w\[[^\]]*\]/ do |match|
26
+ names = (match[/\[([^\]]*)\]/, 1].to_s.split + [class_name]).uniq
27
+ "#{constant} = %w[#{names.join(' ')}]"
28
+ end
29
+ end
30
+
31
+ def migration_version = "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ module Recordables
2
+ # Class macros mixed into ActiveRecord::Base, so a model reads the way the
3
+ # rest of Rails does — `records`, not `include SomeModule`.
4
+ module Macros
5
+ # The spine. Wraps delegated_type so one call sets up the pointer, the type
6
+ # scopes, and the snapshot/version API together.
7
+ #
8
+ # class Recording < ApplicationRecord
9
+ # records :recordable, types: Recordable::TYPES
10
+ # end
11
+ def records(role, types:, **options)
12
+ include Recordables::Recording
13
+
14
+ delegated_type role, types: types, **options
15
+ end
16
+
17
+ # An immutable content type: edits insert a new row instead of updating one.
18
+ #
19
+ # class Post < ApplicationRecord
20
+ # recordable
21
+ # end
22
+ def recordable
23
+ include Recordables::Recordable
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ require "rails/railtie"
2
+
3
+ module Recordables
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "recordables.macros" do
6
+ ActiveSupport.on_load(:active_record) do
7
+ extend Recordables::Macros
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,89 @@
1
+ require "active_support/concern"
2
+
3
+ module Recordables
4
+ module Recordable
5
+ extend ActiveSupport::Concern
6
+
7
+ # Raised when a snapshot owns associations the copy-forward cannot carry.
8
+ # Losing them silently is the failure this gem exists to prevent.
9
+ class UncopyableAssociation < StandardError; end
10
+
11
+ included do
12
+ has_many :recordings, as: :recordable
13
+ has_many :events, as: :recordable
14
+ end
15
+
16
+ def commentable? = false
17
+ def publishable? = false
18
+ def nestable? = false
19
+
20
+ def summary = model_name.human
21
+
22
+ def revisable_attributes = attributes.except("id", "created_at")
23
+
24
+ # Column values ride along in #attributes, but rich text and attached files
25
+ # live in their own tables and would vanish from a new snapshot in silence.
26
+ def copy_content_to(revision)
27
+ guard_uncopyable_associations!
28
+ copy_rich_text_to(revision)
29
+ copy_attachments_to(revision)
30
+ revision
31
+ end
32
+
33
+ private
34
+
35
+ def guard_uncopyable_associations!
36
+ names = uncopyable_association_names
37
+ return if names.empty?
38
+
39
+ raise UncopyableAssociation,
40
+ "#{self.class.name} owns #{names.join(', ')}, which a new snapshot cannot carry. " \
41
+ "Model these as child recordings, or override #copy_content_to."
42
+ end
43
+
44
+ def uncopyable_association_names
45
+ self.class.reflect_on_all_associations.filter_map do |reflection|
46
+ next if reflection.options[:class_name] == "ActionText::RichText"
47
+ next if reflection.name.to_s.start_with?("rich_text_")
48
+ next unless %i[has_many has_one].include?(reflection.macro)
49
+ next if %i[recordings events].include?(reflection.name)
50
+ next if attachment_reflection_names.include?(reflection.name)
51
+
52
+ reflection.name
53
+ end
54
+ end
55
+
56
+ def attachment_reflection_names
57
+ return [] unless self.class.respond_to?(:attachment_reflections)
58
+
59
+ self.class.attachment_reflections.flat_map do |name, _reflection|
60
+ [name.to_sym, :"#{name}_attachment", :"#{name}_attachments", :"#{name}_blob", :"#{name}_blobs"]
61
+ end
62
+ end
63
+
64
+ def rich_text_names
65
+ self.class.reflect_on_all_associations(:has_one)
66
+ .select { |reflection| reflection.options[:class_name] == "ActionText::RichText" }
67
+ .map { |reflection| reflection.name.to_s.delete_prefix("rich_text_") }
68
+ end
69
+
70
+ def copy_rich_text_to(revision)
71
+ rich_text_names.each do |name|
72
+ existing = public_send(name)
73
+ revision.public_send(:"#{name}=", existing.body) if existing&.body
74
+ end
75
+ end
76
+
77
+ def copy_attachments_to(revision)
78
+ return unless self.class.respond_to?(:attachment_reflections)
79
+
80
+ self.class.attachment_reflections.each do |name, reflection|
81
+ attached = public_send(name)
82
+ next unless attached.attached?
83
+
84
+ blobs = reflection.macro == :has_many_attached ? attached.blobs : [attached.blob]
85
+ revision.public_send(name).attach(*blobs)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,48 @@
1
+ require "active_support/concern"
2
+
3
+ module Recordables
4
+ module Recording
5
+ extend ActiveSupport::Concern
6
+
7
+ VERSION_ACTIONS = %w[created updated reverted].freeze
8
+
9
+ class_methods do
10
+ def record(recordable, actor:, parent: nil, **attributes)
11
+ transaction do
12
+ recordable.save!
13
+ recording = create!(recordable: recordable, creator: actor, parent: parent, **attributes)
14
+ recording.log!("created", recordable, actor: actor)
15
+ recording
16
+ end
17
+ end
18
+ end
19
+
20
+ def revise(actor:, **changes)
21
+ transaction do
22
+ revision = recordable.class.new(recordable.revisable_attributes)
23
+ recordable.copy_content_to(revision)
24
+ changes.each { |name, value| revision.public_send(:"#{name}=", value) }
25
+ revision.save!
26
+ update!(recordable: revision)
27
+ log!("updated", revision, actor: actor, details: { "changed" => changes.keys.map(&:to_s) })
28
+ revision
29
+ end
30
+ end
31
+
32
+ def revert_to(snapshot, actor:)
33
+ transaction do
34
+ update!(recordable: snapshot)
35
+ log!("reverted", snapshot, actor: actor, details: { "restored_id" => snapshot.id })
36
+ snapshot
37
+ end
38
+ end
39
+
40
+ def versions = events.where(action: VERSION_ACTIONS).order(:created_at, :id)
41
+
42
+ def recordable_at(time) = versions.where(created_at: ..time).last&.recordable
43
+
44
+ def log!(action, snapshot, actor:, details: {})
45
+ events.create!(recordable: snapshot, actor: actor, action: action, details: details)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ module Recordables
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,11 @@
1
+ require "zeitwerk"
2
+
3
+ loader = Zeitwerk::Loader.for_gem
4
+ loader.ignore("#{__dir__}/generators")
5
+ loader.ignore("#{__dir__}/recordables/railtie.rb")
6
+ loader.setup
7
+
8
+ module Recordables
9
+ end
10
+
11
+ require "recordables/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: recordables
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonas Medeiros
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: zeitwerk
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '2.6'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.6'
46
+ description: Scaffolds a recordings/recordables/events content spine into a Rails
47
+ app, and keeps the snapshot copy-forward logic that is easy to get silently wrong.
48
+ email:
49
+ - jonas.g.medeiros@gmail.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - CHANGELOG.md
55
+ - LICENSE.txt
56
+ - README.md
57
+ - lib/generators/recordables/bucket/bucket_generator.rb
58
+ - lib/generators/recordables/bucket/templates/migration.rb.tt
59
+ - lib/generators/recordables/bucket/templates/model.rb.tt
60
+ - lib/generators/recordables/install/install_generator.rb
61
+ - lib/generators/recordables/install/templates/bucket.rb.tt
62
+ - lib/generators/recordables/install/templates/bucketable.rb.tt
63
+ - lib/generators/recordables/install/templates/create_recordables_tables.rb.tt
64
+ - lib/generators/recordables/install/templates/event.rb.tt
65
+ - lib/generators/recordables/install/templates/recordable.rb.tt
66
+ - lib/generators/recordables/install/templates/recording.rb.tt
67
+ - lib/generators/recordables/type/templates/migration.rb.tt
68
+ - lib/generators/recordables/type/templates/model.rb.tt
69
+ - lib/generators/recordables/type/type_generator.rb
70
+ - lib/recordables.rb
71
+ - lib/recordables/generator_helpers.rb
72
+ - lib/recordables/macros.rb
73
+ - lib/recordables/railtie.rb
74
+ - lib/recordables/recordable.rb
75
+ - lib/recordables/recording.rb
76
+ - lib/recordables/version.rb
77
+ homepage: https://github.com/jonasmedeiros/recordables
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ homepage_uri: https://github.com/jonasmedeiros/recordables
82
+ source_code_uri: https://github.com/jonasmedeiros/recordables
83
+ changelog_uri: https://github.com/jonasmedeiros/recordables/blob/main/CHANGELOG.md
84
+ bug_tracker_uri: https://github.com/jonasmedeiros/recordables/issues
85
+ rubygems_mfa_required: 'true'
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.2'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.6.9
101
+ specification_version: 4
102
+ summary: Generators and runtime helpers for versioned delegated types.
103
+ test_files: []