recorder 1.2.3 → 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: 807b7bea0674cab9a3a39b1fa03f78a62f0313650bb7adf51860a98257273186
4
- data.tar.gz: 175315c3c23acef56bd6f9545681b7525eb41a11cadacf5e671dd197352f31d2
3
+ metadata.gz: 064c2afab28615f3f268bfacee354fdf6fceff7063190e084783dfe1640fe054
4
+ data.tar.gz: 6062f268b4a47ea089cf90fac14ac124fc2c5e07f65be6d37228a663ec4cfc5e
5
5
  SHA512:
6
- metadata.gz: 8eb1a672f08d66c265f6d1b1dffd8d65703b4359f789febb42846f4bb0d89d860c49ffb383b78bd57f3bfefeb349fac6abd7ed8ecc890a22ffc45886087c91b7
7
- data.tar.gz: b65ed4fc4d7fea8a5d442f301789e497d538fa57447a320328f89650e9c65f8773d7247c68e303d49cdbda5977786fb64a8841c1cb5ba65a1aaa638ce9f63a3c
6
+ metadata.gz: ed30006c3d6f960d017a8b97853d5737624c9690f31ffc60ee3d0dc2dbbb21507f3e52e8203dd103192ba8648ca6dc643e969a232fd40a7ee65d0dcc7f11a87b
7
+ data.tar.gz: 689acc4c8867035e627413e5586280e2b05327efa7a002713903300016f19a99caa1194e172de45633f6b4fb8771cdf3a380905784f327b3df01bca39ed93129
data/CHANGELOG.md ADDED
@@ -0,0 +1,146 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
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
+
42
+ ## [1.3.0] - 2026-09-16
43
+
44
+ ### Added
45
+
46
+ - Support for Rails 7.0, 7.1, 7.2, 8.0, and 8.1. Rails 6.1 remains supported.
47
+ - Support for Ruby 3.3 and 3.4. Ruby 3.0 remains the minimum.
48
+ - Declared and tested the supported Ruby versions (#10).
49
+ - The specs and RuboCop now run in GitHub Actions (#9).
50
+ - Releases are published to RubyGems.org from GitHub Actions when a version tag
51
+ is pushed (#22).
52
+ - Gem metadata: `source_code_uri`, `changelog_uri`, `bug_tracker_uri`, and
53
+ `rubygems_mfa_required`.
54
+ - README coverage of the public API: configuration, `Recorder.meta=`,
55
+ `recorder_disabled!`, and the association tracking options (#20).
56
+
57
+ ### Changed
58
+
59
+ - `activerecord` and `activesupport` requirements widen from `~> 6.1` to
60
+ `>= 6.1, < 9`, and the `< 3.4` Ruby ceiling is removed. Rails 6.1 and 7.0
61
+ cannot run on Ruby 3.4; every other combination is exercised in CI.
62
+ - The released gem now ships only `lib/`, the README, the LICENSE, and this
63
+ changelog. Previous releases also carried repository scaffolding — CI
64
+ configuration, `Rakefile`, `bin/`, and dotfiles — that a host app never loads.
65
+ - `recorder_revisions.item_id` and `recorder_revisions.user_id` are now
66
+ `bigint`. Rails has defaulted primary keys to `bigint` since 5.1, so the
67
+ `integer` columns could not hold a key from any table this gem audits once it
68
+ passed 2,147,483,647. No release since Rails 5.0 shipped a migration that could
69
+ run; installs from the 0.1.x line on Rails 4 have the narrow columns and are
70
+ unaffected, because the gem ships no migration that alters an existing table
71
+ (#18).
72
+
73
+ ### Removed
74
+
75
+ - `pg` is no longer a runtime dependency. Revisions still require PostgreSQL
76
+ column types, but the adapter is the host application's to declare, so the
77
+ gem no longer forces `pg` into its bundle.
78
+ - The `--with_partitions` generator option. It dispatched to a template that was
79
+ never added to the gem, so the run wrote the first migration and then aborted,
80
+ leaving a partial install. It never completed once (#18).
81
+ - The `--with_number_column` generator option. The `number` column it added was
82
+ never read by the gem, and the migration it generated could not run on any
83
+ Rails this gem supports, so there is no working installed base. Anyone who has
84
+ the column keeps it — the counter lives in a trigger in their own schema,
85
+ which this does not touch, and the gem never writes the column. Optional
86
+ cleanup, including a working `DROP TRIGGER`, is in #14 — the `down` the gem
87
+ originally shipped is a syntax error and cannot roll it back (#14).
88
+
89
+ ### Fixed
90
+
91
+ - Asynchronous recording no longer enqueues job arguments Sidekiq rejects. The
92
+ `meta` hash supplied through `Recorder.meta=` reached the worker with its
93
+ original keys and values, and Sidekiq raises on arguments that are not JSON
94
+ native from 7.0 onwards. Because the raise happened inside an `after_create`,
95
+ it rolled back the host application's own write. The enqueued payload is now
96
+ normalised to JSON natives in full (#19).
97
+ - `rails generate recorder:install` now produces migrations that run. The
98
+ templates subclassed a bare `ActiveRecord::Migration`, which Rails has
99
+ rejected since 5.0, so `rails db:migrate` raised on the only documented way to
100
+ install the gem. The version is templated from the host app's Rails (#18).
101
+ - `Recorder.version` returns the version instead of raising `TypeError`. It
102
+ scoped into `VERSION::STRING`, but `VERSION` is a `String` (#17).
103
+ - `recorder_disabled!` re-enables recording when the block raises. The re-enable
104
+ was skipped on the exception path, so recording stayed off for the rest of the
105
+ request or job and every later save went silently unaudited (#17).
106
+ - Made the spec suite runnable and trustworthy again (#8).
107
+
108
+ ## [1.2.3] - 2023-04-25
109
+
110
+ ### Fixed
111
+
112
+ - A revision is created when an item's associations change.
113
+ - No revision is created for an `:update` event when nothing changed.
114
+
115
+ ## [1.2.2] - 2022-11-17
116
+
117
+ ### Fixed
118
+
119
+ - Refactoring for full Rails 6 compatibility.
120
+
121
+ ## [1.2.1] - 2022-11-16
122
+
123
+ ### Fixed
124
+
125
+ - Fixed parsing of empty associations.
126
+
127
+ ## [1.2.0] - 2022-11-15
128
+
129
+ ### Added
130
+
131
+ - Rails 6 support.
132
+
133
+ ## [1.1.1] - 2021-09-13
134
+
135
+ Releases at and before 1.1.1 predate this changelog. See the
136
+ [commit history](https://github.com/jetrockets/recorder/commits/master) for
137
+ details.
138
+
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
141
+ [1.3.0]: https://github.com/jetrockets/recorder/compare/v1.2.3...v1.3.0
142
+ [1.2.3]: https://github.com/jetrockets/recorder/compare/v1.2.2...v1.2.3
143
+ [1.2.2]: https://github.com/jetrockets/recorder/compare/v1.2.1...v1.2.2
144
+ [1.2.1]: https://github.com/jetrockets/recorder/compare/v1.2.0...v1.2.1
145
+ [1.2.0]: https://github.com/jetrockets/recorder/compare/v1.1.1...v1.2.0
146
+ [1.1.1]: https://github.com/jetrockets/recorder/releases/tag/v1.1.1
data/README.md CHANGED
@@ -1,6 +1,29 @@
1
1
  # Recorder
2
2
 
3
- Recorder tracks changes of your Rails models
3
+ [![CI](https://github.com/jetrockets/recorder/actions/workflows/ci.yml/badge.svg)](https://github.com/jetrockets/recorder/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/recorder)](https://rubygems.org/gems/recorder)
5
+
6
+ Recorder tracks changes of your Rails models. Each create, update, and destroy on
7
+ an observed model writes a `recorder_revisions` row holding an attribute snapshot,
8
+ the changes, and — when the controller concern is included — the user and IP
9
+ behind the request.
10
+
11
+ ## Requirements
12
+
13
+ - PostgreSQL — revisions are stored in `jsonb` and `inet` columns
14
+ - Ruby and Rails per the table below
15
+
16
+ | Rails | Supported Ruby |
17
+ |-------|----------------|
18
+ | 6.1 | 3.0 – 3.3 |
19
+ | 7.0 | 3.0 – 3.3 |
20
+ | 7.1 | 3.0 – 3.4 |
21
+ | 7.2 | 3.1 – 3.4 |
22
+ | 8.0 | 3.2 – 3.4 |
23
+ | 8.1 | 3.2 – 3.4 |
24
+
25
+ Rails 6.1 and 7.0 cannot run on Ruby 3.4 — they require `mutex_m`, which left the
26
+ default gems in that release. Every combination in the table is exercised in CI.
4
27
 
5
28
  ## Installation
6
29
 
@@ -14,15 +37,25 @@ And then execute:
14
37
 
15
38
  $ bundle
16
39
 
17
- Or install it yourself as:
40
+ Generate the migration for the `recorder_revisions` table:
41
+
42
+ $ rails generate recorder:install
43
+
44
+ The generator writes the migration but does not run it. Run it yourself:
45
+
46
+ $ rails db:migrate
47
+
48
+ The generator accepts one option:
18
49
 
19
- $ gem install recorder
50
+ - `--with_index_by_user_id` — adds an index on `user_id`.
20
51
 
21
52
  ## Usage
22
53
 
23
- To enable logging on a model you just need to include `Recorder::Observer` into the model and configure logging options for it:
54
+ ### Observing a model
24
55
 
25
- ``` ruby
56
+ Include `Recorder::Observer` into the model and configure logging options for it:
57
+
58
+ ```ruby
26
59
  class Post < ActiveRecord::Base
27
60
  include ::Recorder::Observer
28
61
 
@@ -40,8 +73,55 @@ Recorder supports the following options:
40
73
  * `only: [array]` - only these attributes are logged, other attributes are ingored;
41
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;
42
75
  * `async: bool` - a logging strategy (true - asynchronous, false - synchronous).
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.
43
89
 
44
- There are two strategies for logging: synchronous and asynchronous. When the synchronous strategy is used, a revision record is saved immediately after a model is saved, and the async strategy moves creating of revision records to [Sidekiq](https://github.com/mperham/sidekiq).
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).
97
+ Until they do, every observed model records a full attribute snapshot, filtered
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
+ ```
106
+
107
+ ### Global configuration
108
+
109
+ ```ruby
110
+ Recorder.config do |config|
111
+ config.ignore = %i[created_at updated_at]
112
+ config.async = false
113
+ config.sidekiq_options = {queue: 'recorder', retry: 10, backtrace: true}
114
+ end
115
+ ```
116
+
117
+ `ignore` defaults to `[]`, `async` to `false`, and `sidekiq_options` to the hash
118
+ shown above.
119
+
120
+ There are two strategies for logging: synchronous and asynchronous. When the synchronous strategy is used, a revision record is saved immediately after a model is saved, and the async strategy moves creating of revision records to [Sidekiq](https://github.com/sidekiq/sidekiq). Under the async
121
+ strategy the revision is enqueued to `Recorder::Sidekiq::RevisionsWorker` two
122
+ seconds out; the worker is loaded by the railtie when `Sidekiq` is defined.
123
+
124
+ ### Recording the current user
45
125
 
46
126
  To enable storing of such data as user_id and ip, you need to include `Recorder::Rails::ControllerConcern` to `ApplicationController`. Recorder uses [request_store](https://github.com/steveklabnik/request_store) to safely store these data on a thread level.
47
127
 
@@ -52,11 +132,142 @@ To enable storing of such data as user_id and ip, you need to include `Recorder:
52
132
  end
53
133
  ```
54
134
 
135
+ The concern reads `current_user`; override `recorder_user_id` to name a different
136
+ method. Override `recorder_meta` to store a hash alongside every revision. A
137
+ revision without a user is valid — `user_id` stays `nil`.
138
+
139
+ ### Turning recording off
140
+
141
+ `Recorder::Manager` suspends recording for the current request or thread:
142
+
143
+ ```ruby
144
+ class Importer
145
+ include Recorder::Manager
146
+
147
+ def call
148
+ recorder_disabled! do
149
+ # nothing recorded in here
150
+ end
151
+ end
152
+ end
153
+ ```
154
+
155
+ The block form re-enables recording on the way out, including when the block
156
+ raises. Called without a block, `recorder_disabled!` stays in effect until
157
+ `recorder_enabled!`.
158
+
159
+ ### Reading revisions
160
+
161
+ Observed models get a `revisions` association:
162
+
163
+ ```ruby
164
+ revision = post.revisions.ordered_by_created_at.first
165
+
166
+ revision.event # "update"
167
+ revision.data # {"attributes" => {...}, "changes" => {...}, "associations" => {...}}
168
+ revision.user_id
169
+ revision.action_date
170
+ ```
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
+
192
+ `#item_changeset` wraps `data['changes']` in a `Recorder::Changeset`, which reads
193
+ the values back as the model's own types:
194
+
195
+ ```ruby
196
+ changeset = revision.item_changeset
197
+
198
+ changeset.keys # attributes that changed
199
+ changeset.previous(:title) # value before the change
200
+ changeset.next(:title) # value after it
201
+ changeset.human_attribute_name(:title)
202
+ changeset.previous_version # a copy of the record with the old values
203
+ ```
204
+
205
+ Changed associations are reachable the same way:
206
+
207
+ ```ruby
208
+ revision.changed_associations # ["author"]
209
+ revision.association_changeset('author')
210
+ ```
211
+
212
+ To render a changeset yourself, define `PostChangeset` — the class is looked up
213
+ as `"#{model}Changeset"` — or point at another one with a
214
+ `recorder_changeset_class` class method on the model. Otherwise
215
+ `Recorder::Changeset` is used.
216
+
217
+ ## Known issues
218
+
219
+ The gem is under active maintenance and these defects are known as of 1.3.0:
220
+
221
+ - The per-model options above (`ignore:`, `only:`, `associations:`, `async:`)
222
+ are not applied. `Recorder::Tape` asks the record instance for
223
+ `recorder_options`, but `Recorder::Observer` defines that method on the class,
224
+ so the lookup always falls back to `{}` — every observed model records a full
225
+ attribute snapshot, synchronously. Global configuration is unaffected.
226
+ - `Recorder.enabled=` does not switch recording off. It writes to
227
+ `Recorder.config`, which nothing on the recording path reads — the gates are in
228
+ `Recorder.store`, which `Recorder::Manager` drives.
229
+ - Collection associations are never recorded. `associations:` handles only
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.
235
+
55
236
  ## Development
56
237
 
57
238
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
58
239
 
59
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
240
+ The specs need a PostgreSQL server. Connection settings are read from the
241
+ environment — `RECORDER_DB_HOST`, `RECORDER_DB_PORT`, `RECORDER_DB_USERNAME`,
242
+ `RECORDER_DB_PASSWORD`, `RECORDER_DB_NAME` — and default to `postgres:postgres`
243
+ on `localhost:5432` against a `recorder_test` database, which must already exist.
244
+
245
+ `rake spec` runs against whichever Rails version the root `Gemfile` resolves to.
246
+ To run against a specific one, use the appraisal gemfiles:
247
+
248
+ ```bash
249
+ bundle exec appraisal install # once, to generate gemfiles/
250
+ bundle exec appraisal rails-7.2 rake spec
251
+ ```
252
+
253
+ CI runs the suite across the whole Ruby × Rails matrix above, plus `bundle exec
254
+ rubocop`. Both must pass before a pull request merges.
255
+
256
+ To install this gem onto your local machine, run `bundle exec rake install`.
257
+
258
+ To release a new version, bump `Recorder::VERSION` and move the `[Unreleased]`
259
+ changelog entries under it in a pull request. Once that merges, tag the merge
260
+ commit and push the tag:
261
+
262
+ ```bash
263
+ git fetch origin
264
+ git tag vX.Y.Z origin/master
265
+ git push origin vX.Y.Z
266
+ ```
267
+
268
+ The Release workflow then runs CI against the tagged commit, checks the tag
269
+ matches `Recorder::VERSION`, publishes the gem to [rubygems.org](https://rubygems.org)
270
+ through trusted publishing, and creates the GitHub release from the changelog.
60
271
 
61
272
  ## Contributing
62
273
 
@@ -64,11 +275,10 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/jetroc
64
275
 
65
276
  ## Credits
66
277
 
67
- ![JetRockets](https://media.jetrockets.pro/jetrockets-white.png)
278
+ ![JetRockets](https://media.jetrockets.com/jetrockets-white.png)
68
279
 
69
- Recorder is maintained by [JetRockets](https://www.jetrockets.pro]).
280
+ Recorder is maintained by [JetRockets](https://www.jetrockets.com).
70
281
 
71
282
  ## License
72
283
 
73
284
  The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
74
-
@@ -10,20 +10,6 @@ module Recorder
10
10
 
11
11
  source_root File.expand_path('templates', __dir__)
12
12
 
13
- class_option(
14
- :with_partitions,
15
- type: :boolean,
16
- default: false,
17
- desc: 'Create partitions to `recorder_revisions` table'
18
- )
19
-
20
- class_option(
21
- :with_number_column,
22
- type: :boolean,
23
- default: false,
24
- desc: 'Add `number` column to `recorder_revisions` table'
25
- )
26
-
27
13
  class_option(
28
14
  :with_index_by_user_id,
29
15
  type: :boolean,
@@ -35,9 +21,7 @@ module Recorder
35
21
 
36
22
  def create_migration_file
37
23
  add_or_skip_recorder_migration('create_recorder_revisions')
38
- add_or_skip_recorder_migration('add_number_column_to_recorder_revisions') if options.with_number_column?
39
24
  add_or_skip_recorder_migration('add_index_by_user_id_to_recorder_revisions') if options.with_index_by_user_id?
40
- add_or_skip_recorder_migration('add_partitions_to_recorder_revisions') if options.with_partitions?
41
25
  end
42
26
 
43
27
  def self.next_migration_number(dirname)
@@ -47,11 +31,11 @@ module Recorder
47
31
  protected
48
32
 
49
33
  def add_or_skip_recorder_migration(template)
50
- migration_dir = File.expand_path('db/migrate')
34
+ migration_dir = File.expand_path('db/migrate', destination_root)
51
35
  if self.class.migration_exists?(migration_dir, template)
52
36
  ::Kernel.warn "Migration already exists: #{template}"
53
37
  else
54
- migration_template "#{template}.rb", "db/migrate/#{template}.rb"
38
+ migration_template "#{template}.rb.tt", "db/migrate/#{template}.rb"
55
39
  end
56
40
  end
57
41
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This migration adds number column to the `revisions` table.
4
- class AddIndexByUserIdToRecorderRevisions < ActiveRecord::Migration
3
+ # This migration adds an index by `user_id` to the `recorder_revisions` table.
4
+ class AddIndexByUserIdToRecorderRevisions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
5
5
  def change
6
6
  add_index :recorder_revisions, :user_id
7
7
  end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # This migration creates the `recorder_revisions` table.
4
- class CreateRecorderRevisions < ActiveRecord::Migration
4
+ class CreateRecorderRevisions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
5
5
  def change
6
6
  create_table :recorder_revisions do |t|
7
7
  t.string :item_type, null: false
8
- t.integer :item_id
8
+ t.bigint :item_id
9
9
  t.string :event, null: false
10
10
  t.jsonb :data, null: false
11
11
  t.inet :ip
12
12
  t.date :action_date, null: false
13
- t.integer :user_id
13
+ t.bigint :user_id
14
14
  t.jsonb :meta
15
15
  t.datetime :created_at, null: false
16
16
  end
@@ -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
@@ -4,9 +4,11 @@ module Recorder
4
4
  module Manager
5
5
  def recorder_disabled!
6
6
  Recorder.store.recorder_disabled!
7
+ return unless block_given?
7
8
 
8
- if block_given?
9
+ begin
9
10
  yield
11
+ ensure
10
12
  Recorder.store.recorder_enabled!
11
13
  end
12
14
  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])
@@ -28,13 +28,15 @@ module Recorder
28
28
  options[:async].nil? ? Recorder.config.async : options[:async]
29
29
  end
30
30
 
31
+ # Sidekiq accepts only JSON-native job arguments. `data` is serialised
32
+ # ahead of the round trip so it survives as a string for the worker to
33
+ # parse back; everything else is normalised by the round trip itself.
31
34
  def record_async(params, options)
32
35
  params[:data] = params[:data].to_json
33
- params[:action_date] = params[:action_date].to_s
34
36
 
35
37
  Recorder::Sidekiq::RevisionsWorker.perform_in(
36
38
  options[:delay] || 2.seconds,
37
- params.stringify_keys
39
+ JSON.parse(params.to_json)
38
40
  )
39
41
  end
40
42
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Recorder
4
- VERSION = '1.2.3'
4
+ VERSION = '1.4.0'
5
5
  end
data/lib/recorder.rb CHANGED
@@ -60,7 +60,7 @@ module Recorder
60
60
 
61
61
  # Returns version of Recorder as +String+
62
62
  def version
63
- VERSION::STRING
63
+ VERSION
64
64
  end
65
65
  end
66
66
  end
metadata CHANGED
@@ -1,71 +1,83 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recorder
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Alexandrov
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-25 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
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '6.1'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '6.1'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: activesupport
29
35
  requirement: !ruby/object:Gem::Requirement
30
36
  requirements:
31
- - - "~>"
37
+ - - ">="
32
38
  - !ruby/object:Gem::Version
33
39
  version: '6.1'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '9'
34
43
  type: :runtime
35
44
  prerelease: false
36
45
  version_requirements: !ruby/object:Gem::Requirement
37
46
  requirements:
38
- - - "~>"
47
+ - - ">="
39
48
  - !ruby/object:Gem::Version
40
49
  version: '6.1'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '9'
41
53
  - !ruby/object:Gem::Dependency
42
- name: pg
54
+ name: request_store
43
55
  requirement: !ruby/object:Gem::Requirement
44
56
  requirements:
45
57
  - - ">="
46
58
  - !ruby/object:Gem::Version
47
- version: '0'
59
+ version: '1.0'
48
60
  type: :runtime
49
61
  prerelease: false
50
62
  version_requirements: !ruby/object:Gem::Requirement
51
63
  requirements:
52
64
  - - ">="
53
65
  - !ruby/object:Gem::Version
54
- version: '0'
66
+ version: '1.0'
55
67
  - !ruby/object:Gem::Dependency
56
- name: request_store
68
+ name: appraisal
57
69
  requirement: !ruby/object:Gem::Requirement
58
70
  requirements:
59
- - - ">="
71
+ - - "~>"
60
72
  - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :runtime
73
+ version: '2.5'
74
+ type: :development
63
75
  prerelease: false
64
76
  version_requirements: !ruby/object:Gem::Requirement
65
77
  requirements:
66
- - - ">="
78
+ - - "~>"
67
79
  - !ruby/object:Gem::Version
68
- version: '0'
80
+ version: '2.5'
69
81
  - !ruby/object:Gem::Dependency
70
82
  name: bundler
71
83
  requirement: !ruby/object:Gem::Requirement
@@ -84,16 +96,16 @@ dependencies:
84
96
  name: generator_spec
85
97
  requirement: !ruby/object:Gem::Requirement
86
98
  requirements:
87
- - - ">="
99
+ - - "~>"
88
100
  - !ruby/object:Gem::Version
89
- version: '0'
101
+ version: '0.9'
90
102
  type: :development
91
103
  prerelease: false
92
104
  version_requirements: !ruby/object:Gem::Requirement
93
105
  requirements:
94
- - - ">="
106
+ - - "~>"
95
107
  - !ruby/object:Gem::Version
96
- version: '0'
108
+ version: '0.9'
97
109
  - !ruby/object:Gem::Dependency
98
110
  name: jetrockets-standard
99
111
  requirement: !ruby/object:Gem::Requirement
@@ -109,47 +121,47 @@ dependencies:
109
121
  - !ruby/object:Gem::Version
110
122
  version: '0'
111
123
  - !ruby/object:Gem::Dependency
112
- name: rake
124
+ name: pg
113
125
  requirement: !ruby/object:Gem::Requirement
114
126
  requirements:
115
- - - "~>"
127
+ - - ">="
116
128
  - !ruby/object:Gem::Version
117
- version: '13.0'
129
+ version: '1.0'
118
130
  type: :development
119
131
  prerelease: false
120
132
  version_requirements: !ruby/object:Gem::Requirement
121
133
  requirements:
122
- - - "~>"
134
+ - - ">="
123
135
  - !ruby/object:Gem::Version
124
- version: '13.0'
136
+ version: '1.0'
125
137
  - !ruby/object:Gem::Dependency
126
- name: rspec-rails
138
+ name: rake
127
139
  requirement: !ruby/object:Gem::Requirement
128
140
  requirements:
129
- - - ">="
141
+ - - "~>"
130
142
  - !ruby/object:Gem::Version
131
- version: '0'
143
+ version: '13.0'
132
144
  type: :development
133
145
  prerelease: false
134
146
  version_requirements: !ruby/object:Gem::Requirement
135
147
  requirements:
136
- - - ">="
148
+ - - "~>"
137
149
  - !ruby/object:Gem::Version
138
- version: '0'
150
+ version: '13.0'
139
151
  - !ruby/object:Gem::Dependency
140
- name: rails-dummy
152
+ name: rspec-rails
141
153
  requirement: !ruby/object:Gem::Requirement
142
154
  requirements:
143
155
  - - ">="
144
156
  - !ruby/object:Gem::Version
145
- version: '0'
157
+ version: '5.0'
146
158
  type: :development
147
159
  prerelease: false
148
160
  version_requirements: !ruby/object:Gem::Requirement
149
161
  requirements:
150
162
  - - ">="
151
163
  - !ruby/object:Gem::Version
152
- version: '0'
164
+ version: '5.0'
153
165
  description: Recorder tracks changes of your Rails models
154
166
  email:
155
167
  - igor.alexandrov@jetrockets.com
@@ -157,21 +169,13 @@ executables: []
157
169
  extensions: []
158
170
  extra_rdoc_files: []
159
171
  files:
160
- - ".gitignore"
161
- - ".rspec"
162
- - ".rubocop.yml"
163
- - ".travis.yml"
164
- - Gemfile
172
+ - CHANGELOG.md
165
173
  - LICENSE.txt
166
174
  - README.md
167
- - Rakefile
168
- - bin/console
169
- - bin/setup
170
175
  - lib/generators/recorder/USAGE
171
176
  - lib/generators/recorder/install_generator.rb
172
- - lib/generators/recorder/templates/add_index_by_user_id_to_recorder_revisions.rb
173
- - lib/generators/recorder/templates/add_number_column_to_recorder_revisions.rb
174
- - lib/generators/recorder/templates/create_recorder_revisions.rb
177
+ - lib/generators/recorder/templates/add_index_by_user_id_to_recorder_revisions.rb.tt
178
+ - lib/generators/recorder/templates/create_recorder_revisions.rb.tt
175
179
  - lib/recorder.rb
176
180
  - lib/recorder/changeset.rb
177
181
  - lib/recorder/config.rb
@@ -186,11 +190,14 @@ files:
186
190
  - lib/recorder/tape/data.rb
187
191
  - lib/recorder/tape/record.rb
188
192
  - lib/recorder/version.rb
189
- - recorder.gemspec
190
193
  homepage: https://github.com/jetrockets/recorder
191
194
  licenses:
192
195
  - MIT
193
- metadata: {}
196
+ metadata:
197
+ source_code_uri: https://github.com/jetrockets/recorder
198
+ changelog_uri: https://github.com/jetrockets/recorder/blob/master/CHANGELOG.md
199
+ bug_tracker_uri: https://github.com/jetrockets/recorder/issues
200
+ rubygems_mfa_required: 'true'
194
201
  post_install_message:
195
202
  rdoc_options: []
196
203
  require_paths:
@@ -199,14 +206,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
199
206
  requirements:
200
207
  - - ">="
201
208
  - !ruby/object:Gem::Version
202
- version: '0'
209
+ version: '3.0'
203
210
  required_rubygems_version: !ruby/object:Gem::Requirement
204
211
  requirements:
205
212
  - - ">="
206
213
  - !ruby/object:Gem::Version
207
214
  version: '0'
208
215
  requirements: []
209
- rubygems_version: 3.4.6
216
+ rubygems_version: 3.5.22
210
217
  signing_key:
211
218
  specification_version: 4
212
219
  summary: Rails model auditor
data/.gitignore DELETED
@@ -1,16 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /Gemfile.lock
4
- /_yardoc/
5
- /coverage/
6
- /doc/
7
- /pkg/
8
- /spec/reports/
9
- /tmp/
10
-
11
- /spec/dummy/config/database.yml
12
- /spec/dummy/log/*
13
-
14
- # Ignore vim swap files
15
- *.swp
16
- *.swo
data/.rspec DELETED
@@ -1,2 +0,0 @@
1
- --format documentation
2
- --color
data/.rubocop.yml DELETED
@@ -1,27 +0,0 @@
1
- inherit_gem:
2
- jetrockets-standard: config/gems.yml
3
-
4
- AllCops:
5
- Exclude:
6
- - 'bin/*'
7
- - 'tmp/**/*'
8
- - 'docs/**/*'
9
- - 'Gemfile'
10
- - 'vendor/**/*'
11
- - 'gemfiles/**/*'
12
- DisplayCopNames: true
13
- TargetRubyVersion: 2.5
14
-
15
- Style/TrailingCommaInArrayLiteral:
16
- EnforcedStyleForMultiline: no_comma
17
-
18
- Style/TrailingCommaInHashLiteral:
19
- EnforcedStyleForMultiline: no_comma
20
-
21
- Layout/ParameterAlignment:
22
- EnforcedStyle: with_first_parameter
23
-
24
- # See https://github.com/rubocop-hq/rubocop/issues/4222
25
- Lint/AmbiguousBlockAssociation:
26
- Exclude:
27
- - 'spec/**/*'
data/.travis.yml DELETED
@@ -1,4 +0,0 @@
1
- language: ruby
2
- rvm:
3
- - 2.2.4
4
- before_install: gem install bundler -v 1.11.2
data/Gemfile DELETED
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'https://rubygems.org'
4
-
5
- # Specify your gem's dependencies in recorder.gemspec
6
- gemspec
data/Rakefile DELETED
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'bundler/gem_tasks'
4
- require 'rspec/core/rake_task'
5
- require 'rails/dummy/tasks'
6
-
7
- RSpec::Core::RakeTask.new(:spec)
8
-
9
- task default: :spec
data/bin/console DELETED
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require 'bundler/setup'
5
- require 'recorder'
6
-
7
- # You can add fixtures and/or initialization code here to make experimenting
8
- # with your gem easier. You can also use a different console, if you like.
9
-
10
- # (If you use this, don't forget to add pry to your Gemfile!)
11
- # require "pry"
12
- # Pry.start
13
-
14
- require 'irb'
15
- IRB.start
data/bin/setup DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This migration adds number column to the `revisions` table.
4
- class AddNumberColumnToRecorderRevisions < ActiveRecord::Migration
5
- def up
6
- add_column :recorder_revisions, :number, :integer, null: false, default: 0
7
-
8
- execute <<~SQL
9
- CREATE OR REPLACE FUNCTION get_recorder_revisions_number()
10
- RETURNS trigger AS
11
- $BODY$
12
- BEGIN
13
- SELECT COALESCE(MAX(recorder_revisions.number), 0) + 1
14
- INTO NEW.number
15
- FROM
16
- recorder_revisions
17
- WHERE
18
- recorder_revisions.item_type = NEW.item_type
19
- AND recorder_revisions.item_id = NEW.item_id;
20
- #{" "}
21
- RETURN NEW;
22
- END;
23
- $BODY$ LANGUAGE plpgsql;
24
- SQL
25
-
26
- execute <<~SQL
27
- CREATE TRIGGER update_recorder_revisions_number
28
- BEFORE INSERT ON recorder_revisions FOR EACH ROW
29
- EXECUTE PROCEDURE get_recorder_revisions_number();
30
- SQL
31
- end
32
-
33
- def down
34
- execute <<-SQL
35
- DROP TRIGGER update_recorder_revisions_number;
36
- SQL
37
-
38
- execute <<-SQL
39
- DROP FUNCTION IF EXISTS get_recorder_revisions_number;
40
- SQL
41
-
42
- remove_column :recorder_revisions, :number
43
- end
44
- end
data/recorder.gemspec DELETED
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- lib = File.expand_path('lib', __dir__)
4
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
- require 'recorder/version'
6
-
7
- Gem::Specification.new do |spec|
8
- spec.name = 'recorder'
9
- spec.version = Recorder::VERSION
10
- spec.authors = ['Igor Alexandrov']
11
- spec.email = ['igor.alexandrov@jetrockets.com']
12
-
13
- spec.summary = 'Rails model auditor'
14
- spec.description = 'Recorder tracks changes of your Rails models'
15
- spec.homepage = 'https://github.com/jetrockets/recorder'
16
- spec.license = 'MIT'
17
-
18
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
19
- spec.bindir = 'exe'
20
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
- spec.require_paths = ['lib']
22
-
23
- spec.add_dependency 'activerecord', '~> 6.1'
24
- spec.add_dependency 'activesupport', '~> 6.1'
25
- spec.add_dependency 'pg'
26
- spec.add_dependency 'request_store'
27
-
28
- spec.add_development_dependency 'bundler', '>= 2.0'
29
- # spec.add_development_dependency 'database_cleaner'
30
- spec.add_development_dependency 'generator_spec'
31
- spec.add_development_dependency 'jetrockets-standard'
32
- spec.add_development_dependency 'rake', '~> 13.0'
33
- spec.add_development_dependency 'rspec-rails'
34
- # spec.add_development_dependency 'timecop', '~> 0.8.1'
35
- # spec.add_development_dependency 'yard'
36
- spec.add_development_dependency 'rails-dummy'
37
- end