active_version 1.1.0 → 1.3.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: 6b9a886b4b5c2fd7b75b33e9f8d06557da0e51d221bf2c5bee1b88a33a79894e
4
- data.tar.gz: 170d8b76a41e56d68e7ae22d2316cfdfbd4f559c27784a8bfcc0fc2e4e8c713b
3
+ metadata.gz: 5f1b2d07a134e66e4e4330b9d195b32d831d3a2ce0599c324bed86157eb2e3fc
4
+ data.tar.gz: f612e466b59f335183016fc5d9d601005a4b66b07efa6276b54c1260bbd08736
5
5
  SHA512:
6
- metadata.gz: b3a6edf6a90368e5a2b65d20439a4b7b2496dc432e75f6ea6a4805b25ac84dfe24155680da924ec3a696ba004d40826382b6547d45ca0769e400c71e14d05eea
7
- data.tar.gz: 30a796563f87edfd7b54e938d2a93b75381e1d494febf1ed51748fcb4d45058d24343f2267275bdf6462d63059bf0fa8ab7f44ef5ac0f8d27e31cea7c765faea
6
+ metadata.gz: '068727dace361f83992e6b6e92b25e2dcb50cc5128e5cbb80114cfd4d48fa7f4f406f0fe216de0518becbe75f9186ab18637211452ce2a98b66825ea513172f7'
7
+ data.tar.gz: 6d09e28cbb0b651a4aaa89fb879a48e978bb9d9fcde7d3c312226ca256963ed119b9aba9e016eaddbe6a0a613d2e10140c5474f280d545f318fdb80282007930
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.3.0 (2026-04-15)
4
+
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.
7
+ - Add internal library logger.
8
+ - Fix audits, Sequel foreign keys, revision debounce, and versions walk edge cases.
9
+ - Adjust RSpec and RuboCop test configuration; update spec coverage.
10
+
11
+ ## 1.2.0 (2026-03-27)
12
+
13
+ - Fixed SQL generation for audit creation to avoid duplicate `created_at` and `updated_at` columns (or other possible duplicate columns)
14
+
3
15
  ## 1.1.0 (2026-03-27)
4
16
 
5
17
  - Added `audit_auditable_optional` configuration option to control polymorphic `belongs_to :auditable` optionality
