recordables 0.2.1 → 0.2.2

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: 2a183a373143cdec754a8704fb66131ddc8980930ebb3a6e16d7a42309bcdd1f
4
- data.tar.gz: eb62d04c3f09067fa95221810da3cdcb4acdf704052ddd88059893735e7b521d
3
+ metadata.gz: 3cf0fb1698d5bf53e032be066ea8adb125b57cbcd5cd9363858f865e6eba13aa
4
+ data.tar.gz: 67cbad006b327319749fe63958f727550f347aa9728520e42538eddb491bdeb8
5
5
  SHA512:
6
- metadata.gz: 33f79c5525fce7dc3cf40c345c77f38d99f7a511cafca9c7be1b5412b5177ca6dd3722346e7b3365ca7e2c71eb1fbc1d9c8c70ca03de34d6a78f0faacb3c2158
7
- data.tar.gz: 60c51aa0167f1ac34132cab455cbace4275635f8be85157ac183f5394b26062e3e6506373c8fa7e9d59b6ec6b59bda517f5f1203fd14a8fada865b1e66a90eab
6
+ metadata.gz: 6280fcd221af39b67dfbc63b74164c2504b5ee5ffbcbc73bbc92396ce0abc66fff9736c724c356884f89bec440d629daea56c6547f0cd130f25983c0bd10a1e7
7
+ data.tar.gz: 76b116c32281d454c3f691a03f33b69ed562657600e6f2ea6c840426d5d4d33a837e07bc18f52af27a426c9da58de7424f302651ccb690ee2aef44dcfa767f1b
data/CHANGELOG.md CHANGED
@@ -4,10 +4,34 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.2.2]
8
+
9
+ ### Removed
10
+
11
+ - **`trashable`, `Recording#trash!`, `Recording.active`, `Recording#status`.** Soft-delete
12
+ never belonged in a versioning gem — it's a separate concern that got bundled in by
13
+ mistake. `Recording` no longer has a `status` column at all; a recordable type that wants
14
+ its own status (draft/published, say) should model it as a plain column on the recordable
15
+ itself, not on the Recording.
8
16
 
9
17
  ### Added
10
18
 
19
+ - **`Recording#destroy!(actor:)`** — a real, hard delete. Removes the Recording and its
20
+ recordable row. Logs a final `"destroyed"` event first, against the recordable that's
21
+ about to disappear, so the log itself records what was removed and by whom. This is the
22
+ only removal path now; there is no more soft-delete state to fall back to.
23
+ - **Events outlive their Recording.** Destroying a Recording nullifies `recording_id` on
24
+ its Events rather than cascading — the fact that something was created, edited, and later
25
+ destroyed stays part of the permanent log even once the content itself is gone. Needed
26
+ for real compliance/deletion flows (an account or organization being deleted for good),
27
+ where a soft-delete flag that keeps content sitting in the table forever isn't good enough.
28
+ - **An event's actor survives the actor being deleted.** `actor_id` is nullified (not
29
+ cascaded) when the actor row is destroyed, and `actor_name` — a denormalized snapshot
30
+ taken when the event was logged — keeps the log legible afterward.
31
+ `event.actor_label` reads `actor&.<label> || actor_name`. The install generator now asks
32
+ `--actor-label` (default `name`) for which attribute to snapshot.
33
+ - `recordable_belongs_to`, `Recording.creator`, and `Event.actor`/`Event.recording` are all
34
+ now optional at the association level, matching the nullify-on-delete behavior above.
11
35
  - `nested_recordable_attributes_for` accepts `recording_attributes:`, an optional proc
12
36
  called once per new child (with the parent record) to build extra attributes for that
13
37
  child's Recording. Without it, `apply_#{plural}_attributes!` creating a new child had
@@ -16,6 +40,22 @@ All notable changes to this project are documented here. The format follows
16
40
  the common case) hit a hard `NOT NULL` failure the moment a form actually submitted a
17
41
  new child row.
18
42
 
