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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +20 -5
- data/SECURITY.md +10 -14
- data/lib/active_version/adapters/active_record/audits.rb +2 -2
- data/lib/active_version/adapters/active_record/revisions.rb +1 -1
- data/lib/active_version/adapters/active_record/translations.rb +1 -1
- data/lib/active_version/audits/has_audits/audit_combiner.rb +1 -1
- data/lib/active_version/audits/has_audits/audit_writer.rb +43 -37
- 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 +37 -13
- 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/unique_version_collision.rb +38 -0
- data/lib/active_version/version.rb +1 -1
- data/lib/active_version.rb +29 -20
- data/lib/generators/active_version/revisions/templates/migration.rb.erb +6 -7
- data/sig/active_version/configuration.rbs +3 -1
- metadata +37 -10
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,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
|
-
[](https://badge.fury.io/rb/active_version) [](https://github.com/amkisko/active_version.rb/actions/workflows/test.yml) [](https://codecov.io/
|
|
3
|
+
[](https://badge.fury.io/rb/active_version) [](https://github.com/amkisko/active_version.rb/actions/workflows/test.yml) [](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
|
|
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
|
|
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.
|
|
505
|
-
- ActiveRecord >=
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -187,42 +187,46 @@ module ActiveVersion
|
|
|
187
187
|
return nil
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -421,16 +441,20 @@ module ActiveVersion
|
|
|
421
441
|
revisions.reset
|
|
422
442
|
end
|
|
423
443
|
|
|
424
|
-
# One retry on unique (version)
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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]}
|
|
@@ -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
|
data/lib/active_version.rb
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
#
|
|
230
|
-
|
|
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
|
-
#
|
|
237
|
-
|
|
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
|
-
|
|
264
|
+
old_request_context = RequestStore.version_context
|
|
258
265
|
old_block_context = store_get(:active_version_block_context)
|
|
259
266
|
enter_context_block!
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
276
|
+
# Disable auditing for this fiber/thread only (nested-safe depth counter)
|
|
270
277
|
def self.without_auditing
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: 1.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
446
|
+
version: '3.1'
|
|
420
447
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
421
448
|
requirements:
|
|
422
449
|
- - ">="
|