tcb 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77ac348140af6f18f4416932f320bc474f3131c14903c592cf83f352a02e1297
4
- data.tar.gz: 4f5462a8425eb01612a3e6ffb3b89c8c848504db97d8a2fb913c7961abbc67b2
3
+ metadata.gz: e9fdc75ccfefebb2cb421260d89276f946b42f3d0f9e7ed90438b1d2cce1da8c
4
+ data.tar.gz: d48eee8151fc7ea5b2339f8949fc68bd6f04b61062007aff2f335e2f2f1b367d
5
5
  SHA512:
6
- metadata.gz: 2eed8f7a67fcef1a356262a854b707964a2d5eb15c910a5a508368fc602e7f0892742e0ff24035a26f7542ae9de14740288db7fe07655d7e13e25a444db8799e
7
- data.tar.gz: d41fd603ec8b6cc729396ea7ac5b9b692bd983b080f59e8f1df23faab88976563bd460513ed0634d25fba6a3911f77e583e8508c0ce455997347ed25eef00e12
6
+ metadata.gz: 5b8d0a0de064464890a11e71649dbb587fa032cccfd6d57ebaebfe350303ad725519cf7c8b66e8ec48d9b6092d8082e8167daa9d81d3d1cdc632cb3c50f67107
7
+ data.tar.gz: cccbab25a067c195d236f8e83796099d4eae236061021d3fe9a7547016a79b5a4f1bb22d525d9ce8cd506d5eb14be865c31e0417dbb1cc5ef9c8eb2ed52384d3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-05-22
9
+
10
+ ### Added
11
+
12
+ - `TCB.reactions_for(event_class)` — returns all handler classes registered for an event across all domain modules; returns `[]` if none
13
+ - `ensure_reaction` DSL — declares guaranteed delivery handlers via outbox pattern; used as `on EventClass, ensure_reaction(Handler)`
14
+ - `TCB::OutboxEntry` — value object carrying outbox entry state: `id`, `event_id`, `stream_id`, `version`, `handler_class`, `status`, `locked_at`, `delivered_at`, `error`, `created_at`
15
+ - `TCB::OutboxStore::InMemory` — thread-safe in-memory outbox store for tests
16
+ - `TCB::OutboxStore::ActiveRecord` — ActiveRecord persistence adapter for outbox entries; drop-in replacement for `OutboxStore::InMemory`
17
+ - `TCB::OutboxRelay` — single polling cycle: recover stale locks → lock pending → fetch envelopes → invoke handler → mark delivered/failed
18
+ - `Configuration#outbox_store_class` — declares which outbox store adapter to use (`OutboxStore::InMemory` or `OutboxStore::ActiveRecord`); store is instantiated per domain module during configuration
19
+ - `Configuration#outbox_registrations` — collected outbox handler registrations across domain modules, each carrying a reference to its domain's store instance
20
+ - `DomainContext#outbox_table_name` — derives per-domain outbox table name (`..._outbox` suffix)
21
+ - Per-domain `OutboxRecord` AR model defined dynamically for domain modules with outbox registrations; analogous to `EventRecord`
22
+ - Rails generator `tcb:outbox` — generates outbox migration and job scaffold per domain module
23
+ - Outbox migration template — table with `id` (string UUID, primary key), `event_id`, `stream_id`, `version`, `handler_class`, `status`, `locked_at`, `delivered_at`, `error`, `created_at`; indexes on `:status`, `[:status, :locked_at]`, `[:stream_id, :version]`
24
+ - Outbox job template — `ApplicationJob` wrapper around `OutboxRelay` for SolidQueue/Sidekiq integration
25
+
26
+ ## [0.6.2] - 2026-05-07
27
+
28
+ ### Fixed
29
+
30
+ - `tcb:install` generator — `TCB.domain_modules=` moved into `Rails.application.config.to_prepare` block; bare initializer runs before Zeitwerk loads application constants, causing `NameError` when domain modules reference Rails classes such as `ApplicationJob` or `ApplicationRecord`
31
+
8
32
  ## [0.6.1] - 2026-05-06
9
33
 
10
34
  ### Fixed
data/README.md CHANGED
@@ -1,10 +1,59 @@
1
1
  # TCB
2
2
 
3
- A lightweight, thread-safe event and command runtime for Domain-Driven Design on Rails.
3
+ Is your codebase using every form of coupling except the one architects actually recommend?
4
+ How much does a feature request cost you now?
5
+
6
+ TCB gives each concern its own place, a clean domain language, and a full record
7
+ of everything that happened, so you can understand and evolve your business logic
8
+ with confidence.
9
+
10
+ Imagine the following scenario. An order is placed. Stock gets reserved. The customer is notified.
11
+ Now imagine each piece in its own domain, reacting independently, easy to test in isolation,
12
+ simple to evolve as your business grows, and the full picture always visible.
13
+
14
+ ```ruby
15
+ PlaceOrder = Data.define(:order_id, :customer)
16
+ def Sales.place!(order_id:, customer:) = TCB.dispatch(PlaceOrder.new(order_id:, customer:))
17
+
18
+ correlation_id = Sales.place!(order_id: 42, customer: "Alice")
19
+ # Sales → OrderPlaced persisted, published
20
+ # Warehouse → StockReserved reacts automatically, same correlation
21
+ # Notifications → CustomerNotified reacts automatically, same correlation
4
22
 
5
- TCB gives Rails applications a clean domain language. Events, aggregates, and handlers are plain Ruby. No framework inheritance, no infrastructure details leaking into your domain.
23
+ TCB.read_correlation(correlation_id).to_a # => all three events, across all three domains, in order
24
+ ```
25
+
26
+ A lightweight, thread-safe event and command runtime for Domain-Driven Design on Rails.
27
+ Events, aggregates, and handlers are plain Ruby. No framework inheritance, no infrastructure
28
+ details leaking into your domain.
29
+
30
+ Rails can change. Your domains should change only when your business demands it.
31
+ Clean domain code pays compound interest. It's easier to reason about, easier to test,
32
+ and easier for AI agents to work with. TCB keeps your domain that way.
33
+ Rails takes care of the rest.
34
+
35
+
36
+ ## Contents
37
+
38
+ - [Installation](#installation)
39
+ - [Event Bus](#event-bus)
40
+ - [TCB::HandlesEvents](#tcbhandlesevents)
41
+ - [TCB::HandlesCommands](#tcbhandlescommands)
42
+ - [Domain-Driven Design](#domain-driven-design)
43
+ - [Guaranteed Delivery with the Outbox Pattern](#guaranteed-delivery-with-the-outbox-pattern)
44
+ - [Configuration](#configuration)
45
+ - [Reading Events](#reading-events)
46
+ - [Correlation and Causation Tracking](#correlation-and-causation-tracking)
47
+ - [Reading a Correlation Chain](#reading-a-correlation-chain)
48
+ - [Inspecting Reactions](#inspecting-reactions)
49
+ - [Event Store Adapters](#event-store-adapters)
50
+ - [Generators](#generators)
51
+ - [Error Handling](#error-handling)
52
+ - [Graceful Shutdown](#graceful-shutdown)
53
+ - [Testing](#testing)
54
+ - [Why `Data.define`](#why-datadefine)
6
55
 
7
- TCB uses a command and event bus as an architectural coordination mechanism. Commands are decisions routed to exactly one handler. Events are facts broadcast to any number of reactions. The goal is to isolate side-effects, allow independent evolution of behaviors, and support an increasing number of business-significant events.
56
+ ---
8
57
 
9
58
  ## Installation
10
59
 
@@ -46,11 +95,11 @@ bus.publish(UserRegistered.new(id: 1, email: "alice@example.com"))
46
95
 
47
96
  ### Execution model
48
97
 
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.
98
+ 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
99
 
51
100
  This design favors determinism and simplicity: events are always processed in the order they were published, and handlers cannot race with each other.
52
101
 
53
- For tests and simple use cases, `sync: true` executes handlers in the caller thread immediately no background thread, no queue:
102
+ For tests and simple use cases, `sync: true` executes handlers in the caller thread immediately, no background thread, no queue:
54
103
 
55
104
  ```ruby
56
105
  bus = TCB::EventBus.new(sync: true)
@@ -77,7 +126,7 @@ The event queue is unbounded by default. If handlers are slower than the rate of
77
126
  TCB::EventBus.new(max_queue_size: 10_000)
78
127
  ```
79
128
 
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.
129
+ 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
130
 
82
131
  ---
83
132
 
@@ -137,10 +186,10 @@ module Notifications
137
186
  end
138
187
  ```
139
188
 
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.
189
+ Event classes can come from anywhere. Cross-module reactions are the norm, not the exception. Each handler is isolated. One failure does not prevent others from executing.
141
190
 
142
191
  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,
192
+ This is the only place TCB needs to know about your bounded contexts. All reactions,
144
193
  persistence rules, and handler mappings live inside each module itself.
145
194
 
146
195
  ```ruby
@@ -154,7 +203,7 @@ end
154
203
 
155
204
  `TCB.domain_modules=` wires up subscriptions and command routing from all modules.
156
205
  `TCB.configure` provides the infrastructure they run on. The two are intentionally
157
- separate domain modules don't change between environments, infrastructure does.
206
+ separate. Domain modules don't change between environments, infrastructure does.
158
207
 
159
208
  ---
160
209
 
@@ -229,32 +278,24 @@ The domain module is a boundary. Everything inside speaks the domain language. N
229
278
  module Sales
230
279
  include TCB::Domain
231
280
 
232
- # Facade
233
281
  def self.place!(order_id:, customer:)
234
282
  TCB.dispatch(PlaceOrder.new(order_id: order_id, customer: customer))
235
283
  end
236
284
 
237
- # Events
238
285
  OrderPlaced = Data.define(:order_id, :customer)
239
286
 
240
- # Commands
241
287
  PlaceOrder = Data.define(:order_id, :customer) do
242
288
  def validate!
243
289
  raise ArgumentError, "customer required" if customer.nil?
244
290
  end
245
291
  end
246
292
 
247
- # Persistence
248
293
  persist events(
249
294
  OrderPlaced,
250
295
  stream_id_from_event: :order_id
251
296
  )
252
297
 
253
- # Commands
254
298
  handle PlaceOrder, with(PlaceOrderHandler)
255
-
256
- # Reactions
257
- on OrderPlaced, react_with(Warehouse::ReserveStock)
258
299
  end
259
300
  ```
260
301
 
@@ -366,11 +407,108 @@ end
366
407
 
367
408
  ---
368
409
 
410
+ ## Guaranteed Delivery with the Outbox Pattern
411
+
412
+ `on Event, Handler` delivers reactions synchronously and best-effort. If the process crashes mid-dispatch, or a handler calls an external service that fails, the reaction is lost. For cross-domain reactions that must eventually succeed, like sending emails, enqueuing jobs, notifying external systems, TCB offers `ensure_reaction`.
413
+
414
+ ```ruby
415
+ module Invoicing
416
+ include TCB::Domain
417
+
418
+ OrderPlaced = Data.define(:order_id)
419
+
420
+ persist events(OrderPlaced, stream_id_from_event: :order_id)
421
+
422
+ on OrderPlaced, ensure_reaction(SendInvoice, NotifyAccounting)
423
+ end
424
+ ```
425
+
426
+ `ensure_reaction` does not call your handlers inline. Instead, `TCB.record` writes outbox entries atomically alongside the event, in the same database transaction. A background job polls the outbox, invokes each handler, and marks the entry delivered. If the process crashes before the job runs, the entries remain pending and will be picked up on the next poll.
427
+
428
+ > `ensure_reaction` requires the event to be persisted. `persist events(...)` must be declared in the same domain module.
429
+
430
+ ### Setup
431
+
432
+ If your outbox handlers call Rails infrastructure — ActiveJob, mailers, or other application classes — place your full configuration in `config/initializers/tcb.rb`. This ensures Rails is fully loaded when handlers execute:
433
+
434
+ ```ruby
435
+ # config/initializers/tcb.rb
436
+ Rails.application.config.to_prepare do
437
+ TCB.reset!
438
+ TCB.domain_modules = [Sales, Warehouse, Notifications]
439
+
440
+ TCB.configure do |c|
441
+ case Rails.env
442
+ when "test"
443
+ c.event_bus = TCB::EventBus.new(sync: true)
444
+ c.event_store = TCB::EventStore::InMemory.new
445
+ c.outbox_store_class = TCB::OutboxStore::InMemory
446
+ when "development"
447
+ c.event_bus = TCB::EventBus.new(handle_signals: false, shutdown_timeout: 10.0)
448
+ c.event_store = TCB::EventStore::ActiveRecord.new
449
+ c.outbox_store_class = TCB::OutboxStore::ActiveRecord
450
+ when "production"
451
+ c.event_bus = TCB::EventBus.new(handle_signals: true, shutdown_timeout: 30.0)
452
+ c.event_store = TCB::EventStore::ActiveRecord.new
453
+ c.outbox_store_class = TCB::OutboxStore::ActiveRecord
454
+ end
455
+ end
456
+ end
457
+ ```
458
+
459
+ `OutboxStore::InMemory` is a drop-in replacement for tests. Entries live in memory and do not require a migration.
460
+
461
+ ### Generator
462
+
463
+ Generate the outbox migration and job scaffold for a domain module:
464
+
465
+ ```CLI
466
+ bin/rails generate tcb:outbox invoicing
467
+ ```
468
+
469
+ This creates:
470
+
471
+ - `db/migrate/..._create_invoicing_outbox.rb` — the outbox table
472
+ - `app/jobs/invoicing_outbox_job.rb` — a job that runs one relay cycle
473
+
474
+ ```ruby
475
+ class InvoicingOutboxJob < ApplicationJob
476
+ queue_as :default
477
+
478
+ def perform
479
+ TCB::OutboxRelay.new(
480
+ outbox_store: Invoicing::OutboxRecord,
481
+ event_store: TCB.config.event_store,
482
+ lock_timeout: 300
483
+ ).run
484
+ end
485
+ end
486
+ ```
487
+
488
+ Schedule this job to run periodically. Each `perform` call runs one polling cycle: recover stale locks → lock pending entries → fetch events → invoke handlers → mark delivered or failed.
489
+
490
+
491
+ With Solid Queue (Rails 8 default), add a recurring job to `config/recurring.yml`:
492
+
493
+ ```yaml
494
+ invoicing_outbox:
495
+ class: InvoicingOutboxJob
496
+ schedule: every 30 seconds
497
+ ```
498
+
499
+ Pick an interval based on your latency tolerance. Every 30 seconds is a reasonable starting point for most applications.
500
+
501
+ ### Failure handling
502
+
503
+ If a handler raises, the entry is marked `failed` and the error message is stored. Other entries in the same cycle continue processing. Failed entries are not retried automatically. Retry is the responsibility of your job scheduler. Schedule the job to run periodically and retries happen naturally on the next cycle.
504
+
505
+ ---
506
+
369
507
  ## Configuration
370
508
 
371
509
  ### Domain modules
372
510
 
373
- Domain modules are the bounded contexts of your application. Declare them once, at the top level before infrastructure is configured:
511
+ Domain modules are the bounded contexts of your application. Declare them once, at the top level, before infrastructure is configured:
374
512
 
375
513
  ```ruby
376
514
  # config/initializers/tcb.rb
@@ -388,11 +526,12 @@ Infrastructure is environment-specific. Configure it in each environment file so
388
526
  Rails.application.config.to_prepare do
389
527
  TCB.reset!
390
528
  TCB.configure do |c|
391
- c.event_bus = TCB::EventBus.new(
392
- handle_signals: false, # Rails manages process signals in development
529
+ c.event_bus = TCB::EventBus.new(
530
+ handle_signals: false,
393
531
  shutdown_timeout: 10.0
394
532
  )
395
- c.event_store = TCB::EventStore::ActiveRecord.new
533
+ c.event_store = TCB::EventStore::ActiveRecord.new
534
+ c.outbox_store_class = TCB::OutboxStore::ActiveRecord # required if any domain uses ensure_reaction
396
535
  end
397
536
  end
398
537
  ```
@@ -404,11 +543,12 @@ end
404
543
  Rails.application.config.to_prepare do
405
544
  TCB.reset!(graceful_shutdown_time: 10.0)
406
545
  TCB.configure do |c|
407
- c.event_bus = TCB::EventBus.new(
546
+ c.event_bus = TCB::EventBus.new(
408
547
  handle_signals: true,
409
548
  shutdown_timeout: 30.0
410
549
  )
411
- c.event_store = TCB::EventStore::ActiveRecord.new
550
+ c.event_store = TCB::EventStore::ActiveRecord.new
551
+ c.outbox_store_class = TCB::OutboxStore::ActiveRecord
412
552
  end
413
553
  end
414
554
  ```
@@ -419,13 +559,14 @@ end
419
559
  # config/environments/test.rb
420
560
  Rails.application.config.after_initialize do
421
561
  TCB.configure do |c|
422
- c.event_bus = TCB::EventBus.new(sync: true)
423
- c.event_store = TCB::EventStore::InMemory.new
562
+ c.event_bus = TCB::EventBus.new(sync: true)
563
+ c.event_store = TCB::EventStore::InMemory.new
564
+ c.outbox_store_class = TCB::OutboxStore::InMemory # in-memory, no migration needed
424
565
  end
425
566
  end
426
567
  ```
427
568
 
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.
569
+ `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
570
 
430
571
  Each domain module gets its own database table. Domains stay isolated at the persistence level:
431
572
 
@@ -437,6 +578,25 @@ Each domain module gets its own database table. Domains stay isolated at the per
437
578
 
438
579
  ---
439
580
 
581
+ ### Serialization
582
+
583
+ TCB serializes events to YAML. `Symbol`, `Time`, `Date`, `BigDecimal`, and your `Data` events are permitted by default during deserialization.
584
+
585
+ If your events carry attributes of other types — such as `ActiveSupport::TimeWithZone` when using `Time.zone.now` — declare them explicitly:
586
+
587
+ ```ruby
588
+ TCB.configure do |c|
589
+ c.extra_serialization_classes = [
590
+ ActiveSupport::TimeWithZone,
591
+ ActiveSupport::TimeZone,
592
+ ]
593
+ end
594
+ ```
595
+
596
+ This is optional. Omit it if your event attributes use only plain Ruby types.
597
+
598
+ ---
599
+
440
600
  ## Reading Events
441
601
 
442
602
  Events are stored per aggregate stream. Query them by domain module and aggregate id. The result is always ordered by version. For large streams, `in_batches` uses keyset pagination and keeps memory usage flat.
@@ -483,7 +643,7 @@ envelope.causation_id # UUID string, event_id of the triggering event; nil for
483
643
 
484
644
  ## Correlation and causation tracking
485
645
 
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.
646
+ 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
647
 
488
648
  ```
489
649
  Sales.place!(order_id: 42, customer: "Alice")
@@ -529,7 +689,23 @@ TCB.read_correlation("req-abc").between(1.hour.ago, Time.now).to_a
529
689
 
530
690
  Results are ordered by `occurred_at` across all domains. Each result is a `TCB::Envelope` with `correlation_id` and `causation_id` populated.
531
691
 
532
- `across:` defaults to all configured domain modules that have persistence registrations. Domains without persistence like `Notifications` in the example above are excluded automatically.
692
+ `across:` defaults to all configured domain modules that have persistence registrations. Domains without persistence, like `Notifications` in the example above, are excluded automatically.
693
+
694
+ ---
695
+
696
+ ## Inspecting Reactions
697
+
698
+ To see which handlers are registered for an event class across all domain modules:
699
+
700
+ ```ruby
701
+ TCB.reactions_for(Sales::OrderPlaced)
702
+ # => [Warehouse::ReserveStock]
703
+
704
+ TCB.reactions_for(Warehouse::StockReserved)
705
+ # => [Notifications::NotifyCustomer]
706
+ ```
707
+
708
+ Returns an empty array if no reactions are registered. Useful for verifying topology at a glance or asserting reaction wiring in tests.
533
709
 
534
710
  ---
535
711
 
@@ -598,17 +774,21 @@ Generates:
598
774
  | `--skip-migration` | Skip migration (event_store only) |
599
775
  | `--no-comments` | Generate without inline guidance comments |
600
776
 
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:
777
+ 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:
602
778
 
603
779
  ```ruby
604
780
  # config/initializers/tcb.rb
605
- TCB.domain_modules = [
606
- Sales,
607
- Warehouse,
608
- Notifications
609
- ]
781
+ Rails.application.config.to_prepare do
782
+ TCB.domain_modules = [
783
+ Sales,
784
+ Warehouse,
785
+ Notifications
786
+ ]
787
+ end
610
788
  ```
611
789
 
790
+ `to_prepare` runs after Zeitwerk loads all application constants. Use it instead of a bare initializer. Domain modules typically reference Rails classes (ApplicationJob, ApplicationRecord, etc.) that are not yet available at initializer time.
791
+
612
792
  ---
613
793
 
614
794
  ## Error Handling
@@ -645,7 +825,7 @@ bus.force_shutdown
645
825
 
646
826
  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.
647
827
 
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.
828
+ `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
829
 
650
830
  ### Synchronous mode
651
831
 
@@ -661,12 +841,20 @@ Rails.application.config.after_initialize do
661
841
  end
662
842
  ```
663
843
 
844
+ `after_initialize` runs once at boot. Tests do not reload Rails, so `to_prepare` is unnecessary. Between tests, TCB.reset! shuts down the current bus and clears the store, but also wipes the configuration. Restore it in your test teardown so every test starts with a clean, fully configured bus.
845
+
664
846
  ### Minitest
665
847
 
666
848
  ```ruby
667
849
  class OrdersTest < Minitest::Test
668
850
  include TCB::MinitestHelpers
669
- def teardown = TCB.reset!
851
+ def teardown
852
+ TCB.reset!
853
+ TCB.configure do |c|
854
+ c.event_bus = TCB::EventBus.new(sync: true)
855
+ c.event_store = TCB::EventStore::InMemory.new
856
+ end
857
+ end
670
858
 
671
859
  def test_placing_order_publishes_event
672
860
  assert_published(Orders::OrderPlaced) do
@@ -687,7 +875,7 @@ assert_published(Orders::OrderPlaced, within: 0.5) { Orders.place!(...) }
687
875
 
688
876
  #### poll_assert
689
877
 
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.
878
+ 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.
691
879
 
692
880
  ```ruby
693
881
  poll_assert("reserve inventory called") { CALLED.include?(:reserve_inventory) }
@@ -699,7 +887,13 @@ poll_assert("payment processed", within: 2.0) { Payment.completed? }
699
887
  ```ruby
700
888
  # spec/support/tcb.rb
701
889
  RSpec.configure do |config|
702
- config.after(:each) { TCB.reset! }
890
+ config.after(:each) do
891
+ TCB.reset!
892
+ TCB.configure do |c|
893
+ c.event_bus = TCB::EventBus.new(sync: true)
894
+ c.event_store = TCB::EventStore::InMemory.new
895
+ end
896
+ end
703
897
  end
704
898
  ```
705
899
 
@@ -2,19 +2,27 @@
2
2
  # Add your domain modules here after generating them:
3
3
  # rails generate tcb:event_store orders place_order:order_id,customer
4
4
  # rails generate tcb:domain notifications send_welcome_email:user_id,email
5
- TCB.domain_modules = [
6
- # Orders,
7
- # Notifications,
8
- ]
9
-
10
- # Infrastructure — how events are transported and stored
11
- # Runs on every Rails reload in development
5
+ #
6
+ # to_prepare runs after Zeitwerk loads all application constants.
7
+ # Domain modules typically reference Rails classes (ApplicationJob, ApplicationRecord, etc.)
8
+ # that are not yet available at initializer time.
12
9
  Rails.application.config.to_prepare do
13
- TCB.configure do |c|
14
- c.event_bus = TCB::EventBus.new(
15
- handle_signals: true,
16
- shutdown_timeout: 10.0
17
- )
18
- c.event_store = TCB::EventStore::ActiveRecord.new
19
- end
10
+ TCB.domain_modules = [
11
+ # Orders,
12
+ # Notifications,
13
+ ]
20
14
  end
15
+
16
+ # Infrastructure — how events are transported and stored
17
+ # Configure per environment in config/environments/*.rb so differences are explicit.
18
+ # Example for development:
19
+ #
20
+ # Rails.application.config.to_prepare do
21
+ # TCB.reset!
22
+ # TCB.configure do |c|
23
+ # c.event_bus = TCB::EventBus.new(handle_signals: false, shutdown_timeout: 10.0)
24
+ # c.event_store = TCB::EventStore::ActiveRecord.new
25
+ # end
26
+ # end
27
+ #
28
+ # See README for production and test examples.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ module Generators
5
+ class OutboxGenerator < Rails::Generators::Base
6
+ namespace "tcb:outbox"
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ argument :module_name, type: :string
10
+
11
+ def create_migration
12
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
13
+ template "migration.rb.tt", "db/migrate/#{timestamp}_create_#{table_name}.rb"
14
+ end
15
+
16
+ def create_job
17
+ template "job.rb.tt", "app/jobs/#{job_file_name}.rb"
18
+ end
19
+
20
+ private
21
+
22
+ def table_name
23
+ "#{module_name.underscore}_outbox"
24
+ end
25
+
26
+ def migration_class_name
27
+ "Create#{module_name.camelize}Outbox"
28
+ end
29
+
30
+ def module_class_name
31
+ module_name.camelize
32
+ end
33
+
34
+ def job_class_name
35
+ "#{module_name.camelize}OutboxJob"
36
+ end
37
+
38
+ def job_file_name
39
+ "#{module_name.underscore}_outbox_job"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ class <%= job_class_name %> < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform
5
+ TCB::OutboxRelay.new(
6
+ outbox_store: <%= module_class_name %>::OutboxRecord,
7
+ event_store: TCB.config.event_store,
8
+ lock_timeout: 300
9
+ ).run
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :<%= table_name %>, id: false, primary_key: :id do |t|
4
+ t.string :id, null: false
5
+ t.string :event_id, null: false
6
+ t.string :stream_id, null: false
7
+ t.integer :version, null: false
8
+ t.string :handler_class, null: false
9
+ t.string :status, null: false, default: "pending"
10
+ t.datetime :locked_at
11
+ t.datetime :delivered_at
12
+ t.text :error
13
+ t.datetime :created_at, null: false
14
+ end
15
+
16
+ add_index :<%= table_name %>, :status
17
+ add_index :<%= table_name %>, [:status, :locked_at]
18
+ add_index :<%= table_name %>, [:stream_id, :version]
19
+ end
20
+ end
@@ -30,11 +30,24 @@ module TCB
30
30
  @event_store
31
31
  end
32
32
 
33
+ def outbox_store_class=(store)
34
+ @outbox_store_class = store
35
+ end
36
+
37
+ def outbox_store_class
38
+ @outbox_store_class
39
+ end
40
+
41
+ def outbox_registrations
42
+ @outbox_registrations ||= []
43
+ end
44
+
33
45
  def domain_modules=(modules)
34
46
  @domain_modules = modules
35
47
  flush_domain_modules
36
48
  flush_command_handlers
37
49
  flush_persist_registrations
50
+ flush_outbox_registrations
38
51
  end
39
52
 
40
53
  def domain_modules
@@ -108,8 +121,63 @@ module TCB
108
121
  domain_module.persist_registrations.each do |registration|
109
122
  @persist_registrations << registration.with(context: context)
110
123
  end
111
- define_event_record_for(domain_module)
124
+ define_event_record_for(domain_module) if active_record_store?
125
+ end
126
+ end
127
+
128
+ def active_record_store?
129
+ defined?(::ActiveRecord) && @event_store.is_a?(TCB::EventStore::ActiveRecord)
130
+ end
131
+
132
+ def flush_outbox_registrations
133
+ @outbox_registrations = []
134
+ @domain_modules.each do |domain_module|
135
+ next unless domain_module.respond_to?(:outbox_registrations)
136
+ next unless domain_module.outbox_registrations.any?
137
+
138
+ validate_outbox_registrations!(domain_module)
139
+ store = build_outbox_store(domain_module)
140
+
141
+ domain_module.outbox_registrations.each do |r|
142
+ @outbox_registrations << r.with(outbox_store: store)
143
+ end
144
+ end
145
+ end
146
+
147
+
148
+ def validate_outbox_registrations!(domain_module)
149
+ persisted_event_classes = domain_module.persist_registrations.flat_map(&:event_classes)
150
+
151
+ domain_module.outbox_registrations.map(&:event_class).uniq.each do |event_class|
152
+ unless persisted_event_classes.include?(event_class)
153
+ raise ConfigurationError,
154
+ "#{event_class} has ensure_reaction in #{domain_module} but is not persisted."
155
+ end
156
+ end
157
+
158
+ unless @outbox_store_class
159
+ raise ConfigurationError,
160
+ "#{domain_module} has outbox registrations but no outbox_store_class is configured."
161
+ end
162
+ end
163
+
164
+ def build_outbox_store(domain_module)
165
+ if active_record_store?
166
+ define_outbox_record_for(domain_module)
167
+ @outbox_store_class.new(domain_module.const_get(:OutboxRecord))
168
+ else
169
+ @outbox_store_class.new(nil)
170
+ end
171
+ end
172
+
173
+ def define_outbox_record_for(domain_module)
174
+ return if domain_module.const_defined?(:OutboxRecord, false)
175
+
176
+ klass = Class.new(::ActiveRecord::Base) do
177
+ self.table_name = DomainContext.from_module(domain_module).outbox_table_name
178
+ self.primary_key = "id"
112
179
  end
180
+ domain_module.const_set(:OutboxRecord, klass)
113
181
  end
114
182
 
115
183
  def define_event_record_for(domain_module)
@@ -121,4 +189,4 @@ module TCB
121
189
  domain_module.const_set(:EventRecord, klass)
122
190
  end
123
191
  end
124
- end
192
+ end
@@ -5,6 +5,8 @@ module TCB
5
5
  NAMESPACE_SEPARATOR = "/"
6
6
  TABLE_SEPARATOR = "__"
7
7
  TABLE_SUFFIX = "_events"
8
+ OUTBOX_SUFFIX = "_outbox"
9
+
8
10
 
9
11
  def self.from_module(domain_module)
10
12
  value = domain_module.name
@@ -25,5 +27,11 @@ module TCB
25
27
  .gsub(NAMESPACE_SEPARATOR, TABLE_SEPARATOR)
26
28
  .concat(TABLE_SUFFIX)
27
29
  end
30
+
31
+ def outbox_table_name
32
+ value
33
+ .gsub(NAMESPACE_SEPARATOR, TABLE_SEPARATOR)
34
+ .concat(OUTBOX_SUFFIX)
35
+ end
28
36
  end
29
37
  end
@@ -37,6 +37,15 @@ module TCB
37
37
  .then { |e| limit ? e.first(limit) : e }
38
38
  end
39
39
 
40
+ def read_by_event_ids(event_ids)
41
+ @mutex.synchronize do
42
+ @streams.values
43
+ .flatten
44
+ .select { |envelope| event_ids.include?(envelope.event_id) }
45
+ .each_with_object({}) { |envelope, hash| hash[envelope.event_id] = envelope }
46
+ end
47
+ end
48
+
40
49
  def read_by_correlation(correlation_id, context:, occurred_after: nil, occurred_before: nil)
41
50
  @mutex.synchronize { @streams.values.flatten.dup }
42
51
  .select { |e| e.stream_id.start_with?(context) }
@@ -4,20 +4,33 @@ module TCB
4
4
  module HandlesEvents
5
5
  EventHandlerRegistration = Data.define(:event_class, :handlers)
6
6
  PersistRegistration = Data.define(:event_classes, :stream_id_from_event, :context)
7
+ OutboxRegistration = Data.define(:event_class, :handler, :outbox_store)
7
8
 
8
9
  def self.included(base)
9
10
  base.extend(ClassMethods)
10
11
  base.instance_variable_set(:@event_handler_registrations, [])
11
12
  base.instance_variable_set(:@persist_registrations, [])
13
+ base.instance_variable_set(:@outbox_registrations, [])
12
14
  end
13
15
 
14
16
  module ClassMethods
17
+ def on(event_class, registration)
18
+ case registration
19
+ in EventHandlerRegistration => r
20
+ @event_handler_registrations << r.with(event_class: event_class)
21
+ in [OutboxRegistration, *] => registrations
22
+ registrations.each do |r|
23
+ @outbox_registrations << r.with(event_class: event_class)
24
+ end
25
+ end
26
+ end
27
+
15
28
  def react_with(*handlers)
16
29
  EventHandlerRegistration.new(event_class: :undefined, handlers: handlers)
17
30
  end
18
31
 
19
- def on(event_class, registration)
20
- @event_handler_registrations << registration.with(event_class: event_class)
32
+ def ensure_reaction(*handlers)
33
+ handlers.map { |handler| OutboxRegistration.new(event_class: :undefined, handler: handler, outbox_store: nil) }
21
34
  end
22
35
 
23
36
  def persist(registration)
@@ -39,6 +52,10 @@ module TCB
39
52
  def persist_registrations
40
53
  @persist_registrations
41
54
  end
55
+
56
+ def outbox_registrations
57
+ @outbox_registrations
58
+ end
42
59
  end
43
60
  end
44
- end
61
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ TCB::OutboxEntry = Data.define(
4
+ :id,
5
+ :event_id,
6
+ :stream_id,
7
+ :version,
8
+ :handler_class,
9
+ :status,
10
+ :locked_at,
11
+ :delivered_at,
12
+ :error,
13
+ :created_at
14
+ )
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class OutboxRelay
5
+ def initialize(outbox_store:, event_store:, lock_timeout:)
6
+ @outbox_store = outbox_store
7
+ @event_store = event_store
8
+ @lock_timeout = lock_timeout
9
+ end
10
+
11
+ def run
12
+ recover_stale_locks
13
+ entries = lock_pending
14
+ envelopes = fetch_envelopes(entries)
15
+ entries.each { |entry| process(entry, envelopes[entry.event_id]) }
16
+ end
17
+
18
+ private
19
+
20
+ def recover_stale_locks
21
+ @outbox_store.recover_stale_locks(older_than: Time.now - @lock_timeout)
22
+ end
23
+
24
+ def lock_pending
25
+ @outbox_store
26
+ .pending
27
+ .map { |e| @outbox_store.lock(e) }
28
+ end
29
+
30
+ def fetch_envelopes(entries)
31
+ @event_store.read_by_event_ids(entries.map(&:event_id))
32
+ end
33
+
34
+ def process(entry, envelope)
35
+ Object.const_get(entry.handler_class).new.call(envelope.event)
36
+ @outbox_store.mark_delivered(entry)
37
+ rescue => error
38
+ @outbox_store.mark_failed(entry, error: error)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module TCB
6
+ module OutboxStore
7
+ class ActiveRecord
8
+ def initialize(model)
9
+ @model = model
10
+ end
11
+
12
+ def insert(event_id:, stream_id:, version:, handler_class:)
13
+ id = SecureRandom.uuid
14
+ now = Time.now
15
+
16
+ @model.create!(
17
+ id: id,
18
+ event_id: event_id,
19
+ stream_id: stream_id,
20
+ version: version,
21
+ handler_class: handler_class.name,
22
+ status: "pending",
23
+ locked_at: nil,
24
+ delivered_at: nil,
25
+ error: nil,
26
+ created_at: now
27
+ )
28
+
29
+ OutboxEntry.new(
30
+ id: id,
31
+ event_id: event_id,
32
+ stream_id: stream_id,
33
+ version: version,
34
+ handler_class: handler_class.name,
35
+ status: :pending,
36
+ locked_at: nil,
37
+ delivered_at: nil,
38
+ error: nil,
39
+ created_at: now
40
+ )
41
+ end
42
+
43
+ def all
44
+ @model.all.map { |r| to_entry(r) }
45
+ end
46
+
47
+ def pending
48
+ @model.where(status: "pending").order(:stream_id, :version).map { |r| to_entry(r) }
49
+ end
50
+
51
+ def lock(entry, locked_at: Time.now)
52
+ @model.where(id: entry.id).update_all(status: "locked", locked_at: locked_at)
53
+ entry.with(status: :locked, locked_at: locked_at)
54
+ end
55
+
56
+ def mark_delivered(entry, delivered_at: Time.now)
57
+ @model.where(id: entry.id).update_all(status: "delivered", delivered_at: delivered_at)
58
+ entry.with(status: :delivered, delivered_at: delivered_at)
59
+ end
60
+
61
+ def mark_failed(entry, error:)
62
+ @model.where(id: entry.id).update_all(status: "failed", error: error.message)
63
+ entry.with(status: :failed, error: error.message)
64
+ end
65
+
66
+ def recover_stale_locks(older_than:)
67
+ stale = @model.where(status: "locked").where("locked_at < ?", older_than)
68
+ stale.map do |record|
69
+ @model.where(id: record.id).update_all(status: "pending", locked_at: nil)
70
+ to_entry(record).with(status: :pending, locked_at: nil)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def to_entry(record)
77
+ OutboxEntry.new(
78
+ id: record.id,
79
+ event_id: record.event_id,
80
+ stream_id: record.stream_id,
81
+ version: record.version,
82
+ handler_class: record.handler_class,
83
+ status: record.status.to_sym,
84
+ locked_at: record.locked_at,
85
+ delivered_at: record.delivered_at,
86
+ error: record.error,
87
+ created_at: record.created_at
88
+ )
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ module OutboxStore
5
+ class InMemory
6
+ def initialize(_model = nil)
7
+ @entries = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def insert(event_id:, stream_id:, version:, handler_class:)
12
+ entry = OutboxEntry.new(
13
+ id: SecureRandom.uuid,
14
+ event_id: event_id,
15
+ stream_id: stream_id,
16
+ version: version,
17
+ handler_class: handler_class.name,
18
+ status: :pending,
19
+ locked_at: nil,
20
+ delivered_at: nil,
21
+ error: nil,
22
+ created_at: Time.now
23
+ )
24
+ @mutex.synchronize { @entries[entry.id] = entry }
25
+ entry
26
+ end
27
+
28
+ def all
29
+ @mutex.synchronize { @entries.values.dup }
30
+ end
31
+
32
+ def pending
33
+ @mutex
34
+ .synchronize { @entries.values.select { |e| e.status == :pending }
35
+ .sort_by { |e| [e.stream_id, e.version] } }
36
+ end
37
+
38
+ def lock(entry, locked_at: Time.now)
39
+ updated = entry.with(status: :locked, locked_at: locked_at)
40
+ @mutex.synchronize { @entries[entry.id] = updated }
41
+ updated
42
+ end
43
+
44
+ def mark_delivered(entry, delivered_at: Time.now)
45
+ updated = entry.with(status: :delivered, delivered_at: delivered_at)
46
+ @mutex.synchronize { @entries[entry.id] = updated }
47
+ updated
48
+ end
49
+
50
+ def mark_failed(entry, error:)
51
+ updated = entry.with(status: :failed, error: error.message)
52
+ @mutex.synchronize { @entries[entry.id] = updated }
53
+ updated
54
+ end
55
+
56
+ def recover_stale_locks(older_than:)
57
+ stale = @mutex.synchronize do
58
+ @entries.values.select { |e| e.status == :locked && e.locked_at < older_than }
59
+ end
60
+
61
+ stale.map do |entry|
62
+ updated = entry.with(status: :pending, locked_at: nil)
63
+ @mutex.synchronize { @entries[entry.id] = updated }
64
+ updated
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/tcb/record.rb CHANGED
@@ -2,26 +2,33 @@
2
2
 
3
3
  module TCB
4
4
  class Record
5
- def self.call(events_from:, events:, within:, store:, registrations:, &block)
5
+ def self.call(
6
+ events_from:, events:, within:, store:, registrations:, outbox_registrations: [], &block
7
+ )
6
8
  raise ArgumentError, "events_from: or events: must be provided" if events_from.empty? && events.empty?
7
9
 
8
10
  new(
9
- events_from: events_from,
10
- events: events,
11
- store: store,
12
- registrations: registrations,
13
- correlation_id: Thread.current[:tcb_correlation_id],
14
- causation_id: Thread.current[:tcb_causation_id]
11
+ events_from: events_from,
12
+ events: events,
13
+ store: store,
14
+ registrations: registrations,
15
+ outbox_registrations: outbox_registrations,
16
+ correlation_id: Thread.current[:tcb_correlation_id],
17
+ causation_id: Thread.current[:tcb_causation_id]
15
18
  ).call(within: within, &block)
16
19
  end
17
20
 
18
- def initialize(events_from:, events:, store:, registrations:, correlation_id: nil, causation_id: nil)
19
- @events_from = events_from
20
- @events = events
21
- @store = store
22
- @registrations = registrations
23
- @correlation_id = correlation_id
24
- @causation_id = causation_id
21
+ def initialize(
22
+ events_from:, events:, store:, registrations:, outbox_registrations: [],
23
+ correlation_id: nil, causation_id: nil
24
+ )
25
+ @events_from = events_from
26
+ @events = events
27
+ @store = store
28
+ @registrations = registrations
29
+ @outbox_registrations = outbox_registrations
30
+ @correlation_id = correlation_id
31
+ @causation_id = causation_id
25
32
  end
26
33
 
27
34
  def call(within:, &block)
@@ -38,7 +45,9 @@ module TCB
38
45
  block.call if block
39
46
  events = @events_from.flat_map(&:pull_recorded_events)
40
47
  events += @events
41
- persist(events)
48
+ envelopes = persist(events)
49
+ insert_outbox_entries(envelopes)
50
+ envelopes
42
51
  rescue
43
52
  @events_from.each(&:pull_recorded_events)
44
53
  raise
@@ -53,6 +62,19 @@ module TCB
53
62
  order_by_original(events, persisted, remaining)
54
63
  end
55
64
 
65
+ def insert_outbox_entries(envelopes)
66
+ envelopes.each do |envelope|
67
+ @outbox_registrations
68
+ .select { |r| r.event_class == envelope.event.class }
69
+ .each { |r| r.outbox_store.insert(
70
+ event_id: envelope.event_id,
71
+ stream_id: envelope.stream_id,
72
+ version: envelope.version,
73
+ handler_class: r.handler
74
+ )}
75
+ end
76
+ end
77
+
56
78
  def persist_to_store(events)
57
79
  grouped = Hash.new { |h, k| h[k] = [] }
58
80
 
data/lib/tcb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TCB
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/tcb.rb CHANGED
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tcb/envelope"
4
+ require_relative "tcb/outbox_entry"
4
5
  require_relative "tcb/minitest_helpers"
5
6
  require_relative "tcb/domain_context"
6
7
  require_relative "tcb/correlation_query"
7
8
  require_relative "tcb/event_query"
8
9
  require_relative "tcb/event_store/active_record"
9
10
  require_relative "tcb/event_store/in_memory"
11
+ require_relative "tcb/outbox_store/in_memory"
12
+ require_relative "tcb/outbox_store/active_record"
13
+ require_relative "tcb/outbox_relay"
10
14
  require_relative "tcb/stream_id"
11
15
  require_relative "tcb/handles_events"
12
16
  require_relative "tcb/handles_commands"
@@ -43,11 +47,12 @@ module TCB
43
47
 
44
48
  def self.record(events_from: [], events: [], within: nil, &block)
45
49
  Record.call(
46
- events_from: events_from,
47
- events: events,
48
- within: within,
49
- store: config.event_store,
50
- registrations: config.persist_registrations,
50
+ events_from: events_from,
51
+ events: events,
52
+ within: within,
53
+ store: config.event_store,
54
+ registrations: config.persist_registrations,
55
+ outbox_registrations: config.outbox_registrations,
51
56
  &block
52
57
  )
53
58
  end
@@ -59,6 +64,16 @@ module TCB
59
64
  )
60
65
  end
61
66
 
67
+ def self.reactions_for(event_class)
68
+ domain_modules.flat_map do |mod|
69
+ next [] unless mod.respond_to?(:event_handler_registrations)
70
+
71
+ mod.event_handler_registrations
72
+ .select { |r| r.event_class == event_class }
73
+ .flat_map(&:handlers)
74
+ end
75
+ end
76
+
62
77
  def self.read_correlation(correlation_id, across: nil)
63
78
  domains = across || config.domain_modules.select do |m|
64
79
  m.respond_to?(:persist_registrations) && m.persist_registrations.any?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tcb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ljubomir Marković
@@ -141,6 +141,9 @@ files:
141
141
  - lib/generators/tcb/event_store/templates/migration.rb.tt
142
142
  - lib/generators/tcb/install/install_generator.rb
143
143
  - lib/generators/tcb/install/templates/tcb.rb.tt
144
+ - lib/generators/tcb/outbox/outbox_generator.rb
145
+ - lib/generators/tcb/outbox/templates/job.rb.tt
146
+ - lib/generators/tcb/outbox/templates/migration.rb.tt
144
147
  - lib/generators/tcb/shared/command_argument.rb
145
148
  - lib/tcb.rb
146
149
  - lib/tcb/command_bus.rb
@@ -163,6 +166,10 @@ files:
163
166
  - lib/tcb/handles_commands.rb
164
167
  - lib/tcb/handles_events.rb
165
168
  - lib/tcb/minitest_helpers.rb
169
+ - lib/tcb/outbox_entry.rb
170
+ - lib/tcb/outbox_relay.rb
171
+ - lib/tcb/outbox_store/active_record.rb
172
+ - lib/tcb/outbox_store/in_memory.rb
166
173
  - lib/tcb/publish.rb
167
174
  - lib/tcb/record.rb
168
175
  - lib/tcb/records_events.rb
@@ -192,7 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
192
199
  - !ruby/object:Gem::Version
193
200
  version: '0'
194
201
  requirements: []
195
- rubygems_version: 3.6.9
202
+ rubygems_version: 4.0.11
196
203
  specification_version: 4
197
204
  summary: Lightweight DDD runtime for Rails — events, commands, and aggregates
198
205
  test_files: []