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 +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +229 -33
- data/RELEASING.md +107 -0
- data/docs/README.md +32 -0
- data/docs/event_driven_pub_sub.md +258 -0
- data/docs/upcasters.md +303 -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/errors.rb +38 -0
- data/lib/acta/events_query.rb +51 -4
- data/lib/acta/model.rb +7 -7
- data/lib/acta/reactor.rb +22 -0
- data/lib/acta/record.rb +49 -1
- data/lib/acta/testing/dsl.rb +53 -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/upcaster.rb +239 -0
- data/lib/acta/version.rb +1 -1
- data/lib/acta.rb +37 -4
- data/lib/generators/acta/install/install_generator.rb +6 -6
- metadata +23 -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: 0af752d227a9c1f376f4e1201bd2ba2b60faf5d178a1db06975d8bdaec0c66ef
|
|
4
|
+
data.tar.gz: 417ec6f02d8348355bccf44d0682a534d8e3dc5f7a9d6364b5a3d31326e04e76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"
|
|
33
|
+
gem "acta"
|
|
35
34
|
```
|
|
36
35
|
|
|
37
|
-
|
|
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.
|
|
348
|
+
cmd.order_id # => "order_018f2…"
|
|
232
349
|
```
|
|
233
350
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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(
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
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:
|
|
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
|
|
280
|
-
are truncated. The rule: **the
|
|
281
|
-
carries it in its payload, and the
|
|
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!`
|
|
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
|
|
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
|
|
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,
|
|
321
|
-
events would decode with fresh IDs. Events should take an
|
|
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
|
|
325
|
-
treat it as production-critical — even more so if other
|
|
326
|
-
separate user DB, external services) reference your
|
|
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).
|