sourced-message 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab48ba13a4c9d34b8b50fac74177da86672fb65bc55ebccd869b53299c24129c
4
- data.tar.gz: 7c055a6c838bc91f31971bbdc8eb1b1b598d2531ee8627317ba9e2eae572acb3
3
+ metadata.gz: a3c83eaa51d67e2693a9437766624a7331535d0580e3d7481a6b97baeb58f63f
4
+ data.tar.gz: 892f3f3a82b5d9063d830516d167c3f3e7b16b86e8ac2047f1614e787142b48f
5
5
  SHA512:
6
- metadata.gz: 19774b1fa8a68caa0b3074da601e03710f94f7996a2b6ef2b7dcc593abb32d6c34f8a17a71ea7d5be5ad508e4dafde6ad617518de054f04e3b1f2c56e97c4ac1
7
- data.tar.gz: b859d67f328cca620ffc66d15f87cfa33054d0b726bdbb17344a7a5d9f6be1adef325b8abfe960bc6f2a6d702dc95fb3583f2bb4ef42539d35251e851acf798d
6
+ metadata.gz: 0264f5b499a6d287ed286e0e80158dad36d171dd0da3ff9dfe8425dff0bab2884b64c2405eae964db1dcee415f20763df415b6938d95247e56e937f7d196daf8
7
+ data.tar.gz: 57dd69b783f5d076babe42d2e05ff4c654fc710da47098dafa8f20907f98347a6c0e3728e43505a9acf30a81b7cfb8635d35d16b388c88f8250a20010bffcc35
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0]
4
+
5
+ - Add `Sourced::Message::Codec`, the abstract serializer: it compiles a
6
+ `[decoder, encoder]` pair per registered message type over a `Plumb::Codec` format and
7
+ encodes/decodes whole messages. A subclass binds the format by answering
8
+ `.default_format`; each gets its own `.default` and pair cache.
9
+ - Add `Sourced::Message::JSONCodec` (`Plumb::Codec::JSON`) and
10
+ `Sourced::Message::FormsCodec` (`Plumb::Codec::Forms`). The Forms one types string
11
+ params from a browser using the message's own schema.
12
+ - Sourced's store and Sidereal's file store and socket pubsub carried near-identical
13
+ copies of this machinery; both now build on it. Three private seams —
14
+ `#compiled_type`, `#encode_subject`, `#build` — let a subclass change what is
15
+ compiled and encoded, which is how `Sourced::Store::MessageCodec` encodes payloads
16
+ alone while keeping its envelope in columns.
17
+ - `Sourced::Message::VERSION` is now `0.2.0`.
18
+
3
19
  ## [0.1.0] - 2026-06-06
4
20
 
5
21
  - Initial release
