athar 0.3.2 → 0.3.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3606e5dbd82d65ccb6070d0cd82a6408110f10eb6a449546d7ec4cf573d2cbd6
4
- data.tar.gz: 158b8db5f4ce145bee31e562dfc6ab9ced0b7e5bab2c8cfbe7268b129c276836
3
+ metadata.gz: a2d955a6b91e0235f3d0320d0955a0ab48bb1d006dff1c9170b2b421d0d1eeea
4
+ data.tar.gz: 86d78661ae27929bca015f19de65a76fffc779360fb5c25b1a36bdbf88e738d6
5
5
  SHA512:
6
- metadata.gz: 29961e6f2302bb2cc732629ccf57d775fa089f950424860e73b9d56b8f26faebd0dc00874137597774c33e450cd6c3ae32ab62b0647d7c842d8b44beb76ebac6
7
- data.tar.gz: 3a6229cc444e9fe48d84fa8c61c00690047422d604b25a8daa0461222279684b1471d0b936a9648eccfbb659aa6b1707dd8ac1d305b1748535c2b12d0632af09
6
+ metadata.gz: 040a8737dabee43a223852a9d0c7d3678f7fe9e76df2fb5303bedaffa96a37a8e4bfa7f00d797ef439440b48745d9209c932248ebc4dfba7b5bfa5dd657f678f
7
+ data.tar.gz: 1303fbf477398696d6d7eac30f8ea7be923c5e54042f1b1539b118687d9f2e480c1a49794d8529ef073a8a438e15b94d7e8e87c17f35eb84114cd4fb0d89645b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
6
6
 
7
+ ## [0.3.3] - 2026-05-10
8
+
9
+ ### Fixed
10
+
11
+ - Fixed `PG::DatatypeMismatch: UNION types <pk_type> and bigint cannot be matched` when opening the dashboard on hosts where `Rails.configuration.generators.options[:active_record][:primary_key_type] = :uuid` (or `:integer`) caused the install migration to create the audit tables with a non-bigint primary key. The dashboard's UNION between `athar_deletions` and `athar_table_events` now resolves the audit `id` SQL type at runtime and types the empty UNION leg accordingly, so it composes cleanly regardless of the host's primary-key choice.
12
+
13
+ ### Added
14
+
15
+ - `Athar.audit_connection` and `Athar.audit_db_config` module methods centralizing the connection both audit tables share. Multi-database hosts can route audit storage to a dedicated connection by calling `Athar::Deletion.connects_to(...)` in an initializer; the dashboard, retention, and generators follow automatically. The dashboard topbar now reflects the audit DB name when this routing is configured.
16
+ - `FeedQuery` raises `ArgumentError` up-front when `athar_deletions.id` and `athar_table_events.id` resolve to different SQL types, replacing a cryptic Postgres UNION error with a clear message naming both tables and their detected types.
17
+
7
18
  ## [0.3.2] - 2026-05-10
8
19
 
9
20
  ### Fixed
@@ -10,17 +10,17 @@ module Athar
10
10
  @cutoff = cutoff
11
11
  end
12
12
 
13
- def call(connection: ActiveRecord::Base.connection)
13
+ def call
14
14
  Result.new(
15
- users: load_users(connection),
16
- system: load_system(connection),
15
+ users: load_users,
16
+ system: load_system,
17
17
  anonymous_label: "(anonymous)"
18
18
  )
19
19
  end
20
20
 
21
21
  private
22
22
 
23
- def load_users(connection) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
23
+ def load_users # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
24
24
  rows = connection.select_all(<<~SQL).to_a
25
25
  SELECT actor_type, actor_id::text AS actor_id, MAX(deleted_at) AS last_seen
26
26
  FROM #{Athar::DELETIONS_TABLE_NAME}
@@ -50,7 +50,7 @@ module Athar
50
50
  end
51
51
  end
52
52
 
53
- def load_system(connection)
53
+ def load_system
54
54
  rows = connection.select_all(<<~SQL).to_a
55
55
  SELECT metadata->>'actor' AS name, MAX(deleted_at) AS last_seen
56
56
  FROM #{Athar::DELETIONS_TABLE_NAME}
@@ -64,7 +64,11 @@ module Athar
64
64
  end
65
65
 
66
66
  def quote(value)
67
- ActiveRecord::Base.connection.quote(value)
67
+ connection.quote(value)
68
+ end
69
+
70
+ def connection
71
+ Athar.audit_connection
68
72
  end
69
73
  end
70
74
  end
@@ -6,12 +6,9 @@ module Athar
6
6
  class ConnectionInfo
7
7
  attr_reader :database, :version
