recorder 1.3.0 → 1.4.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: 2d2e570a4fef05c798efd2a10ded9db849f500e41fab1e13c6f9e485ebfdfdee
4
- data.tar.gz: 3a3e7f23e99544e05e0e0008de975f9bea9ed2089de89d999ac601a80b3cbe11
3
+ metadata.gz: 064c2afab28615f3f268bfacee354fdf6fceff7063190e084783dfe1640fe054
4
+ data.tar.gz: 6062f268b4a47ea089cf90fac14ac124fc2c5e07f65be6d37228a663ec4cfc5e
5
5
  SHA512:
6
- metadata.gz: 4202283d1af6b5da82c360a3537623516794855e56a31973ed43ff0f0bceb04cdfa412d180af0dab9dc30b096a5213966663dd0cb19af1130e76d52360c24570
7
- data.tar.gz: 4b7451c330c0115755813f0736135b5323b5d1f72b0f0583e21e88dcc725f1adf6fd44c5ebc6fd6060046304c75be96e282ab590a01c0b47d65eb6d4edf8ca80
6
+ metadata.gz: ed30006c3d6f960d017a8b97853d5737624c9690f31ffc60ee3d0dc2dbbb21507f3e52e8203dd103192ba8648ca6dc643e969a232fd40a7ee65d0dcc7f11a87b
7
+ data.tar.gz: 689acc4c8867035e627413e5586280e2b05327efa7a002713903300016f19a99caa1194e172de45633f6b4fb8771cdf3a380905784f327b3df01bca39ed93129
data/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0]
11
+
12
+ ### Added
13
+
14
+ - README coverage of what a revision's `data` holds: the complete attribute
15
+ snapshot on every event, when `changes` and `associations` are present, and
16
+ which events record a revision at all. No behaviour changes — the snapshot is
17
+ what the gem has always written, and it is now written down.
18
+ - A known issue for the `changes` key on `destroy` revisions, which describes
19
+ the record's last update rather than the deletion.
20
+ - A `changes:` option on `recorder` that merges extra entries into a revision's
21
+ `changes`. It takes a Proc evaluated on the record or the name of a method on it;
22
+ both receive the event.
23
+
24
+ ### Changed
25
+
26
+ - `Recorder::Revision` declares both `belongs_to :user` and `belongs_to :item`
27
+ with `optional: true`. The associations have always been optional in practice,
28
+ but only because the gem is required before the Active Record railtie applies
29
+ `belongs_to_required_by_default`, so the reflections are built while the
30
+ default is still off. Had that ordering ever shifted, every revision without a
31
+ user would have failed validation, and so would every `destroy` revision,
32
+ which is written after its item's row is deleted. `Tape::Record#record` calls
33
+ `create`, not `create!`, so those revisions would have been dropped with
34
+ nothing raised anywhere. Recorded revisions are unchanged.
35
+
36
+ ### Fixed
37
+
38
+ - `Recorder::Changeset#previous` and `#next` no longer raise `NoMethodError` when
39
+ the changes carry a key that is not an attribute of the model, and skip values
40
+ that are not a two-element `[old, new]` pair rather than indexing into them.
41
+
10
42
  ## [1.3.0] - 2026-09-16
11
43
 
12
44
  ### Added
