active_version 2.0.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: d52287aa8ea68e0c77baff3065a3140d397c2e638460967880b0b0dbc904b42c
4
- data.tar.gz: aff296230f04d04d90bafe26e12fe0876fc2cd292cd07e6083d71a595a9ea6f6
3
+ metadata.gz: 89fbcf4b5aebb3bd2fcbbedfafb3f6575edd6f22571080ce310005f3e6f27a28
4
+ data.tar.gz: 4a90af0cfcee066886ce989f11b8347303db945036ea57cc0a3f6d7b00bcfeee
5
5
  SHA512:
6
- metadata.gz: 35c7b1c96056c61e5c36a72166d2be067504da0fa922fd439ad4a6b1a09a7630ae80734fb2ad8e97dfb81fd3302c75ed168cb92adb22669f222e5cd6aa363a90
7
- data.tar.gz: db6759859ffd1139a818c45a23ae486e819e035553b32762f586465cbef3a2597507a80ea424036ecc0718fe6a9dbf25cb11fe5c8e4e6c7556d27e124e13704f
6
+ metadata.gz: 965f5ffcea61f3d8024d9ba0aaa7a0e3c514b1ce7b48a6f01f89bec05469ab481e82d894e3527a6b35c5f0830fa2cbf8b1947a7936b96e187ad31a228848d333
7
+ data.tar.gz: d8a5bc8d85a017998917086917d3a88c731cdc9bff9cab6ccf4cfe89d78c53d7b2bcd192ee31b4570a8fcbc532c67287e3399ebf89dd172806acd440fb1283b9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
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
+
3
21
  ## 2.0.0 (2026-08-21)
4
22
 
5
23
  - BREAKING: Require Active Support and Active Record `>= 7.2.3.2` and `< 9.0`.
data/README.md CHANGED
@@ -12,7 +12,7 @@ Design changes go through `rfcs/` (RFC 0001).
12
12
  - Revisions: Schema-aligned snapshots for workflow management
13
13
  - Audits: `:json_column`, `:yaml_column`, or `:mirror_columns` change tracking
14
14
  - Database Triggers: Optional PostgreSQL triggers for zero-overhead versioning
15
- - Sharding Support: Route version tables to separate databases
15
+ - Sharding: Follows the host app connection; this gem does not route shards
16
16
  - SQL Generation: Batch operations via SQL generation
17
17
  - Configurable: Flexible column naming and per-model configuration
18
18
 
@@ -307,7 +307,7 @@ rails g active_version:triggers Post --type=audit
307
307
  rails g active_version:triggers Post --type=revision
