plumb 0.2.0.beta.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 119f529616c7dfe5fe87e12e26e9cf91f3cb4c28fd19cacf89dc15c271e534de
4
- data.tar.gz: 514598daebbf8a7ee477b68c8dd05cb74e9c5102da4f9f7f8a682f2de5939a66
3
+ metadata.gz: c347e5312c27a5db7f2c1f668676028fb2613a7b5f459ac5f2d558577599cbd0
4
+ data.tar.gz: 360a9c7eb0ece5c922eb0447c952a5b449e8164892386d49c781eaf66448e9be
5
5
  SHA512:
6
- metadata.gz: 8997e52706716448c59bff278df20b9768aa0cc1e7d870c3e6dce8d01d15bf248c1ceefd7cff3b91a78462d0a56b8645f1a7085ca6a809aa9701cb4b26bdf862
7
- data.tar.gz: e4dcaa6edaf31597fe1985efea84656a4efb1e9ba75a1f72ad9316a36257af79b4f36bafdd3d051f632ee0a7b005cbac3e07d9e051d12d5b7ff18aae8842e077
6
+ metadata.gz: a55f939ef7a815cc1a49be1f7c9dcb15e3ebefcb5c2ee4e2256cb157f85c90f4dda64a0316d5e9afb22566ccac236960fcab2103b25e92ba17aff41971f3c884
7
+ data.tar.gz: e4b5480376bbcc9904404eea5215b7edabfdb3eb8c439eeae96108a597061f6de338bcdeab5edf667a4f2cdf149761fbe64d7f4e2cd7af3ed1bdab779a01259a
data/README.md CHANGED
@@ -303,7 +303,7 @@ Types::Never.to_json_schema # => { "not" => {} }
303
303
 
