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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +3 -3
- data/SECURITY.md +10 -12
- data/lib/active_version/audits/has_audits/audit_combiner.rb +1 -1
- data/lib/active_version/audits/has_audits/audit_writer.rb +5 -3
- data/lib/active_version/audits/has_audits.rb +23 -8
- data/lib/active_version/audits/sql_builder.rb +1 -1
- data/lib/active_version/configuration.rb +15 -0
- data/lib/active_version/database/triggers/postgresql.rb +43 -25
- data/lib/active_version/migrators/audited.rb +18 -1
- data/lib/active_version/railtie.rb +8 -0
- data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +29 -9
- data/lib/active_version/revisions/has_revisions/revision_queries.rb +7 -6
- data/lib/active_version/revisions/has_revisions.rb +25 -26
- data/lib/active_version/version.rb +1 -1
- data/lib/active_version.rb +28 -20
- data/lib/generators/active_version/revisions/templates/migration.rb.erb +6 -7
- data/sig/active_version/configuration.rbs +3 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89fbcf4b5aebb3bd2fcbbedfafb3f6575edd6f22571080ce310005f3e6f27a28
|
|
4
|
+
data.tar.gz: 4a90af0cfcee066886ce989f11b8347303db945036ea57cc0a3f6d7b00bcfeee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
-
|
|
27
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
103
|
+
# Disable auditing for this fiber/thread only (does not silence other threads)
|
|
104
104
|
def without_auditing
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
193
|
+
# Disable revisions for this fiber/thread only (does not mutate callback chains)
|
|
194
194
|
def without_revisions
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]}
|
data/lib/active_version.rb
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
#
|
|
231
|
-
|
|
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
|
-
#
|
|
238
|
-
|
|
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
|
-
|
|
264
|
+
old_request_context = RequestStore.version_context
|
|
259
265
|
old_block_context = store_get(:active_version_block_context)
|
|
260
266
|
enter_context_block!
|
|
261
|
-
|
|
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
|
-
|
|
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
|
|
276
|
+
# Disable auditing for this fiber/thread only (nested-safe depth counter)
|
|
271
277
|
def self.without_auditing
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
446
|
+
version: '3.1'
|
|
447
447
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
448
448
|
requirements:
|
|
449
449
|
- - ">="
|