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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f1b2d07a134e66e4e4330b9d195b32d831d3a2ce0599c324bed86157eb2e3fc
4
- data.tar.gz: f612e466b59f335183016fc5d9d601005a4b66b07efa6276b54c1260bbd08736
3
+ metadata.gz: d52287aa8ea68e0c77baff3065a3140d397c2e638460967880b0b0dbc904b42c
4
+ data.tar.gz: aff296230f04d04d90bafe26e12fe0876fc2cd292cd07e6083d71a595a9ea6f6
5
5
  SHA512:
6
- metadata.gz: '068727dace361f83992e6b6e92b25e2dcb50cc5128e5cbb80114cfd4d48fa7f4f406f0fe216de0518becbe75f9186ab18637211452ce2a98b66825ea513172f7'
7
- data.tar.gz: 6d09e28cbb0b651a4aaa89fb879a48e978bb9d9fcde7d3c312226ca256963ed119b9aba9e016eaddbe6a0a613d2e10140c5474f280d545f318fdb80282007930
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.2.0` development dependency; `bin/polyrun`, `bin/rspec`, `polyrun.yml`, and `config/polyrun_coverage.yml` for parallel RSpec and merged coverage.
6
- - Add `script/create_postgres_shard_databases.sh` for CI and local Postgres shard databases; document in `POLYRUN.md`; set job-level `DATABASE_URL` in GitHub Actions test workflow.
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
- [![Gem Version](https://badge.fury.io/rb/active_version.svg)](https://badge.fury.io/rb/active_version) [![Test Status](https://github.com/amkisko/active_version.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/active_version.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/active_version.rb/graph/badge.svg?token=2U6NXJOVVM)](https://codecov.io/gh/amkisko/active_version.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/active_version.svg)](https://badge.fury.io/rb/active_version) [![Test Status](https://github.com/amkisko/active_version.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/active_version.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/active_version.rb/graph/badge.svg?token=2U6NXJOVVM)](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 >= 6.0.0 (default runtime)
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) **`~> 1.2.0`** (declared in `active_version.gemspec`) for **parallel RSpec** (`./bin/polyrun`), coverage, and CI report formats—see [POLYRUN.md](POLYRUN.md).
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 NOT open a public GitHub issue for security vulnerabilities.
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
- * 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
-
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 included_modules.include?(ActiveVersion::Audits::HasAudits)
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.included_modules.include?(ActiveVersion::Adapters::ActiveRecord::Audits)
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.included_modules.include?(ActiveVersion::Adapters::ActiveRecord::Revisions)
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.included_modules.include?(ActiveVersion::Adapters::ActiveRecord::Translations)
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 => e
129
- if defined?(Rails) && Rails.respond_to?(:logger)
130
- Rails.logger&.debug("[ActiveVersion] Deferred audit attribute setup for #{name}: #{e.class}: #{e.message}")
131
- end
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 => e
160
- if defined?(Rails) && Rails.respond_to?(:logger)
161
- Rails.logger&.debug("[ActiveVersion] Deferred audit association setup for #{name}: #{e.class}: #{e.message}")
162
- end
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
- # Force reload from database to see any updates
28
- if persisted?
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
- # Filter out combined audits (those with empty changes)
59
- # Check raw column value first (before JSON parsing) for "{}" string
60
- active_audits = all_audits.reject do |audit|
61
- # Check raw column value first - combined audits have "{}" as string
62
- raw_changes = audit.read_attribute(changes_column)
63
-
64
- # If raw value is exactly "{}", it's a combined audit
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 = active_audits.length
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 = active_audits.first(extra_count + 1)
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
- begin
191
- audit_class.create!(insert_attrs)
192
- combine_audits_if_needed if attrs[:action] != "create"
193
- association(:audits).reset if association_cached?(:audits)
194
- nil
195
- rescue ActiveRecord::RecordNotUnique => e
196
- # Handle unique constraint violation (likely version conflict)
197
- # Retry once with recalculated version
198
- if e.message.include?("version") && attrs[:action] != "create"
199
- # Recalculate version and retry
200
- # Use class_name from options if provided (for dynamically created classes)
201
- auditable_type_for_query = audited_options[:class_name] || self.class.name
202
- if auditable_type_for_query.nil?
203
- raise ConfigurationError, "Cannot determine class name for dynamically created class. Please specify class_name option in has_audits"
204
- end
205
- max_version = audit_class.where({"#{auditable_column}_type" => auditable_type_for_query}.merge(active_version_audit_identity_map))
206
- .maximum(version_column) || 0
207
- insert_attrs[version_column] = max_version + 1
208
- begin
209
- audit_class.create!(insert_attrs)
210
- combine_audits_if_needed if attrs[:action] != "create"
211
- association(:audits).reset if association_cached?(:audits)
212
- nil
213
- rescue => retry_error
214
- handle_audit_errors(retry_error, attrs[:action])
215
- nil
216
- end
217
- else
218
- handle_audit_errors(e, attrs[:action])
219
- nil
220
- end
221
- rescue => e
222
- handle_audit_errors(e, attrs[:action])
223
- nil
224
- end
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 || :log
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
- Rails.logger&.warn(
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.instance_methods(false).include?(:audited_options_without_thread_local)
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
- if defined?(Rails) && Rails.respond_to?(:logger)
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 => e
121
- ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
122
- error_msg = "Failed to create revision: #{e.class}"
123
- error_msg += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
124
- error_msg += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
125
- error_msg += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
126
- error_msg += "\nRevision class: #{self.class.revision_class.name}"
127
- error_msg += "\nSource class: #{self.class.name}"
128
- if e.record
129
- error_msg += "\nRecord error fields: #{e.record.errors.attribute_names.map(&:to_s).uniq.sort.join(", ")}"
130
- error_msg += "\nRecord valid?: #{e.record.valid?}"
131
- end
132
- raise error_msg
133
- rescue => e
134
- ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
135
- error_msg = "Failed to create revision: #{e.class}"
136
- error_msg += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
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) violation, matching audit_writer behavior when a DB unique index exists.
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
- revisions.create!(revision_attrs)
435
- rescue ActiveRecord::RecordNotUnique => e
436
- raise e if attempt >= 2
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
- private :create_revision_record_with_version_retry!
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
@@ -1,3 +1,3 @@
1
1
  module ActiveVersion
2
- VERSION = "1.3.0"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -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: 1.3.0
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: 6.0.0
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: 6.0.0
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: 6.0.0
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: 6.0.0
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: '1.4'
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: '1.4'
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: 1.2.0
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: 1.2.0
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.8'
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.8'
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: 3.6.9
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: []