active_version 1.2.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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +6 -2
- data/lib/active_version/adapters/sequel/versioning.rb +2 -1
- data/lib/active_version/audits/audit_record/serializers.rb +8 -2
- data/lib/active_version/audits/audit_record.rb +8 -8
- data/lib/active_version/audits/has_audits/audit_combiner.rb +49 -39
- data/lib/active_version/audits/has_audits/audit_writer.rb +2 -2
- data/lib/active_version/audits/has_audits.rb +11 -13
- data/lib/active_version/instrumentation.rb +1 -3
- data/lib/active_version/query.rb +0 -8
- data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +99 -33
- data/lib/active_version/revisions/has_revisions/revision_queries.rb +7 -2
- data/lib/active_version/revisions/has_revisions.rb +3 -4
- data/lib/active_version/version.rb +1 -1
- data/lib/active_version/version_registry.rb +5 -3
- data/lib/active_version.rb +35 -4
- metadata +26 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08398a92e326503be43e704b7681562a6c3c4d220bd7cf934e295ff3afada135'
|
|
4
|
+
data.tar.gz: a3678d669239918343732b02b8ff8285b32e549bb44ae6eae1cffde75abf0ae1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 158e15ac369fcbe7f730cf6e11ef638f888b5e14fb996be1f555321ddb0b08f11975c96809df18980d66b32402175dd660651791ffd8f3959798cb66b285a882
|
|
7
|
+
data.tar.gz: d0b501f02a783beaab992849dd91420980ccb592d2f56b8b7c810f49ff0952c1e61bc60cb8b8a202cc8af269928924f947243b15349606b8ddc0c4a0f33d414c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 1.4.0 (2026-07-13)
|
|
4
|
+
|
|
5
|
+
- Add `revision_error_behavior` configuration (`:exception`, `:log`, `:silent`) and per-model `has_revisions error_behavior:` override for revision snapshot failures.
|
|
6
|
+
- Skip post-update revision bookkeeping when snapshot creation returns nil under `:log` or `:silent` revision error handling.
|
|
7
|
+
- Route audit warning output through `ActiveVersion.logger` so non-Rails apps and custom log sinks receive audit write failures consistently.
|
|
8
|
+
- Align audit writer fallback error behavior with the configuration default (`:exception`) when neither per-model nor global audit error behavior is set.
|
|
9
|
+
- Fix audit combining to refresh the cached audits association instead of reloading the auditable record, and bound combiner queries with COUNT and LIMIT instead of loading the full audit history each iteration.
|
|
10
|
+
- Fix thread-local audited options reader installation on Rails 6.1 models with delegated class methods.
|
|
11
|
+
- Log debug detail for deferred audit setup, instrumentation probes, and unparseable audit payloads when `ActiveVersion.logger` supports debug output.
|
|
12
|
+
|
|
13
|
+
## 1.3.0 (2026-04-15)
|
|
14
|
+
|
|
15
|
+
- Add `polyrun` `~> 1.3.0` development dependency; `bin/polyrun`, `bin/rspec`, `polyrun.yml`, and `config/polyrun_coverage.yml` for parallel RSpec and merged coverage.
|
|
16
|
+
- Add `script/create_postgres_shard_databases.sh` for CI and local Postgres shard databases; document in `spec/README.md`; set job-level `DATABASE_URL` in GitHub Actions test workflow.
|
|
17
|
+
- Add internal library logger.
|
|
18
|
+
- Fix audits, Sequel foreign keys, revision debounce, and versions walk edge cases.
|
|
19
|
+
- Adjust RSpec and RuboCop test configuration; update spec coverage.
|
|
20
|
+
|
|
3
21
|
## 1.2.0 (2026-03-27)
|
|
4
22
|
|
|
5
23
|
- Fixed SQL generation for audit creation to avoid duplicate `created_at` and `updated_at` columns (or other possible duplicate columns)
|
data/README.md
CHANGED
|
@@ -305,6 +305,8 @@ rails g active_version:triggers Post --type=audit
|
|
|
305
305
|
rails g active_version:triggers Post --type=revision
|
|
306
306
|
```
|
|
307
307
|
|
|
308
|
+
PostgreSQL audit triggers capture the full row as JSON. They do not apply Ruby-side `only`, `except`, or `redacted` filters from `has_audits`. Use triggers only when you want complete row snapshots at the database layer, or generate custom trigger SQL that matches your column policy.
|
|
309
|
+
|
|
308
310
|
### Infrastructure Ownership
|
|
309
311
|
|
|
310
312
|
```ruby
|
|
@@ -447,8 +449,8 @@ The report includes per-record overhead vs ActiveRecord baseline (`p5`, `mean`,
|
|
|
447
449
|
The report also separates ActiveRecord and Sequel benchmark groups to avoid cross-ORM baseline mixing.
|
|
448
450
|
|
|
449
451
|
```bash
|
|
450
|
-
# Normal test run (benchmarks excluded)
|
|
451
|
-
|
|
452
|
+
# Normal test run (benchmarks excluded) — parallel OS processes via Polyrun (same as CI)
|
|
453
|
+
./bin/polyrun
|
|
452
454
|
|
|
453
455
|
# Explicit benchmark run
|
|
454
456
|
usr/bin/benchmark.rb
|
|
@@ -507,6 +509,8 @@ Environment knobs:
|
|
|
507
509
|
|
|
508
510
|
Bug reports and pull requests are welcome on GitHub at https://github.com/amkisko/active_version.rb.
|
|
509
511
|
|
|
512
|
+
Development dependencies include [polyrun](https://rubygems.org/gems/polyrun) (declared in `active_version.gemspec`) for **parallel RSpec** (`./bin/polyrun`), coverage, and CI report formats—see [spec/README.md](spec/README.md).
|
|
513
|
+
|
|
510
514
|
## License
|
|
511
515
|
|
|
512
516
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require "active_support/core_ext/string/inflections"
|
|
2
3
|
|
|
3
4
|
module ActiveVersion
|
|
4
5
|
module Adapters
|
|
@@ -51,7 +52,7 @@ module ActiveVersion
|
|
|
51
52
|
|
|
52
53
|
module InstanceMethods
|
|
53
54
|
def active_version_foreign_key
|
|
54
|
-
self.class.active_version_config[:foreign_key] || :"#{model_name.
|
|
55
|
+
self.class.active_version_config[:foreign_key] || :"#{model_name.singularize}_id"
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def active_version_tracked_columns
|
|
@@ -16,7 +16,10 @@ module ActiveVersion
|
|
|
16
16
|
return value unless value.is_a?(String)
|
|
17
17
|
|
|
18
18
|
JSON.parse(value)
|
|
19
|
-
rescue JSON::ParserError
|
|
19
|
+
rescue JSON::ParserError => error
|
|
20
|
+
ActiveVersion.log_debug(
|
|
21
|
+
"[ActiveVersion::Audits::AuditRecord::Serializers::Json] failed to parse audit payload: #{error.message}"
|
|
22
|
+
)
|
|
20
23
|
value
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -35,7 +38,10 @@ module ActiveVersion
|
|
|
35
38
|
return value unless value.is_a?(String)
|
|
36
39
|
|
|
37
40
|
YAML.safe_load(value, permitted_classes: PERMITTED_CLASSES, aliases: false)
|
|
38
|
-
rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::AliasesNotEnabled, ArgumentError
|
|
41
|
+
rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::AliasesNotEnabled, ArgumentError => error
|
|
42
|
+
ActiveVersion.log_debug(
|
|
43
|
+
"[ActiveVersion::Audits::AuditRecord::Serializers::Yaml] failed to parse audit payload: #{error.message}"
|
|
44
|
+
)
|
|
39
45
|
value
|
|
40
46
|
end
|
|
41
47
|
|
|
@@ -125,10 +125,10 @@ module ActiveVersion
|
|
|
125
125
|
attribute changes_column, :text unless attribute_names.include?(changes_column.to_s)
|
|
126
126
|
attribute context_column, :text unless attribute_names.include?(context_column.to_s)
|
|
127
127
|
end
|
|
128
|
-
rescue *ActiveVersion::Runtime.active_record_connection_errors =>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
rescue *ActiveVersion::Runtime.active_record_connection_errors => error
|
|
129
|
+
ActiveVersion.log_debug(
|
|
130
|
+
"[ActiveVersion] Deferred audit attribute setup for #{name}: #{error.class}: #{error.message}"
|
|
131
|
+
)
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
begin
|
|
@@ -156,10 +156,10 @@ module ActiveVersion
|
|
|
156
156
|
end
|
|
157
157
|
rescue NameError
|
|
158
158
|
# Source class not yet defined, will be set up later
|
|
159
|
-
rescue *ActiveVersion::Runtime.active_record_connection_errors =>
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
159
|
+
rescue *ActiveVersion::Runtime.active_record_connection_errors => error
|
|
160
|
+
ActiveVersion.log_debug(
|
|
161
|
+
"[ActiveVersion] Deferred audit association setup for #{name}: #{error.class}: #{error.message}"
|
|
162
|
+
)
|
|
163
163
|
end
|
|
164
164
|
end
|
|
165
165
|
|
|
@@ -24,54 +24,27 @@ module ActiveVersion
|
|
|
24
24
|
iteration += 1
|
|
25
25
|
break if iteration > max_iterations
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
reload
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Clear association cache to ensure we get fresh data from database.
|
|
33
|
-
# Avoid calling audits reader directly here to prevent AR 6.1
|
|
34
|
-
# delegation edge cases on dynamic models.
|
|
27
|
+
# Clear association cache so the direct audit query below sees SQL updates
|
|
28
|
+
# from prior combine rounds without reloading the auditable record.
|
|
35
29
|
if respond_to?(:association) && association_cached?(:audits)
|
|
36
30
|
association(:audits).reset
|
|
37
31
|
end
|
|
38
32
|
|
|
39
|
-
# Get all audits fresh from database (not from cache)
|
|
40
|
-
# Query directly to ensure we get updated values after SQL updates
|
|
41
33
|
auditable_type = audited_options[:class_name] || self.class.name
|
|
42
34
|
auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable)
|
|
43
35
|
version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
|
|
44
|
-
audit_klass =
|
|
45
|
-
if self.class.reflect_on_association(:audits)
|
|
46
|
-
association(:audits).klass
|
|
47
|
-
else
|
|
48
|
-
self.class.audit_class
|
|
49
|
-
end
|
|
50
|
-
if audit_klass.nil? && self.class.respond_to?(:resolve_audit_class_option, true)
|
|
51
|
-
audit_klass = self.class.send(:resolve_audit_class_option, audited_options[:as])
|
|
52
|
-
end
|
|
36
|
+
audit_klass = combiner_audit_klass
|
|
53
37
|
break unless audit_klass
|
|
54
|
-
all_audits = audit_klass.where({"#{auditable_column}_type" => auditable_type}.merge(active_version_audit_identity_map))
|
|
55
|
-
.order(version_column => :asc)
|
|
56
|
-
.to_a
|
|
57
38
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if raw_changes.is_a?(String) && raw_changes.strip == "{}"
|
|
66
|
-
true
|
|
67
|
-
else
|
|
68
|
-
# Otherwise check parsed value
|
|
69
|
-
changes = audit.audited_changes
|
|
70
|
-
changes.nil? || (changes.is_a?(Hash) && changes.empty?) || (changes.is_a?(String) && changes.strip.empty?)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
39
|
+
active_scope = active_audits_scope_for_combiner(
|
|
40
|
+
audit_klass,
|
|
41
|
+
auditable_type,
|
|
42
|
+
auditable_column,
|
|
43
|
+
version_column,
|
|
44
|
+
changes_column
|
|
45
|
+
)
|
|
73
46
|
|
|
74
|
-
audits_count =
|
|
47
|
+
audits_count = active_scope.count
|
|
75
48
|
break if audits_count <= max_audits
|
|
76
49
|
|
|
77
50
|
# Calculate how many extra audits we have
|
|
@@ -79,7 +52,11 @@ module ActiveVersion
|
|
|
79
52
|
|
|
80
53
|
# Get the oldest active audits to combine (first extra_count + 1)
|
|
81
54
|
# The +1 is because we'll merge into the last one in this set
|
|
82
|
-
audits_to_combine =
|
|
55
|
+
audits_to_combine = active_scope
|
|
56
|
+
.limit(extra_count + 1)
|
|
57
|
+
.to_a
|
|
58
|
+
.reject { |audit| combined_audit_record?(audit, changes_column) }
|
|
59
|
+
.first(extra_count + 1)
|
|
83
60
|
|
|
84
61
|
# Safety check to prevent infinite loops
|
|
85
62
|
break if audits_to_combine.empty? || audits_to_combine.length <= 1
|
|
@@ -89,6 +66,39 @@ module ActiveVersion
|
|
|
89
66
|
end
|
|
90
67
|
end
|
|
91
68
|
|
|
69
|
+
def combiner_audit_klass
|
|
70
|
+
audit_klass =
|
|
71
|
+
if self.class.reflect_on_association(:audits)
|
|
72
|
+
association(:audits).klass
|
|
73
|
+
else
|
|
74
|
+
self.class.audit_class
|
|
75
|
+
end
|
|
76
|
+
if audit_klass.nil? && self.class.respond_to?(:resolve_audit_class_option, true)
|
|
77
|
+
audit_klass = self.class.send(:resolve_audit_class_option, audited_options[:as])
|
|
78
|
+
end
|
|
79
|
+
audit_klass
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def active_audits_scope_for_combiner(audit_klass, auditable_type, auditable_column, version_column, changes_column)
|
|
83
|
+
audit_klass
|
|
84
|
+
.where({"#{auditable_column}_type" => auditable_type}.merge(active_version_audit_identity_map))
|
|
85
|
+
.where.not(changes_column => nil)
|
|
86
|
+
.where.not(changes_column => "")
|
|
87
|
+
.where.not(changes_column => "{}")
|
|
88
|
+
.order(version_column => :asc)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def combined_audit_record?(audit, changes_column)
|
|
92
|
+
raw_changes = audit.read_attribute(changes_column)
|
|
93
|
+
|
|
94
|
+
if raw_changes.is_a?(String) && raw_changes.strip == "{}"
|
|
95
|
+
true
|
|
96
|
+
else
|
|
97
|
+
changes = audit.audited_changes
|
|
98
|
+
changes.nil? || (changes.is_a?(Hash) && changes.empty?) || (changes.is_a?(String) && changes.strip.empty?)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
92
102
|
def evaluate_max_audits
|
|
93
103
|
max_audits = case (option = audited_options[:max_audits])
|
|
94
104
|
when Proc then option.call
|
|
@@ -228,7 +228,7 @@ module ActiveVersion
|
|
|
228
228
|
|
|
229
229
|
def handle_audit_errors(error, action)
|
|
230
230
|
ActiveVersion::Instrumentation.instrument_audit_write_failed(self, error: error, action: action)
|
|
231
|
-
behavior = audited_options[:error_behavior] || ActiveVersion.config.audit_error_behavior || :
|
|
231
|
+
behavior = audited_options[:error_behavior] || ActiveVersion.config.audit_error_behavior || :exception
|
|
232
232
|
|
|
233
233
|
case behavior
|
|
234
234
|
when :log
|
|
@@ -241,7 +241,7 @@ module ActiveVersion
|
|
|
241
241
|
end
|
|
242
242
|
|
|
243
243
|
def log_audit_errors(error, action)
|
|
244
|
-
|
|
244
|
+
ActiveVersion.logger.warn(
|
|
245
245
|
"Unable to create audit for #{action} of #{self.class.name}" \
|
|
246
246
|
"##{id}: #{error.message}"
|
|
247
247
|
)
|
|
@@ -22,7 +22,13 @@ module ActiveVersion
|
|
|
22
22
|
false
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
#
|
|
25
|
+
# Class attributes (must be declared before the custom .audit_class reader below —
|
|
26
|
+
# otherwise class_attribute :audit_class overwrites the resolver and leaves it dead code.)
|
|
27
|
+
class_attribute :audit_associated_with, instance_writer: false
|
|
28
|
+
class_attribute :audited_options, instance_writer: false
|
|
29
|
+
class_attribute :audit_class, instance_writer: false
|
|
30
|
+
|
|
31
|
+
# Get audit class name (overrides class_attribute reader with resolution logic)
|
|
26
32
|
def self.audit_class
|
|
27
33
|
return @audit_class if @audit_class
|
|
28
34
|
|
|
@@ -53,11 +59,6 @@ module ActiveVersion
|
|
|
53
59
|
end
|
|
54
60
|
end
|
|
55
61
|
|
|
56
|
-
# Class attributes
|
|
57
|
-
class_attribute :audit_associated_with, instance_writer: false
|
|
58
|
-
class_attribute :audited_options, instance_writer: false
|
|
59
|
-
class_attribute :audit_class, instance_writer: false
|
|
60
|
-
|
|
61
62
|
# Instance attributes
|
|
62
63
|
attr_accessor :audit_version, :audit_comment, :audit_context
|
|
63
64
|
|
|
@@ -121,13 +122,10 @@ module ActiveVersion
|
|
|
121
122
|
def revisions(from_version = 1)
|
|
122
123
|
return [] unless audits.from_version(from_version).exists?
|
|
123
124
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
targeted_audits = all_audits.select do |audit|
|
|
127
|
-
audit.read_attribute(version_column).to_i >= from_version
|
|
128
|
-
end
|
|
125
|
+
prior_audits = audits.to_version(from_version - 1).ascending.to_a
|
|
126
|
+
targeted_audits = audits.from_version(from_version).ascending.to_a
|
|
129
127
|
|
|
130
|
-
previous_attributes = audit_class.reconstruct_attributes(
|
|
128
|
+
previous_attributes = audit_class.reconstruct_attributes(prior_audits)
|
|
131
129
|
|
|
132
130
|
targeted_audits.map do |audit|
|
|
133
131
|
previous_attributes.merge!(audit.new_attributes)
|
|
@@ -421,7 +419,7 @@ module ActiveVersion
|
|
|
421
419
|
private
|
|
422
420
|
|
|
423
421
|
def install_thread_local_audited_options_reader!
|
|
424
|
-
unless singleton_class.
|
|
422
|
+
unless singleton_class.method_defined?(:audited_options_without_thread_local, false)
|
|
425
423
|
singleton_class.alias_method :audited_options_without_thread_local, :audited_options
|
|
426
424
|
end
|
|
427
425
|
|
|
@@ -217,9 +217,7 @@ module ActiveVersion
|
|
|
217
217
|
end
|
|
218
218
|
|
|
219
219
|
def log_debug(message)
|
|
220
|
-
|
|
221
|
-
Rails.logger&.debug("[ActiveVersion::Instrumentation] #{message}")
|
|
222
|
-
end
|
|
220
|
+
ActiveVersion.log_debug("[ActiveVersion::Instrumentation] #{message}")
|
|
223
221
|
end
|
|
224
222
|
end
|
|
225
223
|
end
|
data/lib/active_version/query.rb
CHANGED
|
@@ -92,14 +92,6 @@ module ActiveVersion
|
|
|
92
92
|
version_column = ActiveVersion.column_mapper.column_for(record.class, :revisions, :version)
|
|
93
93
|
query.order(version_column => :asc)
|
|
94
94
|
end
|
|
95
|
-
|
|
96
|
-
private
|
|
97
|
-
|
|
98
|
-
def log_debug(message)
|
|
99
|
-
if defined?(Rails) && Rails.respond_to?(:logger)
|
|
100
|
-
Rails.logger&.debug("[ActiveVersion::Query] #{message}")
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
95
|
end
|
|
104
96
|
end
|
|
105
97
|
end
|
|
@@ -17,10 +17,13 @@ module ActiveVersion
|
|
|
17
17
|
|
|
18
18
|
# Check debounce time - merge with previous revision if within window
|
|
19
19
|
debounce_time = opts[:debounce_time] || ActiveVersion.config.debounce_time
|
|
20
|
-
if !batch_capture_active && debounce_time
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
if !batch_capture_active && debounce_time
|
|
21
|
+
last_for_merge = last_revision_for_debounce_merge(debounce_time, timestamp)
|
|
22
|
+
if last_for_merge
|
|
23
|
+
merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values, last_revision: last_for_merge)
|
|
24
|
+
version_column = revision_version_column
|
|
25
|
+
return revisions_scope.order(version_column => :desc).first
|
|
26
|
+
end
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
new_version = if batch_capture_active
|
|
@@ -110,30 +113,25 @@ module ActiveVersion
|
|
|
110
113
|
pseudo.define_singleton_method(:persisted?) { true }
|
|
111
114
|
pseudo
|
|
112
115
|
else
|
|
113
|
-
|
|
114
|
-
# Use create! instead of build + save to get better error messages
|
|
115
116
|
begin
|
|
116
|
-
revision =
|
|
117
|
-
rescue ActiveRecord::RecordInvalid =>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
error_msg += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
|
|
135
|
-
error_msg += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
|
|
136
|
-
raise error_msg
|
|
117
|
+
revision = create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
|
|
118
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
119
|
+
revision = handle_revision_errors(
|
|
120
|
+
error,
|
|
121
|
+
revision_attrs: revision_attrs,
|
|
122
|
+
version_column_sym: version_column_sym,
|
|
123
|
+
new_version: new_version,
|
|
124
|
+
invalid_record: error.record
|
|
125
|
+
)
|
|
126
|
+
return revision unless revision
|
|
127
|
+
rescue => error
|
|
128
|
+
revision = handle_revision_errors(
|
|
129
|
+
error,
|
|
130
|
+
revision_attrs: revision_attrs,
|
|
131
|
+
version_column_sym: version_column_sym,
|
|
132
|
+
new_version: new_version
|
|
133
|
+
)
|
|
134
|
+
return revision unless revision
|
|
137
135
|
end
|
|
138
136
|
|
|
139
137
|
# Force reload association to ensure it's visible
|
|
@@ -251,7 +249,7 @@ module ActiveVersion
|
|
|
251
249
|
end
|
|
252
250
|
|
|
253
251
|
# Create the revision using the association (it will set foreign_key automatically)
|
|
254
|
-
revision =
|
|
252
|
+
revision = create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
|
|
255
253
|
# Ensure it was created and persisted
|
|
256
254
|
raise "Failed to create revision" unless revision.persisted?
|
|
257
255
|
# Reload the post to clear all caches and see the new revision
|
|
@@ -350,19 +348,26 @@ module ActiveVersion
|
|
|
350
348
|
end
|
|
351
349
|
|
|
352
350
|
def should_merge_with_previous?(debounce_time, timestamp)
|
|
353
|
-
|
|
351
|
+
!last_revision_for_debounce_merge(debounce_time, timestamp).nil?
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Latest revision row if +timestamp+ falls within +debounce_time+ seconds after that revision; one query.
|
|
355
|
+
def last_revision_for_debounce_merge(debounce_time, timestamp)
|
|
356
|
+
return nil unless debounce_time
|
|
354
357
|
|
|
355
358
|
version_column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
|
|
356
359
|
last_revision = revisions_scope.order(version_column => :desc).first
|
|
357
|
-
return
|
|
360
|
+
return nil unless last_revision
|
|
358
361
|
|
|
359
362
|
time_diff = timestamp.to_f - last_revision.created_at.to_f
|
|
360
|
-
time_diff
|
|
363
|
+
return nil if time_diff > debounce_time
|
|
364
|
+
|
|
365
|
+
last_revision
|
|
361
366
|
end
|
|
362
367
|
|
|
363
|
-
def merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values)
|
|
368
|
+
def merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values, last_revision: nil)
|
|
364
369
|
version_column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
|
|
365
|
-
last_revision
|
|
370
|
+
last_revision ||= revisions_scope.order(version_column => :desc).first
|
|
366
371
|
return unless last_revision
|
|
367
372
|
|
|
368
373
|
# Update last revision with current or persisted attributes (filtered by only/except)
|
|
@@ -416,6 +421,67 @@ module ActiveVersion
|
|
|
416
421
|
revisions.reset
|
|
417
422
|
end
|
|
418
423
|
|
|
424
|
+
# One retry on unique (version) violation, matching audit_writer behavior when a DB unique index exists.
|
|
425
|
+
def create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
|
|
426
|
+
attempt = 0
|
|
427
|
+
begin
|
|
428
|
+
attempt += 1
|
|
429
|
+
revisions.create!(revision_attrs)
|
|
430
|
+
rescue ActiveRecord::RecordNotUnique => error
|
|
431
|
+
raise error if attempt >= 2
|
|
432
|
+
|
|
433
|
+
max_version = revisions_scope.maximum(version_column_sym) || 0
|
|
434
|
+
revision_attrs[version_column_sym] = max_version + 1
|
|
435
|
+
retry
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def handle_revision_errors(error, revision_attrs:, version_column_sym:, new_version:, invalid_record: nil)
|
|
440
|
+
ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: error)
|
|
441
|
+
behavior = self.class.revision_options&.dig(:error_behavior) ||
|
|
442
|
+
ActiveVersion.config.revision_error_behavior ||
|
|
443
|
+
:exception
|
|
444
|
+
|
|
445
|
+
case behavior
|
|
446
|
+
when :exception
|
|
447
|
+
raise build_revision_error_message(
|
|
448
|
+
error,
|
|
449
|
+
revision_attrs: revision_attrs,
|
|
450
|
+
version_column_sym: version_column_sym,
|
|
451
|
+
new_version: new_version,
|
|
452
|
+
invalid_record: invalid_record
|
|
453
|
+
)
|
|
454
|
+
when :log
|
|
455
|
+
ActiveVersion.logger.warn(
|
|
456
|
+
build_revision_error_message(
|
|
457
|
+
error,
|
|
458
|
+
revision_attrs: revision_attrs,
|
|
459
|
+
version_column_sym: version_column_sym,
|
|
460
|
+
new_version: new_version,
|
|
461
|
+
invalid_record: invalid_record
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
nil
|
|
465
|
+
when :silent
|
|
466
|
+
nil
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def build_revision_error_message(error, revision_attrs:, version_column_sym:, new_version:, invalid_record: nil)
|
|
471
|
+
message = "Failed to create revision: #{error.class}"
|
|
472
|
+
message += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
|
|
473
|
+
message += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
|
|
474
|
+
message += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
|
|
475
|
+
if invalid_record
|
|
476
|
+
message += "\nRevision class: #{self.class.revision_class.name}"
|
|
477
|
+
message += "\nSource class: #{self.class.name}"
|
|
478
|
+
message += "\nRecord error fields: #{invalid_record.errors.attribute_names.map(&:to_s).uniq.sort.join(", ")}"
|
|
479
|
+
message += "\nRecord valid?: #{invalid_record.valid?}"
|
|
480
|
+
end
|
|
481
|
+
message
|
|
482
|
+
end
|
|
483
|
+
private :create_revision_record_with_version_retry!, :handle_revision_errors, :build_revision_error_message
|
|
484
|
+
|
|
419
485
|
def refreshable_column_names
|
|
420
486
|
@refreshable_column_names ||=
|
|
421
487
|
self.class.columns
|
|
@@ -91,7 +91,9 @@ module ActiveVersion
|
|
|
91
91
|
# Enumerate all versions (lazy enumerator returning revision instances)
|
|
92
92
|
def versions(reverse: false, include_self: false)
|
|
93
93
|
version_column = revision_version_column
|
|
94
|
-
|
|
94
|
+
ordered_rows = revisions_scope.order(version_column => (reverse ? :desc : :asc)).to_a
|
|
95
|
+
entries_by_version = ordered_rows.index_by { |row| row.public_send(version_column) }
|
|
96
|
+
version_list = ordered_rows.map { |row| row.public_send(version_column) }
|
|
95
97
|
|
|
96
98
|
# If include_self, prepare current state as a revision instance
|
|
97
99
|
current_self = nil
|
|
@@ -104,7 +106,10 @@ module ActiveVersion
|
|
|
104
106
|
|
|
105
107
|
Enumerator.new do |yielder|
|
|
106
108
|
version_list.each do |v|
|
|
107
|
-
|
|
109
|
+
revision_entry = entries_by_version[v]
|
|
110
|
+
next unless revision_entry
|
|
111
|
+
|
|
112
|
+
revision = build_revision_dup(revision_entry)
|
|
108
113
|
yielder << revision if revision
|
|
109
114
|
end
|
|
110
115
|
if include_self
|
|
@@ -113,9 +113,6 @@ module ActiveVersion
|
|
|
113
113
|
|
|
114
114
|
# Set up callbacks based on options
|
|
115
115
|
setup_revision_callbacks(revision_options)
|
|
116
|
-
|
|
117
|
-
# Register with version registry (idempotent)
|
|
118
|
-
ActiveVersion.registry.register(self, :revisions, revision_options)
|
|
119
116
|
end
|
|
120
117
|
|
|
121
118
|
# Check if model has revisions configured
|
|
@@ -134,7 +131,8 @@ module ActiveVersion
|
|
|
134
131
|
except: Array.wrap(options[:except] || []).map(&:to_s),
|
|
135
132
|
foreign_key: normalize_identity_columns(options[:foreign_key]),
|
|
136
133
|
identity_resolver: options[:identity_resolver],
|
|
137
|
-
table_name: options[:table_name]
|
|
134
|
+
table_name: options[:table_name],
|
|
135
|
+
error_behavior: options[:error_behavior]
|
|
138
136
|
}
|
|
139
137
|
end
|
|
140
138
|
|
|
@@ -409,6 +407,7 @@ module ActiveVersion
|
|
|
409
407
|
return if truncated_forward_history && latest_revision_matches_current_state?
|
|
410
408
|
|
|
411
409
|
result = create_snapshot!(use_old_values: true)
|
|
410
|
+
return unless result
|
|
412
411
|
|
|
413
412
|
# Ensure revision is persisted and visible in association
|
|
414
413
|
unless result.persisted?
|
|
@@ -17,9 +17,11 @@ module ActiveVersion
|
|
|
17
17
|
|
|
18
18
|
# Detect option conflicts
|
|
19
19
|
if existing[:options] != options
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
ActiveVersion.logger&.warn(
|
|
21
|
+
"[ActiveVersion] Re-registering #{model_class.name} with :#{version_type} " \
|
|
22
|
+
"with different options. Previous: #{existing[:options].inspect}, " \
|
|
23
|
+
"New: #{options.inspect}. This may indicate a configuration issue."
|
|
24
|
+
)
|
|
23
25
|
else
|
|
24
26
|
# Same options - likely a double include, but not necessarily a problem
|
|
25
27
|
# Log at debug level if needed
|
data/lib/active_version.rb
CHANGED
|
@@ -163,7 +163,7 @@ module ActiveVersion
|
|
|
163
163
|
elsif Fiber.current.respond_to?(:[])
|
|
164
164
|
Fiber.current[key]
|
|
165
165
|
else
|
|
166
|
-
|
|
166
|
+
thread_current_for_fallback_store[key]
|
|
167
167
|
end
|
|
168
168
|
end
|
|
169
169
|
|
|
@@ -173,7 +173,7 @@ module ActiveVersion
|
|
|
173
173
|
elsif Fiber.current.respond_to?(:[]=)
|
|
174
174
|
Fiber.current[key] = value
|
|
175
175
|
else
|
|
176
|
-
|
|
176
|
+
thread_current_for_fallback_store[key] = value
|
|
177
177
|
end
|
|
178
178
|
end
|
|
179
179
|
|
|
@@ -184,13 +184,18 @@ module ActiveVersion
|
|
|
184
184
|
def self.store_keys
|
|
185
185
|
if config.execution_scope == :thread
|
|
186
186
|
Thread.current.thread_variables
|
|
187
|
-
elsif
|
|
188
|
-
|
|
187
|
+
elsif thread_current_for_fallback_store.respond_to?(:keys)
|
|
188
|
+
thread_current_for_fallback_store.keys
|
|
189
189
|
else
|
|
190
190
|
[]
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
+
def self.thread_current_for_fallback_store
|
|
195
|
+
Thread.current
|
|
196
|
+
end
|
|
197
|
+
private_class_method :thread_current_for_fallback_store
|
|
198
|
+
|
|
194
199
|
def self.clear_scoped_keys!(pattern)
|
|
195
200
|
store_keys.grep(pattern).each { |key| store_delete(key) }
|
|
196
201
|
end
|
|
@@ -322,6 +327,32 @@ module ActiveVersion
|
|
|
322
327
|
def self.with_connection(model_class, version_type, &block)
|
|
323
328
|
yield(Runtime.adapter.connection_for(model_class, version_type))
|
|
324
329
|
end
|
|
330
|
+
|
|
331
|
+
# Library warnings (defaults to stderr). Set to +nil+ to silence; use +Logger.new(File::NULL)+ in tests.
|
|
332
|
+
class << self
|
|
333
|
+
attr_writer :logger
|
|
334
|
+
|
|
335
|
+
def logger
|
|
336
|
+
return @logger if defined?(@logger)
|
|
337
|
+
|
|
338
|
+
@logger = default_logger
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def log_debug(message)
|
|
342
|
+
logger = self.logger
|
|
343
|
+
return if logger.nil?
|
|
344
|
+
|
|
345
|
+
logger.debug(message) if logger.respond_to?(:debug)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def default_logger
|
|
349
|
+
l = Logger.new($stderr)
|
|
350
|
+
l.level = Logger::WARN
|
|
351
|
+
l.formatter = proc { |_, _, _, msg| "#{msg}\n" }
|
|
352
|
+
l
|
|
353
|
+
end
|
|
354
|
+
private :default_logger
|
|
355
|
+
end
|
|
325
356
|
end
|
|
326
357
|
|
|
327
358
|
# Load Rails integration if available
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_version
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -85,14 +85,14 @@ dependencies:
|
|
|
85
85
|
requirements:
|
|
86
86
|
- - ">="
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version:
|
|
88
|
+
version: 2.9.5
|
|
89
89
|
type: :development
|
|
90
90
|
prerelease: false
|
|
91
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
92
|
requirements:
|
|
93
93
|
- - ">="
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
|
-
version:
|
|
95
|
+
version: 2.9.5
|
|
96
96
|
- !ruby/object:Gem::Dependency
|
|
97
97
|
name: pg
|
|
98
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -136,61 +136,33 @@ dependencies:
|
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
137
|
version: '0.4'
|
|
138
138
|
- !ruby/object:Gem::Dependency
|
|
139
|
-
name:
|
|
139
|
+
name: polyrun
|
|
140
140
|
requirement: !ruby/object:Gem::Requirement
|
|
141
141
|
requirements:
|
|
142
|
-
- - "
|
|
143
|
-
- !ruby/object:Gem::Version
|
|
144
|
-
version: '0.22'
|
|
145
|
-
type: :development
|
|
146
|
-
prerelease: false
|
|
147
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
-
requirements:
|
|
149
|
-
- - "~>"
|
|
150
|
-
- !ruby/object:Gem::Version
|
|
151
|
-
version: '0.22'
|
|
152
|
-
- !ruby/object:Gem::Dependency
|
|
153
|
-
name: simplecov-console
|
|
154
|
-
requirement: !ruby/object:Gem::Requirement
|
|
155
|
-
requirements:
|
|
156
|
-
- - "~>"
|
|
157
|
-
- !ruby/object:Gem::Version
|
|
158
|
-
version: '0.9'
|
|
159
|
-
type: :development
|
|
160
|
-
prerelease: false
|
|
161
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
162
|
-
requirements:
|
|
163
|
-
- - "~>"
|
|
164
|
-
- !ruby/object:Gem::Version
|
|
165
|
-
version: '0.9'
|
|
166
|
-
- !ruby/object:Gem::Dependency
|
|
167
|
-
name: rspec_junit_formatter
|
|
168
|
-
requirement: !ruby/object:Gem::Requirement
|
|
169
|
-
requirements:
|
|
170
|
-
- - "~>"
|
|
142
|
+
- - ">="
|
|
171
143
|
- !ruby/object:Gem::Version
|
|
172
|
-
version:
|
|
144
|
+
version: 2.2.0
|
|
173
145
|
type: :development
|
|
174
146
|
prerelease: false
|
|
175
147
|
version_requirements: !ruby/object:Gem::Requirement
|
|
176
148
|
requirements:
|
|
177
|
-
- - "
|
|
149
|
+
- - ">="
|
|
178
150
|
- !ruby/object:Gem::Version
|
|
179
|
-
version:
|
|
151
|
+
version: 2.2.0
|
|
180
152
|
- !ruby/object:Gem::Dependency
|
|
181
|
-
name:
|
|
153
|
+
name: prosopite
|
|
182
154
|
requirement: !ruby/object:Gem::Requirement
|
|
183
155
|
requirements:
|
|
184
156
|
- - "~>"
|
|
185
157
|
- !ruby/object:Gem::Version
|
|
186
|
-
version: '
|
|
158
|
+
version: '2.0'
|
|
187
159
|
type: :development
|
|
188
160
|
prerelease: false
|
|
189
161
|
version_requirements: !ruby/object:Gem::Requirement
|
|
190
162
|
requirements:
|
|
191
163
|
- - "~>"
|
|
192
164
|
- !ruby/object:Gem::Version
|
|
193
|
-
version: '
|
|
165
|
+
version: '2.0'
|
|
194
166
|
- !ruby/object:Gem::Dependency
|
|
195
167
|
name: standard
|
|
196
168
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -317,6 +289,20 @@ dependencies:
|
|
|
317
289
|
- - "~>"
|
|
318
290
|
- !ruby/object:Gem::Version
|
|
319
291
|
version: '2'
|
|
292
|
+
- !ruby/object:Gem::Dependency
|
|
293
|
+
name: bundler-audit
|
|
294
|
+
requirement: !ruby/object:Gem::Requirement
|
|
295
|
+
requirements:
|
|
296
|
+
- - "~>"
|
|
297
|
+
- !ruby/object:Gem::Version
|
|
298
|
+
version: '0.9'
|
|
299
|
+
type: :development
|
|
300
|
+
prerelease: false
|
|
301
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
302
|
+
requirements:
|
|
303
|
+
- - "~>"
|
|
304
|
+
- !ruby/object:Gem::Version
|
|
305
|
+
version: '0.9'
|
|
320
306
|
- !ruby/object:Gem::Dependency
|
|
321
307
|
name: rbs
|
|
322
308
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -437,7 +423,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
437
423
|
- !ruby/object:Gem::Version
|
|
438
424
|
version: '0'
|
|
439
425
|
requirements: []
|
|
440
|
-
rubygems_version:
|
|
426
|
+
rubygems_version: 4.0.6
|
|
441
427
|
specification_version: 4
|
|
442
428
|
summary: Unified versioning library for translations, revisions, and audits
|
|
443
429
|
test_files: []
|