recordables 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c0cb9bc821c580bd8f8f9e0e321ad0433b6905b98420ebacc0c2b960556017a
4
- data.tar.gz: 9dc7a3de73258300b9b09ab6ab68c8ce3e8909e120e5e0480c7038cea52bc2c6
3
+ metadata.gz: 7355a9bfb7d1440a8b7746b13b4fa810304eb837c97d8dc850011d12091241b6
4
+ data.tar.gz: 4f13296e03d0467244273af7f96566d21129d48013ec3279d2967236806181a5
5
5
  SHA512:
6
- metadata.gz: bd212dc151fbc5013e6f5f76d72da3eaadaedd955d97aa0c34572bc6f323dc309dbcacbc2aa6ce8d9888efdeec14bf9e45b7f19635206eab5c036e08936aa0ab
7
- data.tar.gz: 71de19f67e01f321d3c86d20368bcf04462af941cbc5472333cfa6f3768ecafc878ec4ace7a281fed1ad04825e452f485d55d03481f58ee2cb25cf3507f130a4
6
+ metadata.gz: 8053edc2e2c5cfa59c71c19658f2faba977bbc29e48b933d6eccd3086a548bb5d76ebcb6a0f65e8e8468f60694587a9c47b76ce2ee8c47a286f3fb631318868b
7
+ data.tar.gz: 504f8827f44890f43442e17f3af80874feb8dcdd88685f362e7e6bd78a00266e2e7710c387ad331480bc67e741facffdc897d97b9bc66eba13749f80b306fcab
data/CHANGELOG.md CHANGED
@@ -6,6 +6,57 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+
11
+ - `trashable` — `default_scope` + `with_trashed` for a recordable type whose Recording
12
+ can be trashed, so a trashed row stops showing up in normal queries. Paired with
13
+ `Recording.active` and `Recording#trash!`.
14
+ - `recordable_belongs_to` — a `belongs_to` replacement for a target that's `trashable`.
15
+ Plain `belongs_to` builds its own `WHERE id = ...` directly against the target class,
16
+ bypassing `trashable`'s `default_scope` entirely, and silently returns a stale,
17
+ superseded row instead of the current one. Resolves through the append-only Event log
18
+ instead, which always names the current version.
19
+ - `immutable` — raises if `update`, `update!`, `update_column(s)`, `save` (with pending
20
+ changes), `destroy`, `delete`, or class-level `update_all` are called directly against
21
+ a persisted recordable, instead of silently mutating a row that's supposed to be an
22
+ immutable snapshot. `record`/`revise` are unaffected — they only ever save a fresh,
23
+ not-yet-persisted instance.
24
+ - `has_children` — the child-Recording pattern (a Task under its Routine) as two
25
+ generated methods per name: a read-only, ordered listing and an `add_*` that creates
26
+ the child with its own Recording, parented to the current one.
27
+ - `nested_recordable_attributes_for` — the `accepts_nested_attributes_for` shape for a
28
+ `has_children` association, since the real thing can't work here (it writes straight
29
+ to association rows at `assign_attributes` time; a child needs a Recording, which
30
+ needs the parent to be saved/revised first). Two-phase: `#{plural}_attributes=` stores
31
+ the submitted rows, `apply_#{plural}_attributes!` creates/revises/trashes them once
32
+ the parent has a current Recording.
33
+ - `repoint_on_revise` — for a real `has_many` pointed at a recordable type by a plain
34
+ foreign key (not a child Recording): repoints every row at the new id `revise()`
35
+ creates, in the same transaction, instead of leaving them FK'd to what just became
36
+ obsolete. Also defines `copy_content_to` as a no-op, since these associations were
37
+ never content to copy forward in the first place.
38
+ - `Recordables::Testing#current_recordable` — the fix for the most common test mistake
39
+ this gem's pattern invites: `#reload` on a recordable after an action that revised it
40
+ re-fetches by the object's own, now-superseded primary key, silently returning what
41
+ the row looked like *before*. Capture the Recording first, resolve through it after.
42
+ - `recordables:backfill` generator — scaffolds the migration for adopting
43
+ `recordable`/`trashable` on a table that already has rows. Exists because of one
44
+ specific trap: once a model has `trashable`, a bare `Model.find_each` inside the very
45
+ migration meant to create that model's first Recordings iterates zero rows — every
46
+ row is invisible under a scope that hides anything without an active Recording yet,
47
+ which at backfill time is all of them. `Model.with_trashed.find_each` fixes it, but
48
+ the migration "succeeds" having touched nothing if it's missing, and nothing about
49
+ that looks wrong until much later.
50
+ - `Recordable#recording` — the current Recording pointing at a snapshot
51
+ (`recordings.last`). Previously left for every consumer to define for themselves.
52
+ - `delete_all` / `destroy_all` on a `trashable` type now raise instead of silently only
53
+ clearing rows with an active Recording — the ambiguity that trips up a test teardown's
54
+ `delete_all`, a `DatabaseCleaner` truncation strategy, or a rake task clearing a table,
55
+ all expecting the table to end up actually empty. `with_trashed.delete_all` (or scoping
56
+ down first, e.g. `Model.where(...).delete_all`) says which is meant.
57
+
58
+ ## [0.1.0]
59
+
9
60
  ### Fixed
10
61
 
11
62
  - `revise` silently discarded edits to rich text: changes were merged into the column
data/README.md CHANGED
@@ -114,11 +114,187 @@ than the snapshot, so revising content never touches them.
114
114
  |---|---|
115
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
116
  | `records` / `recordable` macros, `revise`, `revert_to`, `versions`, `recordable_at`, `copy_content_to` and its guard | **Kept in the gem** |
