acta 0.2.0 → 0.4.0.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,258 @@
1
+ # Event-driven pub/sub
2
+
3
+ The simplest useful shape for an Acta app: one domain event, multiple
4
+ independent subscribers, no event sourcing. The event is the
5
+ publication; reactors are the subscribers; the events table is your
6
+ audit log for free.
7
+
8
+ ## The scenario
9
+
10
+ A user signs up. As a result, several independent things should
11
+ happen:
12
+
13
+ - A welcome email is sent.
14
+ - An analytics service is pinged.
15
+ - The signup is recorded in an audit log.
16
+
17
+ These concerns have different owners, change at different rates, and
18
+ fail in different ways. Coupling them in the controller (or worse, in
19
+ an `after_create_commit` callback on the `User` model) means every
20
+ new concern requires editing the same file, and a flaky third-party
21
+ analytics call can roll back the user creation.
22
+
23
+ With Acta:
24
+
25
+ ```ruby
26
+ # app/events/user_signed_up.rb
27
+ class UserSignedUp < Acta::Event
28
+ stream :user, key: :user_id
29
+
30
+ attribute :user_id, :string
31
+ attribute :email, :string
32
+ attribute :referral_code, :string
33
+
34
+ validates :user_id, :email, presence: true
35
+ end
36
+ ```
37
+
38
+ The signup path creates the AR record and emits the event in the
39
+ same transaction:
40
+
41
+ ```ruby
42
+ # app/controllers/registrations_controller.rb
43
+ class RegistrationsController < ApplicationController
44
+ def create
45
+ ApplicationRecord.transaction do
46
+ user = User.create!(user_params)
47
+
48
+ Acta.emit(UserSignedUp.new(
49
+ user_id: user.id,
50
+ email: user.email,
51
+ referral_code: params[:referral_code]
52
+ ))
53
+ end
54
+
55
+ redirect_to dashboard_path
56
+ end
57
+ end
58
+ ```
59
+
60
+ The explicit `transaction` block is the load-bearing detail. `Acta.emit`
61
+ opens its own inner transaction (with `requires_new: true`), which
62
+ becomes a savepoint inside the outer one — so either the user row
63
+ *and* the event row commit together, or neither does. Without the
64
+ outer transaction these would be two independent commits, and a
65
+ process crash or event validation error between them would leave
66
+ you with a user who has no audit trail, no welcome email, and no
67
+ analytics ping.
68
+
69
+ That's it for the publisher. Each subscriber lives in its own file,
70
+ declares what it cares about, and ignores everything else.
71
+
72
+ ## Subscribers
73
+
74
+ ```ruby
75
+ # app/reactors/welcome_email_reactor.rb
76
+ class WelcomeEmailReactor < Acta::Reactor
77
+ on UserSignedUp do |event|
78
+ UserMailer.welcome(event.user_id).deliver_later
79
+ end
80
+ end
81
+ ```
82
+
83
+ ```ruby
84
+ # app/reactors/analytics_reactor.rb
85
+ class AnalyticsReactor < Acta::Reactor
86
+ on UserSignedUp do |event|
87
+ AnalyticsClient.track(
88
+ user_id: event.user_id,
89
+ event: "signup",
90
+ props: { referral_code: event.referral_code }
91
+ )
92
+ end
93
+ end
94
+ ```
95
+
96
+ The audit log subscriber doesn't exist as code — Acta writes every
97
+ emitted event to the `events` table by default. Browse it at `/acta`
98
+ (see the [Acta::Web engine][acta-web]) or query directly via
99
+ `Acta.events`.
100
+
101
+ [acta-web]: ../README.md#acta-web
102
+
103
+ ## What just happened
104
+
105
+ Each reactor runs **after** the database commit that wrote the event,
106
+ **asynchronously** by default (via ActiveJob). So:
107
+
108
+ - Because the controller wraps both writes in
109
+ `ApplicationRecord.transaction`, the user row and the event row
110
+ commit together. If either raises, neither is persisted — no
111
+ welcome email to a user that doesn't exist.
112
+ - Each reactor enqueues its own job. The welcome email and the
113
+ analytics ping run in parallel, isolated from each other.
114
+ - A failing analytics call doesn't roll back the signup, doesn't
115
+ block the email, doesn't surface to the user. ActiveJob's retry
116
+ semantics apply per-reactor.
117
+ - New subscribers are additive. To send a referral credit when a
118
+ signup uses a code, write a third reactor — no change to the
119
+ controller, the event, or the existing reactors.
120
+
121
+ ### A subtle caveat about reactor enqueue timing
122
+
123
+ Reactors are dispatched after Acta's inner savepoint releases but
124
+ *before* the outer transaction commits. Whether that opens a
125
+ "reactor fired but the user write rolled back" window depends on
126
+ your ActiveJob queue adapter:
127
+
128
+ - **DB-backed queues** (Solid Queue, GoodJob, Que) — the enqueue is
129
+ a row insert that participates in the outer transaction. A
130
+ rollback un-enqueues the job. Atomic.
131
+ - **Redis-backed queues** (Sidekiq) — the enqueue hits Redis
132
+ immediately and survives a rollback. Small window where the email
133
+ goes out but the user doesn't exist. Rails 7.2+ exposes
134
+ `enqueue_after_transaction_commit` to opt into deferred enqueue,
135
+ which closes the window.
136
+ - **Sync reactors** (`sync!`) — run inline during dispatch. Side
137
+ effects (email sent, third-party API called) happen before the
138
+ outer commits and can't be undone by a rollback. Reach for
139
+ `sync!` only when the side effect is itself a DB write inside the
140
+ same transaction, or when "fired but rolled back" is acceptable.
141
+
142
+ On the Rails 8.x + Solid Queue default stack, the right behaviour
143
+ falls out without extra configuration.
144
+
145
+ ## Synchronous when you need it
146
+
147
+ For tests and the rare side effect that must happen inside the same
148
+ request, opt a reactor into sync mode:
149
+
150
+ ```ruby
151
+ class CreateBillingAccountReactor < Acta::Reactor
152
+ sync!
153
+
154
+ on UserSignedUp do |event|
155
+ BillingAccount.create!(user_id: event.user_id, plan: "free")
156
+ end
157
+ end
158
+ ```
159
+
160
+ Sync reactors run **after-commit but in the caller's thread**. They
161
+ still don't block the DB transaction (so they can't roll the signup
162
+ back), but they do block the response. Reach for this when the
163
+ follow-up state must exist before the next user action — and only
164
+ then.
165
+
166
+ ## Testing
167
+
168
+ Reactor tests usually just want to assert that a side effect was
169
+ triggered. Use the matchers:
170
+
171
+ ```ruby
172
+ require "acta/testing"
173
+ require "acta/testing/matchers"
174
+
175
+ RSpec.describe RegistrationsController do
176
+ it "publishes UserSignedUp on successful signup" do
177
+ expect {
178
+ post :create, params: { user: { email: "alice@example.com" } }
179
+ }.to emit(UserSignedUp).with(email: "alice@example.com")
180
+ end
181
+ end
182
+ ```
183
+
184
+ For the reactor itself, run it inline so the side effect actually
185
+ fires:
186
+
187
+ ```ruby
188
+ RSpec.describe WelcomeEmailReactor do
189
+ it "sends the welcome email" do
190
+ Acta::Testing.test_mode do
191
+ Acta.emit(UserSignedUp.new(user_id: "u_1", email: "alice@example.com"))
192
+ end
193
+
194
+ expect(UserMailer.deliveries.last.to).to eq([ "alice@example.com" ])
195
+ end
196
+ end
197
+ ```
198
+
199
+ `Acta::Testing.test_mode` runs reactors inline for the duration of
200
+ the block, regardless of the `sync!` declaration on the class. It
201
+ keeps reactor tests synchronous without committing the whole reactor
202
+ to sync mode in production.
203
+
204
+ ## When this isn't the right shape
205
+
206
+ This pattern works when the AR records (`User`, `BillingAccount`) are
207
+ the source of truth and the events are notifications about state
208
+ changes happening elsewhere. It does **not** make the event log the
209
+ authoritative source of state — `User.create!` happens before any
210
+ event is emitted, and dropping the events table doesn't recreate
211
+ users on the next `Acta.rebuild!`.
212
+
213
+ When you want the events to *be* the source of truth — when
214
+ `Acta.rebuild!` should reproduce the projected state from the log
215
+ alone — reach for projections instead. See the [event sourcing][es]
216
+ pattern.
217
+
218
+ [es]: ../README.md#4-project-state-event-sourced
219
+
220
+ ## Compared to the alternatives
221
+
222
+ | | AR callbacks | `ActiveSupport::Notifications` | [Wisper][wisper] | Acta event-driven |
223
+ |---|---|---|---|---|
224
+ | Persistence | None | None | None | Yes — full payload, actor, timestamps |
225
+ | Async by default | No (in tx) | No (in caller) | No (in caller); async via wisper-sidekiq | Yes (ActiveJob) |
226
+ | Failure isolation | No (rolls back tx) | Sometimes | Subscriber errors propagate to publisher | Yes (per-reactor jobs) |
227
+ | Replay-able | No | No | No | Yes (the events are still there) |
228
+ | Payload typing | AR attributes | Untyped hash | Untyped args | ActiveModel-typed attributes with validations |
229
+ | Subscriber discovery | Reading the model file | Grep the codebase for `subscribe` | Subscriber registration code | `app/reactors/` directory |
230
+ | Test ergonomics | Stubs all the way down | Subscribe a block in spec | wisper-rspec matchers | Built-in matchers + `test_mode` |
231
+
232
+ [wisper]: https://github.com/krisleech/wisper
233
+
234
+ `ActiveSupport::Notifications` is the in-process, ephemeral cousin —
235
+ fire-and-forget, no persistence, ideal for instrumentation (metrics,
236
+ traces, logs) but a poor fit for domain events that other parts of
237
+ the system need to react to.
238
+
239
+ **Wisper** is the long-standing prior art for Rails domain pub/sub —
240
+ publish a symbol-named event, subscribers register interest, the
241
+ gem dispatches. It's at v3.0.0 (May 2024) with light ongoing
242
+ maintenance; not abandoned, not actively developed either. Reach for
243
+ Wisper when you want to decouple callbacks without buying into
244
+ event sourcing or a persistent log: subscriptions are dynamic,
245
+ events are untyped (any args you want), and the runtime is
246
+ process-local. Acta differs in three load-bearing ways: events are
247
+ **typed classes** with validated payload schemas (so a typo in a
248
+ field name is a class-load error, not a runtime nil); subscribers
249
+ are **after-commit + async** by default (so a flaky external API
250
+ call doesn't roll back the publisher's transaction); and every
251
+ publication is **persisted** in the events table (so you have an
252
+ audit log, can replay history, and can survive a process restart
253
+ mid-flight on a notification).
254
+
255
+ The honest summary: AS::Notifications for instrumentation, Wisper
256
+ for lightweight in-process pub/sub without persistence, Acta when
257
+ the publication itself needs to be durable and the subscribers are
258
+ fan-out side effects you want isolated from the request path.
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.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 7.2.0"
6
+ gem "activemodel", "~> 7.2.0"
7
+ gem "activerecord", "~> 7.2.0"
8
+ gem "activesupport", "~> 7.2.0"
9
+ gem "railties", "~> 7.2.0"
10
+
11
+ group :development, :test do
12
+ gem "irb"
13
+ gem "pg", "~> 1.5"
14
+ gem "rake", "~> 13.0"
15
+ gem "rspec", "~> 3.13"
16
+ gem "rubocop-rails-omakase", require: false
17
+ gem "sqlite3", "~> 2.0"
18
+ end
19
+
20
+ gemspec path: ".."
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 8.0.0"
6
+ gem "activemodel", "~> 8.0.0"
7
+ gem "activerecord", "~> 8.0.0"
8
+ gem "activesupport", "~> 8.0.0"
9
+ gem "railties", "~> 8.0.0"
10
+
11
+ group :development, :test do
12
+ gem "irb"
13
+ gem "pg", "~> 1.5"
14
+ gem "rake", "~> 13.0"
15
+ gem "rspec", "~> 3.13"
16
+ gem "rubocop-rails-omakase", require: false
17
+ gem "sqlite3", "~> 2.0"
18
+ end
19
+
20
+ gemspec path: ".."
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 8.1.0"
6
+ gem "activemodel", "~> 8.1.0"
7
+ gem "activerecord", "~> 8.1.0"
8
+ gem "activesupport", "~> 8.1.0"
9
+ gem "railties", "~> 8.1.0"
10
+
11
+ group :development, :test do
12
+ gem "irb"
13
+ gem "pg", "~> 1.5"
14
+ gem "rake", "~> 13.0"
15
+ gem "rspec", "~> 3.13"
16
+ gem "rubocop-rails-omakase", require: false
17
+ gem "sqlite3", "~> 2.0"
18
+ end
19
+
20
+ gemspec path: ".."
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