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 +4 -4
- data/CHANGELOG.md +11 -0
- data/lib/athar/dashboard/actor_options.rb +10 -6
- data/lib/athar/dashboard/connection_info.rb +3 -6
- data/lib/athar/dashboard/feed_query.rb +54 -28
- data/lib/athar/dashboard/kpi_calculator.rb +13 -9
- data/lib/athar/dashboard/model_registry.rb +2 -1
- data/lib/athar/dashboard/sparkline.rb +6 -2
- data/lib/athar/retention.rb +2 -2
- data/lib/athar/version.rb +1 -1
- data/lib/athar.rb +13 -0
- data/lib/generators/athar/model/model_generator.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2d955a6b91e0235f3d0320d0955a0ab48bb1d006dff1c9170b2b421d0d1eeea
|
|
4
|
+
data.tar.gz: 86d78661ae27929bca015f19de65a76fffc779360fb5c25b1a36bdbf88e738d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
13
|
+
def call
|
|
14
14
|
Result.new(
|
|
15
|
-
users: load_users
|
|
16
|
-
system: load_system
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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"]
|
|
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
|
|
23
|
-
aggregates = aggregate
|
|
24
|
-
truncates_30d = truncate_count
|
|
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
|
|
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
|
|
36
|
+
sparkline: Sparkline.new(model: @model, now: @now).buckets
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
|
-
def aggregate
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
38
|
+
connection.quote(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def connection
|
|
42
|
+
Athar.audit_connection
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
end
|
data/lib/athar/retention.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
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
|
-
!
|
|
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.
|