43
+ ### Changed
44
+
45
+ - `has_children`'s children lookup no longer filters through `Recording.active` — with no
46
+ soft-delete state, a destroyed child is simply gone from the table, nothing to scope
47
+ around.
48
+ - The `recordables:backfill` generator no longer needs (or generates) `.with_trashed` —
49
+ that trap only existed because of `trashable`'s `default_scope`, which is gone.
50
+
51
+ **Breaking change**, despite the patch version number — pre-1.0 and the only consumer of this
52
+ gem at the time of release is this repo's own author, so a 0.2.x bump was chosen deliberately
53
+ over the stricter semver convention a minor bump would normally signal here. Existing apps
54
+ using `trashable`/`trash!` need to migrate: replace `trash!` calls with `destroy!`, drop the
55
+ `trashable` macro call, and add a migration to drop `recordings.status` (and add
56
+ `events.actor_name`, make `events.recording_id`/`events.actor_id`/`recordings.creator_id`
57
+ nullable with `ON DELETE SET NULL`, if adopting the new destroy/actor behavior).
58
+
19
59
  ## [0.2.0]
20
60
 
21
61
  ### Added
data/README.md CHANGED
@@ -21,11 +21,37 @@ recording ──points at──▶ recordable the content (Article, Note,
21
21
  current when it happened
22
22
  ```
23
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.
24
+ - **`recordings`** — the spine. Foreign keys and position only. No text columns, no status —
25
+ it stays cheap to index and paginate however large it grows, and never becomes a state
26
+ machine (see "What recordable is for" below).
26
27
  - **recordables** — the content. Immutable: an edit inserts a row, so none of them carry
27
28
  `updated_at`.
28
29
  - **`events`** — append-only, and the reason history and the activity feed are the same data.
30
+ Survives its Recording being destroyed — see "Destroying a recordable" below.
31
+
32
+ ## What recordable is for
33
+
34
+ **Adopt it for content people draft and revise** — an article, a comment, a template someone
35
+ edits over time and might want to compare or restore an earlier version of. That's genuinely
36
+ what this gem is for, and the whole point of the three tables above.
37
+
38
+ **Don't adopt it for workflow state.** A `Task` moving `initial → in_progress → completed`, an
39
+ order moving `pending → shipped`, isn't content with a history worth version-browsing — it's a
40
+ state machine, and a plain `status` enum column with an ordinary `update!` is the right tool.
41
+ Modeling a status transition as `revise()` (a whole new row, a new id, every associated record
42
+ needing to be repointed) buys none of what versioning is for and costs real complexity: every
43
+ existing reference to the row goes stale the instant the status changes, `#reload` silently
44
+ starts returning the pre-transition version (see Testing, below), and every join or `belongs_to`
45
+ pointed at it needs its own repoint story.
46
+
47
+ If a type doesn't need "what did this used to say" or "restore an earlier draft," it almost
48
+ certainly doesn't need `recordable` — just a column.
49
+
50
+ **Status belongs on the recordable, never on the Recording.** If a recordable type genuinely
51
+ does need a status (a document that's `draft`/`published`, say), that's a plain column on the
52
+ recordable's own table, alongside its other content — not something this gem tracks on
53
+ `Recording` on the type's behalf. `Recording` only ever answers "what changed, when, and by
54
+ whom" — it has no opinion on what any particular snapshot's content *means*.
29
55
 
30
56
  ## Getting started
31
57
 
@@ -113,37 +139,65 @@ than the snapshot, so revising content never touches them.
113
139
  | | Where it lives |
114
140
  |---|---|
115
141
  | 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
- | `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 |
142
+ | `records` / `recordable` macros, `revise`, `revert_to`, `destroy!`, `versions`, `recordable_at`, `copy_content_to` and its guard | **Kept in the gem** |
143
+ | `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 |
144
+
145
+ Soft-delete (a "trashed but still in the table" state) isn't something this gem provides —
146
+ that's a separate concern from versioning, and conflating them was a mistake an earlier version
147
+ of this gem made. If your app wants soft-delete, layer it on top of your own recordable
148
+ type with your own scope; `recordable` won't fight you, and it won't manage that state for you
149
+ either.
118
150
 
119
151
  The rule: **generate what's opinionated, keep what fails silently.** Permissions, controllers
120
152
  and tree semantics are deliberately yours — that is why the generated files are plain Rails
121
153
  you can rewrite freely.
122
154
 
123
- ## Trashing, and the belongs_to trap
155
+ ## Destroying a recordable
124
156
 
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:
157
+ Deleting is a real, hard delete — not a status flip. `destroy!` on a Recording removes the
158
+ Recording and its current recordable row:
128
159
 
129
160
  ```ruby
130
161
  class Article < ApplicationRecord
131
162
  recordable
132
- trashable
133
163
  end
134
164
 
135
- article.recording.trash!(actor: current_user)
165
+ article.recording.destroy!(actor: current_user)
136
166
 
137
- Article.count # doesn't see it
138
- Article.with_trashed.count # does
167
+ Article.exists?(article.id) # false — really gone
139
168
  ```
140
169
 
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:
170
+ What survives is the log: every `Event` this recordable ever produced (`created`, `updated`,
171
+ `reverted`) stays in place, including a final `destroyed` event this call logs before removing
172
+ anything. An event's `recording_id` is nullified rather than cascaded when its Recording goes
173
+ away — the fact that an article was created, edited twice, and later deleted is itself part of
174
+ the record this gem exists to keep, even once the content is gone. This matters for compliance
175
+ flows too: deleting an account or an organization needs an actual purge path, not a soft-delete
176
+ flag that keeps the content sitting in the table forever waiting for a "real" cleanup that
177
+ never has to happen.
178
+
179
+ The same applies to *who* did something. An event's `actor` can be deleted independently of any
180
+ recordable it acted on — a person leaves the account, say. `actor_id` is nullified rather than
181
+ left dangling, but the event still needs to read sensibly afterward, so a name is snapshotted
182
+ onto the event the moment it's created:
183
+
184
+ ```ruby
185
+ event.actor # nil, once the actor row is gone
186
+ event.actor_name # "Jonas" — captured when the event happened, survives regardless
187
+ event.actor_label # actor&.name || actor_name — whichever is available right now
188
+ ```
189
+
190
+ `recordables:install` asks which attribute to snapshot (`--actor-label`, defaults to `name`) —
191
+ point it at whatever your actor's display name method is, e.g. `--actor-label=display_name`.
192
+
193
+ The other durability edge is `belongs_to`. Rails' association reader builds its own
194
+ `WHERE id = ...` directly against the target class using whatever id was assigned — but
195
+ `revise()` repoints a Recording at a brand new row the moment content changes, so a plain
196
+ `belongs_to :article` holding an id from before that silently returns the stale, superseded row
197
+ (or nil, if it's since been destroyed) instead of the current one. This is the sharpest edge in
198
+ the whole pattern, and it fails silently: nothing raises, the wrong data just quietly comes
199
+ back. Never hold onto or compare a `recording_id` directly for this reason — always resolve
200
+ through the recordable's own stable id. `recordable_belongs_to` is the fix:
147
201
 
148
202
  ```ruby
149
203
  class Comment < ApplicationRecord
@@ -175,7 +229,8 @@ Article.update_all(title: "x") # raises at the class level
175
229
  ```
176
230
 
177
231
  `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.
232
+ never a second write against a row that's already there. `destroy!` on the Recording is also
233
+ unaffected — it's the sanctioned way to remove an immutable recordable's row.
179
234
 
180
235
  ## Children: a real has_many, or a child Recording?
181
236
 
@@ -222,7 +277,7 @@ Recording), or does something external point at it (repoint_on_revise)?
222
277
  straight to association rows at `assign_attributes` time, but a new child needs its own
223
278
  Recording, which needs the parent to already be saved or revised. `nested_recordable_attributes_for`
224
279
  does the same job (a form posts an array of `{id:, ...fields, _destroy:}` hashes) through
225
- `revise()`/`trash!` instead of a raw write:
280
+ `revise()`/`destroy!` instead of a raw write:
226
281
 
227
282
  ```ruby
228
283
  class Routine < ApplicationRecord
@@ -243,7 +298,7 @@ end
243
298
  Two-phase, matching how a controller/interactor already has to split "assign" from "save"
244
299
  here: `tasks_attributes=` stores the submitted rows without writing anything, then
245
300
  `apply_tasks_attributes!(actor:)` — called once the routine itself has a current Recording —
246
- creates, revises, or trashes each one.
301
+ creates, revises, or destroys each one.
247
302
 
248
303
  ## Testing
249
304
 
@@ -275,10 +330,8 @@ assert_equal "complete", current_recordable(recording).status
275
330
 
276
331
  ## Adopting recordable on an existing table
277
332
 
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:
333
+ Scaffolds the migration that creates each existing row's first Recording (and its `created`
334
+ Event), backfilled from the row's own `created_at`:
282
335
 
283
336
  ```bash
284
337
  bin/rails generate recordables:backfill RoutineTemplate
@@ -286,15 +339,11 @@ bin/rails generate recordables:backfill RoutineTemplate
286
339
 
287
340
  ```ruby
288
341
  # generated: db/migrate/..._backfill_routine_templates_recordings.rb
289
- RoutineTemplate.with_trashed.find_each do |routine_template|
342
+ RoutineTemplate.find_each do |routine_template|
290
343
  Recording.record(routine_template, actor: routine_template.actor, created_at: routine_template.created_at)
291
344
  end
292
345
  ```
293
346
 
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
-
298
347
  ## Caveats
299
348
 
300
349
  - Never put a `uniqueness` validation on a recordable. Old snapshots still hold the old
@@ -304,12 +353,10 @@ something downstream (a query, a destroy cascade) turns up rows with no history
304
353
  - `events.details` is a `json` column. Ruby 4 ships json 3.x, whose `JSON.parse` moved to
305
354
  keyword arguments while ActiveSupport 8.1 still calls it positionally. Pin
306
355
  `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.
356
+ - `Model.delete_all` on a recordable type bypasses `destroy!`, so the Events it would have
357
+ logged never happen and any content-only associations it owns (rich text, attachments) can
358
+ be left behind. Prefer looping `recording.destroy!(actor:)` per row, or `destroy_all` if the
359
+ type has no other before_destroy concerns to skip.
313
360
 
314
361
  ## Development
315
362
 
@@ -319,7 +366,7 @@ bundle exec rake test
319
366
  ```
320
367
 
321
368
  The suite boots a small Rails application in `test/dummy`, so Action Text, Active Storage
322
- and the generators are exercised for real rather than stubbed. 41 tests cover the snapshot
369
+ and the generators are exercised for real rather than stubbed. The suite covers the snapshot
323
370
  lifecycle, rich text and attachment copy-forward, blob sharing, the uncopyable-association
324
371
  guard, and all three generators.
325
372
 
@@ -4,20 +4,11 @@ require "recordables/generator_helpers"
4
4
 
5
5
  module Recordables
6
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.
7
+ # Scaffolds the migration for adopting recordable on a table that
8
+ # already has rows: create the first Recording (and "created" Event)
9
+ # for each one.
10
10
  #
11
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
12
  class BackfillGenerator < Rails::Generators::NamedBase
22
13
  include ActiveRecord::Generators::Migration
23
14
  include Recordables::GeneratorHelpers
@@ -5,12 +5,7 @@ class Backfill<%= class_name.pluralize %>Recordings < ActiveRecord::Migration<%=
5
5
  say_with_time "Creating a Recording for every existing <%= file_name %>" do
6
6
  count = 0
7
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 %>|
8
+ <%= class_name %>.find_each do |<%= file_name %>|
14
9
  Recording.record(
15
10
  <%= file_name %>,
16
11
  actor: <%= file_name %>.<%= creator_expression %>,
@@ -27,7 +22,7 @@ class Backfill<%= class_name.pluralize %>Recordings < ActiveRecord::Migration<%=
27
22
  end
28
23
 
29
24
  def down
30
- Recording.where(recordable_type: "<%= class_name %>").with_trashed.find_each do |recording|
25
+ Recording.where(recordable_type: "<%= class_name %>").find_each do |recording|
31
26
  recording.events.delete_all
32
27
  recording.delete
33
28
  end
@@ -13,6 +13,8 @@ module Recordables
13
13
 
14
14
  class_option :actor, type: :string, default: "User",
15
15
  desc: "Model that creates recordings and appears on events"
16
+ class_option :actor_label, type: :string, default: "name",
17
+ desc: "Attribute on the actor to snapshot onto events, e.g. name or display_name"
16
18
  class_option :buckets, type: :boolean, default: true,
17
19
  desc: "Generate Bucket, the container that owns recordings"
18
20
 
@@ -52,6 +54,8 @@ module Recordables
52
54
 
53
55
  def actor_table = actor_class.tableize
54
56
 
57
+ def actor_label = options[:actor_label]
58
+
55
59
  def migration_version = "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
56
60
  end
57
61
  end
@@ -14,9 +14,8 @@ class CreateRecordablesTables < ActiveRecord::Migration<%= migration_version %>
14
14
  t.references :bucket, null: false, foreign_key: true
15
15
  <% end -%>
16
16
  t.references :parent, foreign_key: { to_table: :recordings }
17
- t.references :creator, null: false, foreign_key: { to_table: :<%= actor_table %> }
17
+ t.references :creator, foreign_key: { to_table: :<%= actor_table %>, on_delete: :nullify }
18
18
  t.references :recordable, polymorphic: true, null: false, index: false
19
- t.integer :status, null: false, default: 0
20
19
  t.integer :position
21
20
  t.timestamps
22
21
 
@@ -28,9 +27,10 @@ class CreateRecordablesTables < ActiveRecord::Migration<%= migration_version %>
28
27
  end
29
28
 
30
29
  create_table :events do |t|
31
- t.references :recording, null: false, foreign_key: true
30
+ t.references :recording, foreign_key: { on_delete: :nullify }
32
31
  t.references :recordable, polymorphic: true, null: false, index: false
33
- t.references :actor, null: false, foreign_key: { to_table: :<%= actor_table %> }
32
+ t.references :actor, foreign_key: { to_table: :<%= actor_table %>, on_delete: :nullify }
33
+ t.string :actor_name
34
34
  t.string :action, null: false
35
35
  t.json :details, null: false, default: {}
36
36
  t.datetime :created_at, null: false
@@ -1,8 +1,15 @@
1
1
  class Event < ApplicationRecord
2
- belongs_to :recording
2
+ belongs_to :recording, optional: true
3
3
  belongs_to :recordable, polymorphic: true
4
- belongs_to :actor, class_name: "<%= actor_class %>"
4
+ belongs_to :actor, class_name: "<%= actor_class %>", optional: true
5
+
6
+ before_create { self.actor_name ||= actor&.<%= actor_label %> }
5
7
 
6
8
  scope :newest_first, -> { order(created_at: :desc, id: :desc) }
7
9
  scope :of_type, ->(type) { where(recordable_type: type) }
10
+
11
+ # actor_name is a denormalized snapshot of actor.<%= actor_label %>, taken
12
+ # when the event was created — it stays legible even after the actor row
13
+ # itself is deleted, when actor_id is nullified to nil.
14
+ def actor_label = actor&.<%= actor_label %> || actor_name
8
15
  end
@@ -4,11 +4,11 @@ class Recording < ApplicationRecord
4
4
  <% if buckets? -%>
5
5
  belongs_to :bucket
6
6
  <% end -%>
7
- belongs_to :creator, class_name: "<%= actor_class %>"
7
+ belongs_to :creator, class_name: "<%= actor_class %>", optional: true
8
8
  belongs_to :parent, class_name: "Recording", optional: true
9
9
 
10
10
  has_many :children, class_name: "Recording", foreign_key: :parent_id, dependent: :destroy
11
- has_many :events, dependent: :destroy
11
+ has_many :events, dependent: :nullify
12
12
 
13
13
  delegate :commentable?, :publishable?, :nestable?, :summary, to: :recordable
14
14
 
@@ -45,7 +45,7 @@ module Recordables
45
45
  target = target_class_name.constantize
46
46
  return target.none unless recording
47
47
 
48
- target.joins(:recordings).merge(::Recording.active.where(parent_id: recording.id))
48
+ target.joins(:recordings).merge(::Recording.where(parent_id: recording.id))
49
49
  .order("recordings.position")
50
50
  end
51
51
 
@@ -6,7 +6,7 @@ module Recordables
6
6
  class ImmutableRecordable < StandardError; end
7
7
 
8
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!
9
+ # in place after it's first saved — only revise() (a new row) or destroy!
10
10
  # (on its Recording) should ever change what a caller sees.
11
11
  #
12
12
  # class RoutineTemplate < ApplicationRecord
@@ -31,7 +31,7 @@ module Recordables
31
31
 
32
32
  included do
33
33
  before_update { raise_immutable!(:update) }
34
- before_destroy { raise_immutable!(:destroy) }
34
+ before_destroy { raise_immutable!(:destroy) unless Recordables::Recording.destroying_recordable? }
35
35
  end
36
36
 
37
37
  # update_column/update_columns/delete bypass callbacks entirely (that's
@@ -57,7 +57,7 @@ module Recordables
57
57
  def raise_immutable!(verb)
58
58
  raise Recordables::ImmutableRecordable, <<~MESSAGE.squish
59
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"}
60
+ recordable — #{verb == :destroy ? "destroy! its Recording" : "call revise(actor:, **changes) on its Recording"}
61
61
  instead, so the change goes through the versioning this gem
62
62
  exists to provide.
63
63
  MESSAGE
@@ -23,20 +23,8 @@ module Recordables
23
23
  include Recordables::Recordable
24
24
  end
25
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
26
  # Raises if anything tries to write directly to a persisted recordable
39
- # row instead of going through revise()/trash! on its Recording. See
27
+ # row instead of going through revise()/destroy! on its Recording. See
40
28
  # Recordables::Immutable for the mechanics.
41
29
  #
42
30
  # class RoutineTemplate < ApplicationRecord
@@ -94,16 +82,17 @@ module Recordables
94
82
  __recordables_define_nested_attributes(plural_name, class_name: target_class_name, recording_attributes: recording_attributes)
95
83
  end
96
84
 
97
- # A belongs_to whose target is trashable, resolved through the append-only
85
+ # A belongs_to whose target is recordable, resolved through the append-only
98
86
  # Event log instead of the raw foreign key.
99
87
  #
100
88
  # Rails' belongs_to association reader builds its own `WHERE id = ...`
101
- # query directly against the target class — bypassing trashable's
102
- # default_scope entirely, since that scope is added to the class's own
103
- # query interface, not baked into every possible query Rails might build
104
- # against it. A plain belongs_to therefore silently returns the stale,
105
- # trashed-or-superseded row instead of nil once the target's been revised
106
- # or trashed — exactly the case this exists to handle.
89
+ # query directly against the target class — but revise() repoints a
90
+ # Recording at a brand new row id, so a plain belongs_to holding the id
91
+ # from whenever it was assigned silently returns the stale, superseded
92
+ # row (or nil, if that row's since been destroyed) instead of the
93
+ # current one. Never store or compare a recording_id directly for this
94
+ # reason — always resolve through the recordable's own stable id, the
95
+ # way this does.
107
96
  #
108
97
  # An Event's recordable_id is set once, at the moment that event
109
98
  # happened, and never rewritten — so the "created" event for a given
@@ -111,7 +100,7 @@ module Recordables
111
100
  # been revised since. That event's Recording is the stable pointer for
112
101
  # the whole lineage, so walking through it reaches whatever version is
113
102
  # current right now: the original row if it still is, or the latest
114
- # revision if the original has since been revised or trashed.
103
+ # revision if the original has since been revised.
115
104
  #
116
105
  # class Routine < ApplicationRecord
117
106
  # recordable_belongs_to :routine_template
@@ -24,7 +24,7 @@ module Recordables
24
24
  # needs its own Recording the moment it's created, which a plain
25
25
  # attribute assignment can't set up. This does the same job (a form
26
26
  # posts an array of {id:, ...fields, _destroy:} hashes; existing rows
27
- # get updated or trashed, new ones get created) through revise()/trash!
27
+ # get updated or destroyed, new ones get created) through revise()/destroy!
28
28
  # instead of a raw write.
29
29
  #
30
30
  # Two-phase, same as accepts_nested_attributes_for from the caller's
@@ -91,7 +91,7 @@ module Recordables
91
91
 
92
92
  if id && (child = current_by_id[id.to_s])
93
93
  if destroy
94
- child.recording.trash!(actor: actor)
94
+ child.recording.destroy!(actor: actor)
95
95
  else
96
96
  child.recording.revise(actor: actor, **changes)
97
97
  end
@@ -6,13 +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
9
+ # Set only around #destroy!'s own recordable.destroy! call below, so
10
+ # Immutable's before_destroy guard can tell "the gem's own sanctioned
11
+ # destroy path" apart from a caller destroying a recordable directly —
12
+ # the same distinction record/revise draw by only ever calling
13
+ # .new(...).save! and never touching an existing row's update!. Module
14
+ # level (not per-host-Recording-class) since Immutable is mixed into
15
+ # the recordable, which has no reference to the Recording class itself.
16
+ def self.destroying_recordable? = Thread.current[:recordables_destroying_recordable] || false
16
17
 
17
18
  class_methods do
18
19
  def record(recordable, actor:, parent: nil, **attributes)
@@ -23,12 +24,6 @@ module Recordables
23
24
  recording
24
25
  end
25
26
  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)
32
27
  end
33
28
 
34
29
  def revise(actor:, **changes)
@@ -59,14 +54,23 @@ module Recordables
59
54
  events.create!(recordable: snapshot, actor: actor, action: action, details: details)
60
55
  end
61
56
 
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)
57
+ # A real delete: removes this Recording and its recordable row. Events
58
+ # are never touched here — recording_id is nullified (see the install
59
+ # migration's foreign_key on_delete: :nullify), not cascaded, so the
60
+ # log this recordable ever happened stays intact after the content
61
+ # itself is gone. Logs a final "destroyed" event first, against the
62
+ # recordable that's about to disappear, so the log itself says what was
63
+ # removed and by whom.
64
+ def destroy!(actor: nil)
67
65
  transaction do
68
- update!(status: :trashed)
69
- log!("trashed", recordable, actor: actor)
66
+ log!("destroyed", recordable, actor: actor)
67
+ begin
68
+ Thread.current[:recordables_destroying_recordable] = true
69
+ recordable.destroy!
70
+ ensure
71
+ Thread.current[:recordables_destroying_recordable] = false
72
+ end
73
+ super()
70
74
  end
71
75
  end
72
76
  end
@@ -8,7 +8,6 @@ module Recordables
8
8
  #
9
9
  # class RoutineTemplate < ApplicationRecord
10
10
  # recordable
11
- # trashable
12
11
  # repoint_on_revise :routine_template_people, :goal_routine_templates
13
12
  # end
14
13
  #
@@ -1,3 +1,3 @@
1
1
  module Recordables
2
- VERSION = "0.2.1"
2
+ VERSION = "0.2.2"
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.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Medeiros
@@ -80,7 +80,6 @@ files:
80
80
  - lib/recordables/recording.rb
81
81
  - lib/recordables/repoint_on_revise.rb
82
82
  - lib/recordables/testing.rb
83
- - lib/recordables/trashable.rb
84
83
  - lib/recordables/version.rb
85
84
  homepage: https://github.com/jonasmedeiros/recordables
86
85
  licenses:
@@ -1,79 +0,0 @@
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