hyperuuid-wasm 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ # Autoloaded, not required: the platform gems carry no library for Fiddle to open and do not
2
+ # declare fiddle, so loading it eagerly would make `require "hyperuuid"` fail under Bundler
3
+ # wherever fiddle is a bundled rather than a default gem (Ruby 4.0). It loads the first time
4
+ # the Fiddle backend runs, and every path there goes through `functions` first, so a platform
5
+ # gem forced onto Fiddle still gets missing_library_message rather than a bare LoadError.
6
+ autoload :Fiddle, "fiddle"
7
+
8
+ module HyperUuid
9
+ # Fiddle plumbing for the native libhyperuuid shared library — dlopen/dlsym plus a raw
10
+ # C-ABI call, no runtime bridge (the same "no shim" positioning as the PHP binding's
11
+ # FFI and C#'s P/Invoke). Fiddle ships with every Ruby install; it's a plain gem dependency of the
12
+ # universal gem (see hyperuuid.gemspec) rather than a third-party one — mirroring Python's
13
+ # zero-dependency PyO3 wheels.
14
+ #
15
+ # A Ruby gem's files are plain files on disk once installed, so native/{rid}/{lib} can be
16
+ # dlopen'd directly, with no extraction step.
17
+ module Runtime
18
+ # The package's two exceptions live on HyperUuid itself (errors.rb). These are the names
19
+ # they had through 0.3.0, kept as aliases of the very same classes so a
20
+ # `rescue HyperUuid::Runtime::TimestampOutOfRangeError` written against an earlier
21
+ # release still catches.
22
+ RandomSourceError = HyperUuid::RandomSourceError
23
+ TimestampOutOfRangeError = HyperUuid::TimestampOutOfRangeError
24
+
25
+ # One copy of each out-of-range message for the Fiddle backend and for the
26
+ # doors' own range check in hyperuuid.rb; the Magnus extension (rust/src/ruby_ext.rs)
27
+ # carries the same text, and spec/native_backend_spec.rb pins that the two agree.
28
+ V6_TIMESTAMP_OUT_OF_RANGE = "unix_millis does not fit the 60-bit v6 timestamp field"
29
+ V7_TIMESTAMP_OUT_OF_RANGE = "unix_millis must fit within the RFC 9562 48-bit field"
30
+
31
+ NATIVE_DIR = File.join(__dir__, "native")
32
+
33
+ @mutex = Mutex.new
34
+ @functions = nil
35
+
36
+ class << self
37
+ def new_v4
38
+ out = scratch
39
+ rc = functions[:new_v4].call(out)
40
+ raise random_source_failure("uuid_new_v4") unless rc.zero?
41
+ out[0, 16]
42
+ end
43
+
44
+ def new_v5(namespace_bytes, name_bytes)
45
+ out = scratch
46
+ # Fiddle passes a String's own bytes for void* (read-only, no copy of them) — the
47
+ # same crossing every other input here uses. An empty name crosses as it is, a
48
+ # valid pointer with length 0, never as nil: NULL is not part of this export's
49
+ # contract.
50
+ rc = functions[:new_v5].call(namespace_bytes, name_bytes, name_bytes.bytesize, out)
51
+ raise random_source_failure("uuid_new_v5") unless rc.zero?
52
+ out[0, 16]
53
+ end
54
+
55
+ def new_v6(unix_millis)
56
+ out = scratch
57
+ rc = functions[:new_v6].call(unix_millis, out)
58
+ case rc
59
+ when 0 then out[0, 16]
60
+ when 2 then raise TimestampOutOfRangeError, V6_TIMESTAMP_OUT_OF_RANGE
61
+ else raise random_source_failure("uuid_new_v6")
62
+ end
63
+ end
64
+
65
+ def v6_unix_millis(bytes)
66
+ functions[:v6_unix_millis].call(bytes)
67
+ end
68
+
69
+ def new_v6_batch(count, unix_millis)
70
+ return "" if count.zero?
71
+ out = buffer(count * 16)
72
+ rc = functions[:new_v6_batch].call(unix_millis, count, out)
73
+ case rc
74
+ when 0 then out[0, count * 16]
75
+ when 2 then raise TimestampOutOfRangeError, V6_TIMESTAMP_OUT_OF_RANGE
76
+ else raise random_source_failure("uuid_new_v6_batch")
77
+ end
78
+ end
79
+
80
+ def new_v7(unix_millis)
81
+ out = scratch
82
+ rc = functions[:new_v7].call(unix_millis, out)
83
+ case rc
84
+ when 0 then out[0, 16]
85
+ when 2 then raise TimestampOutOfRangeError, V7_TIMESTAMP_OUT_OF_RANGE
86
+ else raise random_source_failure("uuid_new_v7")
87
+ end
88
+ end
89
+
90
+ def v7_unix_millis(bytes)
91
+ functions[:v7_unix_millis].call(bytes)
92
+ end
93
+
94
+ def new_v7_batch(count, unix_millis)
95
+ return "" if count.zero?
96
+ out = buffer(count * 16)
97
+ rc = functions[:new_v7_batch].call(unix_millis, count, out)
98
+ case rc
99
+ when 0 then out[0, count * 16]
100
+ when 2 then raise TimestampOutOfRangeError, V7_TIMESTAMP_OUT_OF_RANGE
101
+ else raise random_source_failure("uuid_new_v7_batch")
102
+ end
103
+ end
104
+
105
+ def v7_to_sql_order(bytes)
106
+ rewrite(:v7_to_sql_order, bytes)
107
+ end
108
+
109
+ def v7_to_rfc_order(bytes)
110
+ rewrite(:v7_to_rfc_order, bytes)
111
+ end
112
+
113
+ def v6_to_sql_order(bytes)
114
+ rewrite(:v6_to_sql_order, bytes)
115
+ end
116
+
117
+ def v6_to_rfc_order(bytes)
118
+ rewrite(:v6_to_rfc_order, bytes)
119
+ end
120
+
121
+ # The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
122
+ # library's zero-argument hyperuuid_version export. HyperUuid.native_version unpacks
123
+ # it; like every method here, each backend replaces this one in place.
124
+ def packed_version
125
+ functions[:version].call
126
+ end
127
+
128
+ # The one failure every generating export shares, with the one message every backend
129
+ # raises for it: the export's name, and that the system's random source failed.
130
+ def random_source_failure(export)
131
+ RandomSourceError.new("#{export}: the system random source failed")
132
+ end
133
+
134
+ private
135
+
136
+ # The shared library to dlopen: this install's native/{rid}/{lib}, or — the
137
+ # development loop — the in-repo cargo build, exactly what the other bindings' local
138
+ # staging does (HyperCast's runtime has had this since its first release). Nil when
139
+ # neither exists.
140
+ def library_path
141
+ rid, lib_name = NativePlatform.rid_and_library_name
142
+ path = File.join(NATIVE_DIR, rid, lib_name)
143
+ return path if File.exist?(path)
144
+
145
+ repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
146
+ File.exist?(repo_build) ? repo_build : nil
147
+ end
148
+
149
+ # Why Fiddle found nothing to load. A precompiled platform gem is the one install where
150
+ # that is by design rather than a gap: it carries only its Magnus extensions, and the
151
+ # Fiddle backend is reached there only by forcing it (HYPERUUID_PURE) or because none
152
+ # of its extensions loaded — a gem RubyGems matched to a Ruby it was not built for,
153
+ # such as the glibc Linux gem that `gem install` on RubyGems 3.x picks on Alpine. So
154
+ # that case names its fix, the universal gem, which carries every platform's library,
155
+ # instead of a missing path that reads like a packaging bug. Both arguments are
156
+ # parameters only so the specs can ask for every wording.
157
+ def missing_library_message(gem_platform = Gem.loaded_specs["hyperuuid"]&.platform,
158
+ forced = ENV.key?("HYPERUUID_PURE"))
159
+ rid, lib_name = NativePlatform.rid_and_library_name
160
+ missing = File.join(NATIVE_DIR, rid, lib_name)
161
+ if gem_platform && gem_platform.to_s != Gem::Platform::RUBY
162
+ reason =
163
+ if forced
164
+ "HYPERUUID_PURE forces the Fiddle backend (unset it to use the extension)"
165
+ else
166
+ "none of its extensions loads on this Ruby (#{RUBY_VERSION}, #{RUBY_PLATFORM})"
167
+ end
168
+ "hyperuuid: this #{gem_platform} platform gem carries only Magnus extensions, no " \
169
+ "Fiddle library, and #{reason}. The universal gem has the Fiddle backend for every " \
170
+ "platform: `gem install hyperuuid --platform ruby`, or Bundler's force_ruby_platform " \
171
+ "(#{missing} not found)"
172
+ else
173
+ "hyperuuid: #{missing} not found (unsupported platform, or this gem was built " \
174
+ "without a native library for it)"
175
+ end
176
+ end
177
+
178
+ # One 16-byte scratch allocation per thread, reused by every single-item call —
179
+ # Fiddle::Pointer.malloc(..., RUBY_FREE) registers a GC finalizer per call, measured
180
+ # (in HyperCast, same mechanism) as the dominant per-call cost by an order of
181
+ # magnitude. Batches keep a per-call buffer: one malloc amortized over `count` IDs.
182
+ def scratch
183
+ Thread.current[:hyperuuid_scratch] ||= buffer(16)
184
+ end
185
+
186
+ # Every Fiddle allocation goes through here, and loads the library first: that is what
187
+ # keeps a missing library reported as missing_library_message instead of as whatever
188
+ # touching Fiddle raises first. The scratch buffer pays for it once per thread.
189
+ def buffer(size)
190
+ functions
191
+ Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
192
+ end
193
+
194
+ # The in-place byte-order rewrites are the one shape that must copy in: the native
195
+ # call genuinely mutates the buffer, and the input String is frozen.
196
+ def rewrite(symbol, bytes)
197
+ buf = scratch
198
+ buf[0, 16] = bytes
199
+ functions[symbol].call(buf)
200
+ buf[0, 16]
201
+ end
202
+
203
+ # Loaded lazily and exactly once, mirroring Java's class initializer / Swift's lazy
204
+ # static let — the native library and its function pointers live for the process's
205
+ # lifetime, same as every other binding (never dlclose'd). The unsynchronized read is
206
+ # the hot path; the mutex only guards the one-time load (a benign race — idempotent).
207
+ def functions
208
+ @functions || @mutex.synchronize { @functions ||= load_functions }
209
+ end
210
+
211
+ def load_functions
212
+ path = library_path
213
+ raise LoadError, missing_library_message if path.nil?
214
+
215
+ handle = Fiddle.dlopen(path)
216
+ {
217
+ new_v4: Fiddle::Function.new(handle["uuid_new_v4"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT),
218
+ new_v5: Fiddle::Function.new(
219
+ handle["uuid_new_v5"],
220
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP],
221
+ Fiddle::TYPE_INT
222
+ ),
223
+ new_v6: Fiddle::Function.new(
224
+ handle["uuid_new_v6"],
225
+ [Fiddle::TYPE_UINT64_T, Fiddle::TYPE_VOIDP],
226
+ Fiddle::TYPE_INT
227
+ ),
228
+ v6_unix_millis: Fiddle::Function.new(
229
+ handle["uuid_v6_unix_millis"],
230
+ [Fiddle::TYPE_VOIDP],
231
+ Fiddle::TYPE_UINT64_T
232
+ ),
233
+ new_v6_batch: Fiddle::Function.new(
234
+ handle["uuid_new_v6_batch"],
235
+ [Fiddle::TYPE_UINT64_T, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP],
236
+ Fiddle::TYPE_INT
237
+ ),
238
+ new_v7: Fiddle::Function.new(
239
+ handle["uuid_new_v7"],
240
+ [Fiddle::TYPE_UINT64_T, Fiddle::TYPE_VOIDP],
241
+ Fiddle::TYPE_INT
242
+ ),
243
+ v7_unix_millis: Fiddle::Function.new(
244
+ handle["uuid_v7_unix_millis"],
245
+ [Fiddle::TYPE_VOIDP],
246
+ Fiddle::TYPE_UINT64_T
247
+ ),
248
+ new_v7_batch: Fiddle::Function.new(
249
+ handle["uuid_new_v7_batch"],
250
+ [Fiddle::TYPE_UINT64_T, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP],
251
+ Fiddle::TYPE_INT
252
+ ),
253
+ v7_to_sql_order: Fiddle::Function.new(
254
+ handle["uuid_v7_to_sql_order"],
255
+ [Fiddle::TYPE_VOIDP],
256
+ Fiddle::TYPE_VOID
257
+ ),
258
+ v7_to_rfc_order: Fiddle::Function.new(
259
+ handle["uuid_v7_to_rfc_order"],
260
+ [Fiddle::TYPE_VOIDP],
261
+ Fiddle::TYPE_VOID
262
+ ),
263
+ v6_to_sql_order: Fiddle::Function.new(
264
+ handle["uuid_v6_to_sql_order"],
265
+ [Fiddle::TYPE_VOIDP],
266
+ Fiddle::TYPE_VOID
267
+ ),
268
+ v6_to_rfc_order: Fiddle::Function.new(
269
+ handle["uuid_v6_to_rfc_order"],
270
+ [Fiddle::TYPE_VOIDP],
271
+ Fiddle::TYPE_VOID
272
+ ),
273
+ # The one export that mints nothing: the zero-argument version probe.
274
+ version: Fiddle::Function.new(handle["hyperuuid_version"], [], Fiddle::TYPE_UINT32_T),
275
+ }
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,179 @@
1
+ module HyperUuid
2
+ # A parsed 16-byte RFC 9562 UUID value. Minimal by design — this gem has no runtime
3
+ # dependency on the `uuid` gem, the same "no extra dependency" positioning as the Go
4
+ # binding's lone google/uuid requirement and the Python binding's dependency-free PyO3
5
+ # wheels.
6
+ class Uuid
7
+ include Comparable
8
+
9
+ # The UUID's 16 raw bytes in RFC 9562 (big-endian) order.
10
+ attr_reader :bytes
11
+
12
+ # Wraps a raw 16-byte RFC 9562 (big-endian) UUID value. The bytes are copied, so the
13
+ # String passed in stays the caller's.
14
+ #
15
+ # +owned+ is for this gem's own use: it says +bytes+ is a String the native core has
16
+ # just produced — sixteen bytes, already ASCII-8BIT, held by nothing else — so there is
17
+ # nothing to check, copy or re-tag, and that copy is most of what constructing one costs.
18
+ #
19
+ # @raise [ArgumentError] if +bytes+ isn't exactly 16 bytes.
20
+ def initialize(bytes, owned = false)
21
+ if owned
22
+ @bytes = bytes.freeze
23
+ else
24
+ raise ArgumentError, "bytes must be exactly 16 bytes" unless bytes.bytesize == 16
25
+
26
+ @bytes = bytes.dup.force_encoding(Encoding::BINARY).freeze
27
+ end
28
+ end
29
+
30
+ # The RFC 9562 §5.9 Nil UUID — all 128 bits zero.
31
+ NIL = new(("\x00" * 16).b).freeze
32
+
33
+ # The RFC 9562 §5.10 Max UUID — all 128 bits one.
34
+ MAX = new(("\xFF" * 16).b).freeze
35
+
36
+ # The one text shape .parse accepts: 8-4-4-4-12 hex digits, either case, the four hyphens
37
+ # exactly where #to_s puts them.
38
+ HYPHENATED = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
39
+ private_constant :HYPHENATED
40
+
41
+ # Parses an 8-4-4-4-12 hyphenated hex UUID string — exactly the shape #to_s produces, in
42
+ # either case. Nothing else parses: not the bare 32 hex digits, not hyphens anywhere but
43
+ # those four positions, not braces or a `urn:uuid:` prefix.
44
+ #
45
+ # @raise [ArgumentError] if +string+ isn't a String in that shape.
46
+ def self.parse(string)
47
+ unless string.is_a?(String) && string.match?(HYPHENATED)
48
+ raise ArgumentError, "invalid UUID string: #{string.inspect}"
49
+ end
50
+
51
+ new([string.delete("-")].pack("H*"))
52
+ end
53
+
54
+ # The RFC 9562 version nibble (bits 48-51, the high nibble of octet 6).
55
+ def version
56
+ (bytes.getbyte(6) >> 4) & 0x0F
57
+ end
58
+
59
+ # The RFC 9562 variant bits (top two bits of octet 8). +0b10+ means RFC 9562/4122.
60
+ def variant
61
+ (bytes.getbyte(8) >> 6) & 0b11
62
+ end
63
+
64
+ # The 8-4-4-4-12 hyphenated hex string representation.
65
+ def to_s
66
+ hex = bytes.unpack1("H*")
67
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
68
+ end
69
+ alias_method :to_str, :to_s
70
+
71
+ # The UTC timestamp embedded in a version 6 or 7 UUID's timestamp field. Only meaningful
72
+ # when `version` is 6 or 7 — the RFC 9562 bit layout doesn't distinguish "not a time-based
73
+ # UUID" from "time-based UUID with a very early timestamp", so the caller is responsible
74
+ # for checking `version` first if that matters.
75
+ #
76
+ # Raises by default for any other version; pass `raise_on_mismatch: false` to get `nil`
77
+ # back instead — for a caller that doesn't already know (or want to separately check)
78
+ # whether this UUID is time-based.
79
+ def timestamp(raise_on_mismatch: true)
80
+ millis =
81
+ case version
82
+ when 6 then Runtime.v6_unix_millis(bytes)
83
+ when 7 then Runtime.v7_unix_millis(bytes)
84
+ else
85
+ raise ArgumentError, "timestamp is only defined for version 6 or 7 UUIDs, got version #{version}" if raise_on_mismatch
86
+ return nil
87
+ end
88
+ Time.at(millis / 1000, millis % 1000, :millisecond).utc
89
+ end
90
+
91
+ # Converts an RFC 9562-ordered version 6 or 7 UUID to the byte order SQL Server's
92
+ # `uniqueidentifier` needs on the wire to sort by creation order. Dispatches on `version`
93
+ # the same way #timestamp does.
94
+ #
95
+ # `System.Data.SqlTypes.SqlGuid` comparison — and therefore T-SQL `ORDER BY` on a
96
+ # `uniqueidentifier` column — doesn't compare a GUID's 16 bytes left to right; it uses a
97
+ # fixed, non-sequential byte significance order (octets 10,11,12,13,14,15,8,9,6,7,4,5,
98
+ # 0,1,2,3, most significant first). Computed once in the native Rust core and verified
99
+ # there (and independently, against the real SqlGuid comparator, in this project's C#
100
+ # test suite); this binding calls the same native functions rather than reimplementing
101
+ # the byte math.
102
+ #
103
+ # For v7, this moves the timestamp and counter — the two fields that determine creation
104
+ # order — into that comparison's most-significant bytes, and moves the trailing entropy,
105
+ # which carries no ordering information, into the least-significant ones as one intact
106
+ # block. For v6, which has no monotonic counter the way v7 does, the only field that
107
+ # determines creation order is the 60-bit timestamp itself, so that moves into the most
108
+ # significant bytes instead, with `clock_seq`/`node` (independently random per call, not
109
+ # a counter, so no ordering value either way) relocated into the rest. v6's much simpler
110
+ # byte layout needs no bit-level repacking to do this — just whole-octet-group
111
+ # relocation — unlike v7's, and its version/variant land at different sql-order offsets
112
+ # as a result (octet 8's top nibble / octet 6's top two bits, not 7/8).
113
+ #
114
+ # **v6-specific caveat, unlike v7:** two version 6 UUIDs minted at the same millisecond
115
+ # have identical timestamp bits, so they aren't guaranteed to sort in creation order any
116
+ # more than plain RFC order already does — a pre-existing RFC 9562 v6 limitation, not one
117
+ # this transform introduces.
118
+ #
119
+ # Meaningful only for a genuine version 6 or 7 UUID.
120
+ def to_sql_order
121
+ case version
122
+ when 7 then self.class.new(Runtime.v7_to_sql_order(bytes), true)
123
+ when 6 then self.class.new(Runtime.v6_to_sql_order(bytes), true)
124
+ else raise ArgumentError, "to_sql_order is only defined for version 6 or 7 UUIDs, got version #{version}"
125
+ end
126
+ end
127
+
128
+ # Inverse of #to_sql_order — converts a SQL-Server-ordered version 6 or 7 UUID back to
129
+ # RFC 9562 order.
130
+ #
131
+ # A SQL-ordered value's version nibble sits at a different octet depending on which
132
+ # version produced it (octet 7's top nibble = 7 for v7-sql-order, octet 8's top nibble =
133
+ # 6 for v6-sql-order — #version itself assumes RFC order's octet 6 and can't tell these
134
+ # apart), so this checks both fixed positions directly rather than calling #version.
135
+ #
136
+ # Order matters here and isn't arbitrary: octet 8 must be checked *first*. For v6-sql-order
137
+ # it's deterministic (top nibble always 0x6, by construction), and for v7-sql-order it's
138
+ # also deterministic but structurally excluded from ever reading 0x6 (its top two bits are
139
+ # the fixed variant `10`, so the nibble only ever lands in 0x8-0xB) — no collision either
140
+ # way. Octet 7, by contrast, is *not* safe to check first: for v7-sql-order it's
141
+ # deterministically 0x7, but for v6-sql-order it holds `clock_seq`'s fully random low
142
+ # byte, which has a real (~1-in-16) chance of a top nibble that also happens to read 0x7 —
143
+ # confirmed by an actual test failure during development, not a hypothetical. Checking
144
+ # octet 8 first rules v6 in or out unambiguously before octet 7's reading can matter.
145
+ def from_sql_order
146
+ octet8_version = (bytes.getbyte(8) >> 4) & 0x0F
147
+ octet7_version = (bytes.getbyte(7) >> 4) & 0x0F
148
+ if octet8_version == 6
149
+ self.class.new(Runtime.v6_to_rfc_order(bytes), true)
150
+ elsif octet7_version == 7
151
+ self.class.new(Runtime.v7_to_rfc_order(bytes), true)
152
+ else
153
+ raise ArgumentError, "from_sql_order: not a recognized version 6 or 7 SQL-ordered UUID"
154
+ end
155
+ end
156
+
157
+ # Whether +other+ wraps the same 16 raw bytes.
158
+ def ==(other)
159
+ other.is_a?(Uuid) && bytes == other.bytes
160
+ end
161
+ alias_method :eql?, :==
162
+
163
+ # Hash code consistent with #==, based on the raw bytes.
164
+ def hash
165
+ bytes.hash
166
+ end
167
+
168
+ # Byte-order comparison against +other+, or +nil+ if +other+ isn't a Uuid.
169
+ def <=>(other)
170
+ return nil unless other.is_a?(Uuid)
171
+ bytes <=> other.bytes
172
+ end
173
+
174
+ # Debug representation, e.g. <tt>#<HyperUuid::Uuid ...></tt>.
175
+ def inspect
176
+ "#<HyperUuid::Uuid #{self}>"
177
+ end
178
+ end
179
+ end