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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c2218e4d40ef364d3dba8452e489a06f3e6d0503726e4d95660f7637e00efdc
4
- data.tar.gz: a3602d1b12e575f44f3f0bf4acdad256d5bd25a9ede5f43688f869015b96abe8
3
+ metadata.gz: 0af752d227a9c1f376f4e1201bd2ba2b60faf5d178a1db06975d8bdaec0c66ef
4
+ data.tar.gz: 417ec6f02d8348355bccf44d0682a534d8e3dc5f7a9d6364b5a3d31326e04e76
5
5
  SHA512:
6
- metadata.gz: ac176442ad27e359358e7f59f585facf1ee2dc6c11902737e9da2e1f7b7b354d14ce40aed1bd80e9e4ad12a04f2f5f8d0b9d548c9077fdc58c0debf0f755ac66
7
- data.tar.gz: 5de77801dd8088ae0f3699dae67388db5db0688f3de840c31938073ef5bd57f843f543b2c8584633a4a637cdf41a6f359bec65c9d59c5622228fc80783dba691
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
@@ -7,19 +7,27 @@ module Acta
7
7
  end
8
8
 
9
9
  def last
10
- hydrate(@scope.last)
10
+ upcast_and_hydrate_one(@scope.last)
11
11
  end
12
12
 
13
13
  def first
14
- hydrate(@scope.first)
14
+ upcast_and_hydrate_one(@scope.first)
15
15
  end
16
16
 
17
17
  def find_by_uuid(uuid)
18
- hydrate(@scope.find_by(uuid:))
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
- @scope.map { |record| hydrate(record) }
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
- class Record < ActiveRecord::Base
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Acta
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0.alpha.1"
5
5
  end
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
- event = events.find_by_uuid(record.uuid)
162
- dispatch(event, kind: :projection)
163
- rescue ProjectionError
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.3.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-04-28 00:00:00.000000000 Z
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