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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 47b43f18ac9a1a5d3afc2e169344b1750499409496e2035bcc25e23ed5ec7c13
|
|
4
|
+
data.tar.gz: 31ad25b00b80dd1f53a3005f7a6a7f87c1c0fd4328d3fa638467192622251e6b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 42338a458274a692268e1c95bf003f85a8053f3903b3383813c3780f70d13d23a3035e1b9e4435fdf1542344ddf4442aa7210d4be5cfe8cd9deb7a8e3ced8cef
|
|
7
|
+
data.tar.gz: 4621cf3779124f63c686e23ce3414bf67d4d7683b338153da3e1a24a7bf10776e2f82c085cff57a89e88b7e9e5c9e5bb228257ad0642d22747c31adabd4f327a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be 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
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-06-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`:manual` delivery adapter** — Delivery becomes a no-op; the outbox is drained
|
|
15
|
+
only by an explicit `OutboxPublisher` call (a scheduled job, rake task, or
|
|
16
|
+
operator action). Lets apps control exactly when events are published.
|
|
17
|
+
- **Per-event levels** — Each event can declare an `event_level` (1–5) that decides how it is delivered, without changing producer code.
|
|
18
|
+
- Levels 1–3 invoke in-process subscribers with increasing durability: level 1 synchronously, level 2 via a background job, level 3 durably when the outbox is drained
|
|
19
|
+
- Level 4 publishes to a broker transport; level 5 (event sourcing) is unsupported and raises
|
|
20
|
+
- `Subscriber` base class with `subscribes_to` for self-registering event handlers, backed by `SubscriberRegistry`
|
|
21
|
+
- `OutboxRouter` routes drained outbox events to subscribers or the transport by level
|
|
22
|
+
- `DispatchSubscribersJob` runs level-2 subscribers in the background
|
|
23
|
+
- Level-4 transport safety: warns at boot (`DefinitionTransportCheck`) and raises `OutboxRouter::MissingTransportError` at publish time when no transport is configured; `NullTransport` counts as unconfigured
|
|
24
|
+
- Events with no `event_level` keep the existing outbox-and-transport behavior
|
|
25
|
+
|
|
26
|
+
- **Cloud Reporter** — Optional module that sends event metadata to EventEngine Cloud for observability. Activated by setting `cloud_api_key` in configuration. Zero impact when unconfigured.
|
|
27
|
+
- `Cloud::Serializer` — Converts event notifications to metadata-only entries (never sends payloads)
|
|
28
|
+
- `Cloud::Batch` — Thread-safe entry accumulator with configurable max size
|
|
29
|
+
- `Cloud::ApiClient` — Net::HTTP client with 5s timeout, fire-and-forget error handling
|
|
30
|
+
- `Cloud::Subscribers` — Hooks into existing `ActiveSupport::Notifications` for event tracking
|
|
31
|
+
- `Cloud::Reporter` — Singleton managing the collect/batch/flush lifecycle
|
|
32
|
+
- Engine boot integration — Auto-starts reporter when `cloud_api_key` is present
|
|
33
|
+
- Cloud configuration options: `cloud_api_key`, `cloud_endpoint`, `cloud_batch_size`, `cloud_flush_interval`, `cloud_environment`, `cloud_app_name`
|
|
34
|
+
- Boot logging — Reporter logs start/stop messages for operator visibility
|
|
35
|
+
- `NullTransport` as default transport (logs warnings for discarded events instead of nil errors)
|
|
36
|
+
- Event definition DSL (`event_name`, `event_type`, `input`, `required_payload`, `optional_payload`)
|
|
37
|
+
- Schema compilation via `DslCompiler` and `EventSchemaDumper`
|
|
38
|
+
- Schema versioning with SHA256 fingerprint-based version detection
|
|
39
|
+
- Schema drift detection via `SchemaDriftGuard` and rake tasks
|
|
40
|
+
- `SchemaRegistry` for in-memory schema lookup at runtime
|
|
41
|
+
- Outbox pattern: `OutboxWriter`, `OutboxPublisher` with configurable batch size and max attempts
|
|
42
|
+
- Dead letter support for failed publish attempts
|
|
43
|
+
- `EventEmitter` and `EventBuilder` for event construction
|
|
44
|
+
- Dynamic helper method installation (`EventEngine.cow_fed(...)`)
|
|
45
|
+
- Pluggable transport interface with `InMemoryTransport` and `Kafka` adapters
|
|
46
|
+
- `DefinitionLoader` for auto-loading event definitions
|
|
47
|
+
- Inline and ActiveJob delivery adapters
|
|
48
|
+
- `occurred_at` and `metadata` support on outbox events
|
|
49
|
+
- Rails engine generator for installation
|
|
50
|
+
- Rake tasks: `event_engine:schema`, `event_engine:schema:dump`
|
|
51
|
+
|
|
52
|
+
[Unreleased]: https://github.com/DYB-Development/event_engine/compare/v0.1.0...HEAD
|
|
53
|
+
[0.1.0]: https://github.com/DYB-Development/event_engine/releases/tag/v0.1.0
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright tylercschneider
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# EventEngine
|
|
2
|
+
|
|
3
|
+
EventEngine is the schema-first **core** of an event pipeline for Rails:
|
|
4
|
+
|
|
5
|
+
- **Define** events with a small Ruby DSL.
|
|
6
|
+
- **Compile** them into a canonical, committed schema file (`db/event_schema.rb`).
|
|
7
|
+
- **Emit** them through generated helpers (`EventEngine.cow_fed(...)`) that build a
|
|
8
|
+
validated `Event` and **dispatch** it to registered handlers by *level*.
|
|
9
|
+
|
|
10
|
+
The core gem does **not** deliver events anywhere on its own. It builds the event
|
|
11
|
+
and hands it to whatever handlers are registered. Durable delivery and durable
|
|
12
|
+
storage live in companion gems that register themselves as handlers:
|
|
13
|
+
|
|
14
|
+
| Gem | Responsibility | Add it when |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| **`event_engine`** (this gem) | Define, compile, emit, dispatch | Always — it's the core |
|
|
17
|
+
| [`event_engine-delivery`](https://github.com/DYB-Development/event_engine-delivery) | Transactional outbox, retries, dead-letters, transports (Kafka), dashboard, cloud reporter | You need to deliver events reliably (in-process or to a broker) |
|
|
18
|
+
| [`event_engine-store`](https://github.com/DYB-Development/event_engine-store) | Durable, append-only event log + event-sourcing replay & projections | You need a permanent record of every event / event sourcing |
|
|
19
|
+
|
|
20
|
+
You can run the core gem **by itself** with your own handlers — see
|
|
21
|
+
[The handler extension point](#the-handler-extension-point).
|
|
22
|
+
|
|
23
|
+
> This README documents the core gem **only**. Outbox, transports, dead-letters,
|
|
24
|
+
> the dashboard, and the cloud reporter are documented in `event_engine-delivery`.
|
|
25
|
+
> The event log, replay, and projections are documented in `event_engine-store`.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Table of contents
|
|
30
|
+
|
|
31
|
+
- [Quick start](#quick-start)
|
|
32
|
+
- [Mental model](#mental-model)
|
|
33
|
+
- [Defining events](#defining-events)
|
|
34
|
+
- [The DSL reference](#the-dsl-reference)
|
|
35
|
+
- [How payload fields are extracted](#how-payload-fields-are-extracted)
|
|
36
|
+
- [There is no `type:` casting](#there-is-no-type-casting)
|
|
37
|
+
- [Lifecycle event families](#lifecycle-event-families)
|
|
38
|
+
- [Generating the schema](#generating-the-schema)
|
|
39
|
+
- [How versioning works](#how-versioning-works)
|
|
40
|
+
- [Drift checking in CI](#drift-checking-in-ci)
|
|
41
|
+
- [Emitting events](#emitting-events)
|
|
42
|
+
- [Subscribers](#subscribers)
|
|
43
|
+
- [Event levels](#event-levels)
|
|
44
|
+
- [The handler extension point](#the-handler-extension-point)
|
|
45
|
+
- [Configuration](#configuration)
|
|
46
|
+
- [Rake tasks](#rake-tasks)
|
|
47
|
+
- [Installation generator](#installation-generator)
|
|
48
|
+
- [For AI assistants](#for-ai-assistants)
|
|
49
|
+
- [Contributing](#contributing)
|
|
50
|
+
- [License](#license)
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# Gemfile
|
|
58
|
+
gem "event_engine"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
bundle install
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
1. **Define an event** in `app/event_definitions/cow_fed.rb` (see [Defining events](#defining-events)).
|
|
66
|
+
2. **Dump the schema**:
|
|
67
|
+
```bash
|
|
68
|
+
bin/rails event_engine:schema:dump
|
|
69
|
+
```
|
|
70
|
+
3. **Commit `db/event_schema.rb`** — it is authoritative at runtime.
|
|
71
|
+
4. **Register at least one handler** so emitted events do something. Either add a
|
|
72
|
+
companion gem (`event_engine-delivery` / `event_engine-store`) or write your own
|
|
73
|
+
(see [The handler extension point](#the-handler-extension-point)).
|
|
74
|
+
5. **Emit** from your app code:
|
|
75
|
+
```ruby
|
|
76
|
+
EventEngine.cow_fed(cow: cow)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
> With **no** handler registered, the core gem builds and dispatches the event but
|
|
80
|
+
> nothing observes it. That's expected — core is the dispatch layer; handlers are
|
|
81
|
+
> what *do* something with events.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Mental model
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
EventDefinition (Ruby DSL)
|
|
89
|
+
│ bin/rails event_engine:schema:dump
|
|
90
|
+
▼
|
|
91
|
+
db/event_schema.rb ◄── authoritative at runtime; commit it
|
|
92
|
+
│ Rails boot (Engine initializer)
|
|
93
|
+
▼
|
|
94
|
+
SchemaRegistry ──► installs EventEngine.<event_name> helpers
|
|
95
|
+
│ you call EventEngine.cow_fed(cow: cow)
|
|
96
|
+
▼
|
|
97
|
+
EventBuilder builds a validated EventEngine::Event
|
|
98
|
+
│ EventEngine.dispatch(event)
|
|
99
|
+
▼
|
|
100
|
+
HandlerRegistry ──► every registered handler whose `levels:` match event_level
|
|
101
|
+
(event_engine-delivery, event_engine-store, or your own)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Two things are worth internalizing:
|
|
105
|
+
|
|
106
|
+
1. **The committed schema file — not your definition classes — is the source of
|
|
107
|
+
truth at runtime.** Definition classes are read only at *dump* time. In
|
|
108
|
+
production a missing `db/event_schema.rb` raises at boot.
|
|
109
|
+
2. **Emitting and handling are decoupled.** `EventEngine.dispatch` just fans the
|
|
110
|
+
event out to handlers by level. The core gem ships *no* handlers.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Defining events
|
|
115
|
+
|
|
116
|
+
Put definitions where Rails eager-loads them — conventionally
|
|
117
|
+
`app/event_definitions/`. Subclass `EventEngine::EventDefinition`:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# app/event_definitions/cow_fed.rb
|
|
121
|
+
class CowFed < EventEngine::EventDefinition
|
|
122
|
+
input :cow # required input to the emit helper
|
|
123
|
+
optional_input :farmer # optional input
|
|
124
|
+
|
|
125
|
+
event_name :cow_fed # the event's identity → EventEngine.cow_fed
|
|
126
|
+
event_type :domain # free-form classification (:domain, :integration, …)
|
|
127
|
+
event_level 3 # how it's dispatched (optional; see Event levels)
|
|
128
|
+
|
|
129
|
+
required_payload :weight, from: :cow, attr: :weight
|
|
130
|
+
optional_payload :farmer_name, from: :farmer, attr: :name
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### The DSL reference
|
|
135
|
+
|
|
136
|
+
All methods below are **class-level** macros on an `EventDefinition` subclass.
|
|
137
|
+
|
|
138
|
+
| Macro | Signature | What it does |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `event_name` | `event_name(:symbol)` | The event's identity. Becomes the `EventEngine.<name>` helper. **Required.** |
|
|
141
|
+
| `event_type` | `event_type(:symbol)` | Free-form classification, e.g. `:domain`, `:integration`, `:system`. **Required.** |
|
|
142
|
+
| `event_level` | `event_level(Integer)` | Dispatch level `1..4` (see [Event levels](#event-levels)). Optional. |
|
|
143
|
+
| `input` | `input(:name)` | Declares a **required** input keyword the emit helper accepts. Duplicate names raise `ArgumentError`. |
|
|
144
|
+
| `optional_input` | `optional_input(:name)` | Declares an **optional** input keyword. |
|
|
145
|
+
| `required_payload` | `required_payload(name, from:, attr: nil)` | A payload field that must be present. `from:` names the input it reads; `attr:` is the method called on that input. |
|
|
146
|
+
| `optional_payload` | `optional_payload(name, from:, attr: nil)` | Same, but **omitted from the payload** when the source input is `nil`. |
|
|
147
|
+
|
|
148
|
+
A handful of payload field names are **reserved** (they collide with event
|
|
149
|
+
envelope/outbox columns) and rejected at dump time:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
event_name event_type event_version occurred_at created_at updated_at
|
|
153
|
+
published_at metadata idempotency_key attempts dead_lettered_at
|
|
154
|
+
aggregate_type aggregate_id aggregate_version
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### How payload fields are extracted
|
|
158
|
+
|
|
159
|
+
When you emit, `EventBuilder` walks each declared payload field and pulls a value
|
|
160
|
+
out of the inputs you passed:
|
|
161
|
+
|
|
162
|
+
- `from:` selects **which input** to read.
|
|
163
|
+
- `attr:` is the **method called on that input**. If `attr:` is `nil`, the input
|
|
164
|
+
itself is used (passthrough).
|
|
165
|
+
- For an `optional_payload`, if the `from:` input is `nil` the field is simply left
|
|
166
|
+
out of the payload (no key, not a `nil` value).
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
required_payload :weight, from: :cow, attr: :weight
|
|
170
|
+
# → payload[:weight] = cow.weight
|
|
171
|
+
|
|
172
|
+
optional_payload :raw_cow, from: :cow
|
|
173
|
+
# → payload[:raw_cow] = cow (passthrough; attr omitted)
|
|
174
|
+
|
|
175
|
+
optional_payload :farmer_name, from: :farmer, attr: :name
|
|
176
|
+
# → only present if `farmer:` was passed and non-nil
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The resulting `event.payload` is a **symbol-keyed Hash**.
|
|
180
|
+
|
|
181
|
+
### There is no `type:` casting
|
|
182
|
+
|
|
183
|
+
The complete payload DSL is `required_payload` / `optional_payload` with `from:` and
|
|
184
|
+
`attr:` only — there is no `type:` option and no type casting, and no
|
|
185
|
+
`entity_class` / `entity_id` / `entity_version` macros.
|
|
186
|
+
|
|
187
|
+
Whatever value `attr:` returns is stored as-is. If you need a value coerced to a
|
|
188
|
+
specific type, do it on the source object's method (e.g. have `cow.weight` return a
|
|
189
|
+
`Float`) or expose a purpose-built reader and point `attr:` at it.
|
|
190
|
+
|
|
191
|
+
### Lifecycle event families
|
|
192
|
+
|
|
193
|
+
Related events that describe one capability — `export_csv_started`,
|
|
194
|
+
`export_csv_completed`, `export_csv_failed` — share inputs and payload fields. Writing
|
|
195
|
+
them as three independent `EventDefinition`s lets their names and shared fields drift.
|
|
196
|
+
Subclass `EventEngine::LifecycleDefinition` to stamp the whole family from one template:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
# app/event_definitions/export_csv_events.rb
|
|
200
|
+
class ExportCsvEvents < EventEngine::LifecycleDefinition
|
|
201
|
+
subject :export_csv # validated against the SubjectRegistry
|
|
202
|
+
event_type :product
|
|
203
|
+
|
|
204
|
+
input :export
|
|
205
|
+
required_payload :format, from: :export, attr: :format
|
|
206
|
+
|
|
207
|
+
lifecycle :started, :completed, :failed # → export_csv_started / _completed / _failed
|
|
208
|
+
|
|
209
|
+
on :failed do
|
|
210
|
+
input :error
|
|
211
|
+
required_payload :error_class, from: :error, attr: :class
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
This generates three real `EventDefinition`s named `subject_verb` (flat snake_case, so
|
|
217
|
+
each yields a working `EventEngine.export_csv_completed(...)` helper). Shared declarations
|
|
218
|
+
apply to every verb; an `on :verb` block layers additional inputs/payloads onto that verb
|
|
219
|
+
only. The generated events behave exactly like hand-written ones everywhere — schema dump,
|
|
220
|
+
registry, helpers, metadata enricher, catalog, and compatibility checks all apply unchanged.
|
|
221
|
+
|
|
222
|
+
| Macro | Signature | What it does |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| `subject` | `subject(:symbol)` | The family's subject, carried onto every generated event. Must be registered. |
|
|
225
|
+
| `event_type` | `event_type(:symbol)` | Shared across every verb. |
|
|
226
|
+
| `process_type` | `process_type(:symbol)` | Shared across every verb. Optional. |
|
|
227
|
+
| `lifecycle` | `lifecycle(*verbs)` | Generates one event per verb, named `:"#{subject}_#{verb}"`. |
|
|
228
|
+
| `on` | `on(:verb) { … }` | Layers verb-specific `input` / `required_payload` / `optional_payload` onto that verb only. Add-only. |
|
|
229
|
+
|
|
230
|
+
Shared `input` / `optional_input` / `required_payload` / `optional_payload` are declared
|
|
231
|
+
exactly as on a plain `EventDefinition` and apply to every verb.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Generating the schema
|
|
236
|
+
|
|
237
|
+
After adding or changing definitions:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
bin/rails event_engine:schema:dump # compile definitions → db/event_schema.rb
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
This compiles every `EventDefinition` subclass, merges with the existing committed
|
|
244
|
+
file, and rewrites `db/event_schema.rb`. **Commit the result.** The generated file
|
|
245
|
+
looks like:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
# This file is authoritative in production.
|
|
249
|
+
# It is generated from EventDefinitions via:
|
|
250
|
+
#
|
|
251
|
+
# bin/rails event_engine:schema:dump
|
|
252
|
+
#
|
|
253
|
+
# Do not edit manually.
|
|
254
|
+
|
|
255
|
+
EventEngine::EventSchema.define do |schema|
|
|
256
|
+
schema.register(
|
|
257
|
+
EventEngine::EventDefinition::Schema.new(
|
|
258
|
+
event_name: :cow_fed,
|
|
259
|
+
event_version: 1,
|
|
260
|
+
event_type: :domain,
|
|
261
|
+
event_level: 3,
|
|
262
|
+
required_inputs: [:cow],
|
|
263
|
+
optional_inputs: [:farmer],
|
|
264
|
+
payload_fields: [
|
|
265
|
+
{ name: :weight, required: true, from: :cow, attr: :weight }
|
|
266
|
+
]
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### How versioning works
|
|
273
|
+
|
|
274
|
+
The dumper is **append-only and additive** — it never edits an existing version in
|
|
275
|
+
place:
|
|
276
|
+
|
|
277
|
+
- A brand-new event is written as **version 1**.
|
|
278
|
+
- When you change an existing event, the merger compares a **SHA256 fingerprint**
|
|
279
|
+
of its `event_name`, `event_type`, inputs, and payload fields against the latest
|
|
280
|
+
version in the file. If they differ, it writes a **new version** (`N + 1`); if
|
|
281
|
+
they match, nothing changes.
|
|
282
|
+
- Version numbers are **monotonic** — reverting a change to a previous shape still
|
|
283
|
+
produces a *new* higher version, never reuses an old number.
|
|
284
|
+
|
|
285
|
+
> **`event_level` is intentionally excluded from the fingerprint.** Changing only an
|
|
286
|
+
> event's level does **not** bump its version — level is treated as operational
|
|
287
|
+
> routing metadata, not part of the event contract. This is what lets you "promote"
|
|
288
|
+
> an event up the level ladder as a one-line change with no schema churn.
|
|
289
|
+
|
|
290
|
+
### Drift checking in CI
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
bin/rails event_engine:schema:verify
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
This fails if your definitions have drifted from the committed `db/event_schema.rb`
|
|
297
|
+
(i.e. someone changed a definition but forgot to dump), printing a readable diff of
|
|
298
|
+
what changed. Add it to CI to keep the file honest. The older `event_engine:schema`
|
|
299
|
+
and `event_engine:schema_check` tasks perform the same check without the diff.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Emitting events
|
|
304
|
+
|
|
305
|
+
At boot the engine loads `db/event_schema.rb` and installs a singleton helper on
|
|
306
|
+
`EventEngine` for each event. Pass declared inputs by keyword, plus optional
|
|
307
|
+
emit-time envelope fields:
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
EventEngine.cow_fed(
|
|
311
|
+
cow: cow, # declared inputs, by name
|
|
312
|
+
farmer: farmer,
|
|
313
|
+
|
|
314
|
+
occurred_at: Time.current, # optional; defaults to Time.current
|
|
315
|
+
metadata: { request_id: "abc" }, # optional contextual hash
|
|
316
|
+
idempotency_key: "cow-#{cow.id}-#{Date.current}", # optional; defaults to a UUID
|
|
317
|
+
aggregate_type: "Cow", # optional aggregate tracking
|
|
318
|
+
aggregate_id: cow.id,
|
|
319
|
+
aggregate_version: 1
|
|
320
|
+
)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
- Missing a required input, or passing an unknown input, raises `ArgumentError`.
|
|
324
|
+
- `event_version:` may be passed to pin a specific schema version (defaults to latest).
|
|
325
|
+
- The return value is **whatever the handlers return** — there's no canonical return
|
|
326
|
+
in core. (`event_engine-delivery`, for example, returns the persisted outbox record
|
|
327
|
+
for levels 3+.)
|
|
328
|
+
|
|
329
|
+
The built `EventEngine::Event` exposes: `event_name`, `event_type`, `event_version`,
|
|
330
|
+
`event_level`, `payload` (symbol-keyed), `metadata`, `occurred_at`,
|
|
331
|
+
`idempotency_key`, `aggregate_type`, `aggregate_id`, `aggregate_version`.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Subscribers
|
|
336
|
+
|
|
337
|
+
A **subscriber** reacts to an event in-process. Subclass `EventEngine::Subscriber`,
|
|
338
|
+
declare what it handles, and implement `handle`:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
# app/subscribers/send_welcome_email.rb
|
|
342
|
+
class SendWelcomeEmail < EventEngine::Subscriber
|
|
343
|
+
subscribes_to :user_registered
|
|
344
|
+
|
|
345
|
+
def handle(event)
|
|
346
|
+
# event.payload is symbol-keyed
|
|
347
|
+
UserMailer.welcome(event.payload[:user_id]).deliver_later
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
- `subscribes_to(:event_name)` registers the subscriber at load time.
|
|
353
|
+
- `handle(event)` is required; the base raises `NotImplementedError` otherwise.
|
|
354
|
+
|
|
355
|
+
> **Who actually calls subscribers?** The core gem only *registers* subscribers in
|
|
356
|
+
> `EventEngine::SubscriberRegistry` — it does not invoke them. Invocation is done by
|
|
357
|
+
> a handler. `event_engine-delivery` invokes subscribers for levels 1–3 (see its
|
|
358
|
+
> docs). If you run core standalone, your own handler decides when/whether to call
|
|
359
|
+
> `EventEngine::SubscriberRegistry.subscribers_for(event.event_name)`.
|
|
360
|
+
|
|
361
|
+
Keep subscribers **idempotent** — at levels 3+ they may be retried.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Event levels
|
|
366
|
+
|
|
367
|
+
`event_level` is a hint that tells the *delivery* layer how hard to work to get an
|
|
368
|
+
event where it's going. **Your producer code never changes when you move an event up
|
|
369
|
+
a level — it's a one-line edit to the definition.**
|
|
370
|
+
|
|
371
|
+
| Level | Durable? | Where it goes | Adopt when | Watch out for |
|
|
372
|
+
|---|---|---|---|---|
|
|
373
|
+
| **1 sync** | no | in-app subscribers, synchronously in the caller's stack | a cheap in-process reaction that must happen now | a slow/failing subscriber blocks the caller; nothing persists, so it's lost on a crash |
|
|
374
|
+
| **2 job** | no | in-app subscribers, via a background job | the reaction can be deferred | still not durable; needs an ActiveJob backend; failures don't surface to the caller |
|
|
375
|
+
| **3 outbox** | **yes** | in-app subscribers, when the outbox drains | the reaction must survive a crash and be atomic with your DB write | more moving parts; delivery is eventual |
|
|
376
|
+
| **4 outbox + broker** | **yes** | **outside the app**, to a transport (Kafka, …) | an independent service consumes it on its own cycle | it's a cross-service contract — schema/version discipline matters; needs a real transport |
|
|
377
|
+
|
|
378
|
+
Guiding principle: **adopt the lowest level that solves your real problem; move up
|
|
379
|
+
only when the problem demands it.** Signals to move up:
|
|
380
|
+
|
|
381
|
+
- A level-1 subscriber is slow / on the request hot path → **1 → 2**.
|
|
382
|
+
- Work is lost across crashes/restarts/deploys → **2 → 3**.
|
|
383
|
+
- An independent service must consume the event → **3 → 4**.
|
|
384
|
+
|
|
385
|
+
> **The level table describes behavior implemented by `event_engine-delivery`.** The
|
|
386
|
+
> core gem only stamps `event_level` onto the event and dispatches it. Levels 1–4
|
|
387
|
+
> *mean* something only once a handler that interprets them is registered. Level 5
|
|
388
|
+
> (event sourcing) is reserved but unsupported by the delivery layer.
|
|
389
|
+
|
|
390
|
+
> **Caveat:** if you omit `event_level`, the event's level is `nil`. Handlers decide
|
|
391
|
+
> how to treat `nil` — `event_engine-delivery`, for instance, routes `nil` through
|
|
392
|
+
> its outbox path (the `else` branch). Set a level explicitly to be unambiguous.
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## The handler extension point
|
|
397
|
+
|
|
398
|
+
This is the seam every companion gem (and you) plug into. A **handler** is any
|
|
399
|
+
object that responds to `call(event)`. Register it with the levels it cares about:
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
EventEngine.register_handler(handler, levels: :all) # every event
|
|
403
|
+
EventEngine.register_handler(handler, levels: 1..4) # a Range
|
|
404
|
+
EventEngine.register_handler(handler, levels: [1, 3]) # an explicit list
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
On `EventEngine.dispatch(event)`, every handler whose `levels:` include
|
|
408
|
+
`event.event_level` (or `:all`) gets `call(event)`, in registration order.
|
|
409
|
+
|
|
410
|
+
A minimal standalone handler — no companion gem required:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
# config/initializers/event_engine.rb
|
|
414
|
+
class LogEverythingHandler
|
|
415
|
+
def call(event)
|
|
416
|
+
Rails.logger.info("[event] #{event.event_name} v#{event.event_version} #{event.payload.inspect}")
|
|
417
|
+
event
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
Rails.application.config.after_initialize do
|
|
422
|
+
EventEngine.register_handler(LogEverythingHandler.new, levels: :all)
|
|
423
|
+
end
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
This is exactly how the companion gems hook in:
|
|
427
|
+
|
|
428
|
+
- **`event_engine-delivery`** registers a handler at `levels: :all` that routes by
|
|
429
|
+
level (sync subscribers / background job / outbox / broker).
|
|
430
|
+
- **`event_engine-store`** registers two handlers at `levels: :all` (a recorder and a
|
|
431
|
+
projection dispatcher).
|
|
432
|
+
|
|
433
|
+
Other primitives on the `EventEngine` module:
|
|
434
|
+
|
|
435
|
+
- `EventEngine.dispatch(event)` — fan an `Event` out to handlers (helpers call this).
|
|
436
|
+
- `EventEngine.reset_handlers!` — clear all handlers (useful in tests, or to fully
|
|
437
|
+
take over routing).
|
|
438
|
+
|
|
439
|
+
> Handlers run **in-process, in order, synchronously** within `dispatch`. If a
|
|
440
|
+
> handler raises, later handlers don't run and the exception propagates to the
|
|
441
|
+
> caller. Order matters: register `event_engine-store` before/after `delivery`
|
|
442
|
+
> deliberately if both are present.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## Configuration
|
|
447
|
+
|
|
448
|
+
The **core** gem's configuration is intentionally tiny — just a logger:
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
# config/initializers/event_engine.rb
|
|
452
|
+
EventEngine.configure do |config|
|
|
453
|
+
config.logger = Rails.logger # the only core option
|
|
454
|
+
end
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
| Option | Default | Purpose |
|
|
458
|
+
|---|---|---|
|
|
459
|
+
| `logger` | `Rails.logger` (or `Logger.new($stdout)` outside Rails) | Where core logs |
|
|
460
|
+
|
|
461
|
+
> Delivery options (`delivery_adapter`, `transport`, `batch_size`, …) belong to
|
|
462
|
+
> `event_engine-delivery` and are set via `EventEngine::Delivery.configure` — see that
|
|
463
|
+
> gem's README.
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## Rake tasks
|
|
468
|
+
|
|
469
|
+
| Task | Purpose |
|
|
470
|
+
|---|---|
|
|
471
|
+
| `event_engine:schema:dump` | Compile definitions → `db/event_schema.rb` (commit it) |
|
|
472
|
+
| `event_engine:schema:verify` | Fail with a readable diff if definitions have drifted (use in CI) |
|
|
473
|
+
| `event_engine:schema` | Same drift check, no diff |
|
|
474
|
+
| `event_engine:schema_check` | Same drift check, no diff (alternate name) |
|
|
475
|
+
|
|
476
|
+
(`event_engine-delivery` adds `dead_letters:*` and `outbox:cleanup` tasks.)
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## Installation generator
|
|
481
|
+
|
|
482
|
+
```bash
|
|
483
|
+
bin/rails g event_engine:install
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
It creates `config/initializers/event_engine.rb`, a stub `db/event_schema.rb`, and
|
|
487
|
+
installs Claude Code subagent files under `.claude/agents/` (see
|
|
488
|
+
[For AI assistants](#for-ai-assistants)).
|
|
489
|
+
|
|
490
|
+
The core gem itself ships no migrations. If you need the outbox or the event log,
|
|
491
|
+
install the companion gem you need and run its migrations directly (see
|
|
492
|
+
`event_engine-delivery` / `event_engine-store`).
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
## For AI assistants
|
|
497
|
+
|
|
498
|
+
A condensed, authoritative API reference ships inside the gem at
|
|
499
|
+
`lib/event_engine/reference/guide.md` and is installed into consuming apps as Claude
|
|
500
|
+
Code subagents (`.claude/agents/`). When working in a host app, prefer that
|
|
501
|
+
reference and this README over reading gem internals.
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## Contributing
|
|
506
|
+
|
|
507
|
+
1. Fork and create a feature branch.
|
|
508
|
+
2. Add tests for behavior changes (Minitest; see `test/`).
|
|
509
|
+
3. Run the suite: `bundle exec rake test`.
|
|
510
|
+
4. Open a PR.
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## License
|
|
515
|
+
|
|
516
|
+
Available as open source under the terms of the
|
|
517
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
|
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
4
|
+
load "rails/tasks/engine.rake"
|
|
5
|
+
|
|
6
|
+
require "bundler/gem_tasks"
|
|
7
|
+
|
|
8
|
+
task test: "app:test"
|
|
9
|
+
task default: :test
|
|
10
|
+
|
|
11
|
+
require "event_engine/the_local"
|
|
12
|
+
require "the_local/rake"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//= link_directory ../stylesheets/event_engine .css
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|