acta 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01a8eb1c4e0fb04d54ed2c8302c45f0d38064f07839d3b030a771ce70d03d27c
4
- data.tar.gz: b34f32a1dc253ae7402d6c7a67f17c2a650d369b9e2d6036979f94da2e4b8820
3
+ metadata.gz: 9c2218e4d40ef364d3dba8452e489a06f3e6d0503726e4d95660f7637e00efdc
4
+ data.tar.gz: a3602d1b12e575f44f3f0bf4acdad256d5bd25a9ede5f43688f869015b96abe8
5
5
  SHA512:
6
- metadata.gz: bf7c4fb886f607687bc5a274627f4c0580dc097b4096f5fb5d36f34d91dd6f05a0a2ea69e49a132b13b896ce8b61f0f6729792577543d0132d00565beacd0528
7
- data.tar.gz: 157f9f789ad5d6387a6a71ee0c97754ec69f73a09b638cb7c20f6b68c6836637359af024b1765ffd05236c9908d0f83df6ac450b475827fdc7da7eaed0beef1f
6
+ metadata.gz: ac176442ad27e359358e7f59f585facf1ee2dc6c11902737e9da2e1f7b7b354d14ce40aed1bd80e9e4ad12a04f2f5f8d0b9d548c9077fdc58c0debf0f755ac66
7
+ data.tar.gz: 5de77801dd8088ae0f3699dae67388db5db0688f3de840c31938073ef5bd57f843f543b2c8584633a4a637cdf41a6f359bec65c9d59c5622228fc80783dba691
data/CHANGELOG.md CHANGED
@@ -10,6 +10,50 @@ breaking changes as the API settles through real-world consumer integration.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.3.0] — 2026-04-28
14
+
15
+ ### Added
16
+
17
+ - Reactor `queue_as` macro and `Acta.reactor_queue` global default for
18
+ pinning the ActiveJob queue async reactors land on. Per-class
19
+ declarations beat the global default; with neither set, ActiveJob's
20
+ `:default` queue is used. `sync!` reactors are unaffected. Closes #38.
21
+
22
+ - `Acta::Testing.projection_writes_helper!(config)` adds a
23
+ `with_projection_writes` block helper to every RSpec example. Forwards
24
+ to `Acta::Projection.applying!` so blocks under it pass the
25
+ `acta_managed!` write guard — useful for fixtures, factories, and
26
+ one-off setup. Closes #33.
27
+
28
+ - README now covers per-emit atomicity, what `applying!` actually does
29
+ (write-path safety net, *not* transaction control), and `rebuild!`
30
+ partial-failure behavior. Closes #34.
31
+
32
+ ### Changed
33
+
34
+ - Custom AM type classes consolidated under `Acta::Types::*`.
35
+ `Acta::ModelType` → `Acta::Types::Model`,
36
+ `Acta::ArrayType` → `Acta::Types::Array`. The `:encrypted_string`
37
+ type was already there as `Acta::Types::EncryptedString`. The
38
+ user-facing API (`attribute :foo, Class`, `attribute :foo,
39
+ array_of: ...`, `attribute :foo, :encrypted_string`) is unchanged
40
+ — these classes are internal and never appeared in user code,
41
+ README, or specs. Breaking only for someone constructing them
42
+ directly via `Acta::ModelType.new(...)`.
43
+
44
+ - Lowered Ruby/Rails compat floor: `ruby >= 3.2`, `rails >= 7.2`
45
+ (was 3.4 / 8.1). CI now exercises Ruby 3.2/3.3/3.4 × Rails
46
+ 7.2/8.0/8.1. The prior floor reflected "latest at the time" rather
47
+ than actual API requirements; full spec suite passes against all
48
+ three Rails versions. Closes #37.
49
+
50
+ ### Fixed
51
+
52
+ - Install generator now honors `--database=foo` and writes the migration
53
+ to the target database's `migrations_paths` (typically
54
+ `db/foo_migrate/`) instead of the default `db/migrate/`. Matches AR's
55
+ built-in migration generator behavior. Closes #29.
56
+
13
57
  ## [0.2.0] — 2026-04-27
14
58
 
15
59
  ### Added
