provenance 2.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +358 -0
  5. data/lib/generators/provenance/install/install_generator.rb +34 -0
  6. data/lib/generators/provenance/install/templates/create_provenance_outbox.rb.tt +21 -0
  7. data/lib/generators/provenance/install/templates/initializer.rb.tt +12 -0
  8. data/lib/provenance/action.rb +160 -0
  9. data/lib/provenance/actor.rb +86 -0
  10. data/lib/provenance/configuration.rb +152 -0
  11. data/lib/provenance/context.rb +30 -0
  12. data/lib/provenance/controller.rb +93 -0
  13. data/lib/provenance/emitter.rb +66 -0
  14. data/lib/provenance/entity_change.rb +73 -0
  15. data/lib/provenance/errors.rb +12 -0
  16. data/lib/provenance/event.rb +42 -0
  17. data/lib/provenance/integrity.rb +75 -0
  18. data/lib/provenance/job.rb +62 -0
  19. data/lib/provenance/json_safe.rb +41 -0
  20. data/lib/provenance/middleware.rb +44 -0
  21. data/lib/provenance/model.rb +123 -0
  22. data/lib/provenance/outbox/record.rb +23 -0
  23. data/lib/provenance/outbox/relay.rb +81 -0
  24. data/lib/provenance/outbox.rb +67 -0
  25. data/lib/provenance/railtie.rb +35 -0
  26. data/lib/provenance/rake.rb +27 -0
  27. data/lib/provenance/recorder.rb +206 -0
  28. data/lib/provenance/redactor.rb +63 -0
  29. data/lib/provenance/registry.rb +48 -0
  30. data/lib/provenance/relay_job.rb +15 -0
  31. data/lib/provenance/rspec.rb +139 -0
  32. data/lib/provenance/serializers/cloud_events.rb +26 -0
  33. data/lib/provenance/serializers/native.rb +16 -0
  34. data/lib/provenance/serializers/ocsf.rb +127 -0
  35. data/lib/provenance/serializers.rb +31 -0
  36. data/lib/provenance/sinks/http.rb +81 -0
  37. data/lib/provenance/sinks/io.rb +24 -0
  38. data/lib/provenance/sinks/kafka.rb +45 -0
  39. data/lib/provenance/sinks/logger.rb +22 -0
  40. data/lib/provenance/sinks/memory.rb +29 -0
  41. data/lib/provenance/sinks/proc.rb +20 -0
  42. data/lib/provenance/sinks.rb +35 -0
  43. data/lib/provenance/testing.rb +36 -0
  44. data/lib/provenance/uuid.rb +20 -0
  45. data/lib/provenance/version.rb +6 -0
  46. data/lib/provenance.rb +166 -0
  47. data/lib/tasks/provenance.rake +22 -0
  48. data/schema/event-2.json +126 -0
  49. metadata +150 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Provenance
