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 +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +228 -33
- data/RELEASING.md +107 -0
- data/docs/README.md +31 -0
- data/docs/event_driven_pub_sub.md +258 -0
- data/gemfiles/rails_7_2.gemfile +20 -0
- data/gemfiles/rails_8_0.gemfile +20 -0
- data/gemfiles/rails_8_1.gemfile +20 -0
- data/lib/acta/model.rb +7 -7
- data/lib/acta/reactor.rb +22 -0
- data/lib/acta/testing.rb +33 -0
- data/lib/acta/types/array.rb +35 -0
- data/lib/acta/types/model.rb +39 -0
- data/lib/acta/version.rb +1 -1
- data/lib/acta.rb +16 -1
- data/lib/generators/acta/install/install_generator.rb +6 -6
- metadata +21 -16
- data/PLAN.md +0 -158
- data/lib/acta/array_type.rb +0 -30
- data/lib/acta/model_type.rb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c2218e4d40ef364d3dba8452e489a06f3e6d0503726e4d95660f7637e00efdc
|
|
4
|
+
data.tar.gz: a3602d1b12e575f44f3f0bf4acdad256d5bd25a9ede5f43688f869015b96abe8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"
|
|
32
|
+
gem "acta"
|
|
35
33
|
```
|
|
36
34
|
|
|
37
|
-
|
|
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.
|
|
347
|
+
cmd.order_id # => "order_018f2…"
|
|
232
348
|
```
|
|
233
349
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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(
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
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:
|
|
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
|
|
280
|
-
are truncated. The rule: **the
|
|
281
|
-
carries it in its payload, and the
|
|
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!`
|
|
309
|
-
the `orders` table) and replays every event. The projection
|
|
310
|
-
`event.order_id` — which was written at the original
|
|
311
|
-
re-inserts the row with the same ID. **Rebuild never regenerates
|
|
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
|
|
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,
|
|
321
|
-
events would decode with fresh IDs. Events should take an
|
|
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
|
|
325
|
-
treat it as production-critical — even more so if other
|
|
326
|
-
separate user DB, external services) reference your
|
|
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).
|