tcb 0.5.0 → 0.6.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.
data/README.md CHANGED
@@ -44,45 +44,117 @@ end
44
44
  bus.publish(UserRegistered.new(id: 1, email: "alice@example.com"))
45
45
  ```
46
46
 
47
- ### `TCB::HandlesEvents`
47
+ ### Execution model
48
+
49
+ TCB::EventBus uses a single background thread to process events. Publishing is non-blocking — the event is placed on a queue and control returns to the caller immediately. The dispatcher thread processes events in FIFO order. Handlers for a given event execute sequentially within the dispatcher thread.
50
+
51
+ This design favors determinism and simplicity: events are always processed in the order they were published, and handlers cannot race with each other.
52
+
53
+ For tests and simple use cases, `sync: true` executes handlers in the caller thread immediately — no background thread, no queue:
54
+
55
+ ```ruby
56
+ bus = TCB::EventBus.new(sync: true)
57
+ ```
58
+
59
+ ### Delivery guarantees
60
+
61
+ TCB guarantees:
62
+ - Events published to the bus will be dispatched to all registered handlers, in the order they were published, as long as the process remains alive.
63
+ - If `TCB.record` is used before `TCB.publish`, events are persisted to the event store before any handler runs.
64
+
65
+ TCB does not guarantee:
66
+ - That published events will be processed if the process crashes after publish but before handlers complete.
67
+ - At-least-once, at-most-once, or exactly-once delivery.
68
+ - Retry on handler failure. Failed handlers emit `TCB::SubscriberInvocationFailed`. Retry is the responsibility of the application.
69
+
70
+ If stronger delivery guarantees are required, use a durable external queue (SolidQueue, Sidekiq, etc.) and trigger TCB handlers from jobs.
71
+
72
+ ### Backpressure
73
+
74
+ The event queue is unbounded by default. If handlers are slower than the rate of publishing, the queue will grow without limit. For production systems under sustained load, set `max_queue_size:` to apply backpressure:
75
+
76
+ ```ruby
77
+ TCB::EventBus.new(max_queue_size: 10_000)
78
+ ```
79
+
80
+ When the queue is full, `publish` blocks until space is available. The right value depends on your event volume and handler latency — measure before deciding.
81
+
82
+ ---
83
+
84
+ ## TCB::HandlesEvents
48
85
 
49
86
  Instead of block handlers, reactions can be declared as classes inside a module. This keeps handlers close to the domain and readable at a glance.
50
87
 
51
88
  ```ruby
52
- module Notifications
53
- include TCB::HandlesEvents
89
+ # app/domain/warehouse.rb
90
+ module Warehouse
91
+ include TCB::Domain
92
+
93
+ StockReserved = Data.define(:order_id)
94
+
95
+ persist events(
96
+ StockReserved,
97
+ stream_id_from_event: :order_id
98
+ )
54
99
 
55
- on Auth::UserRegistered, react_with(SendWelcomeEmail, TrackRegistration)
56
- on Orders::OrderPlaced, react_with(SendOrderConfirmation)
100
+ on Sales::OrderPlaced, react_with(ReserveStock)
57
101
  end
58
102
 
59
- class SendWelcomeEmail
60
- def call(event)
61
- WelcomeMailer.deliver(event.email)
103
+ # app/domain/warehouse/reserve_stock.rb
104
+ module Warehouse
105
+ class ReserveStock
106
+ def call(event)
107
+ stock = Stock.new(id: event.order_id)
108
+
109
+ events = TCB.record(events_from: [stock], within: ApplicationRecord) do
110
+ stock.reserve
111
+ end
112
+
113
+ TCB.publish(*events)
114
+ end
62
115
  end
63
116
  end
117
+ ```
118
+
119
+ ```ruby
120
+ # app/domain/notifications.rb
121
+ module Notifications
122
+ include TCB::HandlesEvents
64
123
 
65
- class TrackRegistration
66
- def call(event)
67
- Analytics.track("user_registered", user_id: event.id)
124
+ on Warehouse::StockReserved, react_with(NotifyCustomer)
125
+ end
126
+
127
+ # app/domain/notifications/notify_customer.rb
128
+ module Notifications
129
+ class NotifyCustomer
130
+ def call(event)
131
+ events = TCB.record(events: [CustomerNotified.new(order_id: event.order_id)])
132
+ TCB.publish(*events)
133
+ end
68
134
  end
