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 +4 -4
- data/CHANGELOG.md +41 -1
- data/README.md +84 -37
- data/lib/generators/recordables/backfill/backfill_generator.rb +3 -12
- data/lib/generators/recordables/backfill/templates/backfill_migration.rb.tt +2 -7
- data/lib/generators/recordables/install/install_generator.rb +4 -0
- data/lib/generators/recordables/install/templates/create_recordables_tables.rb.tt +4 -4
- data/lib/generators/recordables/install/templates/event.rb.tt +9 -2
- data/lib/generators/recordables/install/templates/recording.rb.tt +2 -2
- data/lib/recordables/has_children.rb +1 -1
- data/lib/recordables/immutable.rb +3 -3
- data/lib/recordables/macros.rb +10 -21
- data/lib/recordables/nested_recordable_attributes.rb +2 -2
- data/lib/recordables/recording.rb +24 -20
- data/lib/recordables/repoint_on_revise.rb +0 -1
- data/lib/recordables/version.rb +1 -1
- metadata +1 -2
- data/lib/recordables/trashable.rb +0 -79
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3cf0fb1698d5bf53e032be066ea8adb125b57cbcd5cd9363858f865e6eba13aa
|
|
4
|
+
data.tar.gz: 67cbad006b327319749fe63958f727550f347aa9728520e42538eddb491bdeb8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
|
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
|
-
| `
|
|
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
|
-
##
|
|
155
|
+
## Destroying a recordable
|
|
124
156
|
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
165
|
+
article.recording.destroy!(actor: current_user)
|
|
136
166
|
|
|
137
|
-
Article.
|
|
138
|
-
Article.with_trashed.count # does
|
|
167
|
+
Article.exists?(article.id) # false — really gone
|
|
139
168
|
```
|
|
140
169
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
the
|
|
146
|
-
|
|
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()`/`
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
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.
|
|
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
|
-
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
|
|
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 %>").
|
|
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,
|
|
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,
|
|
30
|
+
t.references :recording, foreign_key: { on_delete: :nullify }
|
|
32
31
|
t.references :recordable, polymorphic: true, null: false, index: false
|
|
33
|
-
t.references :actor,
|
|
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: :
|
|
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.
|
|
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
|
|
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 ? "
|
|
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
|
data/lib/recordables/macros.rb
CHANGED
|
@@ -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()/
|
|
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
|
|
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 —
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
data/lib/recordables/version.rb
CHANGED
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.
|
|
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
|