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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc38a55e873e8ea603bef6bb4bb15350b03582214fc1a48856a05cb35498b845
4
- data.tar.gz: 34f0c43fb603e0109729c2254ae6d7ee1f95f14f56f3a0b408ffdb1429efdb48
3
+ metadata.gz: '08398a92e326503be43e704b7681562a6c3c4d220bd7cf934e295ff3afada135'
4
+ data.tar.gz: a3678d669239918343732b02b8ff8285b32e549bb44ae6eae1cffde75abf0ae1
5
5
  SHA512:
6
- metadata.gz: e13a251346e48e51c617961f433f7384f4cb1dce283f41b1cda977180feb1a116a83a83bf39e241bf15d5f7f67daa926dacb19774ee6c496981e9fbc6c49efb7
7
- data.tar.gz: 0cd3cfe32df95ac78d6591f5127f1a44c340cb69238b77471029fb5827c4d3c946a44c62b7734fe065bbe75db208162b206919254e4ad33addf5f4a3dab39b2c
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
- bundle exec rspec
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.singular}_id"
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 => 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
@@ -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 || :log
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
- Rails.logger&.warn(
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
- # Get audit class name
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
- version_column = ActiveVersion.column_mapper.column_for(self, :audits, :version)
125
- all_audits = audits.to_a
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(all_audits - targeted_audits)
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.instance_methods(false).include?(:audited_options_without_thread_local)
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
- 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
@@ -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 && should_merge_with_previous?(debounce_time, timestamp)
21
- merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values)
22
- version_column = revision_version_column
23
- return revisions_scope.order(version_column => :desc).first
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 = revisions.create!(revision_attrs)
117
- rescue ActiveRecord::RecordInvalid => e
118
- ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
119
- error_msg = "Failed to create revision: #{e.class}"
120
- error_msg += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
121
- error_msg += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
122
- error_msg += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
123
- error_msg += "\nRevision class: #{self.class.revision_class.name}"
124
- error_msg += "\nSource class: #{self.class.name}"
125
- if e.record
126
- error_msg += "\nRecord error fields: #{e.record.errors.attribute_names.map(&:to_s).uniq.sort.join(", ")}"
127
- error_msg += "\nRecord valid?: #{e.record.valid?}"
128
- end
129
- raise error_msg
130
- rescue => e
131
- ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
132
- error_msg = "Failed to create revision: #{e.class}"
133
- error_msg += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
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 = revisions.create!(revision_attrs)
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
- return false unless revisions_scope.exists?
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 false unless last_revision
360
+ return nil unless last_revision
358
361
 
359
362
  time_diff = timestamp.to_f - last_revision.created_at.to_f
360
- time_diff <= debounce_time
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 = revisions_scope.order(version_column => :desc).first
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
- version_list = revisions_scope.order(version_column => (reverse ? :desc : :asc)).pluck(version_column)
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
- revision = at_version(v)
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?
@@ -1,3 +1,3 @@
1
1
  module ActiveVersion
2
- VERSION = "1.2.0"
2
+ VERSION = "1.4.0"
3
3
  end
@@ -17,9 +17,11 @@ module ActiveVersion
17
17
 
18
18
  # Detect option conflicts
19
19
  if existing[:options] != options
20
- warn "[ActiveVersion] Re-registering #{model_class.name} with :#{version_type} " \
21
- "with different options. Previous: #{existing[:options].inspect}, " \
22
- "New: #{options.inspect}. This may indicate a configuration issue."
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
@@ -163,7 +163,7 @@ module ActiveVersion
163
163
  elsif Fiber.current.respond_to?(:[])
164
164
  Fiber.current[key]
165
165
  else
166
- Thread.current[key]
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
- Thread.current[key] = value
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 Thread.current.respond_to?(:keys)
188
- Thread.current.keys
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.2.0
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: '1.4'
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: '1.4'
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: simplecov
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: '0.6'
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: '0.6'
151
+ version: 2.2.0
180
152
  - !ruby/object:Gem::Dependency
181
- name: simplecov-cobertura
153
+ name: prosopite
182
154
  requirement: !ruby/object:Gem::Requirement
183
155
  requirements:
184
156
  - - "~>"
185
157
  - !ruby/object:Gem::Version
186
- version: '3'
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: '3'
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: 3.6.9
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: []