@@ -104,7 +136,8 @@ Releases at and before 1.1.1 predate this changelog. See the
104
136
  [commit history](https://github.com/jetrockets/recorder/commits/master) for
105
137
  details.
106
138
 
107
- [Unreleased]: https://github.com/jetrockets/recorder/compare/v1.3.0...HEAD
139
+ [Unreleased]: https://github.com/jetrockets/recorder/compare/v1.4.0...HEAD
140
+ [1.4.0]: https://github.com/jetrockets/recorder/compare/v1.3.0...v1.4.0
108
141
  [1.3.0]: https://github.com/jetrockets/recorder/compare/v1.2.3...v1.3.0
109
142
  [1.2.3]: https://github.com/jetrockets/recorder/compare/v1.2.2...v1.2.3
110
143
  [1.2.2]: https://github.com/jetrockets/recorder/compare/v1.2.1...v1.2.2
data/README.md CHANGED
@@ -73,10 +73,36 @@ Recorder supports the following options:
73
73
  * `only: [array]` - only these attributes are logged, other attributes are ingored;
74
74
  * `associations: {hash} (hash)` - allows to set what associations will be logged alongside with the model. For each association you can also set ignore and only options;
75
75
  * `async: bool` - a logging strategy (true - asynchronous, false - synchronous).
76
-
77
- These per-model options do not reach the recorder yet — see [Known issues](#known-issues).
76
+ * `changes: Proc | Symbol` - extra entries to merge into a revision's `changes`. A Proc
77
+ is evaluated on the record, a Symbol names a method on it; both receive the event
78
+ (`:create`, `:update` or `:destroy`) and return a hash of `name => [old, new]`, or
79
+ `nil` for nothing. Anything else raises `ArgumentError` where `recorder` is called.
80
+
81
+ The entries are merged after `only:` and `ignore:` have been applied, so those
82
+ filters never drop them, and a key that matches an attribute replaces that
83
+ attribute's entry. An update that reports only custom entries still records a
84
+ revision.
85
+
86
+ Inside the callback, read `saved_changes` and `saved_change_to_<attribute>?`, not
87
+ `<attribute>_changed?`: the callback runs from `after_create`/`after_update`, where
88
+ the dirty state has already been reset.
89
+
90
+ `Recorder::Changeset` rebuilds the previous and next versions by assigning each
91
+ change onto a copy of the record, and skips what it cannot assign: a key that is
92
+ not an attribute, and any value that is not a two-element `[old, new]` pair. To display such
93
+ a key, define `previous_<key>`/`next_<key>` on the model's changeset class, and
94
+ pick a name that `Recorder::Changeset` does not already answer to.
95
+
96
+ These per-model options, `changes:` included, do not reach the recorder yet — see [Known issues](#known-issues).
78
97
  Until they do, every observed model records a full attribute snapshot, filtered
79
- only by the global `Recorder.config.ignore`.
98
+ only by the global `Recorder.config.ignore`. Options declared as an instance
99
+ method are read, so that is the way to opt into any of them today:
100
+
101
+ ```ruby
102
+ def recorder_options
103
+ {ignore: %i[identifier], changes: :extra_changes}
104
+ end
105
+ ```
80
106
 
81
107
  ### Global configuration
82
108
 
@@ -143,6 +169,26 @@ revision.user_id
143
169
  revision.action_date
144
170
  ```
145
171
 
172
+ `data` carries a complete attribute snapshot on every event, not only what
173
+ changed:
174
+
175
+ - `attributes` — every attribute of the record as it stood when the callback
176
+ ran, filtered by the model's `only:` or `ignore:` and, failing those, by
177
+ `Recorder.config.ignore`. Always present.
178
+ - `changes` — the same filter applied to `saved_changes`, as
179
+ `name => [old, new]`, plus any entries from `changes:`. Omitted when that
180
+ leaves nothing.
181
+ - `associations` — those two keys again, one entry per association named in
182
+ `associations:`. Omitted when no association reports anything.
183
+
184
+ The snapshot is the contract, not an accident of the implementation: a revision
185
+ is self-contained, so reconstructing a record at a point in time does not mean
186
+ replaying every prior diff. It is also what keeps a `destroy` revision useful,
187
+ since the row it describes is gone.
188
+
189
+ An `update` records a revision only when the record or one of its recorded
190
+ associations reports a change; `create` and `destroy` always record one.
191
+
146
192
  `#item_changeset` wraps `data['changes']` in a `Recorder::Changeset`, which reads
147
193
  the values back as the model's own types:
148
194
 
@@ -170,7 +216,7 @@ as `"#{model}Changeset"` — or point at another one with a
170
216
 
171
217
  ## Known issues
172
218
 
173
- The gem is under active maintenance and these defects are known as of 1.2.3:
219
+ The gem is under active maintenance and these defects are known as of 1.3.0:
174
220
 
175
221
  - The per-model options above (`ignore:`, `only:`, `associations:`, `async:`)
176
222
  are not applied. `Recorder::Tape` asks the record instance for
@@ -182,6 +228,10 @@ The gem is under active maintenance and these defects are known as of 1.2.3:
182
228
  `Recorder.store`, which `Recorder::Manager` drives.
183
229
  - Collection associations are never recorded. `associations:` handles only
184
230
  singular associations; a `has_many` reflection is skipped without a warning.
231
+ - A `destroy` revision carries a `changes` key describing the record's last
232
+ *update*. `destroy` does not clear `saved_changes`, and `data` is built the
233
+ same way for every event. The `attributes` snapshot is the accurate record of
234
+ what was deleted.
185
235
 
186
236
  ## Development
187
237
 
@@ -33,7 +33,7 @@ module Recorder
33
33
  @previous_version = item.dup
34
34
 
35
35
  changes.each do |key, change|
36
- @previous_version.send("#{key}=", change[0])
36
+ @previous_version.try("#{key}=", change[0]) if pair?(change)
37
37
  end
38
38
 
39
39
  @previous_version
@@ -49,10 +49,16 @@ module Recorder
49
49
  @next_version = item.dup
50
50
 
51
51
  changes.each do |key, change|
52
- @next_version.send("#{key}=", change[1])
52
+ @next_version.try("#{key}=", change[1]) if pair?(change)
53
53
  end
54
54
 
55
55
  @next_version
56
56
  end
57
+
58
+ private
59
+
60
+ def pair?(change)
61
+ change.is_a?(Array) && change.size == 2
62
+ end
57
63
  end
58
64
  end
@@ -27,6 +27,8 @@ module Recorder
27
27
  end
28
28
 
29
29
  def recorder(options = {})
30
+ Recorder::Tape::Data.validate_changes_option!(options[:changes])
31
+
30
32
  @recorder_options = options
31
33
 
32
34
  after_create do
@@ -18,8 +18,8 @@ module Recorder
18
18
  )
19
19
  end
20
20
 
21
- belongs_to :item, polymorphic: true, inverse_of: :revisions
22
- belongs_to :user
21
+ belongs_to :item, polymorphic: true, inverse_of: :revisions, optional: true
22
+ belongs_to :user, optional: true
23
23
 
24
24
  validates :item_type, presence: true
25
25
  validates :event, presence: true
@@ -23,8 +23,14 @@ module Recorder
23
23
  {attributes: sanitize_attributes(item.attributes, options)}
24
24
  end
25
25
 
26
+ def self.validate_changes_option!(callback)
27
+ return if callback.nil? || callback.is_a?(Proc) || callback.is_a?(Symbol) || callback.is_a?(String)
28
+
29
+ raise ArgumentError, "`changes:` expects a Proc or a method name, got #{callback.inspect}"
30
+ end
31
+
26
32
  def changes_for(event, options)
27
- changes = sanitize_attributes(item.saved_changes, options)
33
+ changes = sanitize_attributes(item.saved_changes, options).merge(custom_changes_for(event, options))
28
34
 
29
35
  changes.present? ? {changes: changes} : {}
30
36
  end
@@ -37,6 +43,17 @@ module Recorder
37
43
 
38
44
  private
39
45
 
46
+ def custom_changes_for(event, options)
47
+ callback = options[:changes]
48
+ return {} unless callback
49
+
50
+ self.class.validate_changes_option!(callback)
51
+
52
+ changes = callback.is_a?(Proc) ? item.instance_exec(event, &callback) : item.send(callback, event)
53
+
54
+ changes ? Hash(changes).symbolize_keys : {}
55
+ end
56
+
40
57
  def sanitize_attributes(attributes, options)
41
58
  if options[:only].present?
42
59
  only = wrap_options(options[:only])
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Recorder
4
- VERSION = '1.3.0'
4
+ VERSION = '1.4.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recorder
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Alexandrov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-17 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord