sourced-message 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +10 -3
- data/lib/sourced/message.rb +74 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f851a99f35af7389e8aeaa9a62e174c9c76fe83c7123ccf245160672250534b
|
|
4
|
+
data.tar.gz: af633b34af3853e032e2b4735394dec70b03bcce4e19b1dacd54f80cf76382f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 46637a045f56cd99c1ead9dba880d6d846a58106d7d8200c35606fc21d484f92514f310cad4fc1f3ea20298c9e356a875069a4c076c9a40c73a7cc7ebdc1be85
|
|
7
|
+
data.tar.gz: 83972e23b181caa04596cb6235a5fe0aec375bf6795967a12157c1b104c1b5e380c37a246afff29ce57c3fa909847ade958567b0b90ff1af2831c7c922353846
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
- `Message::Registry` raises `DuplicateTypeError` when two classes with different names
|
|
4
|
+
claim one type string, instead of silently replacing the registered class. Registering
|
|
5
|
+
a type again is still allowed when the names are equal (a code reloader redefining a
|
|
6
|
+
constant) or either class is anonymous (throwaway classes in specs; a constant in an
|
|
7
|
+
anonymous module counts as anonymous until the module is named). `.define`
|
|
8
|
+
registers a class before it's assigned to its constant, so the comparison waits until
|
|
9
|
+
the new class has a name: the next registration of the type, its lookup (`[]`,
|
|
10
|
+
`Message.from`, codec `decode`) or `#all` (codec `compile!`) raises.
|
|
11
|
+
|
|
12
|
+
## [0.5.0]
|
|
13
|
+
|
|
14
|
+
- `Message#tags`: an envelope attribute (`Types::Tags`, Symbol keys and String values,
|
|
15
|
+
empty by default) holding key/value pairs a store can index alongside the payload's,
|
|
16
|
+
without the message class declaring them. Codecs encode and decode it with the rest
|
|
17
|
+
of the envelope.
|
|
18
|
+
- `#with_tags(**tags)` returns a copy with tags merged in, values stringified. It raises
|
|
19
|
+
`TagConflictError` when a tag names a payload attribute with a different value.
|
|
20
|
+
- `#correlate` leaves the target's tags alone: tags never pass on to messages produced
|
|
21
|
+
in reaction, unlike metadata.
|
|
22
|
+
- `Sourced::Message::VERSION` is now `0.5.0`.
|
|
23
|
+
|
|
3
24
|
## [0.4.0]
|
|
4
25
|
|
|
5
26
|
- Requires plumb 0.4, whose codecs rewrite Hash keys like values: `#encode` now emits
|
data/README.md
CHANGED
|
@@ -9,11 +9,12 @@ A message is a [Plumb](https://github.com/ismasan/plumb)-typed value object with
|
|
|
9
9
|
- an auto-generated `id` and `created_at` timestamp
|
|
10
10
|
- `causation_id` / `correlation_id` for tracing causal chains across processes
|
|
11
11
|
- arbitrary `metadata`
|
|
12
|
+
- `tags`: key/value pairs for a store to index, outside the payload
|
|
12
13
|
- a global **type registry** that can reconstruct any message from a plain hash — handy for transports, queues and event stores
|
|
13
14
|
- **codecs** that serialize messages to JSON or to form params and back, preserving the types each payload declares
|
|
14
15
|
- scheduling helpers (`#at` / `#in`) for delayed messages
|
|
15
16
|
|
|
16
|
-
Messages are immutable: every "mutating" method (`#with_payload`, `#with_metadata`, `#at`, `#correlate`) returns a copy.
|
|
17
|
+
Messages are immutable: every "mutating" method (`#with_payload`, `#with_metadata`, `#with_tags`, `#at`, `#correlate`) returns a copy.
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -69,6 +70,7 @@ msg.id # => "5f6e..." (auto-generated UUID)
|
|
|
69
70
|
msg.type # => "course.created"
|
|
70
71
|
msg.created_at # => 2026-06-06 12:00:00 ... (defaults to Time.now)
|
|
71
72
|
msg.metadata # => {}
|
|
73
|
+
msg.tags # => {}
|
|
72
74
|
msg.causation_id # => same as msg.id by default
|
|
73
75
|
msg.correlation_id # => same as msg.id by default
|
|
74
76
|
```
|
|
@@ -165,7 +167,7 @@ codec = Sourced::Message::JSONCodec.default.compile!
|
|
|
165
167
|
|
|
166
168
|
encoded = codec.encode(msg)
|
|
167
169
|
# => { id: "8f1c…", causation_id: "8f1c…", correlation_id: "8f1c…",
|
|
168
|
-
# created_at: "2026-09-01T10:00:00.000000+01:00", metadata: {}, type: "course.created",
|
|
170
|
+
# created_at: "2026-09-01T10:00:00.000000+01:00", metadata: {}, tags: {}, type: "course.created",
|
|
169
171
|
# payload: { course_name: "Ruby 101", starts_on: "2026-09-01", level: "beginner" } }
|
|
170
172
|
|
|
171
173
|
decoded = codec.decode(JSON.parse(JSON.dump(encoded), symbolize_names: true))
|
|
@@ -321,6 +323,11 @@ Messages are immutable. Use the `#with_*` helpers to derive new copies:
|
|
|
321
323
|
tagged = msg.with_metadata(channel: 'web', user_id: '42')
|
|
322
324
|
tagged.metadata # => { channel: 'web', user_id: '42' }
|
|
323
325
|
|
|
326
|
+
# Merge tags: String values, outside the payload (keeps the same id).
|
|
327
|
+
# A tag naming a payload attribute must match its value, or it raises TagConflictError.
|
|
328
|
+
scoped = msg.with_tags(school_id: 's1')
|
|
329
|
+
scoped.tags # => { school_id: 's1' }
|
|
330
|
+
|
|
324
331
|
# Override payload attributes
|
|
325
332
|
updated = msg.with_payload(seats: 25)
|
|
326
333
|
updated.payload.seats # => 25
|
|
@@ -329,7 +336,7 @@ updated.payload.course_name # => "Ruby 101" (unchanged)
|
|
|
329
336
|
|
|
330
337
|
### Correlation: tracing causal chains
|
|
331
338
|
|
|
332
|
-
`#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.
|
|
339
|
+
`#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. Tags are not: the copy keeps the target's own.
|
|
333
340
|
|
|
334
341
|
```ruby
|
|
335
342
|
trigger = EnrollStudent.new(payload: { student_id: '42' })
|
data/lib/sourced/message.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Sourced
|
|
|
22
22
|
# +Sourced::Message.registry[type]+ resolves a type registered under any
|
|
23
23
|
# subclass. Resolve from this root to see the whole tree.
|
|
24
24
|
class Message < Plumb::Types::Data
|
|
25
|
-
VERSION = '0.
|
|
25
|
+
VERSION = '0.5.1'
|
|
26
26
|
|
|
27
27
|
EMPTY_ARRAY = [].freeze
|
|
28
28
|
|
|
@@ -43,12 +43,19 @@ module Sourced
|
|
|
43
43
|
Array[Any.defer { JSONValue }] | Hash[Symbol, Any.defer { JSONValue }]
|
|
44
44
|
|
|
45
45
|
Metadata = Hash[Symbol, JSONValue]
|
|
46
|
+
|
|
47
|
+
# Key/value pairs a store can index alongside the payload's. Values are Strings.
|
|
48
|
+
Tags = Hash[Symbol, String]
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
# Raised by {.from} when a type string isn't registered.
|
|
49
52
|
UnknownMessageError = Class.new(ArgumentError)
|
|
53
|
+
# Raised by {#with_tags} when a tag names a payload attribute with a different value.
|
|
54
|
+
TagConflictError = Class.new(ArgumentError)
|
|
50
55
|
# Raised by {#at} when a message would be scheduled in the past.
|
|
51
56
|
PastMessageDateError = Class.new(ArgumentError)
|
|
57
|
+
# Raised by {Registry} when two classes with different names claim one type string.
|
|
58
|
+
DuplicateTypeError = Class.new(ArgumentError)
|
|
52
59
|
LaxTime = Types::Time | Types::String.build(::Time, :parse).policy(:rescue, ::ArgumentError)
|
|
53
60
|
|
|
54
61
|
attribute :id, Types::AutoUUID
|
|
@@ -57,14 +64,25 @@ module Sourced
|
|
|
57
64
|
attribute? :correlation_id, Types::UUID::V4
|
|
58
65
|
attribute :created_at, LaxTime.default { Time.now }
|
|
59
66
|
attribute :metadata, Types::Metadata.default(Plumb::BLANK_HASH)
|
|
67
|
+
# Envelope, not payload: {#correlate} leaves the target's tags alone, so they
|
|
68
|
+
# never pass on to messages produced in reaction.
|
|
69
|
+
attribute :tags, Types::Tags.default(Plumb::BLANK_HASH)
|
|
60
70
|
attribute :payload, Types::Static[nil]
|
|
61
71
|
|
|
62
72
|
# Lookup table mapping type strings to message subclasses.
|
|
73
|
+
#
|
|
74
|
+
# Registering a type again replaces its class: a code reloader redefines a
|
|
75
|
+
# constant, and specs define throwaway anonymous messages reusing type
|
|
76
|
+
# strings. Two classes with different permanent names claiming one type are
|
|
77
|
+
# a bug, and raise {DuplicateTypeError}. {.define} registers a class before
|
|
78
|
+
# it's assigned to its constant, so the replaced class is kept until its
|
|
79
|
+
# successor has a name, and compared on the next registration or lookup.
|
|
63
80
|
class Registry
|
|
64
81
|
# @param message_class [Class] the root message class for this registry
|
|
65
82
|
def initialize(message_class)
|
|
66
83
|
@message_class = message_class
|
|
67
84
|
@lookup = {}
|
|
85
|
+
@replaced = {}
|
|
68
86
|
end
|
|
69
87
|
|
|
70
88
|
# @return [Array<String>] registered type strings
|
|
@@ -77,7 +95,14 @@ module Sourced
|
|
|
77
95
|
#
|
|
78
96
|
# @param key [String] message type string
|
|
79
97
|
# @param klass [Class] message subclass
|
|
98
|
+
# @raise [DuplicateTypeError] if a class with a different name holds the type
|
|
80
99
|
def []=(key, klass)
|
|
100
|
+
check!(key)
|
|
101
|
+
previous = @lookup[key]
|
|
102
|
+
if previous && named(previous) && !previous.equal?(klass)
|
|
103
|
+
duplicate!(key, previous, klass) if named(klass) && named(klass) != named(previous)
|
|
104
|
+
@replaced[key] = previous unless named(klass)
|
|
105
|
+
end
|
|
81
106
|
@lookup[key] = klass
|
|
82
107
|
end
|
|
83
108
|
|
|
@@ -86,7 +111,9 @@ module Sourced
|
|
|
86
111
|
#
|
|
87
112
|
# @param key [String] message type string
|
|
88
113
|
# @return [Class, nil]
|
|
114
|
+
# @raise [DuplicateTypeError] if a class with a different name holds the type
|
|
89
115
|
def [](key)
|
|
116
|
+
check!(key) unless @replaced.empty?
|
|
90
117
|
klass = lookup[key]
|
|
91
118
|
return klass if klass
|
|
92
119
|
|
|
@@ -101,9 +128,11 @@ module Sourced
|
|
|
101
128
|
#
|
|
102
129
|
# @return [Enumerator<Class>] if no block given
|
|
103
130
|
# @yield [Class] each registered message class
|
|
131
|
+
# @raise [DuplicateTypeError] if a class with a different name holds a type
|
|
104
132
|
def all(&block)
|
|
105
133
|
return enum_for(:all) unless block
|
|
106
134
|
|
|
135
|
+
@replaced.keys.each { |key| check!(key) }
|
|
107
136
|
lookup.each_value(&block)
|
|
108
137
|
subclasses.each { |c| c.registry.all(&block) }
|
|
109
138
|
end
|
|
@@ -111,6 +140,29 @@ module Sourced
|
|
|
111
140
|
private
|
|
112
141
|
|
|
113
142
|
attr_reader :lookup, :message_class
|
|
143
|
+
|
|
144
|
+
# Compare the class a type replaced with the one holding it, once the latter has a name.
|
|
145
|
+
def check!(key)
|
|
146
|
+
replaced = @replaced[key]
|
|
147
|
+
return unless replaced
|
|
148
|
+
|
|
149
|
+
current = @lookup[key]
|
|
150
|
+
return unless named(current)
|
|
151
|
+
|
|
152
|
+
@replaced.delete(key)
|
|
153
|
+
duplicate!(key, replaced, current) unless named(current) == named(replaced)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A class's permanent name. A constant set in an anonymous module has a
|
|
157
|
+
# temporary one (+"#<Class:0x...>::Foo"+) until the module is named.
|
|
158
|
+
def named(klass)
|
|
159
|
+
name = klass.name
|
|
160
|
+
name unless name.nil? || name.start_with?('#<')
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def duplicate!(key, registered, klass)
|
|
164
|
+
raise DuplicateTypeError, "message type #{key.inspect} is claimed by both #{registered.name} and #{klass.name}"
|
|
165
|
+
end
|
|
114
166
|
end
|
|
115
167
|
|
|
116
168
|
# @return [Registry] the message type registry for this class
|
|
@@ -200,6 +252,27 @@ module Sourced
|
|
|
200
252
|
with(metadata: metadata.merge(meta))
|
|
201
253
|
end
|
|
202
254
|
|
|
255
|
+
# A copy with +tags+ merged in, values stringified.
|
|
256
|
+
#
|
|
257
|
+
# @param tags [Hash{Symbol => #to_s}]
|
|
258
|
+
# @return [Message]
|
|
259
|
+
# @raise [TagConflictError] if a tag names a payload attribute with a different value
|
|
260
|
+
def with_tags(**tags)
|
|
261
|
+
return self if tags.empty?
|
|
262
|
+
|
|
263
|
+
tags = tags.transform_values(&:to_s)
|
|
264
|
+
declared = self.class.payload_attribute_names
|
|
265
|
+
tags.each do |key, value|
|
|
266
|
+
next unless declared.include?(key)
|
|
267
|
+
next if payload[key].to_s == value
|
|
268
|
+
|
|
269
|
+
raise TagConflictError,
|
|
270
|
+
"#{type} tag #{key}: #{value.inspect} conflicts with its payload value #{payload[key].inspect}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
with(tags: self.tags.merge(tags))
|
|
274
|
+
end
|
|
275
|
+
|
|
203
276
|
def with_payload(attrs = {})
|
|
204
277
|
hash = to_h
|
|
205
278
|
(hash[:payload] ||= {}).merge!(attrs)
|