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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +21 -0
- data/README.md +203 -0
- data/Rakefile +8 -0
- data/docs/examples.md +96 -0
- data/lib/event_engine/definition/configuration.rb +14 -0
- data/lib/event_engine/definition/null_publisher.rb +13 -0
- data/lib/event_engine/definition/version.rb +7 -0
- data/lib/event_engine/definition.rb +46 -0
- data/lib/event_engine/definition_loader.rb +23 -0
- data/lib/event_engine/domain_pack_build.rb +77 -0
- data/lib/event_engine/dsl_compiler.rb +79 -0
- data/lib/event_engine/event_definition/inputs.rb +31 -0
- data/lib/event_engine/event_definition/payloads.rb +33 -0
- data/lib/event_engine/event_definition/schemas.rb +178 -0
- data/lib/event_engine/event_definition/validation.rb +18 -0
- data/lib/event_engine/event_definition.rb +48 -0
- data/lib/event_engine/event_engine_helpers_writer.rb +100 -0
- data/lib/event_engine/event_schema.rb +80 -0
- data/lib/event_engine/lifecycle_definition.rb +77 -0
- data/lib/event_engine/schema_registry.rb +71 -0
- data/lib/event_engine/subject_registry.rb +40 -0
- data/lib/tasks/event_engine_definition.rake +19 -0
- data/sig/event_engine/definition.rbs +6 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d4fd0fd21eecd6b764d2c91c230bff8733c055148918fdcc3dc027b80cecddb3
|
|
4
|
+
data.tar.gz: 39a989288b99eb7fbe86777f787c357bfedee66e36f1800ce9432915ec92ea93
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0b245cffa43807384cc64d41ae50cca49a967636dd08156766ba5af768dc2c456191bc9eadf886cac8db40c1d57ede617275124fa4da4f906998b01d988b2755
|
|
7
|
+
data.tar.gz: e1c634fe59d2e0afe642c65057939c3123513586ad1402e273bcb1352e38e630148d51a148f9404b537240c5fd55443548d7bd485c755c57d2964857387e420e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-07-20
|
|
9
|
+
|
|
10
|
+
First published release of `event_engine-event_definition`, the plain-Ruby
|
|
11
|
+
event-definition contract for the EventEngine pipeline.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- EventDefinition DSL and shared schema-contract value objects (`Schema`,
|
|
15
|
+
`EventSchema`, `SchemaRegistry`, `SubjectRegistry`, `LifecycleDefinition`,
|
|
16
|
+
`DslCompiler`), with no Rails dependency.
|
|
17
|
+
- Configurable publisher port (`EventEngine::Definition.publisher`), defaulting
|
|
18
|
+
to a `NullPublisher` that fails loudly until a real adapter is assigned.
|
|
19
|
+
- Generated namespaced singleton helper methods that publish domain and input
|
|
20
|
+
payloads through the publisher port.
|
|
21
|
+
- `DomainPackBuild`, which writes a flat helper file alongside its `schema.json`
|
|
22
|
+
for a domain pack and exposes a `schema_path` accessor in the generated helper.
|
|
23
|
+
- `DefinitionLoader.load!` to require a pack's definition files, including
|
|
24
|
+
lifecycle-generated events.
|
|
25
|
+
- `EventEngine::Definition.configure` for pack-generation settings.
|
|
26
|
+
- `event_engine:definition:dump` rake task to generate a pack without Rails.
|
|
27
|
+
|
|
28
|
+
[0.2.0]: https://github.com/DYB-Development/event_engine-event_definition/releases/tag/v0.2.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tylercschneider
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# EventEngine::Definition
|
|
2
|
+
|
|
3
|
+
The plain-Ruby event-definition contract for the [EventEngine](https://github.com/DYB-Development/event_engine) pipeline.
|
|
4
|
+
|
|
5
|
+
EventEngine is a schema-first event pipeline: domain events are **declared** with a Ruby DSL, **compiled** into a canonical schema, and **emitted** through generated helper methods that hand each event to a publisher. This gem holds the plain-Ruby foundation of that pipeline — the `EventEngine::EventDefinition` DSL, the shared schema value objects, and the build step that turns a set of definitions into a committed helper file — with **no Rails dependency**.
|
|
6
|
+
|
|
7
|
+
Lightweight domain-pack gems depend on this contract; the full dispatch, registry, and Rails-engine machinery lives in [`event_engine`](https://github.com/DYB-Development/event_engine) and is wired in at runtime.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Requires Ruby `>= 3.2.0`.
|
|
12
|
+
|
|
13
|
+
Add the gem to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "event_engine-event_definition"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run `bundle install`, and require it:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "event_engine/definition"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## A worked example
|
|
26
|
+
|
|
27
|
+
### The data you want to capture
|
|
28
|
+
|
|
29
|
+
Say a lead just signed up in your Rails app. You have the record in hand:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
lead = Lead.create!(
|
|
33
|
+
email: "ada@example.com",
|
|
34
|
+
name: "Ada Lovelace",
|
|
35
|
+
company: "Analytical Engines",
|
|
36
|
+
source: "webinar"
|
|
37
|
+
)
|
|
38
|
+
# => #<Lead id: 42, email: "ada@example.com", name: "Ada Lovelace",
|
|
39
|
+
# company: "Analytical Engines", source: "webinar", created_at: …>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You want to announce that a lead was created so the rest of the system — analytics, CRM sync, a welcome email — can react. Done ad hoc, every place that raises this "event" builds its own hash: one sends `{ email: … }`, another `{ email_address: …, lead: 42 }`, a third forgets `source`. The keys drift, fields go missing, and every consumer has to defend against all of it.
|
|
43
|
+
|
|
44
|
+
Defining the event fixes the shape **once**, so every producer emits the same data in the same format and every consumer can rely on it — with the names checked and the shape fingerprinted so it can't change silently.
|
|
45
|
+
|
|
46
|
+
### Declare the event
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class LeadCreated < EventEngine::EventDefinition
|
|
50
|
+
event_name :lead_created # the event's identity
|
|
51
|
+
event_type :domain # how you classify it
|
|
52
|
+
domain :marketing # the bounded context it belongs to
|
|
53
|
+
|
|
54
|
+
input :lead # the object the event is built from
|
|
55
|
+
|
|
56
|
+
required_payload :lead_id, from: :lead, attr: :id
|
|
57
|
+
required_payload :email, from: :lead, attr: :email
|
|
58
|
+
required_payload :name, from: :lead, attr: :name
|
|
59
|
+
optional_payload :company, from: :lead, attr: :company
|
|
60
|
+
optional_payload :source, from: :lead, attr: :source
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Read each payload line as: *"the event carries `lead_id`, and its value comes from `lead.id`."* The `input :lead` names the object you hand in; each `from:` points back at that input, and each `attr:` is the attribute read off it.
|
|
65
|
+
|
|
66
|
+
### Generate the pack (a build step)
|
|
67
|
+
|
|
68
|
+
The helper doesn't exist until you generate it. `event_engine-event_definition` ships a rake task for that. Configure it once, then run it whenever a definition changes:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# Rakefile (in your domain pack)
|
|
72
|
+
require "event_engine/definition"
|
|
73
|
+
load "tasks/event_engine_definition.rake"
|
|
74
|
+
|
|
75
|
+
EventEngine::Definition.configure do |config|
|
|
76
|
+
config.definitions_path = "app/event_definitions" # where your EventDefinitions live
|
|
77
|
+
config.helper_path = "lib/generated/marketing_events.rb" # where to write the helper
|
|
78
|
+
config.root_module = "MarketingEvents" # the module that wraps the helpers
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
$ rake event_engine:definition:dump
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The task loads every definition under `definitions_path` — **no Rails required** — and writes two files you commit:
|
|
87
|
+
|
|
88
|
+
- a `MarketingEvents` module with one typed method per event, and
|
|
89
|
+
- a `schema.json` alongside it — the committed contract downstream consumers read.
|
|
90
|
+
|
|
91
|
+
This is a build-time step; your app never runs it while serving requests. If your events declare a `subject`, register them too: `config.subject_registry = EventEngine::SubjectRegistry.define { subject :lead }`.
|
|
92
|
+
|
|
93
|
+
### Emit the event (at runtime)
|
|
94
|
+
|
|
95
|
+
With the helper generated, producing the event anywhere in your app is one call. You hand it the whole `lead`; the contract decides what is captured off it:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
MarketingEvents.lead_created(lead: lead)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Every `lead_created` event, from anywhere in the app, carries exactly the shape you declared:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
{
|
|
105
|
+
lead_id: 42,
|
|
106
|
+
email: "ada@example.com",
|
|
107
|
+
name: "Ada Lovelace",
|
|
108
|
+
company: "Analytical Engines",
|
|
109
|
+
source: "webinar"
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> **Where the work happens:** this gem *records* the `from:`/`attr:` mapping and forwards the raw `lead` under `inputs:`. Reading `lead.id`, `lead.email`, … to build that payload is done by the publisher the `event_engine` runtime supplies — see [How it fits](#how-it-fits-with-event_engine). Until one is configured the default publisher raises, so wire it once at boot:
|
|
114
|
+
>
|
|
115
|
+
> ```ruby
|
|
116
|
+
> EventEngine::Definition.publisher = my_publisher # any object with #publish(event_name, **envelope)
|
|
117
|
+
> ```
|
|
118
|
+
|
|
119
|
+
### More examples
|
|
120
|
+
|
|
121
|
+
See **[docs/examples.md](docs/examples.md)** for an event built from **multiple inputs** (an order and its customer) and a **lifecycle** that generates one event per step (started / completed / failed).
|
|
122
|
+
|
|
123
|
+
## The DSL reference
|
|
124
|
+
|
|
125
|
+
### Declarations, field by field
|
|
126
|
+
|
|
127
|
+
| Declaration | Required to be valid? | What it does |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `event_name :x` | **Yes** — `schema` raises without it | The event's unique, snake_case identity. |
|
|
130
|
+
| `event_type :x` | **Yes** — `schema` raises without it | A classification symbol you choose (e.g. `:domain`, `:product`). Not enumerated by this gem. |
|
|
131
|
+
| `domain :x` | No | The bounded context the event belongs to. Keys the event in the registry and scopes the generated helpers. |
|
|
132
|
+
| `subject :x` | No | The aggregate the event is about. If set, it must be registered in a `SubjectRegistry` when the definitions are compiled. |
|
|
133
|
+
| `input :x` | — | Declares an **input** the event is built from. |
|
|
134
|
+
| `optional_input :x` | — | Same, but the input may be omitted at the call site. |
|
|
135
|
+
| `required_payload :name, from:, attr:` | — | Declares a **payload field** in the emitted event. |
|
|
136
|
+
| `optional_payload :name, from:, attr:` | — | Same, but the field is not a guaranteed part of the payload. |
|
|
137
|
+
|
|
138
|
+
### Inputs vs. payload
|
|
139
|
+
|
|
140
|
+
These are two different layers:
|
|
141
|
+
|
|
142
|
+
- **Inputs are the arguments you hand the event** — the whole objects your code already has. Each becomes a keyword argument on the generated helper.
|
|
143
|
+
- `input :lead` → `lead:` is **required** at the call site.
|
|
144
|
+
- `optional_input :campaign` → `campaign:` **defaults to `nil`** and may be omitted.
|
|
145
|
+
|
|
146
|
+
- **Payload fields are the flat data the event carries**, described as a mapping *off* those inputs (`from:` = which input, `attr:` = which attribute on it).
|
|
147
|
+
- `required_payload` → the field is a **guaranteed** part of the payload.
|
|
148
|
+
- `optional_payload` → the field may be absent.
|
|
149
|
+
- The `required` flag is stored in the schema and is part of the event's **fingerprint**, so changing it changes the contract.
|
|
150
|
+
|
|
151
|
+
### Reserved names
|
|
152
|
+
|
|
153
|
+
Compilation rejects definitions that use names owned by the event envelope:
|
|
154
|
+
|
|
155
|
+
- **Payload field names** may not be any of:
|
|
156
|
+
`event_name`, `event_type`, `event_version`, `occurred_at`, `created_at`, `updated_at`, `published_at`, `metadata`, `idempotency_key`, `attempts`, `dead_lettered_at`, `aggregate_type`, `aggregate_id`, `aggregate_version`.
|
|
157
|
+
- **Input names** may not collide with the envelope keys:
|
|
158
|
+
`event_version`, `occurred_at`, `metadata`, `idempotency_key`, `aggregate_type`, `aggregate_id`, `aggregate_version`.
|
|
159
|
+
|
|
160
|
+
A payload field must also have a `from:` that references a declared input, and each event name must be snake_case.
|
|
161
|
+
|
|
162
|
+
### Inspecting the compiled schema
|
|
163
|
+
|
|
164
|
+
Every definition compiles to a `Schema` value object:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
schema = LeadCreated.schema
|
|
168
|
+
|
|
169
|
+
schema.event_name # => :lead_created
|
|
170
|
+
schema.required_inputs # => [:lead]
|
|
171
|
+
schema.fingerprint # => "…sha256 of the event's structure…"
|
|
172
|
+
schema.to_h # => plain data hash (JSON-safe)
|
|
173
|
+
schema.to_ruby # => a Ruby source string that rebuilds the Schema
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The fingerprint is a stable hash of the event's **structure** (name, type, inputs, payload fields) — incidental fields like `domain` don't change it, so a matching fingerprint means a matching contract.
|
|
177
|
+
|
|
178
|
+
## How it fits with `event_engine`
|
|
179
|
+
|
|
180
|
+
This gem defines and generates; `event_engine` runs. The seam between them is the **publisher port** (`EventEngine::Definition.publisher`):
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
this gem event_engine (host runtime)
|
|
184
|
+
──────────────────────────────── ──────────────────────────────
|
|
185
|
+
EventDefinition DSL registers as the publisher
|
|
186
|
+
│ compile ────────────────────►
|
|
187
|
+
▼ reads the raw inputs via the
|
|
188
|
+
generated helper ──publish(event)──► schema's from:/attr: mapping,
|
|
189
|
+
+ committed schema.json then dispatches, persists, brokers…
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
A domain pack depends only on `event_engine-event_definition` to declare its events and build its helper file. In an app that also has `event_engine` installed, `event_engine` provides a real publisher adapter and assigns it to `EventEngine::Definition.publisher`, so calling a generated helper hands the event to the full runtime. Nothing in this gem knows how events are dispatched — that decision lives entirely in `event_engine`.
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then:
|
|
197
|
+
|
|
198
|
+
- `rake test` — run the test suite
|
|
199
|
+
- `bin/console` — an interactive prompt with the gem loaded
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/docs/examples.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# EventEngine::Definition — examples
|
|
2
|
+
|
|
3
|
+
Each example follows the same three beats: the **data** you already have, the **event** you define, and the **call** you make. For the full DSL reference and how this plugs into `event_engine`, see the [README](../README.md).
|
|
4
|
+
|
|
5
|
+
The `SalesEvents` / `ProductEvents` modules below are the helpers produced by generating the pack — the `rake event_engine:definition:dump` build step in the README. These examples assume that step has already run, so they focus on defining the event and calling it.
|
|
6
|
+
|
|
7
|
+
## An event built from multiple inputs
|
|
8
|
+
|
|
9
|
+
You have an order and the customer who placed it — and maybe a coupon:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
order = Order.create!(total_cents: 4_200) # => #<Order id: 100, total_cents: 4200, …>
|
|
13
|
+
customer = order.customer # => #<Customer id: 7, email: "ada@example.com", …>
|
|
14
|
+
coupon = Coupon.find_by(code: "WELCOME") # optional — may be nil
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Define an event that captures a consistent slice of each input:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class OrderPlaced < EventEngine::EventDefinition
|
|
21
|
+
event_name :order_placed
|
|
22
|
+
event_type :domain
|
|
23
|
+
domain :sales
|
|
24
|
+
|
|
25
|
+
input :order
|
|
26
|
+
input :customer
|
|
27
|
+
optional_input :coupon
|
|
28
|
+
|
|
29
|
+
required_payload :order_id, from: :order, attr: :id
|
|
30
|
+
required_payload :total_cents, from: :order, attr: :total_cents
|
|
31
|
+
required_payload :customer_id, from: :customer, attr: :id
|
|
32
|
+
required_payload :customer_email, from: :customer, attr: :email
|
|
33
|
+
optional_payload :coupon_code, from: :coupon, attr: :code
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Emit it — pass each input; optional ones you can leave off:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
SalesEvents.order_placed(order: order, customer: customer, coupon: coupon)
|
|
41
|
+
# or, with no coupon:
|
|
42
|
+
SalesEvents.order_placed(order: order, customer: customer)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Captured:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
{
|
|
49
|
+
order_id: 100,
|
|
50
|
+
total_cents: 4200,
|
|
51
|
+
customer_id: 7,
|
|
52
|
+
customer_email: "ada@example.com",
|
|
53
|
+
coupon_code: "WELCOME"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## A lifecycle: one definition, an event per step
|
|
58
|
+
|
|
59
|
+
You have a long-running job:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
export = Export.create!(format: "csv") # => #<Export id: 9, format: "csv", …>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`LifecycleDefinition` stamps out one event per verb from a shared base, and `on` layers extra data onto a single step:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class ExportCsv < EventEngine::LifecycleDefinition
|
|
69
|
+
subject :export_csv
|
|
70
|
+
event_type :product
|
|
71
|
+
|
|
72
|
+
input :export
|
|
73
|
+
required_payload :format, from: :export, attr: :format
|
|
74
|
+
|
|
75
|
+
lifecycle :started, :completed, :failed
|
|
76
|
+
|
|
77
|
+
on :failed do
|
|
78
|
+
input :error
|
|
79
|
+
required_payload :error_class, from: :error, attr: :class
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
That gives you three events — `export_csv_started`, `export_csv_completed`, and `export_csv_failed` — each carrying `format`, and the failed one also carrying `error_class`.
|
|
85
|
+
|
|
86
|
+
Once the pack is generated, emit the step that happened:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
ProductEvents.export_csv_failed(export: export, error: some_error)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Captured:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
{ format: "csv", error_class: "Timeout::Error" }
|
|
96
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "event_engine/subject_registry"
|
|
2
|
+
|
|
3
|
+
module EventEngine
|
|
4
|
+
module Definition
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :definitions_path, :helper_path, :root_module
|
|
7
|
+
attr_writer :subject_registry
|
|
8
|
+
|
|
9
|
+
def subject_registry
|
|
10
|
+
@subject_registry ||= SubjectRegistry.new
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
module Definition
|
|
3
|
+
class PublisherNotConfigured < Error; end
|
|
4
|
+
|
|
5
|
+
class NullPublisher
|
|
6
|
+
def publish(_event_name, **_envelope)
|
|
7
|
+
raise PublisherNotConfigured,
|
|
8
|
+
"No EventEngine::Definition.publisher configured. Wire a publisher " \
|
|
9
|
+
"adapter (e.g. event_engine) before emitting events."
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "definition/version"
|
|
4
|
+
|
|
5
|
+
require "event_engine/subject_registry"
|
|
6
|
+
require "event_engine/event_definition"
|
|
7
|
+
require "event_engine/definition_loader"
|
|
8
|
+
require "event_engine/event_schema"
|
|
9
|
+
require "event_engine/schema_registry"
|
|
10
|
+
require "event_engine/lifecycle_definition"
|
|
11
|
+
require "event_engine/dsl_compiler"
|
|
12
|
+
require "event_engine/definition/configuration"
|
|
13
|
+
|
|
14
|
+
module EventEngine
|
|
15
|
+
module Definition
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_writer :publisher
|
|
20
|
+
|
|
21
|
+
def publisher
|
|
22
|
+
@publisher ||= NullPublisher.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset_publisher!
|
|
26
|
+
@publisher = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configuration
|
|
30
|
+
@configuration ||= Configuration.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def configure
|
|
34
|
+
yield(configuration)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset_configuration!
|
|
38
|
+
@configuration = nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
require "event_engine/definition/null_publisher"
|
|
45
|
+
require "event_engine/event_engine_helpers_writer"
|
|
46
|
+
require "event_engine/domain_pack_build"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "event_engine/event_definition"
|
|
2
|
+
require "event_engine/lifecycle_definition"
|
|
3
|
+
|
|
4
|
+
module EventEngine
|
|
5
|
+
module DefinitionLoader
|
|
6
|
+
def self.load!(path)
|
|
7
|
+
before_events = EventEngine::EventDefinition.subclasses
|
|
8
|
+
before_lifecycles = EventEngine::LifecycleDefinition.subclasses
|
|
9
|
+
require_ruby_files(path)
|
|
10
|
+
|
|
11
|
+
declared_events = EventEngine::EventDefinition.subclasses - before_events
|
|
12
|
+
new_lifecycles = EventEngine::LifecycleDefinition.subclasses - before_lifecycles
|
|
13
|
+
|
|
14
|
+
declared_events + new_lifecycles.flat_map(&:generated_events)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.require_ruby_files(path)
|
|
18
|
+
Dir.glob(File.join(path, "**", "*.rb")).sort.each do |file|
|
|
19
|
+
require File.expand_path(file)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "event_engine/dsl_compiler"
|
|
5
|
+
require "event_engine/subject_registry"
|
|
6
|
+
require "event_engine/event_engine_helpers_writer"
|
|
7
|
+
|
|
8
|
+
module EventEngine
|
|
9
|
+
class DomainPackBuild
|
|
10
|
+
PORT_EMIT = "EventEngine::Definition.publisher.publish"
|
|
11
|
+
|
|
12
|
+
SCHEMA_FILENAME = "schema.json"
|
|
13
|
+
|
|
14
|
+
HEADER = <<~RUBY.freeze
|
|
15
|
+
# This file is authoritative in production.
|
|
16
|
+
# It is generated from this pack's EventDefinitions.
|
|
17
|
+
# Do not edit manually.
|
|
18
|
+
|
|
19
|
+
RUBY
|
|
20
|
+
|
|
21
|
+
def self.run(definitions, helper_path:, root_module:, header: HEADER,
|
|
22
|
+
subject_registry: SubjectRegistry.new)
|
|
23
|
+
new(
|
|
24
|
+
definitions,
|
|
25
|
+
helper_path: helper_path,
|
|
26
|
+
root_module: root_module,
|
|
27
|
+
header: header,
|
|
28
|
+
subject_registry: subject_registry
|
|
29
|
+
).run
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(definitions, helper_path:, root_module:, header:, subject_registry:)
|
|
33
|
+
@definitions = definitions
|
|
34
|
+
@helper_path = helper_path
|
|
35
|
+
@root_module = root_module
|
|
36
|
+
@header = header
|
|
37
|
+
@subject_registry = subject_registry
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
event_schema = compile
|
|
42
|
+
write_helper(event_schema)
|
|
43
|
+
write_schema_json(event_schema)
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def schema_path
|
|
48
|
+
File.join(File.dirname(@helper_path), SCHEMA_FILENAME)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def compile
|
|
54
|
+
DslCompiler.compile(@definitions, subject_registry: @subject_registry).event_schema
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write_schema_json(event_schema)
|
|
58
|
+
File.write(schema_path, JSON.pretty_generate(schemas(event_schema)))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def schemas(event_schema)
|
|
62
|
+
event_schema.schemas_by_event.values.flat_map(&:values).map(&:to_h)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write_helper(event_schema)
|
|
66
|
+
EventEngineHelpersWriter.write(
|
|
67
|
+
@helper_path,
|
|
68
|
+
event_schema,
|
|
69
|
+
root_module: @root_module,
|
|
70
|
+
emit: PORT_EMIT,
|
|
71
|
+
header: @header,
|
|
72
|
+
group_by_domain: false,
|
|
73
|
+
schema_filename: SCHEMA_FILENAME
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class DslCompiler
|
|
3
|
+
class InvalidEventNameError < StandardError; end
|
|
4
|
+
class ReservedInputNameError < StandardError; end
|
|
5
|
+
|
|
6
|
+
SNAKE_CASE = /\A[a-z][a-z0-9_]*\z/
|
|
7
|
+
|
|
8
|
+
RESERVED_INPUT_NAMES = %i[
|
|
9
|
+
event_version
|
|
10
|
+
occurred_at
|
|
11
|
+
metadata
|
|
12
|
+
idempotency_key
|
|
13
|
+
aggregate_type
|
|
14
|
+
aggregate_id
|
|
15
|
+
aggregate_version
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.compile(definitions, subject_registry: SubjectRegistry.new)
|
|
19
|
+
registry = SchemaRegistry.new
|
|
20
|
+
subject_violations = []
|
|
21
|
+
name_violations = []
|
|
22
|
+
reserved_violations = []
|
|
23
|
+
|
|
24
|
+
Array(definitions).each do |definition|
|
|
25
|
+
schema = definition.schema
|
|
26
|
+
record_subject_violation(schema, subject_violations, subject_registry)
|
|
27
|
+
record_name_violation(schema, name_violations)
|
|
28
|
+
record_reserved_input_violation(schema, reserved_violations)
|
|
29
|
+
registry.register(schema)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
raise_invalid_event_names(name_violations)
|
|
33
|
+
raise_reserved_input_names(reserved_violations)
|
|
34
|
+
raise_unknown_subjects(subject_violations)
|
|
35
|
+
|
|
36
|
+
registry
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.record_name_violation(schema, violations)
|
|
40
|
+
return if schema.event_name.to_s.match?(SNAKE_CASE)
|
|
41
|
+
|
|
42
|
+
violations << schema.event_name.inspect
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.raise_invalid_event_names(violations)
|
|
46
|
+
return if violations.empty?
|
|
47
|
+
|
|
48
|
+
raise InvalidEventNameError, "event names must be snake_case: #{violations.join(", ")}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.record_reserved_input_violation(schema, violations)
|
|
52
|
+
inputs = schema.required_inputs + schema.optional_inputs
|
|
53
|
+
collisions = inputs & RESERVED_INPUT_NAMES
|
|
54
|
+
return if collisions.empty?
|
|
55
|
+
|
|
56
|
+
violations << "#{schema.event_name}: #{collisions.join(", ")}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.raise_reserved_input_names(violations)
|
|
60
|
+
return if violations.empty?
|
|
61
|
+
|
|
62
|
+
raise ReservedInputNameError,
|
|
63
|
+
"input names collide with reserved envelope keys: #{violations.join("; ")}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.record_subject_violation(schema, violations, subject_registry)
|
|
67
|
+
return if schema.subject.nil?
|
|
68
|
+
return if subject_registry.registered?(schema.subject)
|
|
69
|
+
|
|
70
|
+
violations << "#{schema.event_name}: unknown subject #{schema.subject.inspect}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.raise_unknown_subjects(violations)
|
|
74
|
+
return if violations.empty?
|
|
75
|
+
|
|
76
|
+
raise SubjectRegistry::UnknownSubjectError, violations.join(", ")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module EventEngine
|
|
2
|
+
class EventDefinition
|
|
3
|
+
module Inputs
|
|
4
|
+
def self.included(base)
|
|
5
|
+
base.extend ClassMethods
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module ClassMethods
|
|
9
|
+
def input(name)
|
|
10
|
+
name = name.to_sym
|
|
11
|
+
if inputs.key?(name)
|
|
12
|
+
raise ArgumentError, "duplicate input: #{name}"
|
|
13
|
+
end
|
|
14
|
+
inputs[name] = :required
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def optional_input(name)
|
|
18
|
+
name = name.to_sym
|
|
19
|
+
if inputs.key?(name)
|
|
20
|
+
raise ArgumentError, "duplicate input: #{name}"
|
|
21
|
+
end
|
|
22
|
+
inputs[name] = :optional
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def inputs
|
|
26
|
+
@inputs ||= {}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|