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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +10 -3
  4. data/lib/sourced/message.rb +74 -1
  5. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12ddf9298bf827a4f20e9f2db6b9edababd24efa73323326e5cd5ff6bbedebc1
4
- data.tar.gz: a88ab37ddb627868b20cd9a03615334a01d1f957fc3dedae25db4486623b983f
3
+ metadata.gz: 5f851a99f35af7389e8aeaa9a62e174c9c76fe83c7123ccf245160672250534b
4
+ data.tar.gz: af633b34af3853e032e2b4735394dec70b03bcce4e19b1dacd54f80cf76382f3
5
5
  SHA512:
6
- metadata.gz: 154e306175767857b9471c5700fe12ec3f73c17292af5c30826e2f61afa0dc217853e56950cddbf3decf8b38c38f5837e3cad76a822c6078cfd8904ef912093b
7
- data.tar.gz: 0640b5e2560fa1dfd331ee8b015dde0b5195bf1ec55efaa1cb0e98cf4b3713ca8544b4bc784495cb09f08d32d12bc2dc4c60977b60df99b021bf6f71a6e63c76
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' })
@@ -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.4.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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sourced-message
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis