tcb 0.6.2 → 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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +166 -16
- data/lib/generators/tcb/outbox/outbox_generator.rb +43 -0
- data/lib/generators/tcb/outbox/templates/job.rb.tt +11 -0
- data/lib/generators/tcb/outbox/templates/migration.rb.tt +20 -0
- data/lib/tcb/configuration.rb +70 -2
- data/lib/tcb/domain_context.rb +8 -0
- data/lib/tcb/event_store/in_memory.rb +9 -0
- data/lib/tcb/handles_events.rb +20 -3
- data/lib/tcb/outbox_entry.rb +14 -0
- data/lib/tcb/outbox_relay.rb +41 -0
- data/lib/tcb/outbox_store/active_record.rb +92 -0
- data/lib/tcb/outbox_store/in_memory.rb +69 -0
- data/lib/tcb/record.rb +37 -15
- data/lib/tcb/version.rb +1 -1
- data/lib/tcb.rb +20 -5
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9fdc75ccfefebb2cb421260d89276f946b42f3d0f9e7ed90438b1d2cce1da8c
|
|
4
|
+
data.tar.gz: d48eee8151fc7ea5b2339f8949fc68bd6f04b61062007aff2f335e2f2f1b367d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b8d0a0de064464890a11e71649dbb587fa032cccfd6d57ebaebfe350303ad725519cf7c8b66e8ec48d9b6092d8082e8167daa9d81d3d1cdc632cb3c50f67107
|
|
7
|
+
data.tar.gz: cccbab25a067c195d236f8e83796099d4eae236061021d3fe9a7547016a79b5a4f1bb22d525d9ce8cd506d5eb14be865c31e0417dbb1cc5ef9c8eb2ed52384d3
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ 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
|
+
|
|
8
26
|
## [0.6.2] - 2026-05-07
|
|
9
27
|
|
|
10
28
|
### Fixed
|
data/README.md
CHANGED
|
@@ -32,6 +32,29 @@ Clean domain code pays compound interest. It's easier to reason about, easier to
|
|
|
32
32
|
and easier for AI agents to work with. TCB keeps your domain that way.
|
|
33
33
|
Rails takes care of the rest.
|
|
34
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)
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
35
58
|
## Installation
|
|
36
59
|
|
|
37
60
|
Add this line to your application's Gemfile:
|
|
@@ -163,7 +186,7 @@ module Notifications
|
|
|
163
186
|
end
|
|
164
187
|
```
|
|
165
188
|
|
|
166
|
-
Event classes can come from anywhere. Cross-module reactions are the norm, not the exception. Each handler is isolated.
|
|
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.
|
|
167
190
|
|
|
168
191
|
Domain modules are declared once at the top level, before infrastructure is configured.
|
|
169
192
|
This is the only place TCB needs to know about your bounded contexts. All reactions,
|
|
@@ -255,32 +278,24 @@ The domain module is a boundary. Everything inside speaks the domain language. N
|
|
|
255
278
|
module Sales
|
|
256
279
|
include TCB::Domain
|
|
257
280
|
|
|
258
|
-
# Facade
|
|
259
281
|
def self.place!(order_id:, customer:)
|
|
260
282
|
TCB.dispatch(PlaceOrder.new(order_id: order_id, customer: customer))
|
|
261
283
|
end
|
|
262
284
|
|
|
263
|
-
# Events
|
|
264
285
|
OrderPlaced = Data.define(:order_id, :customer)
|
|
265
286
|
|
|
266
|
-
# Commands
|
|
267
287
|
PlaceOrder = Data.define(:order_id, :customer) do
|
|
268
288
|
def validate!
|
|
269
289
|
raise ArgumentError, "customer required" if customer.nil?
|
|
270
290
|
end
|
|
271
291
|
end
|
|
272
292
|
|
|
273
|
-
# Persistence
|
|
274
293
|
persist events(
|
|
275
294
|
OrderPlaced,
|
|
276
295
|
stream_id_from_event: :order_id
|
|
277
296
|
)
|
|
278
297
|
|
|
279
|
-
# Commands
|
|
280
298
|
handle PlaceOrder, with(PlaceOrderHandler)
|
|
281
|
-
|
|
282
|
-
# Reactions
|
|
283
|
-
on OrderPlaced, react_with(Warehouse::ReserveStock)
|
|
284
299
|
end
|
|
285
300
|
```
|
|
286
301
|
|
|
@@ -392,6 +407,103 @@ end
|
|
|
392
407
|
|
|
393
408
|
---
|
|
394
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
|
+
|
|
395
507
|
## Configuration
|
|
396
508
|
|
|
397
509
|
### Domain modules
|
|
@@ -414,11 +526,12 @@ Infrastructure is environment-specific. Configure it in each environment file so
|
|
|
414
526
|
Rails.application.config.to_prepare do
|
|
415
527
|
TCB.reset!
|
|
416
528
|
TCB.configure do |c|
|
|
417
|
-
c.event_bus
|
|
418
|
-
handle_signals: false,
|
|
529
|
+
c.event_bus = TCB::EventBus.new(
|
|
530
|
+
handle_signals: false,
|
|
419
531
|
shutdown_timeout: 10.0
|
|
420
532
|
)
|
|
421
|
-
c.event_store
|
|
533
|
+
c.event_store = TCB::EventStore::ActiveRecord.new
|
|
534
|
+
c.outbox_store_class = TCB::OutboxStore::ActiveRecord # required if any domain uses ensure_reaction
|
|
422
535
|
end
|
|
423
536
|
end
|
|
424
537
|
```
|
|
@@ -430,11 +543,12 @@ end
|
|
|
430
543
|
Rails.application.config.to_prepare do
|
|
431
544
|
TCB.reset!(graceful_shutdown_time: 10.0)
|
|
432
545
|
TCB.configure do |c|
|
|
433
|
-
c.event_bus
|
|
546
|
+
c.event_bus = TCB::EventBus.new(
|
|
434
547
|
handle_signals: true,
|
|
435
548
|
shutdown_timeout: 30.0
|
|
436
549
|
)
|
|
437
|
-
c.event_store
|
|
550
|
+
c.event_store = TCB::EventStore::ActiveRecord.new
|
|
551
|
+
c.outbox_store_class = TCB::OutboxStore::ActiveRecord
|
|
438
552
|
end
|
|
439
553
|
end
|
|
440
554
|
```
|
|
@@ -445,8 +559,9 @@ end
|
|
|
445
559
|
# config/environments/test.rb
|
|
446
560
|
Rails.application.config.after_initialize do
|
|
447
561
|
TCB.configure do |c|
|
|
448
|
-
c.event_bus
|
|
449
|
-
c.event_store
|
|
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
|
|
450
565
|
end
|
|
451
566
|
end
|
|
452
567
|
```
|
|
@@ -463,6 +578,25 @@ Each domain module gets its own database table. Domains stay isolated at the per
|
|
|
463
578
|
|
|
464
579
|
---
|
|
465
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
|
+
|
|
466
600
|
## Reading Events
|
|
467
601
|
|
|
468
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.
|
|
@@ -559,6 +693,22 @@ Results are ordered by `occurred_at` across all domains. Each result is a `TCB::
|
|
|
559
693
|
|
|
560
694
|
---
|
|
561
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.
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
562
712
|
## Event Store Adapters
|
|
563
713
|
|
|
564
714
|
### In-Memory (for tests)
|
|
@@ -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,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
|
data/lib/tcb/configuration.rb
CHANGED
|
@@ -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
|
data/lib/tcb/domain_context.rb
CHANGED
|
@@ -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) }
|
data/lib/tcb/handles_events.rb
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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,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(
|
|
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:
|
|
10
|
-
events:
|
|
11
|
-
store:
|
|
12
|
-
registrations:
|
|
13
|
-
|
|
14
|
-
|
|
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(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
-
@
|
|
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
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:
|
|
47
|
-
events:
|
|
48
|
-
within:
|
|
49
|
-
store:
|
|
50
|
-
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.
|
|
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: 4.0.
|
|
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: []
|