ruby-object-framework 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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +78 -0
  3. data/README.md +250 -0
  4. data/THIRD_PARTY_NOTICES.md +45 -0
  5. data/examples/guides/01_plain_use_case.rb +13 -0
  6. data/examples/guides/02_composed_application.rb +25 -0
  7. data/examples/guides/03_order_domain.rb +14 -0
  8. data/examples/guides/04_scoped_resources.rb +38 -0
  9. data/examples/guides/05_mapping.rb +39 -0
  10. data/examples/guides/06_associations.rb +49 -0
  11. data/examples/guides/07_http.rb +17 -0
  12. data/examples/guides/08_errors.rb +20 -0
  13. data/examples/guides/09_sqlite.rb +38 -0
  14. data/examples/guides/10_grpc.rb +26 -0
  15. data/examples/guides/11_ractor.rb +15 -0
  16. data/examples/guides/12_box.rb +11 -0
  17. data/examples/guides/13_testing.rb +24 -0
  18. data/examples/guides/manifest.yml +62 -0
  19. data/examples/orders/application.rb +51 -0
  20. data/examples/orders/contracts.rb +19 -0
  21. data/examples/orders/domain.rb +35 -0
  22. data/examples/orders/export_proto.rb +5 -0
  23. data/examples/orders/grpc_client.py +24 -0
  24. data/examples/orders/grpc_server.rb +23 -0
  25. data/examples/orders/server.rb +18 -0
  26. data/lib/object_framework/application.rb +136 -0
  27. data/lib/object_framework/capabilities.rb +59 -0
  28. data/lib/object_framework/errors.rb +85 -0
  29. data/lib/object_framework/execution.rb +184 -0
  30. data/lib/object_framework/grpc/messages.rb +30 -0
  31. data/lib/object_framework/grpc/server.rb +264 -0
  32. data/lib/object_framework/grpc/service.rb +55 -0
  33. data/lib/object_framework/http/io.rb +127 -0
  34. data/lib/object_framework/http/presentation.rb +55 -0
  35. data/lib/object_framework/http/protocol.rb +209 -0
  36. data/lib/object_framework/http/router.rb +80 -0
  37. data/lib/object_framework/http/server.rb +279 -0
  38. data/lib/object_framework/http2/connection.rb +399 -0
  39. data/lib/object_framework/http2/frame.rb +21 -0
  40. data/lib/object_framework/http2/hpack.rb +206 -0
  41. data/lib/object_framework/http2/hpack_tables.rb +333 -0
  42. data/lib/object_framework/http2/inbound.rb +75 -0
  43. data/lib/object_framework/memory/storage.rb +97 -0
  44. data/lib/object_framework/persistence/mapping.rb +150 -0
  45. data/lib/object_framework/persistence/query.rb +97 -0
  46. data/lib/object_framework/persistence/session.rb +173 -0
  47. data/lib/object_framework/persistence/transaction.rb +54 -0
  48. data/lib/object_framework/protobuf.rb +268 -0
  49. data/lib/object_framework/sqlite/compiler.rb +77 -0
  50. data/lib/object_framework/sqlite/schema.rb +95 -0
  51. data/lib/object_framework/sqlite/storage.rb +220 -0
  52. data/lib/object_framework/sqlite.rb +6 -0
  53. data/lib/object_framework/version.rb +5 -0
  54. data/lib/object_framework.rb +31 -0
  55. metadata +105 -0
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "object_framework"
4
+
5
+ class SumNumbers
6
+ def self.call(input)
7
+ { total: input[:numbers].sum }
8
+ end
9
+ end
10
+
11
+ owner = Thread.current.object_id
12
+ task = ObjectFramework::Capabilities::IsolatedTask.new(task: SumNumbers)
13
+ result = task.call({ numbers: [1, 2, 3] })
14
+ puts "Total: #{result[:total]}"
15
+ puts "Request thread preserved: #{Thread.current.object_id == owner}"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "object_framework"
4
+
5
+ first = ObjectFramework::Capabilities::Definitions.new
6
+ second = ObjectFramework::Capabilities::Definitions.new
7
+ first.evaluate("class Name; def self.call; 'first application'; end; end")
8
+ second.evaluate("class Name; def self.call; 'second application'; end; end")
9
+ puts first.constant(:Name).call
10
+ puts second.constant(:Name).call
11
+ puts "Definitions leaked: #{Object.const_defined?(:Name)}"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../test/support/spec"
4
+
5
+ class PriceWithTax
6
+ def call(cents:)
7
+ raise ArgumentError, "Price must be nonnegative" if cents.negative?
8
+ cents + cents / 5
9
+ end
10
+ end
11
+
12
+ Spec.test "a zero price remains zero" do
13
+ assert_equal 0, PriceWithTax.new.call(cents: 0)
14
+ end
15
+
16
+ Spec.test "a price includes twenty percent tax" do
17
+ assert_equal 1200, PriceWithTax.new.call(cents: 1000)
18
+ end
19
+
20
+ Spec.test "negative prices are rejected" do
21
+ assert_raises(ArgumentError) { PriceWithTax.new.call(cents: -1) }
22
+ end
23
+
24
+ exit Spec.run
@@ -0,0 +1,62 @@
1
+ - file: 01_plain_use_case.rb
2
+ output: |
3
+ Hello, Ada
4
+ - file: 02_composed_application.rb
5
+ output: |
6
+ Hello, Ada
7
+ Result is frozen: true
8
+ - file: 03_order_domain.rb
9
+ output: |
10
+ confirmed
11
+ An order can only be confirmed once
12
+ - file: 04_scoped_resources.rb
13
+ output: |
14
+ Same resource: true
15
+ opened, closed
16
+ - file: 05_mapping.rb
17
+ output: |
18
+ UUID assigned: true
19
+ Same identity: true
20
+ Independent session: true
21
+ Stored state: pending
22
+ - file: 06_associations.rb
23
+ output: |
24
+ Customer: Ada
25
+ Shared identity: true
26
+ - file: 07_http.rb
27
+ output: |
28
+ HTTP 200
29
+ Order state: confirmed
30
+ - file: 08_errors.rb
31
+ output: |
32
+ 404 not_found
33
+ Occurrence ID present: true
34
+ 409 order_not_confirmable
35
+ Occurrence ID present: true
36
+ - file: 09_sqlite.rb
37
+ output: |
38
+ Schema version: 2
39
+ Read: pending
40
+ Write: confirmed
41
+ Unmapped note: gift
42
+ Open connections: 0
43
+ - file: 10_grpc.rb
44
+ output: |
45
+ ready: confirmed
46
+ - file: 11_ractor.rb
47
+ output: |
48
+ Total: 6
49
+ Request thread preserved: true
50
+ - file: 12_box.rb
51
+ env:
52
+ RUBY_BOX: '1'
53
+ output: |
54
+ first application
55
+ second application
56
+ Definitions leaked: false
57
+ - file: 13_testing.rb
58
+ output: |
59
+ PASS a zero price remains zero
60
+ PASS a price includes twenty percent tax
61
+ PASS negative prices are rejected
62
+ 3 tests, 0 failures
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "object_framework"
4
+ require_relative "domain"
5
+
6
+ module OrdersExample
7
+ class Orders
8
+ def initialize(session:)
9
+ @session = session
10
+ end
11
+
12
+ def fetch(id)
13
+ @session.find(Order, id)
14
+ end
15
+ end
16
+
17
+ Bundle = Data.define(:application, :server, :storage)
18
+
19
+ def self.build(port: 0, storage: ObjectFramework::Memory::Storage.new)
20
+ mappings = ObjectFramework::Persistence::Mappings.new
21
+ mappings.define(Order, table: :orders) do
22
+ field :id
23
+ field :state
24
+ field :total_cents, type: :integer
25
+ end
26
+ app = ObjectFramework::Application.new(storage:, mappings:)
27
+ app.errors.register(OrderNotConfirmable, category: :conflict, code: "order_not_confirmable", title: "Order cannot be confirmed")
28
+ app.factory(:orders, dependencies: [:session]) { |session:| Orders.new(session:) }
29
+ app.operation(:confirm_order, use_case: ConfirmOrder, policy: :write, dependencies: [:orders])
30
+ parameters = ObjectFramework::HTTP::Parameters.new do
31
+ field :order_id, source: :path, key: :id, type: String
32
+ end
33
+ controller = ObjectFramework::HTTP::Controller.new(application: app, operation: :confirm_order, parameters:)
34
+ router = ObjectFramework::HTTP::Router.new
35
+ router.controller(:confirm_order, controller)
36
+ router.route("POST", "/orders/:id/confirm", controller: :confirm_order)
37
+ server = ObjectFramework::HTTP::Server.new(application: app, router:, port:)
38
+ app.install(server)
39
+ seed(storage)
40
+ Bundle.new(application: app, server:, storage:)
41
+ end
42
+
43
+ def self.seed(storage)
44
+ transaction = storage.begin_transaction(mode: :write)
45
+ transaction.insert(:orders, { id: "ready", state: "pending", total_cents: 1500 })
46
+ transaction.insert(:orders, { id: "rejected", state: "cancelled", total_cents: 500 })
47
+ transaction.commit
48
+ ensure
49
+ transaction&.close
50
+ end
51
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "object_framework"
4
+
5
+ module OrdersExample
6
+ def self.grpc_contract
7
+ request = ObjectFramework::Protobuf::Message.new("ConfirmOrderRequest") do
8
+ field :order_id, 1, :string
9
+ end
10
+ confirmation = ObjectFramework::Protobuf::Message.new("OrderConfirmation") do
11
+ field :id, 1, :string
12
+ field :state, 2, :string
13
+ field :total_cents, 3, :int64
14
+ end
15
+ ObjectFramework::GRPC::Service.new("orders.Orders").tap do |service|
16
+ service.rpc(:ConfirmOrder, input: request, output: confirmation, controller: :confirm_order)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OrdersExample
4
+ class OrderNotConfirmable < StandardError; end
5
+
6
+ class Order
7
+ attr_accessor :id
8
+ attr_reader :state, :total_cents
9
+
10
+ def initialize(id: nil, state: "pending", total_cents:)
11
+ raise ArgumentError, "Total must be a nonnegative integer" unless total_cents.is_a?(Integer) && total_cents >= 0
12
+ raise ArgumentError, "Unknown order state" unless %w[pending confirmed cancelled].include?(state)
13
+ @id, @state, @total_cents = id, state, total_cents
14
+ end
15
+
16
+ def confirm
17
+ raise OrderNotConfirmable unless state == "pending" && total_cents.positive?
18
+ @state = "confirmed"
19
+ end
20
+ end
21
+
22
+ Confirmation = Data.define(:id, :state, :total_cents)
23
+
24
+ class ConfirmOrder
25
+ def initialize(orders:)
26
+ @orders = orders
27
+ end
28
+
29
+ def call(order_id:)
30
+ order = @orders.fetch(order_id)
31
+ order.confirm
32
+ Confirmation.new(id: order.id, state: order.state, total_cents: order.total_cents)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "contracts"
4
+
5
+ puts OrdersExample.grpc_contract.to_proto
@@ -0,0 +1,24 @@
1
+ """Confirm an order with grpcio and a contract exported by the Ruby server."""
2
+
3
+ import importlib
4
+ import pathlib
5
+ import sys
6
+ import tempfile
7
+
8
+ import grpc
9
+ from grpc_tools import protoc
10
+
11
+ port, contract, order_id = int(sys.argv[1]), pathlib.Path(sys.argv[2]).resolve(), sys.argv[3]
12
+ with tempfile.TemporaryDirectory() as directory:
13
+ if protoc.main(["protoc", f"-I{contract.parent}", f"--python_out={directory}", str(contract)]) != 0:
14
+ raise RuntimeError("Contract compilation failed")
15
+ sys.path.insert(0, directory)
16
+ messages = importlib.import_module(contract.stem + "_pb2")
17
+ with grpc.insecure_channel(f"127.0.0.1:{port}") as channel:
18
+ confirm = channel.unary_unary(
19
+ "/orders.Orders/ConfirmOrder",
20
+ request_serializer=messages.ConfirmOrderRequest.SerializeToString,
21
+ response_deserializer=messages.OrderConfirmation.FromString,
22
+ )
23
+ result = confirm(messages.ConfirmOrderRequest(order_id=order_id), timeout=3)
24
+ print(f"{result.id}: {result.state}")
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application"
4
+ require_relative "contracts"
5
+
6
+ bundle = OrdersExample.build(port: Integer(ENV.fetch("HTTP_PORT", "8080")))
7
+ controller = ObjectFramework::GRPC::Controller.new(application: bundle.application, operation: :confirm_order)
8
+ grpc = ObjectFramework::GRPC::Server.new(application: bundle.application,
9
+ services: [OrdersExample.grpc_contract], controllers: { confirm_order: controller },
10
+ port: Integer(ENV.fetch("GRPC_PORT", "50051")))
11
+ bundle.application.install(grpc)
12
+ bundle.application.start
13
+ puts "HTTP order API: http://127.0.0.1:#{bundle.server.port}"
14
+ puts "gRPC order API: 127.0.0.1:#{grpc.port} (orders.Orders/ConfirmOrder)"
15
+
16
+ signals = Queue.new
17
+ %w[INT TERM].each { |signal| Signal.trap(signal) { signals << signal } }
18
+ begin
19
+ signals.pop
20
+ ensure
21
+ bundle.application.stop
22
+ bundle.storage.close
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application"
4
+
5
+ bundle = OrdersExample.build(port: Integer(ENV.fetch("PORT", "8080")))
6
+ bundle.application.start
7
+ puts "Order confirmation API: http://127.0.0.1:#{bundle.server.port}"
8
+ puts "POST /orders/ready/confirm; POST /orders/rejected/confirm"
9
+
10
+ # Signal handlers only wake the main thread; resource cleanup stays in normal Ruby code.
11
+ signals = Queue.new
12
+ %w[INT TERM].each { |signal| Signal.trap(signal) { signals << signal } }
13
+ begin
14
+ signals.pop
15
+ ensure
16
+ bundle.application.stop
17
+ bundle.storage.close
18
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ObjectFramework
4
+ Factory = Data.define(:dependencies, :build, :close)
5
+ Operation = Data.define(:use_case, :policy, :dependencies)
6
+
7
+ class Application
8
+ attr_reader :errors, :reporter
9
+
10
+ def initialize(storage: nil, mappings: nil, errors: ErrorRegistry.new, reporter: nil)
11
+ @storage, @mappings, @errors = storage, mappings, errors
12
+ @reporter = reporter || FailureReporter.new(registry: errors)
13
+ @factories, @operations, @modules, @started_modules = {}, {}, {}, []
14
+ @started, @finalized = false, false
15
+ end
16
+
17
+ def factory(name, dependencies: [], close: false, &build)
18
+ mutable!
19
+ raise ConfigurationError, "Reserved execution dependency: #{name}" if %i[context session transaction].include?(name)
20
+ raise ConfigurationError, "Factory #{name} already registered" if @factories.key?(name)
21
+ raise ConfigurationError, "Factory requires a block" unless build
22
+ @factories[name] = Factory.new(dependencies: dependencies.dup.freeze, build:, close:)
23
+ self
24
+ end
25
+
26
+ def operation(name, use_case:, policy:, dependencies: [])
27
+ mutable!
28
+ raise ConfigurationError, "Operation #{name} already registered" if @operations.key?(name)
29
+ raise ConfigurationError, "Unknown transaction policy" unless %i[none read write].include?(policy)
30
+ @operations[name] = Operation.new(use_case:, policy:, dependencies: dependencies.dup.freeze)
31
+ self
32
+ end
33
+
34
+ def install(mod)
35
+ mutable!
36
+ raise ConfigurationError, "Module #{mod.name} already registered" if @modules.key?(mod.name)
37
+ @modules[mod.name] = mod
38
+ self
39
+ end
40
+
41
+ def start
42
+ raise ConfigurationError, "Application is already started" if @started
43
+ unless @finalized
44
+ modules = ordered_modules
45
+ modules.each { |mod| mod.configure(self) }
46
+ validate!
47
+ modules.each { |mod| mod.validate! if mod.respond_to?(:validate!) }
48
+ @factories.freeze
49
+ @operations.freeze
50
+ @modules.freeze
51
+ @errors.validate!
52
+ @finalized = true
53
+ end
54
+ ordered_modules.each do |mod|
55
+ mod.start
56
+ @started_modules << mod
57
+ end
58
+ @started = true
59
+ self
60
+ rescue Exception
61
+ stop
62
+ raise
63
+ end
64
+
65
+ def stop
66
+ error = nil
67
+ @started_modules.reverse_each do |mod|
68
+ mod.stop
69
+ rescue StandardError => failure
70
+ error ||= failure
71
+ end
72
+ @started_modules.clear
73
+ @started = false
74
+ raise error if error && !$!
75
+ self
76
+ end
77
+
78
+ def invoke(name, context: ExecutionContext.new, **arguments)
79
+ raise ConfigurationError, "Application is not started" unless @started
80
+ operation = @operations.fetch(name) { raise ConfigurationError, "Unknown operation: #{name}" }
81
+ Invocation.new(operation:, factories: @factories, storage: @storage, reporter: @reporter,
82
+ mappings: @mappings).call(context:, **arguments)
83
+ end
84
+
85
+ def validate_operation!(name)
86
+ operation = @operations.fetch(name) { raise ConfigurationError, "Unknown operation: #{name}" }
87
+ unless operation.use_case.is_a?(Class) && operation.use_case.public_instance_methods.include?(:call)
88
+ raise ConfigurationError, "Use cases must be classes implementing call"
89
+ end
90
+ operation
91
+ end
92
+
93
+ private
94
+
95
+ def mutable!
96
+ raise ConfigurationError, "Application registrations are finalized" if @finalized
97
+ end
98
+
99
+ def validate!
100
+ @mappings&.validate!
101
+ @factories.each_key { |name| validate_dependency(name, []) }
102
+ @operations.each do |name, operation|
103
+ validate_operation!(name)
104
+ if operation.policy != :none && !@storage
105
+ raise ConfigurationError, "Transactional operations require storage"
106
+ end
107
+ operation.dependencies.each { |name| validate_dependency(name, [], policy: operation.policy) }
108
+ end
109
+ end
110
+
111
+ def validate_dependency(name, visiting, policy: nil)
112
+ return if name == :context
113
+ if %i[session transaction].include?(name)
114
+ raise ConfigurationError, "Storage dependency requires a transaction" if policy == :none
115
+ raise ConfigurationError, "Session requires mappings" if name == :session && !@mappings
116
+ return
117
+ end
118
+ raise ConfigurationError, "Dependency cycle: #{(visiting + [name]).join(' -> ')}" if visiting.include?(name)
119
+ factory = @factories.fetch(name) { raise ConfigurationError, "Unknown dependency: #{name}" }
120
+ factory.dependencies.each { |dependency| validate_dependency(dependency, visiting + [name], policy:) }
121
+ end
122
+
123
+ def ordered_modules
124
+ result = []
125
+ visit = lambda do |name, path|
126
+ raise ConfigurationError, "Module dependency cycle" if path.include?(name)
127
+ mod = @modules.fetch(name) { raise ConfigurationError, "Missing module: #{name}" }
128
+ return if result.include?(mod)
129
+ mod.requires.each { |dependency| visit.call(dependency, path + [name]) }
130
+ result << mod
131
+ end
132
+ @modules.each_key { |name| visit.call(name, []) }
133
+ result
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ObjectFramework
4
+ module Capabilities
5
+ class Unsupported < ConfigurationError; end
6
+
7
+ class TaskFailure < StandardError
8
+ attr_reader :remote_class
9
+ def initialize(remote_class:, message:, backtrace:)
10
+ @remote_class = remote_class
11
+ super(message)
12
+ set_backtrace(backtrace)
13
+ end
14
+ end
15
+
16
+ def self.report
17
+ { ruby_box: Ruby::Box.enabled?, ractor: defined?(::Ractor) != nil,
18
+ yjit: RubyVM::YJIT.enabled?, zjit: RubyVM::ZJIT.enabled? }.freeze
19
+ end
20
+
21
+ class Definitions
22
+ def initialize
23
+ raise Unsupported, "Enable Ruby Box at process boot with RUBY_BOX=1" unless Ruby::Box.enabled?
24
+ @box = Ruby::Box.new
25
+ end
26
+
27
+ def load(path); @box.load(File.expand_path(path)); end
28
+ def require(path); @box.require(path); end
29
+ def evaluate(source); @box.eval(source); end
30
+ def constant(name); @box.const_get(name, false); end
31
+ end
32
+
33
+ class IsolatedTask
34
+ def initialize(task:)
35
+ raise ConfigurationError, "An isolated task must be a class or module implementing call" unless task.is_a?(Module) && task.respond_to?(:call)
36
+ @task = task
37
+ end
38
+
39
+ def call(input, context: nil)
40
+ context&.cancellation&.check!
41
+ payload = Values.detach(input)
42
+ # Only independent values cross this boundary; the request keeps its session.
43
+ ractor = Ractor.new(@task, payload) do |task, values|
44
+ begin
45
+ [:success, ObjectFramework::Values.detach(task.call(values))]
46
+ rescue StandardError => error
47
+ [:failure, error.class.name, error.message, error.backtrace]
48
+ end
49
+ end
50
+ outcome, *result = ractor.value
51
+ context&.cancellation&.check!
52
+ if outcome == :failure
53
+ raise TaskFailure.new(remote_class: result[0], message: result[1], backtrace: result[2])
54
+ end
55
+ Values.detach(result.first)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ObjectFramework
4
+ class ConfigurationError < StandardError; end
5
+ class InvalidInput < StandardError; end
6
+ class NotFound < StandardError; end
7
+ class Conflict < StandardError; end
8
+ class Duplicate < StandardError; end
9
+ class Unavailable < StandardError; end
10
+ class CapacityExceeded < StandardError; end
11
+ class DeadlineExceeded < StandardError; end
12
+ class Cancelled < StandardError; end
13
+ class ClosedSession < StandardError; end
14
+ class UnsafeResult < StandardError; end
15
+
16
+ ErrorDescription = Data.define(:category, :code, :title, :details)
17
+
18
+ class ErrorRegistry
19
+ CATEGORIES = %i[bad_request invalid_input unauthenticated forbidden not_found conflict
20
+ duplicate transaction_conflict rate_limited capacity unavailable
21
+ deadline cancelled unimplemented internal].freeze
22
+ DEFAULTS = {
23
+ InvalidInput => [:invalid_input, "invalid_input", "Invalid input"],
24
+ NotFound => [:not_found, "not_found", "Resource not found"],
25
+ Conflict => [:transaction_conflict, "transaction_conflict", "Transaction conflict"],
26
+ Duplicate => [:duplicate, "duplicate", "Resource already exists"],
27
+ Unavailable => [:unavailable, "unavailable", "Storage unavailable"],
28
+ CapacityExceeded => [:capacity, "capacity_exceeded", "Capacity exhausted"],
29
+ DeadlineExceeded => [:deadline, "deadline_exceeded", "Deadline exceeded"],
30
+ Cancelled => [:cancelled, "cancelled", "Operation cancelled"]
31
+ }.freeze
32
+
33
+ def initialize
34
+ @registrations = {}
35
+ DEFAULTS.each { |type, (category, code, title)| register(type, category:, code:, title:) }
36
+ end
37
+
38
+ def register(type, category:, code:, title:, details: ->(_) { {} })
39
+ raise ConfigurationError, "Error registry is finalized" if @registrations.frozen?
40
+ raise ConfigurationError, "Expected an exception class" unless type.is_a?(Class) && type <= StandardError
41
+ raise ConfigurationError, "Duplicate error registration: #{type}" if @registrations.key?(type)
42
+ raise ConfigurationError, "Unknown error category: #{category}" unless CATEGORIES.include?(category)
43
+ raise ConfigurationError, "A stable code and title are required" if code.to_s.empty? || title.to_s.empty?
44
+ @registrations[type] = [category, code.to_s.dup.freeze, title.to_s.dup.freeze, details].freeze
45
+ self
46
+ end
47
+
48
+ def validate!
49
+ codes = @registrations.values.map { |registration| registration[1] }
50
+ raise ConfigurationError, "Ambiguous public error codes" unless codes.uniq.size == codes.size
51
+ @registrations.freeze
52
+ self
53
+ end
54
+
55
+ def resolve(exception)
56
+ type = exception.class.ancestors.find { |ancestor| @registrations.key?(ancestor) }
57
+ return ErrorDescription.new(category: :internal, code: "internal_error", title: "Internal server error", details: {}) unless type
58
+
59
+ category, code, title, details = @registrations.fetch(type)
60
+ ErrorDescription.new(category:, code:, title:, details: Values.detach(details.call(exception)))
61
+ end
62
+ end
63
+
64
+ class FailureReporter
65
+ def initialize(sink: ->(record) {}, registry: ErrorRegistry.new)
66
+ @sink, @registry = sink, registry
67
+ end
68
+
69
+ def report(exception, context:, secondary: false)
70
+ return if !secondary && !context.claim_failure
71
+
72
+ # Messages and argument values are deliberately absent: they can contain secrets.
73
+ record = {
74
+ operation_id: context.operation_id, code: @registry.resolve(exception).code,
75
+ exception: exception.class.name, cause: exception.cause&.class&.name,
76
+ backtrace: exception.backtrace&.dup, duration: context.elapsed,
77
+ transaction_state: context.transaction_state, secondary:
78
+ }
79
+ @sink.call(Values.detach(record))
80
+ rescue StandardError
81
+ # Diagnostics must never replace the exception being reported.
82
+ nil
83
+ end
84
+ end
85
+ end