308
308
  ```
309
309
 
310
- PostgreSQL audit triggers capture the full row as JSON. They do not apply Ruby-side `only`, `except`, or `redacted` filters from `has_audits`. Use triggers only when you want complete row snapshots at the database layer, or generate custom trigger SQL that matches your column policy.
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.
311
311
 
312
312
  ### Infrastructure Ownership
313
313
 
@@ -503,7 +503,7 @@ Environment knobs:
503
503
 
504
504
  ## Requirements
505
505
 
506
- - Ruby >= 3.0.0
506
+ - Ruby >= 3.1.0
507
507
  - ActiveRecord >= 7.2.3.2 and < 9.0 (default runtime)
508
508
  - SQLite or MySQL or PostgreSQL (optional, for triggers and JSONB support)
509
509
 
data/SECURITY.md CHANGED
@@ -2,26 +2,24 @@
2
2
 
3
3
  ## Reporting a Vulnerability
4
4
 
5
- Do not open a public 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
- - Supply Chain: All automated dependencies must be verified.
24
+ - Keep production credentials, API keys, and personal data out of prompts sent to third-party models or automation services.
25
+ - Verify automated dependencies.
@@ -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
@@ -216,7 +216,7 @@ module ActiveVersion
216
216
  return nil
217
217
  end
218
218
 
219
- auditable_type_for_query = audited_options[:class_name] || self.class.name
219
+ auditable_type_for_query = audited_options[:class_name] || self.class.polymorphic_name
220
220
  if auditable_type_for_query.nil?
221
221
  raise ConfigurationError, "Cannot determine class name for dynamically created class. Please specify class_name option in has_audits"
222
222
  end
@@ -273,7 +273,9 @@ module ActiveVersion
273
273
  ActiveVersion.column_mapper.column_for(self.class, :audits, :version).to_s,
274
274
  ActiveVersion.column_mapper.column_for(self.class, :audits, :action).to_s
275
275
  ]
276
- 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)
277
279
 
278
280
  shared_columns.each do |column_name|
279
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
@@ -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]}
@@ -1,3 +1,3 @@
1
1
  module ActiveVersion
2
- VERSION = "2.0.0"
2
+ VERSION = "2.1.0"
3
3
  end
@@ -69,8 +69,11 @@ module ActiveVersion
69
69
  config
70
70
  end
71
71
 
72
- # 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.
73
74
  def self.auditing_enabled
75
+ return false if store_get(:active_version_auditing_disabled_depth).to_i.positive?
76
+
74
77
  config.auditing_enabled
75
78
  end
76
79
 
@@ -100,7 +103,10 @@ module ActiveVersion
100
103
  end
101
104
 
102
105
  def self.context
103
- # 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!.
104
110
  persistent = store_get(:active_version_persistent_context) || {}
105
111
  request_scoped = RequestStore.version_context || {}
106
112
  persistent.merge(request_scoped)
@@ -112,7 +118,8 @@ module ActiveVersion
112
118
  end
113
119
 
114
120
  # Transaction-aware context (uses PostgreSQL session variables)
115
- # 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.
116
123
  def self.with_context(context = nil, transactional: true, **kwargs, &block)
117
124
  raise ArgumentError, "with_context requires a block" unless block_given?
118
125
 
@@ -129,16 +136,15 @@ module ActiveVersion
129
136
  raise ArgumentError, "context must be a hash or keyword arguments"
130
137
  end
131
138
 
132
- if transactional_context_supported?
133
- # Use PostgreSQL session variables for transaction-aware context
139
+ if transactional && transactional_context_supported?
134
140
  with_transactional_context(context_hash, &block)
135
141
  else
136
- # Fallback to thread-local context
137
142
  with_thread_local_context(context_hash, &block)
138
143
  end
139
144
  end
140
145
 
141
- # 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).
142
148
  def self.with_context!(context)
143
149
  raise ArgumentError, "context must be a hash" unless context.is_a?(Hash)
144
150
 
@@ -217,7 +223,7 @@ module ActiveVersion
217
223
 
218
224
  def self.with_transactional_context(context, &block)
219
225
  connection = Runtime.adapter.base_connection
220
- old_context = self.context.dup
226
+ old_request_context = RequestStore.version_context
221
227
  old_block_context = store_get(:active_version_block_context)
222
228
  enter_context_block!
223
229
 
@@ -227,15 +233,15 @@ module ActiveVersion
227
233
  connection.execute("SET LOCAL active_version.context = #{encoded_context}")
228
234
  end
229
235
 
230
- # Also update thread-local for immediate access
231
- 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)
232
238
  store_set(:active_version_block_context, context)
233
239
 
234
240
  yield
235
241
  ensure
236
242
  # Context is automatically cleared on transaction rollback
237
- # But we still restore thread-local context
238
- self.context = old_context
243
+ # Restore request-scoped context only (do not copy persistent keys into RequestStore)
244
+ RequestStore.version_context = old_request_context
239
245
  store_set(:active_version_block_context, old_block_context)
240
246
  leave_context_block!
241
247
  end
@@ -255,25 +261,27 @@ module ActiveVersion
255
261
  end
256
262
 
257
263
  def self.with_thread_local_context(context, &block)
258
- old_context = self.context.dup
264
+ old_request_context = RequestStore.version_context
259
265
  old_block_context = store_get(:active_version_block_context)
260
266
  enter_context_block!
261
- self.context = old_context.merge(context)
267
+ RequestStore.version_context = (old_request_context || {}).merge(context)
262
268
  store_set(:active_version_block_context, context)
263
269
  yield
264
270
  ensure
265
- self.context = old_context
271
+ RequestStore.version_context = old_request_context
266
272
  store_set(:active_version_block_context, old_block_context)
267
273
  leave_context_block!
268
274
  end
269
275
 
270
- # Disable versioning globally
276
+ # Disable auditing for this fiber/thread only (nested-safe depth counter)
271
277
  def self.without_auditing
272
- auditing_was_enabled = auditing_enabled
273
- disable_auditing
278
+ depth = store_get(:active_version_auditing_disabled_depth).to_i + 1
279
+ store_set(:active_version_auditing_disabled_depth, depth)
274
280
  yield
275
281
  ensure
276
- 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)
277
285
  end
278
286
 
279
287
  def self.disable_auditing
@@ -357,6 +365,6 @@ module ActiveVersion
357
365
  end
358
366
 
359
367
  # Load Rails integration if available
360
- if defined?(Rails)
368
+ if defined?(Rails::Railtie)
361
369
  require "active_version/railtie"
362
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: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -97,14 +97,14 @@ dependencies:
97
97
  requirements:
98
98
  - - ">="
99
99
  - !ruby/object:Gem::Version
100
- version: 2.9.5
100
+ version: 2.9.6
101
101
  type: :development
102
102
  prerelease: false
103
103
  version_requirements: !ruby/object:Gem::Requirement
104
104
  requirements:
105
105
  - - ">="
106
106
  - !ruby/object:Gem::Version
107
- version: 2.9.5
107
+ version: 2.9.6
108
108
  - !ruby/object:Gem::Dependency
109
109
  name: pg
110
110
  requirement: !ruby/object:Gem::Requirement
@@ -443,7 +443,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
443
443
  requirements:
444
444
  - - ">="
445
445
  - !ruby/object:Gem::Version
446
- version: '3.0'
446
+ version: '3.1'
447
447
  required_rubygems_version: !ruby/object:Gem::Requirement
448
448
  requirements:
449
449
  - - ">="