typeid_rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 545fbb367919209222c4334d6ae3ae560a6f338d1d76cd59bf964b74009d0df4
4
+ data.tar.gz: 8d5edbc4b5096f8c418047e71b6400c5895c2f431b0cf1358315b136f91478f9
5
+ SHA512:
6
+ metadata.gz: 8eee45396ef77293c114761afbe6a21f3655af65bc157934eee501b64b59e4a22e9dacc70181f7b45a8369dfcbcf7279b3ebe01cf6250ba26db6bd854a1b436e
7
+ data.tar.gz: 820f7eeb45365192d2f66d4651f2e93abf17b1c5dff9c6486a5bad989fbbe49271dc67e2517194c22d3ee6193cd84041412790bd95906e1920d25739be92fc8c
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-19
4
+
5
+ Initial release.
6
+
7
+ - `TypeidRails.encode`, `parse`, `to_uuid` and `generate` convert between UUIDs and
8
+ [TypeID](https://github.com/jetify-com/typeid) strings, conforming to spec version 0.3.0.
9
+ `TypeidRails::Value` is the parsed, immutable form.
10
+ - `TypeidRails.configure` sets process-wide defaults: `fuzzy` accepts Crockford's lenient
11
+ spellings when decoding, `equality` picks prefix-sensitive (`:typeid`) or UUID-only (`:uuid`)
12
+ comparison, and `uuid_version` picks UUIDv4 or UUIDv7 for `generate`.
13
+ - `TypeidRails::Identifiable` exposes a model's UUID primary key as a TypeID, and registers its
14
+ prefix in `TypeidRails::Registry`. `TypeidRails::AssociationSupport` does the same for foreign
15
+ keys pointing at such models.
16
+ - `typeid_options` sets `fuzzy` and `strict` on a model class, inherited by its subclasses.
17
+ - `TypeidRails::PolymorphicType` types a uuid column whose rows point at different models.
18
+ - `TypeidRails.derive_uuid` derives a stable UUID from input parts with SHAKE128, when the
19
+ `sha3` gem is available.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Sorah Fukumori
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # typeid_rails: TypeID identifiers for UUID primary keys in Rails
2
+
3
+ typeid_rails renders a 128-bit UUID as a [TypeID](https://github.com/jetify-com/typeid), a short string that carries its own type tag, and parses it back. Its ActiveRecord attribute type keeps a plain `uuid` column in the database and exposes the TypeID to application code.
4
+
5
+ ```
6
+ user_0wp9c9wd1d90msb3z67p5eb0v5
7
+ │ └─ the UUID, 26 Base32 characters
8
+ └────── a prefix naming the kind of resource
9
+ ```
10
+
11
+ A UUID is a fine primary key and a poor thing to put in a URL. It is long, it looks like every other UUID, and pasting a user's ID where an order's ID belongs silently finds nothing. A TypeID is shorter, says what it identifies, and is obviously wrong in the wrong place.
12
+
13
+ The implementation passes the TypeID specification's test vectors (spec version 0.3.0). Optional settings relax decoding and equality for applications that already hand out Crockford Base32 identifiers decoded leniently.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ bundle add typeid_rails
19
+ ```
20
+
21
+ Requires Ruby 3.3 or later. The optional ActiveRecord integration is tested against ActiveRecord 7.1 and later.
22
+
23
+ ## Usage
24
+
25
+ ```ruby
26
+ require "typeid_rails"
27
+
28
+ TypeidRails.encode("1cb2589e-342d-4829-958f-e63d8ae58365", prefix: "user")
29
+ # => "user_0wp9c9wd1d90msb3z67p5eb0v5"
30
+
31
+ TypeidRails.to_uuid("user_0wp9c9wd1d90msb3z67p5eb0v5")
32
+ # => "1cb2589e-342d-4829-958f-e63d8ae58365"
33
+ ```
34
+
35
+ `to_uuid` returns `nil` for anything malformed. When a bad identifier should be an error instead, parse it:
36
+
37
+ ```ruby
38
+ id = TypeidRails.parse("user_0wp9c9wd1d90msb3z67p5eb0v5")
39
+ id.uuid # => "1cb2589e-342d-4829-958f-e63d8ae58365"
40
+ id.prefix # => "user"
41
+ id.to_s # => "user_0wp9c9wd1d90msb3z67p5eb0v5"
42
+
43
+ TypeidRails.parse("user_0WP9C9WD1D90MSB3Z67P5EB0V5") # raises TypeidRails::InvalidFormatError
44
+ ```
45
+
46
+ Pass `expected_prefix:` to reject identifiers that belong to another kind of resource. The result is `nil` rather than an exception, since a wrong prefix is usually a lookup that should miss:
47
+
48
+ ```ruby
49
+ TypeidRails.parse("user_0wp9c9wd1d90msb3z67p5eb0v5", expected_prefix: "org") # => nil
50
+ ```
51
+
52
+ Prefixes follow the TypeID grammar: lowercase ASCII letters and underscores, at most 63 characters, with no underscore at either end. An empty prefix is allowed and renders as the bare suffix. `TypeidRails::Value` is immutable. Decoding is strict by default. See [Configuration](#configuration) to relax it.
53
+
54
+ To mint a new identifier without a database round-trip:
55
+
56
+ ```ruby
57
+ TypeidRails.generate(prefix: "user") # => "user_..." from a UUIDv4
58
+ TypeidRails.generate(prefix: "user", version: 7) # => "user_01m5a5kxrqeea9n21at57rtheq"
59
+ ```
60
+
61
+ ### ActiveRecord
62
+
63
+ Under Rails the integration loads itself. Elsewhere, `require "typeid_rails/active_record"`.
64
+
65
+ Declare a prefix on any model whose primary key is a UUID:
66
+
67
+ ```ruby
68
+ class User < ApplicationRecord
69
+ include TypeidRails::Identifiable
70
+ id_prefix "user"
71
+ end
72
+
73
+ User.first.id # => "user_0wp9c9wd1d90msb3z67p5eb0v5"
74
+ User.find_by(id: "user_0wp9c9wd1d90msb3z67p5eb0v5")
75
+ ```
76
+
77
+ The column still holds a plain UUID. Only the attribute is encoded. Raw UUIDs are accepted on assignment too, so seeds, migrations and hand-written queries do not have to encode first.
78
+
79
+ This gem never generates primary keys. That stays with the database (`gen_random_uuid()` or similar) or with your own code.
80
+
81
+ Foreign keys are handled by a second concern, included once in your base class. It is separate because a model with no public identifier of its own, such as a join table or an audit row, can still point at models that have one:
82
+
83
+ ```ruby
84
+ class ApplicationRecord < ActiveRecord::Base
85
+ primary_abstract_class
86
+ include TypeidRails::AssociationSupport
87
+ end
88
+
89
+ class Membership < ApplicationRecord
90
+ belongs_to :user # user_id now reads and writes "user_..." identifiers
91
+ end
92
+ ```
93
+
94
+ By default an identifier with the wrong prefix, or one that is malformed, collapses to the null UUID. It matches no row, and the lookup simply finds nothing. Pass `strict: true` to raise `TypeidRails::PrefixMismatchError` or `TypeidRails::InvalidFormatError` instead:
95
+
96
+ ```ruby
97
+ id_prefix "user", strict: true
98
+ ```
99
+
100
+ A uuid column whose rows point at different models, with a sibling column naming which, has no single prefix. Type it with `TypeidRails::PolymorphicType`. It accepts an identifier under any prefix, or a raw UUID, and reads back the bare UUID, so the owning record supplies the prefix:
101
+
102
+ ```ruby
103
+ class Preference < ApplicationRecord
104
+ OWNER_MODELS = {"user" => User, "org" => Organization}.freeze
105
+
106
+ attribute :owner_id, TypeidRails::PolymorphicType.new
107
+
108
+ def owner_id
109
+ model = OWNER_MODELS[owner_type]
110
+ model ? model.type_for_attribute("id").cast(super) : super
111
+ end
112
+ end
113
+ ```
114
+
115
+ ### Prefix registry
116
+
117
+ Declaring a prefix registers the model under it, so an identifier can be routed back to whatever owns it. This is useful when a token or an API path carries an identifier whose kind is not known ahead of time.
118
+
119
+ ```ruby
120
+ TypeidRails::Registry.model_for("user") # => User
121
+ TypeidRails::Registry.prefix_for(User) # => "user"
122
+ ```
123
+
124
+ ### Deterministic identifiers
125
+
126
+ `derive_uuid` turns any set of input parts into a stable UUID, so two systems that agree on the inputs arrive at the same identifier without talking to each other. Useful when records are mirrored across a boundary and both sides need to name them identically.
127
+
128
+ ```ruby
129
+ TypeidRails.derive_uuid("production", "users", "42")
130
+ # => "82b28cf0-404a-d9b3-1a45-70f9e81ea106"
131
+ ```
132
+
133
+ Parts are joined with a NUL byte before hashing, so `("ab", "c")` and `("a", "bc")` derive different identifiers.
134
+
135
+ This requires the [`sha3`](https://rubygems.org/gems/sha3) gem, which is not a dependency of this gem. Add it yourself if you use this feature:
136
+
137
+ ```ruby
138
+ gem "sha3"
139
+ ```
140
+
141
+ The result is 128 raw bits of SHAKE128 output rendered in UUID shape. It is deliberately not an RFC 9562 UUID: no version or variant bits are set. This keeps derived identifiers stable forever, at the cost of them not being valid v4 or v7 UUIDs.
142
+
143
+ ## Configuration
144
+
145
+ ```ruby
146
+ # config/initializers/typeid_rails.rb
147
+ TypeidRails.configure do |config|
148
+ config.fuzzy = false # accept Crockford's lenient spellings when decoding
149
+ config.equality = :typeid # :typeid or :uuid, see below
150
+ config.uuid_version = 4 # 4 or 7, used by generate
151
+ end
152
+ ```
153
+
154
+ Each setting can be overridden where it applies: `parse(..., fuzzy:)`, `generate(..., version:)`, and `id_prefix "user", fuzzy:` or `Type.new(prefix:, fuzzy:)` for an attribute.
155
+
156
+ - `fuzzy`: TypeID accepts only the lowercase alphabet. With `fuzzy = true`, the suffix also decodes in uppercase, and `i`/`l` read as `1`, `o` as `0` and `u` as `v`, as in [Crockford's Base32](https://www.crockford.com/base32.html). The prefix is never relaxed, and the result always renders canonically.
157
+ - `equality`: with `:typeid`, two values are equal when both the prefix and the UUID match, and a value equals a String only when it is the exact rendering. With `:uuid`, the prefix is ignored and a value also equals its raw UUID text.
158
+ - `uuid_version`: TypeID recommends v7 so identifiers sort by creation time. A v7 UUID also reveals that time to anyone who holds the identifier, so the default is v4.
159
+
160
+ ### Per model
161
+
162
+ The attribute types can take `fuzzy` and `strict` from a model class instead, so they can be set on an abstract class rather than for the whole process:
163
+
164
+ ```ruby
165
+ class ApplicationRecord < ActiveRecord::Base
166
+ primary_abstract_class
167
+ include TypeidRails::AssociationSupport
168
+ typeid_options fuzzy: true, strict: true
169
+ end
170
+
171
+ class LegacyRecord < ApplicationRecord
172
+ self.abstract_class = true
173
+ typeid_options strict: false # merged over the inherited options
174
+ end
175
+ ```
176
+
177
+ An attribute takes the first setting found: the `id_prefix` argument, then the model's `typeid_options`, then the global configuration (`fuzzy`) or `false` (`strict`). A foreign key follows the options of the model that declares the `belongs_to`. `typeid_options` applies to attributes declared after it, so call it before `id_prefix` and `belongs_to` in the same class. `equality` and `uuid_version` act on values outside any model, and stay global.
178
+
179
+ To keep accepting identifiers an application already issued under the lenient Crockford rules, while migrating it towards TypeID:
180
+
181
+ ```ruby
182
+ TypeidRails.configure do |config|
183
+ config.fuzzy = true
184
+ config.equality = :uuid
185
+ end
186
+ ```
187
+
188
+ ## Difference with the typeid gem
189
+
190
+ The [typeid](https://github.com/broothie/typeid-ruby) gem is a general Ruby implementation of TypeID that mints UUIDv7 identifiers. Use it when you need TypeIDs outside ActiveRecord and want no integration with models.
191
+
192
+ typeid_rails is built around existing UUID primary keys. It adds an attribute type so columns stay `uuid` while models read and write TypeIDs, a prefix registry, a type for polymorphic columns, and compatibility settings for identifiers already issued under lenient Crockford rules. The namespaces are different (`TypeidRails` and `TypeID`), so both gems can be loaded together.
193
+
194
+ ## Caveats
195
+
196
+ - With `fuzzy = true`, the gem accepts strings that the TypeID spec requires to be rejected. Encoding is unaffected, and every identifier it renders is a valid TypeID.
197
+ - `generate` mints UUIDv4 by default, while the spec recommends UUIDv7. Set `uuid_version = 7` to follow it.
198
+ - `Value#==` and `#hash` read the `equality` setting on every call. Changing it while values are held as hash keys leaves those hashes inconsistent.
199
+
200
+ ## Development
201
+
202
+ ```bash
203
+ bin/setup # install dependencies
204
+ bundle exec rake # run the specs, including the TypeID test vectors
205
+ bundle exec rbs collection install
206
+ bundle exec steep check # typecheck lib/ against sig/
207
+ hk check --all # run every linter, as CI does
208
+ ```
209
+
210
+ ## Third-party code
211
+
212
+ The bit-buffer technique in `TypeidRails::Base32` is adapted from the [rotp](https://github.com/mdp/rotp) gem, Copyright (c) 2011 Mark Percival, used under the MIT License. The notice is reproduced in `lib/typeid_rails/base32.rb`.
213
+
214
+ The test vectors in `spec/fixtures/typeid/` are taken from the [TypeID specification](https://github.com/jetify-com/typeid) and used under the Apache License 2.0. They are not part of the published gem.
215
+
216
+ ## Contributing
217
+
218
+ Bug reports and pull requests are welcome on GitHub at https://github.com/sorah/typeid_rails.
219
+
220
+ ## License
221
+
222
+ Copyright (c) 2026 Sorah Fukumori
223
+
224
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ desc "Typecheck lib/ against sig/ with steep"
9
+ task :steep do
10
+ sh "bundle exec steep check"
11
+ end
12
+
13
+ task default: :spec
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../typeid_rails"
4
+
5
+ require_relative "type"
6
+ require_relative "polymorphic_type"
7
+ require_relative "identifiable"
8
+ require_relative "association_support"
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ require_relative "model_options"
6
+ require_relative "type"
7
+
8
+ module TypeidRails
9
+ # Applies TypeidRails::Type to foreign keys pointing at Identifiable models, so an
10
+ # association can be assigned a prefixed identifier directly. The type takes this
11
+ # model's typeid_options, not the referenced model's.
12
+ #
13
+ # This lives apart from Identifiable because a model that has no public identifier
14
+ # of its own — a join table, an audit row — can still hold foreign keys to models
15
+ # that do. Include it once, in the application's base record class:
16
+ #
17
+ # class ApplicationRecord < ActiveRecord::Base
18
+ # primary_abstract_class
19
+ # include TypeidRails::AssociationSupport
20
+ # end
21
+ module AssociationSupport
22
+ extend ActiveSupport::Concern
23
+ include ModelOptions
24
+
25
+ class_methods do
26
+ def belongs_to(name, scope = nil, **options, &block)
27
+ result = super
28
+ association = reflect_on_association(name)
29
+ configure_foreign_key_for_association(association) if association
30
+ result
31
+ end
32
+
33
+ # Configures every already-declared belongs_to. Needed only when an association
34
+ # was defined before this module was included.
35
+ def configure_foreign_keys!
36
+ reflect_on_all_associations(:belongs_to).each do |association|
37
+ configure_foreign_key_for_association(association)
38
+ end
39
+ end
40
+
41
+ private def configure_foreign_key_for_association(association)
42
+ return if association.polymorphic?
43
+
44
+ fk_name = association.foreign_key.to_s
45
+ return if attribute_types[fk_name].is_a?(Type)
46
+
47
+ referenced_model = resolve_association_class(association)
48
+ return unless referenced_model.respond_to?(:id_prefix) && referenced_model.id_prefix
49
+
50
+ attribute fk_name, build_typeid_type(referenced_model.id_prefix)
51
+ end
52
+
53
+ # An association may name a class that does not exist yet (or at all); that is
54
+ # not our problem to report. Any other NameError comes from loading the class
55
+ # itself and must not be swallowed.
56
+ private def resolve_association_class(association)
57
+ association.klass
58
+ rescue NameError => e
59
+ raise unless e.name.to_s == association.class_name.to_s.split("::").last
60
+
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+
5
+ module TypeidRails
6
+ # TypeID's Base32 encoding/decoding for raw bytes: Crockford's alphabet over a
7
+ # 130-bit big-endian value whose top 2 bits are zero.
8
+ # Uses a streaming bit-buffer approach to avoid bigint arithmetic.
9
+ #
10
+ # Reference: https://github.com/jetify-com/typeid/tree/main/spec#base32-encoding
11
+ #
12
+ # Bit-buffer technique adapted from rotp gem (https://github.com/mdp/rotp).
13
+ # Copyright (C) 2011 by Mark Percival <m@mdp.im>
14
+ #
15
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ # of this software and associated documentation files (the "Software"), to deal
17
+ # in the Software without restriction, including without limitation the rights
18
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ # copies of the Software, and to permit persons to whom the Software is
20
+ # furnished to do so, subject to the following conditions:
21
+ #
22
+ # The above copyright notice and this permission notice shall be included in
23
+ # all copies or substantial portions of the Software.
24
+ #
25
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
31
+ # THE SOFTWARE.
32
+ module Base32
33
+ # Crockford's Base32 alphabet (lowercase, excludes I, L, O, U to avoid ambiguity)
34
+ ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
35
+
36
+ # Indexable form of ALPHABET, so encoding does not allocate a String per lookup.
37
+ ALPHABET_CHARS = ALPHABET.chars.freeze
38
+
39
+ # Number of bytes a single encoded value carries.
40
+ BYTE_SIZE = 16
41
+
42
+ # Number of characters produced for BYTE_SIZE bytes.
43
+ LENGTH = 26
44
+
45
+ STRICT_DECODE_MAP = ALPHABET.each_char.with_index.to_h.freeze
46
+
47
+ # Looked up after downcasing.
48
+ FUZZY_DECODE_MAP = STRICT_DECODE_MAP.merge(
49
+ "i" => 1,
50
+ "l" => 1,
51
+ "o" => 0,
52
+ "u" => 27, # Crockford reserves U as a check symbol; read it as V.
53
+ ).freeze
54
+
55
+ SHIFT = 5
56
+ MASK = 0x1f
57
+
58
+ class << self
59
+ # Encodes a 16-byte string to 26 Base32 characters.
60
+ # 16 bytes = 128 bits; with 2 leading zero bits = 130 bits = 26 × 5.
61
+ # @param bytes [String] 16-byte binary string
62
+ # @return [String] 26-character base32 string
63
+ def encode_bytes(bytes)
64
+ raise ArgumentError, "Expected #{BYTE_SIZE} bytes, got #{bytes.bytesize}" unless bytes.bytesize == BYTE_SIZE
65
+
66
+ data = bytes.unpack("C*") #: Array[Integer]
67
+ out = String.new
68
+ buffer = 0
69
+ bits_left = 2 # 2 leading zero bits: 2 + 128 = 130 = 26 * 5
70
+ idx = 0
71
+
72
+ while bits_left > 0 || idx < data.length
73
+ if bits_left < SHIFT
74
+ buffer = (buffer << 8) | data.fetch(idx)
75
+ bits_left += 8
76
+ idx += 1
77
+ end
78
+ bits_left -= SHIFT
79
+ out << ALPHABET_CHARS.fetch((buffer >> bits_left) & MASK)
80
+ buffer &= (1 << bits_left) - 1
81
+ end
82
+
83
+ out
84
+ end
85
+
86
+ # Decodes a 26-character Base32 string to a 16-byte string.
87
+ # 26 chars × 5 bits = 130 bits. The first char carries 2 padding zeros + 3 data bits.
88
+ # Remaining 25 chars carry 125 bits. Total data: 3 + 125 = 128 bits = 16 bytes.
89
+ # @param base32 [String] 26-character base32 string
90
+ # @param fuzzy [Boolean] see Configuration#fuzzy
91
+ # @return [String] 16-byte binary string
92
+ def decode_bytes(base32, fuzzy: TypeidRails.config.fuzzy)
93
+ values = to_values(base32, fuzzy:) if valid128?(base32, fuzzy:)
94
+ raise ArgumentError, "Invalid base32 format" unless values
95
+
96
+ # First character: discard top 2 padding bits, keep bottom 3 data bits
97
+ buffer = values.fetch(0) & 0x07
98
+ bits_left = 3
99
+ result = [] #: Array[Integer]
100
+
101
+ values.drop(1).each do |value|
102
+ buffer = (buffer << SHIFT) | value
103
+ bits_left += SHIFT
104
+
105
+ if bits_left >= 8
106
+ bits_left -= 8
107
+ result << ((buffer >> bits_left) & 0xff)
108
+ buffer &= (1 << bits_left) - 1
109
+ end
110
+ end
111
+
112
+ result.pack("C*")
113
+ end
114
+
115
+ # Checks if a string is a valid 26-character base32 encoding of 128 bits.
116
+ # @param string [String] string to validate
117
+ # @param fuzzy [Boolean] see Configuration#fuzzy
118
+ # @return [Boolean]
119
+ def valid128?(string, fuzzy: TypeidRails.config.fuzzy)
120
+ return false unless string.is_a?(String)
121
+ return false unless string.length == LENGTH
122
+
123
+ values = to_values(string, fuzzy:)
124
+ return false unless values
125
+
126
+ # A 128-bit encoding carries 2 leading zero bits, so the first character
127
+ # must decode to a value in 0-7. Without this check, decode_bytes silently
128
+ # masks the top 2 bits and multiple base32 strings collapse to the same UUID.
129
+ values.fetch(0) <= 7
130
+ end
131
+
132
+ # @return [Array<Integer>, nil] nil when a character is outside the alphabet
133
+ private def to_values(string, fuzzy:)
134
+ map = fuzzy ? FUZZY_DECODE_MAP : STRICT_DECODE_MAP
135
+ normalized = fuzzy ? string.downcase : string
136
+ values = [] #: Array[Integer]
137
+ normalized.each_char do |char|
138
+ value = map[char]
139
+ return nil unless value
140
+
141
+ values << value
142
+ end
143
+ values
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeidRails
4
+ # Process-wide defaults. Each setting can also be overridden where it is consumed.
5
+ class Configuration
6
+ UUID_VERSIONS = [4, 7].freeze
7
+ EQUALITY_MODES = [:typeid, :uuid].freeze
8
+
9
+ # When true, suffixes are decoded with Crockford's leniency: uppercase is accepted,
10
+ # and I/L read as 1, O as 0 and U as V. TypeID itself rejects all of these.
11
+ attr_accessor :fuzzy
12
+
13
+ # UUID version minted by Value.generate. 7 sorts by creation time, and also
14
+ # reveals it.
15
+ attr_reader :uuid_version
16
+
17
+ # How Value#== compares. :typeid compares the prefix and the UUID, and matches a
18
+ # String only when it is the exact rendering. :uuid ignores the prefix and also
19
+ # matches the raw UUID text.
20
+ attr_reader :equality
21
+
22
+ def initialize
23
+ @fuzzy = false
24
+ @uuid_version = 4
25
+ @equality = :typeid
26
+ end
27
+
28
+ def equality=(mode)
29
+ unless EQUALITY_MODES.include?(mode)
30
+ raise ArgumentError, "Unsupported equality mode #{mode.inspect}; expected one of #{EQUALITY_MODES.join(", ")}"
31
+ end
32
+
33
+ @equality = mode
34
+ end
35
+
36
+ def uuid_version=(version)
37
+ self.class.validate_uuid_version!(version)
38
+ @uuid_version = version
39
+ end
40
+
41
+ def self.validate_uuid_version!(version)
42
+ return if UUID_VERSIONS.include?(version)
43
+
44
+ raise ArgumentError, "Unsupported UUID version #{version.inspect}; expected one of #{UUID_VERSIONS.join(", ")}"
45
+ end
46
+ end
47
+
48
+ class << self
49
+ def config
50
+ @config ||= Configuration.new
51
+ end
52
+
53
+ def configure
54
+ yield config
55
+ end
56
+
57
+ def reset_config!
58
+ @config = Configuration.new
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "sha3"
5
+ rescue LoadError => e
6
+ raise LoadError, "TypeidRails.derive_uuid requires the `sha3` gem; add `gem \"sha3\"` to your Gemfile", cause: e
7
+ end
8
+
9
+ require_relative "encoder"
10
+
11
+ module TypeidRails
12
+ # Deterministic identifier derivation.
13
+ #
14
+ # Two systems that agree on the same input parts derive the same identifier without
15
+ # coordinating, which is what makes it useful for mirroring records across a boundary.
16
+ #
17
+ # The result is 128 raw bits of SHAKE128 output rendered in UUID shape. It is
18
+ # deliberately *not* an RFC 4122 UUID: no version or variant bits are set, so it
19
+ # cannot be confused with a randomly generated one and stays stable forever.
20
+ module Derivation
21
+ class << self
22
+ # @param parts [Array<String>] input components, joined with a NUL separator
23
+ # @return [String] canonical UUID text
24
+ def uuid(*parts)
25
+ digest = SHA3::Digest::SHAKE_128.new.tap { |shake| shake.update(parts.join("\0")) }.hex_squeeze(16)
26
+ Encoder.format_uuid(digest)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base32"
4
+
5
+ module TypeidRails
6
+ # Converts between canonical UUID text and its Base32 rendering.
7
+ # This is the unprefixed half of the format; see Value for the prefixed form.
8
+ module Encoder
9
+ # Sentinel UUID substituted for invalid or mismatched input by lenient consumers.
10
+ NULL_UUID = "00000000-0000-0000-0000-000000000000"
11
+
12
+ UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
13
+
14
+ class << self
15
+ # @param uuid [String] canonical UUID text
16
+ # @return [String] 26-character Base32 string
17
+ def encode(uuid)
18
+ raise ArgumentError, "Invalid UUID format" unless uuid.is_a?(String) && uuid.match?(UUID_REGEX)
19
+
20
+ bytes = [uuid.delete("-")].pack("H*")
21
+ Base32.encode_bytes(bytes)
22
+ end
23
+
24
+ # @param base32 [String] 26-character Base32 string
25
+ # @param fuzzy [Boolean] see Configuration#fuzzy
26
+ # @return [String] canonical lowercase UUID text
27
+ def decode(base32, fuzzy: TypeidRails.config.fuzzy)
28
+ hex = Base32.decode_bytes(base32, fuzzy:).unpack1("H*") #: String
29
+ format_uuid(hex)
30
+ end
31
+
32
+ def valid?(string, fuzzy: TypeidRails.config.fuzzy)
33
+ Base32.valid128?(string, fuzzy:)
34
+ end
35
+
36
+ # Formats 32 hex characters as canonical UUID text.
37
+ # @param hex [String] 32-character hex string
38
+ # @return [String]
39
+ def format_uuid(hex)
40
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
41
+ end
42
+ end
43
+ end
44
+ end