trakable 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +81 -0
  3. data/CHANGELOG.md +50 -0
  4. data/LICENSE +21 -0
  5. data/README.md +330 -0
  6. data/Rakefile +16 -0
  7. data/benchmark/full_benchmark.rb +221 -0
  8. data/benchmark/integration_memory.rb +70 -0
  9. data/benchmark/memory_benchmark.rb +141 -0
  10. data/benchmark/perf_benchmark.rb +130 -0
  11. data/integration/README.md +65 -0
  12. data/integration/run_all.rb +62 -0
  13. data/integration/scenarios/01-basic-tracking/scenario.rb +51 -0
  14. data/integration/scenarios/02-revert-restoration/scenario.rb +103 -0
  15. data/integration/scenarios/03-whodunnit-tracking/scenario.rb +72 -0
  16. data/integration/scenarios/04-cleanup-retention/scenario.rb +66 -0
  17. data/integration/scenarios/05-without-tracking/scenario.rb +62 -0
  18. data/integration/scenarios/06-callback-lifecycle/scenario.rb +103 -0
  19. data/integration/scenarios/07-global-config/scenario.rb +52 -0
  20. data/integration/scenarios/08-controller-integration/scenario.rb +44 -0
  21. data/integration/scenarios/09-cleanup-max-traks/scenario.rb +58 -0
  22. data/integration/scenarios/10-model-configuration/scenario.rb +68 -0
  23. data/integration/scenarios/11-conditional-tracking/scenario.rb +48 -0
  24. data/integration/scenarios/12-metadata/scenario.rb +54 -0
  25. data/integration/scenarios/13-traks-association/scenario.rb +80 -0
  26. data/integration/scenarios/14-time-travel/scenario.rb +132 -0
  27. data/integration/scenarios/15-diffing-changeset/scenario.rb +109 -0
  28. data/integration/scenarios/16-serialization/scenario.rb +159 -0
  29. data/integration/scenarios/17-associations-tracking/scenario.rb +143 -0
  30. data/integration/scenarios/18-bulk-operations/scenario.rb +70 -0
  31. data/integration/scenarios/19-transactions/scenario.rb +89 -0
  32. data/integration/scenarios/20-performance/scenario.rb +89 -0
  33. data/integration/scenarios/21-storage-backends/scenario.rb +52 -0
  34. data/integration/scenarios/22-multi-tenancy/scenario.rb +49 -0
  35. data/integration/scenarios/23-sti/scenario.rb +58 -0
  36. data/integration/scenarios/24-edge-cases-part1/scenario.rb +86 -0
  37. data/integration/scenarios/25-edge-cases-part2/scenario.rb +74 -0
  38. data/integration/scenarios/26-edge-cases-part3/scenario.rb +76 -0
  39. data/integration/scenarios/27-api-query-interface/scenario.rb +78 -0
  40. data/integration/scenarios/28-security-compliance/scenario.rb +61 -0
  41. data/integration/scenarios/29-soft-delete/scenario.rb +43 -0
  42. data/integration/scenarios/30-custom-events/scenario.rb +45 -0
  43. data/integration/scenarios/31-gem-packaging/scenario.rb +58 -0
  44. data/integration/scenarios/32-bypass-fail-closed/scenario.rb +77 -0
  45. data/integration/scenarios/33-coexistence-standalone/scenario.rb +53 -0
  46. data/integration/scenarios/34-real-tracking/scenario.rb +254 -0
  47. data/integration/scenarios/35-revert-undo/scenario.rb +235 -0
  48. data/integration/scenarios/36-whodunnit-deep/scenario.rb +281 -0
  49. data/integration/scenarios/37-real-world-use-cases/scenario.rb +1213 -0
  50. data/integration/scenarios/38-concurrency/scenario.rb +163 -0
  51. data/integration/scenarios/39-query-scopes/scenario.rb +126 -0
  52. data/integration/scenarios/40-whodunnit-config/scenario.rb +113 -0
  53. data/integration/scenarios/41-batch-cleanup/scenario.rb +186 -0
  54. data/integration/scenarios/scenario_runner.rb +68 -0
  55. data/lib/generators/trakable/install_generator.rb +28 -0
  56. data/lib/generators/trakable/templates/create_traks_migration.rb +23 -0
  57. data/lib/generators/trakable/templates/trakable_initializer.rb +15 -0
  58. data/lib/trakable/cleanup.rb +89 -0
  59. data/lib/trakable/config.rb +22 -0
  60. data/lib/trakable/context.rb +85 -0
  61. data/lib/trakable/controller.rb +25 -0
  62. data/lib/trakable/model.rb +99 -0
  63. data/lib/trakable/railtie.rb +28 -0
  64. data/lib/trakable/revertable.rb +166 -0
  65. data/lib/trakable/tracker.rb +134 -0
  66. data/lib/trakable/trak.rb +98 -0
  67. data/lib/trakable/version.rb +5 -0
  68. data/lib/trakable.rb +51 -0
  69. data/trakable.gemspec +41 -0
  70. metadata +242 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0dcab04bb1079556fb3f2f036a3e7d6a4ee423dc5d9ab31cb27ea41c4bbd4099
4
+ data.tar.gz: c7b63e5946469731c3ecacd67d013d44928f635308c70118a91b07adddc82887
5
+ SHA512:
6
+ metadata.gz: ed12c7a3f09be0d82af29c7d47bc155a70c504c2f46370298e75e65fbad52706a3cf168f6a214d04f52b330f11c39b6404fd4a10de504cb646e33cfe0dbb6ff9
7
+ data.tar.gz: 654294059fa0e7c7a07531b702797988f0f6bedc491496d2c8ee8c37a3083927e02d60b24dc9e315c5c605796d18ac7d23b33219e08748c324084a307ebdd02c
data/.rubocop.yml ADDED
@@ -0,0 +1,81 @@
1
+ plugins: []
2
+
3
+ AllCops:
4
+ NewCops: enable
5
+ TargetRubyVersion: 3.1
6
+ SuggestExtensions: false
7
+ Exclude:
8
+ - benchmark/**/*
9
+ - integration/**/*
10
+ - vendor/**/*
11
+
12
+ Metrics/BlockLength:
13
+ Exclude:
14
+ - test/**/*
15
+ - rakelib/**/*
16
+ - "*.gemspec"
17
+ - lib/trakable/model.rb
18
+
19
+ Metrics/ClassLength:
20
+ Max: 200
21
+
22
+ Metrics/MethodLength:
23
+ Max: 20
24
+ Exclude:
25
+ - test/**/*
26
+
27
+ Metrics/ParameterLists:
28
+ Max: 6
29
+
30
+ Metrics/AbcSize:
31
+ Exclude:
32
+ - test/**/*
33
+ - lib/trakable/revertable.rb
34
+
35
+ Metrics/CyclomaticComplexity:
36
+ Exclude:
37
+ - lib/trakable/revertable.rb
38
+
39
+ Metrics/PerceivedComplexity:
40
+ Exclude:
41
+ - lib/trakable/revertable.rb
42
+
43
+ Lint/MissingSuper:
44
+ Exclude:
45
+ - lib/trakable/trak.rb
46
+
47
+ Naming/MethodParameterName:
48
+ Exclude:
49
+ - test/**/*
50
+
51
+ Style/OptionalBooleanParameter:
52
+ Exclude:
53
+ - test/**/*
54
+
55
+ Layout/LineLength:
56
+ Exclude:
57
+ - "*.gemspec"
58
+
59
+ Style/Documentation:
60
+ Exclude:
61
+ - test/**/*
62
+ - lib/trakable/version.rb
63
+ - lib/generators/**/*
64
+ - lib/trakable/railtie.rb
65
+
66
+ Style/ClassAndModuleChildren:
67
+ Enabled: false
68
+
69
+ Style/OneClassPerFile:
70
+ Exclude:
71
+ - test/**/*
72
+
73
+ Style/WordArray:
74
+ Exclude:
75
+ - test/**/*
76
+
77
+ Gemspec/DevelopmentDependencies:
78
+ Enabled: false
79
+
80
+ Gemspec/RequireMFA:
81
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
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
+ ## [0.2.0] - 2026-03-20
9
+
10
+ ### Added
11
+
12
+ - **Query scopes** on `Trakable::Trak` (AR-only): `for_item_type`, `for_event`, `for_whodunnit`, `created_before`, `created_after`, `recent`
13
+ - **Batch deletion** in `run_retention` with configurable `batch_size` (default: 1,000) to avoid table locks
14
+ - **CI/CD** via GitHub Actions (Ruby 3.1–3.4 matrix, tests + RuboCop)
15
+
16
+ ### Changed
17
+
18
+ - **Cleanup is no longer synchronous** — `Cleanup.run` is no longer called after every trak creation. Run it from a background job instead.
19
+ - `run_retention` now returns the total number of deleted rows (Integer) instead of `true`
20
+
21
+ ## [0.1.0] - 2026-03-20
22
+
23
+ ### Added
24
+
25
+ - Initial release of Trakable gem
26
+ - **Trak model** with JSON serialization for object, changeset, and metadata
27
+ - **Polymorphic whodunnit** tracking (type + id) instead of string
28
+ - **Trakable DSL** for ActiveRecord models with options:
29
+ - `only:` - track specific attributes
30
+ - `ignore:` - skip specific attributes
31
+ - `if:` / `unless:` - conditional tracking
32
+ - `on:` - selective events (create, update, destroy)
33
+ - **Revertable module**:
34
+ - `reify` - build non-persisted record from Trak state
35
+ - `revert!` - restore record to previous state
36
+ - `trak_at` - get record state at specific timestamp
37
+ - **Controller concern** with automatic whodunnit setting via `around_action`
38
+ - **Cleanup module** with:
39
+ - `max_traks` - limit number of traks per record
40
+ - `retention` - automatic pruning of old traks
41
+ - **Railtie** for Rails integration with:
42
+ - Configuration auto-loading from `config.trakable`
43
+ - Install generator for migration and initializer
44
+ - **Thread-safe context** for storing whodunnit and metadata
45
+ - **Global configuration** via `Trakable.configure`
46
+ - Comprehensive README with usage examples
47
+ - MIT License
48
+
49
+ [0.2.0]: https://github.com/hadrienblanc/trakable/releases/tag/v0.2.0
50
+ [0.1.0]: https://github.com/hadrienblanc/trakable/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hadrien Blanc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,330 @@
1
+ # Trakable
2
+
3
+ [![CI](https://github.com/hadrienblanc/trakable/actions/workflows/ci.yml/badge.svg)](https://github.com/hadrienblanc/trakable/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/trakable.svg)](https://rubygems.org/gems/trakable)
5
+ [![Ruby](https://img.shields.io/badge/ruby-3.2%E2%80%934.0-red)](https://github.com/hadrienblanc/trakable)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
7
+
8
+ Audit logging and version tracking for ActiveRecord models.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'trakable'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ ```bash
21
+ $ bundle install
22
+ ```
23
+
24
+ Or install it yourself as:
25
+
26
+ ```bash
27
+ $ gem install trakable
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ### 1. Generate the migration
33
+
34
+ ```bash
35
+ $ rails generate trakable:install
36
+ ```
37
+
38
+ This creates:
39
+ - `db/migrate/create_traks.rb` - Migration for the traks table
40
+ - `config/initializers/trakable.rb` - Configuration file
41
+
42
+ Run the migration:
43
+
44
+ ```bash
45
+ $ rails db:migrate
46
+ ```
47
+
48
+ ### 2. Add tracking to your models
49
+
50
+ ```ruby
51
+ class Post < ApplicationRecord
52
+ include Trakable::Model
53
+
54
+ trakable only: %i[title body], ignore: %i[views_count]
55
+ end
56
+ ```
57
+
58
+ ### 3. Whodunnit is automatic
59
+
60
+ Trakable auto-includes its controller concern via Railtie. It calls `current_user` by default — no setup needed.
61
+
62
+ To use a different method:
63
+
64
+ ```ruby
65
+ Trakable.configure do |config|
66
+ config.whodunnit_method = :current_admin
67
+ end
68
+ ```
69
+
70
+ ## Configuration
71
+
72
+ ### Global configuration
73
+
74
+ In `config/initializers/trakable.rb`:
75
+
76
+ ```ruby
77
+ Trakable.configure do |config|
78
+ # Enable/disable tracking globally
79
+ config.enabled = true
80
+
81
+ # Attributes to ignore by default
82
+ config.ignored_attrs = %w[created_at updated_at id]
83
+
84
+ # Controller method that returns the current user (default: :current_user)
85
+ config.whodunnit_method = :current_user
86
+ end
87
+ ```
88
+
89
+ ### Per-model options
90
+
91
+ ```ruby
92
+ class Post < ApplicationRecord
93
+ include Trakable::Model
94
+
95
+ trakable(
96
+ only: %i[title body], # Only track these attributes
97
+ ignore: %i[views_count], # Ignore these attributes
98
+ on: %i[create update destroy], # Only track these events (default: all)
99
+ if: -> { published? }, # Conditional tracking
100
+ unless: -> { draft? } # Skip if true
101
+ )
102
+ end
103
+ ```
104
+
105
+ ## Usage
106
+
107
+ ### Accessing traks
108
+
109
+ ```ruby
110
+ post = Post.first
111
+
112
+ # Get all traks for a record
113
+ post.traks
114
+
115
+ # Get the last trak
116
+ post.traks.last
117
+ ```
118
+
119
+ ### Trak properties
120
+
121
+ ```ruby
122
+ trak = post.traks.last
123
+
124
+ trak.event # => "update"
125
+ trak.create? # => false
126
+ trak.update? # => true
127
+ trak.destroy? # => false
128
+
129
+ trak.changeset # => { "title" => ["Old Title", "New Title"] }
130
+ trak.object # => { "title" => "Old Title", "body" => "..." }
131
+ trak.metadata # => { "ip" => "192.168.1.1", "user_agent" => "..." }
132
+ trak.created_at # => 2024-01-15 10:30:00 UTC
133
+
134
+ # Whodunnit (polymorphic)
135
+ trak.whodunnit_type # => "User"
136
+ trak.whodunnit_id # => 42
137
+ trak.whodunnit # => #<User id: 42, ...>
138
+ ```
139
+
140
+ ### Setting metadata
141
+
142
+ You can add custom metadata to traks using the context:
143
+
144
+ ```ruby
145
+ Trakable::Context.metadata = { ip: request.ip, user_agent: request.user_agent }
146
+ post.update(title: "New Title")
147
+ # The created trak will include the metadata
148
+ ```
149
+
150
+ ### Revert changes
151
+
152
+ ```ruby
153
+ # Restore the record to the state before this trak
154
+ post.traks.last.revert!
155
+
156
+ # Revert and create a trak for the revert action
157
+ post.traks.last.revert!(trak_revert: true)
158
+ ```
159
+
160
+ ### Time travel
161
+
162
+ ```ruby
163
+ # Get the state at a specific point in time
164
+ post.trak_at(1.day.ago) # => Non-persisted record with state from 1 day ago
165
+
166
+ # Get the state from a specific trak
167
+ post.traks.last.reify # => Non-persisted record with state at that trak
168
+ ```
169
+
170
+ ### Query scopes
171
+
172
+ ```ruby
173
+ # Filter by model type
174
+ Trakable::Trak.for_item_type('Post')
175
+
176
+ # Filter by event
177
+ Trakable::Trak.for_event(:update)
178
+
179
+ # Filter by whodunnit
180
+ Trakable::Trak.for_whodunnit(current_user)
181
+
182
+ # Filter by time range
183
+ Trakable::Trak.created_after(1.week.ago)
184
+ Trakable::Trak.created_before(Date.yesterday)
185
+
186
+ # Newest first
187
+ Trakable::Trak.recent
188
+
189
+ # Combine them
190
+ Trakable::Trak.for_item_type('Post').for_event(:update).created_after(1.day.ago).recent
191
+ ```
192
+
193
+ ### Temporarily disable tracking
194
+
195
+ ```ruby
196
+ # Disable tracking for a block
197
+ Trakable.without_tracking do
198
+ post.update(title: "Won't be tracked")
199
+ end
200
+
201
+ # Force tracking when globally disabled
202
+ Trakable.with_tracking do
203
+ post.update(title: "Will be tracked")
204
+ end
205
+
206
+ # Set whodunnit manually
207
+ Trakable.with_user(current_user) do
208
+ post.update(title: "Tracked with user")
209
+ end
210
+ ```
211
+
212
+ ### Cleanup
213
+
214
+ Configure cleanup options per model:
215
+
216
+ ```ruby
217
+ class Post < ApplicationRecord
218
+ include Trakable::Model
219
+
220
+ trakable max_traks: 100 # Keep only last 100 traks
221
+ trakable retention: 90.days # Delete traks older than 90 days
222
+ end
223
+ ```
224
+
225
+ Cleanup is **not** automatic — call it from a background job to keep your traks table lean:
226
+
227
+ ```ruby
228
+ # In a recurring job (e.g. daily cron)
229
+ Trakable::Cleanup.run_retention(Post)
230
+
231
+ # Per-record cleanup (e.g. after a batch import)
232
+ Trakable::Cleanup.run(post)
233
+ ```
234
+
235
+ ### Edge cases
236
+
237
+ ```ruby
238
+ # When no trak exists at the timestamp, returns current state
239
+ post.trak_at(1.year.ago) # => Returns current state if no older traks exist
240
+
241
+ # When whodunnit record is deleted, returns nil
242
+ trak.whodunnit # => nil (if the user was deleted)
243
+
244
+ # Revert on destroy re-creates the record (with new ID)
245
+ destroy_trak = post.traks.where(event: 'destroy').last
246
+ destroy_trak.revert! # => Creates new record with same attributes but new ID
247
+ ```
248
+
249
+ ## API Reference
250
+
251
+ ### Trakable::Model
252
+
253
+ | Method | Description |
254
+ |--------|-------------|
255
+ | `trakable(options)` | Configure tracking for this model |
256
+ | `traks` | Association to all traks for this record |
257
+ | `trak_at(timestamp)` | Get record state at a specific time |
258
+
259
+ ### Trakable::Trak
260
+
261
+ | Method | Description |
262
+ |--------|-------------|
263
+ | `item` | The tracked record (polymorphic) |
264
+ | `whodunnit` | The user who made the change (polymorphic) |
265
+ | `event` | The event type: "create", "update", or "destroy" |
266
+ | `changeset` | Hash of changed attributes with [old, new] values |
267
+ | `object` | Changed attributes before the change (delta for updates, full snapshot for destroys) |
268
+ | `create?` | True if this is a create event |
269
+ | `update?` | True if this is an update event |
270
+ | `destroy?` | True if this is a destroy event |
271
+ | `reify` | Build non-persisted record with state at this trak |
272
+ | `revert!` | Restore record to state before this trak |
273
+ | `for_item_type(type)` | Scope: filter by item type |
274
+ | `for_event(event)` | Scope: filter by event |
275
+ | `for_whodunnit(user)` | Scope: filter by whodunnit (polymorphic) |
276
+ | `created_before(time)` | Scope: traks before a timestamp |
277
+ | `created_after(time)` | Scope: traks after a timestamp |
278
+ | `recent` | Scope: newest first |
279
+
280
+ ### Trakable::Controller (auto-included via Railtie)
281
+
282
+ | Option | Description |
283
+ |--------|-------------|
284
+ | `config.whodunnit_method` | Controller method that returns the current user (default: `:current_user`) |
285
+
286
+ ## Performance Tips
287
+
288
+ ### Eager loading (N+1 prevention)
289
+
290
+ When loading multiple records with their traks, use `includes` to avoid N+1 queries:
291
+
292
+ ```ruby
293
+ # Bad — N+1
294
+ posts = Post.all
295
+ posts.each { |p| p.traks.count }
296
+
297
+ # Good — eager loaded
298
+ posts = Post.includes(:traks).all
299
+ posts.each { |p| p.traks.size }
300
+ ```
301
+
302
+ ### Compress serialized columns (Rails 7.1+)
303
+
304
+ For large `object`/`changeset` payloads, enable column compression by adding a custom initializer:
305
+
306
+ ```ruby
307
+ # config/initializers/trakable_compression.rb
308
+ Rails.application.config.after_initialize do
309
+ Trakable::Trak.serialize :object, coder: JSON, compress: true
310
+ Trakable::Trak.serialize :changeset, coder: JSON, compress: true
311
+ end
312
+ ```
313
+
314
+ This uses zlib under the hood and can reduce storage by 60-80% for large payloads.
315
+
316
+ ## Differences from PaperTrail
317
+
318
+ | Feature | PaperTrail | Trakable |
319
+ |---------|------------|----------|
320
+ | Whodunnit | String | Polymorphic (type + id) |
321
+ | Changeset | Opt-in | Always stored |
322
+ | Metadata | Not native | Built-in column |
323
+ | Retention | Manual | Built-in (max_traks, retention) |
324
+ | Serialization | YAML default | JSON only |
325
+ | Table name | versions | traks |
326
+ | Updated_at | Yes | No (immutable) |
327
+
328
+ ## License
329
+
330
+ The gem is available as open source under the terms of the [MIT License](./LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.pattern = 'test/**/*_test.rb'
10
+ end
11
+
12
+ task default: :test
13
+
14
+ require 'rubocop/rake_task'
15
+
16
+ RuboCop::RakeTask.new