active_version 1.3.0 → 2.0.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 -2
- data/README.md +20 -3
- data/SECURITY.md +3 -5
- data/lib/active_version/adapters/active_record/audits.rb +2 -2
- data/lib/active_version/adapters/active_record/revisions.rb +1 -1
- data/lib/active_version/adapters/active_record/translations.rb +1 -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 +41 -37
- data/lib/active_version/audits/has_audits.rb +1 -1
- data/lib/active_version/instrumentation.rb +1 -3
- data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +72 -28
- data/lib/active_version/revisions/has_revisions.rb +3 -4
- data/lib/active_version/unique_version_collision.rb +38 -0
- data/lib/active_version/version.rb +1 -1
- data/lib/active_version.rb +8 -0
- metadata +67 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d52287aa8ea68e0c77baff3065a3140d397c2e638460967880b0b0dbc904b42c
|
|
4
|
+
data.tar.gz: aff296230f04d04d90bafe26e12fe0876fc2cd292cd07e6083d71a595a9ea6f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35c7b1c96056c61e5c36a72166d2be067504da0fa922fd439ad4a6b1a09a7630ae80734fb2ad8e97dfb81fd3302c75ed168cb92adb22669f222e5cd6aa363a90
|
|
7
|
+
data.tar.gz: db6759859ffd1139a818c45a23ae486e819e035553b32762f586465cbef3a2597507a80ea424036ecc0718fe6a9dbf25cb11fe5c8e4e6c7556d27e124e13704f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 2.0.0 (2026-08-21)
|
|
4
|
+
|
|
5
|
+
- BREAKING: Require Active Support and Active Record `>= 7.2.3.2` and `< 9.0`.
|
|
6
|
+
- Retry unique version collisions so a revision snapshot or audit still writes after a concurrent insert, including inside an open PostgreSQL transaction.
|
|
7
|
+
- Add `rfcs/` starter pack: process (RFC 0001), positioning (RFC 0002), and Standards Track design RFCs 0003–0006 for translations, revisions, audits, and generators.
|
|
8
|
+
|
|
9
|
+
## 1.4.0 (2026-07-13)
|
|
10
|
+
|
|
11
|
+
- Add `revision_error_behavior` configuration (`:exception`, `:log`, `:silent`) and per-model `has_revisions error_behavior:` override for revision snapshot failures.
|
|
12
|
+
- Skip post-update revision bookkeeping when snapshot creation returns nil under `:log` or `:silent` revision error handling.
|
|
13
|
+
- Route audit warning output through `ActiveVersion.logger` so non-Rails apps and custom log sinks receive audit write failures consistently.
|
|
14
|
+
- Align audit writer fallback error behavior with the configuration default (`:exception`) when neither per-model nor global audit error behavior is set.
|
|
15
|
+
- 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.
|
|
16
|
+
- Fix thread-local audited options reader installation on Rails 6.1 models with delegated class methods.
|
|
17
|
+
- Log debug detail for deferred audit setup, instrumentation probes, and unparseable audit payloads when `ActiveVersion.logger` supports debug output.
|
|
18
|
+
|
|
3
19
|
## 1.3.0 (2026-04-15)
|
|
4
20
|
|
|
5
|
-
- Add `polyrun` `~> 1.
|
|
6
|
-
- Add `script/create_postgres_shard_databases.sh` for CI and local Postgres shard databases; document in `
|
|
21
|
+
- Add `polyrun` `~> 1.3.0` development dependency; `bin/polyrun`, `bin/rspec`, `polyrun.yml`, and `config/polyrun_coverage.yml` for parallel RSpec and merged coverage.
|
|
22
|
+
- 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.
|
|
7
23
|
- Add internal library logger.
|
|
8
24
|
- Fix audits, Sequel foreign keys, revision debounce, and versions walk edge cases.
|
|
9
25
|
- Adjust RSpec and RuboCop test configuration; update spec coverage.
|
data/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# ActiveVersion
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/active_version) [](https://github.com/amkisko/active_version.rb/actions/workflows/test.yml) [](https://codecov.io/
|
|
3
|
+
[](https://badge.fury.io/rb/active_version) [](https://github.com/amkisko/active_version.rb/actions/workflows/test.yml) [](https://app.codecov.io/github/amkisko/active_version.rb)
|
|
4
4
|
|
|
5
5
|
A unified versioning library for ActiveRecord that handles translations, revisions, and audits in a single, extensible architecture.
|
|
6
6
|
|
|
7
|
+
Design changes go through `rfcs/` (RFC 0001).
|
|
8
|
+
|
|
7
9
|
## Features
|
|
8
10
|
|
|
9
11
|
- Translations: Locale-based versioning with automatic value copying
|
|
@@ -305,6 +307,8 @@ rails g active_version:triggers Post --type=audit
|
|
|
305
307
|
rails g active_version:triggers Post --type=revision
|
|
306
308
|
```
|
|
307
309
|
|
|
310
|
+
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.
|
|
311
|
+
|
|
308
312
|
### Infrastructure Ownership
|
|
309
313
|
|
|
310
314
|
```ruby
|
|
@@ -500,14 +504,27 @@ Environment knobs:
|
|
|
500
504
|
## Requirements
|
|
501
505
|
|
|
502
506
|
- Ruby >= 3.0.0
|
|
503
|
-
- ActiveRecord >=
|
|
507
|
+
- ActiveRecord >= 7.2.3.2 and < 9.0 (default runtime)
|
|
504
508
|
- SQLite or MySQL or PostgreSQL (optional, for triggers and JSONB support)
|
|
505
509
|
|
|
506
510
|
## Contributing
|
|
507
511
|
|
|
508
512
|
Bug reports and pull requests are welcome on GitHub at https://github.com/amkisko/active_version.rb.
|
|
509
513
|
|
|
510
|
-
Development dependencies include [polyrun](https://rubygems.org/gems/polyrun)
|
|
514
|
+
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).
|
|
515
|
+
|
|
516
|
+
## Links
|
|
517
|
+
|
|
518
|
+
- [GitHub](https://github.com/amkisko/active_version.rb)
|
|
519
|
+
- [GitLab](https://gitlab.com/amkisko/active_version.rb)
|
|
520
|
+
- [RubyGems](https://rubygems.org/gems/active_version)
|
|
521
|
+
- [Versions Atom](https://rubygems.org/gems/active_version/versions.atom) (feed id `10668858017926`)
|
|
522
|
+
- [libraries.io](https://libraries.io/rubygems/active_version)
|
|
523
|
+
- [Deps.dev](https://deps.dev/rubygems/active_version)
|
|
524
|
+
- [SonarCloud](https://sonarcloud.io/project/overview?id=amkisko_active_version.rb)
|
|
525
|
+
- [Snyk](https://snyk.io/test/github/amkisko/active_version.rb)
|
|
526
|
+
- [Codecov](https://app.codecov.io/github/amkisko/active_version.rb)
|
|
527
|
+
- [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/amkisko/active_version.rb)
|
|
511
528
|
|
|
512
529
|
## License
|
|
513
530
|
|
data/SECURITY.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Reporting a Vulnerability
|
|
4
4
|
|
|
5
|
-
Do
|
|
5
|
+
Do not open a public issue for security vulnerabilities.
|
|
6
6
|
|
|
7
7
|
Email security details to: security@kiskolabs.com
|
|
8
8
|
|
|
@@ -23,7 +23,5 @@ Include: description, steps to reproduce, potential impact, and suggested fix (i
|
|
|
23
23
|
|
|
24
24
|
## Automation Security
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
* Supply Chain: All automated dependencies must be verified.
|
|
29
|
-
|
|
26
|
+
- Context Isolation: It is strictly forbidden to include production credentials, API keys, or Personally Identifiable Information (PII) in prompts sent to third-party LLMs or automation services.
|
|
27
|
+
- Supply Chain: All automated dependencies must be verified.
|
|
@@ -11,7 +11,7 @@ module ActiveVersion
|
|
|
11
11
|
module ClassMethods
|
|
12
12
|
# Declare that a model has audits
|
|
13
13
|
def has_audits(options = {})
|
|
14
|
-
include ActiveVersion::Audits::HasAudits unless
|
|
14
|
+
include ActiveVersion::Audits::HasAudits unless include?(ActiveVersion::Audits::HasAudits)
|
|
15
15
|
|
|
16
16
|
# Call the HasAudits implementation once included
|
|
17
17
|
ActiveVersion::Audits::HasAudits::ClassMethods.instance_method(:has_audits)
|
|
@@ -30,7 +30,7 @@ end
|
|
|
30
30
|
|
|
31
31
|
# If ActiveRecord::Base is already loaded, include immediately
|
|
32
32
|
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:include)
|
|
33
|
-
unless ActiveRecord::Base.
|
|
33
|
+
unless ActiveRecord::Base.include?(ActiveVersion::Adapters::ActiveRecord::Audits)
|
|
34
34
|
ActiveSupport.on_load(:active_record) { include ActiveVersion::Adapters::ActiveRecord::Audits }
|
|
35
35
|
end
|
|
36
36
|
end
|
|
@@ -43,7 +43,7 @@ end
|
|
|
43
43
|
|
|
44
44
|
# If ActiveRecord::Base is already loaded, include immediately
|
|
45
45
|
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:include)
|
|
46
|
-
unless ActiveRecord::Base.
|
|
46
|
+
unless ActiveRecord::Base.include?(ActiveVersion::Adapters::ActiveRecord::Revisions)
|
|
47
47
|
ActiveSupport.on_load(:active_record) { include ActiveVersion::Adapters::ActiveRecord::Revisions }
|
|
48
48
|
end
|
|
49
49
|
end
|
|
@@ -39,7 +39,7 @@ end
|
|
|
39
39
|
|
|
40
40
|
# If ActiveRecord::Base is already loaded, include immediately
|
|
41
41
|
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:include)
|
|
42
|
-
unless ActiveRecord::Base.
|
|
42
|
+
unless ActiveRecord::Base.include?(ActiveVersion::Adapters::ActiveRecord::Translations)
|
|
43
43
|
ActiveSupport.on_load(:active_record) { include ActiveVersion::Adapters::ActiveRecord::Translations }
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -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
|
|
@@ -187,48 +187,52 @@ module ActiveVersion
|
|
|
187
187
|
return nil
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
190
|
+
create_audit_record_with_version_retry!(
|
|
191
|
+
insert_attrs,
|
|
192
|
+
version_column: version_column,
|
|
193
|
+
auditable_column: auditable_column,
|
|
194
|
+
action: attrs[:action]
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# One retry on unique (version) collision. Each insert uses a savepoint so
|
|
201
|
+
# PostgreSQL can continue the outer update/lock transaction after a unique violation.
|
|
202
|
+
def create_audit_record_with_version_retry!(insert_attrs, version_column:, auditable_column:, action:)
|
|
203
|
+
attempt = 0
|
|
204
|
+
begin
|
|
205
|
+
attempt += 1
|
|
206
|
+
self.class.transaction(requires_new: true) do
|
|
207
|
+
audit_class.create!(insert_attrs)
|
|
208
|
+
end
|
|
209
|
+
combine_audits_if_needed if action != "create"
|
|
210
|
+
association(:audits).reset if association_cached?(:audits)
|
|
211
|
+
nil
|
|
212
|
+
rescue => error
|
|
213
|
+
unless ActiveVersion::UniqueVersionCollision.match?(error, version_column: version_column) &&
|
|
214
|
+
action != "create" && attempt < 2
|
|
215
|
+
handle_audit_errors(error, action)
|
|
216
|
+
return nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
auditable_type_for_query = audited_options[:class_name] || self.class.name
|
|
220
|
+
if auditable_type_for_query.nil?
|
|
221
|
+
raise ConfigurationError, "Cannot determine class name for dynamically created class. Please specify class_name option in has_audits"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
max_version = audit_class.uncached do
|
|
225
|
+
audit_class.where({"#{auditable_column}_type" => auditable_type_for_query}.merge(active_version_audit_identity_map))
|
|
226
|
+
.maximum(version_column) || 0
|
|
225
227
|
end
|
|
228
|
+
insert_attrs[version_column] = max_version + 1
|
|
229
|
+
retry
|
|
226
230
|
end
|
|
227
231
|
end
|
|
228
232
|
|
|
229
233
|
def handle_audit_errors(error, action)
|
|
230
234
|
ActiveVersion::Instrumentation.instrument_audit_write_failed(self, error: error, action: action)
|
|
231
|
-
behavior = audited_options[:error_behavior] || ActiveVersion.config.audit_error_behavior || :
|
|
235
|
+
behavior = audited_options[:error_behavior] || ActiveVersion.config.audit_error_behavior || :exception
|
|
232
236
|
|
|
233
237
|
case behavior
|
|
234
238
|
when :log
|
|
@@ -241,7 +245,7 @@ module ActiveVersion
|
|
|
241
245
|
end
|
|
242
246
|
|
|
243
247
|
def log_audit_errors(error, action)
|
|
244
|
-
|
|
248
|
+
ActiveVersion.logger.warn(
|
|
245
249
|
"Unable to create audit for #{action} of #{self.class.name}" \
|
|
246
250
|
"##{id}: #{error.message}"
|
|
247
251
|
)
|
|
@@ -419,7 +419,7 @@ module ActiveVersion
|
|
|
419
419
|
private
|
|
420
420
|
|
|
421
421
|
def install_thread_local_audited_options_reader!
|
|
422
|
-
unless singleton_class.
|
|
422
|
+
unless singleton_class.method_defined?(:audited_options_without_thread_local, false)
|
|
423
423
|
singleton_class.alias_method :audited_options_without_thread_local, :audited_options
|
|
424
424
|
end
|
|
425
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
|
|
@@ -113,30 +113,25 @@ module ActiveVersion
|
|
|
113
113
|
pseudo.define_singleton_method(:persisted?) { true }
|
|
114
114
|
pseudo
|
|
115
115
|
else
|
|
116
|
-
|
|
117
|
-
# Use create! instead of build + save to get better error messages
|
|
118
116
|
begin
|
|
119
117
|
revision = create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
|
|
120
|
-
rescue ActiveRecord::RecordInvalid =>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
error_msg += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
|
|
138
|
-
error_msg += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
|
|
139
|
-
raise error_msg
|
|
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
|
|
140
135
|
end
|
|
141
136
|
|
|
142
137
|
# Force reload association to ensure it's visible
|
|
@@ -426,21 +421,70 @@ module ActiveVersion
|
|
|
426
421
|
revisions.reset
|
|
427
422
|
end
|
|
428
423
|
|
|
429
|
-
# One retry on unique (version)
|
|
424
|
+
# One retry on unique (version) collision. Each insert uses a savepoint so
|
|
425
|
+
# PostgreSQL can continue the outer update/lock transaction after a unique violation.
|
|
430
426
|
def create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
|
|
431
427
|
attempt = 0
|
|
432
428
|
begin
|
|
433
429
|
attempt += 1
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
430
|
+
self.class.transaction(requires_new: true) do
|
|
431
|
+
revisions.create!(revision_attrs)
|
|
432
|
+
end
|
|
433
|
+
rescue => error
|
|
434
|
+
raise error unless ActiveVersion::UniqueVersionCollision.match?(error, version_column: version_column_sym)
|
|
435
|
+
raise error if attempt >= 2
|
|
437
436
|
|
|
438
|
-
max_version = revisions_scope.maximum(version_column_sym) || 0
|
|
437
|
+
max_version = revisions_scope.uncached { revisions_scope.maximum(version_column_sym) } || 0
|
|
439
438
|
revision_attrs[version_column_sym] = max_version + 1
|
|
440
439
|
retry
|
|
441
440
|
end
|
|
442
441
|
end
|
|
443
|
-
|
|
442
|
+
|
|
443
|
+
def handle_revision_errors(error, revision_attrs:, version_column_sym:, new_version:, invalid_record: nil)
|
|
444
|
+
ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: error)
|
|
445
|
+
behavior = self.class.revision_options&.dig(:error_behavior) ||
|
|
446
|
+
ActiveVersion.config.revision_error_behavior ||
|
|
447
|
+
:exception
|
|
448
|
+
|
|
449
|
+
case behavior
|
|
450
|
+
when :exception
|
|
451
|
+
raise build_revision_error_message(
|
|
452
|
+
error,
|
|
453
|
+
revision_attrs: revision_attrs,
|
|
454
|
+
version_column_sym: version_column_sym,
|
|
455
|
+
new_version: new_version,
|
|
456
|
+
invalid_record: invalid_record
|
|
457
|
+
)
|
|
458
|
+
when :log
|
|
459
|
+
ActiveVersion.logger.warn(
|
|
460
|
+
build_revision_error_message(
|
|
461
|
+
error,
|
|
462
|
+
revision_attrs: revision_attrs,
|
|
463
|
+
version_column_sym: version_column_sym,
|
|
464
|
+
new_version: new_version,
|
|
465
|
+
invalid_record: invalid_record
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
nil
|
|
469
|
+
when :silent
|
|
470
|
+
nil
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def build_revision_error_message(error, revision_attrs:, version_column_sym:, new_version:, invalid_record: nil)
|
|
475
|
+
message = "Failed to create revision: #{error.class}"
|
|
476
|
+
message += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
|
|
477
|
+
message += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
|
|
478
|
+
message += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
|
|
479
|
+
if invalid_record
|
|
480
|
+
message += "\nRevision class: #{self.class.revision_class.name}"
|
|
481
|
+
message += "\nSource class: #{self.class.name}"
|
|
482
|
+
message += "\nRecord error fields: #{invalid_record.errors.attribute_names.map(&:to_s).uniq.sort.join(", ")}"
|
|
483
|
+
message += "\nRecord valid?: #{invalid_record.valid?}"
|
|
484
|
+
end
|
|
485
|
+
message
|
|
486
|
+
end
|
|
487
|
+
private :create_revision_record_with_version_retry!, :handle_revision_errors, :build_revision_error_message
|
|
444
488
|
|
|
445
489
|
def refreshable_column_names
|
|
446
490
|
@refreshable_column_names ||=
|
|
@@ -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?
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
module UniqueVersionCollision
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def match?(error, version_column: nil)
|
|
6
|
+
return true if version_uniqueness_invalid?(error, version_column)
|
|
7
|
+
|
|
8
|
+
unique_constraint_names_version?(error, version_column)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def version_uniqueness_invalid?(error, version_column)
|
|
12
|
+
return false unless error.is_a?(ActiveRecord::RecordInvalid)
|
|
13
|
+
return false if version_column.nil?
|
|
14
|
+
|
|
15
|
+
details = error.record&.errors&.details
|
|
16
|
+
return false unless details
|
|
17
|
+
|
|
18
|
+
Array(details[version_column.to_sym]).any? { |detail| detail[:error] == :taken }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def unique_constraint_names_version?(error, version_column)
|
|
22
|
+
needle = version_column.to_s
|
|
23
|
+
return false if needle.empty?
|
|
24
|
+
return false unless unique_constraint_error?(error)
|
|
25
|
+
|
|
26
|
+
[error.message, error.cause&.message].compact.any? { |text| text.include?(needle) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def unique_constraint_error?(error)
|
|
30
|
+
return true if error.is_a?(ActiveRecord::RecordNotUnique)
|
|
31
|
+
return false unless defined?(PG)
|
|
32
|
+
|
|
33
|
+
error.is_a?(ActiveRecord::StatementInvalid) && error.cause.is_a?(PG::UniqueViolation)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_class_method :version_uniqueness_invalid?, :unique_constraint_names_version?, :unique_constraint_error?
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/active_version.rb
CHANGED
|
@@ -12,6 +12,7 @@ require "active_version/column_mapper"
|
|
|
12
12
|
require "active_version/version_registry"
|
|
13
13
|
require "active_version/instrumentation"
|
|
14
14
|
require "active_version/runtime"
|
|
15
|
+
require "active_version/unique_version_collision"
|
|
15
16
|
|
|
16
17
|
# Main entry point for ActiveVersion
|
|
17
18
|
module ActiveVersion
|
|
@@ -338,6 +339,13 @@ module ActiveVersion
|
|
|
338
339
|
@logger = default_logger
|
|
339
340
|
end
|
|
340
341
|
|
|
342
|
+
def log_debug(message)
|
|
343
|
+
logger = self.logger
|
|
344
|
+
return if logger.nil?
|
|
345
|
+
|
|
346
|
+
logger.debug(message) if logger.respond_to?(:debug)
|
|
347
|
+
end
|
|
348
|
+
|
|
341
349
|
def default_logger
|
|
342
350
|
l = Logger.new($stderr)
|
|
343
351
|
l.level = Logger::WARN
|
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:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -15,14 +15,20 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 7.2.3.2
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.0'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
26
|
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
28
|
+
version: 7.2.3.2
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.0'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: rake
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -43,14 +49,20 @@ dependencies:
|
|
|
43
49
|
requirements:
|
|
44
50
|
- - ">="
|
|
45
51
|
- !ruby/object:Gem::Version
|
|
46
|
-
version:
|
|
52
|
+
version: 7.2.3.2
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '9.0'
|
|
47
56
|
type: :development
|
|
48
57
|
prerelease: false
|
|
49
58
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
59
|
requirements:
|
|
51
60
|
- - ">="
|
|
52
61
|
- !ruby/object:Gem::Version
|
|
53
|
-
version:
|
|
62
|
+
version: 7.2.3.2
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '9.0'
|
|
54
66
|
- !ruby/object:Gem::Dependency
|
|
55
67
|
name: rspec
|
|
56
68
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -85,14 +97,14 @@ dependencies:
|
|
|
85
97
|
requirements:
|
|
86
98
|
- - ">="
|
|
87
99
|
- !ruby/object:Gem::Version
|
|
88
|
-
version:
|
|
100
|
+
version: 2.9.5
|
|
89
101
|
type: :development
|
|
90
102
|
prerelease: false
|
|
91
103
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
104
|
requirements:
|
|
93
105
|
- - ">="
|
|
94
106
|
- !ruby/object:Gem::Version
|
|
95
|
-
version:
|
|
107
|
+
version: 2.9.5
|
|
96
108
|
- !ruby/object:Gem::Dependency
|
|
97
109
|
name: pg
|
|
98
110
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -137,18 +149,32 @@ dependencies:
|
|
|
137
149
|
version: '0.4'
|
|
138
150
|
- !ruby/object:Gem::Dependency
|
|
139
151
|
name: polyrun
|
|
152
|
+
requirement: !ruby/object:Gem::Requirement
|
|
153
|
+
requirements:
|
|
154
|
+
- - ">="
|
|
155
|
+
- !ruby/object:Gem::Version
|
|
156
|
+
version: 2.2.0
|
|
157
|
+
type: :development
|
|
158
|
+
prerelease: false
|
|
159
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
160
|
+
requirements:
|
|
161
|
+
- - ">="
|
|
162
|
+
- !ruby/object:Gem::Version
|
|
163
|
+
version: 2.2.0
|
|
164
|
+
- !ruby/object:Gem::Dependency
|
|
165
|
+
name: prosopite
|
|
140
166
|
requirement: !ruby/object:Gem::Requirement
|
|
141
167
|
requirements:
|
|
142
168
|
- - "~>"
|
|
143
169
|
- !ruby/object:Gem::Version
|
|
144
|
-
version:
|
|
170
|
+
version: '2.0'
|
|
145
171
|
type: :development
|
|
146
172
|
prerelease: false
|
|
147
173
|
version_requirements: !ruby/object:Gem::Requirement
|
|
148
174
|
requirements:
|
|
149
175
|
- - "~>"
|
|
150
176
|
- !ruby/object:Gem::Version
|
|
151
|
-
version:
|
|
177
|
+
version: '2.0'
|
|
152
178
|
- !ruby/object:Gem::Dependency
|
|
153
179
|
name: standard
|
|
154
180
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -239,14 +265,14 @@ dependencies:
|
|
|
239
265
|
requirements:
|
|
240
266
|
- - "~>"
|
|
241
267
|
- !ruby/object:Gem::Version
|
|
242
|
-
version: '3.
|
|
268
|
+
version: '3.9'
|
|
243
269
|
type: :development
|
|
244
270
|
prerelease: false
|
|
245
271
|
version_requirements: !ruby/object:Gem::Requirement
|
|
246
272
|
requirements:
|
|
247
273
|
- - "~>"
|
|
248
274
|
- !ruby/object:Gem::Version
|
|
249
|
-
version: '3.
|
|
275
|
+
version: '3.9'
|
|
250
276
|
- !ruby/object:Gem::Dependency
|
|
251
277
|
name: rubocop-thread_safety
|
|
252
278
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -275,6 +301,20 @@ dependencies:
|
|
|
275
301
|
- - "~>"
|
|
276
302
|
- !ruby/object:Gem::Version
|
|
277
303
|
version: '2'
|
|
304
|
+
- !ruby/object:Gem::Dependency
|
|
305
|
+
name: bundler-audit
|
|
306
|
+
requirement: !ruby/object:Gem::Requirement
|
|
307
|
+
requirements:
|
|
308
|
+
- - "~>"
|
|
309
|
+
- !ruby/object:Gem::Version
|
|
310
|
+
version: '0.9'
|
|
311
|
+
type: :development
|
|
312
|
+
prerelease: false
|
|
313
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
314
|
+
requirements:
|
|
315
|
+
- - "~>"
|
|
316
|
+
- !ruby/object:Gem::Version
|
|
317
|
+
version: '0.9'
|
|
278
318
|
- !ruby/object:Gem::Dependency
|
|
279
319
|
name: rbs
|
|
280
320
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -289,6 +329,20 @@ dependencies:
|
|
|
289
329
|
- - "~>"
|
|
290
330
|
- !ruby/object:Gem::Version
|
|
291
331
|
version: '3'
|
|
332
|
+
- !ruby/object:Gem::Dependency
|
|
333
|
+
name: parallel
|
|
334
|
+
requirement: !ruby/object:Gem::Requirement
|
|
335
|
+
requirements:
|
|
336
|
+
- - "~>"
|
|
337
|
+
- !ruby/object:Gem::Version
|
|
338
|
+
version: '1.26'
|
|
339
|
+
type: :development
|
|
340
|
+
prerelease: false
|
|
341
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
342
|
+
requirements:
|
|
343
|
+
- - "~>"
|
|
344
|
+
- !ruby/object:Gem::Version
|
|
345
|
+
version: '1.26'
|
|
292
346
|
description: 'ActiveVersion is a language-agnostic versioning library that unifies
|
|
293
347
|
three core use cases: translations (locale-based), revisions/drafts (workflow snapshots),
|
|
294
348
|
and audits (change tracking).'
|
|
@@ -345,6 +399,7 @@ files:
|
|
|
345
399
|
- lib/active_version/translations.rb
|
|
346
400
|
- lib/active_version/translations/has_translations.rb
|
|
347
401
|
- lib/active_version/translations/translation_record.rb
|
|
402
|
+
- lib/active_version/unique_version_collision.rb
|
|
348
403
|
- lib/active_version/version.rb
|
|
349
404
|
- lib/active_version/version_registry.rb
|
|
350
405
|
- lib/generators/active_version/audits/audits_generator.rb
|
|
@@ -395,7 +450,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
395
450
|
- !ruby/object:Gem::Version
|
|
396
451
|
version: '0'
|
|
397
452
|
requirements: []
|
|
398
|
-
rubygems_version:
|
|
453
|
+
rubygems_version: 4.0.6
|
|
399
454
|
specification_version: 4
|
|
400
455
|
summary: Unified versioning library for translations, revisions, and audits
|
|
401
456
|
test_files: []
|