data/README.md CHANGED
@@ -27,14 +27,14 @@ Adapters: SQLite and Postgres, both first-class.
27
27
 
28
28
  ## Installation
29
29
 
30
- Not published to RubyGems. Install from git:
31
-
32
30
  ```ruby
33
31
  # Gemfile
34
- gem "acta", git: "https://github.com/whoojemaflip/acta.git"
32
+ gem "acta"
35
33
  ```
36
34
 
37
- Requires Rails 8.1+ and Ruby 3.4+.
35
+ Tested against Ruby 3.2, 3.3, 3.4 and Rails 7.2, 8.0, 8.1. Pre-1.0
36
+ the API is still settling through real-world consumer integration.
37
+ Pin a minor (`"~> 0.2"`) if you need stability across `bundle update`.
38
38
 
39
39
  Generate the events table migration:
40
40
 
@@ -43,8 +43,22 @@ bin/rails generate acta:install
43
43
  bin/rails db:migrate
44
44
  ```
45
45
 
46
+ For multi-database apps, target a specific database with `--database`:
47
+
48
+ ```bash
49
+ bin/rails generate acta:install --database=events
50
+ bin/rails db:migrate:events
51
+ ```
52
+
53
+ The migration is written to that database's `migrations_paths`
54
+ (typically `db/<database>_migrate/`).
55
+
46
56
  ## Usage
47
57
 
58
+ The five sections below introduce the primitives in isolation. For
59
+ end-to-end walkthroughs of specific scenarios, see the
60
+ [cookbook](docs/README.md).
61
+
48
62
  ### 1. Define an event
49
63
 
50
64
  ```ruby
@@ -98,6 +112,28 @@ end
98
112
  Reactors run after-commit and default to async via ActiveJob. Use `sync!`
99
113
  to run in the caller's thread (mostly useful for tests).
100
114
 
115
+ Pin a specific ActiveJob queue per class with `queue_as`:
116
+
117
+ ```ruby
118
+ class ConfirmationEmailReactor < Acta::Reactor
119
+ queue_as :fast
120
+ on OrderPlaced do |event|
121
+ OrderMailer.confirmation(event.order_id).deliver_later
122
+ end
123
+ end
124
+ ```
125
+
126
+ Or set a global default for every reactor that doesn't declare its own:
127
+
128
+ ```ruby
129
+ # config/initializers/acta.rb
130
+ Acta.reactor_queue = :fast
131
+ ```
132
+
133
+ Per-class declarations beat the global default; with neither set,
134
+ ActiveJob's `:default` queue is used. `sync!` reactors bypass ActiveJob
135
+ entirely, so the queue setting is ignored for them.
136
+
101
137
  ### 4. Project state (event-sourced)
102
138
 
103
139
  For aggregates where the event log is the source of truth and AR tables
@@ -210,41 +246,142 @@ Test fixtures, data migrations, and one-off backfills can wrap
210
246
  intentional out-of-band writes in `Acta::Projection.applying! { ... }`
211
247
  to bypass the safety net explicitly.
212
248
 
249
+ #### Atomicity and replay
250
+
251
+ Three related semantics that are easy to conflate:
252
+
253
+ **Per-emit atomicity.** `Acta.emit` opens a `requires_new: true`
254
+ transaction that wraps both the event row insert and every projection's
255
+ `on EventClass` block. If any projection raises, the entire emit rolls
256
+ back: the event row isn't persisted, other projections' writes don't
257
+ commit, and async reactors never fan out (they're enqueued only on
258
+ successful commit). Sync reactors also run inside this transaction —
259
+ but their side effects (mailers sent, HTTP calls made) can't be undone
260
+ by a rollback if they've already happened, which is why `sync!` should
261
+ be reserved for follow-up DB writes or cases where "fired but rolled
262
+ back" is acceptable.
263
+
264
+ **`Acta::Projection.applying!` is not the transaction.** It's a separate
265
+ concept: a thread-local flag that gates `acta_managed!` writes,
266
+ distinguishing "projection code is running" from "someone called
267
+ `Order.create!` from a controller." Acta sets the flag automatically
268
+ inside projection blocks and during the truncate phase of `rebuild!`.
269
+ Apps set it explicitly with `Acta::Projection.applying! { ... }` to
270
+ bypass the `acta_managed!` guard for fixtures, migrations, and one-off
271
+ backfills. Toggling the flag does *not* open or join a transaction —
272
+ the per-emit transaction does that work, and `rebuild!` uses one
273
+ implicit transaction per `delete_all` plus one per replayed event.
274
+
275
+ **`Acta.rebuild!` partial failure.** Rebuild truncates every projected
276
+ table first (inside one `applying!` block, in FK-safe order), then
277
+ replays the log event-by-event through projections. If an event raises
278
+ mid-replay, the truncate has already happened and the rebuild halts at
279
+ that event — projected tables are in a *partially-rebuilt* state, not
280
+ the pre-rebuild state and not the fully-replayed state. Treat any
281
+ rebuild failure as needing investigation; once the underlying
282
+ projection bug is fixed, re-running `rebuild!` re-truncates and starts
283
+ over from the beginning of the log. There is no resume-from-event-N
284
+ mode.
285
+
213
286
  ### 5. Commands for validated writes
214
287
 
215
288
  ```ruby
216
289
  # app/commands/place_order.rb
217
290
  class PlaceOrder < Acta::Command
291
+ param :order_id, :string
218
292
  param :customer_id, :string
219
293
  param :total_cents, :integer
220
294
 
221
- validates :customer_id, :total_cents, presence: true
295
+ validates :order_id, :customer_id, :total_cents, presence: true
222
296
  validates :total_cents, numericality: { greater_than: 0 }
223
297
 
224
298
  def call
225
- order_id = "order_#{SecureRandom.uuid}"
226
299
  emit OrderPlaced.new(order_id:, customer_id:, total_cents:)
227
300
  end
228
301
  end
229
302
 
303
+ order_id = "order_#{SecureRandom.uuid_v7}"
304
+ PlaceOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
305
+ # `order_id` is in scope — use it for the redirect, response, etc.
306
+ ```
307
+
308
+ The default pattern is **caller generates the ID, command takes it as a
309
+ param**. Simplest possible thing — the ID is in scope at the call site,
310
+ the command is a thin validate-and-emit shell, and there's no question
311
+ about what `.call` returns.
312
+
313
+ #### What `Command.call` returns
314
+
315
+ `Acta::Command.call` returns the **command instance**. The instance
316
+ carries the params, an `emitted_events` array (every event emitted
317
+ during `#call`, in order), and any state the command exposed via
318
+ `attr_reader`. **The return value of `#call` is discarded** — see the
319
+ pitfall below.
320
+
321
+ Most callers ignore the return value:
322
+
323
+ ```ruby
324
+ PlaceOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
325
+ ```
326
+
327
+ #### When the command should generate the ID
328
+
329
+ If the ID prefix or shape is a domain concern that belongs with the
330
+ command (and the caller would always do the same thing if you forced
331
+ it to generate), expose the generated ID via `attr_reader`:
332
+
333
+ ```ruby
334
+ class PlaceOrder < Acta::Command
335
+ attr_reader :order_id
336
+
337
+ param :customer_id, :string
338
+ param :total_cents, :integer
339
+
340
+ def call
341
+ @order_id = "order_#{SecureRandom.uuid_v7}"
342
+ emit OrderPlaced.new(order_id: @order_id, customer_id:, total_cents:)
343
+ end
344
+ end
345
+
230
346
  cmd = PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
231
- cmd.emitted_events.first.order_id # => "order_…"
347
+ cmd.order_id # => "order_018f2…"
232
348
  ```
233
349
 
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:
350
+ This reads naturally at the call site `cmd.order_id`, not
351
+ `cmd.emitted_events.first.order_id` and gives the value a stable,
352
+ semantic name regardless of how many events the command emits or in
353
+ what order.
354
+
355
+ #### Inspecting the events
356
+
357
+ When you genuinely need the event objects (for further dispatch,
358
+ logging, or because the command emits multiple events with no single
359
+ "primary" one), read `emitted_events`:
239
360
 
240
361
  ```ruby
241
- PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
362
+ cmd = PlaceOrder.call(...)
363
+ cmd.emitted_events # => [#<OrderPlaced ...>]
364
+ cmd.emitted_events.first # the only event in this case
242
365
  ```
243
366
 
244
367
  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`.
368
+ invent a "primary" event — when there are several, the caller (who
369
+ knows the domain) picks what matters from `emitted_events`.
370
+
371
+ #### Pitfall: don't `return` from `#call`
372
+
373
+ ```ruby
374
+ def call
375
+ order_id = "order_#{SecureRandom.uuid_v7}"
376
+ emit OrderPlaced.new(order_id:, …)
377
+ order_id # ← this is silently discarded by Acta::Command.call
378
+ end
379
+ ```
380
+
381
+ A trailing expression in `#call` looks like the obvious way to surface
382
+ the generated ID, but `Command.call` always returns the command
383
+ instance — the body's return value is dropped. Use `attr_reader` (or
384
+ let the caller pass the ID in) instead.
248
385
 
249
386
  ### Optimistic locking (high-water mark)
250
387
 
@@ -273,24 +410,55 @@ fresh state or surface the collision instead of silently clobbering it.
273
410
  don't need this; reach for it when concurrent writes to the same
274
411
  aggregate are realistic and lost-update would be a bug.
275
412
 
276
- ## Identity: generate IDs in commands, never in projections
413
+ ## Identity: IDs originate at the write boundary, never in projections
277
414
 
278
415
  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**.
416
+ stable across `Acta.rebuild!` and must not drift if the projected
417
+ tables are truncated. The rule: **the ID is generated once at the
418
+ write boundary, the event carries it in its payload, and the
419
+ projection reads it back out**.
420
+
421
+ "Write boundary" means either the caller of the command, or the
422
+ command itself — whichever owns the ID's shape. Both are correct;
423
+ pick by where the prefix/format convention lives.
282
424
 
283
425
  ```ruby
426
+ # Pattern A — caller generates (default; what you'll usually want):
284
427
  class CreateOrder < Acta::Command
428
+ param :order_id, :string
285
429
  param :customer_id, :string
286
430
  param :total_cents, :integer
287
431
 
288
432
  def call
289
- order_id = "order_#{SecureRandom.uuid}" # generated here, once, forever
290
433
  emit OrderCreated.new(order_id:, customer_id:, total_cents:)
291
434
  end
292
435
  end
293
436
 
437
+ order_id = "order_#{SecureRandom.uuid_v7}"
438
+ CreateOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
439
+
440
+ # Pattern B — command generates (when the prefix/shape is a domain
441
+ # concern of the command itself):
442
+ class CreateOrder < Acta::Command
443
+ attr_reader :order_id
444
+
445
+ param :customer_id, :string
446
+ param :total_cents, :integer
447
+
448
+ def call
449
+ @order_id = "order_#{SecureRandom.uuid_v7}"
450
+ emit OrderCreated.new(order_id: @order_id, customer_id:, total_cents:)
451
+ end
452
+ end
453
+
454
+ cmd = CreateOrder.call(customer_id: "c_1", total_cents: 4200)
455
+ cmd.order_id # => "order_018f2…"
456
+ ```
457
+
458
+ Either way, the event carries `order_id` in its payload, and the
459
+ projection reads it back:
460
+
461
+ ```ruby
294
462
  class OrderCreated < Acta::Event
295
463
  stream :order, key: :order_id
296
464
  attribute :order_id, :string
@@ -305,25 +473,27 @@ class OrderProjection < Acta::Projection
305
473
  end
306
474
  ```
307
475
 
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.**
476
+ When `Acta.rebuild!` runs, it calls `OrderProjection.truncate!`
477
+ (wiping the `orders` table) and replays every event. The projection
478
+ reads `event.order_id` — which was written at the original write
479
+ and re-inserts the row with the same ID. **Rebuild never regenerates
480
+ IDs.**
312
481
 
313
482
  ### What to avoid
314
483
 
315
484
  - **Generating IDs in projection code.** Non-deterministic — every
316
485
  rebuild produces new IDs, orphaning any foreign references.
