sourced-message 0.3.0 → 0.5.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: c32c4b5ea1020c0623e0f572d67a529a70630e21dd9d865a43386c4ca318915b
4
- data.tar.gz: b1bf6ccf2eadf06ea8c5bf301f692a520bb9cb84de37c07a5a340a0395177ff5
3
+ metadata.gz: b8202b720a3a57bf15a429a136797186c2bc8bd2721a7539e2f08495e7078923
4
+ data.tar.gz: 6532b0db8f33fb6aa9ba4d585d5b0424fd089be4917e994f4a1abd341db132b4
5
5
  SHA512:
6
- metadata.gz: c9d6eb69097f9e6f70df79786f8a285c638d7a985f762ba15d70463109058b5884117d838242834db8771a1ee6996434695d5ab4a4dac4d9809a2d25c0456ffa
7
- data.tar.gz: a1b78a345ee0f8463372e729d9cce17ad28ce9c9da6a2d137da72c174ed10d668148018b7fdb7f13529d2c6d9db78a14f8401e0d976781457f95ec52944998ba
6
+ metadata.gz: ba7bafee643414f03b642074c521257fb731f663dc653d6ddedb3c6ca2d26304e727d7c719fac830d196aee3e1086aed22d126759c4671a3242fcc6c89fff5a8
7
+ data.tar.gz: 5f979cf196b3d6e0bc3b37ddc5b85f0a9284b1d8fad4a15a877ca835b18a747e49d9c1e5a5a0ad0b4acaaffe8b7bbb02411ee34f888445a30f6f3835c6eb267b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0]
4
+
5
+ - `Message#tags`: an envelope attribute (`Types::Tags`, Symbol keys and String values,
6
+ empty by default) holding key/value pairs a store can index alongside the payload's,
7
+ without the message class declaring them. Codecs encode and decode it with the rest
8
+ of the envelope.
9
+ - `#with_tags(**tags)` returns a copy with tags merged in, values stringified. It raises
10
+ `TagConflictError` when a tag names a payload attribute with a different value.
11
+ - `#correlate` leaves the target's tags alone: tags never pass on to messages produced
12
+ in reaction, unlike metadata.
13
+ - `Sourced::Message::VERSION` is now `0.5.0`.
14
+
15
+ ## [0.4.0]
16
+
17
+ - Requires plumb 0.4, whose codecs rewrite Hash keys like values: `#encode` now emits
18
+ String keys (`{'type' => ..., 'payload' => {'name' => ...}}`), and decoders accept
19
+ String or Symbol keys. `JSON.dump` output is unchanged.
20
+ - `Codec#decode` reads the envelope's `type` and `id` under either key form, so
21
+ `decode(encode(message))` works without a JSON round trip.
22
+ - `metadata` is typed as `Types::Metadata`: Symbol keys and `Types::JSONValue` values
23
+ (String, Numeric, boolean, nil, and Arrays/Symbol-keyed Hashes of those, at any depth).
24
+ Codecs now restore nested metadata keys as Symbols. A non-JSON value (a Time, a
25
+ Symbol) makes the message invalid and fails `#encode`, where `JSON.dump` used to
26
+ stringify it silently.
27
+ - `Sourced::Message::VERSION` is now `0.4.0`.
28
+
3
29
  ## [0.3.0]
4
30
 
5
31
  - Add `Sourced::Message#correlation_type`, the type counterpart of `correlation_id`:
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' })
@@ -182,23 +182,27 @@ module Sourced
182
182
  # Rebuild a message from decoded attributes. An unknown type raises: a process
183
183
  # reading types it doesn't know about is missing the class.
184
184
  #
185
- # @param attrs [Hash] symbol-keyed message attributes
185
+ # @param attrs [Hash] message attributes, String- (as #encode emits them) or Symbol-keyed
186
186
  # @return [Sourced::Message]
187
187
  # @raise [UnknownMessageError] if the type isn't in the registry
188
188
  # @raise [UnregisteredTypeError] if the type was not compiled
189
189
  # @raise [DecodeError] if the attributes don't satisfy the schema
190
190
  def decode(attrs)
191
- type = attrs[:type]
191
+ type = envelope(attrs, :type)
192
+ id = envelope(attrs, :id)
192
193
  klass = @registry[type]
193
- raise UnknownMessageError, "Unknown message type: #{label(type, attrs[:id])}" unless klass
194
+ raise UnknownMessageError, "Unknown message type: #{label(type, id)}" unless klass
194
195
 