117
+ | `trashable`, `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 |
117
118
 
118
119
  The rule: **generate what's opinionated, keep what fails silently.** Permissions, controllers
119
120
  and tree semantics are deliberately yours — that is why the generated files are plain Rails
120
121
  you can rewrite freely.
121
122
 
123
+ ## Trashing, and the belongs_to trap
124
+
125
+ `recordable` alone doesn't give you soft-delete — a "trashed" Recording still leaves its
126
+ row visible to every plain query, since `trash!` only changes the *Recording's* status,
127
+ nothing about the row itself. `trashable` closes that gap:
128
+
129
+ ```ruby
130
+ class Article < ApplicationRecord
131
+ recordable
132
+ trashable
133
+ end
134
+
135
+ article.recording.trash!(actor: current_user)
136
+
137
+ Article.count # doesn't see it
138
+ Article.with_trashed.count # does
139
+ ```
140
+
141
+ The one thing this doesn't fix by itself is `belongs_to`. Rails' association reader builds
142
+ its own `WHERE id = ...` directly against the target class, bypassing `trashable`'s
143
+ `default_scope` entirely — so a plain `belongs_to :article` on some other model silently
144
+ returns the stale, trashed-or-superseded row instead of nil or the current version. This is
145
+ the sharpest edge in the whole pattern, and it fails silently: nothing raises, the wrong data
146
+ just quietly comes back. `recordable_belongs_to` is the fix:
147
+
148
+ ```ruby
149
+ class Comment < ApplicationRecord
150
+ recordable_belongs_to :article
151
+ end
152
+ ```
153
+
154
+ It resolves through the append-only `Event` log instead of the raw foreign key — an event's
155
+ `recordable_id` is set once, when that event happened, and never rewritten, so it still names
156
+ the right lineage no matter how many times the target's been revised since.
157
+
158
+ ## Enforcing immutability
159
+
160
+ Nothing about `recordable` stops a `template.update!(name: "x")` from working even after
161
+ you've adopted `revise()` everywhere else — it just silently mutates the row in place. No new
162
+ snapshot, no history entry, no way to revert, and anything reading `cache_version` or
163
+ `updated_at` through the Recording never sees the change. `immutable` turns that into a hard
164
+ failure instead of a silent one:
165
+
166
+ ```ruby
167
+ class Article < ApplicationRecord
168
+ recordable
169
+ immutable
170
+ end
171
+
172
+ article.update!(title: "x") # raises Recordables::ImmutableRecordable
173
+ article.update_column(:title, "x") # raises too — callbacks alone don't catch this
174
+ Article.update_all(title: "x") # raises at the class level
175
+ ```
176
+
177
+ `record`/`revise` are unaffected — they only ever save a fresh, not-yet-persisted instance,
178
+ never a second write against a row that's already there.
179
+
180
+ ## Children: a real has_many, or a child Recording?
181
+
182
+ Two different shapes both look like "this recordable owns other rows," and mixing them up
183
+ either loses data or silently orphans it.
184
+
185
+ **A child Recording** (`has_children`) is for content that's genuinely part of the parent's
186
+ lineage — a `Task` under its `Routine`, a line item under an order. A plain foreign key would
187
+ orphan every child the moment the parent is revised (a new row, a new id) — the fix is
188
+ letting the *child's own Recording* carry the parent link instead, via `parent_id`, which
189
+ survives revisions because a Recording's own id never changes:
190
+
191
+ ```ruby
192
+ class Routine < ApplicationRecord
193
+ recordable
194
+ has_children :tasks
195
+ end
196
+
197
+ routine.tasks # current children, ordered
198
+ routine.add_task(actor: current_user, name: "Stretch")
199
+ ```
200
+
201
+ **A real `has_many`** (`repoint_on_revise`) is for join rows that point *at* a recordable by a
202
+ plain foreign key, and need to follow a revision rather than travel with the child-Recording
203
+ tree — a tagging table, an assignment. `revise()` alone would leave every one of those rows
204
+ FK'd to the old, now-superseded id. `repoint_on_revise` moves them to the new one in the same
205
+ transaction, and also defines `copy_content_to` as a no-op — these associations were never
206
+ content to *copy forward*, they're pointers *at* the row, so the default
207
+ `UncopyableAssociation` guard doesn't apply here:
208
+
209
+ ```ruby
210
+ class RoutineTemplate < ApplicationRecord
211
+ recordable
212
+ repoint_on_revise :routine_template_people
213
+ end
214
+ ```
215
+
216
+ If in doubt: does the association exist to organize the recordable's own content (child
217
+ Recording), or does something external point at it (repoint_on_revise)?
218
+
219
+ ## Nested forms
220
+
221
+ `accepts_nested_attributes_for` can't work on a `has_children` association — it writes
222
+ straight to association rows at `assign_attributes` time, but a new child needs its own
223
+ Recording, which needs the parent to already be saved or revised. `nested_recordable_attributes_for`
224
+ does the same job (a form posts an array of `{id:, ...fields, _destroy:}` hashes) through
225
+ `revise()`/`trash!` instead of a raw write:
226
+
227
+ ```ruby
228
+ class Routine < ApplicationRecord
229
+ recordable
230
+ has_children :tasks
231
+ nested_recordable_attributes_for :tasks
232
+ end
233
+ ```
234
+
235
+ ```erb
236
+ <%= form_with model: @routine do |f| %>
237
+ <%= f.fields_for :tasks, @routine.tasks_for_form do |task_fields| %>
238
+ ...
239
+ <% end %>
240
+ <% end %>
241
+ ```
242
+
243
+ Two-phase, matching how a controller/interactor already has to split "assign" from "save"
244
+ here: `tasks_attributes=` stores the submitted rows without writing anything, then
245
+ `apply_tasks_attributes!(actor:)` — called once the routine itself has a current Recording —
246
+ creates, revises, or trashes each one.
247
+
248
+ ## Testing
249
+
250
+ The most common mistake this whole pattern invites: calling `#reload` on a recordable after
251
+ an action that revised it.
252
+
253
+ ```ruby
254
+ task = create_a_task
255
+ complete_the_task(task)
256
+ task.reload.status # => "initial", not "complete"
257
+ ```
258
+
259
+ `revise()` gives an edit a brand new row — a new primary key — and repoints the Recording at
260
+ it. `task` still holds the *old* primary key, so `#reload` re-fetches by that key: not an
261
+ error, not nil, just exactly the row the action was supposed to replace. A test asserting on
262
+ `task` after the action silently checks what things looked like *before*.
263
+
264
+ ```ruby
265
+ class ActiveSupport::TestCase
266
+ include Recordables::Testing
267
+ end
268
+ ```
269
+
270
+ ```ruby
271
+ recording = task.recording # capture before — a Recording's id never changes
272
+ complete_the_task(task)
273
+ assert_equal "complete", current_recordable(recording).status
274
+ ```
275
+
276
+ ## Adopting recordable on an existing table
277
+
278
+ Once a model has `trashable`, its `default_scope` hides any row with no active
279
+ Recording — which, the moment you first add `trashable`, is every existing row. The
280
+ migration that's supposed to create each row's first Recording has to bypass that scope to
281
+ see them at all:
282
+
283
+ ```bash
284
+ bin/rails generate recordables:backfill RoutineTemplate
285
+ ```
286
+
287
+ ```ruby
288
+ # generated: db/migrate/..._backfill_routine_templates_recordings.rb
289
+ RoutineTemplate.with_trashed.find_each do |routine_template|
290
+ Recording.record(routine_template, actor: routine_template.actor, created_at: routine_template.created_at)
291
+ end
292
+ ```
293
+
294
+ Miss `.with_trashed` here — plain `find_each` — and the migration doesn't error. It "succeeds"
295
+ in milliseconds, having created zero Recordings, and nothing about that looks wrong until
296
+ something downstream (a query, a destroy cascade) turns up rows with no history at all.
297
+
122
298
  ## Caveats
123
299
 
124
300
  - Never put a `uniqueness` validation on a recordable. Old snapshots still hold the old
@@ -128,6 +304,12 @@ you can rewrite freely.
128
304
  - `events.details` is a `json` column. Ruby 4 ships json 3.x, whose `JSON.parse` moved to
129
305
  keyword arguments while ActiveSupport 8.1 still calls it positionally. Pin
130
306
  `gem "json", "~> 2.7"` until that is fixed upstream.
307
+ - On a `trashable` type, `Model.delete_all` / `Model.destroy_all` raise rather than run.
308
+ Under `default_scope` they'd only ever touch rows with an active Recording — anything
309
+ already trashed survives, silently — which is exactly backwards from what a test
310
+ teardown's `delete_all` or a `DatabaseCleaner` truncation strategy expects. Call
311
+ `Model.with_trashed.delete_all` if you mean it, or scope down first
312
+ (`Model.where(...).delete_all`) if you meant only some of the active rows.
131
313
 
132
314
  ## Development
133
315
 
@@ -0,0 +1,55 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "recordables/generator_helpers"
4
+
5
+ module Recordables
6
+ module Generators
7
+ # Scaffolds the migration for adopting recordable/trashable on a table
8
+ # that already has rows: create the first Recording (and "created"
9
+ # Event) for each one.
10
+ #
11
+ # bin/rails generate recordables:backfill RoutineTemplate
12
+ #
13
+ # Exists because of one specific, easy-to-miss trap: once a model has
14
+ # trashable's default_scope, `Model.find_each` inside the very
15
+ # migration meant to create that model's first Recordings iterates
16
+ # zero rows — every row is invisible under a scope that hides anything
17
+ # without an active Recording yet, which at backfill time is all of
18
+ # them. The fix (`Model.with_trashed.find_each`) is one word, but
19
+ # silent when missing: the migration "succeeds" in milliseconds having
20
+ # touched nothing, and nothing about that looks wrong until much later.
21
+ class BackfillGenerator < Rails::Generators::NamedBase
22
+ include ActiveRecord::Generators::Migration
23
+ include Recordables::GeneratorHelpers
24
+
25
+ source_root File.expand_path("templates", __dir__)
26
+
27
+ desc "Generate a migration that backfills the first Recording for every existing row of a type."
28
+
29
+ class_option :creator, type: :string, default: nil,
30
+ desc: "Attribute on the row holding who created it, e.g. person_id"
31
+ class_option :account, type: :string, default: nil,
32
+ desc: "Attribute on the row holding its account/tenant, e.g. account_id"
33
+
34
+ def validate!
35
+ validate_name!
36
+ end
37
+
38
+ def create_backfill_migration
39
+ migration_template "backfill_migration.rb.tt", "db/migrate/backfill_#{table_name}_recordings.rb"
40
+ end
41
+
42
+ private
43
+
44
+ def generator_example = "bin/rails generate recordables:backfill RoutineTemplate"
45
+
46
+ def creator_expression
47
+ options[:creator].presence || "actor"
48
+ end
49
+
50
+ def account_expression
51
+ options[:account].presence || "account"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,35 @@
1
+ class Backfill<%= class_name.pluralize %>Recordings < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ <%= class_name %>.reset_column_information
4
+
5
+ say_with_time "Creating a Recording for every existing <%= file_name %>" do
6
+ count = 0
7
+
8
+ # trashable's default_scope hides any row with no active Recording —
9
+ # exactly every row this backfill is about to create the first one
10
+ # for. Without with_trashed this silently iterates zero rows: the
11
+ # migration "succeeds" in milliseconds having touched nothing, which
12
+ # is easy to miss since nothing about it raises or looks wrong.
13
+ <%= class_name %>.with_trashed.find_each do |<%= file_name %>|
14
+ Recording.record(
15
+ <%= file_name %>,
16
+ actor: <%= file_name %>.<%= creator_expression %>,
17
+ <% if account_expression != "nil" -%>
18
+ account: <%= file_name %>.<%= account_expression %>,
19
+ <% end -%>
20
+ created_at: <%= file_name %>.created_at
21
+ )
22
+ count += 1
23
+ end
24
+
25
+ "#{count} <%= table_name %>"
26
+ end
27
+ end
28
+
29
+ def down
30
+ Recording.where(recordable_type: "<%= class_name %>").with_trashed.find_each do |recording|
31
+ recording.events.delete_all
32
+ recording.delete
33
+ end
34
+ end
35
+ end
@@ -1,8 +1,6 @@
1
1
  class Recording < ApplicationRecord
2
2
  records :recordable, types: Recordable::TYPES, inverse_of: :recordings
3
3
 
4
- enum :status, { active: 0, archived: 1, trashed: 2 }
5
-
6
4
  <% if buckets? -%>
7
5
  belongs_to :bucket
8
6
  <% end -%>
@@ -0,0 +1,60 @@
1
+ require "active_support/concern"
2
+
3
+ module Recordables
4
+ # Opt-in alongside `recordable` for a type whose Recording parents other
5
+ # Recordings — a Task under its Routine, a TaskTemplate under its
6
+ # RoutineTemplate. Not a real has_many: a child's own Recording carries
7
+ # the parent link (parent_id), not a foreign key on the child row itself
8
+ # — a plain FK would silently orphan every child the moment its parent
9
+ # is revised, since revise() replaces the parent row with a new id.
10
+ #
11
+ # class RoutineTemplate < ApplicationRecord
12
+ # recordable
13
+ # has_children :task_templates, class_name: "TaskTemplate"
14
+ # end
15
+ #
16
+ # Defines two methods per name:
17
+ #
18
+ # template.task_templates
19
+ # # => current children, ordered
20
+ # template.add_task_template(actor:, recording: {}, **attrs)
21
+ # # => Recording.record(TaskTemplate.new(attrs), actor:, parent:, **recording)
22
+ #
23
+ # Read-heavy chaining (.count, .where, .order, .each, .find, ...) works
24
+ # the same as a real has_many. Building/creating a child goes through
25
+ # add_* instead — "create a child" is a recording operation (it needs
26
+ # its own Recording, parented to this one), not a plain attribute
27
+ # assignment a bare .build could do.
28
+ #
29
+ # attrs become the child model's own attributes; recording: carries
30
+ # anything that belongs on the Recording itself instead (account:,
31
+ # bucket:, or any other extra column the host app's Recording has) —
32
+ # kept as an explicit, separate argument rather than guessed at by
33
+ # inspecting attrs, since a model attribute happening to share a name
34
+ # with a Recording column would otherwise route to the wrong place
35
+ # silently.
36
+ module HasChildren
37
+ extend ActiveSupport::Concern
38
+
39
+ class_methods do
40
+ def __recordables_define_children(plural_name, class_name: nil)
41
+ singular_name = plural_name.to_s.singularize
42
+ target_class_name = class_name&.to_s || plural_name.to_s.classify
43
+
44
+ define_method(plural_name) do
45
+ target = target_class_name.constantize
46
+ return target.none unless recording
47
+
48
+ target.joins(:recordings).merge(::Recording.active.where(parent_id: recording.id))
49
+ .order("recordings.position")
50
+ end
51
+
52
+ define_method(:"add_#{singular_name}") do |actor: nil, recording: {}, **attrs|
53
+ target = target_class_name.constantize
54
+
55
+ ::Recording.record(target.new(attrs), actor: actor, parent: self.recording, **recording)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,66 @@
1
+ require "active_support/concern"
2
+
3
+ module Recordables
4
+ # Raised by a write attempted directly against a persisted immutable
5
+ # recordable — see Immutable for why, and what to call instead.
6
+ class ImmutableRecordable < StandardError; end
7
+
8
+ # Opt-in alongside `recordable` for a type that should never be updated
9
+ # in place after it's first saved — only revise() (a new row) or trash!
10
+ # (on its Recording) should ever change what a caller sees.
11
+ #
12
+ # class RoutineTemplate < ApplicationRecord
13
+ # recordable
14
+ # immutable
15
+ # end
16
+ #
17
+ # Nothing in Rails stops a plain `template.update!(name: "x")` from
18
+ # working even after adopting the revise() pattern — it just silently
19
+ # mutates the row in place instead of raising or inserting a new one.
20
+ # The row still looks "current" to every reader, but no new Recording
21
+ # snapshot was created: no history entry, no way to revert, and any
22
+ # cache_version or updated_at logic that reads through the Recording
23
+ # never sees the change. That's exactly the failure this gem exists to
24
+ # prevent, and nothing about #recordable on its own stops it.
25
+ #
26
+ # Only guards persisted rows: the record/revise flow itself calls
27
+ # `.new(...).save!` on a fresh, not-yet-persisted instance, which is
28
+ # the one write path this is not meant to catch.
29
+ module Immutable
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ before_update { raise_immutable!(:update) }
34
+ before_destroy { raise_immutable!(:destroy) }
35
+ end
36
+
37
+ # update_column/update_columns/delete bypass callbacks entirely (that's
38
+ # their whole point), so before_update/before_destroy above never see
39
+ # them — each needs its own override to still be caught.
40
+ def update_column(...) = raise_immutable!(:update_column)
41
+ def update_columns(...) = raise_immutable!(:update_columns)
42
+ def delete = raise_immutable!(:delete)
43
+
44
+ class_methods do
45
+ def update_all(...)
46
+ raise Recordables::ImmutableRecordable, <<~MESSAGE.squish
47
+ #{name}.update_all writes directly to the row instead of calling
48
+ revise() on each recordable's Recording — no new snapshot, no
49
+ history entry, nothing to revert to. Loop and call
50
+ recording.revise(actor:, **changes) per row instead.
51
+ MESSAGE
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def raise_immutable!(verb)
58
+ raise Recordables::ImmutableRecordable, <<~MESSAGE.squish
59
+ #{self.class.name}##{verb} was called directly on a persisted
60
+ recordable — #{verb == :destroy ? "trash! its Recording" : "call revise(actor:, **changes) on its Recording"}
61
+ instead, so the change goes through the versioning this gem
62
+ exists to provide.
63
+ MESSAGE
64
+ end
65
+ end
66
+ end
@@ -22,5 +22,113 @@ module Recordables
22
22
  def recordable
23
23
  include Recordables::Recordable
24
24
  end
25
+
26
+ # Adds default_scope + with_trashed to a recordable type, so a trashed
27
+ # Recording's row stops showing up in normal queries. See
28
+ # Recordables::Trashable for the mechanics.
29
+ #
30
+ # class RoutineTemplate < ApplicationRecord
31
+ # recordable
32
+ # trashable
33
+ # end
34
+ def trashable
35
+ include Recordables::Trashable
36
+ end
37
+
38
+ # Raises if anything tries to write directly to a persisted recordable
39
+ # row instead of going through revise()/trash! on its Recording. See
40
+ # Recordables::Immutable for the mechanics.
41
+ #
42
+ # class RoutineTemplate < ApplicationRecord
43
+ # recordable
44
+ # immutable
45
+ # end
46
+ def immutable
47
+ include Recordables::Immutable
48
+ end
49
+
50
+ # Repoints real has_many associations (plain FK, not a child Recording)
51
+ # at each new row a revision creates, instead of leaving them pointed
52
+ # at what revise() just made obsolete. See Recordables::RepointOnRevise
53
+ # for the mechanics.
54
+ #
55
+ # class RoutineTemplate < ApplicationRecord
56
+ # recordable
57
+ # repoint_on_revise :routine_template_people
58
+ # end
59
+ def repoint_on_revise(*association_names)
60
+ include Recordables::RepointOnRevise
61
+ self.repointed_association_names = association_names
62
+ end
63
+
64
+ # A recordable type whose Recording parents other Recordings — a Task
65
+ # under its Routine. See Recordables::HasChildren for the mechanics.
66
+ #
67
+ # class RoutineTemplate < ApplicationRecord
68
+ # recordable
69
+ # has_children :task_templates
70
+ # end
71
+ def has_children(plural_name, class_name: nil)
72
+ include Recordables::HasChildren
73
+ __recordables_define_children(plural_name, class_name: class_name)
74
+ end
75
+
76
+ # The accepts_nested_attributes_for shape for a has_children
77
+ # association — a fields_for form editing a recordable parent's
78
+ # children. See Recordables::NestedRecordableAttributes for the
79
+ # mechanics. Requires has_children for the same name first.
80
+ #
81
+ # class RoutineTemplate < ApplicationRecord
82
+ # recordable
83
+ # has_children :task_templates, class_name: "TaskTemplate"
84
+ # nested_recordable_attributes_for :task_templates, class_name: "TaskTemplate"
85
+ # end
86
+ def nested_recordable_attributes_for(plural_name, class_name: nil)
87
+ include Recordables::NestedRecordableAttributes
88
+ target_class_name = class_name&.to_s || plural_name.to_s.classify
89
+ __recordables_define_nested_attributes(plural_name, class_name: target_class_name)
90
+ end
91
+
92
+ # A belongs_to whose target is trashable, resolved through the append-only
93
+ # Event log instead of the raw foreign key.
94
+ #
95
+ # Rails' belongs_to association reader builds its own `WHERE id = ...`
96
+ # query directly against the target class — bypassing trashable's
97
+ # default_scope entirely, since that scope is added to the class's own
98
+ # query interface, not baked into every possible query Rails might build
99
+ # against it. A plain belongs_to therefore silently returns the stale,
100
+ # trashed-or-superseded row instead of nil once the target's been revised
101
+ # or trashed — exactly the case this exists to handle.
102
+ #
103
+ # An Event's recordable_id is set once, at the moment that event
104
+ # happened, and never rewritten — so the "created" event for a given
105
+ # target id still names that id no matter how many times the target's
106
+ # been revised since. That event's Recording is the stable pointer for
107
+ # the whole lineage, so walking through it reaches whatever version is
108
+ # current right now: the original row if it still is, or the latest
109
+ # revision if the original has since been revised or trashed.
110
+ #
111
+ # class Routine < ApplicationRecord
112
+ # recordable_belongs_to :routine_template
113
+ # end
114
+ #
115
+ # Accepts the same options as belongs_to (class_name, foreign_key,
116
+ # optional, etc) and still defines the plain association under the hood
117
+ # — for eager loading, FK validation, forms — only the reader method is
118
+ # overridden.
119
+ def recordable_belongs_to(name, class_name: nil, foreign_key: nil, **options)
120
+ belongs_to name, class_name: class_name, foreign_key: foreign_key, **options
121
+
122
+ target_class_name = class_name&.to_s || name.to_s.camelize
123
+ fk = foreign_key&.to_s || "#{name}_id"
124
+
125
+ define_method(name) do
126
+ id = public_send(fk)
127
+ next nil if id.blank?
128
+
129
+ ::Event.where(recordable_type: target_class_name, recordable_id: id)
130
+ .first&.recording&.recordable
131
+ end
132
+ end
25
133
  end