317
- `SecureRandom` / `Time.current` / anything stateful has no place in a
318
- projection.
486
+ `SecureRandom` / `Time.current` / anything stateful has no place in
487
+ a projection.
319
488
  - **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.
489
+ if the event assigns a default ID when reconstructed from a row,
490
+ old events would decode with fresh IDs. Events should take an
491
+ explicit `order_id:` attribute and require it in the payload.
323
492
  - **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.
493
+ of IDs. Purging it regenerates all IDs on next write. Back it up
494
+ and treat it as production-critical — even more so if other
495
+ systems (a separate user DB, external services) reference your
496
+ aggregates' IDs.
327
497
 
328
498
  ### Why this matters
329
499
 
@@ -481,6 +651,31 @@ end
481
651
  `with_actor` restores the surrounding actor when the block returns or
482
652
  raises.
483
653
 
654
+ ### Writing to acta_managed! models in setup
655
+
656
+ Tests that need to seed `acta_managed!` AR models directly — without
657
+ going through a command + event + projection chain — would otherwise
658
+ trip `Acta::ProjectionWriteError`. Pull in the `with_projection_writes`
659
+ helper:
660
+
661
+ ```ruby
662
+ RSpec.configure do |config|
663
+ Acta::Testing.projection_writes_helper!(config)
664
+ end
665
+
666
+ # in any spec:
667
+ with_projection_writes do
668
+ zone = Zone.create!(name: "Cheakamus")
669
+ Trail.create!(zone:, name: "Crank It Up")
670
+ end
671
+ ```
672
+
673
+ The helper forwards to `Acta::Projection.applying!`, which is the same
674
+ flag projections use internally — so writes inside the block pass the
675
+ guard. Outside the block, the guard is back in force. Use this for
676
+ factories, fixtures, and one-off setup; for production code paths, emit
677
+ events instead.
678
+
484
679
  ### RSpec matchers
485
680
 
