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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +185 -0
- data/lib/sourced/message/codec.rb +242 -0
- data/lib/sourced/message/forms_codec.rb +30 -0
- data/lib/sourced/message/json_codec.rb +27 -0
- data/lib/sourced/message.rb +6 -1
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3c83eaa51d67e2693a9437766624a7331535d0580e3d7481a6b97baeb58f63f
|
|
4
|
+
data.tar.gz: 892f3f3a82b5d9063d830516d167c3f3e7b16b86e8ac2047f1614e787142b48f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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.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.
|
|
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
|
+
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.
|
|
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
|