304
304
  You rarely write `Types::Never` by hand — it's what an impossible intersection reduces to, which lets the composition algebra prove and discard dead branches (as in `Integer | (String & Integer)` above). It's also useful as a Hash catch-all to forbid undeclared keys — see [`_: Types::Never`](#undeclared-keys-and-the-_-catch-all).
305
305
 
306
- ### Built-in types
306
+ ## Built-in types
307
307
 
308
308
  * `Types::Value`
309
309
  * `Types::Array`
@@ -338,11 +338,11 @@ For parsing stringy formats (HTML forms, query strings) into these types — wha
338
338
 
339
339
  TODO: datetime, others.
340
340
 
341
- ### Policies
341
+ ## Policies
342
342
 
343
343
  Policies are helpers that encapsulate common compositions. Plumb ships with some handy ones, listed below, and you can also define your own.
344
344
 
345
- #### `#present`
345
+ ### `#present`
346
346
 
347
347
  Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
348
348
 
@@ -351,7 +351,7 @@ Types::String.present.resolve('') # Failure with errors
351
351
  Types::Array[Types::String].present.resolve([]) # Failure with errors
352
352
  ```
353
353
 
354
- #### `#nullable`
354
+ ### `#nullable`
355
355
 
356
356
  Allow `nil` values.
357
357
 
@@ -368,7 +368,7 @@ Note that this just encapsulates the following composition:
368
368
  nullable_str = Types::String | Types::Nil
369
369
  ```
370
370
 
371
- #### `#not`
371
+ ### `#not`
372
372
 
373
373
  Negates a type.
374
374
  ```ruby
@@ -394,7 +394,7 @@ NotNil.parse('hello') # 'hello'
394
394
  NotNil.parse(nil) # error
395
395
  ```
396
396
 
397
- #### `#options`
397
+ ### `#options`
398
398
 
399
399
  Sets allowed options for value.
400
400
 
@@ -442,7 +442,7 @@ Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is wit
442
442
  Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
443
443
  ```
444
444
 
445
- #### `#transform`
445
+ ### `#transform`
446
446
 
447
447
  Transform value. Requires specifying the resulting type of the value after transformation.
448
448
 
@@ -472,7 +472,7 @@ Types::Any.transform(:to_i) # ok — unknown input type, no check
472
472
 
473
473
  `#transform` builds a [`Plumb::Function`](#plumbfunctioninput--output) — the underlying typed-conversion node — with a block that takes and returns a plain value. To build one standalone from a callable, or to work at the `Result` level, use `Plumb::Function[]` directly.
474
474
 
475
- #### `#invoke`
475
+ ### `#invoke`
476
476
 
477
477
  `#invoke` builds a step that will invoke one or more methods on the value.
478
478
 
@@ -509,7 +509,7 @@ type.parse([1, 2]) # raises NoMethodError because Array doesn't respond to #stri
509
509
 
510
510
  Use with caution.
511
511
 
512
- #### `#default`
512
+ ### `#default`
513
513
 
514
514
  Default value when no value given (ie. when key is missing in Hash payloads. See `Types::Hash` below).
515
515
 
@@ -564,7 +564,7 @@ Same if you want to apply a default to several cases.
564
564
  str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
565
565
  ```
566
566
 
567
- #### `#build`
567
+ ### `#build`
568
568
 
569
569
  Build a custom object or class.
570
570
 
@@ -597,7 +597,7 @@ Note that this case is identical to `#transform` with a block.
597
597
  StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
598
598
  ```
599
599
 
600
- #### `#check`
600
+ ### `#check`
601
601
 
602
602
  Pass the value through an arbitrary validation
603
603
 
@@ -607,7 +607,7 @@ type.parse('Role: Manager') # 'Role: Manager'
607
607
  type.parse('Manager') # fails
608
608
  ```
609
609
 
610
- #### `#value`
610
+ ### `#value`
611
611
 
612
612
  Constrain a type to a specific value. Compares with `#==`
613
613
 
@@ -624,7 +624,7 @@ All scalar types support this:
624
624
  ten = Types::Integer.value(10)
625
625
  ```
626
626
 
627
- #### `#static`
627
+ ### `#static`
628
628
 
629
629
  A type that always returns a valid, static value, regardless of input.
630
630
 
@@ -663,7 +663,7 @@ type = Types::Integer[100..].static(150) # ok
663
663
 
664
664
  So, normally you'd only use this attached to primitive types without further processing (but your use case may vary).
665
665
 
666
- #### `#generate`
666
+ ### `#generate`
667
667
 
668
668
  Passing a proc will evaluate the proc on every invocation. Use this for generated values.
669
669
 
@@ -682,7 +682,7 @@ random_number.parse # raises Plumb::ParseError because `rand` is not a String
682
682
 
683
683
  You can also pass any `#call() => Object` interface as a generator, instead of a proc.
684
684
 
685
- #### `#metadata`
685
+ ### `#metadata`
686
686
 
687
687
  Add metadata to a type
688
688
 
@@ -705,7 +705,7 @@ type.metadata[:note] # 'An email address'
705
705
 
706
706
  TODO: document custom visitors.
707
707
 
708
- #### `#input_type` and `#output_type`
708
+ ### `#input_type` and `#output_type`
709
709
 
710
710
  Every type exposes the type it expects as input and the type it produces as output.
711
711
 
@@ -733,7 +733,7 @@ For a plain type, both are the type itself. Unions distribute over both sides:
733
733
 
734
734
  These power type introspection — for example, the JSON Schema visitor builds its schema from `#input_type`, since a schema describes the values a caller must send.
735
735
 
736
- #### Composition type-checks
736
+ ### Composition type-checks
737
737
 
738
738
  `#>>` is typed by **subsumption**, like function composition in a statically-typed language: everything the left step *produces* must be acceptable to the right step — i.e. the left's output type must be a **subtype** of the right's input type. If not, `#>>` raises `Plumb::TypeError` at build time, so broken data pipelines fail loudly when you define them, not silently at runtime.
739
739
 
@@ -784,7 +784,7 @@ Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is
784
784
  Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
785
785
  ```
786
786
 
787
- #### Subtype checks: `#<=` and `Plumb::Subtyping`
787
+ ### Subtype checks: `#<=` and `Plumb::Subtyping`
788
788
 
789
789
  The relation behind the composition check is also available directly. `a <= b` asks "is every value described by `a` also described by `b`?" — i.e. is `a` a subtype/subset of `b`? — with `>=`, `<` and `>` derived from it. `Plumb::Subtyping.subtype?(a, b)` is the same check as a method. Built-in and custom types both participate, and raw Ruby classes/values are accepted on either side (they're normalized):
790
790
 
@@ -800,11 +800,11 @@ small = Types::Hash[name: Types::String]
800
800
  big <= small # true (width + depth subtyping)
801
801
  ```
802
802
 
803
- ### Other policies
803
+ ## Other policies
804
804
 
805
805
  There's some other built-in "policies" that can be used via the `#policy` method. Helpers such as `#default` and `#present` are shortcuts for this and can also be used via `#policy(default: 'Hello')` or `#policy(:present)` See [custom policies](#custom-policies) for how to define your own policies.
806
806
 
807
- #### `:respond_to`
807
+ ### `:respond_to`
808
808
 
809
809
  Similar to `Types::Interface`, this is a quick way to assert that a value supports one or more methods.
810
810
 
@@ -814,7 +814,7 @@ List = Types::Any.policy(respond_to: :each)
814
814
  List = Types::Any.policy(respond_to: [:each, :[], :size)
815
815
  ```
816
816
 
817
- #### `:excluded_from`
817
+ ### `:excluded_from`
818
818
 
819
819
  The opposite of `#options`, this policy validates that the value _is not_ included in a list.
820
820
 
@@ -822,7 +822,7 @@ The opposite of `#options`, this policy validates that the value _is not_ includ
822
822
  Name = Types::String.policy(excluded_from: ['Joe', 'Joan'])
823
823
  ```
824
824
 
825
- #### :split` (strings only)
825
+ ### :split` (strings only)
826
826
 
827
827
  Splits string values by a separator (default: `,`).
828
828
 
@@ -835,7 +835,7 @@ CSVLine = Types::String.split(/\s*;\s*/)
835
835
  CSVLine.parse('a;b;c') # => ['a', 'b', 'c']
836
836
  ```
837
837
 
838
- #### `:rescue`
838
+ ### `:rescue`
839
839
 
840
840
  Wraps a step's execution, rescues a specific exception and returns an invalid result.
841
841
 
@@ -859,7 +859,7 @@ type.resolve('2024-') # => Result::Invalid with error message
859
859
  The guard keeps the type it wraps: the example above is still a `Date` for subtyping,
860
860
  JSON Schema and [Codecs](#encoders-and-codecs).
861
861
 
862
- ### `Types::Interface`
862
+ ## `Types::Interface`
863
863
 
864
864
  Use this for objects that must respond to one or more methods.
865
865
 
@@ -894,7 +894,7 @@ case args
894
894
  end
895
895
  ```
896
896
 
897
- #### Merging interfaces
897
+ ### Merging interfaces
898
898
 
899
899
  Use the `+` operator to merge two interfaces into a new one that must support both sets of method names.
900
900
 
@@ -905,7 +905,7 @@ Countable = Types::Interface[:size]
905
905
  CountableIterable = Iterable + Countable
906
906
  ```
907
907
 
908
- #### Intersecting interfaces
908
+ ### Intersecting interfaces
909
909
 
910
910
  Use the `&` operator to produce a new interface with the intersection of method names
911
911
 
@@ -919,7 +919,7 @@ I3 = Types::Interface[:b, :c]
919
919
 
920
920
  TODO: make this a bit more advanced. Check for method arity.
921
921
 
922
- ### `Types::Hash`
922
+ ## `Types::Hash`
923
923
 
924
924
  ```ruby
925
925
  Employee = Types::Hash[
@@ -988,7 +988,7 @@ User = Types::Hash[name: Types::Static['Joe'], age: Integer]
988
988
  User.parse(name: 'Rufus', age: 34) # Valid {name: 'Joe', age: 34}
989
989
  ```
990
990
 
991
- #### Optional keys
991
+ ### Optional keys
992
992
 
993
993
  Keys suffixed with `?` are marked as optional and its values will only be validated and coerced if the key is present in the input hash.
994
994
 
@@ -1012,7 +1012,7 @@ Types::Hash[
1012
1012
  ]
1013
1013
  ```
1014
1014
 
1015
- #### Merging hash definitions
1015
+ ### Merging hash definitions
1016
1016
 
1017
1017
  Use `Types::Hash#+` to merge two definitions. Keys in the second hash override the first one's.
1018
1018
 
@@ -1022,7 +1022,7 @@ Employee = Types::Hash[name: Types::String, company: Types::String]
1022
1022
  StaffMember = User + Employee # Hash[:name, :age, :company]
1023
1023
  ```
1024
1024
 
1025
- #### Hash intersections
1025
+ ### Hash intersections
1026
1026
 
1027
1027
  Use `Types::Hash#&` to intersect two hash definitions as maps. It keeps the keys present in **both**, and intersects each shared key's value type:
1028
1028
 
@@ -1047,7 +1047,7 @@ Types::Hash[a: Types::String, _: Types::Any] & Types::Hash[a: Types::String, b:
1047
1047
  # => Hash[a: String, b: Integer] (:b admitted via the left's catch-all)
1048
1048
  ```
1049
1049
 
1050
- #### `Types::Hash#tagged_by`
1050
+ ### `Types::Hash#tagged_by`
1051
1051
 
1052
1052
  Use `#tagged_by` to resolve what definition to use based on the value of a common key.
1053
1053
 
@@ -1064,7 +1064,7 @@ Events = Types::Hash.tagged_by(
1064
1064
  Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
1065
1065
  ```
1066
1066
 
1067
- #### Undeclared keys and the `_` catch-all
1067
+ ### Undeclared keys and the `_` catch-all
1068
1068
 
1069
1069
  By default, keys present in the input but **not** declared in the schema are dropped:
1070
1070
 
@@ -1119,7 +1119,7 @@ InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
1119
1119
 
1120
1120
  The catch-all also shows up in generated JSON Schema as `additionalProperties`: `_: Any` → `{}` (anything), `_: Integer` → `{ "type": "integer" }`, and `_: Never` → `{ "not": {} }` (nothing allowed).
1121
1121
 
1122
- #### Typed keys
1122
+ ### Typed keys
1123
1123
 
1124
1124
  Keys are not limited to symbols. A key can be any type or matcher, and it matches an input key via `key === other`. So you can key by String, or by a pattern, and mix them with a catch-all:
1125
1125
 
@@ -1131,7 +1131,7 @@ Types::Hash[Types::String[/^id_/] => Types::Integer, # keys matching /^id_/ hold
1131
1131
 
1132
1132
  A typed key is **lenient**: input keys that don't match any declared or typed key follow the catch-all rule above (dropped by default). This is different from a homogeneous map (`Types::Hash[Types::Symbol, Types::Integer]`, a `HashMap` — note the comma, not `=>`), which is **strict** (a non-conforming key is an error) and coerces keys through the key type. Use a `HashMap` for "every key/value has this type"; use typed keys for "keys shaped like this map to that".
1133
1133
 
1134
- #### `Types::Hash#filtered`
1134
+ ### `Types::Hash#filtered`
1135
1135
 
1136
1136
  The `#filtered` modifier returns a valid Hash with the subset of values that were valid, instead of failing the entire result if one or more values are invalid.
1137
1137
 
@@ -1141,7 +1141,7 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
1141
1141
  User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
1142
1142
  ```
1143
1143
 
1144
- ### `Types::Range`
1144
+ ## `Types::Range`
1145
1145
 
1146
1146
  `Types::Range` validates that a value is a Ruby `Range`. On its own it accepts any range:
1147
1147
 
@@ -1169,7 +1169,7 @@ Percent.resolve(10..20) # valid
1169
1169
  Percent.resolve(10..200) # invalid (200 is outside 1..100)
1170
1170
  ```
1171
1171
 
1172
- #### Open-ended ranges with `#where`
1172
+ ### Open-ended ranges with `#where`
1173
1173
 
1174
1174
  Use `#where` with the `begin`/`end` attributes to constrain the range's own endpoints. Passing `end: nil` matches only endless ranges, and `begin: nil` only beginless ranges:
1175
1175
 
@@ -1194,7 +1194,7 @@ NonNegativeStart.resolve(5..10) # valid
1194
1194
  NonNegativeStart.resolve(-5..10) # invalid
1195
1195
  ```
1196
1196
 
1197
- #### Composition
1197
+ ### Composition
1198
1198
 
1199
1199
  `Types::Range` is covariant in its member type and preserves its input value (it validates endpoints without coercing them), so it composes like the other containers. A union absorbs a narrower member into a wider one:
1200
1200
 
@@ -1205,7 +1205,7 @@ Types::Range[1..10] <= Types::Range[Integer] # true (covariant)
1205
1205
  Types::Range[Integer] | Types::Range[1..10] # => Range[Integer]
1206
1206
  ```
1207
1207
 
1208
- #### JSON Schema
1208
+ ### JSON Schema
1209
1209
 
1210
1210
  A `Types::Range` whose member pins numeric bounds maps to JSON Schema's native keywords, preserving an exclusive end as `exclusiveMaximum`:
1211
1211
 
@@ -1214,7 +1214,7 @@ Plumb::JSONSchemaVisitor.call(Types::Range[0...100], root: false)
1214
1214
  # => { "type" => "integer", "minimum" => 0, "exclusiveMaximum" => 100 }
1215
1215
  ```
1216
1216
 
1217
- ### `Types::SymbolizedHash`
1217
+ ## `Types::SymbolizedHash`
1218
1218
 
1219
1219
  This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
1220
1220
 
@@ -1233,7 +1233,7 @@ type = Types::Hash[name: String, age: Integer].symbolized
1233
1233
  type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
1234
1234
  ```
1235
1235
 
1236
- ### maps
1236
+ ## maps
1237
1237
 
1238
1238
  You can also use Hash syntax to define a hash map with specific types for all keys and values:
1239
1239
 
@@ -1264,7 +1264,7 @@ Use `Types::Value` to validate specific values (using `#==`)
1264
1264
  names_and_ones = Types::Hash[String, Types::Integer.value(1)]
1265
1265
  ```
1266
1266
 
1267
- #### `#filtered`
1267
+ ### `#filtered`
1268
1268
 
1269
1269
  Calling the `#filtered` modifier on a Hash Map makes it return a sub set of the keys and values that are valid as per the key and value type definitions.
1270
1270
 
@@ -1277,7 +1277,7 @@ S3Config.parse(ENV.to_h) # { 'S3_BUCKET' => 'foo', 'S3_REGION' => 'us-east-1' }
1277
1277
 
1278
1278
 
1279
1279
 
1280
- ### `Types::Array`
1280
+ ## `Types::Array`
1281
1281
 
1282
1282
  ```ruby
1283
1283
  names = Types::Array[Types::String.present]
@@ -1295,7 +1295,7 @@ emails = Types::Array[Types::String[/@/]]
1295
1295
 
1296
1296
  Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching against the regular expression.
1297
1297
 
1298
- #### Chained array maps fuse into a single pass
1298
+ ### Chained array maps fuse into a single pass
1299
1299
 
1300
1300
  `Types::Array` is covariant in its element type, so mapping `f` over an array and then mapping `g` is the same as mapping `f >> g` once. Composing two arrays applies that, and the collection is traversed once instead of twice:
1301
1301
 
@@ -1336,7 +1336,7 @@ Types::Array[Trim] / Types::Stream[Symbolize]
1336
1336
 
1337
1337
  That guard is what keeps errors identical: two passes report stage by stage, so if the right map could reject what the left produced, one pass could surface errors two passes never reach. Records (`Types::Hash[name: ...]`) don't fuse either, since a record can drop, add and make keys optional.
1338
1338
 
1339
- #### Concurrent arrays
1339
+ ### Concurrent arrays
1340
1340
 
1341
1341
  Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
1342
1342
 
@@ -1359,13 +1359,13 @@ See the [concurrent downloads example](https://github.com/ismasan/plumb/blob/mai
1359
1359
 
1360
1360
  TODO: pluggable concurrency engines (Async?)
1361
1361
 
1362
- #### `#stream`
1362
+ ### `#stream`
1363
1363
 
1364
1364
  Turn an Array definition into an enumerator that yields each element wrapped in `Result::Valid` or `Result::Invalid`.
1365
1365
 
1366
1366
  See [`Types::Stream`](#typesstream) below for more.
1367
1367
 
1368
- #### `#filtered`
1368
+ ### `#filtered`
1369
1369
 
1370
1370
  The `#filtered` modifier makes an array definition return a subset of the input array where the values are valid, as per the array's element type.
1371
1371
 
@@ -1376,7 +1376,7 @@ j_names.parse(%w[james ismael joe toby joan isabel]) # ["james", "joe", "joan"]
1376
1376
 
1377
1377
 
1378
1378
 
1379
- ### `Types::Tuple`
1379
+ ## `Types::Tuple`
1380
1380
 
1381
1381
  ```ruby
1382
1382
  Status = Types::Symbol.options(%i[ok error])
@@ -1408,7 +1408,7 @@ NameAndRegex = Types::Tuple[String, Types::Value[/@/]]
1408
1408
 
1409
1409
 
1410
1410
 
1411
- ### `Types::Stream`
1411
+ ## `Types::Stream`
1412
1412
 
1413
1413
  `Types::Stream` defines an enumerator that validates/coerces each element as it iterates.
1414
1414
 
@@ -1434,7 +1434,7 @@ end
1434
1434
 
1435
1435
  See a more complete the [CSV Stream example](https://github.com/ismasan/plumb/blob/main/examples/csv_stream.rb)
1436
1436
 
1437
- #### `Types::Stream#filtered`
1437
+ ### `Types::Stream#filtered`
1438
1438
 
1439
1439
  Use `#filtered` to turn a `Types::Stream` into a stream that only yields valid elements.
1440
1440
 
@@ -1445,7 +1445,7 @@ ValidElements.parse(data).each do |valid_row|
1445
1445
  end
1446
1446
  ```
1447
1447
 
1448
- #### `Types::Array#stream`
1448
+ ### `Types::Array#stream`
1449
1449
 
1450
1450
  A `Types::Array` definition can be turned into a stream.
1451
1451
 
@@ -1460,11 +1460,11 @@ Str.parse(data).each do |row|
1460
1460
  end
1461
1461
  ```
1462
1462
 
1463
- ### Types::Data
1463
+ ## Types::Data
1464
1464
 
1465
1465
  `Types::Data` provides a superclass to define **immutable** structs or value objects with typed / coercible attributes.
1466
1466
 
1467
- #### `[]` Syntax
1467
+ ### `[]` Syntax
1468
1468
 
1469
1469
  The `[]` syntax is a short-hand for struct definition.
1470
1470
  Like `Plumb::Types::Hash`, suffixing a key with `?` makes it optional.
@@ -1497,7 +1497,7 @@ PersonHash = Types::Hash[name: String, age?: Integer]
1497
1497
  PersonStruct = Types::Data[PersonHash]
1498
1498
  ```
1499
1499
 
1500
- #### `#with`
1500
+ ### `#with`
1501
1501
 
1502
1502
  Note that these instances cannot be mutated (there's no attribute setters), but they can be copied with partial attributes with `#with`
1503
1503
 
@@ -1505,7 +1505,7 @@ Note that these instances cannot be mutated (there's no attribute setters), but
1505
1505
  another_person = person.with(age: 20)
1506
1506
  ```
1507
1507
 
1508
- #### `.attribute` syntax
1508
+ ### `.attribute` syntax
1509
1509
 
1510
1510
  This syntax allows defining struct classes with typed attributes, including nested structs.
1511
1511
 
@@ -1588,7 +1588,7 @@ Note that this does NOT work with union'd or piped structs.
1588
1588
  attribute :company, Company | Person do
1589
1589
  ```
1590
1590
 
1591
- #### Shorthand array syntax
1591
+ ### Shorthand array syntax
1592
1592
 
1593
1593
  ```ruby
1594
1594
  attribute :things, [] # Same as attribute :things, Types::Array
@@ -1605,7 +1605,7 @@ Note that, if you want to match an attribute value against a literal array, you
1605
1605
  attribute :one_two_three, Types::Array.value[[1, 2, 3]])
1606
1606
  ```
1607
1607
 
1608
- #### Optional Attributes
1608
+ ### Optional Attributes
1609
1609
 
1610
1610
  Using `attribute?` allows for optional attributes. If the attribute is not present, these attribute values will be `nil`
1611
1611
 
@@ -1613,7 +1613,7 @@ Using `attribute?` allows for optional attributes. If the attribute is not prese
1613
1613
  attribute? :company, Company
1614
1614
  ```
1615
1615
 
1616
- #### Before steps, symbolizing keys
1616
+ ### Before steps, symbolizing keys
1617
1617
 
1618
1618
  The optional `.step` helper adds arbitrary Plumb steps to a Data constructor's internal pipeline.
1619
1619
 
@@ -1656,7 +1656,7 @@ person.last_name # => 'BLOGGS'
1656
1656
 
1657
1657
  A Data class steps are inherited to its child classes.
1658
1658
 
1659
- #### Inheritance
1659
+ ### Inheritance
1660
1660
 
1661
1661
  Data structs can inherit from other structs. This is useful for defining a base struct with common attributes.
1662
1662
 
@@ -1670,7 +1670,7 @@ class Person < BasePerson
1670
1670
  end
1671
1671
  ```
1672
1672
 
1673
- #### Equality with `#==`
1673
+ ### Equality with `#==`
1674
1674
 
1675
1675
  `#==` is implemented to compare attributes, recursively.
1676
1676
 
@@ -1680,7 +1680,7 @@ person2 = Person.new(name: 'Joe', age: 20)
1680
1680
  person1 == person2 # true
1681
1681
  ```
1682
1682
 
1683
- #### Struct composition
1683
+ ### Struct composition
1684
1684
 
1685
1685
  `Types::Data` supports all the composition operators and helpers.
1686
1686
 
@@ -1702,7 +1702,7 @@ Payload = Types::Hash[
1702
1702
  ]
1703
1703
  ```
1704
1704
 
1705
- #### Attribute writers
1705
+ ### Attribute writers
1706
1706
 
1707
1707
  By default `Types::Data` classes are inmutable, but you can define attribute writers to allow for mutation using the `writer: true` option.
1708
1708
 
@@ -1726,7 +1726,7 @@ config.valid? # true
1726
1726
  config.errors # {}
1727
1727
  ```
1728
1728
 
1729
- #### Recursive struct definitions
1729
+ ### Recursive struct definitions
1730
1730
 
1731
1731
  You can use `#defer`. See [recursive types](#recursive-types).
1732
1732
 
@@ -1741,11 +1741,11 @@ person.friend.name # 'joan'
1741
1741
  person.friend.friend # nil
1742
1742
  ```
1743
1743
 
1744
- ### Plumb::Pipeline
1744
+ ## Plumb::Pipeline
1745
1745
 
1746
1746
  `Plumb::Pipeline` offers a sequential, step-by-step syntax for composing processing steps, as well as a simple middleware API to wrap steps for metrics, logging, debugging, caching and more. See the [command objects](https://github.com/ismasan/plumb/blob/main/examples/command_objects.rb) example for a worked use case.
1747
1747
 
1748
- #### `#pipeline` helper
1748
+ ### `#pipeline` helper
1749
1749
 
1750
1750
  All plumb steps have a `#pipeline` helper.
1751
1751
 
@@ -1777,7 +1777,7 @@ result = CreateUser.resolve(name: 'Joe', age: 40)
1777
1777
  # result.value => User
1778
1778
  ```
1779
1779
 
1780
- ##### `#step` (non-strict) and `#step!` (strict)
1780
+ #### `#step` (non-strict) and `#step!` (strict)
1781
1781
 
1782
1782
  A pipeline is a sequence of validators/coercions that progressively narrows its data, so **`#step` is non-strict**: it chains with [`#/`](#composition-type-checks), skipping the composition check (a later step may legitimately narrow what an earlier one produced). Use **`#step!`** for the strict [`#>>` check](#composition-type-checks) — a build-time `Plumb::TypeError` if a step could never accept the previous step's output.
1783
1783
 
@@ -1811,7 +1811,7 @@ IsJoe = User.check('must be named joe') { |user|
1811
1811
  CreateIfJoe = IsJoe >> CreateUser
1812
1812
  ```
1813
1813
 
1814
- ##### `#around`
1814
+ #### `#around`
1815
1815
 
1816
1816
  Use `#around` in a pipeline definition to add a middleware step that wraps all other steps registered.
1817
1817
 
@@ -1865,7 +1865,7 @@ pl.around do |step, result|
1865
1865
  end
1866
1866
  ```
1867
1867
 
1868
- #### As stand-alone `Plumb::Pipeline` class
1868
+ ### As stand-alone `Plumb::Pipeline` class
1869
1869
 
1870
1870
  `Plumb::Pipeline` can also be used on its own, sub-classed, and it can take class-level `around` middleware.
1871
1871
 
@@ -1910,7 +1910,7 @@ pipe = DebuggablePipeline.new do |pl|
1910
1910
  end
1911
1911
  ```
1912
1912
 
1913
- #### Pipelines all the way down :turtle:
1913
+ ### Pipelines all the way down :turtle:
1914
1914
 
1915
1915
  Pipelines are full Plumb steps, so they can themselves be used as steps.
1916
1916
 
@@ -1926,7 +1926,7 @@ Pipe2 = DebuggablePipeline.new do |pl|
1926
1926
  end
1927
1927
  ```
1928
1928
 
1929
- ### Recursive types
1929
+ ## Recursive types
1930
1930
 
1931
1931
  You can use a proc to defer evaluation of recursive definitions.
1932
1932
 
@@ -1959,11 +1959,11 @@ LinkedList = Types::Hash[
1959
1959
 
1960
1960
 
1961
1961
 
1962
- ### Encoders and Codecs
1962
+ ## Encoders and Codecs
1963
1963
 
1964
1964
  A one-way coercion can parse an external representation (a date string) into a parsed value (a `Date`), but not back. **Encoders** generalize that into pluggable, two-way serialization, and **Codecs** group encoders and apply them to whole schemas — Ruby data structures to JSON-ready structures and back, for example.
1965
1965
 
1966
- #### Defining encoders
1966
+ ### Defining encoders
1967
1967
 
1968
1968
  An encoder is a class declaring an input and an output type, with `#decode` (input ⇒ output) and `#encode` (output ⇒ input) methods:
1969
1969
 
@@ -2003,7 +2003,7 @@ JSONDateRangeEncoder.encode(Date.new(2024, 1, 1)..Date.new(2024, 2, 1))
2003
2003
 
2004
2004
  Encoders also express lenient unions — `Types::Date | SomeDateEncoder` accepts a `Date` or decodes a string into one.
2005
2005
 
2006
- #### Codecs
2006
+ ### Codecs
2007
2007
 
2008
2008
  A codec groups encoders and applies them to whole types at composition time. Codecs know nothing about any particular format — only their encoders. Types that are already valid in the target format are declared with `.noop`:
2009
2009
 
@@ -2091,7 +2091,7 @@ JSONPerson.to_json_schema
2091
2091
  # "dates" is described as { "type" => "object", "properties" => { "from" => { "type" => "string" }, ... } }
2092
2092
  ```
2093
2093
 
2094
- #### Codec instances: a registry of pre-built pairs
2094
+ ### Codec instances: a registry of pre-built pairs
2095
2095
 
2096
2096
  Composing a codec rewrites the whole type tree, so it belongs at boot — not on the path of every message. A codec _instance_ is a registry of `[decoder, encoder]` pairs, each built once by `register` and then looked up by key:
2097
2097
 
@@ -2119,7 +2119,22 @@ registry.freeze
2119
2119
 
2120
2120
  `key?` asks what is registered; an unknown key raises `Plumb::Codec::NoEntryError` (a `KeyError`). Payloads are still validated by their type — a bad one raises `Plumb::ParseError` as usual.
2121
2121
 
2122
- #### `Codec::Forms`: string-based formats
2122
+ An open registry also composes _type_ keys on first use, so you need not enumerate every type you exchange:
2123
+
2124
+ ```ruby
2125
+ CODECS = JSONCodec.new # no block, so it stays open
2126
+
2127
+ CODECS.encode(Person, person) # composes Person now, reuses it after
2128
+ CODECS.decode(Person, payload)
2129
+ CODECS.decode(Types::Date, '2024-01-01')
2130
+ CODECS.decode(::Date, '2024-01-01') # raw Ruby classes work too
2131
+ ```
2132
+
2133
+ Only keys that _are_ types compose themselves — an app-owned tag like `'person.created'` names nothing the codec could build, so it still raises `NoEntryError` until you register it. Freezing is how you declare the set closed: a sealed registry never composes anything new, which is what you want at boot when every type is known.
2134
+
2135
+ Open registries are safe to share between threads — composition happens outside a lock, and racing threads simply compose the same (pure) rewrite twice. Note that type keys match by value, so a type literal built fresh on each call (`CODECS.decode(Types::Hash[on: Types::Date], payload)`) adds an entry per call: pass constants, or seal the registry.
2136
+
2137
+ ### `Codec::Forms`: string-based formats
2123
2138
 
2124
2139
  The second built-in codec targets HTML forms, query strings and other formats where **every value arrives as a string**. Unlike `Codec::JSON` there are almost no native scalars: strings pass through, untyped containers recurse (Rack-style nested params), and everything else maps through an encoder with a strictly-patterned string input type — integers (`/\A-?\d+\z/`), floats, decimals, booleans (`"true"/"1"`, `"false"/"0"`, case-insensitive), ISO 8601 dates and times, scheme-prefixed URIs, and the empty string for `nil` (so `Types::Date | Types::Nil` decodes `''` to `nil`).
2125
2140
 
@@ -2150,7 +2165,7 @@ Things to know:
2150
2165
  * Registering `noop Types::Hash` / `Types::Array` only covers *untyped* containers — structured schemas (and struct classes) are always recursed into, so a generic noop can't accidentally skip encoding of nested fields.
2151
2166
  * Decoding a struct runs the rewritten schema and then the struct's own validation — correct, but a struct attribute with a non-idempotent transform would apply it twice. Struct attributes should be validators/coercions, as they already must be for `#with`.
2152
2167
 
2153
- ### Custom types
2168
+ ## Custom types
2154
2169
 
2155
2170
  Every Plumb type exposes the following one-method interface:
2156
2171
 
@@ -2162,7 +2177,7 @@ As long as an object implements this interface, it can be composed into Plumb wo
2162
2177
 
2163
2178
  The `Result::Valid` class has helper methods `#valid(value) => Result::Valid` and `#invalid(errors:) => Result::Invalid` to facilitate returning valid or invalid values from your own steps.
2164
2179
 
2165
- #### Compose procs or lambdas directly
2180
+ ### Compose procs or lambdas directly
2166
2181
 
2167
2182
  Piping any `#call` object onto Plumb types wraps your object in a composable step, with all methods necessary for further composition.
2168
2183
 
@@ -2170,7 +2185,7 @@ Piping any `#call` object onto Plumb types wraps your object in a composable ste
2170
2185
  Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
2171
2186
  ```
2172
2187
 
2173
- #### `Plumb::Function[input => output]`
2188
+ ### `Plumb::Function[input => output]`
2174
2189
 
2175
2190
  To build a standalone, typed function from a callable — one not already piped onto a type — use `Plumb::Function[]`. Declaring both ends gives you a typed function: the input is validated before your callable runs, and the value it produces is validated against the output type.
2176
2191
 
@@ -2226,7 +2241,7 @@ Either way, `Greeting` is a full Plumb step, which comes with all the Plumb meth
2226
2241
  LoudGreeting = Greeting.default('no greeting').invoke(:upcase)
2227
2242
  ```
2228
2243
 
2229
- #### A custom `#call` class
2244
+ ### A custom `#call` class
2230
2245
 
2231
2246
  Or write a custom class that responds to `#call(Result::Valid) => Result::Valid | Result::Invalid`
2232
2247
 
@@ -2249,7 +2264,7 @@ MyType = Types::String >> Greeting.new('Hola')
2249
2264
 
2250
2265
  This is useful when you want to parameterize your custom steps, for example by initialising them with arguments like the example above.
2251
2266
 
2252
- #### Include `Plumb::Composable` to make instance of a class full "steps"
2267
+ ### Include `Plumb::Composable` to make instance of a class full "steps"
2253
2268
 
2254
2269
  The class above will be wrapped in a composable step when piped into other steps, but it doesn't support Plumb methods on its own.
2255
2270
 
@@ -2281,7 +2296,7 @@ Now you can use your class as a composition starting point directly.
2281
2296
  LoudGreeting = Greeting.new('Hola').default('no greeting').invoke(:upcase)
2282
2297
  ```
2283
2298
 
2284
- #### Extend a class with `Plumb::Composable` to make the class itself a composable step.
2299
+ ### Extend a class with `Plumb::Composable` to make the class itself a composable step.
2285
2300
 
2286
2301
  ```ruby
2287
2302
  class User
@@ -2296,7 +2311,7 @@ end
2296
2311
 
2297
2312
  This is how [Plumb::Types::Data](#typesdata) is implemented.
2298
2313
 
2299
- #### Include `Plumb::Implementation[input => output]` to declare a class' types
2314
+ ### Include `Plumb::Implementation[input => output]` to declare a class' types
2300
2315
 
2301
2316
  `Plumb::Composable` makes your instances composable, but Plumb knows nothing about what they accept or produce — they're opaque, so they opt out of [composition type-checks](#composition-type-checks) and subtype checks.
2302
2317
 
@@ -2358,7 +2373,7 @@ class AdminFinder < UserFinder
2358
2373
  end
2359
2374
  ```
2360
2375
 
2361
- #### Extend `Plumb::Implementation[input => output]` to make the class itself a typed step
2376
+ ### Extend `Plumb::Implementation[input => output]` to make the class itself a typed step
2362
2377
 
2363
2378
  Just as with [`Plumb::Composable`](#extend-a-class-with-plumbcomposable-to-make-the-class-itself-a-composable-step), `extend` instead of `include` puts the whole interface on the class: no instantiation, the class implements `self._call(result)` and answers `.input_type` / `.output_type`.
2364
2379
 
@@ -2381,7 +2396,7 @@ The two forms are alternatives — pick one per class. The extended form deliber
2381
2396
  Plumb::Subtyping.subtype?(ParseUUID, Types::String) # => true
2382
2397
  ```
2383
2398
 
2384
- #### Participating in subtype & composition checks
2399
+ ### Participating in subtype & composition checks
2385
2400
 
2386
2401
  The subtype (`#<=`) and [`#>>` composition](#composition-type-checks) checks are built on a single hook that every `Plumb::Composable` already implements with a sensible default — `#>>` is just `subtype?(produced, accepted)`, so there's nothing extra to implement for composition. A custom type participates **without changing any core library code**: it either relies on the default or overrides the hook. `Plumb::Subtyping` itself only knows the composition algebra (the top type `Types::Any`, the bottom type `Types::Never`, union `#|`, intersection `#&`, refinement/sequencing `#>>`, and conversion `#transform`); everything else is delegated to the type.
2387
2402
 
@@ -2390,7 +2405,7 @@ The default leans on two methods your type already has:
2390
2405
  - `#children` — the sub-types this type is built from, as an array. A type whose single child is a **raw Ruby matcher** (a Class, Range, Regexp or literal — as `Plumb::Constraint` wraps) is treated as *atomic* and compared with Ruby semantics. A type whose children are themselves Plumb types (like `Array`, `Tuple`, `HashMap`) is treated as a **covariant container** — so exposing `#children` is all a custom container needs to compare covariantly.
2391
2406
  - `#==` — structural equality (provided by `Plumb::Composable`).
2392
2407
 
2393
- ##### The hook
2408
+ #### The hook
2394
2409
 
2395
2410
  | Hook | Returns | Used by | Default |
2396
2411
  | --- | --- | --- | --- |
@@ -2421,7 +2436,7 @@ even <= Types::Numeric # => true
2421
2436
  even <= Types::String # => false
2422
2437
  ```
2423
2438
 
2424
- ##### Type flow: `#input_type` / `#output_type`
2439
+ #### Type flow: `#input_type` / `#output_type`
2425
2440
 
2426
2441
  The `#>>` check (and the [JSON Schema visitor](#json-schema)) ask what a type accepts and produces; both [default to `self`](#input_type-and-output_type). Override them when your type changes the value or is opaque about it:
2427
2442
 
@@ -2430,7 +2445,7 @@ The `#>>` check (and the [JSON Schema visitor](#json-schema)) ask what a type ac
2430
2445
 
2431
2446
  Custom types are **values/leaves** in the algebra — you compose them with the built-in combinators (`#>>`, `#|`, `#transform`, `Types::Any`) rather than re-implementing those.
2432
2447
 
2433
- ### Custom policies
2448
+ ## Custom policies
2434
2449
 
2435
2450
  `Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
2436
2451
 
@@ -2456,7 +2471,7 @@ The `#policy` helper supports applying multiply policies.
2456
2471
  Types::String.policy(default_if_nil: 'nothing here', size: (10..20))
2457
2472
  ```
2458
2473
 
2459
- #### Policies as helper methods
2474
+ ### Policies as helper methods
2460
2475
 
2461
2476
  Use the `helper: true` option to register the policy as a method you can call on types directly.
2462
2477
 
@@ -2483,7 +2498,7 @@ AccountName = Types::String.admin
2483
2498
  AccountName.metadata # => { admin: true }
2484
2499
  ```
2485
2500
 
2486
- #### Type-specific policies
2501
+ ### Type-specific policies
2487
2502
 
2488
2503
  You can use the `for_type:` option to define policies that only apply to steps that output certain types. This example is only applicable for types that return `Integer` values.
2489
2504
 
@@ -2499,7 +2514,7 @@ Doubled.parse(2) # 4
2499
2514
  DoubledString = Types::String.multiply_by(2) # raises error
2500
2515
  ```
2501
2516
 
2502
- #### Interface-specific policies
2517
+ ### Interface-specific policies
2503
2518
 
2504
2519
  `for_type`also supports a Symbol for a method name, so that the policy can be applied to any types that support that method.
2505
2520
 
@@ -2515,7 +2530,7 @@ DoubledNumeric = Types::Numeric.multiply_by(2)
2515
2530
  DoubledMoney = Types::Any[Money].multiply_by(2)
2516
2531
  ```
2517
2532
 
2518
- #### Self-contained policy modules
2533
+ ### Self-contained policy modules
2519
2534
 
2520
2535
  You can register a module, class or object with a three-method interface as a policy. This is so that policies can have their own namespace if they need local constants or private methods. For example, this is how the `:split` policy for strings is defined.
2521
2536
 
@@ -2534,7 +2549,7 @@ end
2534
2549
  Plumb.policy :split, SplitPolicy
2535
2550
  ```
2536
2551
 
2537
- ### JSON Schema
2552
+ ## JSON Schema
2538
2553
 
2539
2554
  Plumb ships with a JSON schema visitor that compiles a type composition into a JSON Schema Hash. All Plumb types support a `#to_json_schema` method.
2540
2555
 
@@ -2604,7 +2619,7 @@ Types::DateTime.to_json_schema
2604
2619
  # {"type"=>"string", "format"=>"date-time"}
2605
2620
  ```
2606
2621
 
2607
- ##### Node names for compositions
2622
+ #### Node names for compositions
2608
2623
 
2609
2624
  Two-sided compositions report one of four `#node_name`s, depending on whether the node is a *computation* (some side changes the value) or a *type* (no side does):
2610
2625
 
@@ -2617,7 +2632,7 @@ Two-sided compositions report one of four `#node_name`s, depending on whether th
2617
2632
 
2618
2633
  For a visitor this matters because an `:intersection` describes one value (merge both sides' specs) while an `:and` may describe a conversion (build from the input side). Visitors that don't need the distinction can register just `on(:and)` / `on(:or)`: `:intersection` and `:union` fall back to those when no specific handler is defined.
2619
2634
 
2620
- ### Mermaid diagrams
2635
+ ## Mermaid diagrams
2621
2636
 
2622
2637
  Because a composition is just a tree of `>>` (sequence) and `|` (choice) nodes, it can also be rendered as a [Mermaid](https://mermaid.js.org) `flowchart`. Every Plumb type supports `#to_mermaid`. `>>` becomes sequential arrows; `|` becomes a fork, where the preceding step fans out to each alternative (and a following step joins them back).
2623
2638
 
data/lib/plumb/codec.rb CHANGED
@@ -200,8 +200,23 @@ module Plumb
200
200
  Entry = Data.define(:decoder, :encoder)
201
201
  NoEntryError = Class.new(KeyError)
202
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.
203
217
  def initialize(&)
204
218
  @entries = {}
219
+ @lock = Mutex.new
205
220
  return unless block_given?
206
221
 
207
222
  yield self
@@ -216,12 +231,14 @@ module Plumb
216
231
  # Named, not splatted: both sides are types that #parse, so a swapped pair would
217
232
  # decode where it should encode without anything raising.
218
233
  def register(key, type = key)
219
- decoder, encoder = self.class.for(type)
220
- @entries[key] = Entry.new(decoder:, encoder:)
234
+ raise FrozenError, "#{inspect} is sealed; register before freezing it" if frozen?
235
+
236
+ entry = build_entry(type)
237
+ @lock.synchronize { @entries[key] = entry }
221
238
  self
222
239
  end
223
240
 
224
- def key?(key) = @entries.key?(key)
241
+ def key?(key) = !read(key).nil?
225
242
 
226
243
  def decode(key, payload) = entry(key).decoder.parse(payload)
227
244
  def encode(key, payload) = entry(key).encoder.parse(payload)
@@ -880,7 +897,34 @@ module Plumb
880
897
  private
881
898
 
882
899
  def entry(key)
883
- @entries.fetch(key) { raise NoEntryError, "no encoder/decoder registered for #{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:)
884
928
  end
885
929
  end
886
930
  end
data/lib/plumb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.2.0.beta.2'
4
+ VERSION = '0.2.0.beta.3'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plumb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.beta.2
4
+ version: 0.2.0.beta.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis