athar 0.1.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.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actor_lookup"
4
+
5
+ module Athar
6
+ class Deletion < ActiveRecord::Base
7
+ include ActorLookup
8
+
9
+ self.table_name = Athar::DELETIONS_TABLE_NAME
10
+
11
+ class << self
12
+ def recent
13
+ order(deleted_at: :desc, id: :desc)
14
+ end
15
+
16
+ def for_record(record_or_class, id = nil)
17
+ # Filter by (schema_name, table_name, record_id):
18
+ # - `record_type` is unreliable because STI subclass rows are stored
19
+ # under the concrete class name (`"Admin"`) while
20
+ # `Admin.base_class.name` is `"User"`.
21
+ # - Active Record returns `"reporting.reporting_buckets"` for models
22
+ # in non-public schemas, but Athar stores schema and table in
23
+ # separate columns. Splitting on `.` mirrors what the generator
24
+ # does at trigger-install time.
25
+ if id.nil? && record_or_class.is_a?(ActiveRecord::Base)
26
+ schema, table = split_schema_qualified(record_or_class.class.table_name)
27
+ where(schema_name: schema, table_name: table, record_id: record_or_class.id)
28
+ else
29
+ raise ArgumentError, "id is required when passing a record class or type" if id.nil?
30
+
31
+ klass = record_or_class.is_a?(Class) ? record_or_class : record_or_class.to_s.constantize
32
+ schema, table = split_schema_qualified(klass.table_name)
33
+ where(schema_name: schema, table_name: table, record_id: id)
34
+ end
35
+ end
36
+
37
+ def for_record_type(type)
38
+ where(record_type: type.to_s)
39
+ end
40
+
41
+ def for_table(table_name)
42
+ where(table_name: table_name.to_s)
43
+ end
44
+
45
+ def before(time)
46
+ where(arel_table[:deleted_at].lt(time))
47
+ end
48
+
49
+ def after(time)
50
+ where(arel_table[:deleted_at].gt(time))
51
+ end
52
+
53
+ private
54
+
55
+ def split_schema_qualified(qualified)
56
+ full = qualified.to_s
57
+ full.include?(".") ? full.split(".", 2) : ["public", full]
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Athar
6
+
7
+ initializer "athar.set_logger" do
8
+ Athar.configuration.logger ||= Rails.logger
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/isolated_execution_state"
4
+
5
+ module Athar
6
+ # Fiber-aware metadata stack used by Athar::Context to merge nested
7
+ # `with_metadata` blocks. Storage is delegated to ActiveSupport's
8
+ # IsolatedExecutionState so the stack tracks the request/job execution
9
+ # boundary correctly under fiber-based runtimes (Solid Queue, Falcon, etc.).
10
+ class MetadataStack
11
+ STATE_KEY = :athar_metadata_stack
12
+
13
+ class << self
14
+ def push(meta)
15
+ stack << meta
16
+ current
17
+ end
18
+
19
+ def pop
20
+ stack.pop
21
+ current
22
+ end
23
+
24
+ def current
25
+ stack.reduce({}) { |acc, meta| acc.merge(meta) }
26
+ end
27
+
28
+ def clear!
29
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = []
30
+ end
31
+
32
+ def stack
33
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] ||= []
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Retention
5
+ Result = Struct.new(:deleted_by_age, :deleted_by_count, :table_events_deleted, :batches, keyword_init: true) do
6
+ def total_deleted
7
+ deleted_by_age + deleted_by_count + table_events_deleted
8
+ end
9
+ end
10
+
11
+ class << self
12
+ def prune!(max_age: nil, max_count: nil, batch_size: nil, max_batches: nil, prune_table_events: nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
13
+ config = Athar.configuration.retention
14
+ max_age ||= config.max_age
15
+ max_count ||= config.max_count
16
+ batch_size ||= config.batch_size
17
+ max_batches ||= config.max_batches_per_run
18
+ prune_table_events = config.prune_table_events if prune_table_events.nil?
19
+
20
+ result = Result.new(deleted_by_age: 0, deleted_by_count: 0, table_events_deleted: 0, batches: 0)
21
+
22
+ if max_age
23
+ cutoff = Time.current - max_age
24
+ result.deleted_by_age, age_batches = prune_by_age(
25
+ :athar_deletions,
26
+ "deleted_at",
27
+ cutoff,
28
+ batch_size,
29
+ max_batches
30
+ )
31
+ result.batches += age_batches
32
+
33
+ if prune_table_events
34
+ result.table_events_deleted, table_event_batches = prune_by_age(
35
+ :athar_table_events,
36
+ "occurred_at",
37
+ cutoff,
38
+ batch_size,
39
+ [max_batches - result.batches, 0].max
40
+ )
41
+ result.batches += table_event_batches
42
+ end
43
+ end
44
+
45
+ if max_count && result.batches < max_batches
46
+ result.deleted_by_count, count_batches = prune_by_count(
47
+ :athar_deletions,
48
+ max_count,
49
+ batch_size,
50
+ max_batches - result.batches
51
+ )
52
+ result.batches += count_batches
53
+ end
54
+
55
+ result
56
+ end
57
+
58
+ private
59
+
60
+ def prune_by_age(table, time_column, cutoff, batch_size, max_batches) # rubocop:disable Metrics/MethodLength
61
+ return [0, 0] if max_batches <= 0
62
+
63
+ connection = ActiveRecord::Base.connection
64
+ total = 0
65
+ batches = 0
66
+ loop do
67
+ break if batches >= max_batches
68
+
69
+ deleted = connection.delete(
70
+ <<~SQL
71
+ DELETE FROM #{table}
72
+ WHERE id IN (
73
+ SELECT id FROM #{table}
74
+ WHERE #{time_column} < #{connection.quote(cutoff)}
75
+ ORDER BY #{time_column} ASC
76
+ LIMIT #{batch_size.to_i}
77
+ )
78
+ SQL
79
+ )
80
+ batches += 1
81
+ total += deleted
82
+ break if deleted < batch_size.to_i
83
+ end
84
+
85
+ [total, batches]
86
+ end
87
+
88
+ def prune_by_count(table, max_count, batch_size, max_batches) # rubocop:disable Metrics/MethodLength
89
+ return [0, 0] if max_batches <= 0
90
+
91
+ connection = ActiveRecord::Base.connection
92
+ boundary = connection.select_one(
93
+ <<~SQL
94
+ SELECT deleted_at, id
95
+ FROM #{table}
96
+ ORDER BY deleted_at DESC, id DESC
97
+ OFFSET #{max_count.to_i}
98
+ LIMIT 1
99
+ SQL
100
+ )
101
+ return [0, 0] unless boundary
102
+
103
+ boundary_deleted_at = connection.quote(boundary.fetch("deleted_at"))
104
+ boundary_id = connection.quote(boundary.fetch("id"))
105
+
106
+ total = 0
107
+ batches = 0
108
+ loop do
109
+ break if batches >= max_batches
110
+
111
+ deleted = connection.delete(
112
+ <<~SQL
113
+ DELETE FROM #{table}
114
+ WHERE id IN (
115
+ SELECT id FROM #{table}
116
+ WHERE (deleted_at, id) <= (#{boundary_deleted_at}, #{boundary_id})
117
+ ORDER BY deleted_at ASC, id ASC
118
+ LIMIT #{batch_size.to_i}
119
+ )
120
+ SQL
121
+ )
122
+ batches += 1
123
+ total += deleted
124
+ break if deleted < batch_size.to_i
125
+ end
126
+
127
+ [total, batches]
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ class RetentionJob < ActiveJob::Base
5
+ queue_as { Athar.configuration.retention.queue_name || :default }
6
+
7
+ def perform(max_age: nil, max_count: nil, batch_size: nil, max_batches: nil)
8
+ Athar::Retention.prune!(max_age:, max_count:, batch_size:, max_batches:)
9
+ end
10
+ end
11
+ end
data/lib/athar/sql.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Athar
6
+ module SQL
7
+ GENERATORS_ROOT = File.expand_path("../generators/athar", __dir__)
8
+
9
+ INSTALL_FUNCTIONS_DIR = File.join(GENERATORS_ROOT, "install", "functions")
10
+ MODEL_TRIGGERS_DIR = File.join(GENERATORS_ROOT, "model", "triggers")
11
+
12
+ STATIC_FUNCTIONS = %w[
13
+ athar_filter_keys
14
+ athar_capture_delete
15
+ ].freeze
16
+
17
+ TEMPLATE_FUNCTIONS = %w[
18
+ athar_capture_truncate
19
+ ].freeze
20
+
21
+ INSTALLED_FUNCTIONS = (STATIC_FUNCTIONS + TEMPLATE_FUNCTIONS).freeze
22
+
23
+ class << self
24
+ def read_function(name, locals = {})
25
+ if STATIC_FUNCTIONS.include?(name)
26
+ File.read(File.join(INSTALL_FUNCTIONS_DIR, "#{name}.sql"))
27
+ elsif TEMPLATE_FUNCTIONS.include?(name)
28
+ path = File.join(INSTALL_FUNCTIONS_DIR, "#{name}.sql.erb")
29
+ render(File.read(path), locals)
30
+ else
31
+ raise ArgumentError, "unknown SQL function: #{name.inspect}"
32
+ end
33
+ end
34
+
35
+ def all_functions(locals = {})
36
+ INSTALLED_FUNCTIONS.to_h { |name| [name, read_function(name, locals)] }
37
+ end
38
+
39
+ def function_signature(name)
40
+ case name
41
+ when "athar_filter_keys" then "jsonb, text[]"
42
+ when "athar_capture_delete", "athar_capture_truncate" then ""
43
+ else
44
+ raise ArgumentError, "unknown SQL function: #{name.inspect}"
45
+ end
46
+ end
47
+
48
+ def render(template, locals)
49
+ Render.new(locals).result(template)
50
+ end
51
+ end
52
+
53
+ class Render
54
+ def initialize(locals)
55
+ @locals = locals
56
+ end
57
+
58
+ def result(template)
59
+ binding_context = binding
60
+ @locals.each do |key, value|
61
+ binding_context.local_variable_set(key, value)
62
+ end
63
+ ERB.new(template, trim_mode: "-").result(binding_context)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actor_lookup"
4
+
5
+ module Athar
6
+ class TableEvent < ActiveRecord::Base
7
+ include ActorLookup
8
+
9
+ EVENT_TYPE_TRUNCATE = "truncate"
10
+
11
+ self.table_name = Athar::TABLE_EVENTS_TABLE_NAME
12
+
13
+ class << self
14
+ def recent
15
+ order(occurred_at: :desc, id: :desc)
16
+ end
17
+
18
+ def for_table(table_name)
19
+ where(table_name: table_name.to_s)
20
+ end
21
+
22
+ def truncate
23
+ where(event_type: EVENT_TYPE_TRUNCATE)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ VERSION = "0.1.0"
5
+ end
data/lib/athar.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/numeric/time"
5
+ require "active_support/core_ext/string/inflections"
6
+ require "active_support/json"
7
+ require "active_record"
8
+ require "active_job"
9
+ require "fx"
10
+
11
+ require_relative "athar/version"
12
+ require_relative "athar/configuration"
13
+ require_relative "athar/sql"
14
+ require_relative "athar/metadata_stack"
15
+ require_relative "athar/context"
16
+ require_relative "athar/deletion"
17
+ require_relative "athar/table_event"
18
+ require_relative "athar/retention"
19
+
20
+ module Athar
21
+ class Error < StandardError; end
22
+
23
+ class ConfigurationError < Error; end
24
+
25
+ class << self
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield(configuration)
32
+ end
33
+
34
+ def reset_configuration!
35
+ @configuration = Configuration.new
36
+ end
37
+
38
+ def logger
39
+ configuration.logger || (Rails.logger if defined?(Rails)) || Logger.new($stdout)
40
+ end
41
+
42
+ def with_actor(...)
43
+ Context.with_actor(...)
44
+ end
45
+
46
+ def with_metadata(...)
47
+ Context.with_metadata(...)
48
+ end
49
+
50
+ def with_context(...)
51
+ Context.with_context(...)
52
+ end
53
+
54
+ def without_capture(...)
55
+ Context.without_capture(...)
56
+ end
57
+ end
58
+ end
59
+
60
+ require_relative "athar/retention_job" if defined?(ActiveJob)
61
+ require_relative "athar/engine" if defined?(Rails::Engine)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fx"
4
+
5
+ module Athar
6
+ module Generators
7
+ module FxHelper
8
+ def self.included(base)
9
+ base.class_option :fx,
10
+ type: :boolean,
11
+ optional: true,
12
+ desc: "Use the fx gem to manage SQL functions and triggers (default when fx is loaded)."
13
+ end
14
+
15
+ def fx?
16
+ return true if options[:fx] == true
17
+ return false if options[:fx] == false
18
+
19
+ defined?(::Fx::SchemaDumper) ? true : false
20
+ end
21
+
22
+ def schema_format
23
+ return :ruby unless Rails.application
24
+
25
+ Rails.application.config.active_record.schema_format || :ruby
26
+ end
27
+
28
+ def ensure_raw_sql_supported!
29
+ return if schema_format == :sql
30
+
31
+ raise ::Thor::Error,
32
+ "Athar requires the fx gem (default) or `config.active_record.schema_format = :sql` " \
33
+ "when using --no-fx. Either install fx or switch the host app to SQL schema dumps."
34
+ end
35
+
36
+ # Indent each non-blank line of `text` by `spaces` spaces.
37
+ def indent_sql(text, spaces)
38
+ prefix = " " * spaces
39
+ text.each_line.map { |line| line.strip.empty? ? line : prefix + line }.join
40
+ end
41
+
42
+ # Resolve the app-wide Rails generator primary/foreign-key types.
43
+ def athar_primary_key_type
44
+ primary_key_setting || :primary_key
45
+ end
46
+
47
+ def athar_foreign_key_type
48
+ primary_key_setting || :bigint
49
+ end
50
+
51
+ def primary_key_setting
52
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:configuration)
53
+
54
+ generators_config = ::Rails.configuration.generators
55
+ orm = generators_config.orm
56
+ generators_config.options[orm][:primary_key_type]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,111 @@
1
+ -- Athar capture delete trigger function (v1).
2
+ -- Args:
3
+ -- TG_ARGV[0] record_type (e.g. 'User') or 'null'
4
+ -- TG_ARGV[1] schema_name (e.g. 'public')
5
+ -- TG_ARGV[2] table_name (e.g. 'users')
6
+ -- TG_ARGV[3] primary_key (e.g. 'id')
7
+ -- TG_ARGV[4] id_type ('bigint' | 'integer' | 'uuid')
8
+ -- TG_ARGV[5] record_type_column ('type' | 'null')
9
+ -- TG_ARGV[6] capture_mode ('identity' | 'only' | 'snapshot')
10
+ -- TG_ARGV[7] columns ('{email,name}' or 'null')
11
+
12
+ CREATE OR REPLACE FUNCTION athar_capture_delete()
13
+ RETURNS trigger AS $$
14
+ DECLARE
15
+ arg_record_type text;
16
+ arg_schema_name text;
17
+ arg_table_name text;
18
+ arg_primary_key text;
19
+ arg_id_type text;
20
+ arg_record_type_column text;
21
+ arg_capture_mode text;
22
+ arg_columns text[];
23
+
24
+ full_row jsonb;
25
+ filtered_data jsonb;
26
+ meta jsonb;
27
+ meta_text text;
28
+ computed_record_type text;
29
+ computed_record_id text;
30
+ computed_actor_type text;
31
+ computed_actor_id text;
32
+ BEGIN
33
+ arg_record_type := NULLIF(TG_ARGV[0], 'null');
34
+ arg_schema_name := NULLIF(TG_ARGV[1], 'null');
35
+ arg_table_name := NULLIF(TG_ARGV[2], 'null');
36
+ arg_primary_key := NULLIF(TG_ARGV[3], 'null');
37
+ arg_id_type := NULLIF(TG_ARGV[4], 'null');
38
+ arg_record_type_column := NULLIF(TG_ARGV[5], 'null');
39
+ arg_capture_mode := NULLIF(TG_ARGV[6], 'null');
40
+ arg_columns := NULLIF(TG_ARGV[7], 'null')::text[];
41
+
42
+ full_row := to_jsonb(OLD);
43
+
44
+ computed_record_type := arg_record_type;
45
+ IF arg_record_type_column IS NOT NULL AND full_row ? arg_record_type_column THEN
46
+ IF (full_row ->> arg_record_type_column) IS NOT NULL THEN
47
+ computed_record_type := full_row ->> arg_record_type_column;
48
+ END IF;
49
+ END IF;
50
+
51
+ IF arg_capture_mode = 'identity' THEN
52
+ filtered_data := '{}'::jsonb;
53
+ ELSIF arg_capture_mode = 'snapshot' THEN
54
+ filtered_data := full_row;
55
+ ELSIF arg_capture_mode = 'only' THEN
56
+ filtered_data := athar_filter_keys(full_row, arg_columns);
57
+ ELSE
58
+ RAISE EXCEPTION 'Unsupported Athar capture mode: %', arg_capture_mode;
59
+ END IF;
60
+
61
+ meta := '{}'::jsonb;
62
+ meta_text := current_setting('athar.meta', true);
63
+ IF coalesce(meta_text, '') <> '' THEN
64
+ meta := meta_text::jsonb;
65
+ END IF;
66
+
67
+ computed_record_id := full_row ->> arg_primary_key;
68
+ computed_actor_type := meta ->> 'actor_type';
69
+ computed_actor_id := meta ->> 'actor_id';
70
+
71
+ EXECUTE format(
72
+ 'INSERT INTO athar_deletions (
73
+ record_type,
74
+ record_id,
75
+ actor_type,
76
+ actor_id,
77
+ schema_name,
78
+ table_name,
79
+ deleted_at,
80
+ record_data,
81
+ metadata,
82
+ created_at
83
+ )
84
+ VALUES (
85
+ $1,
86
+ ($2)::%s,
87
+ $3,
88
+ CASE WHEN $4 IS NULL THEN NULL ELSE ($4)::%s END,
89
+ $5,
90
+ $6,
91
+ statement_timestamp(),
92
+ $7,
93
+ $8,
94
+ statement_timestamp()
95
+ )',
96
+ arg_id_type,
97
+ arg_id_type
98
+ )
99
+ USING
100
+ computed_record_type,
101
+ computed_record_id,
102
+ computed_actor_type,
103
+ computed_actor_id,
104
+ arg_schema_name,
105
+ arg_table_name,
106
+ filtered_data,
107
+ meta - 'actor_type' - 'actor_id';
108
+
109
+ RETURN OLD;
110
+ END;
111
+ $$ LANGUAGE plpgsql;
@@ -0,0 +1,51 @@
1
+ -- Athar capture truncate trigger function (v1).
2
+ -- Statement-level AFTER TRUNCATE trigger.
3
+ -- Args:
4
+ -- TG_ARGV[0] schema_name (e.g. 'public')
5
+ -- TG_ARGV[1] table_name (e.g. 'users')
6
+
7
+ CREATE OR REPLACE FUNCTION athar_capture_truncate()
8
+ RETURNS trigger AS $$
9
+ DECLARE
10
+ arg_schema_name text;
11
+ arg_table_name text;
12
+
13
+ meta jsonb;
14
+ meta_text text;
15
+ actor_id_text text;
16
+ BEGIN
17
+ arg_schema_name := NULLIF(TG_ARGV[0], 'null');
18
+ arg_table_name := NULLIF(TG_ARGV[1], 'null');
19
+
20
+ meta := '{}'::jsonb;
21
+ meta_text := current_setting('athar.meta', true);
22
+ IF coalesce(meta_text, '') <> '' THEN
23
+ meta := meta_text::jsonb;
24
+ END IF;
25
+
26
+ actor_id_text := meta ->> 'actor_id';
27
+
28
+ INSERT INTO athar_table_events (
29
+ event_type,
30
+ schema_name,
31
+ table_name,
32
+ actor_type,
33
+ actor_id,
34
+ metadata,
35
+ occurred_at,
36
+ created_at
37
+ )
38
+ VALUES (
39
+ 'truncate',
40
+ arg_schema_name,
41
+ arg_table_name,
42
+ meta ->> 'actor_type',
43
+ CASE WHEN actor_id_text IS NULL THEN NULL ELSE actor_id_text::<%= foreign_key_type %> END,
44
+ meta - 'actor_type' - 'actor_id',
45
+ statement_timestamp(),
46
+ statement_timestamp()
47
+ );
48
+
49
+ RETURN NULL;
50
+ END;
51
+ $$ LANGUAGE plpgsql;
@@ -0,0 +1,29 @@
1
+ -- Athar filter keys function (v1).
2
+ -- Keeps only the listed keys from a JSONB object.
3
+ -- Missing keys are ignored. Empty column list returns {}.
4
+
5
+ CREATE OR REPLACE FUNCTION athar_filter_keys(
6
+ data jsonb,
7
+ columns text[]
8
+ ) RETURNS jsonb AS $$
9
+ DECLARE
10
+ result jsonb := '{}'::jsonb;
11
+ column_name text;
12
+ BEGIN
13
+ IF data IS NULL THEN
14
+ RETURN '{}'::jsonb;
15
+ END IF;
16
+
17
+ IF columns IS NULL OR array_length(columns, 1) IS NULL THEN
18
+ RETURN '{}'::jsonb;
19
+ END IF;
20
+
21
+ FOREACH column_name IN ARRAY columns LOOP
22
+ IF data ? column_name THEN
23
+ result := result || jsonb_build_object(column_name, data -> column_name);
24
+ END IF;
25
+ END LOOP;
26
+
27
+ RETURN result;
28
+ END;
29
+ $$ LANGUAGE plpgsql IMMUTABLE;