8
8
 
9
- def self.fetch(
10
- connection: ActiveRecord::Base.connection,
11
- db_config: ActiveRecord::Base.connection_db_config
12
- )
13
- version_num = connection.select_value("SHOW server_version_num").to_i
14
- new(database: db_config.database.to_s, version: "pg#{version_num / 10_000}")
9
+ def self.fetch
10
+ version_num = Athar.audit_connection.select_value("SHOW server_version_num").to_i
11
+ new(database: Athar.audit_db_config.database.to_s, version: "pg#{version_num / 10_000}")
15
12
  end
16
13
 
17
14
  def initialize(database:, version:)
@@ -3,9 +3,6 @@
3
3
  module Athar
4
4
  module Dashboard
5
5
  class FeedQuery # rubocop:disable Metrics/ClassLength
6
- # Typed empty SELECT used for either UNION leg when the kind filter
7
- # excludes that leg. Column types must match the live SELECTs above
8
- # so PostgreSQL can compose the UNION without coercion.
9
6
  DELETION_SEARCH_COLUMNS = %w[
10
7
  record_type record_id::text schema_name table_name
11
8
  actor_type actor_id::text record_data::text metadata::text
@@ -15,22 +12,6 @@ module Athar
15
12
  schema_name table_name actor_type actor_id::text metadata::text
16
13
  ].freeze
17
14
 
18
- EMPTY_LEG_SELECT = <<~SQL
19
- SELECT
20
- NULL::text AS kind,
21
- NULL::bigint AS id,
22
- NULL::text AS record_type,
23
- NULL::text AS record_id,
24
- NULL::text AS schema_name,
25
- NULL::text AS table_name,
26
- NULL::text AS actor_type,
27
- NULL::text AS actor_id,
28
- NULL::timestamptz AS occurred_at,
29
- NULL::jsonb AS record_data,
30
- NULL::jsonb AS metadata
31
- WHERE FALSE
32
- SQL
33
-
34
15
  def initialize(filters:, per_page: 25, now: Time.current, registry: nil)
35
16
  @filters = filters
36
17
  @per_page = per_page
@@ -38,11 +19,13 @@ module Athar
38
19
  @registry = registry
39
20
  end
40
21
 
41
- def call(connection: ActiveRecord::Base.connection)
22
+ def call
23
+ audit_id_type # raise early on type mismatch instead of letting Postgres bubble a cryptic error
42
24
  connection.select_all(page_sql, "FeedQuery#call").map { |row| to_row(row) }
43
25
  end
44
26
 
45
- def total(connection: ActiveRecord::Base.connection)
27
+ def total
28
+ audit_id_type
46
29
  connection.select_value(count_sql, "FeedQuery#total").to_i
47
30
  end
48
31
 
@@ -67,7 +50,7 @@ module Athar
67
50
  end
68
51
 
69
52
  def deletion_select
70
- return EMPTY_LEG_SELECT if filters.kind == "truncate"
53
+ return empty_leg_select if filters.kind == "truncate"
71
54
 
72
55
  <<~SQL
73
56
  SELECT
@@ -88,7 +71,7 @@ module Athar
88
71
  end
89
72
 
90
73
  def table_event_select
91
- return EMPTY_LEG_SELECT if filters.kind == "delete"
74
+ return empty_leg_select if filters.kind == "delete"
92
75
 
93
76
  <<~SQL
94
77
  SELECT
@@ -108,13 +91,52 @@ module Athar
108
91
  SQL
109
92
  end
110
93
 
94
+ def empty_leg_select
95
+ <<~SQL
96
+ SELECT
97
+ NULL::text AS kind,
98
+ NULL::#{audit_id_type} AS id,
99
+ NULL::text AS record_type,
100
+ NULL::text AS record_id,
101
+ NULL::text AS schema_name,
102
+ NULL::text AS table_name,
103
+ NULL::text AS actor_type,
104
+ NULL::text AS actor_id,
105
+ NULL::timestamptz AS occurred_at,
106
+ NULL::jsonb AS record_data,
107
+ NULL::jsonb AS metadata
108
+ WHERE FALSE
109
+ SQL
110
+ end
111
+
112
+ # Native SQL type of the `id` column on both audit tables, used to type
113
+ # the empty UNION leg so it matches the live legs without coercion.
114
+ # The two tables are always created together by the install migration
115
+ # and share the same primary_key_type; we verify that here so a manual
116
+ # divergence raises a clear error rather than a cryptic UNION mismatch.
117
+ def audit_id_type
118
+ @audit_id_type ||= begin
119
+ deletions = connection.columns(Athar::DELETIONS_TABLE_NAME).find { |c| c.name == "id" }.sql_type
120
+ events = connection.columns(Athar::TABLE_EVENTS_TABLE_NAME).find { |c| c.name == "id" }.sql_type
121
+
122
+ if deletions != events
123
+ raise ArgumentError,
124
+ "athar audit tables have mismatched id sql_types: " \
125
+ "#{Athar::DELETIONS_TABLE_NAME}.id=#{deletions}, " \
126
+ "#{Athar::TABLE_EVENTS_TABLE_NAME}.id=#{events}"
127
+ end
128
+
129
+ deletions
130
+ end
131
+ end
132
+
111
133
  def deletion_where_clause # rubocop:disable Metrics/AbcSize
