acta 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/.tool-versions +1 -0
- data/CHANGELOG.md +210 -0
- data/LICENSE +21 -0
- data/PLAN.md +158 -0
- data/README.md +559 -0
- data/Rakefile +12 -0
- data/app/controllers/acta/web/application_controller.rb +10 -0
- data/app/controllers/acta/web/events_controller.rb +37 -0
- data/app/helpers/acta/web/application_helper.rb +106 -0
- data/app/views/acta/web/events/index.html.erb +312 -0
- data/app/views/acta/web/events/show.html.erb +72 -0
- data/app/views/layouts/acta/web/application.html.erb +594 -0
- data/config/routes.rb +4 -0
- data/lib/acta/actor.rb +34 -0
- data/lib/acta/adapters/base.rb +59 -0
- data/lib/acta/adapters/postgres.rb +73 -0
- data/lib/acta/adapters/sqlite.rb +58 -0
- data/lib/acta/adapters.rb +19 -0
- data/lib/acta/array_type.rb +30 -0
- data/lib/acta/command.rb +48 -0
- data/lib/acta/current.rb +10 -0
- data/lib/acta/errors.rb +102 -0
- data/lib/acta/event.rb +80 -0
- data/lib/acta/events_query.rb +73 -0
- data/lib/acta/handler.rb +9 -0
- data/lib/acta/model.rb +58 -0
- data/lib/acta/model_type.rb +32 -0
- data/lib/acta/projection.rb +64 -0
- data/lib/acta/projection_managed.rb +108 -0
- data/lib/acta/railtie.rb +65 -0
- data/lib/acta/reactor.rb +15 -0
- data/lib/acta/reactor_job.rb +19 -0
- data/lib/acta/record.rb +10 -0
- data/lib/acta/schema.rb +12 -0
- data/lib/acta/serializable.rb +48 -0
- data/lib/acta/testing/dsl.rb +90 -0
- data/lib/acta/testing/matchers.rb +77 -0
- data/lib/acta/testing.rb +50 -0
- data/lib/acta/types/encrypted_string.rb +63 -0
- data/lib/acta/version.rb +5 -0
- data/lib/acta/web/engine.rb +13 -0
- data/lib/acta/web/events_query.rb +81 -0
- data/lib/acta/web.rb +45 -0
- data/lib/acta.rb +296 -0
- data/lib/generators/acta/install/install_generator.rb +23 -0
- data/lib/generators/acta/install/templates/create_acta_events.rb.tt +9 -0
- data/sig/acta.rbs +4 -0
- metadata +152 -0
data/README.md
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
# Acta
|
|
2
|
+
|
|
3
|
+
Lightweight event-driven and event-sourced primitives for Rails.
|
|
4
|
+
|
|
5
|
+
## What it is
|
|
6
|
+
|
|
7
|
+
A small, opinionated set of primitives for Rails applications that want an
|
|
8
|
+
audit log, an event-driven architecture, or event sourcing — without taking
|
|
9
|
+
on a heavyweight framework. Apps compose the primitives à la carte:
|
|
10
|
+
|
|
11
|
+
- Plain event-driven with a persistent audit log
|
|
12
|
+
- Event-sourced aggregates with readonly projections
|
|
13
|
+
- Hybrid — some aggregates event-sourced, others conventional
|
|
14
|
+
|
|
15
|
+
What the library ships:
|
|
16
|
+
|
|
17
|
+
| Primitive | Role |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `Acta::Event` | ActiveModel-backed event classes with typed payloads |
|
|
20
|
+
| `Acta::Handler` | Base primitive — "on event X, run this" |
|
|
21
|
+
| `Acta::Projection` | Sync + transactional + replayable (for ES aggregates) |
|
|
22
|
+
| `Acta::Reactor` | After-commit + async via ActiveJob (for side effects) |
|
|
23
|
+
| `Acta::Command` | Recommended write path with param validation & optimistic concurrency |
|
|
24
|
+
| `Acta::Testing` | RSpec matchers, given-when-then DSL, replay-determinism assertions |
|
|
25
|
+
|
|
26
|
+
Adapters: SQLite and Postgres, both first-class.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Not published to RubyGems. Install from git:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Gemfile
|
|
34
|
+
gem "acta", git: "https://github.com/whoojemaflip/acta.git"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Rails 8.1+ and Ruby 3.4+.
|
|
38
|
+
|
|
39
|
+
Generate the events table migration:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bin/rails generate acta:install
|
|
43
|
+
bin/rails db:migrate
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
### 1. Define an event
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# app/events/order_placed.rb
|
|
52
|
+
class OrderPlaced < Acta::Event
|
|
53
|
+
stream :order, key: :order_id
|
|
54
|
+
|
|
55
|
+
attribute :order_id, :string
|
|
56
|
+
attribute :customer_id, :string
|
|
57
|
+
attribute :total_cents, :integer
|
|
58
|
+
|
|
59
|
+
validates :order_id, :customer_id, :total_cents, presence: true
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2. Emit it
|
|
64
|
+
|
|
65
|
+
Set the actor once at the request boundary:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# ApplicationController
|
|
69
|
+
before_action do
|
|
70
|
+
Acta::Current.actor = Acta::Actor.new(
|
|
71
|
+
type: "user",
|
|
72
|
+
id: current_user.id,
|
|
73
|
+
source: "web"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# somewhere in your code
|
|
78
|
+
Acta.emit(OrderPlaced.new(order_id: "o_1", customer_id: "c_1", total_cents: 4200))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That's the minimum viable Acta app — you now have an append-only audit log
|
|
82
|
+
keyed by actor (who) and source (through what surface). Actor types and
|
|
83
|
+
sources are open strings; pick the vocabulary that fits your app.
|
|
84
|
+
|
|
85
|
+
### 3. React to events (event-driven)
|
|
86
|
+
|
|
87
|
+
For side effects that should happen after each event is durably written:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# app/reactors/confirmation_email_reactor.rb
|
|
91
|
+
class ConfirmationEmailReactor < Acta::Reactor
|
|
92
|
+
on OrderPlaced do |event|
|
|
93
|
+
OrderMailer.confirmation(event.order_id).deliver_later
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Reactors run after-commit and default to async via ActiveJob. Use `sync!`
|
|
99
|
+
to run in the caller's thread (mostly useful for tests).
|
|
100
|
+
|
|
101
|
+
### 4. Project state (event-sourced)
|
|
102
|
+
|
|
103
|
+
For aggregates where the event log is the source of truth and AR tables
|
|
104
|
+
are a derived view:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# app/projections/order_projection.rb
|
|
108
|
+
class OrderProjection < Acta::Projection
|
|
109
|
+
truncates Order
|
|
110
|
+
|
|
111
|
+
on OrderPlaced do |event|
|
|
112
|
+
Order.create!(
|
|
113
|
+
id: event.order_id,
|
|
114
|
+
customer_id: event.customer_id,
|
|
115
|
+
total_cents: event.total_cents,
|
|
116
|
+
status: "placed"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
on OrderShipped do |event|
|
|
121
|
+
Order.find(event.order_id).update!(status: "shipped", shipped_at: event.occurred_at)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`truncates Order` declares the AR classes this projection owns. Acta uses
|
|
127
|
+
the declaration both as the default `truncate!` (`delete_all` on each in
|
|
128
|
+
order) and as input to cross-projection ordering: when one projection's
|
|
129
|
+
tables FK-reference another's, the children are truncated first
|
|
130
|
+
regardless of registration order. List multiple in safe within-projection
|
|
131
|
+
order (children before parents):
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
class CatalogProjection < Acta::Projection
|
|
135
|
+
truncates Trail, Zone # Trail.zone_id → Zone.id, so Trail first
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Override `truncate!` directly when the default isn't enough — `truncates`
|
|
140
|
+
still drives global FK ordering, while the override provides whatever
|
|
141
|
+
custom teardown the projection needs.
|
|
142
|
+
|
|
143
|
+
Projections run synchronously inside the emit transaction. If they raise,
|
|
144
|
+
the entire emit rolls back — the event row isn't written, reactors don't
|
|
145
|
+
fire, base handlers don't fire.
|
|
146
|
+
|
|
147
|
+
Projections register themselves with Acta the first time their class is
|
|
148
|
+
loaded (via `Class.inherited`). Acta's Railtie eagerly loads everything
|
|
149
|
+
under `app/projections`, `app/handlers`, and `app/reactors` on each
|
|
150
|
+
`config.to_prepare`, so subscribers are wired up before the first request
|
|
151
|
+
— including in dev mode where Zeitwerk would otherwise wait until
|
|
152
|
+
something explicitly references the constant. If your subscribers live
|
|
153
|
+
elsewhere, point Acta at them:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# config/application.rb
|
|
157
|
+
config.acta.projection_paths = %w[app/projections app/read_models]
|
|
158
|
+
config.acta.handler_paths = %w[app/handlers]
|
|
159
|
+
config.acta.reactor_paths = %w[app/reactors]
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Set a path list to `[]` to disable auto-loading and manage subscriber
|
|
163
|
+
lifecycle yourself.
|
|
164
|
+
|
|
165
|
+
Replay at any time:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
Acta.rebuild!
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Each projection's `truncate!` runs in FK-safe order (derived from the
|
|
172
|
+
`truncates` declarations), then the log is replayed through projections.
|
|
173
|
+
Reactors are skipped during replay (replay is a state operation, not a
|
|
174
|
+
notification one).
|
|
175
|
+
|
|
176
|
+
#### Guarding projection-owned tables
|
|
177
|
+
|
|
178
|
+
Once a model is maintained by a projection, *every* other write path
|
|
179
|
+
(controllers, console one-offs, rake tasks, callbacks on other models)
|
|
180
|
+
silently breaks the event log as the source of truth. Opt into a runtime
|
|
181
|
+
guard with `acta_managed!`:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class Order < ApplicationRecord
|
|
185
|
+
acta_managed! # writes outside an Acta::Projection raise ProjectionWriteError
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Inside an `Acta::Projection` `on EventClass do |e| ... end` block (and
|
|
190
|
+
during `Acta.rebuild!`'s truncate phase), `Acta::Projection.applying?`
|
|
191
|
+
is true and writes pass through. From a controller, console, or
|
|
192
|
+
unrelated callback, they raise:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
Order.update_all(status: "cancelled")
|
|
196
|
+
# raise: Acta::ProjectionWriteError — Order is acta_managed!
|
|
197
|
+
# Emit an event so the projection can update the row, or wrap
|
|
198
|
+
# intentional out-of-band writes in
|
|
199
|
+
# `Acta::Projection.applying! { ... }` (fixtures, migrations,
|
|
200
|
+
# backfills).
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
For incremental migration, demote violations to warnings:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
acta_managed! on_violation: :warn
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Test fixtures, data migrations, and one-off backfills can wrap
|
|
210
|
+
intentional out-of-band writes in `Acta::Projection.applying! { ... }`
|
|
211
|
+
to bypass the safety net explicitly.
|
|
212
|
+
|
|
213
|
+
### 5. Commands for validated writes
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
# app/commands/place_order.rb
|
|
217
|
+
class PlaceOrder < Acta::Command
|
|
218
|
+
param :customer_id, :string
|
|
219
|
+
param :total_cents, :integer
|
|
220
|
+
|
|
221
|
+
validates :customer_id, :total_cents, presence: true
|
|
222
|
+
validates :total_cents, numericality: { greater_than: 0 }
|
|
223
|
+
|
|
224
|
+
def call
|
|
225
|
+
order_id = "order_#{SecureRandom.uuid}"
|
|
226
|
+
emit OrderPlaced.new(order_id:, customer_id:, total_cents:)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
cmd = PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
|
|
231
|
+
cmd.emitted_events.first.order_id # => "order_…"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`Acta::Command.call` returns the command instance. The instance carries
|
|
235
|
+
the params, the `emitted_events` array (every event emitted during
|
|
236
|
+
`#call`, in order), and any state the command exposed via
|
|
237
|
+
`attr_reader`. Callers that don't care about the events ignore the
|
|
238
|
+
return value:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Commands can emit zero, one, or many events. The framework does not
|
|
245
|
+
invent a "primary" event — when a command emits more than one, the
|
|
246
|
+
caller (who knows the domain) picks what matters from
|
|
247
|
+
`cmd.emitted_events`.
|
|
248
|
+
|
|
249
|
+
### Optimistic locking (high-water mark)
|
|
250
|
+
|
|
251
|
+
Every stream has a high-water mark — the `stream_sequence` of its most
|
|
252
|
+
recent event. `Acta.version_of` reads it; `Acta.emit(..., if_version: N)`
|
|
253
|
+
asserts it. Use the pair when you need optimistic locking against
|
|
254
|
+
concurrent writers to the same aggregate:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
class RenameOrder < Acta::Command
|
|
258
|
+
param :order_id, :string
|
|
259
|
+
param :new_name, :string
|
|
260
|
+
|
|
261
|
+
def call
|
|
262
|
+
version = Acta.version_of(stream_type: :order, stream_key: order_id)
|
|
263
|
+
# ... do work that depends on the current state ...
|
|
264
|
+
emit OrderRenamed.new(order_id:, new_name:), if_version: version
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
If another writer has appended to the stream between `version_of` and
|
|
270
|
+
`emit`, the emit raises `Acta::VersionConflict` — callers retry with
|
|
271
|
+
fresh state or surface the collision instead of silently clobbering it.
|
|
272
|
+
`if_version: 0` asserts a fresh stream (no events yet). Most commands
|
|
273
|
+
don't need this; reach for it when concurrent writes to the same
|
|
274
|
+
aggregate are realistic and lost-update would be a bug.
|
|
275
|
+
|
|
276
|
+
## Identity: generate IDs in commands, never in projections
|
|
277
|
+
|
|
278
|
+
For event-sourced aggregates, aggregate IDs (typically UUIDs) must be
|
|
279
|
+
stable across `Acta.rebuild!` and must not drift if the projected tables
|
|
280
|
+
are truncated. The rule: **the command generates the ID once, the event
|
|
281
|
+
carries it in its payload, and the projection reads it back out**.
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
class CreateOrder < Acta::Command
|
|
285
|
+
param :customer_id, :string
|
|
286
|
+
param :total_cents, :integer
|
|
287
|
+
|
|
288
|
+
def call
|
|
289
|
+
order_id = "order_#{SecureRandom.uuid}" # generated here, once, forever
|
|
290
|
+
emit OrderCreated.new(order_id:, customer_id:, total_cents:)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
class OrderCreated < Acta::Event
|
|
295
|
+
stream :order, key: :order_id
|
|
296
|
+
attribute :order_id, :string
|
|
297
|
+
attribute :customer_id, :string
|
|
298
|
+
attribute :total_cents, :integer
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
class OrderProjection < Acta::Projection
|
|
302
|
+
on OrderCreated do |event|
|
|
303
|
+
Order.insert!(id: event.order_id, customer_id: event.customer_id, ...)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
When `Acta.rebuild!` runs, it calls `OrderProjection.truncate!` (wiping
|
|
309
|
+
the `orders` table) and replays every event. The projection reads
|
|
310
|
+
`event.order_id` — which was written at the original command call — and
|
|
311
|
+
re-inserts the row with the same ID. **Rebuild never regenerates IDs.**
|
|
312
|
+
|
|
313
|
+
### What to avoid
|
|
314
|
+
|
|
315
|
+
- **Generating IDs in projection code.** Non-deterministic — every
|
|
316
|
+
rebuild produces new IDs, orphaning any foreign references.
|
|
317
|
+
`SecureRandom` / `Time.current` / anything stateful has no place in a
|
|
318
|
+
projection.
|
|
319
|
+
- **Generating IDs in the event class's `initialize`.** Same problem:
|
|
320
|
+
if the event assigns a default ID when reconstructed from a row, old
|
|
321
|
+
events would decode with fresh IDs. Events should take an explicit
|
|
322
|
+
`order_id:` attribute and require it in the payload.
|
|
323
|
+
- **Dropping the events table.** The event log is the primary source
|
|
324
|
+
of IDs. Purging it regenerates all IDs on next write. Back it up and
|
|
325
|
+
treat it as production-critical — even more so if other systems (a
|
|
326
|
+
separate user DB, external services) reference your aggregates' IDs.
|
|
327
|
+
|
|
328
|
+
### Why this matters
|
|
329
|
+
|
|
330
|
+
If anything outside the event-sourced aggregate references an ID —
|
|
331
|
+
`ratings.wine_id` in a separate user database, a webhook payload sent to
|
|
332
|
+
a third party, a URL that users have bookmarked — that reference must
|
|
333
|
+
stay valid across rebuilds. Keeping IDs in the event payload guarantees
|
|
334
|
+
it without any special deterministic-UUID schemes.
|
|
335
|
+
|
|
336
|
+
## Event payloads with nested models
|
|
337
|
+
|
|
338
|
+
Payloads can carry arbitrary nested structures — either payload-only
|
|
339
|
+
`Acta::Model` classes or ActiveRecord classes that include
|
|
340
|
+
`Acta::Serializable`.
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
# payload-only class
|
|
344
|
+
class LineItem < Acta::Model
|
|
345
|
+
attribute :sku, :string
|
|
346
|
+
attribute :quantity, :integer
|
|
347
|
+
attribute :price_cents, :integer
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# existing AR class — opt in as a payload type
|
|
351
|
+
class Address < ApplicationRecord
|
|
352
|
+
include Acta::Serializable
|
|
353
|
+
acta_serialize except: [:created_at, :updated_at]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
class OrderSubmitted < Acta::Event
|
|
357
|
+
stream :order, key: :order_id
|
|
358
|
+
|
|
359
|
+
attribute :order_id, :string
|
|
360
|
+
attribute :shipping_address, Address # AR + Serializable
|
|
361
|
+
attribute :items, array_of: LineItem # Array<Acta::Model>
|
|
362
|
+
attribute :tags, array_of: String
|
|
363
|
+
end
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
When embedded, AR instances are **snapshots**: `event.shipping_address.street`
|
|
367
|
+
returns the value at emit time, regardless of later changes. For the
|
|
368
|
+
current row, call `Address.find(event.shipping_address.id)`.
|
|
369
|
+
|
|
370
|
+
## Encrypted attributes
|
|
371
|
+
|
|
372
|
+
Some events carry secrets — OAuth tokens, API keys, password reset
|
|
373
|
+
tokens — that shouldn't sit in `events.payload` as plaintext. Acta
|
|
374
|
+
supports per-attribute encryption that piggybacks on Rails'
|
|
375
|
+
`ActiveRecord::Encryption` (same keys, same rotation story).
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
class StravaCredentialIssued < Acta::Event
|
|
379
|
+
stream :strava_credential, key: :user_id
|
|
380
|
+
|
|
381
|
+
attribute :user_id, :string
|
|
382
|
+
attribute :access_token, :encrypted_string # encrypted in payload
|
|
383
|
+
attribute :refresh_token, :encrypted_string
|
|
384
|
+
attribute :expires_at, :datetime
|
|
385
|
+
end
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
In memory the event behaves normally — `event.access_token` is the
|
|
389
|
+
plaintext you wrote. The encrypted form only appears in the serialized
|
|
390
|
+
payload that's written to the events table:
|
|
391
|
+
|
|
392
|
+
```
|
|
393
|
+
events.payload → { "access_token": "{\"p\":\"…\",\"h\":{\"iv\":\"…\",\"at\":\"…\"}}", "user_id": "u_1", … }
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Encryption is **per-attribute**: leave queryable fields like `user_id`
|
|
397
|
+
plaintext, encrypt only the secrets.
|
|
398
|
+
|
|
399
|
+
### Setup
|
|
400
|
+
|
|
401
|
+
Encrypted attributes use the same configuration as Rails AR encryption.
|
|
402
|
+
If you're already using `encrypts` on AR models, you have nothing to do.
|
|
403
|
+
Otherwise:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
bin/rails db:encryption:init
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Then store the printed keys in `Rails.application.credentials`:
|
|
410
|
+
|
|
411
|
+
```yaml
|
|
412
|
+
active_record_encryption:
|
|
413
|
+
primary_key: ...
|
|
414
|
+
deterministic_key: ...
|
|
415
|
+
key_derivation_salt: ...
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Key rotation
|
|
419
|
+
|
|
420
|
+
To rotate, append a new primary key (Rails keeps the old keys for
|
|
421
|
+
decryption indefinitely):
|
|
422
|
+
|
|
423
|
+
```yaml
|
|
424
|
+
active_record_encryption:
|
|
425
|
+
primary_key:
|
|
426
|
+
- new_primary_key # new writes use this
|
|
427
|
+
- old_primary_key # old payloads still decrypt
|
|
428
|
+
deterministic_key: ...
|
|
429
|
+
key_derivation_salt: ...
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Existing event rows stay decryptable. New emits use the new key. No
|
|
433
|
+
re-encryption migration is required — the audit log accretes ciphertext
|
|
434
|
+
under whichever key was current at write time.
|
|
435
|
+
|
|
436
|
+
## Testing
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
# spec_helper.rb (or equivalent)
|
|
440
|
+
require "acta/testing"
|
|
441
|
+
require "acta/testing/matchers"
|
|
442
|
+
|
|
443
|
+
RSpec.configure do |config|
|
|
444
|
+
Acta::Testing.default_actor!(config)
|
|
445
|
+
config.include Acta::Testing::DSL
|
|
446
|
+
|
|
447
|
+
config.around(:each, :active_record) do |example|
|
|
448
|
+
Acta::Testing.test_mode { example.run }
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Default actor
|
|
454
|
+
|
|
455
|
+
`Acta.emit` requires `Acta::Current.actor` to be set — every event needs
|
|
456
|
+
a known author. `Acta::Testing.default_actor!(config)` adds a
|
|
457
|
+
`before(:each)` that sets a default `system / rspec / test` actor and an
|
|
458
|
+
`after(:each)` that resets it, so specs (and the commands they call)
|
|
459
|
+
don't trip `Acta::MissingActor`. Override any attribute to match your
|
|
460
|
+
project's vocabulary:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
Acta::Testing.default_actor!(config, type: "user", id: "test-user-1", source: "spec")
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
For an individual example that needs to attribute emissions to a
|
|
467
|
+
specific actor, scope an override with `with_actor`:
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
include Acta::Testing::DSL
|
|
471
|
+
|
|
472
|
+
it "records the user as the actor" do
|
|
473
|
+
with_actor(type: "user", id: user.id, source: "web") do
|
|
474
|
+
PlaceOrder.call(...)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
expect(Acta::Record.last.actor_id).to eq(user.id)
|
|
478
|
+
end
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
`with_actor` restores the surrounding actor when the block returns or
|
|
482
|
+
raises.
|
|
483
|
+
|
|
484
|
+
### RSpec matchers
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
expect { PlaceOrder.call(order_id: "o_1", customer_id: "c_1", total_cents: 4200) }
|
|
488
|
+
.to emit(OrderPlaced).with(total_cents: 4200)
|
|
489
|
+
|
|
490
|
+
expect { some_noop }.not_to emit_any_events
|
|
491
|
+
|
|
492
|
+
expect { batched_import }
|
|
493
|
+
.to emit_events([OrderPlaced, OrderPlaced, OrderPlaced])
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### Given/when/then DSL
|
|
497
|
+
|
|
498
|
+
```ruby
|
|
499
|
+
include Acta::Testing::DSL
|
|
500
|
+
|
|
501
|
+
it "ships an order" do
|
|
502
|
+
given_events do
|
|
503
|
+
Acta.emit(OrderPlaced.new(order_id: "o_1", customer_id: "c_1", total_cents: 4200))
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
when_command ShipOrder.new(order_id: "o_1", tracking: "TRK123")
|
|
507
|
+
|
|
508
|
+
then_emitted OrderShipped, order_id: "o_1"
|
|
509
|
+
then_emitted_nothing_else
|
|
510
|
+
end
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Fixtures become narratives — prior state is declared as events, which
|
|
514
|
+
mirrors how state actually accumulates in an event-sourced system.
|
|
515
|
+
|
|
516
|
+
### Replay determinism check
|
|
517
|
+
|
|
518
|
+
```ruby
|
|
519
|
+
it "projects deterministically" do
|
|
520
|
+
# ... emit some events ...
|
|
521
|
+
ensure_replay_deterministic { Order.all.pluck(:id, :status) }
|
|
522
|
+
end
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Catches the common projection bugs (Time.current, rand, external API
|
|
526
|
+
calls) better than code review ever will.
|
|
527
|
+
|
|
528
|
+
## Observability
|
|
529
|
+
|
|
530
|
+
Hook into `ActiveSupport::Notifications` for metrics, tracing, and
|
|
531
|
+
request correlation:
|
|
532
|
+
|
|
533
|
+
- `acta.event_emitted` — `{ event, event_type }`
|
|
534
|
+
- `acta.projection_applied` — `{ event, projection_class }`
|
|
535
|
+
- `acta.reactor_invoked` — `{ event, reactor_class, sync: true }`
|
|
536
|
+
- `acta.reactor_enqueued` — `{ event, reactor_class }`
|
|
537
|
+
|
|
538
|
+
## Development
|
|
539
|
+
|
|
540
|
+
```bash
|
|
541
|
+
bin/setup # install dependencies
|
|
542
|
+
bundle exec rspec # run the test suite (SQLite + Postgres if available)
|
|
543
|
+
bundle exec rake # tests + rubocop
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
The Postgres adapter tests run if a local Postgres instance is reachable.
|
|
547
|
+
Configure via environment variables:
|
|
548
|
+
|
|
549
|
+
```
|
|
550
|
+
ACTA_PG_DATABASE=acta_test
|
|
551
|
+
ACTA_PG_HOST=localhost
|
|
552
|
+
ACTA_PG_PORT=5432
|
|
553
|
+
ACTA_PG_USER=$USER
|
|
554
|
+
ACTA_PG_PASSWORD=
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
## License
|
|
558
|
+
|
|
559
|
+
MIT. See [LICENSE](LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "acta/web/events_query"
|
|
4
|
+
|
|
5
|
+
module Acta
|
|
6
|
+
module Web
|
|
7
|
+
class EventsController < ApplicationController
|
|
8
|
+
PER_PAGE = 40
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@base_count = Acta::Record.count
|
|
12
|
+
|
|
13
|
+
@facet_event_type = Acta::Record.group(:event_type).count.sort_by { |_, n| -n }.to_h
|
|
14
|
+
@facet_stream_type = Acta::Record.group(:stream_type).count.sort_by { |_, n| -n }.to_h
|
|
15
|
+
@facet_actor_id = Acta::Record.group(:actor_id).count.sort_by { |_, n| -n }.to_h
|
|
16
|
+
|
|
17
|
+
query = Acta::Web::EventsQuery.new(params)
|
|
18
|
+
@events_scope = query.scope
|
|
19
|
+
@filtered_count = @events_scope.count
|
|
20
|
+
|
|
21
|
+
@page = [ params[:page].to_i, 0 ].max
|
|
22
|
+
@total_pages = [ (@filtered_count / PER_PAGE.to_f).ceil, 1 ].max
|
|
23
|
+
@page = [ @page, @total_pages - 1 ].min
|
|
24
|
+
|
|
25
|
+
@events = @events_scope.order(id: :desc).offset(@page * PER_PAGE).limit(PER_PAGE)
|
|
26
|
+
|
|
27
|
+
@selected_event = Acta::Record.find_by(uuid: params[:selected]) if params[:selected].present?
|
|
28
|
+
|
|
29
|
+
@active_filters = query.active_filters
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show
|
|
33
|
+
@event = Acta::Record.find_by!(uuid: params[:id])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|