26
134
  end
@@ -0,0 +1,100 @@
1
+ require "active_support/concern"
2
+
3
+ module Recordables
4
+ # Opt-in alongside `has_children` for the accepts_nested_attributes_for
5
+ # shape (a form with fields_for) on a recordable parent's children.
6
+ #
7
+ # class RoutineTemplate < ApplicationRecord
8
+ # recordable
9
+ # has_children :task_templates, class_name: "TaskTemplate"
10
+ # nested_recordable_attributes_for :task_templates, class_name: "TaskTemplate"
11
+ # end
12
+ #
13
+ # accepts_nested_attributes_for can't work here: it writes at
14
+ # assign_attributes time, straight to a real association's rows — but
15
+ # task_templates isn't a real has_many (see HasChildren), and a child
16
+ # needs its own Recording the moment it's created, which a plain
17
+ # attribute assignment can't set up. This does the same job (a form
18
+ # posts an array of {id:, ...fields, _destroy:} hashes; existing rows
19
+ # get updated or trashed, new ones get created) through revise()/trash!
20
+ # instead of a raw write.
21
+ #
22
+ # Two-phase, same as accepts_nested_attributes_for from the caller's
23
+ # side but not under the hood: fields_for-style form submission first
24
+ # calls #{plural}_attributes= (stores the raw rows, writes nothing yet),
25
+ # then the interactor/controller explicitly calls
26
+ # apply_#{plural}_attributes! once the parent itself is saved/revised —
27
+ # a new child needs a *current* Recording to parent under, which only
28
+ # exists after that.
29
+ module NestedRecordableAttributes
30
+ extend ActiveSupport::Concern
31
+
32
+ class_methods do
33
+ def __recordables_define_nested_attributes(plural_name, class_name:)
34
+ singular_name = plural_name.to_s.singularize
35
+ target_class_name = class_name.to_s
36
+ pending_ivar = :"@pending_#{plural_name}_attributes"
37
+ rendered_ivar = :"@rendered_#{plural_name}"
38
+
39
+ attr_reader :"pending_#{plural_name}_attributes"
40
+
41
+ define_method(:"#{plural_name}_attributes=") do |attributes|
42
+ instance_variable_set(pending_ivar, attributes.is_a?(Hash) ? attributes.values : attributes)
43
+ end
44
+
45
+ # Blank row for an "add" button in the form — mirrors what
46
+ # `children.build` would do on a real has_many. In-memory only.
47
+ define_method(:"build_#{singular_name}") do
48
+ send(:"rendered_#{plural_name}") << target_class_name.constantize.new
49
+ end
50
+
51
+ # What the form actually iterates via fields_for: the just-submitted
52
+ # rows if the form was just posted (so a validation error re-renders
53
+ # what the user typed, the same as accepts_nested_attributes_for
54
+ # would), otherwise the persisted children.
55
+ define_method(:"rendered_#{plural_name}") do
56
+ instance_variable_get(rendered_ivar) || instance_variable_set(rendered_ivar, begin
57
+ pending = instance_variable_get(pending_ivar)
58
+
59
+ if pending
60
+ pending.filter_map do |attrs|
61
+ attrs = attrs.to_h.symbolize_keys
62
+ next if ActiveModel::Type::Boolean.new.cast(attrs[:_destroy])
63
+
64
+ target_class_name.constantize.new(attrs.except(:id, :_destroy))
65
+ end
66
+ else
67
+ public_send(plural_name).to_a
68
+ end
69
+ end)
70
+ end
71
+
72
+ define_method(:"apply_#{plural_name}_attributes!") do |actor: nil|
73
+ pending = instance_variable_get(pending_ivar)
74
+ next unless pending
75
+
76
+ current_by_id = public_send(plural_name).index_by { |child| child.id.to_s }
77
+
78
+ pending.each do |attrs|
79
+ attrs = attrs.to_h.symbolize_keys
80
+ id = attrs[:id].presence
81
+ destroy = ActiveModel::Type::Boolean.new.cast(attrs[:_destroy])
82
+ changes = attrs.except(:id, :_destroy)
83
+
84
+ if id && (child = current_by_id[id.to_s])
85
+ if destroy
86
+ child.recording.trash!(actor: actor)
87
+ else
88
+ child.recording.revise(actor: actor, **changes)
89
+ end
90
+ elsif !destroy && changes.present?
91
+ public_send(:"add_#{singular_name}", actor: actor, **changes)
92
+ end
93
+ end
94
+
95
+ instance_variable_set(pending_ivar, nil)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -17,6 +17,14 @@ module Recordables
17
17
  def publishable? = false
18
18
  def nestable? = false
19
19
 
20
+ # The one Recording currently pointing at this snapshot. A row can end
21
+ # up with more than one recordings row over its lifetime only in the
22
+ # sense that revise()/revert_to repoint a Recording between many rows
23
+ # — but at any moment, exactly one Recording points at this specific
24
+ # row (or, once it's been superseded, none). .last is a reasonable
25
+ # proxy for "the current one" without a dedicated column to query.
26
+ def recording = recordings.last
27
+
20
28
  def summary = model_name.human
21
29
 
22
30
  def revisable_attributes = attributes.except("id", "created_at")
@@ -6,6 +6,14 @@ module Recordables
6
6
 
7
7
  VERSION_ACTIONS = %w[created updated reverted].freeze
8
8
 
9
+ included do
10
+ # The install generator's migration creates the status column
11
+ # (integer, default 0, not null) but doesn't declare the enum itself
12
+ # — active/trash! need this exact mapping to exist, so it lives here
13
+ # rather than being left for every consumer to redeclare correctly.
14
+ enum :status, { active: 0, archived: 1, trashed: 2 }
15
+ end
16
+
9
17
  class_methods do
10
18
  def record(recordable, actor:, parent: nil, **attributes)
11
19
  transaction do
@@ -15,6 +23,12 @@ module Recordables
15
23
  recording
16
24
  end
17
25
  end
26
+
27
+ # Every other scope on this table should build on this one rather than
28
+ # querying status directly — trashable's default_scope depends on
29
+ # filtering through exactly this relation so a model swap here (e.g.
30
+ # adding a soft-delete concern upstream) only has one place to change.
31
+ def active = where(status: :active)
18
32
  end
19
33
 
20
34
  def revise(actor:, **changes)
@@ -44,5 +58,16 @@ module Recordables
44
58
  def log!(action, snapshot, actor:, details: {})
45
59
  events.create!(recordable: snapshot, actor: actor, action: action, details: details)
46
60
  end
61
+
62
+ # "Deleting" a recordable never removes a row — it marks this pointer
63
+ # trashed and logs the transition, the same way revise/revert_to never
64
+ # delete either. A trashed Recording keeps recordable/versions/events
65
+ # working exactly as before; only .active-scoped lookups stop seeing it.
66
+ def trash!(actor: nil)
67
+ transaction do
68
+ update!(status: :trashed)
69
+ log!("trashed", recordable, actor: actor)
70
+ end
71
+ end
47
72
  end
48
73
  end
