acta 0.3.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 +72 -0
- data/README.md +1 -0
- data/docs/README.md +5 -4
- data/docs/upcasters.md +303 -0
- data/lib/acta/errors.rb +38 -0
- data/lib/acta/events_query.rb +51 -4
- data/lib/acta/record.rb +49 -1
- data/lib/acta/testing/dsl.rb +53 -0
- data/lib/acta/upcaster.rb +239 -0
- data/lib/acta/version.rb +1 -1
- data/lib/acta.rb +21 -3
- metadata +4 -2
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,78 @@ 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
|
+
|
|
13
85
|
## [0.3.0] — 2026-04-28
|
|
14
86
|
|
|
15
87
|
### Added
|
data/README.md
CHANGED
|
@@ -21,6 +21,7 @@ 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.
|
data/docs/README.md
CHANGED
|
@@ -12,6 +12,11 @@ end-to-end code and the trade-offs that come with each choice.
|
|
|
12
12
|
subscribers, no event sourcing. AR records remain the source of
|
|
13
13
|
truth; the events table is a free audit log. Compares against AR
|
|
14
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.
|
|
15
20
|
|
|
16
21
|
## Patterns coming later
|
|
17
22
|
|
|
@@ -25,7 +30,3 @@ Recipes will land here when these are written or implemented:
|
|
|
25
30
|
- **Process managers (saga)** — coordinating multi-step workflows
|
|
26
31
|
where one event triggers a wait-then-act sequence. Primitive
|
|
27
32
|
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).
|
data/docs/upcasters.md
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# Schema evolution with upcasters
|
|
2
|
+
|
|
3
|
+
Acta records are immutable: once an event lands in the events table,
|
|
4
|
+
nothing edits it. That's the property the audit log relies on. But
|
|
5
|
+
app schemas evolve — a new attribute appears, a semantic shifts, an
|
|
6
|
+
event type gets renamed. The straightforward options are unappealing:
|
|
7
|
+
|
|
8
|
+
- **Mutate history** — rewrite the events table. Breaks immutability
|
|
9
|
+
and any external consumer of the log.
|
|
10
|
+
- **Snapshot the boundary** — preserve projections at the cut and
|
|
11
|
+
declare replay-from-zero unsupported. Loses event sourcing's core
|
|
12
|
+
promise.
|
|
13
|
+
- **Accept that replay-from-zero is broken** — keep emitting new
|
|
14
|
+
shapes but admit `Acta.rebuild!` can't produce the current
|
|
15
|
+
projection from scratch. Corrosive over time.
|
|
16
|
+
|
|
17
|
+
**Upcasters** are the standard event-sourcing answer: at replay
|
|
18
|
+
time, transform old-shape records into new-shape records in memory,
|
|
19
|
+
before projections see them. The stored rows are never touched. The
|
|
20
|
+
transformation logic lives in code, where it's tested and audited.
|
|
21
|
+
|
|
22
|
+
## When to reach for an upcaster
|
|
23
|
+
|
|
24
|
+
- You renamed an event type (`ItemCreated` → `WorkspaceCreated`) and
|
|
25
|
+
want old records to apply to the new projection.
|
|
26
|
+
- You added a required field to an event class and old records lack
|
|
27
|
+
it; you can derive a default at replay time.
|
|
28
|
+
- You're dropping an obsolete event type and want pre-deprecation
|
|
29
|
+
records to be skipped on replay.
|
|
30
|
+
- You're splitting one logical event into several finer-grained ones
|
|
31
|
+
(a 1-to-many fan-out at replay).
|
|
32
|
+
|
|
33
|
+
If your schema change is purely additive *and* every site that reads
|
|
34
|
+
the field tolerates `nil`, you can probably skip upcasters: bump the
|
|
35
|
+
event class to add the attribute, leave `event_version` alone, and
|
|
36
|
+
projections cope with missing-field cases inline. Reach for an
|
|
37
|
+
upcaster when "tolerate missing field" turns into more conditional
|
|
38
|
+
logic than the transform would be.
|
|
39
|
+
|
|
40
|
+
## The shape of an upcaster
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# app/upcasters/workspace_migration_upcasters.rb
|
|
44
|
+
module WorkspaceMigrationUpcasters
|
|
45
|
+
include Acta::Upcaster
|
|
46
|
+
|
|
47
|
+
upcasts "ItemCreated", from: 1, to: 2 do |event, context|
|
|
48
|
+
payload = event.payload
|
|
49
|
+
|
|
50
|
+
if payload["item_type"] == "goal"
|
|
51
|
+
# A v1 goal becomes a v2 workspace. Record the mapping so
|
|
52
|
+
# descendant items can resolve their workspace_id below.
|
|
53
|
+
context[:goal_to_workspace][payload["item_id"]] = payload["item_id"]
|
|
54
|
+
|
|
55
|
+
event.upcast_to(
|
|
56
|
+
type: "WorkspaceCreated",
|
|
57
|
+
payload: {
|
|
58
|
+
"workspace_id" => payload["item_id"],
|
|
59
|
+
"title" => payload["title"]
|
|
60
|
+
},
|
|
61
|
+
schema_version: 2
|
|
62
|
+
)
|
|
63
|
+
else
|
|
64
|
+
workspace_id =
|
|
65
|
+
context[:goal_to_workspace][payload["parent_id"]] ||
|
|
66
|
+
context[:item_to_workspace][payload["parent_id"]]
|
|
67
|
+
|
|
68
|
+
if workspace_id.nil?
|
|
69
|
+
context.fail_replay!(
|
|
70
|
+
"Unmappable item #{payload['item_id']}: no goal ancestor"
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context[:item_to_workspace][payload["item_id"]] = workspace_id
|
|
75
|
+
|
|
76
|
+
event.upcast_to(
|
|
77
|
+
payload: payload.merge("workspace_id" => workspace_id),
|
|
78
|
+
schema_version: 2
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Register it once at boot:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# config/initializers/acta_upcasters.rb
|
|
89
|
+
Acta.register_upcaster(WorkspaceMigrationUpcasters)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Then bump the new emit path so freshly emitted events carry
|
|
93
|
+
`event_version: 2`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class ItemCreated < Acta::Event
|
|
97
|
+
def self.event_version = 2
|
|
98
|
+
attribute :item_id, :string
|
|
99
|
+
attribute :workspace_id, :string
|
|
100
|
+
# ...
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
That's the whole feature surface. New writes are at v2; old reads
|
|
105
|
+
get upcasted to v2 before they hit projections.
|
|
106
|
+
|
|
107
|
+
## What blocks can return
|
|
108
|
+
|
|
109
|
+
Inside an `upcasts` block, the return value controls what the
|
|
110
|
+
pipeline does next:
|
|
111
|
+
|
|
112
|
+
| Return value | Effect |
|
|
113
|
+
| ------------------------------------- | ------------------------------------------------ |
|
|
114
|
+
| `event.upcast_to(...)` | Continue chaining at the new (type, version) |
|
|
115
|
+
| Array of `event.upcast_to(...)` | Fan-out: each branch chains independently |
|
|
116
|
+
| `nil` or `[]` | Drop the record from this replay |
|
|
117
|
+
| `context.fail_replay!("reason")` | Halt with `Acta::ReplayHaltedByUpcaster` |
|
|
118
|
+
|
|
119
|
+
If you need to leave a record alone at the current version — e.g. a
|
|
120
|
+
boundary-marker event that's already in its final shape — use the
|
|
121
|
+
NO_OP sentinel:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
upcasts "GoalPromotedToWorkspace", from: 2, to: 2, &Acta::Upcaster::NO_OP
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Stateless vs stateful upcasters
|
|
128
|
+
|
|
129
|
+
Upcasters come in two flavors and the distinction matters for which
|
|
130
|
+
read surfaces will produce correct output.
|
|
131
|
+
|
|
132
|
+
**Stateless** — the transform depends only on the record itself.
|
|
133
|
+
Adding a default for a new field, renaming a key in the payload,
|
|
134
|
+
or unconditionally bumping the event type all qualify. The
|
|
135
|
+
`context` argument is ignored.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
upcasts "ItemCreated", from: 1, to: 2 do |event, _ctx|
|
|
139
|
+
event.upcast_to(
|
|
140
|
+
payload: event.payload.merge("workspace_id" => "default"),
|
|
141
|
+
schema_version: 2
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Stateful** — the transform depends on context populated by an
|
|
147
|
+
earlier event in the same replay. Resolving a descendant's
|
|
148
|
+
`workspace_id` from a goal seen earlier in the stream is the
|
|
149
|
+
canonical example.
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
upcasts "ItemCreated", from: 1, to: 2 do |event, ctx|
|
|
153
|
+
payload = event.payload
|
|
154
|
+
if payload["item_type"] == "goal"
|
|
155
|
+
ctx[:goal_to_workspace][payload["item_id"]] = payload["item_id"]
|
|
156
|
+
event.upcast_to(type: "WorkspaceCreated", ...)
|
|
157
|
+
else
|
|
158
|
+
workspace_id = ctx[:goal_to_workspace][payload["parent_id"]]
|
|
159
|
+
event.upcast_to(payload: payload.merge("workspace_id" => workspace_id), schema_version: 2)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Stateful upcasters require **global insertion order**, which is
|
|
165
|
+
exactly what `Acta.rebuild!` (and `Acta.events.all` / `#each`)
|
|
166
|
+
provides. They will silently produce incomplete output on read
|
|
167
|
+
surfaces that can't supply that order — see the next section.
|
|
168
|
+
|
|
169
|
+
## Context semantics across read surfaces
|
|
170
|
+
|
|
171
|
+
Different read paths give upcasters different views of the world.
|
|
172
|
+
Stateless upcasters are unaffected by any of this; stateful
|
|
173
|
+
upcasters need to know.
|
|
174
|
+
|
|
175
|
+
| Read surface | Context lifetime | Safe for stateful upcasters? |
|
|
176
|
+
| ------------------------------------- | -------------------------------- | ---------------------------- |
|
|
177
|
+
| `Acta.rebuild!` | One shared, full insertion order | Yes |
|
|
178
|
+
| `Acta.events.all` / `#each` | One shared, full insertion order | Yes |
|
|
179
|
+
| `Acta.events.find_by_uuid(uuid)` | Fresh per call | No — incomplete resolution |
|
|
180
|
+
| `Acta.events.first` / `.last` | Fresh per call | No — incomplete resolution |
|
|
181
|
+
| `Acta.events.for_stream(...)#all` | Shared, but stream-ordered | Usually no — wrong order |
|
|
182
|
+
| `Acta::ReactorJob#perform` | Fresh, single record | No — incomplete resolution |
|
|
183
|
+
| Web admin (`Acta::Web::EventsController`) | N/A — shows raw stored rows | N/A |
|
|
184
|
+
|
|
185
|
+
The pattern: any time you hand the pipeline a full ordered stream,
|
|
186
|
+
stateful upcasters work. Any time you hand it one record (or a
|
|
187
|
+
stream-reordered subset), they can't reconstruct the state they
|
|
188
|
+
need.
|
|
189
|
+
|
|
190
|
+
### Implication for stateful migrations
|
|
191
|
+
|
|
192
|
+
A stateful migration is fundamentally a `rebuild!`-shaped operation.
|
|
193
|
+
The cutover playbook is:
|
|
194
|
+
|
|
195
|
+
1. Deploy code that emits at the new `event_version` and includes
|
|
196
|
+
the upcasters.
|
|
197
|
+
2. Drain the reactor queue. Jobs enqueued before the deploy will
|
|
198
|
+
re-hydrate their events through the upcaster pipeline with a
|
|
199
|
+
fresh context — fine for stateless upcasters, possibly
|
|
200
|
+
incomplete for stateful ones.
|
|
201
|
+
3. Run `Acta.rebuild!` to regenerate projections from the full
|
|
202
|
+
ordered log under the new schema.
|
|
203
|
+
4. Flip reads.
|
|
204
|
+
|
|
205
|
+
Apps that need stateful read-time resolution outside `rebuild!`
|
|
206
|
+
should consider whether the resolved field belongs in the
|
|
207
|
+
projection rather than in the upcaster — projections are the
|
|
208
|
+
durable, queryable view, and once `rebuild!` has run, projections
|
|
209
|
+
hold the post-upcast state without needing the pipeline at read
|
|
210
|
+
time.
|
|
211
|
+
|
|
212
|
+
## Chaining across N versions
|
|
213
|
+
|
|
214
|
+
Upcasters can be declared on a single event type across many
|
|
215
|
+
versions; the pipeline walks them in order:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
module SuccessiveBumps
|
|
219
|
+
include Acta::Upcaster
|
|
220
|
+
|
|
221
|
+
upcasts "ItemCreated", from: 1, to: 2 do |e, _|
|
|
222
|
+
e.upcast_to(payload: e.payload.merge("workspace_id" => "?"), schema_version: 2)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
upcasts "ItemCreated", from: 2, to: 3 do |e, _|
|
|
226
|
+
e.upcast_to(payload: e.payload.except("legacy_kind"), schema_version: 3)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
A v1 record passes through both transforms in sequence. A v2 record
|
|
232
|
+
(emitted between the two migrations) picks up the second transform
|
|
233
|
+
only. Events emitted by current v3 code pass through identity.
|
|
234
|
+
|
|
235
|
+
## Testing upcasters
|
|
236
|
+
|
|
237
|
+
Two helpers live in `Acta::Testing::DSL`:
|
|
238
|
+
|
|
239
|
+
- `acta_seed_event(type:, payload:, event_version: 1, ...)` —
|
|
240
|
+
inserts an event row directly, bypassing `Acta.emit` (which always
|
|
241
|
+
stamps the *current* code's `event_version`).
|
|
242
|
+
- `acta_replay(events:, upcasters: [])` — registers the supplied
|
|
243
|
+
upcasters, seeds events, and runs `Acta.rebuild!`.
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
RSpec.describe "Workspaces migration" do
|
|
247
|
+
include Acta::Testing::DSL
|
|
248
|
+
|
|
249
|
+
it "promotes goals to workspaces and rewires descendants" do
|
|
250
|
+
acta_replay(
|
|
251
|
+
upcasters: [ WorkspaceMigrationUpcasters ],
|
|
252
|
+
events: [
|
|
253
|
+
{ type: "ItemCreated", event_version: 1,
|
|
254
|
+
payload: { "item_id" => "g_1", "item_type" => "goal", "title" => "Q3" } },
|
|
255
|
+
{ type: "ItemCreated", event_version: 1,
|
|
256
|
+
payload: { "item_id" => "i_2", "parent_id" => "g_1", "title" => "Plan" } }
|
|
257
|
+
]
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
expect(Workspace.pluck(:id)).to eq([ "g_1" ])
|
|
261
|
+
expect(Item.find("i_2").workspace_id).to eq("g_1")
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The existing `ensure_replay_deterministic` matcher implicitly
|
|
267
|
+
exercises the upcaster pipeline twice — impure upcasters (state
|
|
268
|
+
leaking outside the per-replay context) surface as a snapshot diff
|
|
269
|
+
on the second pass.
|
|
270
|
+
|
|
271
|
+
## What upcasters intentionally do *not* do
|
|
272
|
+
|
|
273
|
+
- **They don't rewrite the event store.** Stored rows are immutable;
|
|
274
|
+
transforms exist only in memory during a replay pass.
|
|
275
|
+
- **They don't run on the live emit path.** `Acta.emit` stamps the
|
|
276
|
+
current code's `event_version` and dispatches the in-memory event
|
|
277
|
+
directly — no read round-trip, no upcaster pass. Live writes are
|
|
278
|
+
always at the latest version.
|
|
279
|
+
- **They're not a migration framework.** The decision to bump
|
|
280
|
+
`event_version` and write an upcaster is the schema migration. The
|
|
281
|
+
upcaster's job is just replay correctness.
|
|
282
|
+
- **They're not cross-tenant.** Each tenant's events table replays
|
|
283
|
+
independently; upcaster context is per-replay-pass, per-tenant.
|
|
284
|
+
|
|
285
|
+
## Edge cases worth knowing about
|
|
286
|
+
|
|
287
|
+
- **Future-version records.** If a replay sees an event whose stored
|
|
288
|
+
`event_version` exceeds the highest `to` any registered upcaster
|
|
289
|
+
knows how to reach for that type, the pipeline raises
|
|
290
|
+
`Acta::FutureSchemaVersion`. Typically: an older deployment is
|
|
291
|
+
replaying events emitted by a newer one. Halting is the safe call.
|
|
292
|
+
- **Type renames remove the need to keep old classes around.**
|
|
293
|
+
Upcasters operate on raw records pre-hydration, so the original
|
|
294
|
+
`ItemCreated` constant can be deleted from the codebase the moment
|
|
295
|
+
its upcaster renames the type. Hydration only ever happens for
|
|
296
|
+
classes the upcaster pipeline produces.
|
|
297
|
+
- **1-to-many composes with chaining.** Each record an upcaster
|
|
298
|
+
fans out into walks the version ladder independently — including
|
|
299
|
+
through more 1-to-many transforms if you have them.
|
|
300
|
+
- **Conflicting registrations raise at boot.** Two upcaster classes
|
|
301
|
+
that claim the same `(event_type, from)` pair surface as
|
|
302
|
+
`Acta::UpcasterRegistryError` the moment the second one registers.
|
|
303
|
+
Pick an owner.
|
data/lib/acta/errors.rb
CHANGED
|
@@ -99,4 +99,42 @@ module Acta
|
|
|
99
99
|
)
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
|
+
|
|
103
|
+
# Raised by `context.fail_replay!(reason)` inside an upcaster block. Halts
|
|
104
|
+
# replay so the operator can investigate rather than land a partial,
|
|
105
|
+
# possibly-corrupt projection.
|
|
106
|
+
class ReplayHaltedByUpcaster < Error
|
|
107
|
+
attr_reader :record, :reason
|
|
108
|
+
|
|
109
|
+
def initialize(record:, reason:)
|
|
110
|
+
@record = record
|
|
111
|
+
@reason = reason
|
|
112
|
+
super(
|
|
113
|
+
"Upcaster halted replay on event id=#{record.id} uuid=#{record.uuid} " \
|
|
114
|
+
"(#{record.event_type} v#{record.event_version}): #{reason}"
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Raised at registration time when an upcaster set is malformed — e.g.
|
|
120
|
+
# `from` >= `to`, or two upcasters claim the same (event_type, from).
|
|
121
|
+
class UpcasterRegistryError < Error; end
|
|
122
|
+
|
|
123
|
+
# Raised when a record's stored event_version exceeds anything the
|
|
124
|
+
# currently-loaded upcaster registry knows how to reach. Typically means
|
|
125
|
+
# an older deployment is replaying events emitted by a newer one.
|
|
126
|
+
class FutureSchemaVersion < Error
|
|
127
|
+
attr_reader :record, :latest_known_version
|
|
128
|
+
|
|
129
|
+
def initialize(record:, latest_known_version:)
|
|
130
|
+
@record = record
|
|
131
|
+
@latest_known_version = latest_known_version
|
|
132
|
+
super(
|
|
133
|
+
"Event id=#{record.id} uuid=#{record.uuid} (#{record.event_type}) is at " \
|
|
134
|
+
"event_version #{record.event_version}, but the loaded upcaster registry " \
|
|
135
|
+
"only knows up to v#{latest_known_version}. Likely an older deployment " \
|
|
136
|
+
"replaying events emitted by a newer one."
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
102
140
|
end
|
data/lib/acta/events_query.rb
CHANGED
|
@@ -7,19 +7,27 @@ module Acta
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def last
|
|
10
|
-
|
|
10
|
+
upcast_and_hydrate_one(@scope.last)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def first
|
|
14
|
-
|
|
14
|
+
upcast_and_hydrate_one(@scope.first)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def find_by_uuid(uuid)
|
|
18
|
-
|
|
18
|
+
upcast_and_hydrate_one(@scope.find_by(uuid:))
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Iterates the full scope through the upcaster pipeline with a SINGLE
|
|
22
|
+
# shared context across every record, matching `Acta.rebuild!` semantics.
|
|
23
|
+
# Stateful upcasters (those that resolve later events from state seeded
|
|
24
|
+
# by earlier ones) depend on this. Single-record lookups
|
|
25
|
+
# (`find_by_uuid`, `first`, `last`) deliberately use a fresh context —
|
|
26
|
+
# there is no prior history to seed it with — and may produce
|
|
27
|
+
# incomplete output for stateful upcasters. See `docs/upcasters.md`.
|
|
21
28
|
def all
|
|
22
|
-
|
|
29
|
+
context = Upcaster::Context.new
|
|
30
|
+
@scope.flat_map { |record| upcast_and_hydrate(record, context) }
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
def count
|
|
@@ -39,8 +47,47 @@ module Acta
|
|
|
39
47
|
self.class.new(filtered)
|
|
40
48
|
end
|
|
41
49
|
|
|
50
|
+
# Run a single record through the upcaster pipeline and hydrate every
|
|
51
|
+
# output into a typed Acta::Event. Returns an Array (length 0..N) —
|
|
52
|
+
# callers that expect one event (the historic shape) should use the
|
|
53
|
+
# find_by_uuid/first/last helpers above, which apply a fresh context
|
|
54
|
+
# per call and unwrap to a single event (raising if upcasters drop or
|
|
55
|
+
# fan out, since those shapes aren't meaningful for one-record reads).
|
|
56
|
+
#
|
|
57
|
+
# Acta.rebuild! supplies a single shared context for the full pass.
|
|
58
|
+
def upcast_and_hydrate(record, context)
|
|
59
|
+
Upcaster.upcast(record, context).map { |view| hydrate(view) }
|
|
60
|
+
end
|
|
61
|
+
|
|
42
62
|
private
|
|
43
63
|
|
|
64
|
+
# Single-record helper used by the public lookup methods. Drop and
|
|
65
|
+
# fan-out are rejected here — `find_by_uuid(x)` returning either nil
|
|
66
|
+
# (when an upcaster dropped) or an array (when it fanned out) would
|
|
67
|
+
# silently break every existing caller. Live emit and tests reach for
|
|
68
|
+
# this surface assuming one record → one event.
|
|
69
|
+
def upcast_and_hydrate_one(record)
|
|
70
|
+
return nil unless record
|
|
71
|
+
|
|
72
|
+
results = upcast_and_hydrate(record, fresh_context)
|
|
73
|
+
|
|
74
|
+
case results.length
|
|
75
|
+
when 0
|
|
76
|
+
nil
|
|
77
|
+
when 1
|
|
78
|
+
results.first
|
|
79
|
+
else
|
|
80
|
+
raise UpcasterRegistryError,
|
|
81
|
+
"Upcaster fan-out (#{results.length} events) is not supported on " \
|
|
82
|
+
"single-record reads of #{record.event_type} uuid=#{record.uuid}; " \
|
|
83
|
+
"use Acta.rebuild! or EventsQuery#each, which iterate the pipeline."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fresh_context
|
|
88
|
+
Upcaster::Context.new
|
|
89
|
+
end
|
|
90
|
+
|
|
44
91
|
def hydrate(record)
|
|
45
92
|
return nil unless record
|
|
46
93
|
|
data/lib/acta/record.rb
CHANGED
|
@@ -3,8 +3,56 @@
|
|
|
3
3
|
require "active_record"
|
|
4
4
|
|
|
5
5
|
module Acta
|
|
6
|
-
|
|
6
|
+
# Abstract intermediate. The actual events table lives on `Acta::Record`
|
|
7
|
+
# below; this class exists so hosts can call `connects_to` (which
|
|
8
|
+
# ActiveRecord rejects on concrete classes that have `table_name` set).
|
|
9
|
+
#
|
|
10
|
+
# Default behaviour: inherits from ActiveRecord::Base, no shard or
|
|
11
|
+
# connection routing — Acta::Record uses whatever the host's default
|
|
12
|
+
# connection is.
|
|
13
|
+
#
|
|
14
|
+
# Hosts that need the events table to *share a connection pool* with
|
|
15
|
+
# their own tenant-scoped abstract base (so writes inside a single
|
|
16
|
+
# transaction don't fight across pools on the same SQLite file)
|
|
17
|
+
# re-parent EventsRecord via `Acta.set_events_record_parent!`:
|
|
18
|
+
#
|
|
19
|
+
# # config/initializers/_acta_record_parent.rb
|
|
20
|
+
# class TenantRecord < ActiveRecord::Base
|
|
21
|
+
# self.abstract_class = true
|
|
22
|
+
# end
|
|
23
|
+
# Acta.set_events_record_parent!(TenantRecord)
|
|
24
|
+
# # then call connects_to on TenantRecord — Acta::Record rides along
|
|
25
|
+
#
|
|
26
|
+
# Acta::Record inherits from EventsRecord so any routing applied to
|
|
27
|
+
# EventsRecord (or to a re-parented ancestor) automatically applies.
|
|
28
|
+
class EventsRecord < ActiveRecord::Base
|
|
29
|
+
self.abstract_class = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Record < EventsRecord
|
|
7
33
|
self.table_name = "events"
|
|
8
34
|
self.inheritance_column = nil
|
|
9
35
|
end
|
|
36
|
+
|
|
37
|
+
# Re-parent EventsRecord (and therefore Record) onto a host-supplied
|
|
38
|
+
# abstract class. Must run BEFORE any query against Acta::Record
|
|
39
|
+
# executes — call from a host initializer after the parent class is
|
|
40
|
+
# defined. Re-defines the two constants so existing references to
|
|
41
|
+
# `Acta::Record` resolve to the new class.
|
|
42
|
+
#
|
|
43
|
+
# Use case: per-tenant SQLite sharding where the host wants events
|
|
44
|
+
# and its own tenant-scoped rows in the same connection pool to
|
|
45
|
+
# avoid SQLite write contention on cross-pool transactions.
|
|
46
|
+
def self.set_events_record_parent!(parent)
|
|
47
|
+
raise ArgumentError, "parent must be an abstract ActiveRecord class" unless parent.is_a?(Class) && parent < ::ActiveRecord::Base && parent.abstract_class?
|
|
48
|
+
|
|
49
|
+
Acta.send(:remove_const, :Record) if Acta.const_defined?(:Record, false)
|
|
50
|
+
Acta.send(:remove_const, :EventsRecord) if Acta.const_defined?(:EventsRecord, false)
|
|
51
|
+
|
|
52
|
+
Acta.const_set(:EventsRecord, Class.new(parent) { self.abstract_class = true })
|
|
53
|
+
Acta.const_set(:Record, Class.new(Acta::EventsRecord) do
|
|
54
|
+
self.table_name = "events"
|
|
55
|
+
self.inheritance_column = nil
|
|
56
|
+
end)
|
|
57
|
+
end
|
|
10
58
|
end
|
data/lib/acta/testing/dsl.rb
CHANGED
|
@@ -72,6 +72,10 @@ module Acta
|
|
|
72
72
|
# Assert that running Acta.rebuild! twice produces the same projected
|
|
73
73
|
# state. The block returns a snapshot of the relevant state (whatever
|
|
74
74
|
# the app considers authoritative for this projection).
|
|
75
|
+
#
|
|
76
|
+
# Implicitly exercises any registered upcasters — both passes go
|
|
77
|
+
# through the same pipeline, so impure upcasters (state leaking
|
|
78
|
+
# outside the per-replay context) surface as a diff.
|
|
75
79
|
def ensure_replay_deterministic(&snapshot)
|
|
76
80
|
Acta.rebuild!
|
|
77
81
|
first = snapshot.call
|
|
@@ -85,6 +89,55 @@ module Acta
|
|
|
85
89
|
"second pass: #{second.inspect}"
|
|
86
90
|
)
|
|
87
91
|
end
|
|
92
|
+
|
|
93
|
+
# Insert an event row directly into the store, bypassing `Acta.emit`.
|
|
94
|
+
# Used by upcaster specs to seed events at arbitrary `event_version`
|
|
95
|
+
# values — `Acta.emit` always stamps the current code's version, so
|
|
96
|
+
# it can't simulate a pre-migration row.
|
|
97
|
+
#
|
|
98
|
+
# acta_seed_event(type: "ItemAdded", event_version: 1,
|
|
99
|
+
# payload: { "item_id" => "g_1", "item_type" => "goal" })
|
|
100
|
+
def acta_seed_event(type:, payload:, event_version: 1, actor: nil,
|
|
101
|
+
stream_type: nil, stream_key: nil, occurred_at: nil, uuid: nil)
|
|
102
|
+
actor ||= Acta::Current.actor || Acta::Actor.new(
|
|
103
|
+
type: "system", id: "rspec", source: "test"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
Acta::Record.create!(
|
|
107
|
+
uuid: uuid || SecureRandom.uuid,
|
|
108
|
+
event_type: type.to_s,
|
|
109
|
+
event_version: event_version,
|
|
110
|
+
payload: payload,
|
|
111
|
+
actor_type: actor.type,
|
|
112
|
+
actor_id: actor.id,
|
|
113
|
+
source: actor.source,
|
|
114
|
+
metadata: actor.metadata.empty? ? nil : actor.metadata,
|
|
115
|
+
stream_type: stream_type&.to_s,
|
|
116
|
+
stream_key: stream_key,
|
|
117
|
+
occurred_at: occurred_at || Time.current,
|
|
118
|
+
recorded_at: Time.current
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# End-to-end upcaster fixture: register upcasters, seed events at the
|
|
123
|
+
# given versions, run `Acta.rebuild!`. The caller asserts on whatever
|
|
124
|
+
# projection state matters for the migration under test.
|
|
125
|
+
#
|
|
126
|
+
# acta_replay(
|
|
127
|
+
# upcasters: [Scaff::WorkspaceMigrationUpcasters],
|
|
128
|
+
# events: [
|
|
129
|
+
# { type: "Scaff::ItemCreated", event_version: 1,
|
|
130
|
+
# payload: { "item_id" => "g_1", "item_type" => "goal", "title" => "Foo" } },
|
|
131
|
+
# { type: "Scaff::ItemCreated", event_version: 1,
|
|
132
|
+
# payload: { "item_id" => "i_2", "parent_id" => "g_1", "title" => "Bar" } }
|
|
133
|
+
# ]
|
|
134
|
+
# )
|
|
135
|
+
# expect(Workspace.pluck(:id)).to eq(%w[g_1])
|
|
136
|
+
def acta_replay(events:, upcasters: [])
|
|
137
|
+
upcasters.each { |u| Acta.register_upcaster(u) }
|
|
138
|
+
events.each { |attrs| acta_seed_event(**attrs) }
|
|
139
|
+
Acta.rebuild!
|
|
140
|
+
end
|
|
88
141
|
end
|
|
89
142
|
end
|
|
90
143
|
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acta
|
|
4
|
+
# Replay-time event transformation. Apps declare upcasters when an event
|
|
5
|
+
# type's shape changes between schema versions; the pipeline transforms
|
|
6
|
+
# stored records on read so projections see them at the latest shape.
|
|
7
|
+
# See `docs/upcasters.md` for the end-to-end recipe.
|
|
8
|
+
#
|
|
9
|
+
# module Scaff
|
|
10
|
+
# class WorkspaceMigrationUpcasters
|
|
11
|
+
# include Acta::Upcaster
|
|
12
|
+
#
|
|
13
|
+
# upcasts "Scaff::ItemCreated", from: 1, to: 2 do |event, context|
|
|
14
|
+
# payload = event.payload
|
|
15
|
+
# if payload["item_type"] == "goal"
|
|
16
|
+
# context[:goal_to_workspace][payload["item_id"]] = payload["item_id"]
|
|
17
|
+
# event.upcast_to(
|
|
18
|
+
# type: "Scaff::WorkspaceCreated",
|
|
19
|
+
# payload: { "workspace_id" => payload["item_id"], "title" => payload["title"] },
|
|
20
|
+
# schema_version: 2
|
|
21
|
+
# )
|
|
22
|
+
# else
|
|
23
|
+
# event.upcast_to(payload: payload.merge("workspace_id" => "..."), schema_version: 2)
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# Acta.register_upcaster(Scaff::WorkspaceMigrationUpcasters)
|
|
30
|
+
#
|
|
31
|
+
# Upcasters run pre-hydration during every read (`Acta.rebuild!`,
|
|
32
|
+
# `ReactorJob#perform`, the events admin, test fixtures) — apps can
|
|
33
|
+
# safely delete an old event class once a rename upcaster is in place.
|
|
34
|
+
# The live emit path is exempt: emitted events carry the current code's
|
|
35
|
+
# `event_version` and are dispatched in-memory before any read happens.
|
|
36
|
+
module Upcaster
|
|
37
|
+
# Identity sentinel — `upcasts "Foo", from: N, to: N, &Acta::Upcaster::NO_OP`
|
|
38
|
+
# declares the post-migration record at version N as a no-op pass-through
|
|
39
|
+
# (e.g. a `GoalPromotedToWorkspace` event whose effect is already produced
|
|
40
|
+
# by upcasting earlier events).
|
|
41
|
+
NO_OP = lambda { |event, _context| event }.freeze
|
|
42
|
+
|
|
43
|
+
def self.included(base)
|
|
44
|
+
base.extend(ClassMethods)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module ClassMethods
|
|
48
|
+
# Declare a transform. `from` and `to` are integer schema versions on
|
|
49
|
+
# the same event type; `to` must be >= `from`. The block receives an
|
|
50
|
+
# upcast-shaped record and the per-replay context, and must return
|
|
51
|
+
# either a single upcasted record, an array (1-to-many — each branch
|
|
52
|
+
# continues chaining independently), `nil`/`[]` (drop on replay), or
|
|
53
|
+
# call `context.fail_replay!(reason)`.
|
|
54
|
+
def upcasts(event_type, from:, to:, &block)
|
|
55
|
+
raise UpcasterRegistryError, "from must be an Integer" unless from.is_a?(Integer)
|
|
56
|
+
raise UpcasterRegistryError, "to must be an Integer" unless to.is_a?(Integer)
|
|
57
|
+
raise UpcasterRegistryError, "to (#{to}) must be >= from (#{from})" if to < from
|
|
58
|
+
raise UpcasterRegistryError, "block required for upcasts(#{event_type.inspect}, from: #{from}, to: #{to})" unless block
|
|
59
|
+
|
|
60
|
+
upcaster_registrations << { event_type: event_type.to_s, from:, to:, block: }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def upcaster_registrations
|
|
64
|
+
@upcaster_registrations ||= []
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Per-replay state carrier passed to every upcaster block. Hash-shaped
|
|
69
|
+
# by default — `context[:goal_to_workspace] ||= {}`. Lives the length
|
|
70
|
+
# of one replay; never persisted across runs.
|
|
71
|
+
class Context
|
|
72
|
+
class FailReplay < StandardError; end
|
|
73
|
+
|
|
74
|
+
def initialize
|
|
75
|
+
@store = {}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def [](key)
|
|
79
|
+
@store[key] ||= {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def []=(key, value)
|
|
83
|
+
@store[key] = value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def key?(key)
|
|
87
|
+
@store.key?(key)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Halt the replay with a clear reason. Wrapped by the pipeline into
|
|
91
|
+
# `Acta::ReplayHaltedByUpcaster`, which carries the offending record.
|
|
92
|
+
def fail_replay!(reason)
|
|
93
|
+
raise FailReplay, reason
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# In-memory record shape passed to upcaster blocks. Wraps a backing
|
|
98
|
+
# `Acta::Record` (the row as stored) with optional overlays for
|
|
99
|
+
# `event_type`, `event_version`, and `payload` — upcasters mutate
|
|
100
|
+
# *only* the overlays, never the stored row.
|
|
101
|
+
class View
|
|
102
|
+
ENVELOPE_FIELDS = %i[id uuid occurred_at recorded_at actor_type actor_id source metadata stream_type stream_key stream_sequence].freeze
|
|
103
|
+
|
|
104
|
+
attr_reader :base, :event_type, :event_version, :payload
|
|
105
|
+
|
|
106
|
+
def initialize(base, event_type: nil, event_version: nil, payload: nil)
|
|
107
|
+
@base = base
|
|
108
|
+
@event_type = event_type || base.event_type
|
|
109
|
+
@event_version = event_version || base.event_version
|
|
110
|
+
@payload = payload || (base.payload || {})
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
ENVELOPE_FIELDS.each do |field|
|
|
114
|
+
define_method(field) { base.public_send(field) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Produce a new View with the supplied attributes overlaid. `type`
|
|
118
|
+
# defaults to the current event_type; `payload` defaults to the
|
|
119
|
+
# current payload; `schema_version` is required and replaces
|
|
120
|
+
# `event_version`. The original (and the underlying Record) are
|
|
121
|
+
# untouched.
|
|
122
|
+
def upcast_to(type: nil, payload: nil, schema_version:)
|
|
123
|
+
raise ArgumentError, "schema_version required" if schema_version.nil?
|
|
124
|
+
|
|
125
|
+
View.new(
|
|
126
|
+
base,
|
|
127
|
+
event_type: type || @event_type,
|
|
128
|
+
event_version: schema_version,
|
|
129
|
+
payload: payload || @payload
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Holds the merged set of `(event_type, from) → block` entries from
|
|
135
|
+
# every registered upcaster class. Also tracks the max `to` per event
|
|
136
|
+
# type so the pipeline can flag future-version records cleanly.
|
|
137
|
+
class Registry
|
|
138
|
+
def initialize
|
|
139
|
+
@by_key = {}
|
|
140
|
+
@latest_to = Hash.new(0)
|
|
141
|
+
@registered_classes = []
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def register(upcaster_class)
|
|
145
|
+
return if @registered_classes.include?(upcaster_class)
|
|
146
|
+
|
|
147
|
+
upcaster_class.upcaster_registrations.each do |reg|
|
|
148
|
+
key = [ reg[:event_type], reg[:from] ]
|
|
149
|
+
if @by_key.key?(key)
|
|
150
|
+
existing = @by_key[key]
|
|
151
|
+
raise UpcasterRegistryError,
|
|
152
|
+
"Conflicting upcasters for #{reg[:event_type].inspect} v#{reg[:from]}: " \
|
|
153
|
+
"#{existing[:owner].name} already registered the (event_type, from) pair; " \
|
|
154
|
+
"#{upcaster_class.name} tried to register it again."
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@by_key[key] = reg.merge(owner: upcaster_class)
|
|
158
|
+
@latest_to[reg[:event_type]] = [ @latest_to[reg[:event_type]], reg[:to] ].max
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@registered_classes << upcaster_class
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def find(event_type, from)
|
|
165
|
+
@by_key[[ event_type, from ]]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def latest_for(event_type)
|
|
169
|
+
@latest_to[event_type]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def empty?
|
|
173
|
+
@by_key.empty?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def clear!
|
|
177
|
+
@by_key.clear
|
|
178
|
+
@latest_to.clear
|
|
179
|
+
@registered_classes.clear
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Walk a record through every matching upcaster, returning 0..N
|
|
184
|
+
# upcasted records. Identity when no upcaster matches. Handles:
|
|
185
|
+
# - chain: block returns a single record → loop continues at new (event_type, event_version)
|
|
186
|
+
# - 1-to-many: block returns an array → each branch recurses (so chaining + fan-out compose)
|
|
187
|
+
# - drop: block returns nil or [] → record produces no projection input
|
|
188
|
+
# - fail: block calls `context.fail_replay!` → halts with `ReplayHaltedByUpcaster`
|
|
189
|
+
# - future ver: stored event_version exceeds anything we can reach → `FutureSchemaVersion`
|
|
190
|
+
def self.upcast(record, context, registry: Acta.upcaster_registry)
|
|
191
|
+
origin = record.respond_to?(:base) ? record.base : record
|
|
192
|
+
current = record.is_a?(View) ? record : View.new(record)
|
|
193
|
+
return [ current ] if registry.empty?
|
|
194
|
+
|
|
195
|
+
loop do
|
|
196
|
+
reg = registry.find(current.event_type, current.event_version)
|
|
197
|
+
|
|
198
|
+
unless reg
|
|
199
|
+
known_max = registry.latest_for(current.event_type)
|
|
200
|
+
if known_max.positive? && current.event_version > known_max
|
|
201
|
+
raise FutureSchemaVersion.new(record: origin, latest_known_version: known_max)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
break
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
result = begin
|
|
208
|
+
reg[:block].call(current, context)
|
|
209
|
+
rescue Context::FailReplay => e
|
|
210
|
+
raise ReplayHaltedByUpcaster.new(record: origin, reason: e.message)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
return [] if result.nil? || (result.is_a?(Array) && result.empty?)
|
|
214
|
+
|
|
215
|
+
if result.is_a?(Array)
|
|
216
|
+
return result.flat_map { |branch| upcast(branch, context, registry: registry) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
unless result.is_a?(View)
|
|
220
|
+
raise UpcasterRegistryError,
|
|
221
|
+
"Upcaster #{reg[:owner].name} for #{current.event_type} v#{current.event_version} " \
|
|
222
|
+
"returned #{result.class} — expected an Acta::Upcaster::View " \
|
|
223
|
+
"(use `event.upcast_to(...)` to produce one)."
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
if result.event_version == current.event_version && result.event_type == current.event_type
|
|
227
|
+
# Identity at the current version (e.g. NO_OP). Stop the loop —
|
|
228
|
+
# otherwise we'd recurse forever on the same (type, version) key.
|
|
229
|
+
current = result
|
|
230
|
+
break
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
current = result
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
[ current ]
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
data/lib/acta/version.rb
CHANGED
data/lib/acta.rb
CHANGED
|
@@ -17,6 +17,7 @@ require_relative "acta/projection"
|
|
|
17
17
|
require_relative "acta/reactor"
|
|
18
18
|
require_relative "acta/reactor_job"
|
|
19
19
|
require_relative "acta/command"
|
|
20
|
+
require_relative "acta/upcaster"
|
|
20
21
|
require_relative "acta/projection_managed"
|
|
21
22
|
require_relative "acta/railtie" if defined?(::Rails::Railtie)
|
|
22
23
|
|
|
@@ -155,12 +156,29 @@ module Acta
|
|
|
155
156
|
projection_classes << klass unless projection_classes.include?(klass)
|
|
156
157
|
end
|
|
157
158
|
|
|
159
|
+
# Register a set of upcasters (a module/class that `include Acta::Upcaster`
|
|
160
|
+
# and declares `upcasts(...)` blocks). Idempotent — re-registering the
|
|
161
|
+
# same class is a no-op. See `Acta::Upcaster`.
|
|
162
|
+
def self.register_upcaster(klass)
|
|
163
|
+
upcaster_registry.register(klass)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.upcaster_registry
|
|
167
|
+
@upcaster_registry ||= Upcaster::Registry.new
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.reset_upcasters!
|
|
171
|
+
upcaster_registry.clear!
|
|
172
|
+
end
|
|
173
|
+
|
|
158
174
|
def self.rebuild!
|
|
159
175
|
Projection.applying! { truncate_projections! }
|
|
176
|
+
context = Upcaster::Context.new
|
|
160
177
|
Record.order(:id).find_each do |record|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
178
|
+
events.upcast_and_hydrate(record, context).each do |event|
|
|
179
|
+
dispatch(event, kind: :projection)
|
|
180
|
+
end
|
|
181
|
+
rescue ProjectionError, ReplayHaltedByUpcaster, FutureSchemaVersion
|
|
164
182
|
raise
|
|
165
183
|
rescue StandardError => e
|
|
166
184
|
raise ReplayError.new(record:, original: e)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: acta
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0.alpha.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Gladhill
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-05-23 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activejob
|
|
@@ -92,6 +92,7 @@ files:
|
|
|
92
92
|
- config/routes.rb
|
|
93
93
|
- docs/README.md
|
|
94
94
|
- docs/event_driven_pub_sub.md
|
|
95
|
+
- docs/upcasters.md
|
|
95
96
|
- gemfiles/rails_7_2.gemfile
|
|
96
97
|
- gemfiles/rails_8_0.gemfile
|
|
97
98
|
- gemfiles/rails_8_1.gemfile
|
|
@@ -122,6 +123,7 @@ files:
|
|
|
122
123
|
- lib/acta/types/array.rb
|
|
123
124
|
- lib/acta/types/encrypted_string.rb
|
|
124
125
|
- lib/acta/types/model.rb
|
|
126
|
+
- lib/acta/upcaster.rb
|
|
125
127
|
- lib/acta/version.rb
|
|
126
128
|
- lib/acta/web.rb
|
|
127
129
|
- lib/acta/web/engine.rb
|