486
681
  ```ruby
data/RELEASING.md ADDED
@@ -0,0 +1,107 @@
1
+ # Releasing Acta
2
+
3
+ Releases publish to [rubygems.org](https://rubygems.org/gems/acta) via
4
+ GitHub Actions Trusted Publishing (OIDC) — no long-lived API key in
5
+ CI, no `gem push` from a laptop. The pipeline is:
6
+
7
+ ```
8
+ git tag vX.Y.Z → push tag → .github/workflows/release.yml
9
+ ├── runs the test suite as a gate
10
+ └── if green: rubygems mints an OIDC token
11
+ and publishes acta-X.Y.Z.gem
12
+ ```
13
+
14
+ ## Cutting a release
15
+
16
+ 1. **Update `CHANGELOG.md`.** Move entries under `[Unreleased]` to a
17
+ new `[X.Y.Z] — YYYY-MM-DD` section. Leave `[Unreleased]` empty above it.
18
+
19
+ 2. **Bump the version.** Edit `lib/acta/version.rb` to `VERSION = "X.Y.Z"`.
20
+
21
+ 3. **Run the suite locally.** `bundle exec rake` — tests + rubocop.
22
+ Don't tag a red main.
23
+
24
+ 4. **Commit, tag, push.**
25
+ ```bash
26
+ git commit -am "Release X.Y.Z"
27
+ git tag -a vX.Y.Z -m "Release X.Y.Z"
28
+ git push origin main vX.Y.Z
29
+ ```
30
+
31
+ 5. **Watch the workflow.** Actions tab → Release → in-flight run.
32
+ Takes ~2 min. If it goes red, fix forward: revert the version bump
33
+ commit, push that, then re-do steps 1-4 with the next patch number
34
+ (X.Y.Z+1) — tags are immutable once a release has used them, even
35
+ if the publish failed.
36
+
37
+ 6. **Verify.** Once green:
38
+ - https://rubygems.org/gems/acta should show the new version
39
+ - `gem info acta -r` from any machine should resolve it
40
+ - The Releases page on GitHub should have the tag entry (the workflow
41
+ creates it from `release-gem@v1`)
42
+
43
+ ## Choosing the version number
44
+
45
+ Pre-1.0, breaking changes are allowed in minor bumps. The convention
46
+ this project uses:
47
+
48
+ - **Patch (0.2.0 → 0.2.1)** — bug fixes, doc updates, internal
49
+ refactors that don't change behavior
50
+ - **Minor (0.2.x → 0.3.0)** — new features, deprecations, breaking
51
+ changes
52
+ - **Major (0.x → 1.0)** — only when the API has settled enough to
53
+ promise stability
54
+
55
+ Consumers are expected to pin `~> 0.2` (or whatever minor) until 1.0.
56
+
57
+ ## One-time setup (already done — kept for posterity)
58
+
59
+ If this ever needs to be redone (e.g. moving to a new GitHub repo or
60
+ rubygems account):
61
+
62
+ 1. **rubygems.org account** — `tom@gladhill.ca`, MFA enabled.
63
+
64
+ 2. **Trusted Publisher entry** — rubygems.org → Profile → Trusted
65
+ Publishers → Add a publisher:
66
+
67
+ | Field | Value |
68
+ |---|---|
69
+ | Repository | `whoojemaflip/acta` |
70
+ | Workflow filename | `release.yml` |
71
+ | Environment name | `rubygems` |
72
+
73
+ 3. **GitHub Actions environment** — Repo Settings → Environments →
74
+ `rubygems`. The workflow's `release` job is gated on it via
75
+ `environment: rubygems`.
76
+
77
+ 4. **Gemspec hardening** — `acta.gemspec` already sets
78
+ `rubygems_mfa_required = "true"`. This means any future manual
79
+ `gem push` (bypassing the workflow) requires an MFA-authenticated
80
+ API key. Trusted Publishing isn't affected — OIDC bypasses API
81
+ keys entirely.
82
+
83
+ ## Yanking a bad release
84
+
85
+ If a published version turns out to be broken:
86
+
87
+ ```bash
88
+ gem yank acta -v X.Y.Z
89
+ ```
90
+
91
+ Yanking removes the version from the index — `gem install acta -v X.Y.Z`
92
+ will fail, but anyone who already locked it in a Gemfile.lock can
93
+ still resolve it. So yank is for "stop the bleeding," not a do-over.
94
+ The fix-forward pattern is to ship X.Y.Z+1 with the correction.
95
+
96
+ ## Manual fallback (don't use unless the workflow is broken)
97
+
98
+ The keys to do this are not configured by default. If you need to
99
+ emergency-publish from a laptop:
100
+
101
+ ```bash
102
+ gem signin # interactive — needs MFA
103
+ gem build acta.gemspec
104
+ gem push acta-X.Y.Z.gem
105
+ ```
106
+
107
+ Document why the workflow couldn't be used in the commit message.
data/docs/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # Acta cookbook
2
+
3
+ Concrete walkthroughs for the shapes Acta apps actually take. The
4
+ main [README](../README.md) documents primitives in isolation; this
5
+ folder shows how they compose for specific scenarios, with
6
+ end-to-end code and the trade-offs that come with each choice.
7
+
8
+ ## Patterns
9
+
10
+ - [**Event-driven pub/sub**](event_driven_pub_sub.md) — the simplest
11
+ useful Acta shape. One domain event, multiple independent
12
+ subscribers, no event sourcing. AR records remain the source of
13
+ truth; the events table is a free audit log. Compares against AR
14
+ callbacks and `ActiveSupport::Notifications`.
15
+
16
+ ## Patterns coming later
17
+
18
+ Recipes will land here when these are written or implemented:
19
+
20
+ - **Event-sourced aggregates** — projections as the AR view of the
21
+ log; `Acta.rebuild!` as the source-of-truth recovery path. The
22
+ primitive is already documented in the README, but a worked
23
+ example of a full aggregate (command + event + projection +
24
+ replay determinism spec) earns its own page.
25
+ - **Process managers (saga)** — coordinating multi-step workflows
26
+ where one event triggers a wait-then-act sequence. Primitive
27
+ tracked in [#27](https://github.com/whoojemaflip/acta/issues/27).
28
+ - **Schema evolution with upcasters** — adding, renaming, or
29
+ retiring event attributes without leaving stale rows
30
+ un-deserializable. Primitive tracked in
31
+ [#25](https://github.com/whoojemaflip/acta/issues/25).