acta 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +210 -0
- data/LICENSE +21 -0
- data/PLAN.md +158 -0
- data/README.md +559 -0
- data/Rakefile +12 -0
- data/app/controllers/acta/web/application_controller.rb +10 -0
- data/app/controllers/acta/web/events_controller.rb +37 -0
- data/app/helpers/acta/web/application_helper.rb +106 -0
- data/app/views/acta/web/events/index.html.erb +312 -0
- data/app/views/acta/web/events/show.html.erb +72 -0
- data/app/views/layouts/acta/web/application.html.erb +594 -0
- data/config/routes.rb +4 -0
- data/lib/acta/actor.rb +34 -0
- data/lib/acta/adapters/base.rb +59 -0
- data/lib/acta/adapters/postgres.rb +73 -0
- data/lib/acta/adapters/sqlite.rb +58 -0
- data/lib/acta/adapters.rb +19 -0
- data/lib/acta/array_type.rb +30 -0
- data/lib/acta/command.rb +48 -0
- data/lib/acta/current.rb +10 -0
- data/lib/acta/errors.rb +102 -0
- data/lib/acta/event.rb +80 -0
- data/lib/acta/events_query.rb +73 -0
- data/lib/acta/handler.rb +9 -0
- data/lib/acta/model.rb +58 -0
- data/lib/acta/model_type.rb +32 -0
- data/lib/acta/projection.rb +64 -0
- data/lib/acta/projection_managed.rb +108 -0
- data/lib/acta/railtie.rb +65 -0
- data/lib/acta/reactor.rb +15 -0
- data/lib/acta/reactor_job.rb +19 -0
- data/lib/acta/record.rb +10 -0
- data/lib/acta/schema.rb +12 -0
- data/lib/acta/serializable.rb +48 -0
- data/lib/acta/testing/dsl.rb +90 -0
- data/lib/acta/testing/matchers.rb +77 -0
- data/lib/acta/testing.rb +50 -0
- data/lib/acta/types/encrypted_string.rb +63 -0
- data/lib/acta/version.rb +5 -0
- data/lib/acta/web/engine.rb +13 -0
- data/lib/acta/web/events_query.rb +81 -0
- data/lib/acta/web.rb +45 -0
- data/lib/acta.rb +296 -0
- data/lib/generators/acta/install/install_generator.rb +23 -0
- data/lib/generators/acta/install/templates/create_acta_events.rb.tt +9 -0
- data/sig/acta.rbs +4 -0
- metadata +152 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 01a8eb1c4e0fb04d54ed2c8302c45f0d38064f07839d3b030a771ce70d03d27c
|
|
4
|
+
data.tar.gz: b34f32a1dc253ae7402d6c7a67f17c2a650d369b9e2d6036979f94da2e4b8820
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bf7c4fb886f607687bc5a274627f4c0580dc097b4096f5fb5d36f34d91dd6f05a0a2ea69e49a132b13b896ce8b61f0f6729792577543d0132d00565beacd0528
|
|
7
|
+
data.tar.gz: 157f9f789ad5d6387a6a71ee0c97754ec69f73a09b638cb7c20f6b68c6836637359af024b1765ffd05236c9908d0f83df6ac450b475827fdc7da7eaed0beef1f
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby 3.4.1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Acta are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
Public API stability begins at v1.0.0. Versions prior to that may make
|
|
9
|
+
breaking changes as the API settles through real-world consumer integration.
|
|
10
|
+
|
|
11
|
+
## [Unreleased]
|
|
12
|
+
|
|
13
|
+
## [0.2.0] — 2026-04-27
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `Acta::Projection.truncates(*ar_classes)` — class macro for declaring
|
|
18
|
+
the AR classes a projection owns. Used both as the default `truncate!`
|
|
19
|
+
target list (`delete_all` on each in declared order) and as input to
|
|
20
|
+
`Acta.rebuild!`'s cross-projection ordering: projections whose tables
|
|
21
|
+
are FK-referenced by another projection's tables now run first, so
|
|
22
|
+
children are deleted before their parents — independent of registration
|
|
23
|
+
order. Cycles raise `Acta::TruncateOrderError`. Projections without
|
|
24
|
+
`truncates` declarations keep their existing registration-order
|
|
25
|
+
behavior. The truncate phase runs inside `Projection.applying!`, so
|
|
26
|
+
`acta_managed!` models truncate cleanly. Closes #3.
|
|
27
|
+
|
|
28
|
+
- `acta_managed!` AR class macro — opt-in safety net for projection-owned
|
|
29
|
+
models. Once an AR model becomes a projection, writes from anywhere
|
|
30
|
+
other than the projection bypass the event log and break
|
|
31
|
+
`Acta.rebuild!` determinism. `acta_managed!` gates every AR write path
|
|
32
|
+
(save / update / destroy / update_columns / update_all / delete_all /
|
|
33
|
+
insert_all / upsert_all) on `Acta::Projection.applying?` and raises
|
|
34
|
+
`Acta::ProjectionWriteError` (or warns, with `on_violation: :warn`)
|
|
35
|
+
when violated. `Acta::Projection.applying! { … }` is the public escape
|
|
36
|
+
hatch for fixtures, migrations, and intentional backfills. Closes #6.
|
|
37
|
+
|
|
38
|
+
- `Acta::Testing.default_actor!(config, **attrs)` — RSpec configuration
|
|
39
|
+
helper that sets `Acta::Current.actor` before every example and resets
|
|
40
|
+
it after, eliminating the per-spec boilerplate and the easy-to-forget
|
|
41
|
+
`Acta::MissingActor` errors that come with it. Defaults to a
|
|
42
|
+
`system / rspec / test` actor; override any attribute. Closes #8.
|
|
43
|
+
- `Acta::Testing::DSL#with_actor(**attrs) { … }` — block-scoped actor
|
|
44
|
+
override for individual examples that need to attribute emissions to
|
|
45
|
+
a specific user. Restores the previous actor when the block returns
|
|
46
|
+
(or raises).
|
|
47
|
+
|
|
48
|
+
- `Acta::Railtie` — auto-loads projection / handler / reactor classes at boot
|
|
49
|
+
so they self-register before the first emit, even in Rails dev mode where
|
|
50
|
+
Zeitwerk would otherwise lazy-load them on first reference. Without this,
|
|
51
|
+
a projection that nothing has touched yet stays unsubscribed: the emit
|
|
52
|
+
succeeds, the event row is written, and the projection silently never runs.
|
|
53
|
+
Configurable via `config.acta.{projection,handler,reactor}_paths`; defaults
|
|
54
|
+
to `app/projections`, `app/handlers`, `app/reactors`. Set a path list to
|
|
55
|
+
`[]` to opt out. Closes #7.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **Breaking: command DSL collapses around streams, concurrency, and
|
|
60
|
+
emit declarations.**
|
|
61
|
+
- Removed `Acta::Command.stream` macro. Commands no longer declare or
|
|
62
|
+
inherit stream identity — events are the only thing that carries
|
|
63
|
+
stream config.
|
|
64
|
+
- Removed `Acta::Command.on_concurrent_write` macro and the
|
|
65
|
+
capture-at-instantiation / assert-at-emit machinery on Command
|
|
66
|
+
instances.
|
|
67
|
+
- Removed `Acta::Command.emits` macro and `emitted_event_class(es)`.
|
|
68
|
+
The framework no longer asks commands to declare what they emit;
|
|
69
|
+
`def call` is the only source of truth. The "primary event" concept
|
|
70
|
+
that came with single-arg `emits` was a fiction once commands could
|
|
71
|
+
legitimately emit zero, one, or many events.
|
|
72
|
+
- `Acta::Command.call` now returns the command instance (was: the
|
|
73
|
+
return value of the user's `#call` method). Read events back via
|
|
74
|
+
`cmd.emitted_events` — an array of every event emitted during the
|
|
75
|
+
invocation, in order. Idempotent commands return an instance with
|
|
76
|
+
an empty array.
|
|
77
|
+
- Renamed `Acta.emit(event, expected_sequence: N)` keyword to
|
|
78
|
+
`if_version: N`.
|
|
79
|
+
- Renamed `Acta::ConcurrencyConflict` → `Acta::VersionConflict`. Its
|
|
80
|
+
`expected_sequence` / `actual_sequence` readers are now
|
|
81
|
+
`expected_version` / `actual_version`.
|
|
82
|
+
|
|
83
|
+
`Acta::Command` now has four moving parts: `param`, `validates`,
|
|
84
|
+
`call`, `emit`. Apps that need optimistic locking write it explicitly
|
|
85
|
+
using the new public primitive:
|
|
86
|
+
```ruby
|
|
87
|
+
version = Acta.version_of(stream_type: :order, stream_key: order_id)
|
|
88
|
+
emit OrderRenamed.new(...), if_version: version
|
|
89
|
+
```
|
|
90
|
+
Two lines, fully visible, no macro magic. Most commands need none of
|
|
91
|
+
this and lose nothing.
|
|
92
|
+
|
|
93
|
+
- `Acta.register_projection` is now idempotent — registering the same
|
|
94
|
+
projection class twice is a no-op instead of double-dispatching events.
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- `Acta.version_of(stream_type:, stream_key:)` — public class method
|
|
99
|
+
returning the current high-water mark for a stream (0 for fresh
|
|
100
|
+
streams). Pair with `Acta.emit(..., if_version:)` for optimistic
|
|
101
|
+
locking.
|
|
102
|
+
|
|
103
|
+
- Per-attribute payload encryption via `attribute :token, :encrypted_string`.
|
|
104
|
+
Backed by `ActiveRecord::Encryption` — same primary/deterministic/derivation
|
|
105
|
+
keys as Rails AR-encrypted columns, same key-rotation model (append a new
|
|
106
|
+
primary, keep old keys for decryption). In-memory event values stay
|
|
107
|
+
plaintext (`event.token` returns the secret); only the serialized payload
|
|
108
|
+
written to `events.payload` is ciphertext. Resolves the issue where events
|
|
109
|
+
carrying OAuth tokens / API keys would defeat AR encryption on the
|
|
110
|
+
projection's columns by leaving cleartext copies in the audit log. Closes #1.
|
|
111
|
+
- `Acta::Event.from_acta_record(envelope:, payload:)` — internal hydration
|
|
112
|
+
hook that routes payload values through `type.deserialize` before
|
|
113
|
+
construction. Used by `EventsQuery` to decrypt `:encrypted_string`
|
|
114
|
+
attributes on read; existing types are unaffected.
|
|
115
|
+
- Acta::Web masks encrypted payload leaves as `********` in both the
|
|
116
|
+
row preview and the pretty-JSON detail block. Detection is
|
|
117
|
+
envelope-based (`ActiveRecord::Encryption.encryptor.encrypted?`), so
|
|
118
|
+
any AR-encrypted ciphertext in the payload is masked regardless of
|
|
119
|
+
whether the event class declares `:encrypted_string` — including
|
|
120
|
+
historical events written before the attribute was opted in.
|
|
121
|
+
|
|
122
|
+
## [0.1.1]
|
|
123
|
+
|
|
124
|
+
### Added
|
|
125
|
+
|
|
126
|
+
- `Acta::Command` — new `emits EventClass` class-method DSL. The command
|
|
127
|
+
inherits `stream_type` and `stream_key_attribute` from the declared
|
|
128
|
+
event class, eliminating the duplicate `stream :order, key: :order_id`
|
|
129
|
+
declaration in the common case where a command emits a single event
|
|
130
|
+
for its aggregate. Explicit `stream` on the command still works and
|
|
131
|
+
takes precedence when both are given (useful when the command operates
|
|
132
|
+
on a different aggregate than its emitted event, or doesn't emit an
|
|
133
|
+
Acta event at all).
|
|
134
|
+
|
|
135
|
+
## [0.1.0]
|
|
136
|
+
|
|
137
|
+
Feature-complete per the initial implementation plan (M0–M10). Next step
|
|
138
|
+
is real-world consumer integration to validate the API before cutting
|
|
139
|
+
v1.0.0.
|
|
140
|
+
|
|
141
|
+
### Core primitives
|
|
142
|
+
|
|
143
|
+
- `Acta::Event` — ActiveModel-backed event classes with typed payloads,
|
|
144
|
+
validate-on-init, uuid / occurred_at / recorded_at / actor envelope.
|
|
145
|
+
- `Acta::Handler` — base primitive with the `on EventClass` DSL and
|
|
146
|
+
auto-registration via Rails eager loading.
|
|
147
|
+
- `Acta::Projection < Acta::Handler` — sync + transactional + replayable.
|
|
148
|
+
Raises `ProjectionError` on failure, rolling back the emit. Tracks
|
|
149
|
+
subclasses for `Acta.rebuild!`.
|
|
150
|
+
- `Acta::Reactor < Acta::Handler` — after-commit + async via ActiveJob
|
|
151
|
+
(default) or `sync!` opt-in. Skipped during replay.
|
|
152
|
+
- `Acta::Command < Acta::Model` — param validation, `stream` declaration,
|
|
153
|
+
`on_concurrent_write :raise` / `:ignore` optimistic-concurrency DSL.
|
|
154
|
+
Raises `InvalidCommand` on param validation failure.
|
|
155
|
+
- `Acta::Actor` value object — type, id, source, metadata.
|
|
156
|
+
- `Acta::Current` — `ActiveSupport::CurrentAttributes` with an `actor`
|
|
157
|
+
attribute, propagates through ActiveJob.
|
|
158
|
+
|
|
159
|
+
### Payload shape
|
|
160
|
+
|
|
161
|
+
- `Acta::Model` base class — `ActiveModel::Attributes` + `ActiveModel::Model`
|
|
162
|
+
+ JSON round-trip with schema-drift tolerance.
|
|
163
|
+
- Class-typed attributes: `attribute :location, GeoPoint` wraps the class
|
|
164
|
+
in `Acta::ModelType` automatically.
|
|
165
|
+
- Array attributes: `attribute :tags, array_of: Tag` wraps the element
|
|
166
|
+
type in `Acta::ArrayType`.
|
|
167
|
+
- `Acta::Serializable` concern — opt-in for AR classes to participate as
|
|
168
|
+
payload types with `acta_serialize only:` / `except:` control.
|
|
169
|
+
- Nested models and AR classes compose; arrays of either work.
|
|
170
|
+
|
|
171
|
+
### Storage
|
|
172
|
+
|
|
173
|
+
- Single events table with identity, stream, payload (JSON/jsonb), actor,
|
|
174
|
+
source, metadata, and dual time columns (`occurred_at` + `recorded_at`).
|
|
175
|
+
- Indexes: uuid unique, stream-identity partial unique, event_type,
|
|
176
|
+
actor, source, occurred_at.
|
|
177
|
+
- `rails g acta:install` generator for the migration.
|
|
178
|
+
- Adapter seam: `Acta::Adapters::SQLite` (default) and
|
|
179
|
+
`Acta::Adapters::Postgres`.
|
|
180
|
+
- SQLite: single-writer sequencing with unique-constraint backstop.
|
|
181
|
+
- Postgres: `pg_advisory_xact_lock(hashtext(...))` per stream; `uuid` and
|
|
182
|
+
`jsonb` native column types.
|
|
183
|
+
|
|
184
|
+
### Testing
|
|
185
|
+
|
|
186
|
+
- `Acta::Testing.test_mode { }` — inline reactors for the block.
|
|
187
|
+
- RSpec matchers: `emit(EventClass).with(attrs)`, `emit_events([...])`,
|
|
188
|
+
`emit_any_events`.
|
|
189
|
+
- `Acta::Testing::DSL` — given_events / when_command / when_event /
|
|
190
|
+
then_emitted / then_emitted_nothing_else.
|
|
191
|
+
- `ensure_replay_deterministic { snapshot }` — catches Time.current,
|
|
192
|
+
rand, and other non-deterministic projection patterns.
|
|
193
|
+
|
|
194
|
+
### Observability
|
|
195
|
+
|
|
196
|
+
- ActiveSupport::Notifications:
|
|
197
|
+
- `acta.event_emitted` — `{ event, event_type }`
|
|
198
|
+
- `acta.projection_applied` — `{ event, projection_class }`
|
|
199
|
+
- `acta.reactor_invoked` — `{ event, reactor_class, sync: true }`
|
|
200
|
+
- `acta.reactor_enqueued` — `{ event, reactor_class }`
|
|
201
|
+
|
|
202
|
+
### Errors
|
|
203
|
+
|
|
204
|
+
- `Acta::Error` (StandardError)
|
|
205
|
+
- `InvalidEvent` (carries event)
|
|
206
|
+
- `InvalidCommand < CommandError` (carries command)
|
|
207
|
+
- `ConcurrencyConflict` (stream identity, expected/actual sequence)
|
|
208
|
+
- `ProjectionError` (event, projection_class, original)
|
|
209
|
+
- `MissingActor`, `ConfigurationError`, `AdapterError`
|
|
210
|
+
- `UnknownEventType`, `ReplayError`
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Gladhill
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/PLAN.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Acta Implementation Plan
|
|
2
|
+
|
|
3
|
+
Companion to the design doc (private, at `~/Sites/Journal/ideas/event_source_rails.md`).
|
|
4
|
+
This file is version-controlled alongside the code and tracks the milestone
|
|
5
|
+
breakdown for reaching v1.0.
|
|
6
|
+
|
|
7
|
+
## Conventions
|
|
8
|
+
|
|
9
|
+
- **Ruby hash shorthand** (Ruby 3.1+): `{ name:, age: }` when variables in
|
|
10
|
+
scope match keys. Applied everywhere.
|
|
11
|
+
- **RSpec** exclusively for v1. Minitest matcher support considered post-v1.
|
|
12
|
+
- **TDD**: every milestone is a sequence of small red → green → refactor
|
|
13
|
+
cycles. One behaviour per commit where practical.
|
|
14
|
+
- **Rubocop**: `rubocop-rails-omakase` style. Clean before each commit.
|
|
15
|
+
- **Commits**: atomic, imperative mood, one logical change each.
|
|
16
|
+
- **Semver** from v0.1. Public API stability begins at v1.0.
|
|
17
|
+
|
|
18
|
+
## Environment
|
|
19
|
+
|
|
20
|
+
- Ruby: 3.4+
|
|
21
|
+
- Rails floor: 8.1+
|
|
22
|
+
- Local: `~/Sites/acta`
|
|
23
|
+
- Remote: `git@github.com:whoojemaflip/acta.git`
|
|
24
|
+
|
|
25
|
+
## Milestone breakdown
|
|
26
|
+
|
|
27
|
+
Each milestone is independently shippable.
|
|
28
|
+
|
|
29
|
+
### M0 — Scaffolding ✅
|
|
30
|
+
|
|
31
|
+
Gem skeleton, RSpec, rubocop-rails-omakase, CI, README, LICENSE, CHANGELOG,
|
|
32
|
+
`PLAN.md` in repo. Baseline green build.
|
|
33
|
+
|
|
34
|
+
### M1 — First emit (the round-trip milestone) ✅
|
|
35
|
+
|
|
36
|
+
**Goal:** `Acta.emit(event)` persists a row; `Acta.events.last` reads it back.
|
|
37
|
+
|
|
38
|
+
1. Adapter seam — spec `Acta::Adapters::Base` interface; SQLite adapter stub.
|
|
39
|
+
2. Migration generator — `rails g acta:install` creates the events table.
|
|
40
|
+
3. `Acta::Model` — AM::Attributes + AM::Model + `to_acta_hash` / `from_acta_hash`
|
|
41
|
+
+ `validate!` in initialize raising `Acta::InvalidEvent`.
|
|
42
|
+
4. `Acta::Event < Acta::Model` — adds `uuid`, `event_type`, `event_version`,
|
|
43
|
+
`occurred_at`, `recorded_at`, `actor`.
|
|
44
|
+
5. `Acta::Actor` value object — `type`, `id`, `source`, `metadata`.
|
|
45
|
+
6. `Acta::Current` — CurrentAttributes with `actor`.
|
|
46
|
+
7. `Acta.configure` — connection + single-store `:default` registration
|
|
47
|
+
(latent store concept).
|
|
48
|
+
8. `Acta.emit(event)` — strict on missing actor (`Acta::MissingActor`);
|
|
49
|
+
persists via adapter; returns the persisted event.
|
|
50
|
+
9. `Acta.events` — query API returning `Acta::Event` instances from the log.
|
|
51
|
+
10. Error leaves so far: `Error`, `InvalidEvent`, `MissingActor`,
|
|
52
|
+
`ConfigurationError`, `AdapterError`.
|
|
53
|
+
|
|
54
|
+
**Checkpoint:** end-to-end spec that configures, emits, queries, asserts.
|
|
55
|
+
|
|
56
|
+
### M2 — Streams & concurrency ✅
|
|
57
|
+
|
|
58
|
+
1. Stream DSL — `stream :order, key: :order_id` on event classes.
|
|
59
|
+
2. Sequence calculation in SQLite adapter (BEGIN IMMEDIATE + SELECT MAX).
|
|
60
|
+
3. `ConcurrencyConflict` on unique-index violation.
|
|
61
|
+
4. Stream-scoped query — `Acta.events.for_stream(type:, key:)`.
|
|
62
|
+
5. `on_concurrent_write :raise` machinery (wires into M6 commands).
|
|
63
|
+
|
|
64
|
+
### M3 — Handlers & dispatch ✅
|
|
65
|
+
|
|
66
|
+
1. `Acta::Handler` base class + `on EventClass do |event| ... end` DSL.
|
|
67
|
+
2. Auto-registration via inheritance + Rails `eager_load_paths`.
|
|
68
|
+
3. Dispatch on emit (sync base handlers).
|
|
69
|
+
4. Registry isolation for specs — `Acta.reset_handlers!`.
|
|
70
|
+
|
|
71
|
+
### M4 — Projections ✅
|
|
72
|
+
|
|
73
|
+
1. `Acta::Projection < Acta::Handler` with sync+transactional contract.
|
|
74
|
+
2. Projections run inside emit transaction.
|
|
75
|
+
3. `ProjectionError` wraps underlying exception + projection class.
|
|
76
|
+
4. `Acta.rebuild!` — truncate projections, replay log, re-run projections.
|
|
77
|
+
5. Replay skips reactors (prep for M5).
|
|
78
|
+
|
|
79
|
+
### M5 — Reactors ✅
|
|
80
|
+
|
|
81
|
+
1. `Acta::Reactor < Acta::Handler` with after-commit + ActiveJob default.
|
|
82
|
+
2. `Acta::ReactorJob` — loads event by uuid, dispatches to reactor class.
|
|
83
|
+
3. `sync true` opt-in.
|
|
84
|
+
4. Skip on replay.
|
|
85
|
+
5. Actor propagation via `Acta::Current` serialized into ActiveJob.
|
|
86
|
+
|
|
87
|
+
### M6 — Commands ✅
|
|
88
|
+
|
|
89
|
+
1. `Acta::Command < Acta::Model` — param validation via AM::Attributes.
|
|
90
|
+
2. `stream :order, key: :order_id` on command — declares aggregate identity.
|
|
91
|
+
3. `on_concurrent_write :raise` — captures stream sequence at instantiation.
|
|
92
|
+
4. `.call(**params)` entry; `emit event` as instance method;
|
|
93
|
+
`InvalidCommand` on validation failure.
|
|
94
|
+
5. Auto-loading from `app/commands/`.
|
|
95
|
+
|
|
96
|
+
### M7 — Testing DSL (`Acta::Testing`) ✅
|
|
97
|
+
|
|
98
|
+
1. RSpec matchers: `emit(EventClass).with(...)`, `emit_events([...])`,
|
|
99
|
+
`not_to emit_any_events`.
|
|
100
|
+
2. `given_events { ... }` — seeds the log directly without running reactors.
|
|
101
|
+
3. `when_command(cmd)` — runs command, captures emitted events.
|
|
102
|
+
4. `then_emitted(EventClass, **attrs)` / `then_emitted_nothing_else`.
|
|
103
|
+
5. `Acta.test_mode { ... }` — inline reactors for the block.
|
|
104
|
+
6. Replay determinism helper —
|
|
105
|
+
`expect_projections_deterministic { ... }`.
|
|
106
|
+
|
|
107
|
+
### M8 — `Acta::Serializable` (AR piggyback) ✅
|
|
108
|
+
|
|
109
|
+
1. Concern adding `to_acta_hash` / `self.from_acta_hash(hash)` on AR classes.
|
|
110
|
+
2. `acta_serialize only: / except:` configuration.
|
|
111
|
+
3. Type dispatch for AR classes in event attributes.
|
|
112
|
+
4. Arrays of AR (`array_of:`) support.
|
|
113
|
+
5. Nested AR-in-AR round-trip.
|
|
114
|
+
6. STI support via `type` column capture.
|
|
115
|
+
7. Schema-drift tolerance — filter unknown keys on deserialize.
|
|
116
|
+
|
|
117
|
+
### M9 — Postgres adapter ✅
|
|
118
|
+
|
|
119
|
+
1. `Acta::Adapters::Postgres` implementation.
|
|
120
|
+
2. Advisory locks (`pg_advisory_xact_lock(hashtext(...))`) per stream.
|
|
121
|
+
3. `jsonb` column type + `uuid` column type + `gen_random_uuid()`.
|
|
122
|
+
4. Shared behaviour specs — `it_behaves_like "an Acta adapter"`.
|
|
123
|
+
5. CI matrix with both SQLite and Postgres.
|
|
124
|
+
6. Concurrency-specific specs exercising genuine concurrent writers.
|
|
125
|
+
|
|
126
|
+
### M10 — v1.0 polish ✅
|
|
127
|
+
|
|
128
|
+
1. Observability via `ActiveSupport::Notifications` —
|
|
129
|
+
`acta.event_emitted`, `acta.projection_applied`, `acta.reactor_enqueued`.
|
|
130
|
+
2. Remaining error leaves — `UnknownEventType`, `ReplayError`, gaps.
|
|
131
|
+
3. README rewrite with full worked examples.
|
|
132
|
+
4. Tag v1.0.0.
|
|
133
|
+
|
|
134
|
+
## Milestone dependencies
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
M0 → M1 → M2 → M3 → M4 ─┐
|
|
138
|
+
└──→ M5 ─┐
|
|
139
|
+
M6 ──────┴─→ M7 → M8 → M9 → M10
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
M4 and M5 are independent after M3. M6 can start after M2. M8 benefits from
|
|
143
|
+
M7. M9 can start anytime after M1 but is most valuable after M8.
|
|
144
|
+
|
|
145
|
+
## Out of scope for v1
|
|
146
|
+
|
|
147
|
+
- Upcasters (column reserved)
|
|
148
|
+
- Multi-store (latent concept, not exposed)
|
|
149
|
+
- MySQL adapter
|
|
150
|
+
- `Acta::Saga` / process managers
|
|
151
|
+
- LISTEN/NOTIFY or other pub/sub transport
|
|
152
|
+
- Snapshots
|
|
153
|
+
|
|
154
|
+
## Quality gates per commit
|
|
155
|
+
|
|
156
|
+
- `bundle exec rspec` green
|
|
157
|
+
- `bundle exec rubocop` clean
|
|
158
|
+
- Change has a spec unless it's pure refactoring
|