data/README.md CHANGED
@@ -447,8 +447,8 @@ The report includes per-record overhead vs ActiveRecord baseline (`p5`, `mean`,
447
447
  The report also separates ActiveRecord and Sequel benchmark groups to avoid cross-ORM baseline mixing.
448
448
 
449
449
  ```bash
450
- # Normal test run (benchmarks excluded)
451
- bundle exec rspec
450
+ # Normal test run (benchmarks excluded) — parallel OS processes via Polyrun (same as CI)
451
+ ./bin/polyrun
452
452
 
453
453
  # Explicit benchmark run
454
454
  usr/bin/benchmark.rb
@@ -507,6 +507,8 @@ Environment knobs:
507
507
 
508
508
  Bug reports and pull requests are welcome on GitHub at https://github.com/amkisko/active_version.rb.
509
509
 
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).
511
+
510
512
  ## License
511
513
 
512
514
  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
@@ -104,11 +104,15 @@ module ActiveVersion
104
104
  end
105
105
 
106
106
  def prepare_sql_values(changes)
107
- changes.each_with_object({}) do |(k, v), h|
108
- h[k] = v.last if v.is_a?(Array)
109
- h[k] = v unless v.is_a?(Array)
110
- h[k] = JSON.generate(h[k]) if h[k].is_a?(Hash) || h[k].is_a?(Array)
107
+ h = changes.each_with_object({}) do |(k, v), acc|
108
+ acc[k] = v.last if v.is_a?(Array)
109
+ acc[k] = v unless v.is_a?(Array)
110
+ acc[k] = JSON.generate(acc[k]) if acc[k].is_a?(Hash) || acc[k].is_a?(Array)
111
111
  end
112
+ # Arel INSERT uses keys as column names; stringify so :action / mirror :attrs match
113
+ # string keys from column_mapper and so symbol + string for the same column cannot
114
+ # both appear (PG::DuplicateColumn).
115
+ h.transform_keys(&:to_s)
112
116
  end
113
117
  end
114
118
  end
@@ -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)
@@ -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
@@ -113,7 +116,7 @@ module ActiveVersion
113
116
 
114
117
  # Use create! instead of build + save to get better error messages
115
118
  begin
116
- revision = revisions.create!(revision_attrs)
119
+ revision = create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
117
120
  rescue ActiveRecord::RecordInvalid => e
118
121
  ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
119
122
  error_msg = "Failed to create revision: #{e.class}"
@@ -251,7 +254,7 @@ module ActiveVersion
251
254
  end
252
255
 
253
256
  # Create the revision using the association (it will set foreign_key automatically)
254
- revision = revisions.create!(revision_attrs)
257
+ revision = create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
255
258
  # Ensure it was created and persisted
256
259
  raise "Failed to create revision" unless revision.persisted?
257
260
  # Reload the post to clear all caches and see the new revision
@@ -350,19 +353,26 @@ module ActiveVersion
350
353
  end
351
354
 
352
355
  def should_merge_with_previous?(debounce_time, timestamp)
353
- return false unless revisions_scope.exists?
356
+ !last_revision_for_debounce_merge(debounce_time, timestamp).nil?
357
+ end
358
+
359
+ # Latest revision row if +timestamp+ falls within +debounce_time+ seconds after that revision; one query.
360
+ def last_revision_for_debounce_merge(debounce_time, timestamp)
361
+ return nil unless debounce_time
354
362
 
355
363
  version_column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
356
364
  last_revision = revisions_scope.order(version_column => :desc).first
357
- return false unless last_revision
365
+ return nil unless last_revision
358
366
 
359
367
  time_diff = timestamp.to_f - last_revision.created_at.to_f
360
- time_diff <= debounce_time
368
+ return nil if time_diff > debounce_time
369
+
370
+ last_revision
361
371
  end
362
372
 
363
- def merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values)
373
+ def merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values, last_revision: nil)
364
374
  version_column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
365
- last_revision = revisions_scope.order(version_column => :desc).first
375
+ last_revision ||= revisions_scope.order(version_column => :desc).first
366
376
  return unless last_revision
367
377
 
368
378
  # Update last revision with current or persisted attributes (filtered by only/except)
@@ -416,6 +426,22 @@ module ActiveVersion
416
426
  revisions.reset
417
427
  end
418
428
 
429
+ # One retry on unique (version) violation, matching audit_writer behavior when a DB unique index exists.
430
+ def create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
431
+ attempt = 0
432
+ begin
433
+ attempt += 1
434
+ revisions.create!(revision_attrs)
435
+ rescue ActiveRecord::RecordNotUnique => e
436
+ raise e if attempt >= 2
437
+
438
+ max_version = revisions_scope.maximum(version_column_sym) || 0
439
+ revision_attrs[version_column_sym] = max_version + 1
440
+ retry
441
+ end
442
+ end
443
+ private :create_revision_record_with_version_retry!
444
+
419
445
  def refreshable_column_names
420
446
  @refreshable_column_names ||=
421
447
  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
@@ -1,3 +1,3 @@
1
1
  module ActiveVersion
2
- VERSION = "1.1.0"
2
+ VERSION = "1.3.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,25 @@ 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 default_logger
342
+ l = Logger.new($stderr)
343
+ l.level = Logger::WARN
344
+ l.formatter = proc { |_, _, _, msg| "#{msg}\n" }
345
+ l
346
+ end
347
+ private :default_logger
348
+ end
325
349
  end
326
350
 
327
351
  # 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.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -136,61 +136,19 @@ 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
142
  - - "~>"
143
143
  - !ruby/object:Gem::Version
144
- version: '0.22'
144
+ version: 1.2.0
145
145
  type: :development
146
146
  prerelease: false
147
147
  version_requirements: !ruby/object:Gem::Requirement
148
148
  requirements:
149
149
  - - "~>"
150
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
- - - "~>"
171
- - !ruby/object:Gem::Version
172
- version: '0.6'
173
- type: :development
174
- prerelease: false
175
- version_requirements: !ruby/object:Gem::Requirement
176
- requirements:
177
- - - "~>"
178
- - !ruby/object:Gem::Version
179
- version: '0.6'
180
- - !ruby/object:Gem::Dependency
181
- name: simplecov-cobertura
182
- requirement: !ruby/object:Gem::Requirement
183
- requirements:
184
- - - "~>"
185
- - !ruby/object:Gem::Version
186
- version: '3'
187
- type: :development
188
- prerelease: false
189
- version_requirements: !ruby/object:Gem::Requirement
190
- requirements:
191
- - - "~>"
192
- - !ruby/object:Gem::Version
193
- version: '3'
151
+ version: 1.2.0
194
152
  - !ruby/object:Gem::Dependency
195
153
  name: standard
196
154
  requirement: !ruby/object:Gem::Requirement