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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/lib/generators/recordables/bucket/bucket_generator.rb +36 -0
- data/lib/generators/recordables/bucket/templates/migration.rb.tt +10 -0
- data/lib/generators/recordables/bucket/templates/model.rb.tt +7 -0
- data/lib/generators/recordables/install/install_generator.rb +58 -0
- data/lib/generators/recordables/install/templates/bucket.rb.tt +13 -0
- data/lib/generators/recordables/install/templates/bucketable.rb.tt +11 -0
- data/lib/generators/recordables/install/templates/create_recordables_tables.rb.tt +42 -0
- data/lib/generators/recordables/install/templates/event.rb.tt +8 -0
- data/lib/generators/recordables/install/templates/recordable.rb.tt +3 -0
- data/lib/generators/recordables/install/templates/recording.rb.tt +19 -0
- data/lib/generators/recordables/type/templates/migration.rb.tt +10 -0
- data/lib/generators/recordables/type/templates/model.rb.tt +9 -0
- data/lib/generators/recordables/type/type_generator.rb +44 -0
- data/lib/recordables/generator_helpers.rb +33 -0
- data/lib/recordables/macros.rb +26 -0
- data/lib/recordables/railtie.rb +11 -0
- data/lib/recordables/recordable.rb +89 -0
- data/lib/recordables/recording.rb +48 -0
- data/lib/recordables/version.rb +3 -0
- data/lib/recordables.rb +11 -0
- metadata +103 -0
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
|
+
[](https://rubygems.org/gems/recordables)
|
|
4
|
+
[](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,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,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,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,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,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
|
data/lib/recordables.rb
ADDED
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: []
|