event_engine 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +53 -0
- data/MIT-LICENSE +20 -0
- data/README.md +517 -0
- data/Rakefile +12 -0
- data/app/assets/config/event_engine_manifest.js +1 -0
- data/app/assets/stylesheets/event_engine/application.css +15 -0
- data/app/controllers/event_engine/application_controller.rb +4 -0
- data/app/helpers/event_engine/application_helper.rb +4 -0
- data/app/jobs/event_engine/application_job.rb +4 -0
- data/app/mailers/event_engine/application_mailer.rb +6 -0
- data/app/models/event_engine/application_record.rb +5 -0
- data/app/views/layouts/event_engine/application.html.erb +15 -0
- data/config/routes.rb +2 -0
- data/lib/event_engine/configuration.rb +20 -0
- data/lib/event_engine/definition_loader.rb +26 -0
- data/lib/event_engine/dsl_compiler.rb +50 -0
- data/lib/event_engine/engine.rb +56 -0
- data/lib/event_engine/event.rb +32 -0
- data/lib/event_engine/event_builder.rb +45 -0
- data/lib/event_engine/event_definition/inputs.rb +43 -0
- data/lib/event_engine/event_definition/payloads.rb +47 -0
- data/lib/event_engine/event_definition/schemas.rb +158 -0
- data/lib/event_engine/event_definition/validation.rb +18 -0
- data/lib/event_engine/event_definition.rb +76 -0
- data/lib/event_engine/event_schema.rb +99 -0
- data/lib/event_engine/event_schema_dumper.rb +13 -0
- data/lib/event_engine/event_schema_loader.rb +37 -0
- data/lib/event_engine/event_schema_merger.rb +62 -0
- data/lib/event_engine/event_schema_writer.rb +47 -0
- data/lib/event_engine/handler_registry.rb +23 -0
- data/lib/event_engine/lifecycle_definition.rb +86 -0
- data/lib/event_engine/process_type.rb +26 -0
- data/lib/event_engine/railtie.rb +9 -0
- data/lib/event_engine/reference/guide.md +129 -0
- data/lib/event_engine/reference.rb +16 -0
- data/lib/event_engine/schema_catalog.rb +50 -0
- data/lib/event_engine/schema_compatibility.rb +50 -0
- data/lib/event_engine/schema_diff.rb +35 -0
- data/lib/event_engine/schema_drift_guard.rb +38 -0
- data/lib/event_engine/schema_registry.rb +122 -0
- data/lib/event_engine/subject_registry.rb +40 -0
- data/lib/event_engine/the_local/agents/event_engine-develop.md +142 -0
- data/lib/event_engine/the_local/agents/event_engine-info.md +140 -0
- data/lib/event_engine/the_local/agents/event_engine-install.md +140 -0
- data/lib/event_engine/the_local.rb +55 -0
- data/lib/event_engine/version.rb +3 -0
- data/lib/event_engine.rb +197 -0
- data/lib/generators/event_engine/install_generator.rb +31 -0
- data/lib/generators/event_engine/templates/event_schema.rb +10 -0
- data/lib/generators/event_engine/templates/initializer.rb +4 -0
- data/lib/tasks/event_engine_catalog.rake +13 -0
- data/lib/tasks/event_engine_schema.rake +82 -0
- data/lib/tasks/event_engine_schema_check.rake +20 -0
- data/lib/tasks/event_engine_tasks.rake +4 -0
- metadata +127 -0
data/config/routes.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
# Holds configuration options for EventEngine core.
|
|
3
|
+
#
|
|
4
|
+
# @example
|
|
5
|
+
# EventEngine.configure do |config|
|
|
6
|
+
# config.logger = Rails.logger
|
|
7
|
+
# end
|
|
8
|
+
class Configuration
|
|
9
|
+
# @!attribute [rw] logger
|
|
10
|
+
# Logger instance for EventEngine messages.
|
|
11
|
+
# @return [Logger] defaults to +Rails.logger+
|
|
12
|
+
attr_accessor :logger
|
|
13
|
+
|
|
14
|
+
attr_accessor :metadata_defaults
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
module DefinitionLoader
|
|
3
|
+
class << self
|
|
4
|
+
def ensure_loaded!
|
|
5
|
+
eager_load_definitions!
|
|
6
|
+
LifecycleDefinition.materialize_all!
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def eager_load_definitions!
|
|
10
|
+
return if loaded?
|
|
11
|
+
|
|
12
|
+
unless defined?(Rails) && Rails.application
|
|
13
|
+
raise "EventEngine requires a Rails application to load definitions"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Rails.application.eager_load!
|
|
17
|
+
|
|
18
|
+
@loaded = true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def loaded?
|
|
22
|
+
@loaded ||= false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class DslCompiler
|
|
3
|
+
class InvalidEventNameError < StandardError; end
|
|
4
|
+
|
|
5
|
+
SNAKE_CASE = /\A[a-z][a-z0-9_]*\z/
|
|
6
|
+
|
|
7
|
+
def self.compile(definitions)
|
|
8
|
+
registry = SchemaRegistry.new
|
|
9
|
+
subject_violations = []
|
|
10
|
+
name_violations = []
|
|
11
|
+
|
|
12
|
+
Array(definitions).each do |definition|
|
|
13
|
+
schema = definition.schema
|
|
14
|
+
record_subject_violation(schema, subject_violations)
|
|
15
|
+
record_name_violation(schema, name_violations)
|
|
16
|
+
registry.register(schema)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
raise_invalid_event_names(name_violations)
|
|
20
|
+
raise_unknown_subjects(subject_violations)
|
|
21
|
+
|
|
22
|
+
registry
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.record_name_violation(schema, violations)
|
|
26
|
+
return if schema.event_name.to_s.match?(SNAKE_CASE)
|
|
27
|
+
|
|
28
|
+
violations << schema.event_name.inspect
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.raise_invalid_event_names(violations)
|
|
32
|
+
return if violations.empty?
|
|
33
|
+
|
|
34
|
+
raise InvalidEventNameError, "event names must be snake_case: #{violations.join(", ")}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.record_subject_violation(schema, violations)
|
|
38
|
+
return if schema.subject.nil?
|
|
39
|
+
return if EventEngine.subject_registry.registered?(schema.subject)
|
|
40
|
+
|
|
41
|
+
violations << "#{schema.event_name}: unknown subject #{schema.subject.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.raise_unknown_subjects(violations)
|
|
45
|
+
return if violations.empty?
|
|
46
|
+
|
|
47
|
+
raise SubjectRegistry::UnknownSubjectError, violations.join(", ")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace EventEngine
|
|
4
|
+
|
|
5
|
+
initializer "event_engine.load_schema_and_install_helpers" do |app|
|
|
6
|
+
app.config.after_initialize do
|
|
7
|
+
schema_path = Rails.root.join("db", "event_schema.rb")
|
|
8
|
+
|
|
9
|
+
if File.exist?(schema_path)
|
|
10
|
+
Engine.send(
|
|
11
|
+
:load_schema_and_install_helpers,
|
|
12
|
+
schema_path: schema_path
|
|
13
|
+
)
|
|
14
|
+
else
|
|
15
|
+
Engine.send(
|
|
16
|
+
:handle_missing_schema!,
|
|
17
|
+
schema_path
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def load_schema_and_install_helpers(schema_path:)
|
|
27
|
+
EventEngine.boot_from_schema!(
|
|
28
|
+
schema_path: schema_path,
|
|
29
|
+
registry: EventEngine::SchemaRegistry.new
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle_missing_schema!(schema_path)
|
|
34
|
+
if Rails.env.development? || Rails.env.test?
|
|
35
|
+
Rails.logger.warn(
|
|
36
|
+
"[EventEngine] Schema file not found at #{schema_path}. " \
|
|
37
|
+
"Run: bin/rails event_engine:schema:dump"
|
|
38
|
+
)
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
raise <<~MSG
|
|
43
|
+
EventEngine schema file missing.
|
|
44
|
+
|
|
45
|
+
Expected to find:
|
|
46
|
+
#{schema_path}
|
|
47
|
+
|
|
48
|
+
Run:
|
|
49
|
+
bin/rails event_engine:schema:dump
|
|
50
|
+
|
|
51
|
+
And commit the generated file.
|
|
52
|
+
MSG
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
# A non-persisted, in-memory representation of an emitted event with a
|
|
3
|
+
# symbol-keyed payload. Passed to subscribers' +#handle(event)+ at every
|
|
4
|
+
# in-process level (1-3), and returned by the emitter for levels 1 and 2.
|
|
5
|
+
Event = Struct.new(
|
|
6
|
+
:event_name,
|
|
7
|
+
:event_type,
|
|
8
|
+
:event_version,
|
|
9
|
+
:process_type,
|
|
10
|
+
:subject,
|
|
11
|
+
:domain,
|
|
12
|
+
:payload,
|
|
13
|
+
:metadata,
|
|
14
|
+
:occurred_at,
|
|
15
|
+
:idempotency_key,
|
|
16
|
+
:aggregate_type,
|
|
17
|
+
:aggregate_id,
|
|
18
|
+
:aggregate_version,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
) do
|
|
21
|
+
# Builds an Event from a record responding to the same members (e.g. an
|
|
22
|
+
# OutboxEvent), normalizing the payload to symbol keys.
|
|
23
|
+
#
|
|
24
|
+
# @param record [#payload]
|
|
25
|
+
# @return [Event]
|
|
26
|
+
def self.from(record)
|
|
27
|
+
attrs = members.to_h { |member| [member, record.public_send(member)] }
|
|
28
|
+
attrs[:payload] = attrs[:payload].to_h.transform_keys(&:to_sym)
|
|
29
|
+
new(**attrs)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
# Constructs an event payload hash from a schema and input data.
|
|
3
|
+
# Validates that required inputs are present and no unknown inputs are passed.
|
|
4
|
+
class EventBuilder
|
|
5
|
+
# Builds an event attributes hash from schema and input data.
|
|
6
|
+
#
|
|
7
|
+
# @param schema [EventDefinition::Schema] the event schema
|
|
8
|
+
# @param data [Hash] input data keyed by input name
|
|
9
|
+
# @return [Hash] event attributes including :event_name, :event_type, :event_version, :payload
|
|
10
|
+
# @raise [ArgumentError] if required inputs are missing or unknown inputs are provided
|
|
11
|
+
def self.build(schema:, data:)
|
|
12
|
+
validate_inputs!(schema, data)
|
|
13
|
+
|
|
14
|
+
payload = {}
|
|
15
|
+
|
|
16
|
+
schema.payload_fields.each do |field|
|
|
17
|
+
input = data[field[:from]]
|
|
18
|
+
next if input.nil? && !field[:required]
|
|
19
|
+
|
|
20
|
+
value = field[:attr] ? input.public_send(field[:attr]) : input
|
|
21
|
+
payload[field[:name]] = value
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
event_name: schema.event_name,
|
|
26
|
+
event_type: schema.event_type,
|
|
27
|
+
event_version: schema.event_version,
|
|
28
|
+
payload: payload
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def self.validate_inputs!(schema, data)
|
|
35
|
+
data_keys = data.keys.map(&:to_sym)
|
|
36
|
+
|
|
37
|
+
missing = schema.required_inputs - data_keys
|
|
38
|
+
raise ArgumentError, "missing required input: #{missing.join(', ')}" if missing.any?
|
|
39
|
+
|
|
40
|
+
allowed = schema.required_inputs + schema.optional_inputs
|
|
41
|
+
unknown = data_keys - allowed
|
|
42
|
+
raise ArgumentError, "unknown input: #{unknown.join(', ')}" if unknown.any?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventDefinition
|
|
3
|
+
# DSL methods for declaring required and optional inputs on an event definition.
|
|
4
|
+
module Inputs
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend ClassMethods
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
# Declares a required input for this event.
|
|
11
|
+
#
|
|
12
|
+
# @param name [Symbol] the input name
|
|
13
|
+
# @raise [ArgumentError] if the input is already declared
|
|
14
|
+
def input(name)
|
|
15
|
+
name = name.to_sym
|
|
16
|
+
if inputs.key?(name)
|
|
17
|
+
raise ArgumentError, "duplicate input: #{name}"
|
|
18
|
+
end
|
|
19
|
+
inputs[name] = :required
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Declares an optional input for this event.
|
|
23
|
+
#
|
|
24
|
+
# @param name [Symbol] the input name
|
|
25
|
+
# @raise [ArgumentError] if the input is already declared
|
|
26
|
+
def optional_input(name)
|
|
27
|
+
name = name.to_sym
|
|
28
|
+
if inputs.key?(name)
|
|
29
|
+
raise ArgumentError, "duplicate input: #{name}"
|
|
30
|
+
end
|
|
31
|
+
inputs[name] = :optional
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns all declared inputs as a hash of name => :required/:optional.
|
|
35
|
+
#
|
|
36
|
+
# @return [Hash{Symbol => Symbol}]
|
|
37
|
+
def inputs
|
|
38
|
+
@inputs ||= {}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventDefinition
|
|
3
|
+
# DSL methods for declaring payload fields on an event definition.
|
|
4
|
+
module Payloads
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend ClassMethods
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
# Declares an optional payload field.
|
|
11
|
+
#
|
|
12
|
+
# @param name [Symbol] the field name in the event payload
|
|
13
|
+
# @param from [Symbol] the input to extract the value from
|
|
14
|
+
# @param attr [Symbol, nil] method to call on the input (nil for passthrough)
|
|
15
|
+
def optional_payload(name, from: nil, attr: nil)
|
|
16
|
+
payload_fields << {
|
|
17
|
+
name: name.to_sym,
|
|
18
|
+
required: false,
|
|
19
|
+
from: from,
|
|
20
|
+
attr: attr
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Declares a required payload field.
|
|
25
|
+
#
|
|
26
|
+
# @param name [Symbol] the field name in the event payload
|
|
27
|
+
# @param from [Symbol] the input to extract the value from
|
|
28
|
+
# @param attr [Symbol, nil] method to call on the input (nil for passthrough)
|
|
29
|
+
def required_payload(name, from: nil, attr: nil)
|
|
30
|
+
payload_fields << {
|
|
31
|
+
name: name.to_sym,
|
|
32
|
+
required: true,
|
|
33
|
+
from: from,
|
|
34
|
+
attr: attr
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns all declared payload field definitions.
|
|
39
|
+
#
|
|
40
|
+
# @return [Array<Hash>]
|
|
41
|
+
def payload_fields
|
|
42
|
+
@payload_fields ||= []
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventDefinition
|
|
3
|
+
# Provides schema generation and fingerprinting for event definitions.
|
|
4
|
+
module Schemas
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend ClassMethods
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Immutable representation of a compiled event schema.
|
|
10
|
+
# Holds the event identity, inputs, and payload field definitions.
|
|
11
|
+
# Used both at development time (compilation) and runtime (registry).
|
|
12
|
+
class Schema < Struct.new(
|
|
13
|
+
:event_name,
|
|
14
|
+
:event_version,
|
|
15
|
+
:event_type,
|
|
16
|
+
:process_type,
|
|
17
|
+
:subject,
|
|
18
|
+
:domain,
|
|
19
|
+
:required_inputs,
|
|
20
|
+
:optional_inputs,
|
|
21
|
+
:payload_fields,
|
|
22
|
+
keyword_init: true
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Returns a SHA256 fingerprint of the schema's canonical representation.
|
|
26
|
+
# Used to detect schema changes and trigger version bumps.
|
|
27
|
+
#
|
|
28
|
+
# @return [String] hex-encoded SHA256 digest
|
|
29
|
+
def fingerprint
|
|
30
|
+
Digest::SHA256.hexdigest(
|
|
31
|
+
canonical_representation
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Serializes the schema to a Ruby source string for the schema file.
|
|
36
|
+
#
|
|
37
|
+
# @return [String]
|
|
38
|
+
def to_ruby
|
|
39
|
+
<<~RUBY.strip
|
|
40
|
+
EventEngine::EventDefinition::Schema.new(
|
|
41
|
+
event_name: #{event_name.inspect},
|
|
42
|
+
event_version: #{event_version.inspect},
|
|
43
|
+
event_type: #{event_type.inspect},
|
|
44
|
+
process_type: #{process_type.inspect},
|
|
45
|
+
subject: #{subject.inspect},
|
|
46
|
+
domain: #{domain.inspect},
|
|
47
|
+
required_inputs: #{required_inputs.inspect},
|
|
48
|
+
optional_inputs: #{optional_inputs.inspect},
|
|
49
|
+
payload_fields: [#{payload_fields.map { |h| ruby_hash(h) }.join(", ")}]
|
|
50
|
+
)
|
|
51
|
+
RUBY
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def canonical_representation
|
|
57
|
+
{
|
|
58
|
+
event_name: event_name.to_s,
|
|
59
|
+
event_type: event_type.to_s,
|
|
60
|
+
required_inputs: required_inputs.map(&:to_s).sort,
|
|
61
|
+
optional_inputs: optional_inputs.map(&:to_s).sort,
|
|
62
|
+
payload_fields: payload_fields
|
|
63
|
+
.map { |h| h.transform_values { |v| v.to_s } }
|
|
64
|
+
.sort_by { |h| h[:name].to_s }
|
|
65
|
+
}.to_json
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ruby_hash(hash)
|
|
69
|
+
inner = hash.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
|
70
|
+
"{#{inner}}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
module ClassMethods
|
|
75
|
+
# Builds and returns a {Schema} from this definition's DSL declarations.
|
|
76
|
+
#
|
|
77
|
+
# @return [Schema]
|
|
78
|
+
# @raise [ArgumentError] if the definition has validation errors
|
|
79
|
+
def schema
|
|
80
|
+
errors = schema_errors
|
|
81
|
+
raise ArgumentError, errors.join(", ") if errors.any?
|
|
82
|
+
|
|
83
|
+
required = inputs.select { |_, v| v== :required }.keys
|
|
84
|
+
optional = inputs.select { |_, v| v== :optional }.keys
|
|
85
|
+
|
|
86
|
+
Schema.new(
|
|
87
|
+
event_name: @event_name,
|
|
88
|
+
event_type: @event_type,
|
|
89
|
+
process_type: @process_type,
|
|
90
|
+
subject: @subject,
|
|
91
|
+
domain: @domain,
|
|
92
|
+
required_inputs: required,
|
|
93
|
+
optional_inputs: optional,
|
|
94
|
+
payload_fields: payload_fields
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns validation errors for this definition, if any.
|
|
99
|
+
#
|
|
100
|
+
# @return [Array<String>]
|
|
101
|
+
def schema_errors
|
|
102
|
+
errors = []
|
|
103
|
+
validate_identity(errors)
|
|
104
|
+
validate_process_type(errors)
|
|
105
|
+
validate_payload_fields(errors)
|
|
106
|
+
errors
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Whether this definition has a valid schema (no errors).
|
|
110
|
+
#
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def valid_schema?
|
|
113
|
+
schema_errors.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def validate_identity(errors)
|
|
119
|
+
errors << "event_name is required" unless @event_name
|
|
120
|
+
errors << "event_type is required" unless @event_type
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_process_type(errors)
|
|
124
|
+
return if @process_type.nil? || ProcessType.known?(@process_type)
|
|
125
|
+
errors << "process_type is unknown: #{@process_type.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_payload_fields(errors)
|
|
129
|
+
seen = {}
|
|
130
|
+
|
|
131
|
+
payload_fields.each do |field|
|
|
132
|
+
name = field[:name]
|
|
133
|
+
|
|
134
|
+
if seen[name]
|
|
135
|
+
errors << "duplicate payload field: #{name}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if RESERVED_PAYLOAD_FIELDS.include?(name)
|
|
139
|
+
errors << "payload field uses reserved name: #{name}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if field[:from].nil?
|
|
143
|
+
errors << "payload field #{name} must have a from:"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
unless inputs.key?(field[:from])
|
|
147
|
+
errors << "payload field #{name} references unknown input: #{field[:from]}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# attr: is optional - when omitted, input value is used directly (passthrough)
|
|
151
|
+
|
|
152
|
+
seen[name] = true
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventDefinition
|
|
3
|
+
module Validation
|
|
4
|
+
def validate_inputs!(inputs)
|
|
5
|
+
declared = self.class.inputs
|
|
6
|
+
provided = inputs.keys.map(&:to_sym)
|
|
7
|
+
|
|
8
|
+
return if declared.empty?
|
|
9
|
+
|
|
10
|
+
missing = declared - provided
|
|
11
|
+
raise ArgumentError, "missing input: #{missing.join(', ')}" if missing.any?
|
|
12
|
+
|
|
13
|
+
extra = provided - declared
|
|
14
|
+
raise ArgumentError, "undeclared input: #{extra.join(', ')}" if extra.any?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "event_engine/event_definition/inputs"
|
|
2
|
+
require "event_engine/event_definition/payloads"
|
|
3
|
+
require "event_engine/event_definition/validation"
|
|
4
|
+
require "event_engine/event_definition/schemas"
|
|
5
|
+
|
|
6
|
+
module EventEngine
|
|
7
|
+
# Base class for defining events using the EventEngine DSL.
|
|
8
|
+
#
|
|
9
|
+
# Subclass this to declare an event's name, type, inputs, and payload fields.
|
|
10
|
+
# Definitions are compiled into a schema file at development time and are
|
|
11
|
+
# not used at runtime.
|
|
12
|
+
#
|
|
13
|
+
# @example Define an event
|
|
14
|
+
# class CowFed < EventEngine::EventDefinition
|
|
15
|
+
# input :cow
|
|
16
|
+
# optional_input :farmer
|
|
17
|
+
#
|
|
18
|
+
# event_name :cow_fed
|
|
19
|
+
# event_type :domain
|
|
20
|
+
#
|
|
21
|
+
# required_payload :weight, from: :cow, attr: :weight
|
|
22
|
+
# optional_payload :farmer_name, from: :farmer, attr: :name
|
|
23
|
+
# end
|
|
24
|
+
class EventDefinition
|
|
25
|
+
# Payload field names reserved by the outbox schema.
|
|
26
|
+
RESERVED_PAYLOAD_FIELDS = %i[
|
|
27
|
+
event_name
|
|
28
|
+
event_type
|
|
29
|
+
event_version
|
|
30
|
+
occurred_at
|
|
31
|
+
created_at
|
|
32
|
+
updated_at
|
|
33
|
+
published_at
|
|
34
|
+
metadata
|
|
35
|
+
idempotency_key
|
|
36
|
+
attempts
|
|
37
|
+
dead_lettered_at
|
|
38
|
+
aggregate_type
|
|
39
|
+
aggregate_id
|
|
40
|
+
aggregate_version
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
include Inputs
|
|
44
|
+
include Payloads
|
|
45
|
+
include Validation
|
|
46
|
+
include Schemas
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# Sets the event name for this definition.
|
|
50
|
+
#
|
|
51
|
+
# @param value [Symbol] the event name (e.g. +:cow_fed+)
|
|
52
|
+
def event_name(value)
|
|
53
|
+
@event_name = value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sets the event type for this definition.
|
|
57
|
+
#
|
|
58
|
+
# @param value [Symbol] the event type (e.g. +:domain+, +:integration+)
|
|
59
|
+
def event_type(value)
|
|
60
|
+
@event_type = value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def process_type(value)
|
|
64
|
+
@process_type = value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def subject(value)
|
|
68
|
+
@subject = value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def domain(value)
|
|
72
|
+
@domain = value
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
# Container for all event schemas, organized by event name and version.
|
|
3
|
+
# This is the data structure loaded from the compiled +db/event_schema.rb+ file
|
|
4
|
+
# and used by {SchemaRegistry} at runtime.
|
|
5
|
+
class EventSchema
|
|
6
|
+
class DuplicateEventNameError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Creates an EventSchema using a block DSL (used by the schema file).
|
|
9
|
+
#
|
|
10
|
+
# @yieldparam schema [EventSchema]
|
|
11
|
+
# @return [EventSchema]
|
|
12
|
+
def self.define(&block)
|
|
13
|
+
schema = new
|
|
14
|
+
block.call(schema)
|
|
15
|
+
schema
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@schemas_by_event = {}
|
|
20
|
+
@finalized = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Registers a schema for a specific event name and version.
|
|
24
|
+
#
|
|
25
|
+
# @param schema [EventDefinition::Schema]
|
|
26
|
+
# @raise [FrozenError] if the schema has been finalized
|
|
27
|
+
def register(schema)
|
|
28
|
+
raise FrozenError, "EventSchema is finalized" if @finalized
|
|
29
|
+
event_name = schema.event_name
|
|
30
|
+
version = schema.event_version
|
|
31
|
+
|
|
32
|
+
@schemas_by_event[event_name] ||= {}
|
|
33
|
+
guard_duplicate_event_name!(@schemas_by_event[event_name][version], schema)
|
|
34
|
+
@schemas_by_event[event_name][version] = schema
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def guard_duplicate_event_name!(existing, incoming)
|
|
38
|
+
return unless existing
|
|
39
|
+
|
|
40
|
+
raise DuplicateEventNameError,
|
|
41
|
+
"duplicate event_name #{incoming.event_name.inspect}: " \
|
|
42
|
+
"already registered with domain #{existing.domain.inspect}, " \
|
|
43
|
+
"cannot register again with domain #{incoming.domain.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns all registered event names.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<Symbol>]
|
|
49
|
+
def events
|
|
50
|
+
@schemas_by_event.keys
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns sorted version numbers for a given event.
|
|
54
|
+
#
|
|
55
|
+
# @param event_name [Symbol]
|
|
56
|
+
# @return [Array<Integer>]
|
|
57
|
+
def versions_for(event_name)
|
|
58
|
+
versions = @schemas_by_event[event_name]
|
|
59
|
+
return [] unless versions
|
|
60
|
+
versions.keys.sort
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns the schema for a specific event name and version.
|
|
64
|
+
#
|
|
65
|
+
# @param event_name [Symbol]
|
|
66
|
+
# @param version [Integer]
|
|
67
|
+
# @return [EventDefinition::Schema, nil]
|
|
68
|
+
def schema_for(event_name, version)
|
|
69
|
+
@schemas_by_event.dig(event_name, version)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the latest (highest version) schema for an event.
|
|
73
|
+
#
|
|
74
|
+
# @param event_name [Symbol]
|
|
75
|
+
# @return [EventDefinition::Schema, nil]
|
|
76
|
+
def latest_for(event_name)
|
|
77
|
+
versions = @schemas_by_event[event_name]
|
|
78
|
+
return nil unless versions && !versions.empty?
|
|
79
|
+
versions[versions.keys.max]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Freezes the schema, preventing further registrations.
|
|
83
|
+
#
|
|
84
|
+
# @return [void]
|
|
85
|
+
def finalize!
|
|
86
|
+
@finalized = true
|
|
87
|
+
@schemas_by_event.each_value(&:freeze)
|
|
88
|
+
@schemas_by_event.freeze
|
|
89
|
+
freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the internal hash of schemas keyed by event name and version.
|
|
93
|
+
#
|
|
94
|
+
# @return [Hash{Symbol => Hash{Integer => EventDefinition::Schema}}]
|
|
95
|
+
def schemas_by_event
|
|
96
|
+
@schemas_by_event
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventSchemaDumper
|
|
3
|
+
def self.dump!(definitions:, path:)
|
|
4
|
+
compiled_schema = DslCompiler.compile(definitions)
|
|
5
|
+
compiled_schema.finalize!
|
|
6
|
+
|
|
7
|
+
loaded_schema = EventSchemaLoader.load(path)
|
|
8
|
+
merged_schema = EventSchemaMerger.merge(compiled_schema, loaded_schema)
|
|
9
|
+
|
|
10
|
+
EventSchemaWriter.write(path, merged_schema)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|