6
+ # Converts arbitrary Ruby values into JSON-safe primitives.
7
+ module JsonSafe
8
+ module_function
9
+
10
+ # @param value [Object]
11
+ # @return [Object] a String, Numeric, true, false, nil, Array or Hash with String keys
12
+ def call(value)
13
+ case value
14
+ when nil, true, false, Integer then value
15
+ when Float then value.finite? ? value : value.to_s
16
+ when BigDecimal then value.to_s("F")
17
+ when String then string(value)
18
+ when Symbol then value.to_s
19
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = call(v) }
20
+ when Array, Set then value.map { |v| call(v) }
21
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601(3)
22
+ when Date then value.iso8601
23
+ when ActiveSupport::Duration then value.iso8601
24
+ when Numeric then value.to_s
25
+ else value.respond_to?(:as_json) ? call(value.as_json) : value.to_s
26
+ end
27
+ end
28
+
29
+ # @param value [String]
30
+ # @return [String]
31
+ def string(value)
32
+ if value.encoding == Encoding::BINARY && !(value.ascii_only? && value.valid_encoding?)
33
+ "[BINARY #{value.bytesize} bytes]"
34
+ elsif value.valid_encoding?
35
+ value
36
+ else
37
+ value.dup.force_encoding(Encoding::UTF_8).scrub
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ # Rack middleware that opens an +http+ action per request and closes it once the
5
+ # application has produced the response.
6
+ class Middleware
7
+ # @param app [#call]
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ # @param env [Hash] Rack env
13
+ # @return [Array] Rack response
14
+ def call(env)
15
+ return @app.call(env) if !Provenance.enabled? || Provenance.current
16
+
17
+ action = Action.new(kind: :http, name: nil, context: Context.new(env: env))
18
+ Provenance.within(action) do
19
+ response = @app.call(env)
20
+ action.request["status"] = response[0].to_i
21
+ response
22
+ ensure
23
+ describe(action, env)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def describe(action, env)
30
+ request = ActionDispatch::Request.new(env)
31
+ action.request.merge!(
32
+ "id" => env["action_dispatch.request_id"],
33
+ "method" => request.request_method,
34
+ "path" => request.path,
35
+ "ip" => request.remote_ip,
36
+ "user_agent" => request.user_agent
37
+ )
38
+ action.request["status"] ||= nil
39
+ action.name ||= "#{request.request_method} #{request.path}"
40
+ rescue => e
41
+ Provenance.config.report_error(e)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ # Opt-in model tracking: +has_provenance+ and the hooks it installs.
5
+ module Model
6
+ # Bulk operations on relations of tracked models.
7
+ module BulkRelation
8
+ # @see ActiveRecord::Relation#update_all
9
+ def update_all(updates)
10
+ Provenance::Recorder.bulk(self, :bulk_update, updates: updates) { super }
11
+ end
12
+
13
+ # @see ActiveRecord::Relation#delete_all
14
+ def delete_all
15
+ Provenance::Recorder.bulk(self, :bulk_delete) { super }
16
+ end
17
+
18
+ # @see ActiveRecord::Relation#insert_all
19
+ def insert_all(attributes, **options)
20
+ Provenance::Recorder.bulk_insert(self, attributes) { super }
21
+ end
22
+
23
+ # @see ActiveRecord::Relation#insert_all!
24
+ def insert_all!(attributes, **options)
25
+ Provenance::Recorder.bulk_insert(self, attributes) { super }
26
+ end
27
+
28
+ # @see ActiveRecord::Relation#upsert_all
29
+ def upsert_all(attributes, **options)
30
+ Provenance::Recorder.bulk_insert(self, attributes) { super }
31
+ end
32
+ end
33
+
34
+ # Bulk update through an association collection proxy.
35
+ module BulkCollectionProxy
36
+ # @see ActiveRecord::Relation#update_all
37
+ def update_all(updates)
38
+ Provenance::Recorder.bulk(self, :bulk_update, updates: updates) { super }
39
+ end
40
+ end
41
+
42
+ # Keeps bulk tracking on STI subclasses defined after +has_provenance+.
43
+ module Inheritance
44
+ # @api private
45
+ def inherited(subclass)
46
+ super
47
+ Provenance::Model.install_bulk(subclass)
48
+ end
49
+ end
50
+
51
+ # Class macros added to ActiveRecord::Base.
52
+ module ClassMethods
53
+ # Opts the model in to change tracking.
54
+ #
55
+ # @param only [Array<Symbol>] track only these attributes
56
+ # @param except [Array<Symbol>] track all attributes but these
57
+ # @param redact [Array<Symbol>] record these attributes as "[REDACTED]"
58
+ # @param associations [Array<Symbol>] collection associations producing link/unlink changes
59
+ # @param ignore_if [Proc, nil] +->(record) { ... }+; truthy skips the record
60
+ # @return [void]
61
+ def has_provenance(only: nil, except: nil, redact: [], associations: [], ignore_if: nil)
62
+ raise ArgumentError, "use either only: or except:, not both" if only && except
63
+
64
+ options = Registry::Options.new(
65
+ only: Array(only).map(&:to_s), except: Array(except).map(&:to_s),
66
+ redact: Array(redact).map(&:to_s), associations: Array(associations).map(&:to_sym),
67
+ ignore_if: ignore_if
68
+ )
69
+ Provenance.registry.register(self, options)
70
+ Provenance::Model.install(self, options)
71
+ end
72
+ end
73
+
74
+ module_function
75
+
76
+ # @api private
77
+ def install(model, options)
78
+ unless model.instance_variable_get(:@provenance_installed)
79
+ model.instance_variable_set(:@provenance_installed, true)
80
+ model.after_create { Provenance::Recorder.record(self, :create) }
81
+ model.after_update { Provenance::Recorder.record(self, :update) }
82
+ model.after_destroy { Provenance::Recorder.record(self, :destroy) }
83
+ model.singleton_class.prepend(Inheritance)
84
+ ([model] + model.descendants).each { |klass| install_bulk(klass) }
85
+ end
86
+ installed = model.instance_variable_get(:@provenance_associations) || []
87
+ (options.associations - installed).each { |name| install_association(model, name) }
88
+ model.instance_variable_set(:@provenance_associations, installed | options.associations)
89
+ end
90
+
91
+ # @api private
92
+ def install_bulk(model)
93
+ prepend_once(model.relation_delegate_class(ActiveRecord::Relation), BulkRelation)
94
+ prepend_once(model.relation_delegate_class(ActiveRecord::AssociationRelation), BulkRelation)
95
+ prepend_once(model.relation_delegate_class(ActiveRecord::Associations::CollectionProxy), BulkCollectionProxy)
96
+ end
97
+
98
+ # @api private
99
+ def prepend_once(klass, mod)
100
+ klass.prepend(mod) unless klass.ancestors.include?(mod)
101
+ end
102
+
103
+ # Appends link/unlink recorders to the association's after_add/after_remove
104
+ # callback lists.
105
+ #
106
+ # @api private
107
+ def install_association(model, name)
108
+ reflection = model.reflect_on_association(name)
109
+ unless reflection&.collection?
110
+ raise ArgumentError, "#{model.name} has no collection association #{name.inspect}"
111
+ end
112
+
113
+ {after_add: :link, after_remove: :unlink}.each do |callback, operation|
114
+ accessor = "#{callback}_for_#{name}"
115
+ model.class_attribute(accessor, instance_accessor: false, instance_predicate: false) unless model.respond_to?(accessor)
116
+ recorder = ->(_method, owner, record) { Provenance::Recorder.association(owner, name, record, operation) }
117
+ model.public_send(:"#{accessor}=", Array(model.public_send(accessor)) + [recorder])
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ ActiveSupport.on_load(:active_record) { extend Provenance::Model::ClassMethods }
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ module Outbox
5
+ # A stored event awaiting or past delivery.
6
+ class Record < ActiveRecord::Base
7
+ # Row awaiting delivery.
8
+ PENDING = "pending"
9
+ # Row delivered to every sink.
10
+ DELIVERED = "delivered"
11
+
12
+ self.table_name = "provenance_outbox"
13
+
14
+ scope :pending, -> { where(status: PENDING) }
15
+ scope :delivered, -> { where(status: DELIVERED) }
16
+
17
+ # @return [Hash] the stored native event
18
+ def event
19
+ JSON.parse(payload)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ module Outbox
5
+ # Drains pending outbox rows in id order and delivers them to the sinks.
6
+ module Relay
7
+ # Upper bound for the retry delay, in seconds.
8
+ MAX_BACKOFF = 3600
9
+
10
+ module_function
11
+
12
+ # Delivers due rows batch by batch until none are left or a batch fails, then
13
+ # prunes old delivered rows.
14
+ #
15
+ # @param batch_size [Integer]
16
+ # @return [Integer] number of delivered rows
17
+ def run(batch_size: Provenance.config.outbox_batch_size)
18
+ delivered = 0
19
+ loop do
20
+ count = Record.transaction { drain(batch_size) }
21
+ break unless count&.positive?
22
+
23
+ delivered += count
24
+ end
25
+ prune
26
+ delivered
27
+ end
28
+
29
+ # Locks and delivers one batch. Stops at the first row that is not due yet so
30
+ # rows are never delivered out of order.
31
+ #
32
+ # @api private
33
+ # @return [Integer, nil] delivered rows, or nil when the batch failed
34
+ def drain(batch_size)
35
+ scope = Record.pending.order(:id).limit(batch_size)
36
+ scope = scope.lock("FOR UPDATE SKIP LOCKED") if Outbox.postgresql?
37
+ now = Time.current
38
+ rows = scope.to_a.take_while { |row| row.next_attempt_at.nil? || row.next_attempt_at <= now }
39
+ return 0 if rows.empty?
40
+
41
+ begin
42
+ Emitter.deliver(rows.map(&:event), raise_errors: true)
43
+ rescue => e
44
+ fail_rows(rows, e, now)
45
+ return nil
46
+ end
47
+ Record.where(id: rows.map(&:id)).update_all(status: Record::DELIVERED, delivered_at: now, last_error: nil)
48
+ rows.size
49
+ end
50
+
51
+ # @api private
52
+ def fail_rows(rows, error, now)
53
+ rows.each do |row|
54
+ attempts = row.attempts.to_i + 1
55
+ row.update_columns(
56
+ attempts: attempts, next_attempt_at: now + backoff(attempts),
57
+ last_error: "#{error.class}: #{error.message}".truncate(1000)
58
+ )
59
+ end
60
+ Provenance.config.report_error(error)
61
+ end
62
+
63
+ # @param attempts [Integer]
64
+ # @return [Float] seconds until the next attempt
65
+ def backoff(attempts)
66
+ base = [5 * (2**(attempts - 1)), MAX_BACKOFF].min
67
+ base + rand * base * 0.2
68
+ end
69
+
70
+ # Deletes delivered rows older than +outbox_retention+, keeping the newest row
71
+ # of each app as the anchor of the integrity chain.
72
+ #
73
+ # @return [Integer] deleted rows
74
+ def prune
75
+ cutoff = Time.current - Provenance.config.outbox_retention
76
+ anchors = Record.group(:app).maximum(:id).values
77
+ Record.delivered.where(delivered_at: ...cutoff).where.not(id: anchors).delete_all
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ # Reliable delivery: events are stored in the +provenance_outbox+ table and
5
+ # delivered by {RelayJob}.
6
+ module Outbox
7
+ autoload :Record, "provenance/outbox/record"
8
+ autoload :Relay, "provenance/outbox/relay"
9
+
10
+ # Attempts made to allocate a sequence number before giving up.
11
+ WRITE_ATTEMPTS = 5
12
+
13
+ module_function
14
+
15
+ # Stores a native event, adding the integrity chain when a key is configured.
16
+ #
17
+ # @param event [Hash] native event
18
+ # @return [Record]
19
+ def write(event)
20
+ attempts = 0
21
+ begin
22
+ attempts += 1
23
+ record = Record.transaction(requires_new: true) { insert(event) }
24
+ rescue ActiveRecord::RecordNotUnique
25
+ retry if attempts < WRITE_ATTEMPTS
26
+ raise
27
+ end
28
+ RelayJob.perform_later if Provenance.config.outbox_enqueue_relay
29
+ record
30
+ end
31
+
32
+ # @api private
33
+ def insert(event)
34
+ app = event["app"]
35
+ key = Provenance.config.integrity.key
36
+ seq = mac = nil
37
+ if key.present?
38
+ lock(app)
39
+ last_seq, prev = Record.where(app: app).where.not(seq: nil).order(seq: :desc).pick(:seq, :mac)
40
+ seq = last_seq.to_i + 1
41
+ mac = Integrity.mac(key, prev, event)
42
+ event = event.merge("integrity" => {"seq" => seq, "prev" => prev, "mac" => mac})
43
+ end
44
+ Record.create!(
45
+ app: app, event_id: event["id"], seq: seq, mac: mac, payload: JSON.generate(event),
46
+ status: Record::PENDING, attempts: 0, next_attempt_at: Time.current
47
+ )
48
+ end
49
+
50
+ # Serializes sequence allocation per app on PostgreSQL. Other databases rely on
51
+ # the unique (app, seq) index and a retry.
52
+ #
53
+ # @api private
54
+ def lock(app)
55
+ return unless postgresql?
56
+
57
+ Record.connection.execute(
58
+ Record.sanitize_sql_array(["SELECT pg_advisory_xact_lock(hashtext(?))", "provenance:#{app}"])
59
+ )
60
+ end
61
+
62
+ # @return [Boolean]
63
+ def postgresql?
64
+ Record.connection_db_config.adapter.to_s.match?(/postg/i)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Provenance
6
+ # Wires Provenance into a Rails application.
7
+ class Railtie < Rails::Railtie
8
+ initializer "provenance.middleware" do |app|
9
+ app.middleware.insert_after ActionDispatch::RequestId, Provenance::Middleware
10
+ end
11
+
12
+ initializer "provenance.action_controller" do
13
+ ActiveSupport.on_load(:action_controller) { include Provenance::Controller }
14
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*, payload|
15
+ Provenance::Controller::Subscriber.call(payload)
16
+ end
17
+ end
18
+
19
+ initializer "provenance.active_job" do
20
+ ActiveSupport.on_load(:active_job) { include Provenance::Job }
21
+ end
22
+
23
+ config.after_initialize do
24
+ Provenance.config.validate!
25
+ end
26
+
27
+ rake_tasks do
28
+ load File.expand_path("../tasks/provenance.rake", __dir__)
29
+ end
30
+
31
+ generators do
32
+ require "generators/provenance/install/install_generator"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ # Opt-in rake integration: every executed task becomes a +task+ action.
5
+ #
6
+ # @example Rakefile
7
+ # Provenance::Rake.install!
8
+ module Rake
9
+ # Prepended into Rake::Task.
10
+ module TaskExtension
11
+ # @api private
12
+ def execute(args = nil)
13
+ return super unless Provenance.enabled?
14
+
15
+ Provenance.track(kind: :task, name: name) { super }
16
+ end
17
+ end
18
+
19
+ # Wraps rake task execution. Idempotent.
20
+ #
21
+ # @return [void]
22
+ def self.install!
23
+ require "rake"
24
+ ::Rake::Task.prepend(TaskExtension) unless ::Rake::Task.ancestors.include?(TaskExtension)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Provenance
6
+ # Builds entity changes from model callbacks and bulk relation calls and attaches
7
+ # them to the current action once their transaction commits.
8
+ module Recorder
9
+ BULK_KEY = :provenance_bulk_depth
10
+ private_constant :BULK_KEY
11
+
12
+ module_function
13
+
14
+ # Records a create, update or destroy of a single record.
15
+ #
16
+ # @param record [ActiveRecord::Base]
17
+ # @param operation [Symbol] :create, :update or :destroy
18
+ # @return [void]
19
+ def record(record, operation)
20
+ options = options_for(record.class)
21
+ return unless options
22
+ return if options.ignore_if&.call(record)
23
+
24
+ raw = case operation
25
+ when :create, :destroy then record.attributes.compact
26
+ when :update then record.saved_changes
27
+ end
28
+ diff = filter(record.class, raw, options)
29
+ return if operation == :update && diff.empty?
30
+
31
+ change = EntityChange.new(entity: record.class.name, entity_id: record.id, operation: operation, diff: diff)
32
+ append(record.class, change, "#{record.class.name}.#{operation}")
33
+ end
34
+
35
+ # Records a link or unlink through a tracked association.
36
+ #
37
+ # @param owner [ActiveRecord::Base]
38
+ # @param association [Symbol]
39
+ # @param target [ActiveRecord::Base]
40
+ # @param operation [Symbol] :link or :unlink
41
+ # @return [void]
42
+ def association(owner, association, target, operation)
43
+ options = options_for(owner.class)
44
+ return unless options
45
+ return if options.ignore_if&.call(owner)
46
+
47
+ change = EntityChange.new(
48
+ entity: owner.class.name, entity_id: -> { owner.id }, operation: operation, association: association,
49
+ target: {"type" => target.class.name, "id" => -> { target.id }}
50
+ )
51
+ append(owner.class, change, "#{owner.class.name}.#{operation}")
52
+ end
53
+
54
+ # Wraps update_all/delete_all on a relation of a tracked model.
55
+ #
56
+ # @param relation [ActiveRecord::Relation]
57
+ # @param operation [Symbol] :bulk_update or :bulk_delete
58
+ # @param updates [Hash, String, Array, nil] the update_all argument
59
+ # @yieldreturn [Integer] affected rows
60
+ # @return [Integer] the block's return value
61
+ def bulk(relation, operation, updates: nil)
62
+ model = relation.model
63
+ options = options_for(model)
64
+ return yield if options.nil? || bulk_depth.positive?
65
+
66
+ ids, truncated = bulk_ids(relation)
67
+ count = nested_bulk { yield }
68
+ diff = updates.is_a?(Hash) ? filter(model, updates.to_h { |k, v| [k.to_s, [nil, v]] }, options) : nil
69
+ change = EntityChange.new(
70
+ entity: model.name, operation: operation, diff: diff, count: count,
71
+ where: fingerprint(relation), ids: ids, truncated: truncated
72
+ )
73
+ append(model, change, "#{model.name}.#{operation}")
74
+ count
75
+ end
76
+
77
+ # Wraps insert_all/upsert_all on a tracked model.
78
+ #
79
+ # @param relation [ActiveRecord::Relation]
80
+ # @param attributes [Array<Hash>, Hash]
81
+ # @yieldreturn [ActiveRecord::Result]
82
+ # @return [ActiveRecord::Result] the block's return value
83
+ def bulk_insert(relation, attributes)
84
+ model = relation.model
85
+ options = options_for(model)
86
+ return yield if options.nil? || bulk_depth.positive?
87
+
88
+ result = nested_bulk { yield }
89
+ rows = attributes.is_a?(Hash) ? [attributes] : Array(attributes)
90
+ limit = Provenance.config.bulk_ids_limit
91
+ ids = inserted_ids(model, rows, result)
92
+ change = EntityChange.new(
93
+ entity: model.name, operation: :bulk_insert, count: rows.size,
94
+ ids: ids.first(limit), truncated: ids.size > limit
95
+ )
96
+ append(model, change, "#{model.name}.bulk_insert")
97
+ result
98
+ end
99
+
100
+ # Attaches a change to the current action after the surrounding transaction
101
+ # commits, or to an implicit +custom+ action when none is open.
102
+ #
103
+ # @param model [Class]
104
+ # @param change [EntityChange]
105
+ # @param implicit_name [String]
106
+ # @return [void]
107
+ def append(model, change, implicit_name)
108
+ action = Provenance.current
109
+ if action
110
+ after_commit(model) { action.add_change(change) }
111
+ else
112
+ action = Action.new(kind: :custom, name: implicit_name)
113
+ after_commit(model) { action.add_change(change) }
114
+ Emitter.finish(action)
115
+ end
116
+ end
117
+
118
+ # Runs the block when the model's innermost joinable transaction commits, or
119
+ # immediately when none is open. Rolled-back transactions never run it.
120
+ #
121
+ # @param model [Class]
122
+ # @return [void]
123
+ def after_commit(model, &block)
124
+ transaction = model.connection_pool.active_connection&.current_transaction
125
+ if transaction&.open? && transaction.joinable?
126
+ transaction.after_commit(&block)
127
+ else
128
+ block.call
129
+ end
130
+ end
131
+
132
+ # @api private
133
+ def options_for(model)
134
+ return nil unless Provenance.enabled?
135
+
136
+ Provenance.registry.options_for(model)
137
+ end
138
+
139
+ # @api private
140
+ def filter(model, attributes, options)
141
+ primary_keys = Array(model.primary_key).map(&:to_s)
142
+ diff = attributes.to_h { |k, v| [k.to_s, v] }
143
+ .select { |name, _| !primary_keys.include?(name) && options.track?(name) }
144
+ Redactor.diff(diff, options.redact + encrypted_attributes(model))
145
+ end
146
+
147
+ # @api private
148
+ def encrypted_attributes(model)
149
+ model.respond_to?(:encrypted_attributes) ? Array(model.encrypted_attributes).map(&:to_s) : []
150
+ end
151
+
152
+ # @api private
153
+ def bulk_ids(relation)
154
+ limit = Provenance.config.bulk_ids_limit
155
+ ids = if relation.loaded?
156
+ relation.records.map(&:id)
157
+ else
158
+ fetch = limit + 1
159
+ fetch = [relation.limit_value, fetch].min if relation.limit_value
160
+ relation.limit(fetch).pluck(*Array(relation.model.primary_key))
161
+ end
162
+ [ids.first(limit).map(&:to_s), ids.size > limit]
163
+ end
164
+
165
+ # @api private
166
+ def inserted_ids(model, rows, result)
167
+ primary_key = model.primary_key
168
+ return [] unless primary_key.is_a?(String)
169
+
170
+ ids = if result.respond_to?(:columns) && result.columns.include?(primary_key)
171
+ result.rows.map { |row| row[result.columns.index(primary_key)] }
172
+ else
173
+ rows.filter_map { |row| row[primary_key] || row[primary_key.to_sym] }
174
+ end
175
+ ids.map(&:to_s)
176
+ end
177
+
178
+ # Fingerprint of a relation's WHERE clause with bind values replaced by
179
+ # placeholders, so the same query shape yields the same fingerprint.
180
+ #
181
+ # @param relation [ActiveRecord::Relation]
182
+ # @return [String, nil] "sha256:<16 hex chars>"
183
+ def fingerprint(relation)
184
+ return nil if relation.where_clause.empty?
185
+
186
+ sql = relation.model.with_connection do |connection|
187
+ connection.visitor.compile(relation.where_clause.ast, Arel::Collectors::SQLString.new)
188
+ end
189
+ "sha256:#{Digest::SHA256.hexdigest(sql)[0, 16]}"
190
+ end
191
+
192
+ # @api private
193
+ def bulk_depth
194
+ ActiveSupport::IsolatedExecutionState[BULK_KEY].to_i
195
+ end
196
+
197
+ # @api private
198
+ def nested_bulk
199
+ state = ActiveSupport::IsolatedExecutionState
200
+ state[BULK_KEY] = bulk_depth + 1
201
+ yield
202
+ ensure
203
+ state[BULK_KEY] -= 1
204
+ end
205
+ end
206
+ end