plumb 0.2.0.beta.1 → 0.2.0.beta.3

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.
@@ -45,22 +45,17 @@ module Plumb
45
45
  def output_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.resolved_output(c) }
46
46
 
47
47
  def concurrent
48
- ConcurrentArrayClass.new(element_type:)
48
+ concurrent_class.new(element_type:)
49
49
  end
50
50
 
51
51
  def stream
52
52
  StreamClass.new(element_type:)
53
53
  end
54
54
 
55
+ # A lenient version of this Array: it accepts any Array and emits one with only
56
+ # the valid elements, dropping the rest. @see FilteredArray
55
57
  def filtered
56
- Constraint.new(::Array) >> Function.opaque(inspect: "Array[#{element_type}].filtered",
57
- identity: [:filtered_array, element_type]) do |result|
58
- arr = result.value.each.with_object([]) do |e, memo|
59
- r = element_type.resolve(e)
60
- memo << r.value if r.valid?
61
- end
62
- result.valid!(arr)
63
- end
58
+ filtered_class.new(element_type:)
64
59
  end
65
60
 
66
61
  def call(result)
@@ -78,6 +73,12 @@ module Plumb
78
73
 
79
74
  attr_reader :element_type
80
75
 
76
+ # Named, not hardcoded, so the two combine in EITHER order: each subclass points
77
+ # at the variant that keeps what it already is, instead of the second call
78
+ # dropping the first.
79
+ def concurrent_class = ConcurrentArrayClass
80
+ def filtered_class = FilteredArray
81
+
81
82
  def _inspect