135
+
136
+ CustomerNotified = Data.define(:order_id)
69
137
  end
70
138
  ```
71
139
 
72
- Event classes can come from anywhere. `TCB::HandlesEvents` only cares that they are published on the bus. Cross-module reactions are the norm, not the exception.
140
+ Event classes can come from anywhere. Cross-module reactions are the norm, not the exception. Each handler is isolated. Ine failure does not prevent others from executing.
73
141
 
74
- Register at configuration time:
142
+ Domain modules are declared once at the top level, before infrastructure is configured.
143
+ This is the only place TCB needs to know about your bounded contexts — all reactions,
144
+ persistence rules, and handler mappings live inside each module itself.
75
145
 
76
146
  ```ruby
147
+ TCB.domain_modules = [Sales, Warehouse, Notifications]
148
+
77
149
  TCB.configure do |c|
78
- c.event_bus = TCB::EventBus.new
79
- c.domain_modules = [Notifications]
150
+ c.event_bus = TCB::EventBus.new
151
+ c.event_store = TCB::EventStore::ActiveRecord.new
80
152
  end
81
-
82
- TCB.publish(Auth::UserRegistered.new(id: 1, email: "alice@example.com"))
83
153
  ```
84
154
 
85
- Handlers execute asynchronously in a background thread. Each handler is isolated. One failure does not prevent others from executing.
155
+ `TCB.domain_modules=` wires up subscriptions and command routing from all modules.
156
+ `TCB.configure` provides the infrastructure they run on. The two are intentionally
157
+ separate — domain modules don't change between environments, infrastructure does.
86
158
 
87
159
  ---
88
160
 
@@ -114,9 +186,9 @@ module Orders
114
186
  handle PlaceOrder, with(PlaceOrderHandler)
115
187
  end
116
188
 
189
+ TCB.domain_modules = [ Orders ]
117
190
  TCB.configure do |c|
118
- c.event_bus = TCB::EventBus.new
119
- c.domain_modules = [Orders]
191
+ c.event_bus = TCB::EventBus.new
120
192
  end
121
193
 
122
194
  TCB.dispatch(PlaceOrder.new(order_id: 42, customer: "Alice"))
@@ -136,12 +208,16 @@ Keep domain code together. TCB convention is a single `app/domain` folder. Beyon
136
208
 
137
209
  ```
138
210
  app/domain/
139
- orders.rb # domain module, public interface
140
- orders/
141
- order.rb # aggregate
211
+ sales.rb # domain module, public interface
212
+ sales/
213
+ order.rb # aggregate
142
214
  place_order_handler.rb
143
- reserve_inventory.rb
144
- charge_payment.rb
215
+ warehouse.rb
216
+ warehouse/
217
+ reserve_stock.rb
218
+ notifications.rb
219
+ notifications/
220
+ notify_customer.rb
145
221
  ```
146
222
 
147
223
  ### The domain module
@@ -149,50 +225,36 @@ app/domain/
149
225
  The domain module is a boundary. Everything inside speaks the domain language. Nothing leaks out, nothing bleeds in. Keep everything that belongs together, together. Events, commands, persistence rules, and reactions are all declared in one place:
150
226
 
151
227
  ```ruby
152
- # app/domain/orders.rb
153
- module Orders
228
+ # app/domain/sales.rb
229
+ module Sales
154
230
  include TCB::Domain
155
231
 
232
+ # Facade
233
+ def self.place!(order_id:, customer:)
234
+ TCB.dispatch(PlaceOrder.new(order_id: order_id, customer: customer))
235
+ end
236
+
156
237
  # Events
157
- OrderPlaced = Data.define(:order_id, :customer)
158
- OrderCancelled = Data.define(:order_id, :reason)
238
+ OrderPlaced = Data.define(:order_id, :customer)
159
239
 
160
240
  # Commands
161
- PlaceOrder = Data.define(:order_id, :customer) do
241
+ PlaceOrder = Data.define(:order_id, :customer) do
162
242
  def validate!
163
243
  raise ArgumentError, "customer required" if customer.nil?
164
244
  end
165
245
  end
166
246
 
167
- CancelOrder = Data.define(:order_id, :reason) do
168
- def validate!
169
- raise ArgumentError, "reason required" if reason.nil?
170
- end
171
- end
172
-
173
247
  # Persistence
