acta 0.2.0 → 0.4.0.alpha.1

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: 0af752d227a9c1f376f4e1201bd2ba2b60faf5d178a1db06975d8bdaec0c66ef
4
+ data.tar.gz: 417ec6f02d8348355bccf44d0682a534d8e3dc5f7a9d6364b5a3d31326e04e76
5
5
  SHA512:
6
- metadata.gz: bf7c4fb886f607687bc5a274627f4c0580dc097b4096f5fb5d36f34d91dd6f05a0a2ea69e49a132b13b896ce8b61f0f6729792577543d0132d00565beacd0528
7
- data.tar.gz: 157f9f789ad5d6387a6a71ee0c97754ec69f73a09b638cb7c20f6b68c6836637359af024b1765ffd05236c9908d0f83df6ac450b475827fdc7da7eaed0beef1f
6
+ metadata.gz: 112a12b4dac2b676183237c2d73df829d9d0f6e3e4519be94fb25844d1dce7cd890a8de8a758d0e469b6eaf5b2454cf5c033e139a22785e82ae0b158396148fb
7
+ data.tar.gz: 4ca8da1823ee930a0a3ebdc3b70d47b108d53e7e05c2328ce58976fc09fa65d9eb256520fdf376cd5da03099f70bd9b02858e7ccd32e429b62ca62939d802aa5
data/CHANGELOG.md CHANGED
@@ -10,6 +10,122 @@ breaking changes as the API settles through real-world consumer integration.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.0.alpha.1] — 2026-05-22
14
+
15
+ Prerelease intended for Scaff dogfooding against real prod tenant
16
+ data ahead of the Workspaces schema migration. Promote to `0.4.0`
17
+ once integration is green.
18
+
19
+ ### Added
20
+
21
+ - `Acta::Upcaster` — replay-time event transformation for apps whose
22
+ schemas evolve. Apps declare `upcasts(event_type, from:, to:) { ... }`
23
+ blocks on a module that `include Acta::Upcaster`, register it with
24
+ `Acta.register_upcaster(Klass)`, and bump the relevant
25
+ `Acta::Event.event_version`. On every read path
26
+ (`Acta.rebuild!`, `ReactorJob#perform`, the events admin, test
27
+ fixtures) the pipeline walks records pre-hydration through any
28
+ matching upcasters, so projections see the latest shape without
29
+ the stored rows ever being mutated.
30
+ - Supported transform shapes: 1-to-1 chaining across N versions,
31
+ 1-to-many fan-out (each branch chains independently),
32
+ drop-on-replay (`nil` / `[]`), explicit `context.fail_replay!`,
33
+ and `Acta::Upcaster::NO_OP` as a terminal pass-through. Stateful
34
+ transforms read/write a per-replay `Acta::Upcaster::Context`.
35
+ - `Acta::EventsQuery#all` / `#each` now iterate the scope through
36
+ the upcaster pipeline with a single shared `Context` across the
37
+ full pass, matching `Acta.rebuild!` semantics. Single-record
38
+ lookups (`find_by_uuid`, `first`, `last`) deliberately use a
39
+ fresh context — there's no prior history to seed it with — and
40
+ may produce incomplete output for stateful upcasters. The web
41
+ admin shows raw stored rows, sidestepping the question.
42
+ `docs/upcasters.md` carries the read-surface table.
43
+ - `Acta::ReplayHaltedByUpcaster`, `Acta::UpcasterRegistryError`,
44
+ `Acta::FutureSchemaVersion` for the corresponding failure modes.
45
+ - Testing helpers `Acta::Testing::DSL#acta_seed_event` (insert a
46
+ row at an arbitrary `event_version`, bypassing `Acta.emit`) and
47
+ `#acta_replay(events:, upcasters:)` (seed + register + rebuild
48
+ in one call).
49
+ - `docs/upcasters.md` cookbook entry covering renames, fan-outs,
50
+ drops, stateful context, the mid-deploy reactor edge case, and
51
+ test patterns.
52
+
53
+ No schema migration: the existing `event_version` column carries
54
+ upcaster fence semantics. Apps without upcasters see no behavior
55
+ change — the pipeline is a one-method-call identity pass.
56
+
57
+ ## [0.3.2] — 2026-05-11
58
+
59
+ ### Added
60
+
61
+ - `Acta.set_events_record_parent!(klass)` lets a host re-parent
62
+ `Acta::EventsRecord` (and therefore `Acta::Record`) onto a
63
+ custom abstract base. The use case is per-tenant SQLite
64
+ sharding: when the host's tenant-scoped abstract class and
65
+ `Acta::EventsRecord` are independent, Rails 8 multi-DB gives
66
+ them separate connection pools, which trips SQLite write
67
+ contention on cross-pool transactions to the same file. Sharing
68
+ the pool by sharing the parent class fixes this.
69
+ Backwards-compatible — apps that don't call the new method
70
+ see no change.
71
+
72
+ ## [0.3.1] — 2026-05-11
73
+
74
+ ### Added
75
+
76
+ - `Acta::EventsRecord` abstract base. `Acta::Record` now inherits
77
+ from it so hosts can call `connects_to` (database/role or shards)
78
+ on `Acta::EventsRecord` to route the events table to a specific
79
+ connection. Calling `connects_to` directly on `Acta::Record` was
80
+ rejected by ActiveRecord because the class is concrete (has
81
+ `table_name = "events"` set); the abstract intermediate is the
82
+ idiomatic Rails seam. Backwards-compatible — existing apps that
83
+ don't reopen `EventsRecord` see no change.
84
+
85
+ ## [0.3.0] — 2026-04-28
86
+
87
+ ### Added
88
+
89
+ - Reactor `queue_as` macro and `Acta.reactor_queue` global default for
90
+ pinning the ActiveJob queue async reactors land on. Per-class
91
+ declarations beat the global default; with neither set, ActiveJob's
92
+ `:default` queue is used. `sync!` reactors are unaffected. Closes #38.
93
+
94
+ - `Acta::Testing.projection_writes_helper!(config)` adds a
95
+ `with_projection_writes` block helper to every RSpec example. Forwards
96
+ to `Acta::Projection.applying!` so blocks under it pass the
97
+ `acta_managed!` write guard — useful for fixtures, factories, and
98
+ one-off setup. Closes #33.
99
+
100
+ - README now covers per-emit atomicity, what `applying!` actually does
101
+ (write-path safety net, *not* transaction control), and `rebuild!`
102
+ partial-failure behavior. Closes #34.
103
+
104
+ ### Changed
105
+
106
+ - Custom AM type classes consolidated under `Acta::Types::*`.
107
+ `Acta::ModelType` → `Acta::Types::Model`,
108
+ `Acta::ArrayType` → `Acta::Types::Array`. The `:encrypted_string`
109
+ type was already there as `Acta::Types::EncryptedString`. The
110
+ user-facing API (`attribute :foo, Class`, `attribute :foo,
111
+ array_of: ...`, `attribute :foo, :encrypted_string`) is unchanged
112
+ — these classes are internal and never appeared in user code,
113
+ README, or specs. Breaking only for someone constructing them
114
+ directly via `Acta::ModelType.new(...)`.
115
+
116
+ - Lowered Ruby/Rails compat floor: `ruby >= 3.2`, `rails >= 7.2`
117
+ (was 3.4 / 8.1). CI now exercises Ruby 3.2/3.3/3.4 × Rails
118
+ 7.2/8.0/8.1. The prior floor reflected "latest at the time" rather
119
+ than actual API requirements; full spec suite passes against all
120
+ three Rails versions. Closes #37.
121
+
122
+ ### Fixed
123
+
124
+ - Install generator now honors `--database=foo` and writes the migration
125
+ to the target database's `migrations_paths` (typically
126
+ `db/foo_migrate/`) instead of the default `db/migrate/`. Matches AR's
127
+ built-in migration generator behavior. Closes #29.
128
+
13
129
  ## [0.2.0] — 2026-04-27
