active_version 1.4.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: '08398a92e326503be43e704b7681562a6c3c4d220bd7cf934e295ff3afada135'
4
- data.tar.gz: a3678d669239918343732b02b8ff8285b32e549bb44ae6eae1cffde75abf0ae1
3
+ metadata.gz: d52287aa8ea68e0c77baff3065a3140d397c2e638460967880b0b0dbc904b42c
4
+ data.tar.gz: aff296230f04d04d90bafe26e12fe0876fc2cd292cd07e6083d71a595a9ea6f6
5
5
  SHA512:
6
- metadata.gz: 158e15ac369fcbe7f730cf6e11ef638f888b5e14fb996be1f555321ddb0b08f11975c96809df18980d66b32402175dd660651791ffd8f3959798cb66b285a882
7
- data.tar.gz: d0b501f02a783beaab992849dd91420980ccb592d2f56b8b7c810f49ff0952c1e61bc60cb8b8a202cc8af269928924f947243b15349606b8ddc0c4a0f33d414c
6
+ metadata.gz: 35c7b1c96056c61e5c36a72166d2be067504da0fa922fd439ad4a6b1a09a7630ae80734fb2ad8e97dfb81fd3302c75ed168cb92adb22669f222e5cd6aa363a90
7
+ data.tar.gz: db6759859ffd1139a818c45a23ae486e819e035553b32762f586465cbef3a2597507a80ea424036ecc0718fe6a9dbf25cb11fe5c8e4e6c7556d27e124e13704f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 1.4.0 (2026-07-13)
4
10
 
5
11
  - Add `revision_error_behavior` configuration (`:exception`, `:log`, `:silent`) and per-model `has_revisions error_behavior:` override for revision snapshot failures.
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
@@ -502,7 +504,7 @@ Environment knobs:
502
504
  ## Requirements
503
505
 
504
506
  - Ruby >= 3.0.0
505
- - ActiveRecord >= 6.0.0 (default runtime)
507
+ - ActiveRecord >= 7.2.3.2 and < 9.0 (default runtime)
506
508
  - SQLite or MySQL or PostgreSQL (optional, for triggers and JSONB support)
507
509
 
508
510
  ## Contributing
@@ -511,6 +513,19 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/amkisk
511
513
 
512
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).
513
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)
528
+
514
529
  ## License
515
530
 
516
531
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
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
@@ -187,42 +187,46 @@ 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
 
@@ -421,16 +421,20 @@ module ActiveVersion
421
421
  revisions.reset
422
422
  end
423
423
 
424
- # 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.
425
426
  def create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
426
427
  attempt = 0
427
428
  begin
428
429
  attempt += 1
429
- revisions.create!(revision_attrs)
430
- rescue ActiveRecord::RecordNotUnique => error
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)
431
435
  raise error if attempt >= 2
432
436
 
433
- max_version = revisions_scope.maximum(version_column_sym) || 0
437
+ max_version = revisions_scope.uncached { revisions_scope.maximum(version_column_sym) } || 0
434
438
  revision_attrs[version_column_sym] = max_version + 1
435
439
  retry
436
440
  end
@@ -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.4.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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_version
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.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
@@ -253,14 +265,14 @@ dependencies:
253
265
  requirements:
254
266
  - - "~>"
255
267
  - !ruby/object:Gem::Version
256
- version: '3.8'
268
+ version: '3.9'
257
269
  type: :development
258
270
  prerelease: false
259
271
  version_requirements: !ruby/object:Gem::Requirement
260
272
  requirements:
261
273
  - - "~>"
262
274
  - !ruby/object:Gem::Version
263
- version: '3.8'
275
+ version: '3.9'
264
276
  - !ruby/object:Gem::Dependency
265
277
  name: rubocop-thread_safety
266
278
  requirement: !ruby/object:Gem::Requirement
@@ -317,6 +329,20 @@ dependencies:
317
329
  - - "~>"
318
330
  - !ruby/object:Gem::Version
319
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'
320
346
  description: 'ActiveVersion is a language-agnostic versioning library that unifies
321
347
  three core use cases: translations (locale-based), revisions/drafts (workflow snapshots),
322
348
  and audits (change tracking).'
@@ -373,6 +399,7 @@ files:
373
399
  - lib/active_version/translations.rb
374
400
  - lib/active_version/translations/has_translations.rb
375
401
  - lib/active_version/translations/translation_record.rb
402
+ - lib/active_version/unique_version_collision.rb
376
403
  - lib/active_version/version.rb
377
404
  - lib/active_version/version_registry.rb
378
405
  - lib/generators/active_version/audits/audits_generator.rb