195
- build(klass, attrs, pair(type, attrs[:id]).decoder)
196
+ build(klass, attrs, pair(type, id).decoder)
196
197
  rescue Plumb::ParseError => e
197
- raise DecodeError, "cannot decode #{label(type, attrs[:id])}: #{e.message}"
198
+ raise DecodeError, "cannot decode #{label(type, id)}: #{e.message}"
198
199
  end
199
200
 
200
201
  private
201
202
 
203
+ # Read before the decoder runs, so it must accept both key forms the decoder does.
204
+ def envelope(attrs, key) = attrs.fetch(key) { attrs[key.to_s] }
205
+
202
206
  # --- subclass seams -------------------------------------------------------
203
207
 
204
208
  # @param klass [Class<Sourced::Message>]
@@ -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.3.0'
25
+ VERSION = '0.5.0'
26
26
 
27
27
  EMPTY_ARRAY = [].freeze
28
28
 
@@ -34,10 +34,24 @@ module Sourced
34
34
 
35
35
  # Accepts a UUID string or generates a new one when none is provided.
36
36
  AutoUUID = UUID::V4.default { SecureRandom.uuid }
37
+
38
+ # A JSON value, with Symbol-keyed objects at every depth. Typed all the way
39
+ # down so a codec decodes nested keys too. The self-references are deferred
40
+ # inside the union, not the constant itself: naming a type here freezes it,
41
+ # and a frozen Deferred can't materialize.
42
+ JSONValue = String | Numeric | Boolean | Nil |
43
+ Array[Any.defer { JSONValue }] | Hash[Symbol, Any.defer { JSONValue }]
44
+
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]
37
49
  end
38
50
 
39
51
  # Raised by {.from} when a type string isn't registered.
40
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)
41
55
  # Raised by {#at} when a message would be scheduled in the past.
42
56
  PastMessageDateError = Class.new(ArgumentError)
43
57
  LaxTime = Types::Time | Types::String.build(::Time, :parse).policy(:rescue, ::ArgumentError)
@@ -47,7 +61,10 @@ module Sourced
47
61
  attribute? :causation_id, Types::UUID::V4
48
62
  attribute? :correlation_id, Types::UUID::V4
49
63
  attribute :created_at, LaxTime.default { Time.now }
50
- attribute :metadata, Types::Hash.default(Plumb::BLANK_HASH)
64
+ attribute :metadata, Types::Metadata.default(Plumb::BLANK_HASH)
65
+ # Envelope, not payload: {#correlate} leaves the target's tags alone, so they
66
+ # never pass on to messages produced in reaction.
67
+ attribute :tags, Types::Tags.default(Plumb::BLANK_HASH)
51
68
  attribute :payload, Types::Static[nil]
52
69
 
53
70
  # Lookup table mapping type strings to message subclasses.
@@ -191,6 +208,27 @@ module Sourced
191
208
  with(metadata: metadata.merge(meta))
192
209
  end
193
210
 
211
+ # A copy with +tags+ merged in, values stringified.
212
+ #
213
+ # @param tags [Hash{Symbol => #to_s}]
214
+ # @return [Message]
215
+ # @raise [TagConflictError] if a tag names a payload attribute with a different value
216
+ def with_tags(**tags)
217
+ return self if tags.empty?
218
+
219
+ tags = tags.transform_values(&:to_s)
220
+ declared = self.class.payload_attribute_names
221
+ tags.each do |key, value|
222
+ next unless declared.include?(key)
223
+ next if payload[key].to_s == value
224
+
225
+ raise TagConflictError,
226
+ "#{type} tag #{key}: #{value.inspect} conflicts with its payload value #{payload[key].inspect}"
227
+ end
228
+
229
+ with(tags: self.tags.merge(tags))
230
+ end
231
+
194
232
  def with_payload(attrs = {})
195
233
  hash = to_h
196
234
  (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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0.3'
18
+ version: '0.4'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '0.3'
25
+ version: '0.4'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: fugit
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -74,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
74
  - !ruby/object:Gem::Version
75
75
  version: '0'
76
76
  requirements: []
77
- rubygems_version: 4.0.8
77
+ rubygems_version: 4.0.21
78
78
  specification_version: 4
79
79
  summary: Canonical typed Message class and registry shared by Sourced and Sidereal.
80
80
  test_files: []