174
248
  persist events(
175
249
  OrderPlaced,
176
- OrderCancelled,
177
250
  stream_id_from_event: :order_id
178
251
  )
179
252
 
180
253
  # Commands
181
- handle PlaceOrder, with(PlaceOrderHandler)
182
- handle CancelOrder, with(CancelOrderHandler)
254
+ handle PlaceOrder, with(PlaceOrderHandler)
183
255
 
184
256
  # Reactions
185
- on OrderPlaced, react_with(ReserveInventory, ChargePayment)
186
- on OrderCancelled, react_with(RefundPayment)
187
-
188
- # Facade
189
- def self.place!(order_id:, customer:)
190
- TCB.dispatch(PlaceOrder.new(order_id: order_id, customer: customer))
191
- end
192
-
193
- def self.cancel!(order_id:, reason:)
194
- TCB.dispatch(CancelOrder.new(order_id: order_id, reason: reason))
195
- end
257
+ on OrderPlaced, react_with(Warehouse::ReserveStock)
196
258
  end
197
259
  ```
198
260
 
@@ -201,8 +263,7 @@ end
201
263
  The facade is the public contract. Callers get plain method calls with meaningful names. TCB stays out of sight:
202
264
 
203
265
  ```ruby
204
- Orders.place!(order_id: 42, customer: "Alice")
205
- Orders.cancel!(order_id: 42, reason: "changed mind")
266
+ Sales.place!(order_id: 42, customer: "Alice")
206
267
  ```
207
268
 
208
269
  Facade methods use the bang convention. `TCB.dispatch` calls `validate!` on the command before routing it to the handler and raises if validation fails. Naming facade methods with `!` signals to callers that exceptions are expected.
@@ -214,8 +275,8 @@ An aggregate is the consistency boundary around your domain state. It decides wh
214
275
  The `TCB.record` block is the transactional boundary. Pass one aggregate or many. Either everything is persisted as it should be, or nothing is. The domain stays in a valid state.
215
276
 
216
277
  ```ruby
217
- # app/domain/orders/order.rb
218
- module Orders
278
+ # app/domain/sales/order.rb
279
+ module Sales
219
280
  class Order
220
281
  include TCB::RecordsEvents
221
282
 
@@ -226,10 +287,6 @@ module Orders
226
287
  def place(customer:)
227
288
  record OrderPlaced.new(order_id: id, customer: customer)
228
289
  end
229
-
230
- def cancel(reason:)
231
- record OrderCancelled.new(order_id: id, reason: reason)
232
- end
233
290
  end
234
291
  end
235
292
  ```
@@ -238,17 +295,13 @@ end
238
295
 
239
296
  The command handler is the entry point into the domain. This is where you ensure the domain stays in a valid state before announcing anything to the rest of the system. Persist first, publish after.
240
297
 
241
- #### With aggregate
242
-
243
298
  ```ruby
244
- # app/domain/orders/place_order_handler.rb
245
- module Orders
299
+ # app/domain/sales/place_order_handler.rb
300
+ module Sales
246
301
  class PlaceOrderHandler
247
302
  def call(command)
248
303
  order = Order.new(id: command.order_id)
249
304
 
250
- # within: ApplicationRecord wraps the block in a database transaction.
251
- # If anything raises, no events are persisted and none are published.
252
305
  events = TCB.record(events_from: [order], within: ApplicationRecord) do
253
306
  order.place(customer: command.customer)
254
307
  end
@@ -315,25 +368,73 @@ end
315
368
 
316
369
  ## Configuration
317
370
 
318
- Each domain module gets its own database table. Domains stay isolated at the persistence level. This is a step toward event sourcing. Every business-significant moment is recorded, queryable, and replayable.
371
+ ### Domain modules
319
372
 
320
- | Module | Table |
321
- |---|---|
322
- | `Orders` | `orders_events` |
323
- | `Payments` | `payments_events` |
324
- | `Payments::Charges` | `payments_charges_events` |
373
+ Domain modules are the bounded contexts of your application. Declare them once, at the top level — before infrastructure is configured:
325
374
 
326
375
  ```ruby
327
- TCB.configure do |c|
328
- c.event_bus = TCB::EventBus.new
329
- c.event_store = TCB::EventStore::ActiveRecord.new
330
- c.domain_modules = [Orders, Payments]
376
+ # config/initializers/tcb.rb
377
+ TCB.domain_modules = [Orders, Notifications]
378
+ ```
379
+
380
+ This is the only place TCB needs to know about your domain modules. All reactions, persistence rules, and handler mappings are declared inside each module itself.
381
+
382
+ ### Infrastructure
383
+
384
+ Infrastructure is environment-specific. Configure it in each environment file so the differences are explicit and co-located with other environment settings:
385
+
386
+ ```ruby
387
+ # config/environments/development.rb
388
+ Rails.application.config.to_prepare do
389
+ TCB.reset!
390
+ TCB.configure do |c|
391
+ c.event_bus = TCB::EventBus.new(
392
+ handle_signals: false, # Rails manages process signals in development
393
+ shutdown_timeout: 10.0
394
+ )
395
+ c.event_store = TCB::EventStore::ActiveRecord.new
396
+ end
397
+ end
398
+ ```
399
+
400
+ `to_prepare` runs after every Rails reload. `TCB.reset!` shuts down the previous bus before configuring a new one. Without it, each reload would leak a dispatcher thread.
401
+
402
+ ```ruby
403
+ # config/environments/production.rb
404
+ Rails.application.config.to_prepare do
405
+ TCB.reset!(graceful_shutdown_time: 10.0)
406
+ TCB.configure do |c|
407
+ c.event_bus = TCB::EventBus.new(
408
+ handle_signals: true,
409
+ shutdown_timeout: 30.0
410
+ )
411
+ c.event_store = TCB::EventStore::ActiveRecord.new
412
+ end
413
+ end
414
+ ```
331
415
 
332
- # Optional: additional classes for YAML serialization
333
- c.extra_serialization_classes = [ActiveSupport::TimeWithZone, Money]
416
+ `handle_signals: true` installs SIGTERM/SIGINT handlers for graceful shutdown. `graceful_shutdown_time` on `reset!` gives the previous bus time to drain before replacing it.
417
+
418
+ ```ruby
419
+ # config/environments/test.rb
420
+ Rails.application.config.after_initialize do
421
+ TCB.configure do |c|
422
+ c.event_bus = TCB::EventBus.new(sync: true)
423
+ c.event_store = TCB::EventStore::InMemory.new
424
+ end
334
425
  end
335
426
  ```
336
427
 
428
+ `sync: true` executes handlers in the caller thread — no background thread, no polling. `after_initialize` runs once at boot. Between tests, call `TCB.reset!` to get a fresh bus and store.
429
+
430
+ Each domain module gets its own database table. Domains stay isolated at the persistence level:
431
+
432
+ | Module | Table |
433
+ |---|---|
434
+ | `Orders` | `orders_events` |
435
+ | `Payments` | `payments_events` |
436
+ | `Payments::Charges` | `payments_charges_events` |
437
+
337
438
  ---
338
439
 
339
440
  ## Reading Events
@@ -369,13 +470,67 @@ end
369
470
  Each result is a `TCB::Envelope`:
370
471
 
371
472
  ```ruby
372
- envelope.event # the domain event
373
- envelope.event_id # UUID string
374
- envelope.stream_id # "context|aggregate_id"
375
- envelope.version # integer, sequential per stream
376
- envelope.occurred_at # Time
473
+ envelope.event # the domain event
474
+ envelope.event_id # UUID string
475
+ envelope.stream_id # "context|aggregate_id"
476
+ envelope.version # integer, sequential per stream
477
+ envelope.occurred_at # Time
478
+ envelope.correlation_id # UUID string, shared across all events in a dispatch chain
479
+ envelope.causation_id # UUID string, event_id of the triggering event; nil for the first event
480
+ ```
481
+
482
+ ---
483
+
484
+ ## Correlation and causation tracking
485
+
486
+ Every `TCB.dispatch` generates a `correlation_id` and returns it to the caller. All events produced within that dispatch chain share the same `correlation_id`, regardless of how deep the reactive chain goes. `causation_id` identifies the direct cause — the `event_id` of the envelope that triggered the handler.
487
+
488
+ ```
489
+ Sales.place!(order_id: 42, customer: "Alice")
490
+ └─ Sales::OrderPlaced correlation_id: "req-abc", causation_id: nil
491
+ └─ Warehouse::StockReserved correlation_id: "req-abc", causation_id: OrderPlaced.event_id
492
+ └─ Notifications::CustomerNotified correlation_id: "req-abc", causation_id: StockReserved.event_id
493
+ ```
494
+
495
+ `correlation_id` can be provided externally. That's useful for tying a dispatch to an incoming HTTP request:
496
+
497
+ ```ruby
498
+ correlation_id = TCB.dispatch(
499
+ Sales::PlaceOrder.new(order_id: 42, customer: "Alice"),
500
+ correlation_id: request.uuid
501
+ )
502
+ response.set_header("X-Correlation-ID", correlation_id)
377
503
  ```
378
504
 
505
+ `correlation_id` and `causation_id` are set by `TCB.record`, not by `TCB.publish`. A handler that calls `TCB.record` within a reactive chain will always have both fields populated. A handler that only calls `TCB.publish` without `TCB.record` produces envelopes without these fields. There is no event store context to propagate from.
506
+
507
+ Handler interfaces are unchanged. `def call(event)` receives the domain event as always. Correlation and causation travel in the envelope, not in your domain code.
508
+
509
+ Events recorded outside a dispatch context (directly via `TCB.record` without a preceding `TCB.dispatch`) have `nil` for both fields. This is expected: there is no dispatch to correlate them to.
510
+
511
+ ---
512
+
513
+ ## Reading a correlation chain
514
+
515
+ To query all events across domains that share a `correlation_id`:
516
+
517
+ ```ruby
518
+ # All domains with persistence
519
+ TCB.read_correlation("req-abc").to_a
520
+
521
+ # Specific domains
522
+ TCB.read_correlation("req-abc", across: [Sales, Warehouse, Notifications]).to_a
523
+
524
+ # With time filters
525
+ TCB.read_correlation("req-abc").occurred_after(1.hour.ago).to_a
526
+ TCB.read_correlation("req-abc").occurred_before(Time.now).to_a
527
+ TCB.read_correlation("req-abc").between(1.hour.ago, Time.now).to_a
528
+ ```
529
+
530
+ Results are ordered by `occurred_at` across all domains. Each result is a `TCB::Envelope` with `correlation_id` and `causation_id` populated.
531
+
532
+ `across:` defaults to all configured domain modules that have persistence registrations. Domains without persistence — like `Notifications` in the example above — are excluded automatically.
533
+
379
534
  ---
380
535
 
381
536
  ## Event Store Adapters
@@ -395,7 +550,7 @@ TCB::EventStore::ActiveRecord.new
395
550
  Generate migration and AR model:
396
551
 
397
552
  ```
398
- bin/rails generate TCB:event_store orders
553
+ bin/rails generate tcb:event_store orders
399
554
  ```
400
555
 
401
556
  ---
@@ -407,7 +562,7 @@ TCB includes generators to scaffold domain modules, command handlers, and migrat
407
562
  ### Install
408
563
 
409
564
  ```bash
410
- rails generate TCB:install
565
+ rails generate tcb:install
411
566
  ```
412
567
 
413
568
  Creates `config/initializers/tcb.rb` with a minimal configuration template.
@@ -415,7 +570,7 @@ Creates `config/initializers/tcb.rb` with a minimal configuration template.
415
570
  ### Domain module with event store
416
571
 
417
572
  ```bash
418
- rails generate TCB:event_store orders place_order:order_id,customer cancel_order:order_id,reason
573
+ rails generate tcb:event_store orders place_order:order_id,customer cancel_order:order_id,reason
419
574
  ```
420
575
 
421
576
  Generates:
@@ -427,7 +582,7 @@ Generates:
427
582
  ### Domain module without persistence (pub/sub only)
428
583
 
429
584
  ```bash
430
- rails generate TCB:domain notifications send_welcome_email:user_id,email send_verification_sms:user_id,phone
585
+ rails generate tcb:domain notifications send_welcome_email:user_id,email send_verification_sms:user_id,phone
431
586
  ```
432
587
 
433
588
  Generates:
@@ -443,12 +598,14 @@ Generates:
443
598
  | `--skip-migration` | Skip migration (event_store only) |
444
599
  | `--no-comments` | Generate without inline guidance comments |
445
600
 
446
- After generating, add your module to `config/initializers/tcb.rb`. This is the only place TCB needs to know about your domain modules. All reactions, persistence rules, and handler mappings are declared inside the module itself, not here:
601
+ After generating, add your module to config/initializers/tcb.rb. Domain modules don't change between environments infrastructure does. Keeping them separate means your bounded contexts are declared once, while the bus and store are configured per environment:
447
602
 
448
603
  ```ruby
449
- c.domain_modules = [
450
- Orders,
451
- Notifications,
604
+ # config/initializers/tcb.rb
605
+ TCB.domain_modules = [
606
+ Sales,
607
+ Warehouse,
608
+ Notifications
452
609
  ]
453
610
  ```
454
611
 
@@ -484,89 +641,89 @@ bus.force_shutdown
484
641
 
485
642
  ## Testing
486
643
 
487
- Use `TCB::EventStore::InMemory` in tests. TCB is designed so that `TCB.reset!` fully tears down and rebuilds the configuration between tests. It shuts down the event bus, clears the event store, and re-registers all handlers.
644
+ ### Setup
488
645
 
489
- The `TCB.configure` block is stored and replayed on every `reset!`. Each test gets a fresh event bus and a clean event store automatically.
646
+ Configure TCB once at boot in `config/environments/test.rb` (see Configuration above). Between tests, call `TCB.reset!` to get a fresh event bus and a clean event store.
490
647
 
491
- ### Rails initializer
648
+ `TCB.reset!` shuts down the current bus, clears the event store, and clears all subscriptions. The next test starts with a clean slate. Domain modules do not need to be re-declared — they are set once at the top level and persist across resets.
649
+
650
+ ### Synchronous mode
651
+
652
+ With `sync: true`, handlers execute in the caller thread immediately after `publish`. No background thread, no polling, no timing concerns. Tests are faster and deterministic. This is the recommended approach.
492
653
 
493
654
  ```ruby
494
- # config/initializers/tcb.rb
495
- Rails.application.config.to_prepare do
655
+ # config/environments/test.rb
656
+ Rails.application.config.after_initialize do
496
657
  TCB.configure do |c|
497
- c.event_bus = TCB::EventBus.new(
498
- handle_signals: true,
499
- shutdown_timeout: 10.0
500
- )
501
- c.event_store = Rails.env.test? ? TCB::EventStore::InMemory.new
502
- : TCB::EventStore::ActiveRecord.new
503
- c.domain_modules = [Orders, Notifications]
658
+ c.event_bus = TCB::EventBus.new(sync: true)
659
+ c.event_store = TCB::EventStore::InMemory.new
504
660
  end
505
661
  end
506
662
  ```
507
663
 
508
- ### RSpec
664
+ ### Minitest
509
665
 
510
666
  ```ruby
511
- # spec/support/tcb.rb
512
- RSpec.configure do |config|
513
- config.include TCB::RSpecHelpers
667
+ class OrdersTest < Minitest::Test
668
+ include TCB::MinitestHelpers
669
+ def teardown = TCB.reset!
514
670
 
515
- config.after(:each) do
516
- TCB.reset!
671
+ def test_placing_order_publishes_event
672
+ assert_published(Orders::OrderPlaced) do
673
+ Orders.place!(order_id: 42, customer: "Alice")
674
+ end
517
675
  end
518
676
  end
519
677
  ```
520
678
 
521
- Require it from `rails_helper.rb`:
679
+ #### assert_published
522
680
 
523
681
  ```ruby
524
- require "support/tcb"
682
+ assert_published(Orders::OrderPlaced) { Orders.place!(order_id: 42, customer: "Alice") }
683
+ assert_published(Orders::OrderPlaced.new(order_id: 42, customer: "Alice")) { Orders.place!(...) }
684
+ assert_published(Orders::OrderPlaced, Notifications::WelcomeEmailQueued) { Orders.place!(...) }
685
+ assert_published(Orders::OrderPlaced, within: 0.5) { Orders.place!(...) }
525
686
  ```
526
687
 
527
- `TCB.reset!` replays the configure block. `Rails.env.test?` is re-evaluated each time, so `InMemory` gets a fresh instance on every reset.
688
+ #### poll_assert
528
689
 
529
- #### have_published
690
+ Only needed when using an async bus. With `TCB::EventBus.new(sync: true)`, handlers execute in the caller thread and results are available immediately — no polling required.
530
691
 
531
692
  ```ruby
532
- expect { Orders.place!(order_id: 42, customer: "Alice") }.to have_published(Orders::OrderPlaced)
533
- expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced.new(order_id: 42, customer: "Alice"))
534
- expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced, within: 0.5)
693
+ poll_assert("reserve inventory called") { CALLED.include?(:reserve_inventory) }
694
+ poll_assert("payment processed", within: 2.0) { Payment.completed? }
535
695
  ```
536
696
 
537
- #### poll_match
697
+ ### RSpec
538
698
 
539
699
  ```ruby
540
- expect { CALLED.include?(:reserve_inventory) }.to poll_match
541
- expect { Payment.completed? }.to poll_match(within: 2.0)
700
+ # spec/support/tcb.rb
701
+ RSpec.configure do |config|
702
+ config.after(:each) { TCB.reset! }
703
+ end
542
704
  ```
543
705
 
544
- ### Minitest
706
+ Require it from `rails_helper.rb`:
545
707
 
546
708
  ```ruby
547
- class OrdersTest < Minitest::Test
548
- include TCB::MinitestHelpers
549
-
550
- def teardown
551
- TCB.reset!
552
- end
553
- end
709
+ require "support/tcb"
554
710
  ```
555
711
 
556
- #### assert_published
712
+ #### have_published
557
713
 
558
714
  ```ruby
559
- assert_published(Orders::OrderPlaced) { Orders.place!(order_id: 42, customer: "Alice") }
560
- assert_published(Orders::OrderPlaced.new(order_id: 42, customer: "Alice")) { Orders.place!(...) }
561
- assert_published(Orders::OrderPlaced, Notifications::WelcomeEmailQueued) { Orders.place!(...) }
562
- assert_published(Orders::OrderPlaced, within: 0.5) { Orders.place!(...) }
715
+ expect { Orders.place!(order_id: 42, customer: "Alice") }.to have_published(Orders::OrderPlaced)
716
+ expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced.new(order_id: 42, customer: "Alice"))
717
+ expect { Orders.place!(...) }.to have_published(Orders::OrderPlaced, within: 0.5)
563
718
  ```
564
719
 
565
- #### poll_assert
720
+ #### poll_match
721
+
722
+ Only needed with an async bus.
566
723
 
567
724
  ```ruby
568
- poll_assert("handler was called") { CALLED.include?(:reserve_inventory) }
569
- poll_assert("payment processed", within: 2.0) { Payment.completed? }
725
+ expect { CALLED.include?(:reserve_inventory) }.to poll_match
726
+ expect { Payment.completed? }.to poll_match(within: 2.0)
570
727
  ```
571
728
 
572
729
  ---
@@ -5,7 +5,7 @@ require_relative "../shared/command_argument"
5
5
  module TCB
6
6
  module Generators
7
7
  class DomainGenerator < Rails::Generators::Base
8
- namespace "TCB:domain"
8
+ namespace "tcb:domain"
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
11
  argument :module_name, type: :string
@@ -5,7 +5,7 @@ require_relative "../shared/command_argument"
5
5
  module TCB
6
6
  module Generators
7
7
  class EventStoreGenerator < Rails::Generators::Base
8
- namespace "TCB:event_store"
8
+ namespace "tcb:event_store"
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
11
  argument :module_name, type: :string
@@ -1,14 +1,18 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration[8.0]
2
2
  def change
3
3
  create_table :<%= table_name %> do |t|
4
- t.string :event_id, null: false
5
- t.string :stream_id, null: false
6
- t.integer :version, null: false
7
- t.string :event_type, null: false
8
- t.text :payload, null: false
9
- t.datetime :occurred_at, null: false
4
+ t.string :event_id, null: false
5
+ t.string :stream_id, null: false
6
+ t.integer :version, null: false
7
+ t.string :event_type, null: false
8
+ t.text :payload, null: false
9
+ t.datetime :occurred_at, null: false
10
+ t.string :correlation_id
11
+ t.string :causation_id
10
12
  end
11
13
 
12
14
  add_index :<%= table_name %>, [:stream_id, :version], unique: true
15
+ add_index :<%= table_name %>, :event_id, unique: true
16
+ add_index :<%= table_name %>, :correlation_id
13
17
  end
14
- end
18
+ end
@@ -3,7 +3,7 @@
3
3
  module TCB
4
4
  module Generators
5
5
  class InstallGenerator < Rails::Generators::Base
6
- namespace "TCB:install"
6
+ namespace "tcb:install"
7
7
  source_root File.expand_path("templates", __dir__)
8
8
 
9
9
  desc "Creates a TCB initializer in config/initializers"