EventEngine::Store
The permanent, queryable event record for EventEngine.
Where event_engine-delivery's
outbox is a transient delivery buffer (rows are deleted once published and the
retention window passes), event_engine-store is the durable source of truth: an
immutable, append-only event log the host application owns. It registers a handler
with EventEngine that records every dispatched event, and provides
event-sourcing replay — rebuilding state by reading the log back in append order.
It deliberately does not aggregate or compute metrics — turning the log into rollups is a separate concern.
| Gem | Keeps events…? |
|---|---|
event_engine-delivery (outbox) |
only until delivered + retention expires — a buffer |
event_engine-store (this gem) |
forever, append-only — the record |
The two are complementary; you can run both at once.
Table of contents
- Installation
- How it hooks into the core
- The
StoredEventtable - Querying the log
- Customizing the store ← start here for extensions
- Expanding the table with your own columns
- Adding query scopes to the model
- Recording only some events
- Replacing the recorder entirely
- Disabling the default recorder
- Making recording resilient / async
- Replay
- Projections
- License
Installation
# Gemfile
gem "event_engine"
gem "event_engine-store"bundle installCopy the migration into your app and run it:
bin/rails railties:install:migrations # copies the StoredEvent migration
bin/rails db:migrateThere is no install generator, initializer, or configuration — the store wires itself up at boot. Once migrated, every dispatched event is recorded automatically.
How it hooks into the core
At Rails boot the engine registers two handlers with the core, for every process type:
# lib/event_engine/store/engine.rb
initializer "event_engine.store.register_recorder" do
config.after_initialize do
EventEngine.register_handler(Recorder.new, process_types: :all)
EventEngine.register_handler(ProjectionDispatcher.new, process_types: :all)
end
endSo on every EventEngine.<event> call:
-
Recorder#call(event)inserts aStoredEventrow. -
ProjectionDispatcher#call(event)callsapply(event)on each registered projection.
Because both register at
process_types: :all, the store records events of every process type, whichever processorconfig/event_rules.ymlroutes them to. That's by design: the store is your record of what happened, independent of how it was processed. If you only want to record some events, see Recording only some events.
The StoredEvent table
EventEngine::Store::StoredEvent (table event_engine_store_stored_events) is
append-only: once a row is persisted it is read-only (readonly? returns true on
persisted records), so an attempt to update raises ActiveRecord::ReadOnlyRecord.
| Column | Type | Indexed | Notes |
|---|---|---|---|
event_name |
string | ✓ | NOT NULL |
event_type |
string | classification | |
event_version |
integer | schema version | |
process_type |
string | processor the event was routed to | |
payload |
json | event data | |
metadata |
json | context (actor, request id, …) | |
occurred_at |
datetime | ✓ | logical event time |
idempotency_key |
string | ✓ | not unique — duplicates allowed |
aggregate_type |
string | aggregate tracking | |
aggregate_id |
string | ||
aggregate_version |
integer | ||
created_at |
datetime | DB insert time (NOT NULL) |
The full event envelope is captured. The default migration:
create_table :event_engine_store_stored_events do |t|
t.string :event_name, null: false
t.string :event_type
t.integer :event_version
t.string :process_type
t.json :payload
t.json :metadata
t.datetime :occurred_at
t.string :idempotency_key
t.string :aggregate_type
t.string :aggregate_id
t.integer :aggregate_version
t.datetime :created_at, null: false
end
add_index :event_engine_store_stored_events, :event_name
add_index :event_engine_store_stored_events, :occurred_at
add_index :event_engine_store_stored_events, :idempotency_keyQuerying the log
The model ships with no scopes — query it with plain ActiveRecord:
SE = EventEngine::Store::StoredEvent
SE.where(event_name: "order_placed")
SE.where("occurred_at > ?", 7.days.ago)
SE.where(aggregate_type: "Order", aggregate_id: order.id).order(:id) # one aggregate's history
SE.order(:id).find_each { |e| … } # full log, batched
SE.pluck(:event_name).tally # quick histogramFor richer queries, add your own scopes — see below.
Customizing the store
This is the part most teams need. The store is deliberately minimal, so the customization seams are explicit. The most common ask — "I want more columns on the event table" — has an important catch, covered first.
Expanding the table with your own columns
You'll often want first-class columns instead of digging into the payload/metadata
JSON on every query.
Why expand the table:
-
Indexable, fast filtering/joins — e.g.
actor_id,tenant_id,correlation_idas real columns you can index and join on, instead ofmetadata->>'actor_id'. - Reporting & BI tools that don't grok JSON columns well.
- Foreign keys / constraints to enforce integrity against other tables.
-
Partitioning / retention by a real column (e.g.
tenant_id,created_at).
Adding a column is two steps: a migration adds the column, and you extend the
Recorder to populate it (the recorder writes a fixed set of attributes, so a new
column stays NULL until the recorder fills it).
Step 1 — migration (use a timestamp after the gem's 20260605000001):
# db/migrate/20260701000000_extend_stored_events.rb
class ExtendStoredEvents < ActiveRecord::Migration[8.0]
def change
add_column :event_engine_store_stored_events, :actor_id, :integer
add_column :event_engine_store_stored_events, :tenant_id, :integer
add_column :event_engine_store_stored_events, :correlation_id, :string
add_index :event_engine_store_stored_events, :actor_id
add_index :event_engine_store_stored_events, [:tenant_id, :created_at]
end
endStep 2 — capture the values. Wrap the recorder so it pulls your new fields out of
each event (typically from metadata) in addition to the defaults:
# config/initializers/event_engine_store.rb
require "event_engine/store/recorder"
module EventEngine
module Store
class Recorder
# Re-open #call to add columns. Keep the default capture and extend it.
def call(event)
meta = event.metadata || {}
StoredEvent.create!(
event_name: event.event_name,
event_type: event.event_type,
event_version: event.event_version,
process_type: event.process_type,
payload: event.payload,
metadata: event.metadata,
occurred_at: event.occurred_at,
idempotency_key: event.idempotency_key,
aggregate_type: event.aggregate_type,
aggregate_id: event.aggregate_id,
aggregate_version: event.aggregate_version,
# your added columns:
actor_id: meta[:actor_id] || meta["actor_id"],
tenant_id: meta[:tenant_id] || meta["tenant_id"],
correlation_id: meta[:correlation_id] || meta["correlation_id"]
)
end
end
end
endNow emit with the context in metadata and it lands in real columns:
EventEngine.order_placed(order: order, metadata: { actor_id: current_user.id, tenant_id: tenant.id })Tip — an upgrade-resilient alternative. Re-opening
Recorder#callreplaces it, so it won't pick up changes to the default capture in a future gem version. If you'd rather not re-list the defaults,prependa module that lets the gem do its insert and then patches your columns on the returned record:module CaptureContext def call(event) record = super meta = event.metadata || {} record.update_columns( # update_columns bypasses the readonly guard actor_id: meta[:actor_id] || meta["actor_id"], tenant_id: meta[:tenant_id] || meta["tenant_id"] ) record end end EventEngine::Store::Recorder.prepend(CaptureContext)This is resilient to default-capture changes, at the cost of a second write.
Adding query scopes to the model
Re-open the model to add scopes/helpers (you can't add columns this way — that needs a migration):
# config/initializers/event_engine_store.rb (or app/models/…)
EventEngine::Store::StoredEvent.class_eval do
scope :named, ->(name) { where(event_name: name) }
scope :for_aggregate, ->(type, id) { where(aggregate_type: type, aggregate_id: id).order(:id) }
scope :since, ->(time) { where("occurred_at >= ?", time) }
end
EventEngine::Store::StoredEvent.named("order_placed").since(1.day.ago)Recording only some events
The default records everything. To record a subset, prepend a filter that skips the rest:
module RecordDomainOnly
RECORDED = %w[order_placed payment_captured].freeze
def call(event)
return unless RECORDED.include?(event.event_name.to_s)
super
end
end
EventEngine::Store::Recorder.prepend(RecordDomainOnly)Why: keep the log focused on domain/audit-worthy events and avoid recording noisy system events.
Replacing the recorder entirely
For a fundamentally different write path (a different table, sharding, an external audit service), register your own handler and turn off the default one:
class MyAuditRecorder
def call(event)
AuditLog.create!(kind: event.event_name, data: event.payload, at: event.occurred_at)
end
end
Rails.application.config.after_initialize do
EventEngine.register_handler(MyAuditRecorder.new, process_types: :all)
end(See Disabling the default recorder to stop the built-in one from also writing.)
Disabling the default recorder
There's no config flag to skip it, so neutralize it in an initializer. Cleanest is to
make Recorder#call a no-op:
module DisableDefaultRecorder
def call(_event); end
end
EventEngine::Store::Recorder.prepend(DisableDefaultRecorder)Avoid
EventEngine.reset_handlers!for this — it clears all handlers, includingevent_engine-delivery's and the projection dispatcher. Only reach for it if you're fully taking over routing.
Making recording resilient / async
The default Recorder calls StoredEvent.create! synchronously inside dispatch.
A DB hiccup will therefore raise straight back into your emitting code. If recording
must never break emission, wrap it to rescue (and optionally enqueue a retry):
module ResilientRecord
def call(event)
super
rescue => e
Rails.logger.error("[store] failed to record #{event.event_name}: #{e.class} #{e.message}")
# optional: RecordEventLaterJob.perform_later(event.to_h)
nil
end
end
EventEngine::Store::Recorder.prepend(ResilientRecord)Trade-off: rescuing protects emission but means a recording failure no longer surfaces loudly — make sure you alert on the log line.
Replay
EventEngine::Store::Replay.each reconstructs EventEngine::Event objects from the
log in append order (ordered by id, batched via find_each):
EventEngine::Store::Replay.each do |event|
# event is an EventEngine::Event rebuilt from its row
puts "#{event.occurred_at} #{event.event_name}"
end
# Without a block you get an Enumerator (a *live* query, not a snapshot):
enum = EventEngine::Store::Replay.each
enum.countA replayed event differs from the one that was dispatched:
-
payloadandmetadatahave string keys, because they are read back from JSON columns (event.payload["weight"], notevent.payload[:weight]). -
event_nameandprocess_typeare strings, read back from string columns. -
subjectanddomainare nil, because the table has no columns for them.
Use replay to rebuild read models, backfill a new projection, or audit history.
Projections
A projection is any object with apply(event). Register it and it's updated live
as events are dispatched, and can be rebuilt from the full log on demand.
class OrdersByDay
def initialize; @counts = Hash.new(0); end
attr_reader :counts
def apply(event)
@counts[event.occurred_at.to_date] += 1 if event.event_name.to_s == "order_placed"
end
end
projection = OrdersByDay.new
EventEngine::Store.register_projection(projection) # live updates from now on
EventEngine::Store.rebuild(projection) # replay the whole log into it
EventEngine::Store.reset_projections! # clear all (e.g. in tests)API: register_projection(p), projections, reset_projections!, rebuild(p).
Why projections: maintain a denormalized read model (counters, dashboards, search indexes) that you can always recompute from the authoritative log — the core event-sourcing payoff.
Note: projections run synchronously inside dispatch. If a projection's
applyraises, it propagates and can break event emission (and stops later handlers). Keepapplyfast and defensive; for heavy work, haveapplyenqueue a job instead of doing the work inline.
License
Available as open source under the terms of the MIT License.