vert-core 1.0.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +126 -0
  4. data/lib/vert/authorization/controller_methods.rb +84 -0
  5. data/lib/vert/authorization/dynamic_policy.rb +156 -0
  6. data/lib/vert/authorization/permission_resolver.rb +253 -0
  7. data/lib/vert/authorization/policy_finder.rb +72 -0
  8. data/lib/vert/clients/document_service_client.rb +104 -0
  9. data/lib/vert/concerns/auditable.rb +24 -0
  10. data/lib/vert/concerns/company_scoped.rb +48 -0
  11. data/lib/vert/concerns/current_attributes.rb +53 -0
  12. data/lib/vert/concerns/document_storeable.rb +180 -0
  13. data/lib/vert/concerns/multi_tenant.rb +45 -0
  14. data/lib/vert/concerns/soft_deletable.rb +46 -0
  15. data/lib/vert/concerns/uuid_primary_key.rb +42 -0
  16. data/lib/vert/configuration.rb +65 -0
  17. data/lib/vert/generators/install_generator.rb +66 -0
  18. data/lib/vert/generators/rls_migration_generator.rb +57 -0
  19. data/lib/vert/generators/templates/application_record.rb.tt +8 -0
  20. data/lib/vert/generators/templates/create_outbox_events.rb.tt +24 -0
  21. data/lib/vert/generators/templates/create_rls_functions.rb.tt +27 -0
  22. data/lib/vert/generators/templates/current.rb.tt +10 -0
  23. data/lib/vert/generators/templates/enable_rls_on_tables.rb.tt +39 -0
  24. data/lib/vert/generators/templates/health_controller.rb.tt +5 -0
  25. data/lib/vert/generators/templates/initializer.rb.tt +39 -0
  26. data/lib/vert/generators/templates/outbox_event.rb.tt +11 -0
  27. data/lib/vert/health/checker.rb +119 -0
  28. data/lib/vert/health/routes.rb +44 -0
  29. data/lib/vert/outbox/event.rb +68 -0
  30. data/lib/vert/outbox/publisher.rb +105 -0
  31. data/lib/vert/outbox/publisher_job.rb +30 -0
  32. data/lib/vert/railtie.rb +54 -0
  33. data/lib/vert/rls/connection_handler.rb +56 -0
  34. data/lib/vert/rls/consumer_context.rb +31 -0
  35. data/lib/vert/rls/context_middleware.rb +37 -0
  36. data/lib/vert/rls/job_context.rb +56 -0
  37. data/lib/vert/version.rb +5 -0
  38. data/lib/vert.rb +58 -0
  39. data/vert.gemspec +43 -0
  40. metadata +223 -0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module Vert
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Installs Vert and generates configuration files"
12
+
13
+ def create_initializer
14
+ template "initializer.rb.tt", "config/initializers/vert.rb"
15
+ end
16
+
17
+ def create_current_model
18
+ template "current.rb.tt", "app/models/current.rb"
19
+ end
20
+
21
+ def create_application_record
22
+ template "application_record.rb.tt", "app/models/application_record.rb"
23
+ end
24
+
25
+ def create_outbox_event_model
26
+ template "outbox_event.rb.tt", "app/models/outbox_event.rb"
27
+ end
28
+
29
+ def create_outbox_migration
30
+ migration_template "create_outbox_events.rb.tt",
31
+ "db/migrate/#{timestamp}_create_outbox_events.rb"
32
+ end
33
+
34
+ def create_health_controller
35
+ template "health_controller.rb.tt", "app/controllers/health_controller.rb"
36
+ end
37
+
38
+ def add_routes
39
+ route 'get "/health", to: "health#show"'
40
+ route 'get "/health/live", to: "health#live"'
41
+ route 'get "/health/ready", to: "health#ready"'
42
+ end
43
+
44
+ def show_instructions
45
+ say "\n"
46
+ say "Vert installed successfully!", :green
47
+ say "\n"
48
+ say "Next steps:", :yellow
49
+ say "1. Edit config/initializers/vert.rb to enable the features you need"
50
+ say "2. Run migrations: rails db:migrate"
51
+ say "3. Include Vert concerns in your models as needed:"
52
+ say " include Vert::Concerns::UuidPrimaryKey"
53
+ say " include Vert::Concerns::MultiTenant"
54
+ say " include Vert::Concerns::Auditable"
55
+ say " include Vert::Concerns::SoftDeletable"
56
+ say "\n"
57
+ end
58
+
59
+ private
60
+
61
+ def timestamp
62
+ Time.current.strftime("%Y%m%d%H%M%S")
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module Vert
7
+ module Generators
8
+ class RlsMigrationGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Generates PostgreSQL Row Level Security setup migration"
12
+
13
+ class_option :tables, type: :array, default: [],
14
+ desc: "Tables to enable RLS on"
15
+
16
+ def create_rls_functions_migration
17
+ migration_template "create_rls_functions.rb.tt",
18
+ "db/migrate/#{timestamp}_create_rls_functions.rb"
19
+ end
20
+
21
+ def create_enable_rls_migration
22
+ return if options[:tables].empty?
23
+
24
+ migration_template "enable_rls_on_tables.rb.tt",
25
+ "db/migrate/#{next_timestamp}_enable_rls_on_tables.rb"
26
+ end
27
+
28
+ def show_instructions
29
+ say "\n"
30
+ say "RLS migrations generated!", :green
31
+ say "\n"
32
+ say "Next steps:", :yellow
33
+ say "1. Run migrations: rails db:migrate"
34
+ say "2. Set enable_rls = true in config/initializers/vert.rb"
35
+ say "3. Ensure your database user has appropriate privileges"
36
+ say "\n"
37
+ say "To enable RLS on additional tables, run:", :cyan
38
+ say " rails generate vert:rls_migration --tables orders invoices"
39
+ say "\n"
40
+ end
41
+
42
+ private
43
+
44
+ def timestamp
45
+ @timestamp ||= Time.current.strftime("%Y%m%d%H%M%S")
46
+ end
47
+
48
+ def next_timestamp
49
+ @next_timestamp ||= (Time.current + 1.second).strftime("%Y%m%d%H%M%S")
50
+ end
51
+
52
+ def tables_list
53
+ options[:tables]
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ primary_abstract_class
5
+
6
+ # Optional: include Vert::Concerns::UuidPrimaryKey for UUID v7 primary keys
7
+ # include Vert::Concerns::UuidPrimaryKey
8
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateOutboxEvents < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :outbox_events, id: :uuid do |t|
6
+ t.uuid :tenant_id, null: false
7
+ t.string :event_type, null: false
8
+ t.string :aggregate_type, null: false
9
+ t.uuid :aggregate_id, null: false
10
+ t.jsonb :payload, null: false, default: {}
11
+ t.string :status, null: false, default: "pending"
12
+ t.integer :retry_count, null: false, default: 0
13
+ t.text :last_error
14
+ t.datetime :published_at
15
+ t.datetime :failed_at
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :outbox_events, :tenant_id
21
+ add_index :outbox_events, [:status, :created_at]
22
+ add_index :outbox_events, [:aggregate_type, :aggregate_id]
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRlsFunctions < ActiveRecord::Migration[7.1]
4
+ def up
5
+ execute <<~SQL
6
+ CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS uuid AS $$
7
+ SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid;
8
+ $$ LANGUAGE SQL STABLE;
9
+
10
+ CREATE OR REPLACE FUNCTION current_company_id() RETURNS uuid AS $$
11
+ SELECT NULLIF(current_setting('app.current_company_id', true), '')::uuid;
12
+ $$ LANGUAGE SQL STABLE;
13
+
14
+ CREATE OR REPLACE FUNCTION current_user_id() RETURNS uuid AS $$
15
+ SELECT NULLIF(current_setting('app.current_user_id', true), '')::uuid;
16
+ $$ LANGUAGE SQL STABLE;
17
+ SQL
18
+ end
19
+
20
+ def down
21
+ execute <<~SQL
22
+ DROP FUNCTION IF EXISTS current_tenant_id();
23
+ DROP FUNCTION IF EXISTS current_company_id();
24
+ DROP FUNCTION IF EXISTS current_user_id();
25
+ SQL
26
+ end
27
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Current - Thread-safe request context
4
+ #
5
+ # Provides access to current tenant, user, and company context.
6
+ #
7
+ class Current < Vert::Current
8
+ # Add any application-specific attributes here
9
+ # attribute :custom_attribute
10
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EnableRlsOnTables < ActiveRecord::Migration[7.1]
4
+ TABLES = <%= tables_list.inspect %>.freeze
5
+
6
+ def up
7
+ TABLES.each do |table_name|
8
+ enable_rls_on_table(table_name)
9
+ end
10
+ end
11
+
12
+ def down
13
+ TABLES.each do |table_name|
14
+ disable_rls_on_table(table_name)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def enable_rls_on_table(table_name)
21
+ execute <<~SQL
22
+ ALTER TABLE #{table_name} ENABLE ROW LEVEL SECURITY;
23
+ ALTER TABLE #{table_name} FORCE ROW LEVEL SECURITY;
24
+
25
+ DROP POLICY IF EXISTS tenant_isolation_#{table_name} ON #{table_name};
26
+ CREATE POLICY tenant_isolation_#{table_name} ON #{table_name}
27
+ FOR ALL
28
+ USING (tenant_id = current_tenant_id())
29
+ WITH CHECK (tenant_id = current_tenant_id());
30
+ SQL
31
+ end
32
+
33
+ def disable_rls_on_table(table_name)
34
+ execute <<~SQL
35
+ DROP POLICY IF EXISTS tenant_isolation_#{table_name} ON #{table_name};
36
+ ALTER TABLE #{table_name} DISABLE ROW LEVEL SECURITY;
37
+ SQL
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HealthController < ActionController::API
4
+ include Vert::Health::ControllerMixin
5
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Vert - Configuration
4
+ #
5
+ # Enable only the features you need. All flags default to false except enable_health.
6
+ #
7
+ Vert.configure do |config|
8
+ # --- Row Level Security (PostgreSQL) ---
9
+ # config.enable_rls = ENV.fetch("ENABLE_RLS", "false") == "true"
10
+ # config.rls_user = ENV.fetch("RLS_USER", "app_user")
11
+
12
+ # --- Outbox (reliable event publishing to RabbitMQ) ---
13
+ # config.enable_outbox = true
14
+ # config.rabbitmq_url = ENV.fetch("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/")
15
+ # config.exchange_name = ENV.fetch("RABBITMQ_EXCHANGE", "vert.events")
16
+
17
+ # --- Health checks ---
18
+ # config.enable_health = true
19
+ # config.health_check_path = "/health"
20
+ # config.auto_mount_health_routes = false
21
+ # config.health_check_database = true
22
+ # config.health_check_redis = false
23
+ # config.health_check_rabbitmq = false
24
+ # config.health_check_sidekiq = false
25
+
26
+ # --- Authorization (RBAC/ABAC with Pundit) ---
27
+ # config.enable_authorization = false
28
+
29
+ # --- Model concerns (enable if your app uses these patterns) ---
30
+ # config.enable_multi_tenant = false
31
+ # config.enable_auditable = false
32
+ # config.enable_soft_deletable = false
33
+ # config.enable_uuid_primary_key = false
34
+ # config.enable_company_scoped = false
35
+ # config.enable_document_storeable = false
36
+
37
+ # --- Document service (for has_document) ---
38
+ # config.document_service_url = ENV.fetch("DOCUMENT_SERVICE_URL", "http://localhost:3020")
39
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OutboxEvent - Transactional outbox for reliable event publishing
4
+ #
5
+ # Events are stored in the database and published asynchronously
6
+ # to RabbitMQ. Enable enable_outbox in config/initializers/vert.rb
7
+ # and run the OutboxPublisherJob (e.g. via Sidekiq-Cron) to publish.
8
+ #
9
+ class OutboxEvent < ApplicationRecord
10
+ include Vert::Outbox::Event
11
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vert
4
+ module Health
5
+ class Checker
6
+ attr_reader :checks
7
+
8
+ def initialize
9
+ @checks = {}
10
+ setup_default_checks
11
+ end
12
+
13
+ def add_check(name, &block)
14
+ @checks[name] = block
15
+ end
16
+
17
+ alias register add_check
18
+
19
+ def remove_check(name)
20
+ @checks.delete(name)
21
+ end
22
+
23
+ def check_all
24
+ results = {}
25
+ overall_healthy = true
26
+
27
+ @checks.each do |name, check|
28
+ result = check.call
29
+ results[name] = result
30
+ overall_healthy = false unless result[:status] == "ok"
31
+ rescue StandardError => e
32
+ results[name] = { status: "error", message: e.message }
33
+ overall_healthy = false
34
+ end
35
+
36
+ {
37
+ status: overall_healthy ? "healthy" : "unhealthy",
38
+ timestamp: Time.current.iso8601,
39
+ checks: results
40
+ }
41
+ end
42
+
43
+ def liveness
44
+ { status: "ok", timestamp: Time.current.iso8601 }
45
+ end
46
+
47
+ def readiness
48
+ db_ok = check_database[:status] == "ok"
49
+ {
50
+ status: db_ok ? "ready" : "not_ready",
51
+ timestamp: Time.current.iso8601,
52
+ checks: { database: check_database }
53
+ }
54
+ end
55
+
56
+ def check_database
57
+ ActiveRecord::Base.connection.execute("SELECT 1")
58
+ { status: "ok" }
59
+ rescue StandardError => e
60
+ { status: "error", message: e.message }
61
+ end
62
+
63
+ def check_redis
64
+ return { status: "skipped", message: "Redis check disabled" } unless Vert.config.health_check_redis
65
+ return { status: "skipped", message: "Redis not configured" } unless redis_available?
66
+
67
+ Redis.current.ping == "PONG" ? { status: "ok" } : { status: "error", message: "Ping failed" }
68
+ rescue StandardError => e
69
+ { status: "error", message: e.message }
70
+ end
71
+
72
+ def check_rabbitmq
73
+ return { status: "skipped", message: "RabbitMQ check disabled" } unless Vert.config.health_check_rabbitmq
74
+
75
+ connection = Bunny.new(Vert.config.rabbitmq_url, connection_timeout: 5)
76
+ connection.start
77
+ connection.close
78
+ { status: "ok" }
79
+ rescue StandardError => e
80
+ { status: "error", message: e.message }
81
+ end
82
+
83
+ def check_sidekiq
84
+ return { status: "skipped", message: "Sidekiq check disabled" } unless Vert.config.health_check_sidekiq
85
+ return { status: "skipped", message: "Sidekiq not configured" } unless sidekiq_available?
86
+
87
+ stats = Sidekiq::Stats.new
88
+ { status: "ok", processed: stats.processed, failed: stats.failed, queues: stats.queues }
89
+ rescue StandardError => e
90
+ { status: "error", message: e.message }
91
+ end
92
+
93
+ class << self
94
+ def checker
95
+ @checker ||= new
96
+ end
97
+
98
+ delegate :check_all, :liveness, :readiness, :add_check, :register, to: :checker
99
+ end
100
+
101
+ private
102
+
103
+ def setup_default_checks
104
+ add_check(:database) { check_database } if Vert.config.health_check_database
105
+ add_check(:redis) { check_redis } if Vert.config.health_check_redis
106
+ add_check(:rabbitmq) { check_rabbitmq } if Vert.config.health_check_rabbitmq
107
+ add_check(:sidekiq) { check_sidekiq } if Vert.config.health_check_sidekiq
108
+ end
109
+
110
+ def redis_available?
111
+ defined?(Redis) && Redis.respond_to?(:current)
112
+ end
113
+
114
+ def sidekiq_available?
115
+ defined?(Sidekiq::Stats)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vert
4
+ module Health
5
+ module Routes
6
+ class << self
7
+ def mount(router, path: nil)
8
+ path ||= Vert.config.health_check_path
9
+ router.instance_eval do
10
+ scope path do
11
+ get "/", to: "health#show", as: :health
12
+ get "/live", to: "health#live", as: :health_live
13
+ get "/ready", to: "health#ready", as: :health_ready
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ module ControllerMixin
21
+ extend ActiveSupport::Concern
22
+
23
+ def show
24
+ result = Vert::Health.check_all
25
+ status = result[:status] == "healthy" ? :ok : :service_unavailable
26
+ render json: result, status: status
27
+ end
28
+
29
+ def live
30
+ render json: Vert::Health.liveness, status: :ok
31
+ end
32
+
33
+ def ready
34
+ result = Vert::Health.readiness
35
+ status = result[:status] == "ready" ? :ok : :service_unavailable
36
+ render json: result, status: status
37
+ end
38
+ end
39
+
40
+ class Controller < ActionController::API
41
+ include ControllerMixin
42
+ end
43
+ end
44
+ end if defined?(ActionController::API)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vert
4
+ module Outbox
5
+ module Event
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ validates :event_type, presence: true
10
+ validates :aggregate_type, presence: true
11
+ validates :aggregate_id, presence: true
12
+ validates :payload, presence: true
13
+ validates :status, presence: true
14
+ enum :status, { pending: 0, published: 1, failed: 2 }, prefix: true
15
+ scope :pending_events, -> { status_pending.order(:created_at) }
16
+ scope :failed_events, -> { status_failed.where("retry_count < ?", max_retry_count).order(:created_at) }
17
+ scope :by_aggregate, ->(type, id) { where(aggregate_type: type, aggregate_id: id) }
18
+ scope :publishable, -> { pending_events.or(failed_events) }
19
+ end
20
+
21
+ class_methods do
22
+ def max_retry_count
23
+ 5
24
+ end
25
+
26
+ def create_event(event_type:, aggregate_type:, aggregate_id:, payload:)
27
+ create!(
28
+ tenant_id: Vert::Current.tenant_id,
29
+ event_type: event_type,
30
+ aggregate_type: aggregate_type,
31
+ aggregate_id: aggregate_id,
32
+ payload: payload,
33
+ status: :pending
34
+ )
35
+ end
36
+
37
+ def publish_for(aggregate, event_type:, payload: {})
38
+ create_event(
39
+ event_type: event_type,
40
+ aggregate_type: aggregate.class.name,
41
+ aggregate_id: aggregate.id,
42
+ payload: payload.merge(id: aggregate.id, tenant_id: aggregate.tenant_id)
43
+ )
44
+ end
45
+ end
46
+
47
+ def mark_as_published!
48
+ update!(status: :published, published_at: Time.current)
49
+ end
50
+
51
+ def mark_as_failed!(error)
52
+ update!(status: :failed, retry_count: retry_count + 1, last_error: error.to_s, failed_at: Time.current)
53
+ end
54
+
55
+ def can_retry?
56
+ status_failed? && retry_count < self.class.max_retry_count
57
+ end
58
+
59
+ def routing_key
60
+ event_type.tr("_", ".")
61
+ end
62
+
63
+ def message_headers
64
+ { tenant_id: tenant_id, event_type: event_type, aggregate_type: aggregate_type, aggregate_id: aggregate_id, event_id: id, timestamp: created_at.iso8601 }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bunny"
4
+
5
+ module Vert
6
+ module Outbox
7
+ class Publisher
8
+ attr_reader :connection, :channel, :exchange
9
+
10
+ def initialize
11
+ @connection = nil
12
+ @channel = nil
13
+ @exchange = nil
14
+ end
15
+
16
+ def connect
17
+ @connection = Bunny.new(Vert.config.rabbitmq_url)
18
+ @connection.start
19
+ @channel = @connection.create_channel
20
+ @exchange = @channel.topic(Vert.config.exchange_name, durable: true)
21
+ self
22
+ end
23
+
24
+ def close
25
+ @connection&.close
26
+ @connection = nil
27
+ @channel = nil
28
+ @exchange = nil
29
+ end
30
+
31
+ def connected?
32
+ @connection&.open? && @channel&.open?
33
+ end
34
+
35
+ def publish(event)
36
+ connect unless connected?
37
+ message = build_message(event)
38
+ @exchange.publish(
39
+ message.to_json,
40
+ routing_key: event.routing_key,
41
+ persistent: true,
42
+ content_type: "application/json",
43
+ message_id: event.id.to_s,
44
+ timestamp: event.created_at.to_i,
45
+ headers: event.message_headers
46
+ )
47
+ event.mark_as_published!
48
+ true
49
+ rescue StandardError => e
50
+ event.mark_as_failed!(e)
51
+ false
52
+ end
53
+
54
+ def publish_batch(events)
55
+ results = { published: 0, failed: 0 }
56
+ events.each { |event| publish(event) ? results[:published] += 1 : results[:failed] += 1 }
57
+ results
58
+ end
59
+
60
+ class << self
61
+ def with_connection
62
+ publisher = new
63
+ publisher.connect
64
+ yield publisher
65
+ ensure
66
+ publisher&.close
67
+ end
68
+
69
+ def publish_pending(batch_size: 100)
70
+ return { published: 0, failed: 0, error: "OutboxEvent not defined" } unless outbox_event_class
71
+ results = { published: 0, failed: 0 }
72
+ with_connection do |publisher|
73
+ outbox_event_class.publishable.find_in_batches(batch_size: batch_size) do |events|
74
+ batch_results = publisher.publish_batch(events)
75
+ results[:published] += batch_results[:published]
76
+ results[:failed] += batch_results[:failed]
77
+ end
78
+ end
79
+ results
80
+ end
81
+
82
+ private
83
+
84
+ def outbox_event_class
85
+ @outbox_event_class ||= (Object.const_get("OutboxEvent") rescue nil)
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def build_message(event)
92
+ {
93
+ event_id: event.id.to_s,
94
+ event_type: event.event_type,
95
+ aggregate_type: event.aggregate_type,
96
+ aggregate_id: event.aggregate_id.to_s,
97
+ tenant_id: event.tenant_id.to_s,
98
+ occurred_at: event.created_at.iso8601,
99
+ metadata: { tenant_id: event.tenant_id.to_s, published_at: Time.current.iso8601 },
100
+ data: event.payload.is_a?(Hash) ? (event.payload[:data] || event.payload["data"] || event.payload) : event.payload
101
+ }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vert
4
+ module Outbox
5
+ class PublisherJob
6
+ include Sidekiq::Job if defined?(Sidekiq::Job)
7
+ sidekiq_options queue: :critical, retry: 3 if respond_to?(:sidekiq_options)
8
+
9
+ def perform
10
+ return unless Vert.config.enable_outbox
11
+ return unless outbox_event_class
12
+
13
+ outbox_event_class.pending_events.find_each { |event| publish_event(event) }
14
+ outbox_event_class.failed_events.find_each { |event| publish_event(event) }
15
+ end
16
+
17
+ private
18
+
19
+ def publish_event(event)
20
+ Publisher.with_connection { |publisher| publisher.publish(event) }
21
+ rescue StandardError => e
22
+ Rails.logger.error("[Vert::Outbox] Failed #{event.event_type}: #{e.message}") if defined?(Rails)
23
+ end
24
+
25
+ def outbox_event_class
26
+ @outbox_event_class ||= (Object.const_get("OutboxEvent") rescue nil)
27
+ end
28
+ end
29
+ end
30
+ end