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,243 @@
1
+ module ActiveVersion
2
+ module Database
3
+ module Triggers
4
+ # PostgreSQL trigger support for ActiveVersion
5
+ module PostgreSQL
6
+ class << self
7
+ # Generate trigger function for audits
8
+ # @param table_name [String] Name of the table to create trigger for
9
+ # @param audit_table_name [String] Name of the audit table
10
+ # @param options [Hash] Trigger options
11
+ # @return [String] SQL for trigger function
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"
19
+
20
+ <<~SQL
21
+ CREATE OR REPLACE FUNCTION #{function_name}()
22
+ RETURNS TRIGGER AS $$
23
+ DECLARE
24
+ new_version INTEGER;
25
+ changes JSONB;
26
+ context_data JSONB;
27
+ action_type TEXT;
28
+ BEGIN
29
+ -- Determine action
30
+ IF TG_OP = 'INSERT' THEN
31
+ action_type := 'create';
32
+ changes := to_jsonb(NEW.*);
33
+ ELSIF TG_OP = 'UPDATE' THEN
34
+ action_type := 'update';
35
+ changes := jsonb_build_object(
36
+ 'old', to_jsonb(OLD.*),
37
+ 'new', to_jsonb(NEW.*)
38
+ );
39
+ ELSIF TG_OP = 'DELETE' THEN
40
+ action_type := 'destroy';
41
+ changes := to_jsonb(OLD.*);
42
+ END IF;
43
+
44
+ -- Get version number
45
+ IF action_type = 'create' THEN
46
+ new_version := 1;
47
+ ELSE
48
+ SELECT COALESCE(MAX(#{version_column}), 0) + 1
49
+ INTO new_version
50
+ FROM #{audit_table_name}
51
+ WHERE #{audit_table_name}.auditable_id = COALESCE(NEW.id, OLD.id)
52
+ AND #{audit_table_name}.auditable_type = '#{auditable_type}';
53
+ END IF;
54
+
55
+ -- Get context from session variables (if set)
56
+ context_data := COALESCE(
57
+ current_setting('active_version.context', true)::jsonb,
58
+ '{}'::jsonb
59
+ );
60
+
61
+ -- Insert audit record
62
+ IF TG_OP = 'DELETE' THEN
63
+ INSERT INTO #{audit_table_name} (
64
+ auditable_id,
65
+ auditable_type,
66
+ #{action_column},
67
+ #{changes_column},
68
+ #{version_column},
69
+ #{context_column},
70
+ created_at,
71
+ updated_at
72
+ ) VALUES (
73
+ OLD.id,
74
+ '#{auditable_type}',
75
+ action_type,
76
+ changes,
77
+ new_version,
78
+ context_data,
79
+ NOW(),
80
+ NOW()
81
+ );
82
+ RETURN OLD;
83
+ ELSE
84
+ INSERT INTO #{audit_table_name} (
85
+ auditable_id,
86
+ auditable_type,
87
+ #{action_column},
88
+ #{changes_column},
89
+ #{version_column},
90
+ #{context_column},
91
+ created_at,
92
+ updated_at
93
+ ) VALUES (
94
+ NEW.id,
95
+ '#{auditable_type}',
96
+ action_type,
97
+ changes,
98
+ new_version,
99
+ context_data,
100
+ NOW(),
101
+ NOW()
102
+ );
103
+ RETURN NEW;
104
+ END IF;
105
+ END;
106
+ $$ LANGUAGE plpgsql;
107
+ SQL
108
+ end
109
+
110
+ # Generate trigger for audits
111
+ # @param table_name [String] Name of the table
112
+ # @param options [Hash] Trigger options
113
+ # @return [String] SQL for CREATE TRIGGER statement
114
+ 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}"
117
+ events = options[:events] || [:insert, :update, :delete]
118
+
119
+ event_clause = events.map(&:upcase).join(" OR ")
120
+
121
+ <<~SQL
122
+ CREATE TRIGGER #{trigger_name}
123
+ AFTER #{event_clause} ON #{table_name}
124
+ FOR EACH ROW
125
+ WHEN (coalesce(current_setting('active_version.disabled', true), '') <> 'on')
126
+ EXECUTE FUNCTION #{function_name}();
127
+ SQL
128
+ end
129
+
130
+ # Generate trigger function for revisions
131
+ # @param table_name [String] Name of the table
132
+ # @param revision_table_name [String] Name of the revision table
133
+ # @param options [Hash] Trigger options
134
+ # @return [String] SQL for trigger function
135
+ def generate_revision_trigger_function(table_name, revision_table_name, options = {})
136
+ function_name = "active_version_revision_#{table_name}"
137
+ foreign_key = options[:foreign_key] || "#{table_name.singularize}_id"
138
+ version_column = options[:version_column] || "version"
139
+
140
+ <<~SQL
141
+ CREATE OR REPLACE FUNCTION #{function_name}()
142
+ RETURNS TRIGGER AS $$
143
+ DECLARE
144
+ new_version INTEGER;
145
+ revision_data RECORD;
146
+ BEGIN
147
+ -- Only create revision on UPDATE
148
+ IF TG_OP != 'UPDATE' THEN
149
+ RETURN NEW;
150
+ END IF;
151
+
152
+ -- Skip if nothing changed
153
+ IF NEW.* IS NOT DISTINCT FROM OLD.* THEN
154
+ RETURN NEW;
155
+ END IF;
156
+
157
+ -- Get next version number
158
+ SELECT COALESCE(MAX(#{version_column}), 0) + 1
159
+ INTO new_version
160
+ FROM #{revision_table_name}
161
+ WHERE #{revision_table_name}.#{foreign_key} = NEW.id;
162
+
163
+ -- Create revision with OLD values
164
+ INSERT INTO #{revision_table_name} (
165
+ #{foreign_key},
166
+ #{version_column},
167
+ #{build_revision_columns(table_name, options)},
168
+ created_at,
169
+ updated_at
170
+ )
171
+ SELECT
172
+ NEW.id,
173
+ new_version,
174
+ #{build_revision_values("OLD", table_name, options)},
175
+ NOW(),
176
+ NOW();
177
+
178
+ RETURN NEW;
179
+ END;
180
+ $$ LANGUAGE plpgsql;
181
+ SQL
182
+ end
183
+
184
+ # Generate trigger for revisions
185
+ # @param table_name [String] Name of the table
186
+ # @param options [Hash] Trigger options
187
+ # @return [String] SQL for CREATE TRIGGER statement
188
+ 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}"
191
+
192
+ <<~SQL
193
+ CREATE TRIGGER #{trigger_name}
194
+ BEFORE UPDATE ON #{table_name}
195
+ FOR EACH ROW
196
+ WHEN (coalesce(current_setting('active_version.disabled', true), '') <> 'on')
197
+ EXECUTE FUNCTION #{function_name}();
198
+ SQL
199
+ end
200
+
201
+ # Drop trigger function
202
+ # @param function_name [String] Name of the function
203
+ # @return [String] SQL for DROP FUNCTION statement
204
+ def drop_trigger_function(function_name)
205
+ "DROP FUNCTION IF EXISTS #{function_name}() CASCADE;"
206
+ end
207
+
208
+ # Drop trigger
209
+ # @param trigger_name [String] Name of the trigger
210
+ # @param table_name [String] Name of the table
211
+ # @return [String] SQL for DROP TRIGGER statement
212
+ def drop_trigger(trigger_name, table_name)
213
+ "DROP TRIGGER IF EXISTS #{trigger_name} ON #{table_name} CASCADE;"
214
+ end
215
+
216
+ private
217
+
218
+ def build_revision_columns(table_name, options)
219
+ columns = normalize_revision_columns(options)
220
+ columns.join(", ")
221
+ end
222
+
223
+ def build_revision_values(record_prefix, table_name, options)
224
+ columns = normalize_revision_columns(options)
225
+ columns.map { |column| "#{record_prefix}.#{column}" }.join(", ")
226
+ end
227
+
228
+ def normalize_revision_columns(options)
229
+ raw_columns = Array(options[:columns]).map(&:to_s)
230
+ if raw_columns.empty?
231
+ raise ArgumentError, "revision trigger generation requires :columns option"
232
+ end
233
+
234
+ foreign_key = options[:foreign_key].to_s
235
+ version_column = options[:version_column].to_s
236
+ metadata_columns = [foreign_key, version_column, "id", "created_at", "updated_at"]
237
+ raw_columns.reject { |col| metadata_columns.include?(col) }
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,7 @@
1
+ require "active_version/database/triggers/postgresql"
2
+
3
+ module ActiveVersion
4
+ module Database
5
+ # Database utilities for ActiveVersion
6
+ end
7
+ end
@@ -0,0 +1,226 @@
1
+ module ActiveVersion
2
+ # Instrumentation for ActiveSupport::Notifications
3
+ module Instrumentation
4
+ EVENTS = {
5
+ translation_created: "translation_created.active_version",
6
+ translation_updated: "translation_updated.active_version",
7
+ translation_destroyed: "translation_destroyed.active_version",
8
+ translation_fallback_used: "translation_fallback_used.active_version",
9
+ revision_created: "revision.active_version",
10
+ revision_reverted: "revision_reverted.active_version",
11
+ revision_switch_applied: "revision_switch_applied.active_version",
12
+ revision_write_failed: "revision_write_failed.active_version",
13
+ audit_created: "audit.active_version",
14
+ audit_write_failed: "audit_write_failed.active_version",
15
+ audit_sql_generated: "audit_sql.active_version"
16
+ }.freeze
17
+
18
+ class << self
19
+ # Instrument a translation creation
20
+ def instrument_translation_created(translation, source)
21
+ instrument_event(:translation_created) do
22
+ {
23
+ translation: translation,
24
+ source: source,
25
+ locale: translation.locale
26
+ }
27
+ end
28
+ end
29
+
30
+ # Instrument a translation update
31
+ def instrument_translation_updated(translation, source)
32
+ instrument_event(:translation_updated) do
33
+ {
34
+ translation: translation,
35
+ source: source,
36
+ locale: translation.locale
37
+ }
38
+ end
39
+ end
40
+
41
+ # Instrument a translation destroy
42
+ def instrument_translation_destroyed(translation, source)
43
+ instrument_event(:translation_destroyed) do
44
+ {
45
+ translation: translation,
46
+ source: source,
47
+ locale: translation.locale,
48
+ translation_id: translation.id
49
+ }
50
+ end
51
+ end
52
+
53
+ # Instrument when translation falls back from requested locale
54
+ def instrument_translation_fallback_used(source, attr:, requested_locale:, resolved_locale:)
55
+ instrument_event(:translation_fallback_used) do
56
+ {
57
+ source: source,
58
+ attr: attr,
59
+ requested_locale: requested_locale,
60
+ resolved_locale: resolved_locale
61
+ }
62
+ end
63
+ end
64
+
65
+ # Instrument a revision creation
66
+ def instrument_revision_created(revision, source)
67
+ source_class = source&.class
68
+ source_class ||= revision.class.source_class if revision.class.respond_to?(:source_class)
69
+ version_value = nil
70
+
71
+ if source_class
72
+ version_column = ActiveVersion.column_mapper.column_for(source_class, :revisions, :version)
73
+ version_value = if revision.respond_to?(version_column)
74
+ revision.public_send(version_column)
75
+ else
76
+ revision[version_column]
77
+ end
78
+ else
79
+ version_value = revision[ActiveVersion.config.revision_version_column] if revision.respond_to?(:[])
80
+ version_value ||= revision.version if revision.respond_to?(:version)
81
+ end
82
+
83
+ instrument_event(:revision_created) do
84
+ {
85
+ revision: revision,
86
+ source: source,
87
+ version: version_value
88
+ }
89
+ end
90
+ rescue NameError
91
+ instrument_event(:revision_created) do
92
+ {
93
+ revision: revision,
94
+ source: source,
95
+ version: nil
96
+ }
97
+ end
98
+ end
99
+
100
+ # Instrument revision revert action
101
+ def instrument_revision_reverted(source, from_version:, to_version:, strategy: :revert_to)
102
+ instrument_event(:revision_reverted) do
103
+ {
104
+ source: source,
105
+ from_version: from_version,
106
+ to_version: to_version,
107
+ strategy: strategy
108
+ }
109
+ end
110
+ end
111
+
112
+ # Instrument revision pointer switch action
113
+ def instrument_revision_switch_applied(source, from_version:, to_version:, append:)
114
+ instrument_event(:revision_switch_applied) do
115
+ {
116
+ source: source,
117
+ from_version: from_version,
118
+ to_version: to_version,
119
+ append: append
120
+ }
121
+ end
122
+ end
123
+
124
+ # Instrument revision write failures
125
+ def instrument_revision_write_failed(source, error:)
126
+ instrument_event(:revision_write_failed) do
127
+ {
128
+ source: source,
129
+ error: format_error(error)
130
+ }
131
+ end
132
+ end
133
+
134
+ # Instrument an audit creation
135
+ def instrument_audit_created(audit, auditable)
136
+ source_class = auditable&.class
137
+ source_class ||= audit.class.source_class if audit.class.respond_to?(:source_class)
138
+ version_value = nil
139
+
140
+ if source_class
141
+ version_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :version)
142
+ version_value = if audit.respond_to?(version_column)
143
+ audit.public_send(version_column)
144
+ else
145
+ audit[version_column]
146
+ end
147
+ else
148
+ version_value = audit[ActiveVersion.config.audit_version_column] if audit.respond_to?(:[])
149
+ version_value ||= audit.version if audit.respond_to?(:version)
150
+ end
151
+
152
+ instrument_event(:audit_created) do
153
+ {
154
+ audit: audit,
155
+ auditable: auditable,
156
+ action: audit.action,
157
+ version: version_value
158
+ }
159
+ end
160
+ rescue NameError
161
+ instrument_event(:audit_created) do
162
+ {
163
+ audit: audit,
164
+ auditable: auditable,
165
+ action: audit.action,
166
+ version: nil
167
+ }
168
+ end
169
+ end
170
+
171
+ # Instrument audit write failures
172
+ def instrument_audit_write_failed(source, error:, action:)
173
+ instrument_event(:audit_write_failed) do
174
+ {
175
+ source: source,
176
+ action: action,
177
+ error: format_error(error)
178
+ }
179
+ end
180
+ end
181
+
182
+ # Instrument SQL generation
183
+ def instrument_audit_sql_generated(model, sql)
184
+ instrument_event(:audit_sql_generated) do
185
+ {
186
+ model: model,
187
+ sql: sql
188
+ }
189
+ end
190
+ end
191
+
192
+ private
193
+
194
+ def instrument_event(event_key)
195
+ event_name = EVENTS[event_key]
196
+ return unless event_name
197
+ return unless listening?(event_name)
198
+
199
+ payload = block_given? ? yield : {}
200
+ ActiveSupport::Notifications.instrument(event_name, payload)
201
+ end
202
+
203
+ def listening?(event_name)
204
+ ActiveSupport::Notifications.notifier.listening?(event_name)
205
+ rescue => e
206
+ log_debug("listener probe failed for #{event_name}: #{e.class}: #{e.message}; assuming listeners may exist")
207
+ true
208
+ end
209
+
210
+ def format_error(error)
211
+ return nil if error.nil?
212
+
213
+ {
214
+ class: error.class.name,
215
+ message: error.message
216
+ }
217
+ end
218
+
219
+ def log_debug(message)
220
+ if defined?(Rails) && Rails.respond_to?(:logger)
221
+ Rails.logger&.debug("[ActiveVersion::Instrumentation] #{message}")
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,84 @@
1
+ require "active_version/migrators/base"
2
+
3
+ module ActiveVersion
4
+ module Migrators
5
+ # Migrator from audited gem
6
+ class Audited < Base
7
+ class << self
8
+ # Migrate audits from audited gem
9
+ # @param model_class [Class] ActiveRecord model class
10
+ # @param options [Hash] Migration options
11
+ # @option options [Boolean] :dry_run (false) Don't actually migrate
12
+ # @return [Integer] Number of records migrated
13
+ def migrate(model_class, options = {})
14
+ return 0 unless model_class.respond_to?(:audited?)
15
+
16
+ audit_class = model_class.audit_class
17
+ return 0 unless audit_class
18
+
19
+ old_audits = source_audits(model_class)
20
+ count = 0
21
+
22
+ if old_audits.respond_to?(:find_each)
23
+ old_audits.find_each do |old_audit|
24
+ count += 1
25
+ next if options[:dry_run]
26
+
27
+ audit_data = convert_audit(old_audit, model_class)
28
+ create_audit(nil, audit_data, audit_class)
29
+ end
30
+ else
31
+ # Handle array case (from mocked source_audits in tests)
32
+ old_audits.each do |old_audit|
33
+ count += 1
34
+ next if options[:dry_run]
35
+
36
+ audit_data = convert_audit(old_audit, model_class)
37
+ create_audit(nil, audit_data, audit_class)
38
+ end
39
+ end
40
+
41
+ count
42
+ end
43
+
44
+ private
45
+
46
+ def source_audits(model_class)
47
+ # Try to find old audit class
48
+ old_audit_class = if defined?(::Audited::Audit)
49
+ ::Audited::Audit
50
+ else
51
+ begin
52
+ "#{model_class.name}Audit".constantize
53
+ rescue
54
+ nil
55
+ end
56
+ end
57
+
58
+ return [] unless old_audit_class
59
+
60
+ old_audit_class.where(auditable_type: model_class.name)
61
+ end
62
+
63
+ def convert_audit(old_audit, model_class)
64
+ auditable_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :auditable).to_s
65
+ version_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :version).to_s
66
+ changes_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :changes).to_s
67
+ context_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :context).to_s
68
+ comment_column = ActiveVersion.column_mapper.column_for(model_class, :audits, :comment).to_s
69
+
70
+ {
71
+ "#{auditable_column}_id" => old_audit.auditable_id,
72
+ "#{auditable_column}_type" => old_audit.auditable_type,
73
+ version_column => old_audit.version,
74
+ changes_column => old_audit.audited_changes,
75
+ context_column => (old_audit.respond_to?(:audited_context) ? old_audit.audited_context : {}),
76
+ comment_column => old_audit.comment,
77
+ :created_at => old_audit.created_at,
78
+ :updated_at => old_audit.updated_at || old_audit.created_at
79
+ }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end