data/README.md CHANGED
@@ -10,6 +10,7 @@ A message is a [Plumb](https://github.com/ismasan/plumb)-typed value object with
10
10
  - `causation_id` / `correlation_id` for tracing causal chains across processes
11
11
  - arbitrary `metadata`
12
12
  - a global **type registry** that can reconstruct any message from a plain hash — handy for transports, queues and event stores
13
+ - **codecs** that serialize messages to JSON or to form params and back, preserving the types each payload declares
13
14
  - scheduling helpers (`#at` / `#in`) for delayed messages
14
15
 
15
16
  Messages are immutable: every "mutating" method (`#with_payload`, `#with_metadata`, `#at`, `#correlate`) returns a copy.
@@ -127,6 +128,190 @@ Sourced::Message.registry.all.to_a # => [CourseCreated, EnrollStudent, ...]
127
128
  Sourced::Message.registry['course.created'] # => CourseCreated
128
129
  ```
129
130
 
131
+ ### Serialization: codecs
132
+
133
+ `.from` rebuilds the right class from a hash, but it does **not** translate values. Message types are declared with native Ruby types, and JSON has no `Date`, `Time`, `Symbol` or `BigDecimal` — so a naive `to_h` → JSON → `.from` round trip quietly hands back strings:
134
+
135
+ ```ruby
136
+ CourseCreated = Sourced::Message.define('course.created') do
137
+ attribute :course_name, String
138
+ attribute :starts_on, Sourced::Message::Types::Date
139
+ attribute :level, Sourced::Message::Types::Symbol
140
+ end
141
+
142
+ msg = CourseCreated.new(
143
+ payload: { course_name: 'Ruby 101', starts_on: Date.new(2026, 9, 1), level: :beginner }
144
+ )
145
+
146
+ back = Sourced::Message.from(JSON.parse(JSON.dump(msg.to_h), symbolize_names: true))
147
+ back.payload.starts_on # => "2026-09-01" (a String!)
148
+ back.payload.level # => "beginner" (a String!)
149
+ back.valid? # => false
150
+ ```
151
+
152
+ Nothing raises along the way, because `.new` does not validate. The message is simply wrong, and you find out somewhere else entirely.
153
+
154
+ A codec closes that gap. It compiles a `[decoder, encoder]` pair per registered message type and translates values in both directions. Two ship, differing only in the wire format they bind:
155
+
156
+ | Class | Format | For |
157
+ |---|---|---|
158
+ | `Sourced::Message::JSONCodec` | `Plumb::Codec::JSON` | stores, queues, socket frames, files |
159
+ | `Sourced::Message::FormsCodec` | `Plumb::Codec::Forms` | HTML form params and query strings |
160
+
161
+ Both inherit their machinery from `Sourced::Message::Codec`, which is abstract — it has no format of its own and exists to be subclassed (or handed a `format:` for a one-off).
162
+
163
+ ```ruby
164
+ codec = Sourced::Message::JSONCodec.default.compile!
165
+
166
+ encoded = codec.encode(msg)
167
+ # => { id: "8f1c…", causation_id: "8f1c…", correlation_id: "8f1c…",
168
+ # created_at: "2026-09-01T10:00:00.000000+01:00", metadata: {}, type: "course.created",
169
+ # payload: { course_name: "Ruby 101", starts_on: "2026-09-01", level: "beginner" } }
170
+
171
+ decoded = codec.decode(JSON.parse(JSON.dump(encoded), symbolize_names: true))
172
+ decoded.payload.starts_on # => #<Date: 2026-09-01>
173
+ decoded.payload.level # => :beginner
174
+ ```
175
+
176
+ `#encode` returns JSON-native structures — Hashes, Arrays, Strings, numbers, booleans, `nil` — ready for `JSON.dump`. The codec never writes bytes itself, so the transport decides how they are stored or framed.
177
+
178
+ #### Compiling is explicit
179
+
180
+ A codec has no pairs until it is compiled, and it never compiles itself on first use. Compile once, wherever your process considers boot to be over and every message class has loaded:
181
+
182
+ ```ruby
183
+ codec = Sourced::Message::JSONCodec.default
184
+ codec.compiled? # => false
185
+ codec.compile! # => the codec, pairs built and frozen
186
+ codec.encode(msg) # ready
187
+ ```
188
+
189
+ This is also the **boot check**. A message type the format cannot represent raises at `compile!`, naming the offending attribute, instead of failing on the first message that happens to carry it:
190
+
191
+ ```ruby
192
+ Sourced::Message.define('reports.generated') { attribute :result, Plumb::Types::Any }
193
+ Sourced::Message::JSONCodec.default.compile!
194
+ # => Plumb::TypeError: cannot apply Plumb::Codec::JSON[…] (decode) to …:
195
+ # field `payload.result` (Plumb::Types::Any) matches no encoder and is not
196
+ # covered by its noop types. Register an encoder for it, or declare it with .noop.
197
+ ```
198
+
199
+ The path is dotted from the message root, so `payload.result` points straight at the attribute to fix.
200
+
201
+ `#compile!` is **idempotent**, so several collaborators sharing one codec can each call it on start without coordinating. A type registered *after* a compile stays invisible until you ask for a rebuild:
202
+
203
+ ```ruby
204
+ codec.compile! # cheap no-op once compiled
205
+ codec.recompile! # rebuild, picking up types and encoders registered since
206
+ ```
207
+
208
+ #### Errors
209
+
210
+ | Raised by | When |
211
+ |---|---|
212
+ | `Plumb::TypeError` | `#compile!` — a message type this format cannot represent |
213
+ | `JSONCodec::EncodeError` | `#encode` — the message does not satisfy its own schema |
214
+ | `JSONCodec::DecodeError` | `#decode` — the encoded values no longer fit the schema (a schema change, a hand-edited record, a foreign writer) |
215
+ | `JSONCodec::UnregisteredTypeError` | either — nothing has compiled yet, or this type was not in the compiled set |
216
+ | `Sourced::Message::UnknownMessageError` | `#decode` — the type string is not in the registry at all |
217
+
218
+ `EncodeError` and `DecodeError` name the offending type and message id, so a bad record is findable.
219
+
220
+ #### Teaching it your own types
221
+
222
+ The format is `Plumb::Codec::JSON`, a process-wide global. Register an encoder on it and every codec in the process learns the type at once:
223
+
224
+ ```ruby
225
+ Money = Data.define(:cents, :currency)
226
+
227
+ class MoneyEncoder < Plumb::Encoder[
228
+ Plumb::Types::String[/\A-?\d+ [A-Z]{3}\z/] => Plumb::Types::Any[Money]
229
+ ]
230
+ def encode(money) = "#{money.cents} #{money.currency}"
231
+
232
+ def decode(str)
233
+ cents, currency = str.split
234
+ Money.new(cents: cents.to_i, currency:)
235
+ end
236
+ end
237
+
238
+ Plumb::Codec::JSON.encoder(MoneyEncoder)
239
+ ```
240
+
241
+ Register at load time. A codec compiled before an encoder arrives never sees it.
242
+
243
+ Note the constraint this creates: every message class in the registry must be encodable by the format, because `#compile!` walks all of them. A type carrying a value the format knows nothing about fails the compile for everyone.
244
+
245
+ #### Decoding form params: `FormsCodec`
246
+
247
+ Form params carry no types — every scalar arrives as a String. `FormsCodec` lets the message's own schema do the coercion a web handler would otherwise do by hand:
248
+
249
+ ```ruby
250
+ codec = Sourced::Message::FormsCodec.default.compile!
251
+
252
+ msg = codec.decode(
253
+ id: SecureRandom.uuid,
254
+ type: 'course.created',
255
+ created_at: Time.now.iso8601(6),
256
+ metadata: {},
257
+ payload: { course_name: 'Ruby 101', seats: '30', starts_on: '2026-09-01' }
258
+ )
259
+
260
+ msg.payload.seats # => 30 (Integer, from "30")
261
+ msg.payload.starts_on # => #<Date: 2026-09-01> (from "2026-09-01")
262
+ ```
263
+
264
+ Encoding renders the mirror image — every scalar a String — which is what a form needs to round-trip a message back to the browser.
265
+
266
+ #### Encoding something other than the whole message
267
+
268
+ `JSONCodec` encodes the entire message, envelope included, which suits a transport that carries one message as one document — a file body, a socket frame. A store that keeps the envelope in columns wants only the payload encoded. Subclass and override three private seams:
269
+
270
+ ```ruby
271
+ class PayloadOnlyCodec < Sourced::Message::JSONCodec
272
+ private
273
+
274
+ # What Plumb type to compile for a message class.
275
+ def compiled_type(klass)
276
+ schema = klass._schema.to_h
277
+ schema[schema.keys.find { |k| k.to_sym == :payload }]
278
+ end
279
+
280
+ # What #encode feeds the encoder.
281
+ def encode_subject(message) = message.payload
282
+
283
+ # What #decode returns.
284
+ def build(klass, attrs, decoder)
285
+ klass.new(attrs.merge(payload: decoder.parse(attrs[:payload])))
286
+ end
287
+ end
288
+ ```
289
+
290
+ This is exactly what `Sourced::Store::MessageCodec` does. Subclasses get their own `.default` and pair cache automatically — which they need, since one message class compiles to a different pair on each side.
291
+
292
+ #### Sharing, resetting and the pair cache
293
+
294
+ `.default` is the shared instance, so a process compiles its pairs once. `.reset!` drops it — for tests between examples, and for a development-mode class reloader:
295
+
296
+ ```ruby
297
+ Sourced::Message::JSONCodec.default # the shared instance
298
+ Sourced::Message::JSONCodec.reset! # next .default compiles afresh
299
+ ```
300
+
301
+ Compiled pairs are cached per message class on the codec class and **survive `reset!`**, because building a pair is the whole cost of a compile and a class that did not change does not need a new one. Redefining a type produces a new class, which misses the cache and compiles fresh. `.clear_pairs!` forces a cold rebuild — needed only for a class whose schema changed *in place*, which `.reset!` cannot detect since the class is the same object.
302
+
303
+ A codec can also be scoped to its own set of encoders, or to a private registry:
304
+
305
+ ```ruby
306
+ class AuditFormat < Plumb::Codec::JSON
307
+ encoder RedactedEmailEncoder
308
+ end
309
+
310
+ Sourced::Message::JSONCodec.new(format: AuditFormat, registry: my_registry).compile!
311
+ ```
312
+
313
+ `registry:` needs only `#all(&block)` and `#[](type)` — that is the whole contract.
314
+
130
315
  ### Copying with changes
131
316
 
132
317
  Messages are immutable. Use the `#with_*` helpers to derive new copies:
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb'
4
+
5
+ module Sourced
6
+ class Message
7
+ # Compiles a +[decoder, encoder]+ {Pair} per message class and serializes messages
8
+ # through it. Abstract: a subclass binds the wire format by answering
9
+ # {.default_format}. {JSONCodec} and {FormsCodec} are the two that ship.
10
+ #
11
+ # codec = Sourced::Message::JSONCodec.default.compile!
12
+ # codec.encode(message) # => structures native to the format
13
+ # codec.decode(attrs) # => the message
14
+ #
15
+ # Pairs are keyed by type string and built by {#compile!} from a {Registry}; a type
16
+ # missing from the result raises, so a type defined after the compile stays invisible
17
+ # until a fresh codec is built. Compiling is always explicit — nothing here compiles
18
+ # itself on first use — which makes "when is serialization settled?" a question with
19
+ # one answer per process.
20
+ #
21
+ # It encodes the *whole* message, envelope included, which suits a transport carrying
22
+ # a message as a single document (a file, a socket frame, a form submission). A store
23
+ # that keeps the envelope in columns wants only the payload encoded;
24
+ # {Sourced::Store::MessageCodec} subclasses {JSONCodec} for exactly that. The three
25
+ # private seams below are the whole subclass contract:
26
+ #
27
+ # #compiled_type(klass) what Plumb type to compile for a message class
28
+ # #encode_subject(message) what #encode feeds the encoder
29
+ # #build(klass, attrs, decoder) what #decode returns
30
+ #
31
+ # Every subclass gets its own +.default+ and +.pairs+ for free (both are plain class
32
+ # ivars), which is load-bearing: one message class compiles to a different pair under
33
+ # each format and each seam set, and they must not collide.
34
+ class Codec
35
+ # The format a subclass serializes with. Abstract here: {Codec} itself has no
36
+ # format, so it can only be instantiated by passing one explicitly.
37
+ #
38
+ # @return [Class<Plumb::Codec>]
39
+ def self.default_format
40
+ raise NotImplementedError,
41
+ "#{name} has no format of its own; use a subclass (JSONCodec, FormsCodec) " \
42
+ 'or pass format:'
43
+ end
44
+
45
+ # Raised when a message being written can't be represented in the codec's
46
+ # format, which in practice means the message itself is invalid.
47
+ EncodeError = Class.new(StandardError)
48
+
49
+ # Raised when a serialized message no longer satisfies its class's schema —
50
+ # a schema change, a hand-edited record, a foreign writer.
51
+ DecodeError = Class.new(StandardError)
52
+
53
+ # Raised when asked for a type this codec has no pair for: one defined after the
54
+ # compile, one absent from this process, or any type at all when nothing has
55
+ # compiled yet.
56
+ UnregisteredTypeError = Class.new(StandardError)
57
+
58
+ # A message class's compiled +[decoder, encoder]+ for one format.
59
+ Pair = Data.define(:decoder, :encoder)
60
+
61
+ # The instance that callers share. Holding no connections or file handles, it is
62
+ # safe to share, so a process compiles its pairs once.
63
+ #
64
+ # @return [Codec]
65
+ def self.default = @default ||= new
66
+
67
+ # Drop the shared instance, so the next {.default} compiles against the current
68
+ # registry — between tests, and for a development-mode class reloader.
69
+ #
70
+ # The {.pairs} cache deliberately survives: building a pair is the expensive part
71
+ # of a compile, and a class that did not change does not need a new one.
72
+ #
73
+ # @return [void]
74
+ def self.reset!
75
+ @default = nil
76
+ end
77
+
78
+ # Compiled pairs, keyed by message class, shared across every instance of this
79
+ # class and every reset. A class's schema is fixed once its +define+ block has
80
+ # run, so its pair is too — which makes recompiling a matter of re-collecting
81
+ # existing pairs. Redefining a message type produces a *new* class, so it misses
82
+ # the cache and compiles fresh; that is what makes the cache safe under reloading.
83
+ # (Reopening a class to add attributes after it has been compiled once would not
84
+ # be picked up; {.clear_pairs!} is the way out of that.)
85
+ #
86
+ # Keyed by the message class, not by the type {#compiled_type} returns: a class is
87
+ # identity-keyed and stable, where a Plumb node would put the cache at the mercy
88
+ # of that node's +#hash+/+#eql?+.
89
+ #
90
+ # Held strongly. A weakly-keyed map would not help: a pair is built from its class
91
+ # and refers back to it, so holding the pair keeps the class reachable either way.
92
+ # A reloader that discards classes should call {.clear_pairs!}.
93
+ #
94
+ # @return [Hash{Class => Hash{Class<Plumb::Codec> => Pair}}]
95
+ def self.pairs
96
+ @pairs ||= {}
97
+ end
98
+
99
+ # Discard cached pairs, so every class is compiled again. For a reloader dropping
100
+ # classes, and for a class whose schema changed in place — which {.reset!} cannot
101
+ # detect, since the class is the same object.
102
+ #
103
+ # @return [void]
104
+ def self.clear_pairs!
105
+ @pairs = nil
106
+ end
107
+
108
+ # @return [Class<Plumb::Codec>] the format compiled onto message types
109
+ attr_reader :format
110
+
111
+ # @param format [Class<Plumb::Codec>] the format compiled onto message types.
112
+ # Defaults to the subclass's {.default_format}; pass one to scope a codec to its
113
+ # own set of encoders, as specs do.
114
+ # @param registry [Registry] resolves type strings to classes. Needs only +#all+
115
+ # and +#[]+. Defaults to the root registry, which recurses into every subclass
116
+ # registry, so one codec covers every message type in the process.
117
+ def initialize(format: self.class.default_format, registry: Sourced::Message.registry)
118
+ @format = format
119
+ @registry = registry
120
+ @messages = nil
121
+ end
122
+
123
+ # +attr_reader :format+ shadows +Kernel#format+ in instance scope, so this
124
+ # interpolates.
125
+ #
126
+ # @return [String]
127
+ def inspect = "#<#{self.class.name} format=#{@format.name}#{compiled? ? '' : ' (not compiled)'}>"
128
+
129
+ # @return [Boolean] whether {#compile!} has run
130
+ def compiled? = !@messages.nil?
131
+
132
+ # Build a pair for every message class in the registry, frozen once they are all in.
133
+ #
134
+ # Also the boot check: a message type this format cannot represent raises here,
135
+ # naming the offending attribute path, so the failure lands at boot instead of on
136
+ # the first message that happens to carry the type.
137
+ #
138
+ # Idempotent, so several collaborators sharing a codec can each call it on start
139
+ # without coordinating. {#recompile!} is how to pick up types registered since.
140
+ #
141
+ # Note what it does *not* check: whether data already written satisfies its schema.
142
+ # That is a per-message question, answered by {#decode} when the message is read.
143
+ #
144
+ # @return [self]
145
+ # @raise [Plumb::TypeError] if any registered message type can't be serialized by
146
+ # this format
147
+ def compile!
148
+ return self if compiled?
149
+
150
+ messages = {}
151
+ @registry.all { |klass| messages[klass.type] = pair_for(klass) }
152
+ @messages = messages.freeze
153
+ self
154
+ end
155
+
156
+ # Compile again from scratch, picking up message types and encoders registered
157
+ # since the last compile.
158
+ #
159
+ # @return [self]
160
+ # @raise [Plumb::TypeError] see {#compile!}
161
+ def recompile!
162
+ @messages = nil
163
+ compile!
164
+ end
165
+
166
+ # @param type [String] message type string
167
+ # @return [Boolean] whether a pair was compiled for this type
168
+ def registered?(type) = !@messages.nil? && @messages.key?(type)
169
+
170
+ # Encode a message into the format's native values, ready to serialize.
171
+ #
172
+ # @param message [Sourced::Message]
173
+ # @return [Object] whatever the format renders — a Hash for JSON
174
+ # @raise [EncodeError] if the message doesn't satisfy its own schema
175
+ # @raise [UnregisteredTypeError] if the type was not compiled
176
+ def encode(message)
177
+ pair(message.type, message.id).encoder.parse(encode_subject(message))
178
+ rescue Plumb::ParseError => e
179
+ raise EncodeError, "cannot encode #{label(message.type, message.id)}: #{e.message}"
180
+ end
181
+
182
+ # Rebuild a message from decoded attributes. An unknown type raises: a process
183
+ # reading types it doesn't know about is missing the class.
184
+ #
185
+ # @param attrs [Hash] symbol-keyed message attributes
186
+ # @return [Sourced::Message]
187
+ # @raise [UnknownMessageError] if the type isn't in the registry
188
+ # @raise [UnregisteredTypeError] if the type was not compiled
189
+ # @raise [DecodeError] if the attributes don't satisfy the schema
190
+ def decode(attrs)
191
+ type = attrs[:type]
192
+ klass = @registry[type]
193
+ raise UnknownMessageError, "Unknown message type: #{label(type, attrs[:id])}" unless klass
194
+
195
+ build(klass, attrs, pair(type, attrs[:id]).decoder)
196
+ rescue Plumb::ParseError => e
197
+ raise DecodeError, "cannot decode #{label(type, attrs[:id])}: #{e.message}"
198
+ end
199
+
200
+ private
201
+
202
+ # --- subclass seams -------------------------------------------------------
203
+
204
+ # @param klass [Class<Sourced::Message>]
205
+ # @return [Object] the Plumb type to compile a pair for
206
+ def compiled_type(klass) = klass
207
+
208
+ # @param message [Sourced::Message]
209
+ # @return [Object] what the encoder receives
210
+ def encode_subject(message) = message
211
+
212
+ # @param klass [Class<Sourced::Message>] resolved from the registry
213
+ # @param attrs [Hash] the encoded attributes
214
+ # @param decoder [Object] this type's compiled decoder
215
+ # @return [Sourced::Message]
216
+ def build(_klass, attrs, decoder) = decoder.parse(attrs)
217
+
218
+ # --------------------------------------------------------------------------
219
+
220
+ # @raise [UnregisteredTypeError] naming the message, and separating "nothing has
221
+ # compiled" from "this type isn't in the compiled set" — the two are fixed in
222
+ # different places
223
+ def pair(type, id)
224
+ raise UnregisteredTypeError, "codec has not compiled; #{label(type, id)} cannot be handled" if @messages.nil?
225
+
226
+ @messages[type] ||
227
+ raise(UnregisteredTypeError, "no encoder/decoder compiled for #{label(type, id)}")
228
+ end
229
+
230
+ # The class's pair for this format, built once per class and reused across every
231
+ # recompile. Building it is the whole cost of a compile — the rewrite walks the
232
+ # schema and resolves an encoder for every leaf.
233
+ def pair_for(klass)
234
+ by_format = self.class.pairs[klass] ||= {}
235
+ by_format[@format] ||= Pair.new(*@format.for(compiled_type(klass)))
236
+ end
237
+
238
+ # "orders.placed (a1b2c3…)" — enough to find the offending message.
239
+ def label(type, id) = "#{type} (#{id})"
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sourced/message/codec'
4
+
5
+ module Sourced
6
+ class Message
7
+ # Serializes messages to and from the string-shaped structures of HTML forms and
8
+ # query strings, where every scalar arrives as a String.
9
+ #
10
+ # Decoding is the direction that earns its keep: form params carry no types, so a
11
+ # web handler would otherwise coerce each attribute by hand. Here the message's own
12
+ # schema does it.
13
+ #
14
+ # codec = Sourced::Message::FormsCodec.default.compile!
15
+ #
16
+ # codec.decode(
17
+ # id: '…', type: 'course.created', created_at: '2026-09-01T10:00:00.000000+01:00',
18
+ # metadata: {}, payload: { course_name: 'Ruby 101', seats: '30', starts_on: '2026-09-01' }
19
+ # )
20
+ # # => CourseCreated with seats: 30 (Integer) and starts_on: #<Date: 2026-09-01>
21
+ #
22
+ # Encoding renders the mirror image — every scalar a String — which is what a form
23
+ # needs to round-trip a message back to the browser.
24
+ #
25
+ # See {Codec} for the machinery, and for the seams a subclass overrides.
26
+ class FormsCodec < Codec
27
+ def self.default_format = Plumb::Codec::Forms
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sourced/message/codec'
4
+
5
+ module Sourced
6
+ class Message
7
+ # Serializes messages to and from JSON-native structures — Hashes, Arrays, Strings,
8
+ # numbers, booleans, +nil+ — ready for +JSON.dump+. The codec never writes bytes
9
+ # itself, so the transport decides how they are stored or framed.
10
+ #
11
+ # codec = Sourced::Message::JSONCodec.default.compile!
12
+ # codec.encode(message) # => JSON-native Hash
13
+ # codec.decode(attrs) # => the message
14
+ #
15
+ # +Plumb::Codec::JSON+ is a process-wide global, so an encoder registered on it
16
+ # teaches every JSON codec in the process at once:
17
+ #
18
+ # Plumb::Codec::JSON.encoder(MoneyEncoder)
19
+ #
20
+ # Register at load time — a codec compiled before an encoder arrives never sees it.
21
+ #
22
+ # See {Codec} for the machinery, and for the seams a subclass overrides.
23
+ class JSONCodec < Codec
24
+ def self.default_format = Plumb::Codec::JSON
25
+ end
26
+ end
27
+ end
@@ -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.1.1'
25
+ VERSION = '0.2.1'
26
26
 
27
27
  EMPTY_ARRAY = [].freeze
28
28
 
@@ -270,3 +270,8 @@ module Sourced
270
270
  class Command < Message; end
271
271
  class Event < Message; end
272
272
  end
273
+
274
+ # Required here so `require 'sourced/message'` gives you the whole gem.
275
+ require 'sourced/message/codec'
276
+ require 'sourced/message/json_codec'
277
+ require 'sourced/message/forms_codec'
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.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: plumb
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.0.18
18
+ version: 0.2.0.beta.2
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.0.18
25
+ version: 0.2.0.beta.2
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: fugit
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +51,9 @@ files:
51
51
  - README.md
52
52
  - Rakefile
53
53
  - lib/sourced/message.rb
54
+ - lib/sourced/message/codec.rb
55
+ - lib/sourced/message/forms_codec.rb
56
+ - lib/sourced/message/json_codec.rb
54
57
  homepage: https://github.com/ismasan/sourced-message
55
58
  licenses:
56
59
  - MIT