Class: ForestAdminAgent::AuditTrail::Store
- Inherits:
-
Object
- Object
- ForestAdminAgent::AuditTrail::Store
- 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
- #append(record) ⇒ Object
-
#append_all(records) ⇒ Object
Inserts rows and returns their ids, in the order given.
-
#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.
-
#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.
- #confirm(id, attributes) ⇒ Object
- #connect! ⇒ Object
- #count_by_record(collection:, record_id:, user_ids: nil, start_timestamp: nil, end_timestamp: nil, fields: nil, search: nil) ⇒ Object
-
#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.
-
#initialize(database:, schema: DEFAULT_SCHEMA, table_name: DEFAULT_TABLE) ⇒ Store
constructor
A new instance of Store.
- #list_by_correlation(collection:, record_id:, correlation_key:) ⇒ Object
- #list_by_correlations(collection:, record_id:, correlation_keys:) ⇒ Object
- #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
-
#list_since(collection:, record_id:, timestamp:) ⇒ Object
Entries recorded strictly after
timestamp, newest first: what a state reconstruction has to undo. -
#renamed_from(collection:, record_id:) ⇒ Object
The ids this record was renamed from, each with the moment it stopped being that id.
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 (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: , end_timestamp: , fields: fields, search: search) .where.not(user_id: nil) .distinct .pluck(*AUTHOR_COLUMNS) .map { |values| AUTHOR_COLUMNS.zip(values).to_h } .uniq { || [: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.
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: , 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: , 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()) .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 { |(_, , row_id)| [, row_id] } { id: id, until: as_iso(at), until_row: row } end end |