event_engine-event_definition 0.2.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,33 @@
1
+ module EventEngine
2
+ class EventDefinition
3
+ module Payloads
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+ end
7
+
8
+ module ClassMethods
9
+ def optional_payload(name, from: nil, attr: nil)
10
+ payload_fields << {
11
+ name: name.to_sym,
12
+ required: false,
13
+ from: from,
14
+ attr: attr
15
+ }
16
+ end
17
+
18
+ def required_payload(name, from: nil, attr: nil)
19
+ payload_fields << {
20
+ name: name.to_sym,
21
+ required: true,
22
+ from: from,
23
+ attr: attr
24
+ }
25
+ end
26
+
27
+ def payload_fields
28
+ @payload_fields ||= []
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,178 @@
1
+ require "json"
2
+ require "digest"
3
+
4
+ module EventEngine
5
+ class EventDefinition
6
+ module Schemas
7
+ def self.included(base)
8
+ base.extend ClassMethods
9
+ end
10
+
11
+ class Schema < Struct.new(
12
+ :event_name,
13
+ :event_version,
14
+ :event_type,
15
+ :subject,
16
+ :domain,
17
+ :required_inputs,
18
+ :optional_inputs,
19
+ :payload_fields,
20
+ keyword_init: true
21
+ )
22
+
23
+ def self.from_h(hash)
24
+ h = hash.transform_keys(&:to_sym)
25
+
26
+ new(
27
+ event_name: h[:event_name]&.to_sym,
28
+ event_version: h[:event_version],
29
+ event_type: h[:event_type]&.to_sym,
30
+ subject: h[:subject]&.to_sym,
31
+ domain: h[:domain]&.to_sym,
32
+ required_inputs: Array(h[:required_inputs]).map(&:to_sym),
33
+ optional_inputs: Array(h[:optional_inputs]).map(&:to_sym),
34
+ payload_fields: Array(h[:payload_fields]).map { |field| payload_field_from_h(field) }
35
+ )
36
+ end
37
+
38
+ def self.payload_field_from_h(field)
39
+ f = field.transform_keys(&:to_sym)
40
+
41
+ {
42
+ name: f[:name]&.to_sym,
43
+ required: f[:required],
44
+ from: f[:from]&.to_sym,
45
+ attr: f[:attr]&.to_sym
46
+ }
47
+ end
48
+
49
+ def fingerprint
50
+ Digest::SHA256.hexdigest(
51
+ canonical_representation
52
+ )
53
+ end
54
+
55
+ def to_h
56
+ {
57
+ event_name: event_name,
58
+ event_version: event_version,
59
+ event_type: event_type,
60
+ subject: subject,
61
+ domain: domain,
62
+ required_inputs: required_inputs,
63
+ optional_inputs: optional_inputs,
64
+ payload_fields: payload_fields.map { |field| payload_field_h(field) },
65
+ fingerprint: fingerprint
66
+ }
67
+ end
68
+
69
+ def to_ruby
70
+ <<~RUBY.strip
71
+ EventEngine::EventDefinition::Schema.new(
72
+ event_name: #{event_name.inspect},
73
+ event_version: #{event_version.inspect},
74
+ event_type: #{event_type.inspect},
75
+ subject: #{subject.inspect},
76
+ domain: #{domain.inspect},
77
+ required_inputs: #{required_inputs.inspect},
78
+ optional_inputs: #{optional_inputs.inspect},
79
+ payload_fields: [#{payload_fields.map { |h| ruby_hash(h) }.join(", ")}]
80
+ )
81
+ RUBY
82
+ end
83
+
84
+ private
85
+
86
+ def canonical_representation
87
+ {
88
+ event_name: event_name.to_s,
89
+ event_type: event_type.to_s,
90
+ required_inputs: required_inputs.map(&:to_s).sort,
91
+ optional_inputs: optional_inputs.map(&:to_s).sort,
92
+ payload_fields: payload_fields
93
+ .map { |h| h.transform_values { |v| v.to_s } }
94
+ .sort_by { |h| h[:name].to_s }
95
+ }.to_json
96
+ end
97
+
98
+ def payload_field_h(field)
99
+ {
100
+ name: field[:name],
101
+ from: field[:from],
102
+ attr: field[:attr],
103
+ required: field[:required]
104
+ }
105
+ end
106
+
107
+ def ruby_hash(hash)
108
+ inner = hash.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
109
+ "{#{inner}}"
110
+ end
111
+ end
112
+
113
+ module ClassMethods
114
+ def schema
115
+ errors = schema_errors
116
+ raise ArgumentError, errors.join(", ") if errors.any?
117
+
118
+ required = inputs.select { |_, v| v== :required }.keys
119
+ optional = inputs.select { |_, v| v== :optional }.keys
120
+
121
+ Schema.new(
122
+ event_name: @event_name,
123
+ event_type: @event_type,
124
+ subject: @subject,
125
+ domain: @domain,
126
+ required_inputs: required,
127
+ optional_inputs: optional,
128
+ payload_fields: payload_fields
129
+ )
130
+ end
131
+
132
+ def schema_errors
133
+ errors = []
134
+ validate_identity(errors)
135
+ validate_payload_fields(errors)
136
+ errors
137
+ end
138
+
139
+ def valid_schema?
140
+ schema_errors.empty?
141
+ end
142
+
143
+ private
144
+
145
+ def validate_identity(errors)
146
+ errors << "event_name is required" unless @event_name
147
+ errors << "event_type is required" unless @event_type
148
+ end
149
+
150
+ def validate_payload_fields(errors)
151
+ seen = {}
152
+
153
+ payload_fields.each do |field|
154
+ name = field[:name]
155
+
156
+ if seen[name]
157
+ errors << "duplicate payload field: #{name}"
158
+ end
159
+
160
+ if RESERVED_PAYLOAD_FIELDS.include?(name)
161
+ errors << "payload field uses reserved name: #{name}"
162
+ end
163
+
164
+ if field[:from].nil?
165
+ errors << "payload field #{name} must have a from:"
166
+ end
167
+
168
+ unless inputs.key?(field[:from])
169
+ errors << "payload field #{name} references unknown input: #{field[:from]}"
170
+ end
171
+
172
+ seen[name] = true
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ 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,48 @@
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
+ class EventDefinition
8
+ RESERVED_PAYLOAD_FIELDS = %i[
9
+ event_name
10
+ event_type
11
+ event_version
12
+ occurred_at
13
+ created_at
14
+ updated_at
15
+ published_at
16
+ metadata
17
+ idempotency_key
18
+ attempts
19
+ dead_lettered_at
20
+ aggregate_type
21
+ aggregate_id
22
+ aggregate_version
23
+ ].freeze
24
+
25
+ include Inputs
26
+ include Payloads
27
+ include Validation
28
+ include Schemas
29
+
30
+ class << self
31
+ def event_name(value)
32
+ @event_name = value
33
+ end
34
+
35
+ def event_type(value)
36
+ @event_type = value
37
+ end
38
+
39
+ def subject(value)
40
+ @subject = value
41
+ end
42
+
43
+ def domain(value)
44
+ @domain = value
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,100 @@
1
+ module EventEngine
2
+ class EventEngineHelpersWriter
3
+ HEADER = <<~RUBY.freeze
4
+ # This file is authoritative in production.
5
+ # It is generated from EventDefinitions via:
6
+ #
7
+ # bin/rails event_engine:schema:dump
8
+ #
9
+ # Do not edit manually.
10
+
11
+ RUBY
12
+
13
+ ENVELOPE_KEYS = DslCompiler::RESERVED_INPUT_NAMES
14
+
15
+ def self.write(path, event_schema, root_module: "EventEngine", emit: "EventEngine.emit",
16
+ header: HEADER, group_by_domain: true, schema_filename: nil)
17
+ File.write(
18
+ path,
19
+ generate(event_schema, root_module: root_module, emit: emit, header: header,
20
+ group_by_domain: group_by_domain, schema_filename: schema_filename)
21
+ )
22
+ end
23
+
24
+ def self.generate(event_schema, root_module: "EventEngine", emit: "EventEngine.emit",
25
+ header: HEADER, group_by_domain: true, schema_filename: nil)
26
+ body = group_by_domain ? grouped_body(event_schema, emit) : flat_body(event_schema, emit)
27
+ body = "#{schema_path_accessor(schema_filename)}#{body}" if schema_filename
28
+
29
+ "#{header}module #{root_module}\n#{body}end\n"
30
+ end
31
+
32
+ def self.schema_path_accessor(filename)
33
+ " def self.schema_path\n File.expand_path(#{filename.inspect}, __dir__)\n end\n"
34
+ end
35
+
36
+ def self.grouped_body(event_schema, emit)
37
+ events_by_domain(event_schema).map do |domain, event_names|
38
+ helpers = helpers_for(event_schema, domain, event_names, emit, indent: 4)
39
+
40
+ " module #{module_name(domain)}\n#{helpers} end\n"
41
+ end.join
42
+ end
43
+
44
+ def self.flat_body(event_schema, emit)
45
+ events_by_domain(event_schema).map do |domain, event_names|
46
+ helpers_for(event_schema, domain, event_names, emit, indent: 2)
47
+ end.join
48
+ end
49
+
50
+ def self.helpers_for(event_schema, domain, event_names, emit, indent:)
51
+ event_names.map do |event_name|
52
+ helper(domain, event_schema.latest_for(event_name, domain: domain), emit, indent)
53
+ end.join
54
+ end
55
+
56
+ def self.events_by_domain(event_schema)
57
+ event_schema.schemas_by_event.keys
58
+ .group_by { |(domain, _event_name)| domain }
59
+ .transform_values { |pairs| pairs.map { |(_domain, event_name)| event_name }.sort }
60
+ end
61
+
62
+ def self.module_name(domain)
63
+ domain.to_s.split("_").map(&:capitalize).join
64
+ end
65
+
66
+ def self.helper(domain, schema, emit, indent)
67
+ pad = " " * indent
68
+ call_pad = " " * (indent + 2)
69
+ arg_pad = " " * (indent + 4)
70
+
71
+ arguments = [
72
+ schema.event_name.inspect,
73
+ "domain: #{domain.inspect}",
74
+ "inputs: #{inputs_hash(schema)}",
75
+ *ENVELOPE_KEYS.map { |name| "#{name}: #{name}" }
76
+ ].map { |argument| "#{arg_pad}#{argument}" }.join(",\n")
77
+
78
+ "#{pad}def self.#{schema.event_name}(#{signature(schema)})\n" \
79
+ "#{call_pad}#{emit}(\n" \
80
+ "#{arguments}\n" \
81
+ "#{call_pad})\n" \
82
+ "#{pad}end\n"
83
+ end
84
+
85
+ def self.signature(schema)
86
+ required = schema.required_inputs.map { |name| "#{name}:" }
87
+ optional = schema.optional_inputs.map { |name| "#{name}: nil" }
88
+ envelope = ENVELOPE_KEYS.map { |name| "#{name}: nil" }
89
+
90
+ (required + optional + envelope).join(", ")
91
+ end
92
+
93
+ def self.inputs_hash(schema)
94
+ inputs = schema.required_inputs + schema.optional_inputs
95
+ return "{}" if inputs.empty?
96
+
97
+ "{ #{inputs.map { |name| "#{name}: #{name}" }.join(", ")} }"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,80 @@
1
+ module EventEngine
2
+ class EventSchema
3
+ class DuplicateEventNameError < StandardError; end
4
+
5
+ def self.define(&block)
6
+ schema = new
7
+ block.call(schema)
8
+ schema
9
+ end
10
+
11
+ def initialize
12
+ @schemas_by_event = {}
13
+ @finalized = false
14
+ end
15
+
16
+ def register(schema)
17
+ raise FrozenError, "EventSchema is finalized" if @finalized
18
+ key = key_for(schema.domain, schema.event_name)
19
+ version = schema.event_version
20
+
21
+ @schemas_by_event[key] ||= {}
22
+ guard_duplicate_event_name!(@schemas_by_event[key][version], schema)
23
+ @schemas_by_event[key][version] = schema
24
+ end
25
+
26
+ def guard_duplicate_event_name!(existing, incoming)
27
+ return unless existing
28
+
29
+ raise DuplicateEventNameError,
30
+ "duplicate (domain, event_name) " \
31
+ "(#{incoming.domain.inspect}, #{incoming.event_name.inspect}): " \
32
+ "already registered at version #{existing.event_version.inspect}"
33
+ end
34
+
35
+ def events(domain: nil)
36
+ @schemas_by_event.keys
37
+ .select { |(schema_domain, _name)| domain.nil? || schema_domain == domain }
38
+ .map { |(_domain, event_name)| event_name }
39
+ .uniq
40
+ end
41
+
42
+ def versions_for(event_name, domain: nil)
43
+ version_sets_for(event_name, domain).flat_map(&:keys).uniq.sort
44
+ end
45
+
46
+ def schema_for(event_name, version, domain: nil)
47
+ set = version_sets_for(event_name, domain).find { |versions| versions.key?(version) }
48
+ set && set[version]
49
+ end
50
+
51
+ def latest_for(event_name, domain: nil)
52
+ merged = version_sets_for(event_name, domain).reduce({}, :merge)
53
+ return nil if merged.empty?
54
+ merged[merged.keys.max]
55
+ end
56
+
57
+ def finalize!
58
+ @finalized = true
59
+ @schemas_by_event.each_value(&:freeze)
60
+ @schemas_by_event.freeze
61
+ freeze
62
+ end
63
+
64
+ def schemas_by_event
65
+ @schemas_by_event
66
+ end
67
+
68
+ private
69
+
70
+ def key_for(domain, event_name)
71
+ [domain, event_name]
72
+ end
73
+
74
+ def version_sets_for(event_name, domain = nil)
75
+ @schemas_by_event.select do |(schema_domain, name), _versions|
76
+ name == event_name && (domain.nil? || schema_domain == domain)
77
+ end.values
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,77 @@
1
+ require "event_engine/event_definition"
2
+
3
+ module EventEngine
4
+ class LifecycleDefinition
5
+ include EventDefinition::Inputs
6
+ include EventDefinition::Payloads
7
+
8
+ class << self
9
+ def subject(value)
10
+ @subject = value
11
+ end
12
+
13
+ def event_type(value)
14
+ @event_type = value
15
+ end
16
+
17
+ def lifecycle(*verbs)
18
+ @verbs = verbs
19
+ end
20
+
21
+ def on(verb, &block)
22
+ verb_overrides[verb] = block
23
+ end
24
+
25
+ def verb_overrides
26
+ @verb_overrides ||= {}
27
+ end
28
+
29
+ def generated_events
30
+ @generated_events ||= Array(@verbs).map { |verb| build_event(verb) }
31
+ end
32
+
33
+ def materialize_all!
34
+ subclasses.flat_map(&:generated_events)
35
+ end
36
+
37
+ def declared_subject
38
+ @subject
39
+ end
40
+
41
+ def declared_event_type
42
+ @event_type
43
+ end
44
+
45
+ private
46
+
47
+ def build_event(verb)
48
+ template = self
49
+ name = :"#{template.declared_subject}_#{verb}"
50
+
51
+ Class.new(EventDefinition) do
52
+ event_name name
53
+ event_type template.declared_event_type
54
+
55
+ define_singleton_method(:inspect) { "EventEngine::LifecycleDefinition(#{name})" }
56
+ define_singleton_method(:to_s) { inspect }
57
+ subject template.declared_subject
58
+
59
+ template.inputs.each do |name, kind|
60
+ kind == :required ? input(name) : optional_input(name)
61
+ end
62
+
63
+ template.payload_fields.each do |field|
64
+ if field[:required]
65
+ required_payload field[:name], from: field[:from], attr: field[:attr]
66
+ else
67
+ optional_payload field[:name], from: field[:from], attr: field[:attr]
68
+ end
69
+ end
70
+
71
+ override = template.verb_overrides[verb]
72
+ class_eval(&override) if override
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,71 @@
1
+ module EventEngine
2
+ class SchemaRegistry
3
+ class UnknownEventError < StandardError; end
4
+
5
+ class RegistryFrozenError < StandardError; end
6
+
7
+ def initialize(event_schema = EventSchema.new)
8
+ @event_schema = event_schema
9
+ @loaded = false
10
+ end
11
+
12
+ def register(schema)
13
+ @event_schema.register(schema)
14
+ end
15
+
16
+ def events
17
+ @event_schema.events
18
+ end
19
+
20
+ def versions_for(event_name, domain: nil)
21
+ @event_schema.versions_for(event_name, domain: domain)
22
+ end
23
+
24
+ def load_from_schema!(schema)
25
+ raise RegistryFrozenError, "EventRegistry already loaded" if loaded?
26
+ @event_schema = schema
27
+
28
+ @loaded = true
29
+ self
30
+ end
31
+
32
+ def reset!
33
+ @event_schema = {}
34
+ @loaded = false
35
+ end
36
+
37
+ def schema(event_name, version: nil, domain: nil)
38
+ raise RegistryFrozenError, "EventRegistry not loaded" unless loaded?
39
+
40
+ schema =
41
+ if version
42
+ @event_schema.schema_for(event_name, version, domain: domain)
43
+ else
44
+ @event_schema.latest_for(event_name, domain: domain)
45
+ end
46
+
47
+ unless schema
48
+ raise UnknownEventError,
49
+ "Unknown #{version ? "version #{version} for " : ""}event: #{event_name}"
50
+ end
51
+
52
+ schema
53
+ end
54
+
55
+ def latest_for(event_name, domain: nil)
56
+ @event_schema.latest_for(event_name, domain: domain)
57
+ end
58
+
59
+ def event_schema
60
+ @event_schema
61
+ end
62
+
63
+ def finalize!
64
+ @event_schema.finalize!
65
+ end
66
+
67
+ def loaded?
68
+ @loaded == true
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,40 @@
1
+ module EventEngine
2
+ class SubjectRegistry
3
+ class UnknownSubjectError < StandardError; end
4
+
5
+ class Subject
6
+ attr_reader :name, :metadata
7
+
8
+ def initialize(name, **metadata)
9
+ @name = name
10
+ @metadata = metadata
11
+ end
12
+ end
13
+
14
+ def self.define(&block)
15
+ registry = new
16
+ registry.instance_eval(&block) if block
17
+ registry
18
+ end
19
+
20
+ def initialize
21
+ @subjects = {}
22
+ end
23
+
24
+ def subject(name, **metadata)
25
+ @subjects[name] = Subject.new(name, **metadata)
26
+ end
27
+
28
+ def [](name)
29
+ @subjects[name]
30
+ end
31
+
32
+ def registered?(name)
33
+ @subjects.key?(name)
34
+ end
35
+
36
+ def names
37
+ @subjects.keys
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,19 @@
1
+ require "event_engine/definition"
2
+
3
+ namespace :event_engine do
4
+ namespace :definition do
5
+ desc "Generate the pack's helper and schema.json from its EventDefinitions"
6
+ task :dump do
7
+ config = EventEngine::Definition.configuration
8
+
9
+ definitions = EventEngine::DefinitionLoader.load!(config.definitions_path)
10
+
11
+ EventEngine::DomainPackBuild.run(
12
+ definitions,
13
+ helper_path: config.helper_path,
14
+ root_module: config.root_module,
15
+ subject_registry: config.subject_registry
16
+ )
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ module EventEngine
2
+ module Definition
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end