active_version 1.4.0 → 2.1.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: 89fbcf4b5aebb3bd2fcbbedfafb3f6575edd6f22571080ce310005f3e6f27a28
4
+ data.tar.gz: 4a90af0cfcee066886ce989f11b8347303db945036ea57cc0a3f6d7b00bcfeee
5
5
  SHA512:
6
- metadata.gz: 158e15ac369fcbe7f730cf6e11ef638f888b5e14fb996be1f555321ddb0b08f11975c96809df18980d66b32402175dd660651791ffd8f3959798cb66b285a882
7
- data.tar.gz: d0b501f02a783beaab992849dd91420980ccb592d2f56b8b7c810f49ff0952c1e61bc60cb8b8a202cc8af269928924f947243b15349606b8ddc0c4a0f33d414c
6
+ metadata.gz: 965f5ffcea61f3d8024d9ba0aaa7a0e3c514b1ce7b48a6f01f89bec05469ab481e82d894e3527a6b35c5f0830fa2cbf8b1947a7936b96e187ad31a228848d333
7
+ data.tar.gz: d8a5bc8d85a017998917086917d3a88c731cdc9bff9cab6ccf4cfe89d78c53d7b2bcd192ee31b4570a8fcbc532c67287e3399ebf89dd172806acd440fb1283b9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.1.0 (2026-10-29)
4
+
5
+ - Record PostgreSQL audit updates as changed-field pairs and support quoted table and column names.
6
+ - Copy source table columns into generated revision tables so custom attributes retain their values.
7
+ - Keep inherited model audits visible through ActiveRecord polymorphic associations.
8
+
9
+ - Fix `without_auditing` and `without_revisions` to use per-fiber/thread depth so other threads keep writing history.
10
+ - Restore only request-scoped context after `with_context` so persistent keys do not shadow a later `with_context!`; clear persistent context via the Railtie executor `to_complete` hook on request end.
11
+ - Honor `with_context(..., transactional: false)` by skipping the PostgreSQL `SET LOCAL` path.
12
+ - Fix `at(time:)` / `revision_at` to return the live state between updates (earliest revision after the time), matching pre-change snapshot semantics.
13
+ - Keep the earliest pre-change revision payload when debounce merges callback-driven updates inside the window.
14
+ - Skip pending-save columns when refreshing default-function attributes during revision snapshots.
15
+ - Map `action` and optional user/address/request fields in `Migrators::Audited`.
16
+ - Skip `redacted` and `encrypted_attributes` when copying shared columns for `:mirror_columns` storage.
17
+ - Validate `audit_error_behavior` and `revision_error_behavior` in `Configuration#validate!`.
18
+ - Require `Rails::Railtie` before loading the railtie; raise `required_ruby_version` to `>= 3.1` to match Active Support 7.2+.
19
+ - Document sharding as host-connection follow-through, not gem-owned routing.
20
+
21
+ ## 2.0.0 (2026-08-21)
22
+
23
+ - BREAKING: Require Active Support and Active Record `>= 7.2.3.2` and `< 9.0`.
24
+ - Retry unique version collisions so a revision snapshot or audit still writes after a concurrent insert, including inside an open PostgreSQL transaction.
25
+ - Add `rfcs/` starter pack: process (RFC 0001), positioning (RFC 0002), and Standards Track design RFCs 0003–0006 for translations, revisions, audits, and generators.
26
+
3
27
  ## 1.4.0 (2026-07-13)
4
28
 
5
29
  - 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,16 +1,18 @@
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
10
12
  - Revisions: Schema-aligned snapshots for workflow management
11
13
  - Audits: `:json_column`, `:yaml_column`, or `:mirror_columns` change tracking
12
14
  - Database Triggers: Optional PostgreSQL triggers for zero-overhead versioning
13
- - Sharding Support: Route version tables to separate databases
15
+ - Sharding: Follows the host app connection; this gem does not route shards
14
16
  - SQL Generation: Batch operations via SQL generation
15
17
  - Configurable: Flexible column naming and per-model configuration
16
18
 
@@ -305,7 +307,7 @@ rails g active_version:triggers Post --type=audit
305
307
  rails g active_version:triggers Post --type=revision