14
130
 
15
131
  ### Added
data/README.md CHANGED
@@ -21,20 +21,21 @@ What the library ships:
21
21
  | `Acta::Projection` | Sync + transactional + replayable (for ES aggregates) |
22
22
  | `Acta::Reactor` | After-commit + async via ActiveJob (for side effects) |
23
23
  | `Acta::Command` | Recommended write path with param validation & optimistic concurrency |
24
+ | `Acta::Upcaster` | Replay-time transforms for events whose shape changed between schema versions |
24
25
  | `Acta::Testing` | RSpec matchers, given-when-then DSL, replay-determinism assertions |
25
26
 
26
27
  Adapters: SQLite and Postgres, both first-class.
27
28
 
28
29
  ## Installation
29
30
 
30
- Not published to RubyGems. Install from git:
31
-
32
31
  ```ruby
33
32
  # Gemfile
34
- gem "acta", git: "https://github.com/whoojemaflip/acta.git"
33
+ gem "acta"
35
34
  ```
36
35
 
37
- Requires Rails 8.1+ and Ruby 3.4+.
36
+ Tested against Ruby 3.2, 3.3, 3.4 and Rails 7.2, 8.0, 8.1. Pre-1.0
37
+ the API is still settling through real-world consumer integration.
38
+ Pin a minor (`"~> 0.2"`) if you need stability across `bundle update`.
38
39
 
39
40
  Generate the events table migration:
40
41
 
@@ -43,8 +44,22 @@ bin/rails generate acta:install
43
44
  bin/rails db:migrate
44
45
  ```
45
46
 
47
+ For multi-database apps, target a specific database with `--database`:
48
+
49
+ ```bash
50
+ bin/rails generate acta:install --database=events
51
+ bin/rails db:migrate:events
52
+ ```
53
+
54
+ The migration is written to that database's `migrations_paths`
55
+ (typically `db/<database>_migrate/`).
56
+
46
57
  ## Usage
47
58
 
59
+ The five sections below introduce the primitives in isolation. For
60
+ end-to-end walkthroughs of specific scenarios, see the
61
+ [cookbook](docs/README.md).
62
+
48
63
  ### 1. Define an event
49
64
 
50
65
  ```ruby
@@ -98,6 +113,28 @@ end
98
113
  Reactors run after-commit and default to async via ActiveJob. Use `sync!`
99
114
  to run in the caller's thread (mostly useful for tests).
100
115
 
116
+ Pin a specific ActiveJob queue per class with `queue_as`:
117
+
118
+ ```ruby
119
+ class ConfirmationEmailReactor < Acta::Reactor
120
+ queue_as :fast
121
+ on OrderPlaced do |event|
122
+ OrderMailer.confirmation(event.order_id).deliver_later
123
+ end
124
+ end
125
+ ```
126
+
127
+ Or set a global default for every reactor that doesn't declare its own:
128
+
129
+ ```ruby
130
+ # config/initializers/acta.rb
131
+ Acta.reactor_queue = :fast
132
+ ```
133
+
134
+ Per-class declarations beat the global default; with neither set,
135
+ ActiveJob's `:default` queue is used. `sync!` reactors bypass ActiveJob
136
+ entirely, so the queue setting is ignored for them.
137
+
101
138
  ### 4. Project state (event-sourced)
102
139
 
103
140
  For aggregates where the event log is the source of truth and AR tables
@@ -210,41 +247,142 @@ Test fixtures, data migrations, and one-off backfills can wrap
210
247
  intentional out-of-band writes in `Acta::Projection.applying! { ... }`
211
248
  to bypass the safety net explicitly.
212
249
 
