active_version 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +492 -0
  5. data/SECURITY.md +29 -0
  6. data/lib/active_version/adapters/active_record/audits.rb +36 -0
  7. data/lib/active_version/adapters/active_record/base.rb +37 -0
  8. data/lib/active_version/adapters/active_record/revisions.rb +49 -0
  9. data/lib/active_version/adapters/active_record/translations.rb +45 -0
  10. data/lib/active_version/adapters/active_record.rb +10 -0
  11. data/lib/active_version/adapters/sequel/versioning.rb +282 -0
  12. data/lib/active_version/adapters/sequel.rb +9 -0
  13. data/lib/active_version/adapters.rb +5 -0
  14. data/lib/active_version/audits/audit_record/callbacks.rb +180 -0
  15. data/lib/active_version/audits/audit_record/serializers.rb +49 -0
  16. data/lib/active_version/audits/audit_record.rb +522 -0
  17. data/lib/active_version/audits/has_audits/audit_callbacks.rb +46 -0
  18. data/lib/active_version/audits/has_audits/audit_combiner.rb +212 -0
  19. data/lib/active_version/audits/has_audits/audit_writer.rb +282 -0
  20. data/lib/active_version/audits/has_audits/change_filters.rb +114 -0
  21. data/lib/active_version/audits/has_audits/database_adapter_helper.rb +86 -0
  22. data/lib/active_version/audits/has_audits.rb +891 -0
  23. data/lib/active_version/audits/sql_builder.rb +263 -0
  24. data/lib/active_version/audits.rb +10 -0
  25. data/lib/active_version/column_mapper.rb +92 -0
  26. data/lib/active_version/configuration.rb +124 -0
  27. data/lib/active_version/database/triggers/postgresql.rb +243 -0
  28. data/lib/active_version/database.rb +7 -0
  29. data/lib/active_version/instrumentation.rb +226 -0
  30. data/lib/active_version/migrators/audited.rb +84 -0
  31. data/lib/active_version/migrators/base.rb +191 -0
  32. data/lib/active_version/migrators.rb +8 -0
  33. data/lib/active_version/query.rb +105 -0
  34. data/lib/active_version/railtie.rb +17 -0
  35. data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +499 -0
  36. data/lib/active_version/revisions/has_revisions/revision_queries.rb +182 -0
  37. data/lib/active_version/revisions/has_revisions.rb +443 -0
  38. data/lib/active_version/revisions/revision_record.rb +287 -0
  39. data/lib/active_version/revisions/sql_builder.rb +266 -0
  40. data/lib/active_version/revisions.rb +10 -0
  41. data/lib/active_version/runtime.rb +148 -0
  42. data/lib/active_version/sharding/connection_router.rb +20 -0
  43. data/lib/active_version/sharding.rb +7 -0
  44. data/lib/active_version/tasks/active_version.rake +29 -0
  45. data/lib/active_version/translations/has_translations.rb +350 -0
  46. data/lib/active_version/translations/translation_record.rb +258 -0
  47. data/lib/active_version/translations.rb +9 -0
  48. data/lib/active_version/version.rb +3 -0
  49. data/lib/active_version/version_registry.rb +87 -0
  50. data/lib/active_version.rb +329 -0
  51. data/lib/generators/active_version/audits/audits_generator.rb +65 -0
  52. data/lib/generators/active_version/audits/templates/audit_model.rb.erb +16 -0
  53. data/lib/generators/active_version/audits/templates/migration_jsonb.rb.erb +33 -0
  54. data/lib/generators/active_version/audits/templates/migration_table.rb.erb +34 -0
  55. data/lib/generators/active_version/install/install_generator.rb +19 -0
  56. data/lib/generators/active_version/install/templates/initializer.rb.erb +38 -0
  57. data/lib/generators/active_version/revisions/revisions_generator.rb +71 -0
  58. data/lib/generators/active_version/revisions/templates/backfill_migration.rb.erb +19 -0
  59. data/lib/generators/active_version/revisions/templates/migration.rb.erb +20 -0
  60. data/lib/generators/active_version/revisions/templates/revision_model.rb.erb +8 -0
  61. data/lib/generators/active_version/translations/templates/migration.rb.erb +16 -0
  62. data/lib/generators/active_version/translations/templates/translation_model.rb.erb +15 -0
  63. data/lib/generators/active_version/translations/translations_generator.rb +73 -0
  64. data/lib/generators/active_version/triggers/templates/migration.rb.erb +100 -0
  65. data/lib/generators/active_version/triggers/triggers_generator.rb +74 -0
  66. data/sig/active_version/advanced.rbs +51 -0
  67. data/sig/active_version/audits.rbs +128 -0
  68. data/sig/active_version/configuration.rbs +38 -0
  69. data/sig/active_version/core.rbs +53 -0
  70. data/sig/active_version/instrumentation.rbs +17 -0
  71. data/sig/active_version/registry_and_mapping.rbs +18 -0
  72. data/sig/active_version/revisions.rbs +70 -0
  73. data/sig/active_version/runtime.rbs +29 -0
  74. data/sig/active_version/translations.rbs +43 -0
  75. data/sig/active_version.rbs +3 -0
  76. metadata +443 -0