@@ -0,0 +1,58 @@
1
+ require "active_support/concern"
2
+
3
+ module Recordables
4
+ # Opt-in alongside `recordable` for a type that owns real has_many
5
+ # associations pointed at it by a plain foreign key (not a child
6
+ # Recording) — join rows that need to follow a revision, not vanish
7
+ # when the row they FK to gets replaced.
8
+ #
9
+ # class RoutineTemplate < ApplicationRecord
10
+ # recordable
11
+ # trashable
12
+ # repoint_on_revise :routine_template_people, :goal_routine_templates
13
+ # end
14
+ #
15
+ # Two problems, one macro:
16
+ #
17
+ # 1. #copy_content_to's default UncopyableAssociation guard exists to
18
+ # stop a real association from silently vanishing off a new snapshot
19
+ # — but these associations were never content to copy in the first
20
+ # place, they're FK pointers *at* the row. Nothing to carry forward;
21
+ # they need to be repointed instead. This overrides copy_content_to
22
+ # to a no-op, same as hand-rolling `def copy_content_to(r) = r` would.
23
+ #
24
+ # 2. revise() alone leaves every one of those rows FK'd to the OLD,
25
+ # now-superseded id — invisible to anything that reads the
26
+ # association off the new row, and (worse) silently orphaned if that
27
+ # old row is later trashed or garbage collected. This overrides
28
+ # revise() to repoint each named association's foreign key at the new
29
+ # row in the same transaction as the revision itself.
30
+ #
31
+ # update_all skips touch callbacks, so updated_at is bumped by hand —
32
+ # otherwise any fragment cache keyed on those rows (e.g. one that reads
33
+ # `join_row.routine_template.name`) would invalidate late.
34
+ module RepointOnRevise
35
+ extend ActiveSupport::Concern
36
+
37
+ included do
38
+ class_attribute :repointed_association_names, default: []
39
+ end
40
+
41
+ def copy_content_to(revision) = revision
42
+
43
+ def revise(actor: nil, **changes)
44
+ transaction do
45
+ new_row = recording.revise(actor: actor, **changes)
46
+
47
+ self.class.repointed_association_names.each do |association_name|
48
+ reflection = self.class.reflect_on_association(association_name)
49
+ foreign_key = reflection.foreign_key
50
+
51
+ public_send(association_name).update_all(foreign_key => new_row.id, :updated_at => Time.current)
52
+ end
53
+
54
+ new_row
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,30 @@
1
+ module Recordables
2
+ # Include in a test base class to get current_recordable, the fix for the
3
+ # single most common test mistake this gem's pattern invites: calling
4
+ # `#reload` on a recordable after an action that revised it.
5
+ #
6
+ # class ActiveSupport::TestCase
7
+ # include Recordables::Testing
8
+ # end
9
+ #
10
+ # revise() gives a change a brand new row — a new primary key — and
11
+ # repoints the Recording at it. The Ruby object still on hand still
12
+ # carries the OLD primary key, so `#reload` re-fetches by that key: not
13
+ # an error, not nil, just the exact row the action was supposed to
14
+ # replace. A test asserting on that object after the action reads
15
+ # whatever the row looked like BEFORE, and can pass while checking
16
+ # nothing about what actually happened.
17
+ #
18
+ # Capture the Recording before the action runs — its id is the one
19
+ # thing revise() never changes — then resolve through it afterward:
20
+ #
21
+ # recording = task.recording
22
+ # complete_the_task(task)
23
+ # assert_equal "complete", current_recordable(recording).status
24
+ module Testing
25
+ def current_recordable(recording_or_id)
26
+ recording = recording_or_id.is_a?(::Recording) ? recording_or_id : ::Recording.find(recording_or_id)
27
+ recording.reload.recordable
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,79 @@
1
+ require "active_support/concern"
2
+
3
+ module Recordables
4
+ # Raised by delete_all/destroy_all on a trashable model — see Trashable
5
+ # for why those are ambiguous under a default_scope that hides trashed
6
+ # rows, and what to call instead.
7
+ class AmbiguousBulkDelete < StandardError; end
8
+
9
+ # Opt-in alongside `recordable` for a type whose Recording can be trashed.
10
+ # Without this, a trashed Recording's recordable row stays visible to every
11
+ # plain query — trash! only changes the Recording's own status, nothing
12
+ # about the row a bare Model.find or Model.where sees. This scopes normal
13
+ # queries down to rows with an active Recording, the same way a real DELETE
14
+ # would look to callers that never think about trashing at all.
15
+ #
16
+ # class RoutineTemplate < ApplicationRecord
17
+ # recordable
18
+ # trashable
19
+ # end
20
+ #
21
+ # Uses a subquery, not a join, so it composes safely everywhere — including
22
+ # inside Recording#recordable itself, which must still resolve a trashed
23
+ # row's own class via a plain Model.find under the hood.
24
+ module Trashable
25
+ extend ActiveSupport::Concern
26
+
27
+ included do
28
+ # ::Recording, not Recording — inside this module's lexical scope,
29
+ # plain `Recording` resolves to Recordables::Recording (the concern
30
+ # mixed into the host app's Recording class) rather than the host
31
+ # app's top-level Recording class itself.
32
+ default_scope {
33
+ where(primary_key => ::Recording.active.where(recordable_type: name).select(:recordable_id))
34
+ }
35
+ end
36
+
37
+ class_methods do
38
+ # Lifts the trashed-row filter only — unlike .unscoped, every other
39
+ # condition already built on the relation stays intact (an account's
40
+ # scoping association chain, an explicit .find(id), etc). Needed
41
+ # anywhere that has to see trashed rows on purpose: an admin trash-can
42
+ # view, a cascading destroy that has to clean up what it can still see,
43
+ # or a backfill migration creating the very first Recording a row will
44
+ # ever have (see the docs on that gotcha below).
45
+ def with_trashed
46
+ unscope(where: primary_key)
47
+ end
48
+
49
+ # A bare Model.delete_all/destroy_all under default_scope only ever
50
+ # touches rows with an active Recording — anything already trashed is
51
+ # invisible to it and survives untouched. That's silent and easy to
52
+ # mistake for "the table's empty now" (a test teardown's delete_all,
53
+ # a DatabaseCleaner truncation strategy, a rake task clearing a
54
+ # table) when it's actually only ever cleared the active subset.
55
+ # Raising forces the caller to say which they mean; scoping down
56
+ # first (e.g. Model.where(...).delete_all) still works exactly like
57
+ # any other ActiveRecord relation.
58
+ def delete_all(...)
59
+ raise Recordables::AmbiguousBulkDelete, <<~MESSAGE.squish
60
+ #{name}.delete_all only deletes rows with an active Recording —
61
+ anything already trashed stays behind, silently. Call
62
+ #{name}.with_trashed.delete_all if that's really what you want,
63
+ or scope down first (e.g. #{name}.where(...).delete_all) if you
64
+ meant only the active rows matching some condition.
65
+ MESSAGE
66
+ end
67
+
68
+ def destroy_all(...)
69
+ raise Recordables::AmbiguousBulkDelete, <<~MESSAGE.squish
70
+ #{name}.destroy_all only destroys rows with an active Recording —
71
+ anything already trashed stays behind, silently. Call
72
+ #{name}.with_trashed.destroy_all if that's really what you want,
73
+ or scope down first (e.g. #{name}.where(...).destroy_all) if you
74
+ meant only the active rows matching some condition.
75
+ MESSAGE
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,3 +1,3 @@
1
1
  module Recordables
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recordables
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Medeiros
@@ -54,6 +54,8 @@ files:
54
54
  - CHANGELOG.md
55
55
  - LICENSE.txt
56
56
  - README.md
57
+ - lib/generators/recordables/backfill/backfill_generator.rb
58
+ - lib/generators/recordables/backfill/templates/backfill_migration.rb.tt
57
59
  - lib/generators/recordables/bucket/bucket_generator.rb
58
60
  - lib/generators/recordables/bucket/templates/migration.rb.tt
59
61
  - lib/generators/recordables/bucket/templates/model.rb.tt
@@ -69,10 +71,16 @@ files:
69
71
  - lib/generators/recordables/type/type_generator.rb
70
72
  - lib/recordables.rb
71
73
  - lib/recordables/generator_helpers.rb
74
+ - lib/recordables/has_children.rb
75
+ - lib/recordables/immutable.rb
72
76
  - lib/recordables/macros.rb
77
+ - lib/recordables/nested_recordable_attributes.rb
73
78
  - lib/recordables/railtie.rb
74
79
  - lib/recordables/recordable.rb
75
80
  - lib/recordables/recording.rb
81
+ - lib/recordables/repoint_on_revise.rb
82
+ - lib/recordables/testing.rb
83
+ - lib/recordables/trashable.rb
76
84
  - lib/recordables/version.rb
77
85
  homepage: https://github.com/jonasmedeiros/recordables
78
86
  licenses: