sourced-message 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/Rakefile +8 -0
- data/lib/sourced/message.rb +271 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 31c91f70a00fff9b43bef2a60f506086bac336a5faa52a902c405c9374afe6b5
|
|
4
|
+
data.tar.gz: 2a0923e44fe974832e726ec16d2d57b9c8e09de4351984ff92d62c8da1c4ac9f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7877198025efae1bd7c6264da63091381dd319e5e84507fdd8f45452f3fc68298f6eafcedb005d78e75491df241c14c7364b826f81d534ecf18e9219f5661b89
|
|
7
|
+
data.tar.gz: da2f6ee90b5d568708a6a5c6b98f2fd856a6df853d35f1c9a9e482d857e7211becf8b7ef3837abaec0bf62c85f7999198977ecb14062f44f3b7bdf48affe79f2
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ismael Celis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Sourced::Message
|
|
2
|
+
|
|
3
|
+
`Sourced::Message` is a canonical, typed message class for event-driven Ruby systems. It is the shared base used by [Sourced](https://github.com/ismasan/sourced) and Sidereal, but it has no dependency on either and can be used on its own.
|
|
4
|
+
|
|
5
|
+
A message is a [Plumb](https://github.com/ismasan/plumb)-typed value object with:
|
|
6
|
+
|
|
7
|
+
- a stable, human-readable `type` string (e.g. `'course.created'`)
|
|
8
|
+
- a typed, validated `payload`
|
|
9
|
+
- an auto-generated `id` and `created_at` timestamp
|
|
10
|
+
- `causation_id` / `correlation_id` for tracing causal chains across processes
|
|
11
|
+
- arbitrary `metadata`
|
|
12
|
+
- a global **type registry** that can reconstruct any message from a plain hash — handy for transports, queues and event stores
|
|
13
|
+
- scheduling helpers (`#at` / `#in`) for delayed messages
|
|
14
|
+
|
|
15
|
+
Messages are immutable: every "mutating" method (`#with_payload`, `#with_metadata`, `#at`, `#correlate`) returns a copy.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Install the gem and add it to the application's Gemfile by executing:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle add sourced-message
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gem install sourced-message
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then require it:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require 'sourced/message'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Ruby >= 3.2.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Defining message types
|
|
42
|
+
|
|
43
|
+
Use `.define` with a unique type string and an optional block describing the payload attributes (via Plumb's `attribute` DSL):
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
CourseCreated = Sourced::Message.define('course.created') do
|
|
47
|
+
attribute :course_name, String
|
|
48
|
+
attribute :seats, Integer
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each defined type is a subclass of `Sourced::Message` and is automatically added to the registry.
|
|
53
|
+
|
|
54
|
+
A message can also be defined with no payload:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
PingReceived = Sourced::Message.define('ping.received')
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Creating messages
|
|
61
|
+
|
|
62
|
+
Pass the payload as a hash. The payload is validated and coerced against the schema you declared:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
msg = CourseCreated.new(payload: { course_name: 'Ruby 101', seats: 30 })
|
|
66
|
+
|
|
67
|
+
msg.id # => "5f6e..." (auto-generated UUID)
|
|
68
|
+
msg.type # => "course.created"
|
|
69
|
+
msg.created_at # => 2026-06-06 12:00:00 ... (defaults to Time.now)
|
|
70
|
+
msg.metadata # => {}
|
|
71
|
+
msg.causation_id # => same as msg.id by default
|
|
72
|
+
msg.correlation_id # => same as msg.id by default
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Reading the payload
|
|
76
|
+
|
|
77
|
+
The payload is a typed object. Access attributes by method, by `[]`, or with `fetch`:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
msg.payload.course_name # => "Ruby 101"
|
|
81
|
+
msg.payload[:seats] # => 30
|
|
82
|
+
msg.payload.fetch(:seats) # => 30
|
|
83
|
+
msg.payload.fetch(:missing) # => raises KeyError
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Commands and events
|
|
87
|
+
|
|
88
|
+
`Sourced::Command` and `Sourced::Event` are ready-made subclasses. Define types on them the same way — they register their own types, all visible from the root registry:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
EnrollStudent = Sourced::Command.define('student.enroll') do
|
|
92
|
+
attribute :student_id, String
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
StudentEnrolled = Sourced::Event.define('student.enrolled') do
|
|
96
|
+
attribute :student_id, String
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### The registry and `.from`
|
|
101
|
+
|
|
102
|
+
Every defined type lives in a single registry rooted at `Sourced::Message`. This lets you reconstruct the correct subclass from a plain hash that carries a `:type` key — for example when reading messages off a queue, a database, or an HTTP request:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
hash = { type: 'course.created', payload: { course_name: 'Ruby 101', seats: 30 } }
|
|
106
|
+
|
|
107
|
+
msg = Sourced::Message.from(hash)
|
|
108
|
+
msg.class # => CourseCreated
|
|
109
|
+
msg.payload.course_name # => "Ruby 101"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Resolving from the root `Sourced::Message` finds types registered under any subclass (`Command`, `Event`, or your own):
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
Sourced::Message.from(type: 'student.enroll', payload: { student_id: '42' }).class
|
|
116
|
+
# => EnrollStudent
|
|
117
|
+
|
|
118
|
+
Sourced::Message.from(type: 'unknown.type')
|
|
119
|
+
# => raises Sourced::Message::UnknownMessageError
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Inspect what's registered:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
Sourced::Message.registry.keys # => ["course.created", "student.enroll", ...]
|
|
126
|
+
Sourced::Message.registry.all.to_a # => [CourseCreated, EnrollStudent, ...]
|
|
127
|
+
Sourced::Message.registry['course.created'] # => CourseCreated
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Copying with changes
|
|
131
|
+
|
|
132
|
+
Messages are immutable. Use the `#with_*` helpers to derive new copies:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# Merge new metadata (keeps the same id)
|
|
136
|
+
tagged = msg.with_metadata(channel: 'web', user_id: '42')
|
|
137
|
+
tagged.metadata # => { channel: 'web', user_id: '42' }
|
|
138
|
+
|
|
139
|
+
# Override payload attributes
|
|
140
|
+
updated = msg.with_payload(seats: 25)
|
|
141
|
+
updated.payload.seats # => 25
|
|
142
|
+
updated.payload.course_name # => "Ruby 101" (unchanged)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Correlation: tracing causal chains
|
|
146
|
+
|
|
147
|
+
`#correlate` links one message as the cause of another. It returns a copy of the target with `causation_id` set to the source's `id` and `correlation_id` propagated from the source. Metadata from both messages is merged.
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
trigger = EnrollStudent.new(payload: { student_id: '42' })
|
|
151
|
+
result = StudentEnrolled.new(payload: { student_id: '42' })
|
|
152
|
+
|
|
153
|
+
caused = trigger.correlate(result)
|
|
154
|
+
caused.causation_id # => trigger.id
|
|
155
|
+
caused.correlation_id # => trigger.correlation_id
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
This makes it possible to follow a chain of messages across process boundaries: all messages descending from the same originating message share a `correlation_id`, while `causation_id` records the direct parent.
|
|
159
|
+
|
|
160
|
+
### Scheduling: delayed messages
|
|
161
|
+
|
|
162
|
+
`#at` (aliased as `#in`) returns a copy with `created_at` set to a future instant. It accepts three forms:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# An absolute Time / DateTime
|
|
166
|
+
msg.at(Time.now + 3600)
|
|
167
|
+
|
|
168
|
+
# An Integer number of seconds from now
|
|
169
|
+
msg.in(60)
|
|
170
|
+
|
|
171
|
+
# A Fugit / ISO8601 duration string
|
|
172
|
+
msg.in('5m')
|
|
173
|
+
msg.in('1h30m')
|
|
174
|
+
msg.in('PT1H30M')
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Scheduling a message into the past raises `Sourced::Message::PastMessageDateError`:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
msg.at(Time.now - 60) # => raises Sourced::Message::PastMessageDateError
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Passing a string that isn't a duration (e.g. an absolute date) raises `ArgumentError`:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
msg.in('2026-12-31T10:00:00') # => raises ArgumentError
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Pattern matching with `case`/`when`
|
|
190
|
+
|
|
191
|
+
`Sourced::Message.===` is transparent to wrappers that implement `#to_message`, so messages match correctly in `case`/`when` even when wrapped (e.g. by a positioned/persisted envelope):
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
case message
|
|
195
|
+
when CourseCreated then handle_course_created(message)
|
|
196
|
+
when StudentEnrolled then handle_student_enrolled(message)
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Development
|
|
201
|
+
|
|
202
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
203
|
+
|
|
204
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the `VERSION` constant in `lib/sourced/message.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
205
|
+
|
|
206
|
+
## Contributing
|
|
207
|
+
|
|
208
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ismasan/sourced-message.
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'plumb'
|
|
5
|
+
require 'fugit'
|
|
6
|
+
|
|
7
|
+
module Sourced
|
|
8
|
+
# Canonical message class, shared by Sourced and Sidereal.
|
|
9
|
+
#
|
|
10
|
+
# A message has no +stream_id+ or +seq+ — it goes into a flat, globally-ordered
|
|
11
|
+
# log. Supports +causation_id+ / +correlation_id+ for tracing causal chains.
|
|
12
|
+
#
|
|
13
|
+
# Define message types via {.define}:
|
|
14
|
+
#
|
|
15
|
+
# CourseCreated = Sourced::Message.define('course.created') do
|
|
16
|
+
# attribute :course_name, String
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Subclasses (e.g. {Sourced::Command}, {Sourced::Event}, +Sidereal::Message+)
|
|
20
|
+
# all share one **top-level registry rooted at {Sourced::Message}**:
|
|
21
|
+
# {Registry#[]} recurses downward into subclass registries, so
|
|
22
|
+
# +Sourced::Message.registry[type]+ resolves a type registered under any
|
|
23
|
+
# subclass. Resolve from this root to see the whole tree.
|
|
24
|
+
class Message < Plumb::Types::Data
|
|
25
|
+
VERSION = '0.1.0'
|
|
26
|
+
|
|
27
|
+
EMPTY_ARRAY = [].freeze
|
|
28
|
+
|
|
29
|
+
# Plumb types used to define the message attributes. Nested under the class
|
|
30
|
+
# so it never collides with the +Sourced::Types+ (sourced gem) or
|
|
31
|
+
# +Sidereal::Types+ modules.
|
|
32
|
+
module Types
|
|
33
|
+
include Plumb::Types
|
|
34
|
+
|
|
35
|
+
# Accepts a UUID string or generates a new one when none is provided.
|
|
36
|
+
AutoUUID = UUID::V4.default { SecureRandom.uuid }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Raised by {.from} when a type string isn't registered.
|
|
40
|
+
UnknownMessageError = Class.new(ArgumentError)
|
|
41
|
+
# Raised by {#at} when a message would be scheduled in the past.
|
|
42
|
+
PastMessageDateError = Class.new(ArgumentError)
|
|
43
|
+
|
|
44
|
+
attribute :id, Types::AutoUUID
|
|
45
|
+
attribute :type, Types::String.present
|
|
46
|
+
attribute? :causation_id, Types::UUID::V4
|
|
47
|
+
attribute? :correlation_id, Types::UUID::V4
|
|
48
|
+
attribute :created_at, Types::Forms::Time.default { Time.now }
|
|
49
|
+
attribute :metadata, Types::Hash.default(Plumb::BLANK_HASH)
|
|
50
|
+
attribute :payload, Types::Static[nil]
|
|
51
|
+
|
|
52
|
+
# Lookup table mapping type strings to message subclasses.
|
|
53
|
+
class Registry
|
|
54
|
+
# @param message_class [Class] the root message class for this registry
|
|
55
|
+
def initialize(message_class)
|
|
56
|
+
@message_class = message_class
|
|
57
|
+
@lookup = {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Array<String>] registered type strings
|
|
61
|
+
def keys = @lookup.keys
|
|
62
|
+
|
|
63
|
+
# @return [Array<Class>] direct subclasses of the root message class
|
|
64
|
+
def subclasses = message_class.subclasses
|
|
65
|
+
|
|
66
|
+
# Register a message class under a type string.
|
|
67
|
+
#
|
|
68
|
+
# @param key [String] message type string
|
|
69
|
+
# @param klass [Class] message subclass
|
|
70
|
+
def []=(key, klass)
|
|
71
|
+
@lookup[key] = klass
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Look up a message class by type string.
|
|
75
|
+
# Searches this registry first, then recurses into subclass registries.
|
|
76
|
+
#
|
|
77
|
+
# @param key [String] message type string
|
|
78
|
+
# @return [Class, nil]
|
|
79
|
+
def [](key)
|
|
80
|
+
klass = lookup[key]
|
|
81
|
+
return klass if klass
|
|
82
|
+
|
|
83
|
+
subclasses.each do |c|
|
|
84
|
+
klass = c.registry[key]
|
|
85
|
+
return klass if klass
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# All registered message classes across this registry and subclass registries.
|
|
91
|
+
#
|
|
92
|
+
# @return [Enumerator<Class>] if no block given
|
|
93
|
+
# @yield [Class] each registered message class
|
|
94
|
+
def all(&block)
|
|
95
|
+
return enum_for(:all) unless block
|
|
96
|
+
|
|
97
|
+
lookup.each_value(&block)
|
|
98
|
+
subclasses.each { |c| c.registry.all(&block) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
attr_reader :lookup, :message_class
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Registry] the message type registry for this class
|
|
107
|
+
def self.registry
|
|
108
|
+
@registry ||= Registry.new(self)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Base class for typed message payloads.
|
|
112
|
+
class Payload < Plumb::Types::Data
|
|
113
|
+
# @param key [Symbol] attribute name
|
|
114
|
+
# @return [Object] attribute value
|
|
115
|
+
def [](key) = attributes[key]
|
|
116
|
+
|
|
117
|
+
# @see Hash#fetch
|
|
118
|
+
def fetch(...) = to_h.fetch(...)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Define a new message type. Registers it in the {.registry} and
|
|
122
|
+
# optionally defines a typed payload.
|
|
123
|
+
#
|
|
124
|
+
# @param type_str [String] unique message type identifier (e.g. 'course.created')
|
|
125
|
+
# @yield optional block to define payload attributes via +attribute+ DSL
|
|
126
|
+
# @return [Class] the new message subclass
|
|
127
|
+
#
|
|
128
|
+
# @example
|
|
129
|
+
# UserJoined = Sourced::Message.define('user.joined') do
|
|
130
|
+
# attribute :course_name, String
|
|
131
|
+
# attribute :user_id, String
|
|
132
|
+
# end
|
|
133
|
+
def self.define(type_str, &payload_block)
|
|
134
|
+
type_str.freeze unless type_str.frozen?
|
|
135
|
+
|
|
136
|
+
registry[type_str] = Class.new(self) do
|
|
137
|
+
def self.node_name = :data
|
|
138
|
+
define_singleton_method(:type) { type_str }
|
|
139
|
+
|
|
140
|
+
attribute :type, Types::Static[type_str]
|
|
141
|
+
if block_given?
|
|
142
|
+
payload_class = Class.new(Payload, &payload_block)
|
|
143
|
+
const_set(:Payload, payload_class)
|
|
144
|
+
attribute :payload, payload_class
|
|
145
|
+
names = payload_class._schema.to_h.keys.map(&:to_sym).freeze
|
|
146
|
+
define_singleton_method(:payload_attribute_names) { names }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Instantiate the correct message subclass from a hash with a +:type+ key.
|
|
152
|
+
#
|
|
153
|
+
# Resolve from the root ({Sourced::Message}) to see types registered under
|
|
154
|
+
# any subclass — that's how a cross-process transport reconstructs both
|
|
155
|
+
# Sourced and Sidereal message types from one registry.
|
|
156
|
+
#
|
|
157
|
+
# @param attrs [Hash] must include +:type+ matching a registered type string
|
|
158
|
+
# @return [Message] instance of the appropriate subclass
|
|
159
|
+
# @raise [UnknownMessageError] if the type string is not registered
|
|
160
|
+
def self.from(attrs)
|
|
161
|
+
klass = registry[attrs[:type]]
|
|
162
|
+
raise UnknownMessageError, "Unknown message type: #{attrs[:type]}" unless klass
|
|
163
|
+
|
|
164
|
+
klass.new(attrs)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def initialize(attrs = {})
|
|
168
|
+
attrs = attrs.merge(payload: {}) unless attrs[:payload]
|
|
169
|
+
super(attrs)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Identity implementation of the +to_message+ contract — see {.===} and any
|
|
173
|
+
# wrapper (e.g. +Sourced::PositionedMessage#to_message+).
|
|
174
|
+
def to_message = self
|
|
175
|
+
|
|
176
|
+
# Make +case/when+ transparent to a wrapper implementing +#to_message+.
|
|
177
|
+
# Ruby's default +Module#===+ is implemented in C and ignores +is_a?+
|
|
178
|
+
# overrides, so wrapped messages would otherwise fall through.
|
|
179
|
+
def self.===(other)
|
|
180
|
+
return true if super
|
|
181
|
+
return false unless other.respond_to?(:to_message)
|
|
182
|
+
|
|
183
|
+
unwrapped = other.to_message
|
|
184
|
+
!unwrapped.equal?(other) && super(unwrapped)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def with_metadata(meta = {})
|
|
188
|
+
return self if meta.empty?
|
|
189
|
+
|
|
190
|
+
with(metadata: metadata.merge(meta))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def with_payload(attrs = {})
|
|
194
|
+
hash = to_h
|
|
195
|
+
(hash[:payload] ||= {}).merge!(attrs)
|
|
196
|
+
self.class.new(hash)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Return a copy with +created_at+ set to a future instant. Three
|
|
200
|
+
# accepted forms:
|
|
201
|
+
#
|
|
202
|
+
# - +Time+ / +DateTime+ / anything with +<+ — used as the absolute
|
|
203
|
+
# target.
|
|
204
|
+
# - +Integer+ — interpreted as seconds; added to +Time.now+.
|
|
205
|
+
# - +String+ — parsed via +Fugit.parse_duration+ as a duration (e.g.
|
|
206
|
+
# +'5m'+, +'1h30m'+, +'PT5M'+) and added to +Time.now+.
|
|
207
|
+
#
|
|
208
|
+
# Raises {PastMessageDateError} when the resolved target is
|
|
209
|
+
# before +created_at+.
|
|
210
|
+
def at(value)
|
|
211
|
+
target = case value
|
|
212
|
+
when Integer
|
|
213
|
+
Time.now + value
|
|
214
|
+
when String
|
|
215
|
+
parsed = Fugit.parse_duration(value) or
|
|
216
|
+
raise ArgumentError,
|
|
217
|
+
"Message#at: String argument must be an ISO8601 / Fugit duration " \
|
|
218
|
+
"(e.g. '5m', 'PT1H30M'); got #{value.inspect}"
|
|
219
|
+
parsed.add_to_time(Time.now).to_local_time
|
|
220
|
+
else
|
|
221
|
+
value
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
if target < created_at
|
|
225
|
+
raise PastMessageDateError, "Message #{type} can't be delayed to a date in the past"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
with(created_at: target)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
alias in at
|
|
232
|
+
|
|
233
|
+
# Set causation and correlation IDs on another message, establishing
|
|
234
|
+
# a causal link from this message to +message+. Merges metadata.
|
|
235
|
+
#
|
|
236
|
+
# @param message [Message] the message to correlate
|
|
237
|
+
# @return [Message] a copy of +message+ with causation/correlation set
|
|
238
|
+
#
|
|
239
|
+
# @example
|
|
240
|
+
# caused = source_event.correlate(SomeCommand.new(payload: { ... }))
|
|
241
|
+
# caused.causation_id # => source_event.id
|
|
242
|
+
# caused.correlation_id # => source_event.correlation_id
|
|
243
|
+
def correlate(message)
|
|
244
|
+
attrs = {
|
|
245
|
+
causation_id: id,
|
|
246
|
+
correlation_id: correlation_id,
|
|
247
|
+
metadata: metadata.merge(message.metadata || Plumb::BLANK_HASH)
|
|
248
|
+
}
|
|
249
|
+
message.with(attrs)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Returns the declared payload attribute names for this message class.
|
|
253
|
+
# Subclasses created via {.define} override this with a cached frozen array.
|
|
254
|
+
#
|
|
255
|
+
# @return [Array<Symbol>] attribute names (e.g. +[:course_name, :user_id]+)
|
|
256
|
+
def self.payload_attribute_names = EMPTY_ARRAY
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
# Hook called by Plumb after schema parsing, when +:id+ has been resolved.
|
|
261
|
+
# Defaults +causation_id+ and +correlation_id+ to the message's own +id+.
|
|
262
|
+
def prepare_attributes(attrs)
|
|
263
|
+
attrs[:correlation_id] = attrs[:id] unless attrs[:correlation_id]
|
|
264
|
+
attrs[:causation_id] = attrs[:id] unless attrs[:causation_id]
|
|
265
|
+
attrs
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
class Command < Message; end
|
|
270
|
+
class Event < Message; end
|
|
271
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sourced-message
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ismael Celis
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: plumb
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.0.17
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.0.17
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: fugit
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
description: Provides Sourced::Message — a Plumb-typed message with a shared type
|
|
41
|
+
registry, payloads, correlation, and scheduling — used as the common base for Sourced
|
|
42
|
+
and Sidereal messages.
|
|
43
|
+
email:
|
|
44
|
+
- ismaelct@gmail.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- LICENSE.txt
|
|
51
|
+
- README.md
|
|
52
|
+
- Rakefile
|
|
53
|
+
- lib/sourced/message.rb
|
|
54
|
+
homepage: https://github.com/ismasan/sourced-message
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata:
|
|
58
|
+
homepage_uri: https://github.com/ismasan/sourced-message
|
|
59
|
+
source_code_uri: https://github.com/ismasan/sourced-message
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 3.2.0
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 4.0.8
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: Canonical typed Message class and registry shared by Sourced and Sidereal.
|
|
77
|
+
test_files: []
|