250
+ #### Atomicity and replay
251
+
252
+ Three related semantics that are easy to conflate:
253
+
254
+ **Per-emit atomicity.** `Acta.emit` opens a `requires_new: true`
255
+ transaction that wraps both the event row insert and every projection's
256
+ `on EventClass` block. If any projection raises, the entire emit rolls
257
+ back: the event row isn't persisted, other projections' writes don't
258
+ commit, and async reactors never fan out (they're enqueued only on
259
+ successful commit). Sync reactors also run inside this transaction —
260
+ but their side effects (mailers sent, HTTP calls made) can't be undone
261
+ by a rollback if they've already happened, which is why `sync!` should
262
+ be reserved for follow-up DB writes or cases where "fired but rolled
263
+ back" is acceptable.
264
+
265
+ **`Acta::Projection.applying!` is not the transaction.** It's a separate
266
+ concept: a thread-local flag that gates `acta_managed!` writes,
267
+ distinguishing "projection code is running" from "someone called
268
+ `Order.create!` from a controller." Acta sets the flag automatically
269
+ inside projection blocks and during the truncate phase of `rebuild!`.
270
+ Apps set it explicitly with `Acta::Projection.applying! { ... }` to
271
+ bypass the `acta_managed!` guard for fixtures, migrations, and one-off
272
+ backfills. Toggling the flag does *not* open or join a transaction —
273
+ the per-emit transaction does that work, and `rebuild!` uses one
274
+ implicit transaction per `delete_all` plus one per replayed event.
275
+
276
+ **`Acta.rebuild!` partial failure.** Rebuild truncates every projected
277
+ table first (inside one `applying!` block, in FK-safe order), then
278
+ replays the log event-by-event through projections. If an event raises
279
+ mid-replay, the truncate has already happened and the rebuild halts at
280
+ that event — projected tables are in a *partially-rebuilt* state, not
281
+ the pre-rebuild state and not the fully-replayed state. Treat any
282
+ rebuild failure as needing investigation; once the underlying
283
+ projection bug is fixed, re-running `rebuild!` re-truncates and starts
284
+ over from the beginning of the log. There is no resume-from-event-N
285
+ mode.
286
+
213
287
  ### 5. Commands for validated writes
214
288
 
215
289
  ```ruby
216
290
  # app/commands/place_order.rb
217
291
  class PlaceOrder < Acta::Command
292
+ param :order_id, :string
218
293
  param :customer_id, :string
219
294
  param :total_cents, :integer
220
295
 
221
- validates :customer_id, :total_cents, presence: true
296
+ validates :order_id, :customer_id, :total_cents, presence: true
222
297
  validates :total_cents, numericality: { greater_than: 0 }
223
298
 
224
299
  def call
225
- order_id = "order_#{SecureRandom.uuid}"
226
300
  emit OrderPlaced.new(order_id:, customer_id:, total_cents:)
227
301
  end
228
302
  end
229
303
 
304
+ order_id = "order_#{SecureRandom.uuid_v7}"
305
+ PlaceOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
306
+ # `order_id` is in scope — use it for the redirect, response, etc.
307
+ ```
308
+
309
+ The default pattern is **caller generates the ID, command takes it as a
310
+ param**. Simplest possible thing — the ID is in scope at the call site,
311
+ the command is a thin validate-and-emit shell, and there's no question
312
+ about what `.call` returns.
313
+
314
+ #### What `Command.call` returns
315
+
316
+ `Acta::Command.call` returns the **command instance**. The instance
317
+ carries the params, an `emitted_events` array (every event emitted
318
+ during `#call`, in order), and any state the command exposed via
319
+ `attr_reader`. **The return value of `#call` is discarded** — see the
320
+ pitfall below.
321
+
322
+ Most callers ignore the return value:
323
+
324
+ ```ruby
325
+ PlaceOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
326
+ ```
327
+
328
+ #### When the command should generate the ID
329
+
330
+ If the ID prefix or shape is a domain concern that belongs with the
331
+ command (and the caller would always do the same thing if you forced
332
+ it to generate), expose the generated ID via `attr_reader`:
333
+
334
+ ```ruby
335
+ class PlaceOrder < Acta::Command
336
+ attr_reader :order_id
337
+
338
+ param :customer_id, :string
339
+ param :total_cents, :integer
340
+
341
+ def call
342
+ @order_id = "order_#{SecureRandom.uuid_v7}"
343
+ emit OrderPlaced.new(order_id: @order_id, customer_id:, total_cents:)
344
+ end
345
+ end
346
+
230
347
  cmd = PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
231
- cmd.emitted_events.first.order_id # => "order_…"
348
+ cmd.order_id # => "order_018f2…"
232
349
  ```
