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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +53 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +517 -0
  5. data/Rakefile +12 -0
  6. data/app/assets/config/event_engine_manifest.js +1 -0
  7. data/app/assets/stylesheets/event_engine/application.css +15 -0
  8. data/app/controllers/event_engine/application_controller.rb +4 -0
  9. data/app/helpers/event_engine/application_helper.rb +4 -0
  10. data/app/jobs/event_engine/application_job.rb +4 -0
  11. data/app/mailers/event_engine/application_mailer.rb +6 -0
  12. data/app/models/event_engine/application_record.rb +5 -0
  13. data/app/views/layouts/event_engine/application.html.erb +15 -0
  14. data/config/routes.rb +2 -0
  15. data/lib/event_engine/configuration.rb +20 -0
  16. data/lib/event_engine/definition_loader.rb +26 -0
  17. data/lib/event_engine/dsl_compiler.rb +50 -0
  18. data/lib/event_engine/engine.rb +56 -0
  19. data/lib/event_engine/event.rb +32 -0
  20. data/lib/event_engine/event_builder.rb +45 -0
  21. data/lib/event_engine/event_definition/inputs.rb +43 -0
  22. data/lib/event_engine/event_definition/payloads.rb +47 -0
  23. data/lib/event_engine/event_definition/schemas.rb +158 -0
  24. data/lib/event_engine/event_definition/validation.rb +18 -0
  25. data/lib/event_engine/event_definition.rb +76 -0
  26. data/lib/event_engine/event_schema.rb +99 -0
  27. data/lib/event_engine/event_schema_dumper.rb +13 -0
  28. data/lib/event_engine/event_schema_loader.rb +37 -0
  29. data/lib/event_engine/event_schema_merger.rb +62 -0
  30. data/lib/event_engine/event_schema_writer.rb +47 -0
  31. data/lib/event_engine/handler_registry.rb +23 -0
  32. data/lib/event_engine/lifecycle_definition.rb +86 -0
  33. data/lib/event_engine/process_type.rb +26 -0
  34. data/lib/event_engine/railtie.rb +9 -0
  35. data/lib/event_engine/reference/guide.md +129 -0
  36. data/lib/event_engine/reference.rb +16 -0
  37. data/lib/event_engine/schema_catalog.rb +50 -0
  38. data/lib/event_engine/schema_compatibility.rb +50 -0
  39. data/lib/event_engine/schema_diff.rb +35 -0
  40. data/lib/event_engine/schema_drift_guard.rb +38 -0
  41. data/lib/event_engine/schema_registry.rb +122 -0
  42. data/lib/event_engine/subject_registry.rb +40 -0
  43. data/lib/event_engine/the_local/agents/event_engine-develop.md +142 -0
  44. data/lib/event_engine/the_local/agents/event_engine-info.md +140 -0
  45. data/lib/event_engine/the_local/agents/event_engine-install.md +140 -0
  46. data/lib/event_engine/the_local.rb +55 -0
  47. data/lib/event_engine/version.rb +3 -0
  48. data/lib/event_engine.rb +197 -0
  49. data/lib/generators/event_engine/install_generator.rb +31 -0
  50. data/lib/generators/event_engine/templates/event_schema.rb +10 -0
  51. data/lib/generators/event_engine/templates/initializer.rb +4 -0
  52. data/lib/tasks/event_engine_catalog.rake +13 -0
  53. data/lib/tasks/event_engine_schema.rake +82 -0
  54. data/lib/tasks/event_engine_schema_check.rake +20 -0
  55. data/lib/tasks/event_engine_tasks.rake +4 -0
  56. 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
+ */
@@ -0,0 +1,4 @@
1
+ module EventEngine
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module EventEngine
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module EventEngine
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module EventEngine
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module EventEngine
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Event engine</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "event_engine/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>