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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventSchemaLoader
|
|
3
|
+
def self.load(path)
|
|
4
|
+
registry = SchemaRegistry.new
|
|
5
|
+
return registry unless File.exist?(path)
|
|
6
|
+
|
|
7
|
+
contents = File.read(path.to_s)
|
|
8
|
+
return registry if contents.strip.empty?
|
|
9
|
+
|
|
10
|
+
sandbox = Module.new
|
|
11
|
+
sandbox.const_set(:EventEngine, EventEngine)
|
|
12
|
+
|
|
13
|
+
schema =
|
|
14
|
+
sandbox.module_eval(contents, path.to_s)
|
|
15
|
+
|
|
16
|
+
unless schema.is_a?(EventEngine::EventSchema)
|
|
17
|
+
raise <<~MSG
|
|
18
|
+
Invalid EventEngine schema file.
|
|
19
|
+
|
|
20
|
+
Expected #{path} to return an EventSchema from:
|
|
21
|
+
EventEngine::EventSchema.define { ... }
|
|
22
|
+
|
|
23
|
+
But got:
|
|
24
|
+
#{schema.inspect}
|
|
25
|
+
MSG
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
schema.schemas_by_event.each_value do |versions|
|
|
29
|
+
versions.each_value do |s|
|
|
30
|
+
registry.register(s)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
registry
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventSchemaMerger
|
|
3
|
+
def self.merge(compiled_registry, file_registry)
|
|
4
|
+
merged = EventSchema.new
|
|
5
|
+
|
|
6
|
+
file_loaded_schema = file_registry.event_schema
|
|
7
|
+
|
|
8
|
+
file_loaded_schema.events.each do |event|
|
|
9
|
+
file_loaded_schema.versions_for(event).each do |version|
|
|
10
|
+
merged.register(file_loaded_schema.schema_for(event, version))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Merge compiled schemas
|
|
15
|
+
compiled_registry.events.each do |event|
|
|
16
|
+
compiled_schema = compiled_registry.latest_for(event)
|
|
17
|
+
|
|
18
|
+
existing_versions = merged.versions_for(event)
|
|
19
|
+
latest_version = existing_versions.max
|
|
20
|
+
latest_schema = latest_version && merged.schema_for(event, latest_version)
|
|
21
|
+
|
|
22
|
+
if no_schema_change?(latest_schema, compiled_schema)
|
|
23
|
+
next
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
new_version = version(latest_version)
|
|
27
|
+
new_schema = compiled_schema.dup
|
|
28
|
+
new_schema.event_version = new_version
|
|
29
|
+
|
|
30
|
+
merged.register(new_schema)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
merged.finalize!
|
|
34
|
+
|
|
35
|
+
merged
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.changed?(compiled_registry, file_registry)
|
|
39
|
+
compiled_registry.events.any? do |event|
|
|
40
|
+
compiled_schema = compiled_registry.latest_for(event)
|
|
41
|
+
|
|
42
|
+
existing_versions = file_registry.versions_for(event)
|
|
43
|
+
latest_version = existing_versions.max
|
|
44
|
+
latest_schema = latest_version && file_registry.schema(event, version: latest_version)
|
|
45
|
+
|
|
46
|
+
# New event entirely
|
|
47
|
+
return true unless latest_schema
|
|
48
|
+
|
|
49
|
+
# Fingerprint mismatch means a new version would be created
|
|
50
|
+
latest_schema.fingerprint != compiled_schema.fingerprint
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.no_schema_change?(latest_schema, compiled_schema)
|
|
55
|
+
latest_schema && latest_schema.fingerprint == compiled_schema.fingerprint
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.version(latest_version)
|
|
59
|
+
(latest_version || 0) + 1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventSchemaWriter
|
|
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
|
+
def self.write(path, event_schema)
|
|
14
|
+
schemas =
|
|
15
|
+
event_schema
|
|
16
|
+
.schemas_by_event
|
|
17
|
+
.flat_map { |_event, versions| versions.values }
|
|
18
|
+
.sort_by { |s| [s.event_name.to_s, s.event_version] }
|
|
19
|
+
|
|
20
|
+
File.open(path, "w") do |io|
|
|
21
|
+
io.write(HEADER)
|
|
22
|
+
io.write("EventEngine::EventSchema.define do |schema|\n")
|
|
23
|
+
|
|
24
|
+
schemas.each do |definition|
|
|
25
|
+
write_definition(io, definition)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
io.write("end\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.write_definition(io, definition)
|
|
33
|
+
io.write(" schema.register(\n")
|
|
34
|
+
indent(io, 4) { definition.to_ruby }
|
|
35
|
+
io.write(" )\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.indent(io, spaces)
|
|
39
|
+
padding = " " * spaces
|
|
40
|
+
yield.each_line do |line|
|
|
41
|
+
io.write(padding)
|
|
42
|
+
io.write(line)
|
|
43
|
+
io.write("\n")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class HandlerRegistry
|
|
3
|
+
def initialize
|
|
4
|
+
@handlers = []
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def register(handler, levels:)
|
|
8
|
+
@handlers << { handler: handler, levels: levels }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def dispatch(event)
|
|
12
|
+
@handlers.each do |registration|
|
|
13
|
+
levels = registration[:levels]
|
|
14
|
+
registration[:handler].call(event) if levels == :all || levels.include?(event.process_type)
|
|
15
|
+
end
|
|
16
|
+
event
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def clear!
|
|
20
|
+
@handlers.clear
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
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 process_type(value)
|
|
18
|
+
@process_type = value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def lifecycle(*verbs)
|
|
22
|
+
@verbs = verbs
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on(verb, &block)
|
|
26
|
+
verb_overrides[verb] = block
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def verb_overrides
|
|
30
|
+
@verb_overrides ||= {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def generated_events
|
|
34
|
+
@generated_events ||= Array(@verbs).map { |verb| build_event(verb) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def materialize_all!
|
|
38
|
+
subclasses.flat_map(&:generated_events)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def declared_subject
|
|
42
|
+
@subject
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def declared_event_type
|
|
46
|
+
@event_type
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def declared_process_type
|
|
50
|
+
@process_type
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_event(verb)
|
|
56
|
+
template = self
|
|
57
|
+
name = :"#{template.declared_subject}_#{verb}"
|
|
58
|
+
|
|
59
|
+
Class.new(EventDefinition) do
|
|
60
|
+
event_name name
|
|
61
|
+
event_type template.declared_event_type
|
|
62
|
+
|
|
63
|
+
define_singleton_method(:inspect) { "EventEngine::LifecycleDefinition(#{name})" }
|
|
64
|
+
define_singleton_method(:to_s) { inspect }
|
|
65
|
+
subject template.declared_subject
|
|
66
|
+
process_type template.declared_process_type if template.declared_process_type
|
|
67
|
+
|
|
68
|
+
template.inputs.each do |name, kind|
|
|
69
|
+
kind == :required ? input(name) : optional_input(name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
template.payload_fields.each do |field|
|
|
73
|
+
if field[:required]
|
|
74
|
+
required_payload field[:name], from: field[:from], attr: field[:attr]
|
|
75
|
+
else
|
|
76
|
+
optional_payload field[:name], from: field[:from], attr: field[:attr]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
override = template.verb_overrides[verb]
|
|
81
|
+
class_eval(&override) if override
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
module ProcessType
|
|
3
|
+
ALL = %i[inline background durable broker telemetry sourced].freeze
|
|
4
|
+
|
|
5
|
+
PROCESSORS = {
|
|
6
|
+
inline: :subscribers,
|
|
7
|
+
background: :subscribers,
|
|
8
|
+
durable: :delivery,
|
|
9
|
+
broker: :delivery,
|
|
10
|
+
telemetry: :telemetry,
|
|
11
|
+
sourced: :sourcing
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.all
|
|
15
|
+
ALL
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.processor_for(type)
|
|
19
|
+
PROCESSORS[type]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.known?(type)
|
|
23
|
+
ALL.include?(type)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
## EventEngine
|
|
2
|
+
|
|
3
|
+
> **DO NOT** explore the event_engine gem source code. This reference is the
|
|
4
|
+
> complete user-facing API, embedded verbatim into every event_engine local so
|
|
5
|
+
> their guidance never drifts. Keep it the single source of truth.
|
|
6
|
+
|
|
7
|
+
EventEngine is a Rails engine for defining domain events as declarative classes,
|
|
8
|
+
compiling them to a committed schema, emitting them through generated helpers, and
|
|
9
|
+
dispatching them to registered handlers. Core builds and routes events; it ships no
|
|
10
|
+
handlers of its own. Durable delivery, an event store, and ready-made subscriber
|
|
11
|
+
classes are separate companion gems (`event_engine-delivery`, `event_engine-store`,
|
|
12
|
+
`event_engine-subscribers`) — this reference covers core only.
|
|
13
|
+
|
|
14
|
+
### What it offers
|
|
15
|
+
|
|
16
|
+
**Define events** — subclass `EventEngine::EventDefinition` in `app/event_definitions/`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
class CowFed < EventEngine::EventDefinition
|
|
20
|
+
event_name :cow_fed # the event's identity (required)
|
|
21
|
+
event_type :domain # classification, e.g. :domain (required)
|
|
22
|
+
process_type :durable # routing type (optional; set it explicitly)
|
|
23
|
+
|
|
24
|
+
input :cow # a required input
|
|
25
|
+
optional_input :farmer # an optional input
|
|
26
|
+
|
|
27
|
+
required_payload :weight, from: :cow, attr: :weight
|
|
28
|
+
optional_payload :farmer_name, from: :farmer, attr: :name
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| DSL method | Purpose |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `event_name(:symbol)` | The event's identity; becomes `EventEngine.<name>`. Required. |
|
|
35
|
+
| `event_type(:symbol)` | Classification, e.g. `:domain`. Required. |
|
|
36
|
+
| `process_type(:symbol)` | Routing type (optional). One of the six values below. |
|
|
37
|
+
| `input(:name)` / `optional_input(:name)` | Inputs the emit helper must / may receive. |
|
|
38
|
+
| `required_payload(name, from:, attr: nil)` | Payload field; `from:` names an input, `attr:` is the method read on it (`nil` passes the input through). |
|
|
39
|
+
| `optional_payload(name, from:, attr: nil)` | Same, but omitted when the source input is nil. |
|
|
40
|
+
|
|
41
|
+
Duplicate input names raise `ArgumentError`; payload `from:` must reference a
|
|
42
|
+
declared input.
|
|
43
|
+
|
|
44
|
+
**process_type** — core stamps this symbol onto every emitted event but does not act
|
|
45
|
+
on it. Which handlers receive an event is decided by each handler's `levels:`. The
|
|
46
|
+
values:
|
|
47
|
+
|
|
48
|
+
| value | intent |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `:inline` | handled in-process, synchronously |
|
|
51
|
+
| `:background` | handled in-process, via a background job |
|
|
52
|
+
| `:durable` | handled when a durable outbox drains |
|
|
53
|
+
| `:broker` | published to an external transport |
|
|
54
|
+
| `:telemetry` | metrics / observability handlers |
|
|
55
|
+
| `:sourced` | an append-only event store |
|
|
56
|
+
|
|
57
|
+
The companion gems register the handlers that give `:durable`, `:broker`, `:sourced`,
|
|
58
|
+
etc. their behavior; core just routes to whatever is registered. If `process_type`
|
|
59
|
+
is omitted it is `nil` — set it explicitly so routing intent is clear.
|
|
60
|
+
|
|
61
|
+
**Emit events** — booting installs an `EventEngine.<event_name>` helper per event:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
EventEngine.cow_fed(
|
|
65
|
+
cow: cow, farmer: farmer, # declared inputs, by name
|
|
66
|
+
occurred_at: Time.current, # optional, defaults to now
|
|
67
|
+
metadata: { request_id: "abc" }, # optional
|
|
68
|
+
idempotency_key: "…", # optional, defaults to a UUID
|
|
69
|
+
aggregate_type: "Cow", aggregate_id: cow.id, aggregate_version: 1,
|
|
70
|
+
event_version: 1 # optional, defaults to the latest schema version
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Missing a required input, or passing an unknown one, raises `ArgumentError`. The
|
|
75
|
+
event's `payload` is symbol-keyed.
|
|
76
|
+
|
|
77
|
+
**Register handlers** — a handler is any object responding to `call(event)`:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
EventEngine.register_handler(handler, levels: [:inline, :durable]) # or levels: :all
|
|
81
|
+
EventEngine.dispatch(event) # fan an event out (emit helpers call this)
|
|
82
|
+
EventEngine.reset_handlers! # clear all handlers
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Handlers run synchronously in registration order; if one raises, the rest don't run.
|
|
86
|
+
Keep handlers idempotent.
|
|
87
|
+
|
|
88
|
+
**Configure** — `config/initializers/event_engine.rb`, logger only:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
EventEngine.configure { |config| config.logger = Rails.logger }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Schema workflow** — definitions compile to a committed `db/event_schema.rb`, which
|
|
95
|
+
is authoritative at boot:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
bin/rails event_engine:schema:dump # compile definitions → db/event_schema.rb
|
|
99
|
+
bin/rails event_engine:schema_check # CI: fail if definitions drift from the file
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A new event is version 1; changing an event's identity or payload bumps its version.
|
|
103
|
+
Changing only `process_type` does not bump the version.
|
|
104
|
+
|
|
105
|
+
### Install
|
|
106
|
+
|
|
107
|
+
1. Add the gem and install: `gem "event_engine"`, then `bundle install`.
|
|
108
|
+
2. Run `bin/rails g event_engine:install` — creates `db/event_schema.rb` and
|
|
109
|
+
`config/initializers/event_engine.rb`.
|
|
110
|
+
3. Define events as classes in `app/event_definitions/`.
|
|
111
|
+
4. Run `bin/rails event_engine:schema:dump` and commit `db/event_schema.rb`.
|
|
112
|
+
5. Set `config.logger` in the initializer if you want something other than the default.
|
|
113
|
+
|
|
114
|
+
Durable delivery, an event store, and prebuilt subscriber classes are separate gems
|
|
115
|
+
(`event_engine-delivery`, `event_engine-store`, `event_engine-subscribers`); add them
|
|
116
|
+
when you need them and follow their own setup.
|
|
117
|
+
|
|
118
|
+
### EventEngine conventions
|
|
119
|
+
|
|
120
|
+
- Define one `EventDefinition` class per event in `app/event_definitions/`; never
|
|
121
|
+
hand-build event hashes.
|
|
122
|
+
- Build payloads from inputs with `required_payload`/`optional_payload`; don't pass
|
|
123
|
+
raw payload hashes to the emit helper.
|
|
124
|
+
- Always set `process_type` explicitly so routing intent is clear.
|
|
125
|
+
- Emit only through the generated `EventEngine.<event_name>` helpers, passing the
|
|
126
|
+
declared inputs.
|
|
127
|
+
- Re-run `event_engine:schema:dump` and commit `db/event_schema.rb` after any
|
|
128
|
+
definition change; keep `event_engine:schema_check` green in CI.
|
|
129
|
+
- Keep handlers and subscribers idempotent.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
# Single source of truth for the EventEngine API reference. The companion
|
|
3
|
+
# Claude Code subagents (and any future doc generator) read from here so they
|
|
4
|
+
# can never disagree about how the gem is used.
|
|
5
|
+
module Reference
|
|
6
|
+
DIR = File.expand_path("reference", __dir__)
|
|
7
|
+
|
|
8
|
+
def self.content
|
|
9
|
+
read("guide.md")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.read(name)
|
|
13
|
+
File.read(File.join(DIR, name)).chomp
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class SchemaCatalog
|
|
3
|
+
def initialize(schema_registry:, subject_registry:)
|
|
4
|
+
@schema_registry = schema_registry
|
|
5
|
+
@subject_registry = subject_registry
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def to_markdown
|
|
9
|
+
(["# Event Catalog"] + event_sections).join("\n\n") + "\n"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def event_sections
|
|
15
|
+
@schema_registry.events.map do |event|
|
|
16
|
+
section(@schema_registry.latest_for(event))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def section(schema)
|
|
21
|
+
([
|
|
22
|
+
"## #{schema.event_name} (v#{schema.event_version})",
|
|
23
|
+
"- Type: #{schema.event_type}",
|
|
24
|
+
subject_line(schema)
|
|
25
|
+
] + payload_lines(schema)).compact.join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def payload_lines(schema)
|
|
29
|
+
return [] if schema.payload_fields.empty?
|
|
30
|
+
|
|
31
|
+
["- Payload:"] + schema.payload_fields.map do |field|
|
|
32
|
+
" - #{field[:name]} (#{field[:required] ? "required" : "optional"})"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def subject_line(schema)
|
|
37
|
+
return nil unless schema.subject
|
|
38
|
+
|
|
39
|
+
details = subject_details(schema.subject)
|
|
40
|
+
details.empty? ? "- Subject: #{schema.subject}" : "- Subject: #{schema.subject} (#{details})"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def subject_details(name)
|
|
44
|
+
registered = @subject_registry[name]
|
|
45
|
+
return "" unless registered
|
|
46
|
+
|
|
47
|
+
registered.metadata.map { |key, value| "#{key}: #{value}" }.join(", ")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class SchemaCompatibility
|
|
3
|
+
def self.violations(old_registry:, new_registry:)
|
|
4
|
+
new_registry.events.flat_map do |event|
|
|
5
|
+
previous = old_registry.latest_for(event)
|
|
6
|
+
next [] unless previous
|
|
7
|
+
|
|
8
|
+
current = new_registry.latest_for(event)
|
|
9
|
+
new(old: previous, new: current).breaking_changes.map do |change|
|
|
10
|
+
"#{event}: #{change}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(old:, new:)
|
|
16
|
+
@old = old
|
|
17
|
+
@new = new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def breaking_changes
|
|
21
|
+
removed_required_fields + newly_required_fields
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def removed_required_fields
|
|
27
|
+
(required_names(@old) - field_names(@new)).map do |name|
|
|
28
|
+
"required payload field removed: #{name}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def newly_required_fields
|
|
33
|
+
(optional_names(@old) & required_names(@new)).map do |name|
|
|
34
|
+
"payload field became required: #{name}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def field_names(schema)
|
|
39
|
+
schema.payload_fields.map { |field| field[:name] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def required_names(schema)
|
|
43
|
+
schema.payload_fields.select { |field| field[:required] }.map { |field| field[:name] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def optional_names(schema)
|
|
47
|
+
schema.payload_fields.reject { |field| field[:required] }.map { |field| field[:name] }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class SchemaDiff
|
|
3
|
+
def initialize(expected:, actual:)
|
|
4
|
+
@expected = expected
|
|
5
|
+
@actual = actual
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def changed?
|
|
9
|
+
@expected != @actual
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_s
|
|
13
|
+
expected_lines = @expected.lines
|
|
14
|
+
actual_lines = @actual.lines
|
|
15
|
+
|
|
16
|
+
Array.new([ expected_lines.size, actual_lines.size ].max) do |index|
|
|
17
|
+
line_diff(expected_lines[index], actual_lines[index])
|
|
18
|
+
end.compact.join
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def line_diff(expected_line, actual_line)
|
|
24
|
+
return " #{expected_line}" if expected_line == actual_line
|
|
25
|
+
|
|
26
|
+
[ marked("-", expected_line), marked("+", actual_line) ].compact.join
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def marked(sign, line)
|
|
30
|
+
return nil if line.nil?
|
|
31
|
+
|
|
32
|
+
"#{sign}#{line}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "event_engine/schema_diff"
|
|
2
|
+
|
|
3
|
+
module EventEngine
|
|
4
|
+
class SchemaDriftGuard
|
|
5
|
+
class DriftError < StandardError; end
|
|
6
|
+
|
|
7
|
+
def self.check!(schema_path:, definitions:)
|
|
8
|
+
raise DriftError, "Schema file does not exist: #{schema_path}" unless File.exist?(schema_path)
|
|
9
|
+
|
|
10
|
+
committed = File.read(schema_path)
|
|
11
|
+
regenerated = dump_to_string(definitions)
|
|
12
|
+
|
|
13
|
+
return true if committed == regenerated
|
|
14
|
+
|
|
15
|
+
raise DriftError, <<~MSG
|
|
16
|
+
EventEngine schema drift detected.
|
|
17
|
+
|
|
18
|
+
The DSL definitions do not match #{schema_path}.
|
|
19
|
+
|
|
20
|
+
#{SchemaDiff.new(expected: committed, actual: regenerated)}
|
|
21
|
+
Run:
|
|
22
|
+
bin/rails event_engine:schema:dump
|
|
23
|
+
|
|
24
|
+
And commit the updated schema file.
|
|
25
|
+
MSG
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.dump_to_string(definitions)
|
|
29
|
+
Tempfile.create("event_schema") do |file|
|
|
30
|
+
EventEngine::EventSchemaDumper.dump!(
|
|
31
|
+
definitions: definitions,
|
|
32
|
+
path: file.path
|
|
33
|
+
)
|
|
34
|
+
File.read(file.path)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|