306
308
  ```
307
309
 
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.
310
+ PostgreSQL audit triggers capture complete rows on create and destroy, and changed-field old/new pairs on update. Disable the matching Ruby callbacks with `has_audits auto: false` or `has_revisions auto: false` before installing triggers so each change has one history writer. 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
311
 
310
312
  ### Infrastructure Ownership
311
313
 
@@ -501,8 +503,8 @@ Environment knobs:
501
503
 
502
504
  ## Requirements
503
505
 
504
- - Ruby >= 3.0.0
505
- - ActiveRecord >= 6.0.0 (default runtime)
506
+ - Ruby >= 3.1.0
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,28 +2,24 @@
2
2
 
3
3
  ## Reporting a Vulnerability
4
4
 
5
- Do NOT open a public GitHub issue for security vulnerabilities.
6
-
7
5
  Email security details to: security@kiskolabs.com
8
6
 
9
- Include: description, steps to reproduce, potential impact, and suggested fix (if available).
7
+ Send the report only to that address. Include description, steps to reproduce, potential impact, and a suggested fix when you have one.
10
8
 
11
9
  ### Response Timeline
12
10
 
13
- - We will acknowledge receipt of your report
14
- - We will provide an initial assessment
15
- - We will keep you informed of our progress and resolution timeline
11
+ - We will acknowledge receipt of your report.
12
+ - We will provide an initial assessment.
13
+ - We will keep you informed of our progress and resolution timeline.
16
14
 
17
15
  ### Disclosure Policy
18
16
 
19
- - We will work with you to understand and resolve the issue
20
- - We will credit you for the discovery (unless you prefer to remain anonymous)
21
- - We will publish a security advisory after the vulnerability is patched
22
- - We will coordinate public disclosure with you
17
+ - We will work with you to understand and resolve the issue.
18
+ - We will credit you for the discovery unless you prefer to remain anonymous.
19
+ - We will publish a security advisory after the vulnerability is patched.
20
+ - We will coordinate public disclosure with you.
23
21
 
24
22
  ## Automation Security
25
23
 
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
-
24
+ - Keep production credentials, API keys, and personal data out of prompts sent to third-party models or automation services.
25
+ - Verify automated dependencies.
@@ -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
@@ -30,7 +30,7 @@ module ActiveVersion
30
30
  association(:audits).reset
31
31
  end
32
32
 
33
- auditable_type = audited_options[:class_name] || self.class.name
33
+ auditable_type = audited_options[:class_name] || self.class.polymorphic_name
34
34
  auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable)
35
35
  version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
36
36
  audit_klass = combiner_audit_klass
@@ -116,7 +116,7 @@ module ActiveVersion
116
116
  insert_attrs.merge!(identity_map)
117
117
  # Use class_name from options if provided (for dynamically created classes)
118
118
  # Otherwise use the actual class name
119
- auditable_type = audited_options[:class_name] || self.class.name
119
+ auditable_type = audited_options[:class_name] || self.class.polymorphic_name
120
120
  if auditable_type.nil?
121
121
  raise ConfigurationError, "Cannot determine class name for dynamically created class. Please specify class_name option in has_audits (e.g., has_audits as: PostAudit, class_name: 'Post')"
122
122
  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.polymorphic_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
 
@@ -269,7 +273,9 @@ module ActiveVersion
269
273
  ActiveVersion.column_mapper.column_for(self.class, :audits, :version).to_s,
270
274
  ActiveVersion.column_mapper.column_for(self.class, :audits, :action).to_s
271
275
  ]
272
- shared_columns -= excluded
276
+ redacted = Array(audited_options[:redacted]).map(&:to_s)
277
+ encrypted = respond_to?(:encrypted_attributes) ? Array(encrypted_attributes).map(&:to_s) : []
278
+ shared_columns -= (excluded + redacted + encrypted)
273
279
 
274
280
  shared_columns.each do |column_name|
275
281
  next if insert_attrs.key?(column_name)
@@ -100,18 +100,21 @@ module ActiveVersion
100
100
  audited_options.present?
101
101
  end
102
102
 
103
- # Disable auditing for a block
103
+ # Disable auditing for this fiber/thread only (does not silence other threads)
104
104
  def without_auditing
105
- auditing_was_enabled = class_auditing_enabled?
106
- disable_auditing
105
+ key = auditing_disabled_depth_store_key
106
+ depth = ActiveVersion.store_get(key).to_i + 1
107
+ ActiveVersion.store_set(key, depth)
107
108
  yield
108
109
  ensure
109
- enable_auditing if auditing_was_enabled
110
+ depth = ActiveVersion.store_get(key).to_i - 1
111
+ depth = 0 if depth.negative?
112
+ ActiveVersion.store_set(key, depth)
110
113
  end
111
114
 
112
- # Enable auditing for a block
115
+ # Enable auditing for a block even when class auditing was disabled permanently
113
116
  def with_auditing
114
- auditing_was_enabled = class_auditing_enabled?
117
+ auditing_was_enabled = class_auditing_enabled_without_skip?
115
118
  enable_auditing
116
119
  yield
117
120
  ensure
@@ -506,10 +509,22 @@ module ActiveVersion
506
509
  end
507
510
 
508
511
  def class_auditing_enabled?
509
- @class_auditing_enabled != false
512
+ return false if ActiveVersion.store_get(auditing_disabled_depth_store_key).to_i.positive?
513
+
514
+ class_auditing_enabled_without_skip?
510
515
  end
511
516
  public :class_auditing_enabled?
512
517
 
518
+ def class_auditing_enabled_without_skip?
519
+ @class_auditing_enabled != false
520
+ end
521
+ private :class_auditing_enabled_without_skip?
522
+
523
+ def auditing_disabled_depth_store_key
524
+ :"active_version_#{name}_auditing_disabled_depth"
525
+ end
526
+ private :auditing_disabled_depth_store_key
527
+
513
528
  def disable_auditing
514
529
  @class_auditing_enabled = false
515
530
  end
@@ -808,7 +823,7 @@ module ActiveVersion
808
823
  # Use active_audits for filtered results
809
824
  def audits
810
825
  # Use class_name from options if provided (for dynamically created classes)
811
- auditable_type = audited_options[:class_name] || self.class.name
826
+ auditable_type = audited_options[:class_name] || self.class.polymorphic_name
812
827
  if auditable_type.nil?
813
828
  raise ConfigurationError, "Cannot determine class name for dynamically created class. Please specify class_name option in has_audits (e.g., has_audits as: PostAudit, class_name: 'Post')"
814
829
  end
@@ -169,7 +169,7 @@ module ActiveVersion
169
169
  end
170
170
  return nil if identity_map.values.any?(&:nil?)
171
171
  attrs.merge!(identity_map)
172
- attrs["#{auditable_column}_type"] = record.class.name
172
+ attrs["#{auditable_column}_type"] = record.class.polymorphic_name
173
173
 
174
174
  # Set version
175
175
  version_column = ActiveVersion.column_mapper.column_for(record.class, :audits, :version)
@@ -78,10 +78,13 @@ module ActiveVersion
78
78
  @debounce_time = nil # Time in seconds to merge revisions within window
79
79
  end
80
80
 
81
+ ERROR_BEHAVIORS = %i[exception log silent].freeze
82
+
81
83
  # Validate configuration
82
84
  def validate!
83
85
  validate_storage_type!
84
86
  validate_execution_scope!
87
+ validate_error_behaviors!
85
88
  validate_column_names!
86
89
  end
87
90
 
@@ -101,6 +104,18 @@ module ActiveVersion
101
104
  "execution_scope must be :fiber or :thread, got: #{@execution_scope.inspect}"
102
105
  end
103
106
 
107
+ def validate_error_behaviors!
108
+ unless ERROR_BEHAVIORS.include?(@audit_error_behavior)
109
+ raise ConfigurationError,
110
+ "audit_error_behavior must be one of #{ERROR_BEHAVIORS.inspect}, got: #{@audit_error_behavior.inspect}"
111
+ end
112
+
113
+ unless ERROR_BEHAVIORS.include?(@revision_error_behavior)
114
+ raise ConfigurationError,
115
+ "revision_error_behavior must be one of #{ERROR_BEHAVIORS.inspect}, got: #{@revision_error_behavior.inspect}"
116
+ end
117
+ end
118
+
104
119
  def validate_column_names!
105
120
  # Ensure column names are symbols or strings
106
121
  column_attrs = [
@@ -10,12 +10,14 @@ module ActiveVersion
10
10
  # @param options [Hash] Trigger options
11
11
  # @return [String] SQL for trigger function
12
12
  def generate_audit_trigger_function(table_name, audit_table_name, options = {})
13
- function_name = "active_version_audit_#{table_name}"
14
- auditable_type = options[:auditable_type] || table_name.classify
15
- version_column = options[:version_column] || "version"
16
- changes_column = options[:changes_column] || "audited_changes"
17
- context_column = options[:context_column] || "audited_context"
18
- action_column = options[:action_column] || "action"
13
+ function_name = quote_identifier("active_version_audit_#{table_name.tr(".", "_")}")
14
+ auditable_type = (options[:auditable_type] || table_name.classify).gsub("'", "''")
15
+ version_column = quote_identifier(options[:version_column] || "version")
16
+ changes_column = quote_identifier(options[:changes_column] || "audited_changes")
17
+ context_column = quote_identifier(options[:context_column] || "audited_context")
18
+ action_column = quote_identifier(options[:action_column] || "action")
19
+
20
+ audit_table_name = quote_table(audit_table_name)
19
21
 
20
22
  <<~SQL
21
23
  CREATE OR REPLACE FUNCTION #{function_name}()
@@ -32,10 +34,13 @@ module ActiveVersion
32
34
  changes := to_jsonb(NEW.*);
33
35
  ELSIF TG_OP = 'UPDATE' THEN
34
36
  action_type := 'update';
35
- changes := jsonb_build_object(
36
- 'old', to_jsonb(OLD.*),
37
- 'new', to_jsonb(NEW.*)
38
- );
37
+ SELECT COALESCE(jsonb_object_agg(item.key, jsonb_build_array(to_jsonb(OLD.*) -> item.key, item.value)), '{}'::jsonb)
38
+ INTO changes
39
+ FROM jsonb_each(to_jsonb(NEW.*)) AS item
40
+ WHERE item.value IS DISTINCT FROM to_jsonb(OLD.*) -> item.key;
41
+ IF changes = '{}'::jsonb THEN
42
+ RETURN NEW;
43
+ END IF;
39
44
  ELSIF TG_OP = 'DELETE' THEN
40
45
  action_type := 'destroy';
41
46
  changes := to_jsonb(OLD.*);
@@ -54,7 +59,7 @@ module ActiveVersion
54
59
 
55
60
  -- Get context from session variables (if set)
56
61
  context_data := COALESCE(
57
- current_setting('active_version.context', true)::jsonb,
62
+ NULLIF(current_setting('active_version.context', true), '')::jsonb,
58
63
  '{}'::jsonb
59
64
  );
60
65
 
@@ -112,15 +117,19 @@ module ActiveVersion
112
117
  # @param options [Hash] Trigger options
113
118
  # @return [String] SQL for CREATE TRIGGER statement
114
119
  def generate_audit_trigger(table_name, options = {})
115
- function_name = "active_version_audit_#{table_name}"
116
- trigger_name = options[:trigger_name] || "active_version_audit_on_#{table_name}"
120
+ function_name = quote_identifier("active_version_audit_#{table_name.tr(".", "_")}")
121
+ trigger_name = quote_identifier(options[:trigger_name] || "active_version_audit_on_#{table_name.tr(".", "_")}")
117
122
  events = options[:events] || [:insert, :update, :delete]
118
123
 
119
- event_clause = events.map(&:upcase).join(" OR ")
124
+ event_names = events.map { |event| event.to_s.upcase }
125
+ unless event_names.any? && (event_names - %w[INSERT UPDATE DELETE]).empty?
126
+ raise ArgumentError, "audit trigger events must be insert, update, or delete"
127
+ end
128
+ event_clause = event_names.join(" OR ")
120
129
 
121
130
  <<~SQL
122
131
  CREATE TRIGGER #{trigger_name}
123
- AFTER #{event_clause} ON #{table_name}
132
+ AFTER #{event_clause} ON #{quote_table(table_name)}
124
133
  FOR EACH ROW
125
134
  WHEN (coalesce(current_setting('active_version.disabled', true), '') <> 'on')
126
135
  EXECUTE FUNCTION #{function_name}();
@@ -133,9 +142,10 @@ module ActiveVersion
133
142
  # @param options [Hash] Trigger options
134
143
  # @return [String] SQL for trigger function
135
144
  def generate_revision_trigger_function(table_name, revision_table_name, options = {})
136
- function_name = "active_version_revision_#{table_name}"
137
- foreign_key = options[:foreign_key] || "#{table_name.singularize}_id"
138
- version_column = options[:version_column] || "version"
145
+ function_name = quote_identifier("active_version_revision_#{table_name.tr(".", "_")}")
146
+ foreign_key = quote_identifier(options[:foreign_key] || "#{table_name.singularize}_id")
147
+ revision_table_name = quote_table(revision_table_name)
148
+ version_column = quote_identifier(options[:version_column] || "version")
139
149
 
140
150
  <<~SQL
141
151
  CREATE OR REPLACE FUNCTION #{function_name}()
@@ -186,12 +196,12 @@ module ActiveVersion
186
196
  # @param options [Hash] Trigger options
187
197
  # @return [String] SQL for CREATE TRIGGER statement
188
198
  def generate_revision_trigger(table_name, options = {})
189
- function_name = "active_version_revision_#{table_name}"
190
- trigger_name = options[:trigger_name] || "active_version_revision_on_#{table_name}"
199
+ function_name = quote_identifier("active_version_revision_#{table_name.tr(".", "_")}")
200
+ trigger_name = quote_identifier(options[:trigger_name] || "active_version_revision_on_#{table_name.tr(".", "_")}")
191
201
 
192
202
  <<~SQL
193
203
  CREATE TRIGGER #{trigger_name}
194
- BEFORE UPDATE ON #{table_name}
204
+ BEFORE UPDATE ON #{quote_table(table_name)}
195
205
  FOR EACH ROW
196
206
  WHEN (coalesce(current_setting('active_version.disabled', true), '') <> 'on')
197
207
  EXECUTE FUNCTION #{function_name}();
@@ -202,7 +212,7 @@ module ActiveVersion
202
212
  # @param function_name [String] Name of the function
203
213
  # @return [String] SQL for DROP FUNCTION statement
204
214
  def drop_trigger_function(function_name)
205
- "DROP FUNCTION IF EXISTS #{function_name}() CASCADE;"
215
+ "DROP FUNCTION IF EXISTS #{quote_identifier(function_name)}() CASCADE;"
206
216
  end
207
217
 
208
218
  # Drop trigger
@@ -210,19 +220,27 @@ module ActiveVersion
210
220
  # @param table_name [String] Name of the table
211
221
  # @return [String] SQL for DROP TRIGGER statement
212
222
  def drop_trigger(trigger_name, table_name)
213
- "DROP TRIGGER IF EXISTS #{trigger_name} ON #{table_name} CASCADE;"
223
+ "DROP TRIGGER IF EXISTS #{quote_identifier(trigger_name)} ON #{quote_table(table_name)} CASCADE;"
214
224
  end
215
225
 
216
226
  private
217
227
 
228
+ def quote_identifier(name)
229
+ %("#{name.to_s.gsub('"', '""')}")
230
+ end
231
+
232
+ def quote_table(name)
233
+ name.to_s.split(".").map { |part| quote_identifier(part) }.join(".")
234
+ end
235
+
218
236
  def build_revision_columns(table_name, options)
219
237
  columns = normalize_revision_columns(options)
220
- columns.join(", ")
238
+ columns.map { |column| quote_identifier(column) }.join(", ")
221
239
  end
222
240
 
223
241
  def build_revision_values(record_prefix, table_name, options)
224
242
  columns = normalize_revision_columns(options)
225
- columns.map { |column| "#{record_prefix}.#{column}" }.join(", ")
243
+ columns.map { |column| "#{record_prefix}.#{quote_identifier(column)}" }.join(", ")
226
244
  end
227
245
 
228
246
  def normalize_revision_columns(options)
@@ -66,8 +66,9 @@ module ActiveVersion
66
66
  changes_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :changes).to_s
67
67
  context_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :context).to_s
68
68
  comment_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :comment).to_s
69
+ action_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :action).to_s
69
70
 
70
- {
71
+ data = {
71
72
  "#{auditable_column}_id" => old_audit.auditable_id,
72
73
  "#{auditable_column}_type" => old_audit.auditable_type,
73
74
  version_column => old_audit.version,
@@ -77,6 +78,22 @@ module ActiveVersion
77
78
  :created_at => old_audit.created_at,
78
79
  :updated_at => old_audit.updated_at || old_audit.created_at
79
80
  }
81
+
82
+ copy_optional_audit_field(data, old_audit, :action, action_column)
83
+ copy_optional_audit_field(data, old_audit, :user_id, ActiveVersion.config.audit_user_column)
84
+ copy_optional_audit_field(data, old_audit, :user_type, :user_type)
85
+ copy_optional_audit_field(data, old_audit, :remote_address, ActiveVersion.config.audit_remote_address_column)
86
+ copy_optional_audit_field(data, old_audit, :request_uuid, ActiveVersion.config.audit_request_uuid_column)
87
+ copy_optional_audit_field(data, old_audit, :associated_id, :"#{ActiveVersion.config.audit_associated_column}_id")
88
+ copy_optional_audit_field(data, old_audit, :associated_type, :"#{ActiveVersion.config.audit_associated_column}_type")
89
+
90
+ data
91
+ end
92
+
93
+ def copy_optional_audit_field(data, old_audit, source_method, destination_column)
94
+ return unless old_audit.respond_to?(source_method)
95
+
96
+ data[destination_column.to_s] = old_audit.public_send(source_method)
80
97
  end
81
98
  end
82
99
  end
@@ -9,6 +9,14 @@ module ActiveVersion
9
9
  # Expose configuration in Rails config
10
10
  config.active_version = ActiveVersion.config
11
11
 
12
+ # Drop fiber/thread persistent context when the request or job unit ends so a
13
+ # reused Puma/Sidekiq thread does not stamp the previous actor onto later audits.
14
+ initializer "active_version.clear_context_on_complete" do |app|
15
+ app.executor.to_complete do
16
+ ActiveVersion.clear_context!
17
+ end
18
+ end
19
+
12
20
  # Add rake tasks
13
21
  rake_tasks do
14
22
  load "active_version/tasks/active_version.rake"
@@ -37,17 +37,28 @@ module ActiveVersion
37
37
  current_version + 1
38
38
  end
39
39
 
40
- # Refresh only columns with default functions (query optimization)
40
+ # Refresh only columns with default functions (query optimization).
41
+ # Never overwrite attributes the caller is about to save.
41
42
  refreshable_columns = refreshable_column_names
42
43
  if refreshable_columns.any?
43
- refreshed = self.class.select(refreshable_columns).find(id)
44
- refreshable_columns.each do |col|
45
- refreshed_value = if refreshed.respond_to?(col)
46
- refreshed.public_send(col)
47
- elsif refreshed.respond_to?(:[])
48
- refreshed[col]
44
+ pending_changes = if respond_to?(:changes_to_save) && changes_to_save.present?
45
+ changes_to_save.keys.map(&:to_s)
46
+ elsif respond_to?(:changed)
47
+ Array(changed).map(&:to_s)
48
+ else
49
+ []
50
+ end
51
+ columns_to_refresh = refreshable_columns - pending_changes
52
+ if columns_to_refresh.any?
53
+ refreshed = self.class.select(columns_to_refresh).find(id)
54
+ columns_to_refresh.each do |col|
55
+ refreshed_value = if refreshed.respond_to?(col)
56
+ refreshed.public_send(col)
57
+ elsif refreshed.respond_to?(:[])
58
+ refreshed[col]
59
+ end
60
+ self[col] = refreshed_value
49
61
  end
50
- self[col] = refreshed_value
51
62
  end
52
63
  end
53
64
 
@@ -370,7 +381,16 @@ module ActiveVersion
370
381
  last_revision ||= revisions_scope.order(version_column => :desc).first
371
382
  return unless last_revision
372
383
 
373
- # Update last revision with current or persisted attributes (filtered by only/except)
384
+ # Callback path stores pre-change state. Keep the earliest pre-state and
385
+ # only extend the debounce window; overwriting would drop history (A -> B
386
+ # then B -> C within the window must leave A, not replace it with B).
387
+ if use_old_values
388
+ last_revision.class.where(id: last_revision.id).update_all(updated_at: timestamp)
389
+ revisions.reset
390
+ return
391
+ end
392
+
393
+ # Update last revision with current attributes (filtered by only/except)
374
394
  base_attrs = snapshot_base_attributes(use_old_values)
375
395
 
376
396
  snapshot_attrs = if only_attrs
@@ -421,16 +441,20 @@ module ActiveVersion
421
441
  revisions.reset
422
442
  end
423
443
 
424
- # One retry on unique (version) violation, matching audit_writer behavior when a DB unique index exists.
444
+ # One retry on unique (version) collision. Each insert uses a savepoint so
445
+ # PostgreSQL can continue the outer update/lock transaction after a unique violation.
425
446
  def create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
426
447
  attempt = 0
427
448
  begin
428
449
  attempt += 1
429
- revisions.create!(revision_attrs)
430
- rescue ActiveRecord::RecordNotUnique => error
450
+ self.class.transaction(requires_new: true) do
451
+ revisions.create!(revision_attrs)
452
+ end
453
+ rescue => error
454
+ raise error unless ActiveVersion::UniqueVersionCollision.match?(error, version_column: version_column_sym)
431
455
  raise error if attempt >= 2
432
456
 
433
- max_version = revisions_scope.maximum(version_column_sym) || 0
457
+ max_version = revisions_scope.uncached { revisions_scope.maximum(version_column_sym) } || 0
434
458
  revision_attrs[version_column_sym] = max_version + 1
435
459
  retry
436
460
  end
@@ -13,7 +13,9 @@ module ActiveVersion
13
13
  at_version(version)
14
14
  end
15
15
 
16
- # Get revision at specific time
16
+ # Get revision row that encodes state at specific time.
17
+ # Revision N stores pre-update state and is stamped at that update, so the
18
+ # state after an update is the next revision's payload (or current self).
17
19
  def revision_at(time: nil)
18
20
  return nil unless time
19
21
 
@@ -21,10 +23,7 @@ module ActiveVersion
21
23
  raise ActiveVersion::FutureTimeError, "Future state cannot be known" if time_obj.future?
22
24
 
23
25
  version_column = revision_version_column
24
- revision_entry = revisions_scope.where("created_at <= ?", time_obj).order(version_column => :desc).first
25
- # If requested time is before the first revision, return the earliest revision if present.
26
- revision_entry ||= revisions_scope.order(version_column => :asc).first
27
- revision_entry
26
+ revisions_scope.where("created_at > ?", time_obj).order(version_column => :asc).first
28
27
  end
29
28
 
30
29
  # Return a copy of record at specified time (read-only)
@@ -133,7 +132,9 @@ module ActiveVersion
133
132
 
134
133
  def find_revision_by_time(time)
135
134
  version_column = revision_version_column
136
- revisions_scope.where("created_at <= ?", time).order(version_column => :desc).first
135
+ # Earliest revision after +time+ holds the live state as of +time+
136
+ # (that row's payload is the pre-state of the next change).
137
+ revisions_scope.where("created_at > ?", time).order(version_column => :asc).first
137
138
  end
138
139
 
139
140
  def revisions_scope
@@ -190,27 +190,21 @@ module ActiveVersion
190
190
  end
191
191
  end
192
192
 
193
- # Disable revisions for a block
193
+ # Disable revisions for this fiber/thread only (does not mutate callback chains)
194
194
  def without_revisions
195
- callbacks = if respond_to?(:_update_callbacks)
196
- _update_callbacks.select { |cb| cb.filter == :create_revision_before_update }
197
- else
198
- []
199
- end
200
- callback_installed = callbacks.any?
201
-
202
- skip_callback(:update, :before, :create_revision_before_update) if callback_installed
195
+ key = revision_disabled_depth_store_key
196
+ depth = ActiveVersion.store_get(key).to_i + 1
197
+ ActiveVersion.store_set(key, depth)
203
198
  yield
204
199
  ensure
205
- # Restore callback if it was set up
206
- if callback_installed && revision_options && revision_options[:auto] != false && revision_options[:on].include?(:update)
207
- set_callback(:update, :before, :create_revision_before_update, if: :should_create_revision?)
208
- end
200
+ depth = ActiveVersion.store_get(key).to_i - 1
201
+ depth = 0 if depth.negative?
202
+ ActiveVersion.store_set(key, depth)
209
203
  end
210
204
 
211
- # Enable revisions for a block
205
+ # Enable revisions for a block even when class revisions were disabled permanently
212
206
  def with_revisions
213
- revision_was_enabled = class_revision_enabled?
207
+ revision_was_enabled = class_revision_enabled_without_skip?
214
208
  enable_revisions
215
209
  yield
216
210
  ensure
@@ -218,10 +212,22 @@ module ActiveVersion
218
212
  end
219
213
 
220
214
  def class_revision_enabled?
221
- @class_revision_enabled != false
215
+ return false if ActiveVersion.store_get(revision_disabled_depth_store_key).to_i.positive?
216
+
217
+ class_revision_enabled_without_skip?
222
218
  end
223
219
  public :class_revision_enabled?
224
220
 
221
+ def class_revision_enabled_without_skip?
222
+ @class_revision_enabled != false
223
+ end
224
+ private :class_revision_enabled_without_skip?
225
+
226
+ def revision_disabled_depth_store_key
227
+ :"active_version_#{name}_revision_disabled_depth"
228
+ end
229
+ private :revision_disabled_depth_store_key
230
+
225
231
  private
226
232
 
227
233
  def disable_revisions
@@ -362,17 +368,10 @@ module ActiveVersion
362
368
  return false
363
369
  end
364
370
 
365
- # Check class-level enabled state (default to true if not set)
366
- class_enabled = if self.class.instance_variable_defined?(:@class_revision_enabled)
367
- self.class.instance_variable_get(:@class_revision_enabled)
368
- else
369
- true # Default to enabled
370
- end
371
- return false unless class_enabled != false
371
+ return false unless self.class.class_revision_enabled?
372
372
 
373
- # Check global enabled state
374
- # Note: We use config.auditing_enabled for both audits and revisions
375
- return false unless ActiveVersion.config.auditing_enabled
373
+ # Check global enabled state (respects per-fiber without_auditing depth)
374
+ return false unless ActiveVersion.auditing_enabled
376
375
 
377
376
  # Get revision options (with defaults if not set)
378
377
  options = self.class.revision_options || {auto: true, on: [:update]}
@@ -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.1.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
@@ -68,8 +69,11 @@ module ActiveVersion
68
69
  config
69
70
  end
70
71
 
71
- # Convenience methods for accessing configuration
72
+ # Convenience methods for accessing configuration.
73
+ # +without_auditing+ uses a per-fiber/thread depth so concurrent callers are not silenced.
72
74
  def self.auditing_enabled
75
+ return false if store_get(:active_version_auditing_disabled_depth).to_i.positive?
76
+
73
77
  config.auditing_enabled
74
78
  end
75
79
 
@@ -99,7 +103,10 @@ module ActiveVersion
99
103
  end
100
104
 
101
105
  def self.context
102
- # Merge persistent context with request-scoped context
106
+ # Merge persistent context (fiber/thread store) with request-scoped context.
107
+ # with_context ensure must restore only RequestStore.version_context so
108
+ # persistent keys are not copied into the request store and cannot shadow
109
+ # a later with_context!.
103
110
  persistent = store_get(:active_version_persistent_context) || {}
104
111
  request_scoped = RequestStore.version_context || {}
105
112
  persistent.merge(request_scoped)
@@ -111,7 +118,8 @@ module ActiveVersion
111
118
  end
112
119
 
113
120
  # Transaction-aware context (uses PostgreSQL session variables)
114
- # Accepts either a hash as first argument or keyword arguments
121
+ # Accepts either a hash as first argument or keyword arguments.
122
+ # Pass transactional: false to skip SET LOCAL even when PostgreSQL supports it.
115
123
  def self.with_context(context = nil, transactional: true, **kwargs, &block)
116
124
  raise ArgumentError, "with_context requires a block" unless block_given?
117
125
 
@@ -128,16 +136,15 @@ module ActiveVersion
128
136
  raise ArgumentError, "context must be a hash or keyword arguments"
129
137
  end
130
138
 
131
- if transactional_context_supported?
132
- # Use PostgreSQL session variables for transaction-aware context
139
+ if transactional && transactional_context_supported?
133
140
  with_transactional_context(context_hash, &block)
134
141
  else
135
- # Fallback to thread-local context
136
142
  with_thread_local_context(context_hash, &block)
137
143
  end
138
144
  end
139
145
 
140
- # Persistent context (connection-level, persists across operations)
146
+ # Persistent context for the current fiber/thread (use clear_context! or the
147
+ # Railtie executor hook so a reused server thread does not leak actors).
141
148
  def self.with_context!(context)
142
149
  raise ArgumentError, "context must be a hash" unless context.is_a?(Hash)
143
150
 
@@ -216,7 +223,7 @@ module ActiveVersion
216
223
 
217
224
  def self.with_transactional_context(context, &block)
218
225
  connection = Runtime.adapter.base_connection
219
- old_context = self.context.dup
226
+ old_request_context = RequestStore.version_context
220
227
  old_block_context = store_get(:active_version_block_context)
221
228
  enter_context_block!
222
229
 
@@ -226,15 +233,15 @@ module ActiveVersion
226
233
  connection.execute("SET LOCAL active_version.context = #{encoded_context}")
227
234
  end
228
235
 
229
- # Also update thread-local for immediate access
230
- self.context = old_context.merge(context)
236
+ # Merge into request-scoped store only (not persistent), so ensure restores cleanly
237
+ RequestStore.version_context = (old_request_context || {}).merge(context)
231
238
  store_set(:active_version_block_context, context)
232
239
 
233
240
  yield
234
241
  ensure
235
242
  # Context is automatically cleared on transaction rollback
236
- # But we still restore thread-local context
237
- self.context = old_context
243
+ # Restore request-scoped context only (do not copy persistent keys into RequestStore)
244
+ RequestStore.version_context = old_request_context
238
245
  store_set(:active_version_block_context, old_block_context)
239
246
  leave_context_block!
240
247
  end
@@ -254,25 +261,27 @@ module ActiveVersion
254
261
  end
255
262
 
256
263
  def self.with_thread_local_context(context, &block)
257
- old_context = self.context.dup
264
+ old_request_context = RequestStore.version_context
258
265
  old_block_context = store_get(:active_version_block_context)
259
266
  enter_context_block!
260
- self.context = old_context.merge(context)
267
+ RequestStore.version_context = (old_request_context || {}).merge(context)
261
268
  store_set(:active_version_block_context, context)
262
269
  yield
263
270
  ensure
264
- self.context = old_context
271
+ RequestStore.version_context = old_request_context
265
272
  store_set(:active_version_block_context, old_block_context)
266
273
  leave_context_block!
267
274
  end
268
275
 
269
- # Disable versioning globally
276
+ # Disable auditing for this fiber/thread only (nested-safe depth counter)
270
277
  def self.without_auditing
271
- auditing_was_enabled = auditing_enabled
272
- disable_auditing
278
+ depth = store_get(:active_version_auditing_disabled_depth).to_i + 1
279
+ store_set(:active_version_auditing_disabled_depth, depth)
273
280
  yield
274
281
  ensure
275
- enable_auditing if auditing_was_enabled
282
+ depth = store_get(:active_version_auditing_disabled_depth).to_i - 1
283
+ depth = 0 if depth.negative?
284
+ store_set(:active_version_auditing_disabled_depth, depth)
276
285
  end
277
286
 
278
287
  def self.disable_auditing
@@ -356,6 +365,6 @@ module ActiveVersion
356
365
  end
357
366
 
358
367
  # Load Rails integration if available
359
- if defined?(Rails)
368
+ if defined?(Rails::Railtie)
360
369
  require "active_version/railtie"
361
370
  end
@@ -4,13 +4,12 @@ class Create<%= revision_class_name.pluralize %> < ActiveRecord::Migration<%= mi
4
4
  t.references :<%= file_name %>, null: false, foreign_key: true, index: false
5
5
  t.integer :<%= version_column %>, null: false
6
6
 
7
- # Copy all columns from source table
8
- # Note: This is a simplified version - in production, you'd want to
9
- # inspect the actual source table columns dynamically
10
- # For now, we'll add common columns that can be customized
11
- t.text :title
12
- t.text :body
13
- # Add other columns as needed
7
+ source_primary_key = connection.primary_key(:<%= source_table_name %>)
8
+ connection.columns(:<%= source_table_name %>).each do |column|
9
+ next if [source_primary_key, "created_at", "updated_at"].include?(column.name)
10
+
11
+ t.column column.name, column.sql_type, limit: column.limit, precision: column.precision, scale: column.scale
12
+ end
14
13
 
15
14
  t.timestamps
16
15
  end
@@ -1,5 +1,7 @@
1
1
  module ActiveVersion
2
2
  class Configuration
3
+ ERROR_BEHAVIORS: Array[Symbol]
4
+
3
5
  attr_accessor auditing_enabled: bool
4
6
  attr_accessor current_user_method: Symbol | String
5
7
  attr_accessor ignored_attributes: Array[String]
@@ -13,6 +15,7 @@ module ActiveVersion
13
15
  attr_accessor revision_version_column: Symbol | String
14
16
  attr_accessor revision_foreign_key_suffix: String
15
17
 
18
+ attr_accessor default_audit_class: Class?
16
19
  attr_accessor audit_storage: :json_column | :yaml_column | :mirror_columns
17
20
  attr_accessor audit_action_column: Symbol | String
18
21
  attr_accessor audit_changes_column: Symbol | String
@@ -32,7 +35,6 @@ module ActiveVersion
32
35
  attr_accessor audit_error_behavior: Symbol
33
36
  attr_accessor revision_error_behavior: Symbol
34
37
  attr_accessor debounce_time: Numeric?
35
- attr_accessor partition_schema_guards_enabled: bool
36
38
 
37
39
  def validate!: () -> void
38
40
  end
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.1.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: 2.9.5
100
+ version: 2.9.6
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: 2.9.5
107
+ version: 2.9.6
96
108
  - !ruby/object:Gem::Dependency
97
109
  name: pg
98
110
  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
@@ -416,7 +443,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
416
443
  requirements:
417
444
  - - ">="
418
445
  - !ruby/object:Gem::Version
419
- version: '3.0'
446
+ version: '3.1'
420
447
  required_rubygems_version: !ruby/object:Gem::Requirement
421
448
  requirements:
422
449
  - - ">="