Class: ForestAdminAgent::AuditTrail::Store

Inherits:
Object
  • Object
show all
Defined in:
lib/forest_admin_agent/audit_trail/store.rb

Overview

SQL-backed storage that both writes every audited change and reads the per-record history back.

#connect! opens the connection and migrates; the agent factory calls it at boot rather than leaving it to the first write, so a database the agent cannot reach is a startup failure instead of an agent that looks healthy while recording nothing — and, under critical: true, instead of one that refuses every write the moment somebody first tries to save something.

Constant Summary collapse

DEFAULT_SCHEMA =
'forest'.freeze
DEFAULT_TABLE =
'audit_logs'.freeze
COLUMNS =
%i[timestamp operation collection record_id previous_record_id status user_id user_first_name
user_last_name user_email action_name correlation_key previous_values new_values].freeze
AUTHOR_COLUMNS =
%i[user_id user_first_name user_last_name user_email].freeze

Instance Method Summary collapse

Constructor Details

#initialize(database:, schema: DEFAULT_SCHEMA, table_name: DEFAULT_TABLE) ⇒ Store

Returns a new instance of Store.



18
19
20
21
22
23
24
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 18

def initialize(database:, schema: DEFAULT_SCHEMA, table_name: DEFAULT_TABLE)
  @database = database
  @schema = schema
  @table_name = table_name
  @mutex = Mutex.new
  @ready = false
end

Instance Method Details

#append(record) ⇒ Object



32
33
34
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 32

def append(record)
  append_all([record]).first
end

#append_all(records) ⇒ Object

Inserts rows and returns their ids, in the order given. Batched, because a "delete all" snapshot can be thousands of records and the pending/confirm protocol writes each of them twice.

The ids are matched to their rows by record_id rather than by the order RETURNING happens to come back in, which Postgres does not promise: pairing them positionally would confirm each pending row with another record's diff. One row per record per operation, so that key is unique within a batch — bar a pending create, which has no id yet and is always a batch of one.



43
44
45
46
47
48
49
50
51
52
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 43

def append_all(records)
  return [] if records.empty?

  rows = records.map { |record| to_row(record) }
  return rows.map { |row| model.create!(row).id } unless batch_returning?(rows)

  returned = model.insert_all(rows, returning: %i[id record_id]).rows.to_h { |id, key| [key, id] }

  rows.map { |row| returned[row[:record_id]] }
end

#authors_by_record(collection:, record_id:, user_ids: nil, start_timestamp: nil, end_timestamp: nil, fields: nil, search: nil) ⇒ Object

The distinct authors of the entries the current filters match, whatever page is being asked for. The identity comes from the rows themselves, so a user who has since been renamed or removed still reads as they were when they acted.



98
99
100
101
102
103
104
105
106
107
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 98

def authors_by_record(collection:, record_id:, user_ids: nil, start_timestamp: nil,
                      end_timestamp: nil, fields: nil, search: nil)
  scope(collection, record_id, user_ids: user_ids, start_timestamp: start_timestamp,
                               end_timestamp: end_timestamp, fields: fields, search: search)
    .where.not(user_id: nil)
    .distinct
    .pluck(*AUTHOR_COLUMNS)
    .map { |values| AUTHOR_COLUMNS.zip(values).to_h }
    .uniq { |author| author[:user_id] }
end

#batch_returning?(rows) ⇒ Boolean

One insert per row when the ids cannot be matched back: no RETURNING on this adapter (MySQL), or a batch whose record ids are not distinct enough to pair on.

Returns:

  • (Boolean)


56
57
58
59
60
61
62
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 56

def batch_returning?(rows)
  return false unless model.connection.supports_insert_returning?

  keys = rows.map { |row| row[:record_id] }

  keys.none?(&:nil?) && keys.uniq.size == keys.size
end

#confirm(id, attributes) ⇒ Object



64
65
66
67
68
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 64

def confirm(id, attributes)
  row = model.find_by(id: id)

  row&.update!(**attributes, status: Recording::DONE)
end

#connect!Object



26
27
28
29
30
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 26

def connect!
  ensure_ready

  self
end

#count_by_record(collection:, record_id:, user_ids: nil, start_timestamp: nil, end_timestamp: nil, fields: nil, search: nil) ⇒ Object



89
90
91
92
93
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 89

def count_by_record(collection:, record_id:, user_ids: nil, start_timestamp: nil,
                    end_timestamp: nil, fields: nil, search: nil)
  scope(collection, record_id, user_ids: user_ids, start_timestamp: start_timestamp,
                               end_timestamp: end_timestamp, fields: fields, search: search).count
end

#discard(ids) ⇒ Object

A write that turned out to change nothing leaves no trace: the pending row goes rather than sitting there implying the write is unaccounted for.



72
73
74
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 72

def discard(ids)
  model.where(id: ids).delete_all unless ids.empty?
end

#list_by_correlation(collection:, record_id:, correlation_key:) ⇒ Object



142
143
144
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 142

def list_by_correlation(collection:, record_id:, correlation_key:)
  list_by_correlations(collection: collection, record_id: record_id, correlation_keys: [correlation_key])
end

#list_by_correlations(collection:, record_id:, correlation_keys:) ⇒ Object



146
147
148
149
150
151
152
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 146

def list_by_correlations(collection:, record_id:, correlation_keys:)
  return [] if correlation_keys.empty?

  model.where(collection: collection, record_id: record_id, correlation_key: correlation_keys)
       .order(:timestamp, :id)
       .map { |row| from_row(row) }
end

#list_by_record(collection:, record_id:, skip: 0, limit: nil, user_ids: nil, start_timestamp: nil, end_timestamp: nil, fields: nil, search: nil, order: 'asc') ⇒ Object



76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 76

def list_by_record(collection:, record_id:, skip: 0, limit: nil, user_ids: nil, start_timestamp: nil,
                   end_timestamp: nil, fields: nil, search: nil, order: 'asc')
  # `id` (insertion order) breaks ties on equal timestamps in both directions, keeping pages
  # deterministic and stable.
  relation = scope(collection, record_id, user_ids: user_ids, start_timestamp: start_timestamp,
                                          end_timestamp: end_timestamp, fields: fields, search: search)
             .order(timestamp: order.to_s == 'desc' ? :desc : :asc, id: :asc)
             .offset(skip || 0)
  relation = relation.limit(limit) unless limit.nil?

  relation.map { |row| from_row(row) }
end

#list_since(collection:, record_id:, timestamp:) ⇒ Object

Entries recorded strictly after timestamp, newest first: what a state reconstruction has to undo. Strictly after, so an entry stamped exactly at the requested instant counts as part of that state instead of being reverted out of it. Confirmed rows only: a pending one records an attempt whose outcome is unknown, and undoing a change that may never have happened would invent a state the record was never in. The history reads keep pending rows — they are evidence, and status tells the reader what they are — but a reconstruction cannot act on them.



134
135
136
137
138
139
140
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 134

def list_since(collection:, record_id:, timestamp:)
  model.where(collection: collection, status: Recording::DONE)
       .where(*segments_condition(record_id))
       .where('timestamp > ?', as_time(timestamp))
       .order(timestamp: :desc, id: :desc)
       .map { |row| from_row(row) }
end

#renamed_from(collection:, record_id:) ⇒ Object

The ids this record was renamed from, each with the moment it stopped being that id. Walking those back is what lets a history query reach rows written before a rename — they stay under the id they were written with, since that is the id they were true of — and the moment bounds how far: the id it left may have been taken by another record afterwards, whose rows are none of this record's business.



113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/forest_admin_agent/audit_trail/store.rb', line 113

def renamed_from(collection:, record_id:)
  model.where(collection: collection, record_id: record_id)
       .where.not(previous_record_id: nil)
       .pluck(:previous_record_id, :timestamp, :id)
       .group_by(&:first)
       .map do |id, rows|
         # The row id comes along as the tie-breaker: the trail orders itself by (timestamp, id), so a
         # bound that knew only the timestamp would mean something slightly different from "before".
         _, at, row = rows.max_by { |(_, timestamp, row_id)| [timestamp, row_id] }

         { id: id, until: as_iso(at), until_row: row }
       end
end