112
134
  clauses = ["TRUE"]
113
135
  clauses << time_clause("deleted_at")
114
136
  clauses << "record_type = #{quote(filters.model)}" if filters.model
115
137
  # capture_mode is not on the audit row; mode filter operates by table.
116
138
  clauses << tables_with_mode_clause(filters.mode) if filters.mode != "all"
117
- clauses << actor_clause("athar_deletions")
139
+ clauses << actor_clause
118
140
  clauses << search_clause(DELETION_SEARCH_COLUMNS)
119
141
  clauses.compact.join(" AND ")
120
142
  end
@@ -124,7 +146,7 @@ module Athar
124
146
  clauses << time_clause("occurred_at")
125
147
  clauses << tables_for_model_clause if filters.model
126
148
  clauses << tables_with_mode_clause(filters.mode) if filters.mode != "all"
127
- clauses << actor_clause("athar_table_events")
149
+ clauses << actor_clause
128
150
  clauses << search_clause(TABLE_EVENT_SEARCH_COLUMNS)
129
151
  clauses.compact.join(" AND ")
130
152
  end
@@ -136,7 +158,7 @@ module Athar
136
158
  "#{column} >= #{quote(cutoff)}"
137
159
  end
138
160
 
139
- def actor_clause(_table)
161
+ def actor_clause
140
162
  actor_filter = filters.actor_filter
141
163
  return nil unless actor_filter
142
164
 
@@ -182,13 +204,17 @@ module Athar
182
204
  end
183
205
 
184
206
  def quote(value)
185
- ActiveRecord::Base.connection.quote(value)
207
+ connection.quote(value)
208
+ end
209
+
210
+ def connection
211
+ Athar.audit_connection
186
212
  end
187
213
 
188
214
  def to_row(hash)
189
215
  {
190
216
  kind: hash["kind"],
191
- id: hash["id"].to_i,
217
+ id: hash["id"],
192
218
  record_type: hash["record_type"],
193
219
  record_id: hash["record_id"],
194
220
  schema_name: hash["schema_name"],
@@ -19,27 +19,27 @@ module Athar
19
19
  @registry = registry
20
20
  end
21
21
 
22
- def call(connection: ActiveRecord::Base.connection) # rubocop:disable Metrics/AbcSize
23
- aggregates = aggregate(connection)
24
- truncates_30d = truncate_count(connection)
22
+ def call # rubocop:disable Metrics/AbcSize
23
+ aggregates = aggregate
24
+ truncates_30d = truncate_count
25
25
 
26
26
  Result.new(
27
27
  # scope_total covers the same universe the feed UNIONs over: row
28
28
  # deletions plus all table events for the model's tables (not just
29
29
  # the last 30d), so "filtered N of M" never overshoots M.
30
- scope_total: aggregates["scope_total"].to_i + table_event_total(connection),
30
+ scope_total: aggregates["scope_total"].to_i + table_event_total,
31
31
  last_24h: aggregates["last_24h"].to_i,
32
32
  last_7d: aggregates["last_7d"].to_i,
33
33
  prior_7d: aggregates["prior_7d"].to_i,
34
34
  distinct_actors_30d: aggregates["distinct_actors_30d"].to_i,
35
35
  truncates_30d: truncates_30d,
36
- sparkline: Sparkline.new(model: @model, now: @now).buckets(connection: connection)
36
+ sparkline: Sparkline.new(model: @model, now: @now).buckets
37
37
  )
38
38
  end
39
39
 
40
40
  private
41
41
 
42
- def aggregate(connection) # rubocop:disable Metrics/AbcSize
42
+ def aggregate # rubocop:disable Metrics/AbcSize
43
43
  sql = <<~SQL
44
44
  SELECT
45
45
  COUNT(*) AS scope_total,
@@ -56,7 +56,7 @@ module Athar
56
56
  connection.select_one(sql)
57
57
  end
58
58
 
59
- def truncate_count(connection)
59
+ def truncate_count
60
60
  sql = <<~SQL
61
61
  SELECT COUNT(*) AS n FROM #{Athar::TABLE_EVENTS_TABLE_NAME}
62
62
  WHERE event_type = 'truncate' AND occurred_at > #{quote(@now - 30.days)}
@@ -65,7 +65,7 @@ module Athar
65
65
  connection.select_value(sql).to_i
66
66
  end
67
67
 
68
- def table_event_total(connection)
68
+ def table_event_total
69
69
  sql = <<~SQL
70
70
  SELECT COUNT(*) AS n FROM #{Athar::TABLE_EVENTS_TABLE_NAME}
71
71
  WHERE TRUE #{table_scope_clause}
@@ -95,7 +95,11 @@ module Athar
95
95
  end
96
96
 
97
97
  def quote(value)
98
- ActiveRecord::Base.connection.quote(value)
98
+ connection.quote(value)
99
+ end
100
+
101
+ def connection
102
+ Athar.audit_connection
99
103
  end
100
104
  end
101
105
  end
@@ -37,7 +37,8 @@ module Athar
37
37
  PG_ARRAY_BARE = /[^,]+/
38
38
 
39
39
  class << self
40
- def discover(connection = ActiveRecord::Base.connection) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
40
+ def discover # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
41
+ connection = Athar.audit_connection
41
42
  triggers = parse_delete_triggers(connection)
42
43
  truncate_keys = truncate_trigger_keys(connection)
43
44
  counts = load_counts(connection)
@@ -10,7 +10,7 @@ module Athar
10
10
  @now = now
11
11
  end
12
12
 
13
- def buckets(connection: ActiveRecord::Base.connection) # rubocop:disable Metrics/AbcSize
13
+ def buckets # rubocop:disable Metrics/AbcSize
14
14
  rows = connection.select_all(<<~SQL).to_a
15
15
  SELECT date_trunc('day', deleted_at) AS day, COUNT(*) AS n
16
16
  FROM #{Athar::DELETIONS_TABLE_NAME}
@@ -35,7 +35,11 @@ module Athar
35
35
  end
36
36
 
37
37
  def quote(value)
38
- ActiveRecord::Base.connection.quote(value)
38
+ connection.quote(value)
39
+ end
40
+
41
+ def connection
42
+ Athar.audit_connection
39
43
  end
40
44
  end
41
45
  end
@@ -60,7 +60,7 @@ module Athar
60
60
  def prune_by_age(table, time_column, cutoff, batch_size, max_batches) # rubocop:disable Metrics/MethodLength
61
61
  return [0, 0] if max_batches <= 0
62
62
 
63
- connection = ActiveRecord::Base.connection
63
+ connection = Athar.audit_connection
64
64
  total = 0
65
65
  batches = 0
66
66
  loop do
@@ -88,7 +88,7 @@ module Athar
88
88
  def prune_by_count(table, max_count, batch_size, max_batches) # rubocop:disable Metrics/MethodLength
89
89
  return [0, 0] if max_batches <= 0
90
90
 
91
- connection = ActiveRecord::Base.connection
91
+ connection = Athar.audit_connection
92
92
  boundary = connection.select_one(
93
93
  <<~SQL
94
94
  SELECT deleted_at, id
data/lib/athar/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Athar
4
- VERSION = "0.3.2"
4
+ VERSION = "0.3.3"
5
5
  end
data/lib/athar.rb CHANGED
@@ -55,6 +55,19 @@ module Athar
55
55
  def without_capture(...)
56
56
  Context.without_capture(...)
57
57
  end
58
+
59
+ # The connection both audit tables share. The install migration creates
60
+ # `athar_deletions` and `athar_table_events` together, and the dashboard
61
+ # composes them via UNION — so they must live on a single physical
62
+ # connection. Centralized here so multi-DB hosts can route both via
63
+ # `Athar::Deletion.connects_to(...)` and the gem follows automatically.
64
+ def audit_connection
65
+ Deletion.connection
66
+ end
67
+
68
+ def audit_db_config
69
+ Deletion.connection_db_config
70
+ end
58
71
  end
59
72
  end
60
73
 
@@ -441,7 +441,7 @@ module Athar
441
441
  "athar_mask_#{mask}"
442
442
  ])
443
443
 
444
- !ActiveRecord::Base.connection.select_value(sql).nil?
444
+ !Athar.audit_connection.select_value(sql).nil?
445
445
  rescue StandardError
446
446
  # If we can't reach the DB, fall back to false. The trigger install
447
447
  # itself will fail loudly later if the function truly doesn't exist.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: athar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ali Hamdi Ali Fadel