82
83
  %(Array[#{element_type}])
83
84
  end
@@ -136,6 +137,68 @@ module Plumb
136
137
 
137
138
  [values, errors]
138
139
  end
140
+
141
+ def filtered_class = ConcurrentFilteredArray
142
+ end
143
+
144
+ # Same element type as an ArrayClass, but drops invalid elements instead of
145
+ # rejecting the whole Array. Being an ArrayClass is what keeps it rewritable: a
146
+ # visitor — or a Codec — that maps the element type rebuilds a FilteredArray, not
147
+ # a plain one. @see HashMap::FilteredHashMap, the same arrangement for maps.
148
+ class FilteredArray < self
149
+ # It drops elements, so it changes the value whatever its element type does.
150
+ def value_preserving? = false
151
+
152
+ # Lenient: it never rejects an Array, so as a #>> consumer it accepts any
153
+ # enumerable — without this `Types::Array >> Array[String].filtered` is an
154
+ # illegal narrowing.
155
+ def accepted_type = Types::Each
156
+
157
+ # `Array[T].filtered.stream` IS `Array[T].stream.filtered` — a filtered Stream
158
+ # drops the same elements, lazily.
159
+ def stream = super.filtered
160
+
161
+ def call(result)
162
+ return result.invalid!(errors: 'is not an Array') unless ::Array === result.value
163
+
164
+ result.valid!(valid_elements(result.value))
165
+ end
166
+
167
+ private
168
+
169
+ def concurrent_class = ConcurrentFilteredArray
170
+
171
+ # The only thing the concurrent variant replaces. No errors to collect, unlike
172
+ # #map_array_elements — an invalid element is simply not there.
173
+ def valid_elements(array)
174
+ array.each.with_object([]) do |e, memo|
175
+ r = element_type.resolve(e)
176
+ memo << r.value if r.valid?
177
+ end
178
+ end
179
+
180
+ def _inspect = "Array[#{element_type}].filtered"
181
+ end
182
+
183
+ # Both at once, reached from either side (`.filtered.concurrent`,
184
+ # `.concurrent.filtered`), and itself under both.
185
+ class ConcurrentFilteredArray < FilteredArray
186
+ private
187
+
188
+ def filtered_class = ConcurrentFilteredArray
189
+
190
+ # A rejected future re-raises, as ConcurrentArrayClass does: an element step
191
+ # that RAISED is a bug to surface, not an element to drop.
192
+ def valid_elements(array)
193
+ futures = array.map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
194
+
195
+ futures.each_with_object([]) do |future, memo|
196
+ r = future.value # blocks until settled; nil when rejected
197
+ raise future.reason if future.rejected?
198
+
199
+ memo << r.value if r.valid?
200
+ end
201
+ end
139
202
  end
140
203
  end
141
204
  end
data/lib/plumb/codec.rb CHANGED
@@ -32,9 +32,8 @@ module Plumb
32
32
  # algebra, so parsing, subtyping and visitors work on it unchanged. It works
33
33
  # on any type, not just Hash schemas: `JSONCodec >> Types::Date` returns the
34
34
  # matched encoder's decode step.
35
+ #
35
36
  class Codec
36
- include Composable
37
-
38
37
  class << self
39
38
  # Register one or more Encoder subclasses, in matching-priority order.
40
39
  # The registry is inheritable: subclassing a codec extends it, and a
@@ -46,7 +45,7 @@ module Plumb
46
45
  raise ArgumentError, "expected an Encoder subclass, got #{enc.inspect}"
47
46
  end
48
47
 
49
- own_encoders << enc
48
+ encoders << enc
50
49
  end
51
50
  self
52
51
  end
@@ -56,154 +55,196 @@ module Plumb
56
55
  # nothing. Real encoders always match first, so a noop never shadows a
57
56
  # registered encoder for the same type.
58
57
  def noop(*types)
59
- types.each { |t| own_noop_types << Composable.wrap(t) }
58
+ types.each { |t| noop_types << Composable.wrap(t) }
60
59
  self
61
60
  end
62
61
 
63
- # All registered encoders, inherited first, own last (later registrations
64
- # win ties in matching).
65
- def encoders = inherited_registry(:encoders, own_encoders)
62
+ # A subclass starts from a COPY of its parent's registries and appends its own,
63
+ # so `encoders` reads inherited-first, own-last (later registrations win ties in
64
+ # matching). Copied at subclass time rather than resolved at read time because
65
+ # registration happens in a class body: a parent is complete before a subclass
66
+ # of it exists.
67
+ def inherited(subclass)
68
+ super
69
+ subclass.encoders = encoders.dup
70
+ subclass.noop_types = noop_types.dup
71
+ end
66
72
 
67
- # All registered pass-through types, inherited first.
68
- def noop_types = inherited_registry(:noop_types, own_noop_types)
73
+ def encoders = @encoders ||= []
74
+ def noop_types = @noop_types ||= []
69
75
 
70
- # Class-level composition delegates to a memoized instance, so a Codec
71
- # subclass composes directly: `JSONCodec >> Person`.
72
- def instance = @instance ||= new
73
- def >>(other) = instance >> other
74
- def |(other) = instance | other
75
- def &(other) = instance & other
76
- def for(type) = instance.for(type)
77
- def to_plumb_type(op:, left:) = instance.to_plumb_type(op:, left:)
78
- def to_composable = instance
79
- def call(result) = instance.call(result)
76
+ # Only #inherited seeds a registry; everything else registers through
77
+ # #encoder / #noop.
78
+ attr_writer :encoders, :noop_types
79
+ protected :encoders=, :noop_types=
80
80
 
81
- private
81
+ # Decode direction: rewrite `other` so it accepts the encoders' input form
82
+ # and produces the output values `other` describes. The rewritten type
83
+ # REPLACES the composition — no codec node remains.
84
+ def >>(other)
85
+ Rewriter.new(self, :decode).call(Composable.wrap(other))
86
+ end
87
+
88
+ # Both directions for a type, as a [decoding, encoding] pair:
89
+ #
90
+ # decoder, encoder = Plumb::Codec::JSON.for(Person)
91
+ # decoder.parse(input_data) # => a Person hash
92
+ # encoder.parse(person) # => output structures
93
+ #
94
+ # @param type [Composable, Object]
95
+ # @return [Array(Composable, Composable)]
96
+ def for(type)
97
+ type = Composable.wrap(type)
98
+ [self >> type, type >> self]
99
+ end
100
+
101
+ # Encode direction, reached when the codec is the RIGHT operand
102
+ # (`Person >> JSONCodec` — see Composable.resolve_operand): build an encode
103
+ # rewrite of what `left` produces. Composable#>> then composes
104
+ # `And(left, rewrite)`: the left validates its input once, the
105
+ # rewrite encodes. Building from `left`'s OUTPUT (not `left` itself)
106
+ # avoids re-running its coercions on already-parsed values.
107
+ def to_plumb_type(op:, left:)
108
+ unless op == :>>
109
+ raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
110
+ end
82
111
 
83
- # A registry a subclass extends rather than replaces: whatever the
84
- # ancestor exposes under `name`, plus this class's own registrations.
85
- def inherited_registry(name, own)
86
- inherited = superclass.respond_to?(name) ? superclass.public_send(name) : BLANK_ARRAY
87
- inherited + own
112
+ left = Composable.wrap(left)
113
+ # A plain-include struct wraps as an opaque Step (output Any), so its
114
+ # rewrite target is the struct node itself, not its resolved output.
115
+ target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
116
+ Rewriter.new(self, :encode).call(target)
88
117
  end
89
118
 
90
- def own_encoders = @own_encoders ||= []
91
- def own_noop_types = @own_noop_types ||= []
92
- end
119
+ def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"
120
+ alias & |
93
121
 
94
- attr_reader :encoders, :noop_types
122
+ # A Codec is not a value type, and has no node to stand in for one. Both of
123
+ # these say so at the point of the mistake — without #to_composable,
124
+ # Composable.wrap would see a `#call` and build an opaque step out of the class.
125
+ def to_composable = raise(Plumb::TypeError, "#{inspect} is not a type; compose it with a type via #>>")
95
126
 
96
- # @param extra_encoders [Array<Class>] encoders for this instance, appended
97
- # after (and winning ties over) the class-level registry.
98
- def initialize(*extra_encoders)
99
- @encoders = self.class.encoders + extra_encoders
100
- @noop_types = self.class.noop_types
101
- @noop_union = @noop_types.reduce(:|)
102
- freeze
103
- end
127
+ def call(_result)
128
+ raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
129
+ end
104
130
 
105
- # Decode direction: rewrite `other` so it accepts the encoders' input form
106
- # and produces the output values `other` describes. The rewritten type
107
- # REPLACES the composition — no codec node remains.
108
- def >>(other)
109
- Rewriter.new(self, :decode).call(Composable.wrap(other))
110
- end
131
+ # The best matching encoder for `type`, or nil. Matching is against each
132
+ # encoder's output type — schemas are written in output
133
+ # terms in both directions. Most-specific wins; equivalent types
134
+ # tie-break to the last registered; incomparable multi-matches raise.
135
+ def encoder_for(type, path = BLANK_ARRAY)
136
+ matches = encoders.select { |e| Plumb::Subtyping.subtype?(type, e.output_type) }
137
+ return nil if matches.empty?
138
+ return matches.first if matches.size == 1
111
139
 
112
- # Both directions for a type, as a [decoding, encoding] pair:
113
- #
114
- # decoder, encoder = Plumb::Codec::JSON.for(Person)
115
- # decoder.parse(input_data) # => a Person hash
116
- # encoder.parse(person) # => output structures
117
- #
118
- # @param type [Composable, Object]
119
- # @return [Array(Composable, Composable)]
120
- def for(type)
121
- type = Composable.wrap(type)
122
- [self >> type, type >> self]
123
- end
140
+ minimal = matches.reject do |e|
141
+ # e is dominated when another match's output type is strictly narrower.
142
+ matches.any? do |o|
143
+ !o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
144
+ end
145
+ end
124
146
 
125
- # Encode direction, reached when the codec is the RIGHT operand
126
- # (`Person >> JSONCodec` — see Composable#to_plumb_type): build an encode
127
- # rewrite of what `left` produces. Composable#>> then composes
128
- # `And(left, rewrite)`: the left validates its input once, the
129
- # rewrite encodes. Building from `left`'s OUTPUT (not `left` itself)
130
- # avoids re-running its coercions on already-parsed values.
131
- def to_plumb_type(op:, left:)
132
- unless op == :>>
133
- raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
134
- end
135
-
136
- left = Composable.wrap(left)
137
- # A plain-include struct wraps as an opaque Step (output Any), so its
138
- # rewrite target is the struct node itself, not its resolved output.
139
- target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
140
- Rewriter.new(self, :encode).call(target)
141
- end
147
+ first = minimal.first
148
+ unless minimal.all? { |e| Plumb::Subtyping.equivalent?(e.output_type, first.output_type) }
149
+ raise Plumb::TypeError,
150
+ "#{inspect}: #{at_path(path)} (#{type.inspect}) matches multiple incomparable encoders: " \
151
+ "#{minimal.map(&:inspect).join(', ')}. Register a more specific encoder or restructure."
152
+ end
142
153
 
143
- def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"
144
- alias & |
154
+ minimal.last
155
+ end
145
156
 
146
- def call(_result)
147
- raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
148
- end
157
+ # Is `type` (one side of it, per direction) covered by the registered noop
158
+ # types? Decode checks what the type ACCEPTS (it will be fed raw input
159
+ # data); encode checks what it PRODUCES (its output lands in the encoded
160
+ # document).
161
+ def noop?(type, direction)
162
+ return false unless noop_union
149
163
 
150
- # The best matching encoder for `type`, or nil. Matching is against each
151
- # encoder's output type — schemas are written in output
152
- # terms in both directions. Most-specific wins; equivalent types
153
- # tie-break to the last registered; incomparable multi-matches raise.
154
- def encoder_for(type, path = BLANK_ARRAY)
155
- matches = encoders.select { |e| Plumb::Subtyping.subtype?(type, e.output_type) }
156
- return nil if matches.empty?
157
- return matches.first if matches.size == 1
158
-
159
- minimal = matches.reject do |e|
160
- # e is dominated when another match's output type is strictly narrower.
161
- matches.any? do |o|
162
- !o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
163
- end
164
+ side = direction == :decode ? Plumb::Subtyping.accepted_type(type) : Plumb::Subtyping.resolved_output(type)
165
+ # A pure filter with an opaque side is judged by the type itself: a
166
+ # bare-matcher Constraint (a branch of a factored refinement union, eg.
167
+ # the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
168
+ # as its accepted type, but as a value-preserving refinement it IS its
169
+ # own honest description.
170
+ side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
171
+ Plumb::Subtyping.subtype?(side, noop_union)
164
172
  end
165
173
 
166
- first = minimal.first
167
- unless minimal.all? { |e| Plumb::Subtyping.equivalent?(e.output_type, first.output_type) }
168
- raise Plumb::TypeError,
169
- "#{inspect}: #{at_path(path)} (#{type.inspect}) matches multiple incomparable encoders: " \
170
- "#{minimal.map(&:inspect).join(', ')}. Register a more specific encoder or restructure."
174
+ # Is this concrete VALUE covered by the noop types? Used for Static nodes,
175
+ # whose fixed value can be validated directly — subtyping over the node
176
+ # can't relate an atomic Static to a container noop (`Static[[]]` vs
177
+ # `Types::Array`), but the value itself can just be checked.
178
+ def noop_value?(value)
179
+ return false unless noop_union
180
+
181
+ noop_union === value
171
182
  end
172
183
 
173
- minimal.last
184
+ def at_path(path) = path.empty? ? 'the root type' : "field `#{path.join('.')}`"
185
+
186
+ # Named, so an anonymous `Class.new(Codec)` still says what it carries.
187
+ def inspect = "#{name || superclass.name}[#{encoders.map(&:inspect).join(', ')}]"
188
+
189
+ private
190
+
191
+ # Derived from #noop_types, so resolved once — after boot, when the first
192
+ # composition asks.
193
+ def noop_union
194
+ return @noop_union if defined?(@noop_union)
195
+
196
+ @noop_union = noop_types.reduce(:|)
197
+ end
174
198
  end
175
199
 
176
- # Is `type` (one side of it, per direction) covered by the registered noop
177
- # types? Decode checks what the type ACCEPTS (it will be fed raw input
178
- # data); encode checks what it PRODUCES (its output lands in the encoded
179
- # document).
180
- def noop?(type, direction)
181
- return false unless @noop_union
182
-
183
- side = direction == :decode ? Plumb::Subtyping.accepted_type(type) : Plumb::Subtyping.resolved_output(type)
184
- # A pure filter with an opaque side is judged by the type itself: a
185
- # bare-matcher Constraint (a branch of a factored refinement union, eg.
186
- # the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
187
- # as its accepted type, but as a value-preserving refinement it IS its
188
- # own honest description.
189
- side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
190
- Plumb::Subtyping.subtype?(side, @noop_union)
200
+ Entry = Data.define(:decoder, :encoder)
201
+ NoEntryError = Class.new(KeyError)
202
+
203
+ # An INSTANCE is a registry of composed [decoder, encoder] pairs, so the
204
+ # per-message path is just #parse. Keys are the app's own — a tag for
205
+ # self-describing payloads, or the type itself:
206
+ #
207
+ # registry = Plumb::Codec::JSON.new do |c|
208
+ # c.register('person.created', Person) # tag -> type
209
+ # c.register(Types::Date) # key defaults to the type
210
+ # end
211
+ # registry.decode('person.created', payload)
212
+ # registry.encode(Types::Date, Date.today)
213
+ #
214
+ # Built with a block it is FROZEN — a closed set, declared at boot. Built
215
+ # without one it stays open and composes type keys on first use, so an app
216
+ # need not enumerate them; #freeze seals it later.
217
+ def initialize(&)
218
+ @entries = {}
219
+ @lock = Mutex.new
220
+ return unless block_given?
221
+
222
+ yield self
223
+ freeze
191
224
  end
192
225
 
193
- # Is this concrete VALUE covered by the noop types? Used for Static nodes,
194
- # whose fixed value can be validated directly — subtyping over the node
195
- # can't relate an atomic Static to a container noop (`Static[[]]` vs
196
- # `Types::Array`), but the value itself can just be checked.
197
- def noop_value?(value)
198
- return false unless @noop_union
226
+ def freeze
227
+ @entries.freeze
228
+ super
229
+ end
199
230
 
200
- @noop_union === value
231
+ # Named, not splatted: both sides are types that #parse, so a swapped pair would
232
+ # decode where it should encode without anything raising.
233
+ def register(key, type = key)
234
+ raise FrozenError, "#{inspect} is sealed; register before freezing it" if frozen?
235
+
236
+ entry = build_entry(type)
237
+ @lock.synchronize { @entries[key] = entry }
238
+ self
201
239
  end
202
240
 
203
- def at_path(path) = path.empty? ? 'the root type' : "field `#{path.join('.')}`"
241
+ def key?(key) = !read(key).nil?
242
+
243
+ def decode(key, payload) = entry(key).decoder.parse(payload)
244
+ def encode(key, payload) = entry(key).encoder.parse(payload)
204
245
 
205
- private def _inspect
206
- "#{self.class == Codec ? 'Plumb::Codec' : self.class.name}[#{encoders.map(&:inspect).join(', ')}]"
246
+ def inspect
247
+ %(#<#{self.class}:#{object_id} [#{@entries.size} entries]>)
207
248
  end
208
249
 
209
250
  # The deep rewrite walker. Top-down, per node:
@@ -293,11 +334,16 @@ module Plumb
293
334
  return NodeMapper.map(type) { |t| visit(t, path) }
294
335
  end
295
336
 
337
+ # Before encoder matching, for the Static reason: a source IS a subtype of what
338
+ # it declares, so an encoder would replace the code that produces the value.
339
+ return visit_source(type, path) if source_step?(type)
340
+
296
341
  enc = encoder_for(type, path)
297
342
  return replace(type, enc, path) if enc
298
343
 
299
344
  case type
300
345
  when HashClass then visit_hash(type, path)
346
+ when FilteredHash then visit_filtered_hash(type, path)
301
347
  when ArrayClass, StreamClass then visit_array(type, path)
302
348
  when TupleClass then visit_tuple(type, path)
303
349
  when HashMap then visit_hash_map(type, path)
@@ -311,13 +357,37 @@ module Plumb
311
357
  end
312
358
  end
313
359
 
314
- # A leaf: nothing to recurse into structurally. It survives when the
315
- # format already carries it (the noop check), or — decoding — when the
360
+ # A leaf: nothing to recurse into structurally. It survives when it is OPAQUE,
361
+ # when the format already carries it (the noop check), or — decoding — when the
316
362
  # codec can bridge what it CONSUMES.
317
363
  def visit_leaf(type, path)
364
+ return type if opaque_step?(type)
365
+
318
366
  bridge_input(type, path) || noop_or_fail(type, path)
319
367
  end
320
368
 
369
+ # An OPAQUE step (`Any -> Any`) is a black box — a `#generate` generator, a bare
370
+ # proc in a schema. It names no type to rewrite, so it passes through, and what
371
+ # it emits lands unencoded, exactly as its `Any` output says.
372
+ def opaque_step?(type) = type.is_a?(Plumb::TypedStep) && type.opaque?
373
+
374
+ # A SOURCE step declares `Any -> T`: it produces a T out of whatever it is
375
+ # handed. The `#default { }` generator is one.
376
+ def source_step?(type)
377
+ type.is_a?(Plumb::TypedStep) && type.input_type.is_a?(AnyClass) && !type.output_type.is_a?(AnyClass)
378
+ end
379
+
380
+ # DECODE: leave it alone. Accepting `Any` it already accepts the encoded form,
381
+ # and it produces the decoded value itself (`Date.default { Date.today }` yields
382
+ # a Date, not a wire string).
383
+ #
384
+ # ENCODE: the rewrite is composed AFTER the type it encodes (see
385
+ # Codec#to_plumb_type), so the source has already run and the T it declared is
386
+ # what sits here. Rewriting the node would run the generator a second time.
387
+ def visit_source(type, path)
388
+ @direction == :decode ? type : visit(type.output_type, path)
389
+ end
390
+
321
391
  # DECODE: a converting leaf (a Function, a Plumb::Implementation — any node
322
392
  # whose accepted type is a distinct node) consumes values the encoded
323
393
  # document does not carry: `Hash[time: Time] -> Thing` wants a Time, a JSON
@@ -443,12 +513,10 @@ module Plumb
443
513
  # constant) still rewrite their input once.
444
514
  input = enc.input_type_for(type)
445
515
  rewritten_input = @input_memo.fetch(type) do
446
- begin
447
- @input_stack.push(enc)
448
- @input_memo[type] = visit(input, path + ["<#{enc.inspect} input>"])
449
- ensure
450
- @input_stack.pop
451
- end
516
+ @input_stack.push(enc)
517
+ @input_memo[type] = visit(input, path + ["<#{enc.inspect} input>"])
518
+ ensure
519
+ @input_stack.pop
452
520
  end
453
521
 
454
522
  # Splice the input into the step unless it is the encoder's declared
@@ -480,6 +548,21 @@ module Plumb
480
548
  end
481
549
  end
482
550
 
551
+ # DECODE rewrites the schema a filter FILTERS and rebuilds the filter around it,
552
+ # so decoding happens inside it, per field: #bridge_input's spliced step would
553
+ # decode the whole hash first, making one unreadable field fatal — what
554
+ # .filtered exists to avoid. (The filtered Array and HashMap need none of this;
555
+ # being containers they rewrite through #visit_array / #visit_hash_map.)
556
+ #
557
+ # ENCODE: `resolved_output` reduces the filter to its relaxed schema before the
558
+ # rewrite, so this is only reached defensively.
559
+ def visit_filtered_hash(type, path)
560
+ return visit(type.output_type, path) unless @direction == :decode
561
+
562
+ schema = visit(type.input_type, path)
563
+ schema.equal?(type.input_type) ? type : schema.filtered
564
+ end
565
+
483
566
  def visit_array(type, path)
484
567
  return noop_or_fail(type, path) if type.children.first.is_a?(AnyClass) # untyped — a leaf
485
568
 
@@ -507,11 +590,16 @@ module Plumb
507
590
  NodeMapper.map_children(type) { |variant| visit(variant, path) }
508
591
  end
509
592
 
593
+ # Rewriting can collapse two branches onto the SAME node — encoding a
594
+ # `#default { }`, both resolve to the type. Keep one: the second is unreachable,
595
+ # and a failing encode would otherwise report its errors twice.
510
596
  def visit_or(type, path)
511
597
  left, right = type.children
512
598
  l = visit(left, path)
513
599
  r = visit(right, path)
514
- l.equal?(left) && r.equal?(right) ? type : Disjunction.build(l, r)
600
+ return type if l.equal?(left) && r.equal?(right)
601
+
602
+ l == r ? l : Disjunction.build(l, r)
515
603
  end
516
604
 
517
605
  # A MEET: a data-bearing type refined by pure filters (a #where's
@@ -560,27 +648,41 @@ module Plumb
560
648
  # Rewriting it too decodes twice: #bridge_input splices `b`'s own decode step in
561
649
  # front, the already-decoded value hits a step expecting the encoded form, and the
562
650
  # pipeline rejects everything. A step only counts as having consumed the wire if
563
- # its rewrite actually CHANGED it.
651
+ # its rewrite actually CHANGED it — or if it is a black box, which says the same
652
+ # thing: what it hands on is unknown. `Types::Integer.generate { 1 }` is
653
+ # `(opaque >> Integer)`, and rewriting that Integer into the codec's
654
+ # `String -> Integer` step would feed it the generated Integer and reject
655
+ # everything.
564
656
  def visit_composition(type, path)
565
657
  left, right = type.children
566
658
  l = pure_refinement?(left) ? left : visit(left, path)
567
- # `right` receives what `left` produced, so it faces the wire only when
568
- # `left`'s rewrite left it unchanged.
569
- wire_consumed = @direction == :decode && !l.equal?(left)
570
- r = pure_refinement?(right) || wire_consumed ? right : visit(right, path)
659
+ r = pure_refinement?(right) || wire_consumed?(left, l) ? right : visit(right, path)
571
660
 
572
661
  l.equal?(left) && r.equal?(right) ? type : Conjunction.build(l, r)
573
662
  end
574
663
 
664
+ # Encoding, nothing consumes a wire: every step is rewritten in place.
665
+ def wire_consumed?(left, rewritten)
666
+ return false unless @direction == :decode
667
+
668
+ !rewritten.equal?(left) || opaque_step?(rewritten) || source_step?(rewritten)
669
+ end
670
+
575
671
  # A pure refinement carries no encodable type — it filters the adjacent
576
672
  # type's values. A baseless Constraint refining a sibling qualifies, but a
577
673
  # base-type Constraint — `Types::Date` IS `Constraint(::Date)` — is data an
578
674
  # encoder must see (eg. in `Types::Date.where(year: ...)` ==
579
675
  # `And(Constraint(::Date), AVM)`), and so is a baseless Module gate, which
580
676
  # names a type rather than filtering one.
677
+ #
678
+ # `Any` is the TRIVIAL filter, so it qualifies too — but only ALONGSIDE a sibling
679
+ # that carries a type; on its own it is still a leaf, and still an error. It gets
680
+ # here from an opaque step's output slot, which is what `resolved_output` leaves
681
+ # behind (`Integer.generate { 1 }` resolves to `(Any >> Integer)`): the value is
682
+ # unknown until the Integer beside it vouches for it, and that is what to encode.
581
683
  def pure_refinement?(child)
582
684
  case child
583
- when AttributeValueMatch, ValueClass, Not then true
685
+ when AttributeValueMatch, ValueClass, Not, AnyClass then true
584
686
  when Constraint then child.base.nil? && !child.matcher.is_a?(::Module)
585
687
  else false
586
688
  end
@@ -608,7 +710,6 @@ module Plumb
608
710
  'Register an encoder for it, or declare it with .noop.'
609
711
  end
610
712
  end
611
-
612
713
  end
613
714
 
614
715
  # ------------------------------------------------------------------
@@ -634,9 +735,10 @@ module Plumb
634
735
  def decode(str) = ::Date.parse(str)
635
736
  end
636
737
 
637
- # Time <=> ISO 8601 date-time string ("2024-08-30T20:15:23Z").
738
+ # Time <=> ISO 8601 date-time string ("2024-08-30T20:15:23.456789Z").
739
+ #
638
740
  class TimeEncoder < Encoder[Types::String[TIME_EXPR].metadata(format: 'date-time') => Types::Time]
639
- def encode(time) = time.iso8601
741
+ def encode(time) = time.iso8601(6)
640
742
  def decode(str) = ::Time.parse(str)
641
743
  end
642
744
 
@@ -791,5 +893,38 @@ module Plumb
791
893
  encoder DateEncoder, TimeEncoder, SymbolEncoder, DecimalEncoder
792
894
  encoder URIEncoder, HTTPURIEncoder, FileURIEncoder
793
895
  end
896
+
897
+ private
898
+
899
+ def entry(key)
900
+ read(key) || lazy_entry(key) || raise(NoEntryError, "no encoder/decoder registered for #{key}")
901
+ end
902
+
903
+ # Sealed, @entries can never change again, so reads need no synchronization —
904
+ # the shared-global case is also the cheapest one.
905
+ def read(key) = frozen? ? @entries[key] : @lock.synchronize { @entries[key] }
906
+
907
+ # Fill in on demand, so an app need not enumerate every type it exchanges.
908
+ # Only for keys that ARE types: an app-owned tag ('person.created') names
909
+ # nothing the codec could compile, and still raises. Freezing is how an app
910
+ # declares its set closed — a sealed registry never fills in.
911
+ #
912
+ # Keys match by value, so a type literal built fresh per call adds an entry
913
+ # per call. Constants and classes are stable; sealing rules it out entirely.
914
+ def lazy_entry(key)
915
+ return nil if frozen? || !(key.is_a?(Composable) || key.is_a?(::Module))
916
+
917
+ # Composed OUTSIDE the lock, as TypeCache does: holding it across a rewrite
918
+ # would convoy every other thread's lookups behind one cold type. Racing
919
+ # threads both compose; the rewrite is a pure function of the type, so the
920
+ # loser's copy is equivalent and simply dropped.
921
+ entry = build_entry(key)
922
+ @lock.synchronize { @entries[key] ||= entry }
923
+ end
924
+
925
+ def build_entry(type)
926
+ decoder, encoder = self.class.for(type)
927
+ Entry.new(decoder:, encoder:)
928
+ end
794
929
  end
795
930
  end