@@ -0,0 +1,258 @@
1
+ module ActiveVersion
2
+ module Translations
3
+ # Marker module for translation models
4
+ # Identifies a model as a translation record
5
+ module TranslationRecord
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :active_version_translation_schema, instance_writer: false, default: {}
10
+
11
+ # Mark this as a translation record
12
+ def self.translation_record?
13
+ true
14
+ end
15
+
16
+ # Get source model name (e.g., "Post" from "PostTranslation")
17
+ def self.source_name
18
+ return @source_name if @source_name
19
+ return nil unless name
20
+ @source_name = name.underscore.gsub("_translation", "").to_sym
21
+ end
22
+
23
+ # Get source model class (lazy)
24
+ def self.source_class
25
+ @source_class ||= begin
26
+ klass = source_name.to_s.classify.safe_constantize
27
+ klass || raise(NameError, "Could not find source class #{source_name.to_s.classify}")
28
+ end
29
+ end
30
+
31
+ # Get foreign key name(s) (e.g., "post_id" or ["tenant_id", "post_id"])
32
+ def self.source_foreign_key
33
+ schema_foreign_key = (active_version_translation_schema || {})[:foreign_key]
34
+ if schema_foreign_key.present?
35
+ return schema_foreign_key.is_a?(Array) ? schema_foreign_key.map(&:to_s) : schema_foreign_key.to_s
36
+ end
37
+
38
+ model_name = source_name.to_s.classify
39
+ options = ActiveVersion.registry.config_for_model_name(model_name, :translations) || {}
40
+ options = ActiveVersion.registry.config_for(source_class, :translations) || {} if options.empty?
41
+ foreign_key = options[:foreign_key].presence || "#{source_name}_id"
42
+ foreign_key.is_a?(Array) ? foreign_key.map(&:to_s) : foreign_key.to_s
43
+ rescue NameError
44
+ "#{source_name}_id"
45
+ end
46
+
47
+ def self.source_primary_key
48
+ schema_identity_resolver = (active_version_translation_schema || {})[:identity_resolver]
49
+ if schema_identity_resolver.present?
50
+ return schema_identity_resolver.map(&:to_s) if schema_identity_resolver.is_a?(Array)
51
+ return schema_identity_resolver.to_s if schema_identity_resolver.is_a?(Symbol)
52
+ return schema_identity_resolver if schema_identity_resolver.is_a?(String) && schema_identity_resolver.present?
53
+ end
54
+
55
+ model_name = source_name.to_s.classify
56
+ options = ActiveVersion.registry.config_for_model_name(model_name, :translations) || {}
57
+ options = ActiveVersion.registry.config_for(source_class, :translations) || {} if options.empty?
58
+
59
+ resolver = options[:identity_resolver]
60
+ return resolver.map(&:to_s) if resolver.is_a?(Array)
61
+ return resolver.to_s if resolver.is_a?(Symbol)
62
+ return resolver if resolver.is_a?(String) && resolver.present?
63
+
64
+ nil
65
+ rescue NameError
66
+ nil
67
+ end
68
+
69
+ def self.locale_column_name
70
+ schema_locale = (active_version_translation_schema || {})[:locale_column]
71
+ if schema_locale.present?
72
+ return schema_locale.to_sym
73
+ end
74
+
75
+ locale_column = ActiveVersion.column_mapper.column_for(source_class, :translations, :locale)
76
+ return locale_column if column_names.include?(locale_column.to_s)
77
+
78
+ ActiveVersion.config.translation_locale_column
79
+ rescue NameError, ActiveRecord::ConnectionNotDefined
80
+ ActiveVersion.config.translation_locale_column
81
+ end
82
+
83
+ # Set up belongs_to association (deferred until source class exists)
84
+ def self.setup_associations(force: false)
85
+ reflection = reflect_on_association(source_name)
86
+ return if @associations_setup && !force && Array(reflection&.foreign_key).map(&:to_s) == Array(source_foreign_key).map(&:to_s)
87
+ @associations_setup = true
88
+
89
+ assoc_options = {
90
+ foreign_key: source_foreign_key,
91
+ inverse_of: :translations,
92
+ touch: true
93
+ }
94
+ primary_key = source_primary_key
95
+ assoc_options[:primary_key] = primary_key if primary_key.present?
96
+ send(:belongs_to, source_name, **assoc_options)
97
+
98
+ # Validations
99
+ begin
100
+ locale_column = locale_column_name
101
+ validates locale_column, presence: true, uniqueness: {scope: Array(source_foreign_key)}
102
+ rescue NameError, ActiveRecord::ConnectionNotDefined
103
+ # Source class not yet defined, will be set up later
104
+ end
105
+ end
106
+
107
+ # Setup will be called when source class is available
108
+ def self.setup_locale_enum
109
+ return if @locale_enum_setup
110
+ return unless defined?(I18n) && I18n.respond_to?(:available_locales)
111
+ return unless source_name
112
+
113
+ @locale_enum_setup = true
114
+ begin
115
+ locale_column = locale_column_name
116
+ column = columns_hash[locale_column.to_s]
117
+ return unless column&.type == :integer
118
+ enum locale_column, I18n.available_locales.index_by(&:to_s)
119
+ rescue NameError, ActiveRecord::ConnectionNotDefined
120
+ # Source class not yet defined
121
+ end
122
+ end
123
+
124
+ # Scopes
125
+ scope :for_locale, ->(locale) {
126
+ return none unless source_name
127
+ locale_column = locale_column_name
128
+ where(locale_column => locale)
129
+ }
130
+
131
+ # After create hook to update source version
132
+ after_create :update_source_version
133
+ after_create :instrument_translation_created
134
+
135
+ # After update hook to instrument translation updated
136
+ after_update :instrument_translation_updated
137
+ after_destroy :instrument_translation_destroyed
138
+
139
+ # Setup associations when class is loaded (only if name is available)
140
+ setup_associations if name
141
+ setup_locale_enum if name
142
+ end
143
+
144
+ class TranslationSchemaDSL
145
+ def initialize(klass)
146
+ @klass = klass
147
+ end
148
+
149
+ def locale_column(value)
150
+ @klass.translation_locale_column(value)
151
+ end
152
+
153
+ def foreign_key(value)
154
+ @klass.translation_foreign_key(value)
155
+ end
156
+
157
+ def identity_resolver(value)
158
+ @klass.translation_identity_resolver(value)
159
+ end
160
+ end
161
+
162
+ class_methods do
163
+ def configure_translation(**options, &block)
164
+ apply_translation_configuration(**options)
165
+ TranslationSchemaDSL.new(self).instance_eval(&block) if block_given?
166
+ active_version_translation_schema
167
+ end
168
+
169
+ def translation_locale_column(value = nil) = schema_option(:locale_column, value, cast: :symbol)
170
+ def translation_foreign_key(value = nil) = schema_option(:foreign_key, value, cast: :identity)
171
+ def translation_identity_resolver(value = nil) = schema_option(:identity_resolver, value, cast: :resolver)
172
+
173
+ def apply_translation_configuration(locale_column: nil, foreign_key: nil, identity_resolver: nil)
174
+ translation_locale_column(locale_column) if locale_column
175
+ translation_foreign_key(foreign_key) if foreign_key
176
+ translation_identity_resolver(identity_resolver) if identity_resolver
177
+ active_version_translation_schema
178
+ end
179
+
180
+ private
181
+
182
+ def schema_option(key, value, cast:)
183
+ schema = (active_version_translation_schema || {}).dup
184
+ return schema[key] if value.nil?
185
+
186
+ schema[key] = case cast
187
+ when :symbol
188
+ value.to_sym
189
+ when :identity
190
+ value.is_a?(Array) ? value.map(&:to_s) : value.to_s
191
+ when :resolver
192
+ if value.is_a?(Array)
193
+ value.map(&:to_s)
194
+ elsif value.is_a?(Symbol)
195
+ value.to_s
196
+ else
197
+ value
198
+ end
199
+ else
200
+ value
201
+ end
202
+ self.active_version_translation_schema = schema
203
+ schema[key]
204
+ end
205
+ end
206
+
207
+ # Check if attribute is present for locale
208
+ def attr_present_for_locale?(locale, attr_name, presence_check = nil)
209
+ return false unless self.class.source_name
210
+
211
+ begin
212
+ locale_column = self.class.locale_column_name
213
+ return false unless send(locale_column).to_s == locale.to_s
214
+
215
+ if presence_check
216
+ send(presence_check, attr_name)
217
+ else
218
+ send(attr_name).present?
219
+ end
220
+ rescue NameError
221
+ # Source class not yet defined, check locale directly
222
+ return false unless respond_to?(:locale)
223
+ return false unless self.locale.to_s == locale.to_s
224
+ send(attr_name).present?
225
+ end
226
+ end
227
+
228
+ # Get source version (for versioning of versions)
229
+ def source_version
230
+ send(self.class.source_name)
231
+ end
232
+
233
+ private
234
+
235
+ def update_source_version
236
+ source = send(self.class.source_name)
237
+ return unless source
238
+
239
+ # Update source's updated_at if it has translations
240
+ if source.respond_to?(:update_default_translation)
241
+ source.update_default_translation
242
+ end
243
+ end
244
+
245
+ def instrument_translation_created
246
+ ActiveVersion::Instrumentation.instrument_translation_created(self, source_version)
247
+ end
248
+
249
+ def instrument_translation_updated
250
+ ActiveVersion::Instrumentation.instrument_translation_updated(self, source_version)
251
+ end
252
+
253
+ def instrument_translation_destroyed
254
+ ActiveVersion::Instrumentation.instrument_translation_destroyed(self, source_version)
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,9 @@
1
+ require "active_version/translations/translation_record"
2
+ require "active_version/translations/has_translations"
3
+
4
+ module ActiveVersion
5
+ module Translations
6
+ # Translations module for ActiveVersion
7
+ # Provides locale-based versioning functionality
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveVersion
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,87 @@
1
+ module ActiveVersion
2
+ # Registry for tracking versioned models and their configuration
3
+ class VersionRegistry
4
+ def initialize
5
+ @models = {}
6
+ @version_classes = {}
7
+ end
8
+
9
+ # Register a model with versioning
10
+ # Detects conflicts when re-registering with different options or duplicate registrations
11
+ def register(model_class, version_type, options = {})
12
+ key = registry_key(model_class, version_type)
13
+
14
+ # Check for existing registration
15
+ if @models.key?(key)
16
+ existing = @models[key]
17
+
18
+ # Detect option conflicts
19
+ if existing[:options] != options
20
+ warn "[ActiveVersion] Re-registering #{model_class.name} with :#{version_type} " \
21
+ "with different options. Previous: #{existing[:options].inspect}, " \
22
+ "New: #{options.inspect}. This may indicate a configuration issue."
23
+ else
24
+ # Same options - likely a double include, but not necessarily a problem
25
+ # Log at debug level if needed
26
+ end
27
+ end
28
+
29
+ @models[key] = {
30
+ model_class: model_class,
31
+ version_type: version_type,
32
+ options: options,
33
+ registered_at: Time.current
34
+ }
35
+ end
36
+
37
+ # Get version class for a model and version type
38
+ def version_class_for(model_class, version_type)
39
+ key = registry_key(model_class, version_type)
40
+ @version_classes[key]
41
+ end
42
+
43
+ # Register a version class
44
+ def register_version_class(model_class, version_type, version_class)
45
+ key = registry_key(model_class, version_type)
46
+ @version_classes[key] = version_class
47
+ end
48
+
49
+ # Check if a model is registered for versioning
50
+ def registered?(model_class, version_type)
51
+ key = registry_key(model_class, version_type)
52
+ @models.key?(key)
53
+ end
54
+
55
+ # Get all registered models for a version type
56
+ def models_for_version_type(version_type)
57
+ @models.select { |_k, v| v[:version_type] == version_type }
58
+ .map { |_k, v| v[:model_class] }
59
+ end
60
+
61
+ # Get configuration for a model and version type
62
+ def config_for(model_class, version_type)
63
+ key = registry_key(model_class, version_type)
64
+ @models[key]&.fetch(:options, {})
65
+ end
66
+
67
+ # Get configuration by model class name and version type.
68
+ # Useful while constants are still being wired and only the intended class
69
+ # name is known.
70
+ def config_for_model_name(model_name, version_type)
71
+ key = :"#{model_name}:#{version_type}"
72
+ @models[key]&.fetch(:options, {})
73
+ end
74
+
75
+ # Clear all registrations (useful for testing)
76
+ def clear!
77
+ @models.clear
78
+ @version_classes.clear
79
+ end
80
+
81
+ private
82
+
83
+ def registry_key(model_class, version_type)
84
+ :"#{model_class.name}:#{version_type}"
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,329 @@
1
+ require "active_support"
2
+ begin
3
+ require "active_record"
4
+ rescue LoadError
5
+ # ActiveRecord is optional at runtime.
6
+ end
7
+
8
+ require "active_version/version"
9
+ require "active_version/configuration"
10
+ require "active_version/column_mapper"
11
+ require "active_version/version_registry"
12
+ require "active_version/instrumentation"
13
+ require "active_version/runtime"
14
+
15
+ # Main entry point for ActiveVersion
16
+ module ActiveVersion
17
+ class Error < StandardError; end
18
+ class ConfigurationError < Error; end
19
+ class VersionNotFoundError < Error; end
20
+ class ReadonlyVersionError < Error; end
21
+ class FutureTimeError < Error; end
22
+ class DeletedColumnError < Error; end
23
+
24
+ extend ActiveSupport::Autoload
25
+
26
+ autoload :Adapters
27
+ autoload :Translations
28
+ autoload :Revisions
29
+ autoload :Audits
30
+ autoload :Database
31
+ autoload :Sharding
32
+ autoload :Query
33
+ autoload :Migrators
34
+ autoload :Runtime
35
+
36
+ # Load translations module
37
+ require "active_version/translations"
38
+ # Load revisions module
39
+ require "active_version/revisions"
40
+ # Load audits module
41
+ require "active_version/audits"
42
+
43
+ # Load ActiveRecord adapters (they use ActiveSupport.on_load, so safe to require)
44
+ begin
45
+ # Ensure base adapter is loaded first
46
+ require "active_version/adapters/active_record/base"
47
+ require "active_version/adapters/active_record/translations"
48
+ require "active_version/adapters/active_record/revisions"
49
+ require "active_version/adapters/active_record/audits"
50
+ rescue LoadError
51
+ # Adapters may not be available in all environments
52
+ end
53
+
54
+ begin
55
+ require "active_version/adapters/sequel"
56
+ rescue LoadError
57
+ # Sequel adapter is optional at runtime.
58
+ end
59
+
60
+ # Global configuration
61
+ def self.config
62
+ @config ||= Configuration.new
63
+ end
64
+
65
+ def self.configure
66
+ yield config if block_given?
67
+ config
68
+ end
69
+
70
+ # Convenience methods for accessing configuration
71
+ def self.auditing_enabled
72
+ config.auditing_enabled
73
+ end
74
+
75
+ def self.auditing_enabled=(value)
76
+ config.auditing_enabled = value
77
+ end
78
+
79
+ # Runtime adapter access (ActiveRecord by default).
80
+ def self.runtime_adapter
81
+ Runtime.adapter
82
+ end
83
+
84
+ def self.runtime_adapter=(adapter)
85
+ Runtime.adapter = adapter
86
+ end
87
+
88
+ def self.reset_runtime_adapter!
89
+ Runtime.reset_adapter!
90
+ end
91
+
92
+ # Context management (like audited)
93
+ class RequestStore < ActiveSupport::CurrentAttributes
94
+ attribute :version_context
95
+ attribute :audited_user
96
+ attribute :request_uuid
97
+ attribute :remote_address
98
+ end
99
+
100
+ def self.context
101
+ # Merge persistent context with request-scoped context
102
+ persistent = store_get(:active_version_persistent_context) || {}
103
+ request_scoped = RequestStore.version_context || {}
104
+ persistent.merge(request_scoped)
105
+ end
106
+
107
+ def self.context=(value)
108
+ raise ConfigurationError, "context must be a hash" unless value.is_a?(Hash)
109
+ RequestStore.version_context = value
110
+ end
111
+
112
+ # Transaction-aware context (uses PostgreSQL session variables)
113
+ # Accepts either a hash as first argument or keyword arguments
114
+ def self.with_context(context = nil, transactional: true, **kwargs, &block)
115
+ raise ArgumentError, "with_context requires a block" unless block_given?
116
+
117
+ # If context is nil but kwargs are provided, use kwargs as context
118
+ # If context is provided, use it (and ignore kwargs)
119
+ # If both are nil/empty, use empty hash
120
+ context_hash = if context.nil? && kwargs.any?
121
+ kwargs
122
+ elsif context.is_a?(Hash)
123
+ context
124
+ elsif context.nil?
125
+ {}
126
+ else
127
+ raise ArgumentError, "context must be a hash or keyword arguments"
128
+ end
129
+
130
+ if transactional_context_supported?
131
+ # Use PostgreSQL session variables for transaction-aware context
132
+ with_transactional_context(context_hash, &block)
133
+ else
134
+ # Fallback to thread-local context
135
+ with_thread_local_context(context_hash, &block)
136
+ end
137
+ end
138
+
139
+ # Persistent context (connection-level, persists across operations)
140
+ def self.with_context!(context)
141
+ raise ArgumentError, "context must be a hash" unless context.is_a?(Hash)
142
+
143
+ if store_get(:active_version_in_block)
144
+ raise Error, "with_context! cannot be called from within a with_context block"
145
+ end
146
+
147
+ store_set(:active_version_persistent_context, context)
148
+ nil
149
+ end
150
+
151
+ # Clear persistent context
152
+ def self.clear_context!
153
+ store_delete(:active_version_persistent_context)
154
+ store_set(:active_version_context_depth, 0)
155
+ store_set(:active_version_in_block, false)
156
+ nil
157
+ end
158
+
159
+ def self.store_get(key)
160
+ if config.execution_scope == :thread
161
+ Thread.current.thread_variable_get(key)
162
+ elsif Fiber.current.respond_to?(:[])
163
+ Fiber.current[key]
164
+ else
165
+ Thread.current[key]
166
+ end
167
+ end
168
+
169
+ def self.store_set(key, value)
170
+ if config.execution_scope == :thread
171
+ Thread.current.thread_variable_set(key, value)
172
+ elsif Fiber.current.respond_to?(:[]=)
173
+ Fiber.current[key] = value
174
+ else
175
+ Thread.current[key] = value
176
+ end
177
+ end
178
+
179
+ def self.store_delete(key)
180
+ store_set(key, nil)
181
+ end
182
+
183
+ def self.store_keys
184
+ if config.execution_scope == :thread
185
+ Thread.current.thread_variables
186
+ elsif Thread.current.respond_to?(:keys)
187
+ Thread.current.keys
188
+ else
189
+ []
190
+ end
191
+ end
192
+
193
+ def self.clear_scoped_keys!(pattern)
194
+ store_keys.grep(pattern).each { |key| store_delete(key) }
195
+ end
196
+ public_class_method :store_get, :store_set, :store_delete, :clear_scoped_keys!
197
+
198
+ def self.enter_context_block!
199
+ depth = store_get(:active_version_context_depth).to_i + 1
200
+ store_set(:active_version_context_depth, depth)
201
+ store_set(:active_version_in_block, depth.positive?)
202
+ end
203
+
204
+ def self.leave_context_block!
205
+ depth = store_get(:active_version_context_depth).to_i - 1
206
+ depth = 0 if depth.negative?
207
+ store_set(:active_version_context_depth, depth)
208
+ store_set(:active_version_in_block, depth.positive?)
209
+ end
210
+
211
+ def self.with_transactional_context(context, &block)
212
+ connection = Runtime.adapter.base_connection
213
+ old_context = self.context.dup
214
+ old_block_context = store_get(:active_version_block_context)
215
+ enter_context_block!
216
+
217
+ # Set PostgreSQL session variable
218
+ if connection.open_transactions.positive?
219
+ encoded_context = connection.quote(ActiveSupport::JSON.encode(context))
220
+ connection.execute("SET LOCAL active_version.context = #{encoded_context}")
221
+ end
222
+
223
+ # Also update thread-local for immediate access
224
+ self.context = old_context.merge(context)
225
+ store_set(:active_version_block_context, context)
226
+
227
+ yield
228
+ ensure
229
+ # Context is automatically cleared on transaction rollback
230
+ # But we still restore thread-local context
231
+ self.context = old_context
232
+ store_set(:active_version_block_context, old_block_context)
233
+ leave_context_block!
234
+ end
235
+
236
+ def self.transactional_context_supported?
237
+ connection = Runtime.adapter.base_connection
238
+ Runtime.supports_transactional_context?(connection)
239
+ rescue *Runtime.active_record_connection_errors
240
+ false
241
+ rescue
242
+ false
243
+ end
244
+
245
+ def self.time_parser
246
+ zone = Time.zone if Time.respond_to?(:zone)
247
+ zone || Time
248
+ end
249
+
250
+ def self.with_thread_local_context(context, &block)
251
+ old_context = self.context.dup
252
+ old_block_context = store_get(:active_version_block_context)
253
+ enter_context_block!
254
+ self.context = old_context.merge(context)
255
+ store_set(:active_version_block_context, context)
256
+ yield
257
+ ensure
258
+ self.context = old_context
259
+ store_set(:active_version_block_context, old_block_context)
260
+ leave_context_block!
261
+ end
262
+
263
+ # Disable versioning globally
264
+ def self.without_auditing
265
+ auditing_was_enabled = auditing_enabled
266
+ disable_auditing
267
+ yield
268
+ ensure
269
+ enable_auditing if auditing_was_enabled
270
+ end
271
+
272
+ def self.disable_auditing
273
+ self.auditing_enabled = false
274
+ end
275
+
276
+ def self.enable_auditing
277
+ self.auditing_enabled = true
278
+ end
279
+
280
+ # Version registry access
281
+ def self.registry
282
+ @registry ||= VersionRegistry.new
283
+ end
284
+
285
+ # Column mapper access
286
+ def self.column_mapper
287
+ @column_mapper ||= ColumnMapper.new
288
+ end
289
+
290
+ # Parse time from various formats
291
+ # Converts Numeric (Unix timestamp), String, Date, Time, or other objects to Time
292
+ # @param time [Numeric, String, Date, Time, Object] Time value in various formats
293
+ # @return [Time] Time object
294
+ def self.parse_time(time)
295
+ parser = time_parser
296
+ case time
297
+ when Numeric then parser.at(time)
298
+ when String then parser.parse(time)
299
+ when Date then time.to_time
300
+ when Time then time
301
+ else parser.parse(time.to_s)
302
+ end
303
+ end
304
+
305
+ # Parse time and return Time object (alias for clarity)
306
+ def self.parse_time_to_time(time)
307
+ parse_time(time)
308
+ end
309
+
310
+ # Connection access is intentionally application-owned.
311
+ # ActiveVersion does not route between shards/connections.
312
+ # These methods remain as pass-through helpers.
313
+ def self.connection_for(model_class, version_type)
314
+ :default
315
+ end
316
+
317
+ def self.adapter_for(model_class, version_type)
318
+ Runtime.adapter.connection_for(model_class, version_type)
319
+ end
320
+
321
+ def self.with_connection(model_class, version_type, &block)
322
+ yield(Runtime.adapter.connection_for(model_class, version_type))
323
+ end
324
+ end
325
+
326
+ # Load Rails integration if available
327
+ if defined?(Rails)
328
+ require "active_version/railtie"
329
+ end