233
350
 
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:
351
+ This reads naturally at the call site `cmd.order_id`, not
352
+ `cmd.emitted_events.first.order_id` and gives the value a stable,
353
+ semantic name regardless of how many events the command emits or in
354
+ what order.
355
+
356
+ #### Inspecting the events
357
+
358
+ When you genuinely need the event objects (for further dispatch,
359
+ logging, or because the command emits multiple events with no single
360
+ "primary" one), read `emitted_events`:
239
361
 
240
362
  ```ruby
241
- PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
363
+ cmd = PlaceOrder.call(...)
364
+ cmd.emitted_events # => [#<OrderPlaced ...>]
365
+ cmd.emitted_events.first # the only event in this case
242
366
  ```
243
367
 
244
368
  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`.
369
+ invent a "primary" event — when there are several, the caller (who
370
+ knows the domain) picks what matters from `emitted_events`.
371
+
372
+ #### Pitfall: don't `return` from `#call`
373
+
374
+ ```ruby
375
+ def call
376
+ order_id = "order_#{SecureRandom.uuid_v7}"
377
+ emit OrderPlaced.new(order_id:, …)
378
+ order_id # ← this is silently discarded by Acta::Command.call
379
+ end
380
+ ```
381
+
382
+ A trailing expression in `#call` looks like the obvious way to surface
383
+ the generated ID, but `Command.call` always returns the command
384
+ instance — the body's return value is dropped. Use `attr_reader` (or
385
+ let the caller pass the ID in) instead.
248
386
 
249
387
  ### Optimistic locking (high-water mark)
250
388
 
@@ -273,24 +411,55 @@ fresh state or surface the collision instead of silently clobbering it.
273
411
  don't need this; reach for it when concurrent writes to the same
274
412
  aggregate are realistic and lost-update would be a bug.
275
413
 
276
- ## Identity: generate IDs in commands, never in projections
414
+ ## Identity: IDs originate at the write boundary, never in projections
277
415
 
278
416
  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**.
417
+ stable across `Acta.rebuild!` and must not drift if the projected
418
+ tables are truncated. The rule: **the ID is generated once at the
419
+ write boundary, the event carries it in its payload, and the
420
+ projection reads it back out**.
421
+
422
+ "Write boundary" means either the caller of the command, or the
423
+ command itself — whichever owns the ID's shape. Both are correct;
424
+ pick by where the prefix/format convention lives.
282
425
 
283
426
  ```ruby
427
+ # Pattern A — caller generates (default; what you'll usually want):
284
428
  class CreateOrder < Acta::Command
429
+ param :order_id, :string
285
430
  param :customer_id, :string
286
431
  param :total_cents, :integer
287
432
 
288
433
  def call
289
- order_id = "order_#{SecureRandom.uuid}" # generated here, once, forever
290
434
  emit OrderCreated.new(order_id:, customer_id:, total_cents:)
291
435
  end
292
436
  end
293
437
 
438
+ order_id = "order_#{SecureRandom.uuid_v7}"
439
+ CreateOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
440
+
441
+ # Pattern B — command generates (when the prefix/shape is a domain
442
+ # concern of the command itself):
443
+ class CreateOrder < Acta::Command
444
+ attr_reader :order_id
445
+
446
+ param :customer_id, :string
447
+ param :total_cents, :integer
448
+
449
+ def call
450
+ @order_id = "order_#{SecureRandom.uuid_v7}"
451
+ emit OrderCreated.new(order_id: @order_id, customer_id:, total_cents:)
452
+ end
453
+ end
454
+
455
+ cmd = CreateOrder.call(customer_id: "c_1", total_cents: 4200)
456
+ cmd.order_id # => "order_018f2…"
457
+ ```
458
+
459
+ Either way, the event carries `order_id` in its payload, and the
460
+ projection reads it back:
461
+
462
+ ```ruby
294
463
  class OrderCreated < Acta::Event
295
464
  stream :order, key: :order_id
296
465
  attribute :order_id, :string
@@ -305,25 +474,27 @@ class OrderProjection < Acta::Projection
305
474
  end
306
475
  ```
307
476
 
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.**
477
+ When `Acta.rebuild!` runs, it calls `OrderProjection.truncate!`
478
+ (wiping the `orders` table) and replays every event. The projection
479
+ reads `event.order_id` — which was written at the original write
480
+ and re-inserts the row with the same ID. **Rebuild never regenerates
481
+ IDs.**
312
482
 
313
483
  ### What to avoid
314
484
 
315
485
  - **Generating IDs in projection code.** Non-deterministic — every
316
486
  rebuild produces new IDs, orphaning any foreign references.
317
- `SecureRandom` / `Time.current` / anything stateful has no place in a
318
- projection.
487
+ `SecureRandom` / `Time.current` / anything stateful has no place in
488
+ a projection.
319
489
  - **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.
490
+ if the event assigns a default ID when reconstructed from a row,
491
+ old events would decode with fresh IDs. Events should take an
492
+ explicit `order_id:` attribute and require it in the payload.
323
493
  - **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.
494
+ of IDs. Purging it regenerates all IDs on next write. Back it up
495
+ and treat it as production-critical — even more so if other
496
+ systems (a separate user DB, external services) reference your
497
+ aggregates' IDs.
327
498
 
328
499
  ### Why this matters
329
500
 
@@ -481,6 +652,31 @@ end
481
652
  `with_actor` restores the surrounding actor when the block returns or
482
653
  raises.
483
654
 
655
+ ### Writing to acta_managed! models in setup
656
+
657
+ Tests that need to seed `acta_managed!` AR models directly — without
658
+ going through a command + event + projection chain — would otherwise
659
+ trip `Acta::ProjectionWriteError`. Pull in the `with_projection_writes`
660
+ helper:
661
+
662
+ ```ruby
663
+ RSpec.configure do |config|
664
+ Acta::Testing.projection_writes_helper!(config)
665
+ end
666
+
667
+ # in any spec:
668
+ with_projection_writes do
669
+ zone = Zone.create!(name: "Cheakamus")
670
+ Trail.create!(zone:, name: "Crank It Up")
671
+ end
672
+ ```
673
+
674
+ The helper forwards to `Acta::Projection.applying!`, which is the same
675
+ flag projections use internally — so writes inside the block pass the
676
+ guard. Outside the block, the guard is back in force. Use this for
677
+ factories, fixtures, and one-off setup; for production code paths, emit
678
+ events instead.
679
+
484
680
  ### RSpec matchers
485
681
 
486
682
  ```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,32 @@
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
+ - [**Schema evolution with upcasters**](upcasters.md) — transform
16
+ old-shape events into the current shape at replay time so
17
+ `Acta.rebuild!` stays faithful across migrations. Covers renames,
18
+ fan-outs, drops, stateful context, and the mid-deploy reactor
19
+ edge case.
20
+
21
+ ## Patterns coming later
22
+
23
+ Recipes will land here when these are written or implemented:
24
+
25
+ - **Event-sourced aggregates** — projections as the AR view of the
26
+ log; `Acta.rebuild!` as the source-of-truth recovery path. The
27
+ primitive is already documented in the README, but a worked
28
+ example of a full aggregate (command + event + projection +
29
+ replay determinism spec) earns its own page.
30
+ - **Process managers (saga)** — coordinating multi-step workflows
31
+ where one event triggers a wait-then-act sequence. Primitive
32
+ tracked in [#27](https://github.com/whoojemaflip/acta/issues/27).