hyperuuid 0.3.0 → 0.5.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.
@@ -12,8 +12,18 @@ module HyperUuid
12
12
  # files are already plain files on disk once installed — native/{rid}/{lib} can be
13
13
  # dlopen'd directly, no extraction step needed.
14
14
  module Runtime
15
- class RandomSourceError < StandardError; end
16
- class TimestampOutOfRangeError < StandardError; end
15
+ # The package's two exceptions live on HyperUuid itself (errors.rb). These are the names
16
+ # they had through 0.3.0, kept as aliases of the very same classes so a
17
+ # `rescue HyperUuid::Runtime::TimestampOutOfRangeError` written against an earlier
18
+ # release still catches.
19
+ RandomSourceError = HyperUuid::RandomSourceError
20
+ TimestampOutOfRangeError = HyperUuid::TimestampOutOfRangeError
21
+
22
+ # One copy of each out-of-range message for the Fiddle and wasm backends and for the
23
+ # doors' own range check in hyperuuid.rb; the Magnus extension (rust/src/ruby_ext.rs)
24
+ # carries the same text, and spec/native_backend_spec.rb pins that the two agree.
25
+ V6_TIMESTAMP_OUT_OF_RANGE = "unix_millis does not fit the 60-bit v6 timestamp field"
26
+ V7_TIMESTAMP_OUT_OF_RANGE = "unix_millis must fit within the RFC 9562 48-bit field"
17
27
 
18
28
  NATIVE_DIR = File.join(__dir__, "native")
19
29
 
@@ -24,17 +34,18 @@ module HyperUuid
24
34
  def new_v4
25
35
  out = scratch
26
36
  rc = functions[:new_v4].call(out)
27
- raise RandomSourceError, "uuid_new_v4 failed with code #{rc}" unless rc.zero?
37
+ raise random_source_failure("uuid_new_v4") unless rc.zero?
28
38
  out[0, 16]
29
39
  end
30
40
 
31
41
  def new_v5(namespace_bytes, name_bytes)
32
42
  out = scratch
33
- # Fiddle passes a String's bytes for void* directly (read-only) — no Pointer
34
- # wrapper, no copy — the same zero-copy crossing every other input here uses.
35
- name = name_bytes.empty? ? nil : name_bytes
36
- rc = functions[:new_v5].call(namespace_bytes, name, name_bytes.bytesize, out)
37
- raise RandomSourceError, "uuid_new_v5 failed with code #{rc}" unless rc.zero?
43
+ # Fiddle passes a String's own bytes for void* (read-only, no copy of them) — the
44
+ # same crossing every other input here uses. An empty name crosses as it is, a
45
+ # valid pointer with length 0, never as nil: NULL is not part of this export's
46
+ # contract.
47
+ rc = functions[:new_v5].call(namespace_bytes, name_bytes, name_bytes.bytesize, out)
48
+ raise random_source_failure("uuid_new_v5") unless rc.zero?
38
49
  out[0, 16]
39
50
  end
40
51
 
@@ -43,8 +54,8 @@ module HyperUuid
43
54
  rc = functions[:new_v6].call(unix_millis, out)
44
55
  case rc
45
56
  when 0 then out[0, 16]
46
- when 2 then raise TimestampOutOfRangeError, "unix_millis does not fit the 60-bit v6 timestamp field"
47
- else raise RandomSourceError, "uuid_new_v6 failed with code #{rc}"
57
+ when 2 then raise TimestampOutOfRangeError, V6_TIMESTAMP_OUT_OF_RANGE
58
+ else raise random_source_failure("uuid_new_v6")
48
59
  end
49
60
  end
50
61
 
@@ -58,8 +69,8 @@ module HyperUuid
58
69
  rc = functions[:new_v6_batch].call(unix_millis, count, out)
59
70
  case rc
60
71
  when 0 then out[0, count * 16]
61
- when 2 then raise TimestampOutOfRangeError, "unix_millis does not fit the 60-bit v6 timestamp field"
62
- else raise RandomSourceError, "uuid_new_v6_batch failed with code #{rc}"
72
+ when 2 then raise TimestampOutOfRangeError, V6_TIMESTAMP_OUT_OF_RANGE
73
+ else raise random_source_failure("uuid_new_v6_batch")
63
74
  end
64
75
  end
65
76
 
@@ -68,8 +79,8 @@ module HyperUuid
68
79
  rc = functions[:new_v7].call(unix_millis, out)
69
80
  case rc
70
81
  when 0 then out[0, 16]
71
- when 2 then raise TimestampOutOfRangeError, "unix_millis must fit within the RFC 9562 48-bit field"
72
- else raise RandomSourceError, "uuid_new_v7 failed with code #{rc}"
82
+ when 2 then raise TimestampOutOfRangeError, V7_TIMESTAMP_OUT_OF_RANGE
83
+ else raise random_source_failure("uuid_new_v7")
73
84
  end
74
85
  end
75
86
 
@@ -83,8 +94,8 @@ module HyperUuid
83
94
  rc = functions[:new_v7_batch].call(unix_millis, count, out)
84
95
  case rc
85
96
  when 0 then out[0, count * 16]
86
- when 2 then raise TimestampOutOfRangeError, "unix_millis must fit within the RFC 9562 48-bit field"
87
- else raise RandomSourceError, "uuid_new_v7_batch failed with code #{rc}"
97
+ when 2 then raise TimestampOutOfRangeError, V7_TIMESTAMP_OUT_OF_RANGE
98
+ else raise random_source_failure("uuid_new_v7_batch")
88
99
  end
89
100
  end
90
101
 
@@ -104,18 +115,44 @@ module HyperUuid
104
115
  rewrite(:v6_to_rfc_order, bytes)
105
116
  end
106
117
 
118
+ # The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
119
+ # library's zero-argument hyperuuid_version export. HyperUuid.native_version unpacks
120
+ # it; like every method here, each backend replaces this one in place.
121
+ def packed_version
122
+ functions[:version].call
123
+ end
124
+
125
+ # The one failure every generating export shares, with the one message every backend
126
+ # raises for it: the export's name, and that the system's random source failed.
127
+ def random_source_failure(export)
128
+ RandomSourceError.new("#{export}: the system random source failed")
129
+ end
130
+
107
131
  # Whether this platform has a shared library for Fiddle to dlopen at all: a known RID
108
- # and the file actually present in this install. Backend selection (hyperuuid.rb)
109
- # asks this before falling back to the WebAssembly backend, which needs neither.
132
+ # and the file actually present — in this install, or in the in-repo cargo build the
133
+ # dev loop falls back to. Backend selection (hyperuuid.rb) asks this before falling
134
+ # back to the WebAssembly backend, which needs neither.
110
135
  def fiddle_library_available?
111
- rid, lib_name = NativePlatform.rid_and_library_name
112
- File.exist?(File.join(NATIVE_DIR, rid, lib_name))
136
+ !library_path.nil?
113
137
  rescue NativePlatform::UnsupportedPlatformError
114
138
  false
115
139
  end
116
140
 
117
141
  private
118
142
 
143
+ # The shared library to dlopen: this install's native/{rid}/{lib}, or — the
144
+ # development loop — the in-repo cargo build, exactly what the other bindings' local
145
+ # staging does (HyperCast's runtime has had this since its first release). Nil when
146
+ # neither exists.
147
+ def library_path
148
+ rid, lib_name = NativePlatform.rid_and_library_name
149
+ path = File.join(NATIVE_DIR, rid, lib_name)
150
+ return path if File.exist?(path)
151
+
152
+ repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
153
+ File.exist?(repo_build) ? repo_build : nil
154
+ end
155
+
119
156
  # One 16-byte scratch allocation per thread, reused by every single-item call —
120
157
  # Fiddle::Pointer.malloc(..., RUBY_FREE) registers a GC finalizer per call, measured
121
158
  # (in HyperCast, same mechanism) as the dominant per-call cost by an order of
@@ -142,11 +179,12 @@ module HyperUuid
142
179
  end
143
180
 
144
181
  def load_functions
145
- rid, lib_name = NativePlatform.rid_and_library_name
146
- path = File.join(NATIVE_DIR, rid, lib_name)
147
- unless File.exist?(path)
182
+ path = library_path
183
+ if path.nil?
184
+ rid, lib_name = NativePlatform.rid_and_library_name
148
185
  raise LoadError,
149
- "hyperuuid: #{path} not found (unsupported platform, or this gem was built without a native library for it)"
186
+ "hyperuuid: #{File.join(NATIVE_DIR, rid, lib_name)} not found (unsupported " \
187
+ "platform, or this gem was built without a native library for it)"
150
188
  end
151
189
 
152
190
  handle = Fiddle.dlopen(path)
@@ -207,6 +245,8 @@ module HyperUuid
207
245
  [Fiddle::TYPE_VOIDP],
208
246
  Fiddle::TYPE_VOID
209
247
  ),
248
+ # The one export that mints nothing: the zero-argument version probe.
249
+ version: Fiddle::Function.new(handle["hyperuuid_version"], [], Fiddle::TYPE_UINT32_T),
210
250
  }
211
251
  end
212
252
  end
@@ -9,12 +9,22 @@ module HyperUuid
9
9
  # The UUID's 16 raw bytes in RFC 9562 (big-endian) order.
10
10
  attr_reader :bytes
11
11
 
12
- # Wraps a raw 16-byte RFC 9562 (big-endian) UUID value.
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.
13
18
  #
14
19
  # @raise [ArgumentError] if +bytes+ isn't exactly 16 bytes.
15
- def initialize(bytes)
16
- raise ArgumentError, "bytes must be exactly 16 bytes" unless bytes.bytesize == 16
17
- @bytes = bytes.dup.force_encoding(Encoding::BINARY).freeze
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
18
28
  end
19
29
 
20
30
  # The RFC 9562 §5.9 Nil UUID — all 128 bits zero.
@@ -23,13 +33,22 @@ module HyperUuid
23
33
  # The RFC 9562 §5.10 Max UUID — all 128 bits one.
24
34
  MAX = new(("\xFF" * 16).b).freeze
25
35
 
26
- # Parses an 8-4-4-4-12 hyphenated hex UUID string.
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.
27
44
  #
28
- # @raise [ArgumentError] if +string+ isn't a valid UUID string.
45
+ # @raise [ArgumentError] if +string+ isn't a String in that shape.
29
46
  def self.parse(string)
30
- hex = string.delete("-")
31
- raise ArgumentError, "invalid UUID string: #{string.inspect}" unless hex.match?(/\A[0-9a-fA-F]{32}\z/)
32
- new([hex].pack("H*"))
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*"))
33
52
  end
34
53
 
35
54
  # The RFC 9562 version nibble (bits 48-51, the high nibble of octet 6).
@@ -100,8 +119,8 @@ module HyperUuid
100
119
  # Meaningful only for a genuine version 6 or 7 UUID.
101
120
  def to_sql_order
102
121
  case version
103
- when 7 then self.class.new(Runtime.v7_to_sql_order(bytes))
104
- when 6 then self.class.new(Runtime.v6_to_sql_order(bytes))
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)
105
124
  else raise ArgumentError, "to_sql_order is only defined for version 6 or 7 UUIDs, got version #{version}"
106
125
  end
107
126
  end
@@ -127,9 +146,9 @@ module HyperUuid
127
146
  octet8_version = (bytes.getbyte(8) >> 4) & 0x0F
128
147
  octet7_version = (bytes.getbyte(7) >> 4) & 0x0F
129
148
  if octet8_version == 6
130
- self.class.new(Runtime.v6_to_rfc_order(bytes))
149
+ self.class.new(Runtime.v6_to_rfc_order(bytes), true)
131
150
  elsif octet7_version == 7
132
- self.class.new(Runtime.v7_to_rfc_order(bytes))
151
+ self.class.new(Runtime.v7_to_rfc_order(bytes), true)
133
152
  else
134
153
  raise ArgumentError, "from_sql_order: not a recognized version 6 or 7 SQL-ordered UUID"
135
154
  end
@@ -10,9 +10,10 @@ module HyperUuid
10
10
  # Same integration shape as the Magnus extension: on require (after runtime.rb has defined
11
11
  # the Fiddle backend) this redefines the `HyperUuid::Runtime` singleton methods **in place**
12
12
  # — no delegation layer, no second surface. Everything above Runtime (the `Uuid` class, the
13
- # module doors, batch slicing) stays shared byte-for-byte across all three backends, and the
14
- # same `Runtime::RandomSourceError` / `Runtime::TimestampOutOfRangeError` classes carry the
15
- # same messages.
13
+ # module doors and their argument checks, batch slicing) stays shared byte-for-byte across
14
+ # all three backends, and the same `HyperUuid::RandomSourceError` /
15
+ # `HyperUuid::TimestampOutOfRangeError` classes carry the same messages — literally the
16
+ # same constants and helper runtime.rb defines for the Fiddle backend.
16
17
  #
17
18
  # Two things differ from the native backends, both forced by the sandbox:
18
19
  #
@@ -28,6 +29,11 @@ module HyperUuid
28
29
  # library does natively.
29
30
  module Runtime
30
31
  WASM_MODULE_PATH = File.join(NATIVE_DIR, "wasm32-wasip1", "hyperuuid.wasm")
32
+ # Development loop: the in-repo cargo build, the same fallback runtime.rb takes for the
33
+ # native library, so `HYPERUUID_WASM=1 bundle exec rspec` needs nothing staged by hand.
34
+ WASM_REPO_BUILD_PATH = File.expand_path(
35
+ File.join(__dir__, "../../../rust/target/wasm32-wasip1/release/hyperuuid.wasm")
36
+ )
31
37
 
32
38
  # One instantiated module: the exported functions, the exported memory, and the two
33
39
  # 16-byte scratch buffers (one in, one out) the single-item doors reuse for the life of
@@ -42,9 +48,11 @@ module HyperUuid
42
48
  uuid_new_v4 uuid_new_v5 uuid_new_v6 uuid_v6_unix_millis uuid_new_v6_batch
43
49
  uuid_new_v7 uuid_v7_unix_millis uuid_new_v7_batch
44
50
  uuid_v7_to_sql_order uuid_v7_to_rfc_order uuid_v6_to_sql_order uuid_v6_to_rfc_order
51
+ hyperuuid_version
45
52
  ].freeze
46
53
 
47
54
  def initialize(path)
55
+ path = WASM_REPO_BUILD_PATH if !File.exist?(path) && File.exist?(WASM_REPO_BUILD_PATH)
48
56
  unless File.exist?(path)
49
57
  raise LoadError,
50
58
  "hyperuuid: #{path} not found (this gem was built without its WebAssembly module)"
@@ -62,7 +70,11 @@ module HyperUuid
62
70
  instance = linker.instantiate(store, mod)
63
71
 
64
72
  @memory = instance.export("memory").to_memory
65
- @fn = EXPORTS.to_h { |name| [name, instance.export(name.to_s).to_func] }
73
+ @fn = EXPORTS.to_h do |name|
74
+ export = instance.export(name.to_s) or
75
+ raise LoadError, "hyperuuid: #{path} does not export #{name} (a module older than this binding)"
76
+ [name, export.to_func]
77
+ end
66
78
  @in = malloc(16)
67
79
  @out = malloc(16)
68
80
  end
@@ -99,7 +111,7 @@ module HyperUuid
99
111
  def new_v4
100
112
  wasm do |w|
101
113
  rc = w.fn(:uuid_new_v4).call(w.out)
102
- raise RandomSourceError, "uuid_new_v4 failed with code #{rc}" unless rc.zero?
114
+ raise random_source_failure("uuid_new_v4") unless rc.zero?
103
115
  w.memory.read(w.out, 16)
104
116
  end
105
117
  end
@@ -119,7 +131,7 @@ module HyperUuid
119
131
  w.fn(:uuid_new_v5).call(w.in, name, name_bytes.bytesize, w.out)
120
132
  end
121
133
  end
122
- raise RandomSourceError, "uuid_new_v5 failed with code #{rc}" unless rc.zero?
134
+ raise random_source_failure("uuid_new_v5") unless rc.zero?
123
135
  w.memory.read(w.out, 16)
124
136
  end
125
137
  end
@@ -129,8 +141,8 @@ module HyperUuid
129
141
  rc = w.fn(:uuid_new_v6).call(i64(unix_millis), w.out)
130
142
  case rc
131
143
  when 0 then w.memory.read(w.out, 16)
132
- when 2 then raise TimestampOutOfRangeError, "unix_millis does not fit the 60-bit v6 timestamp field"
133
- else raise RandomSourceError, "uuid_new_v6 failed with code #{rc}"
144
+ when 2 then raise TimestampOutOfRangeError, V6_TIMESTAMP_OUT_OF_RANGE
145
+ else raise random_source_failure("uuid_new_v6")
134
146
  end
135
147
  end
136
148
  end
@@ -149,8 +161,8 @@ module HyperUuid
149
161
  rc = w.fn(:uuid_new_v6_batch).call(i64(unix_millis), count, out)
150
162
  case rc
151
163
  when 0 then w.memory.read(out, count * 16)
152
- when 2 then raise TimestampOutOfRangeError, "unix_millis does not fit the 60-bit v6 timestamp field"
153
- else raise RandomSourceError, "uuid_new_v6_batch failed with code #{rc}"
164
+ when 2 then raise TimestampOutOfRangeError, V6_TIMESTAMP_OUT_OF_RANGE
165
+ else raise random_source_failure("uuid_new_v6_batch")
154
166
  end
155
167
  end
156
168
  end
@@ -161,8 +173,8 @@ module HyperUuid
161
173
  rc = w.fn(:uuid_new_v7).call(i64(unix_millis), w.out)
162
174
  case rc
163
175
  when 0 then w.memory.read(w.out, 16)
164
- when 2 then raise TimestampOutOfRangeError, "unix_millis must fit within the RFC 9562 48-bit field"
165
- else raise RandomSourceError, "uuid_new_v7 failed with code #{rc}"
176
+ when 2 then raise TimestampOutOfRangeError, V7_TIMESTAMP_OUT_OF_RANGE
177
+ else raise random_source_failure("uuid_new_v7")
166
178
  end
167
179
  end
168
180
  end
@@ -181,8 +193,8 @@ module HyperUuid
181
193
  rc = w.fn(:uuid_new_v7_batch).call(i64(unix_millis), count, out)
182
194
  case rc
183
195
  when 0 then w.memory.read(out, count * 16)
184
- when 2 then raise TimestampOutOfRangeError, "unix_millis must fit within the RFC 9562 48-bit field"
185
- else raise RandomSourceError, "uuid_new_v7_batch failed with code #{rc}"
196
+ when 2 then raise TimestampOutOfRangeError, V7_TIMESTAMP_OUT_OF_RANGE
197
+ else raise random_source_failure("uuid_new_v7_batch")
186
198
  end
187
199
  end
188
200
  end
@@ -204,6 +216,10 @@ module HyperUuid
204
216
  rewrite(:uuid_v6_to_rfc_order, bytes)
205
217
  end
206
218
 
219
+ def packed_version
220
+ wasm { |w| w.fn(:hyperuuid_version).call }
221
+ end
222
+
207
223
  private
208
224
 
209
225
  # The in-place byte-order rewrites: copy the caller's frozen bytes into the guest,
@@ -228,7 +244,9 @@ module HyperUuid
228
244
 
229
245
  # wasm has no unsigned integers: a u64 crosses as an i64. The core's own range checks
230
246
  # (48-bit v7, 60-bit v6) still see the exact value the caller passed, they just see
231
- # it through a two's-complement reinterpretation on the way in and back.
247
+ # it through a two's-complement reinterpretation on the way in and back. Exact only
248
+ # for a value that is a u64 to begin with, which the doors in hyperuuid.rb guarantee
249
+ # before any backend is called.
232
250
  def i64(value)
233
251
  value >= 2**63 ? value - 2**64 : value
234
252
  end
data/lib/hyperuuid.rb CHANGED
@@ -1,49 +1,91 @@
1
- require "time"
2
-
1
+ require_relative "hyperuuid/errors"
3
2
  require_relative "hyperuuid/uuid"
4
3
  require_relative "hyperuuid/namespaces"
5
4
  require_relative "hyperuuid/native_platform"
6
5
  require_relative "hyperuuid/runtime"
7
6
 
8
- # RFC 9562 UUID v4 (random), v5 (deterministic), v6 and v7 (time-sortable) generation, calling
9
- # directly into the native libhyperuuid shared library via Fiddle — no runtime bridge, no
10
- # extra gem dependency. Bundles a native build for every supported platform (see
11
- # HyperUuid::NativePlatform) and picks the right one at runtime, the same trick the Go/
12
- # Java bindings use since RubyGems has no per-platform native selection wired up here.
7
+ # RFC 9562 UUID v4 (random), v5 (deterministic), v6 and v7 (time-sortable) generation from one
8
+ # Rust core, reached through whichever of three backends this install can load: the core
9
+ # linked straight into a Magnus native extension (shipped precompiled in the platform gems),
10
+ # the native libhyperuuid shared library called through Fiddle (the universal gem bundles
11
+ # one per supported platform, picked by HyperUuid::NativePlatform), or the same core as a
12
+ # WebAssembly module inside the wasmtime gem. No runtime bridge in any of them, and nothing
13
+ # compiled at install time. HyperUuid::BACKEND names the one that loaded; the selection logic
14
+ # is at the bottom of this file.
15
+ #
16
+ # The doors below — with Uuid, Namespaces and the two exception classes — are the public
17
+ # surface, and are shared by every backend, argument validation included: a caller bug raises
18
+ # the same error whichever backend is live.
13
19
  module HyperUuid
14
20
  # This gem's own version — distinct from the RFC 9562 UUID *versions* (v4/v5/v6/v7) the
15
21
  # rest of this module generates.
16
- VERSION = "0.3.0"
22
+ VERSION = "0.5.0"
23
+
24
+ # The widest batch count, and the longest v5 name in bytes, the native ABI carries (a u32).
25
+ # (The millisecond count is a u64; unix_millis_from refuses anything wider.)
26
+ U32_MAX = 0xFFFF_FFFF
27
+ private_constant :U32_MAX
17
28
 
18
29
  # Creates a random UUID version 4 (RFC 9562 §5.4).
19
30
  def self.new_v4
20
- Uuid.new(Runtime.new_v4)
31
+ Uuid.new(Runtime.new_v4, true)
21
32
  end
22
33
 
23
34
  # Creates a deterministic UUID version 5 (RFC 9562 §5.5) from a namespace and a name. The
24
35
  # same (namespace, name) pair always produces the same UUID. `name` may be a text String
25
- # (encoded as UTF-8) or already-raw ASCII-8BIT bytes, which are used as-is.
36
+ # (encoded as UTF-8) or already-raw ASCII-8BIT bytes, which are used as-is; it may be empty.
37
+ #
38
+ # @raise [TypeError] if +namespace+ isn't a HyperUuid::Uuid or +name+ isn't a String.
26
39
  def self.new_v5(namespace, name)
40
+ raise TypeError, "namespace must be a HyperUuid::Uuid; got #{namespace.class}" unless namespace.is_a?(Uuid)
41
+ raise TypeError, "name must be a String; got #{name.class}" unless name.is_a?(String)
42
+
27
43
  name_bytes =
28
44
  if name.encoding == Encoding::ASCII_8BIT
29
45
  name
30
46
  else
31
47
  name.encode(Encoding::UTF_8).dup.force_encoding(Encoding::BINARY)
32
48
  end
33
- Uuid.new(Runtime.new_v5(namespace.bytes, name_bytes))
49
+ raise ArgumentError, "name must be at most #{U32_MAX} bytes" if name_bytes.bytesize > U32_MAX
50
+
51
+ Uuid.new(Runtime.new_v5(namespace.bytes, name_bytes), true)
34
52
  end
35
53
 
36
54
  # Converts +value+ to a Unix-epoch millisecond integer: +nil+ becomes the current time, a
37
55
  # +Time+ is converted exactly (via its own Rational seconds, avoiding float rounding), and
38
- # anything else (an Integer millisecond count) passes through unchanged. Shared by every
56
+ # an Integer millisecond count passes through unchanged. Shared by every
39
57
  # `new_v6`/`new_v7`/batch door below so a caller can pass either a `Time` or a raw
40
58
  # millisecond count interchangeably.
41
- private_class_method def self.unix_millis_from(value)
42
- case value
43
- when nil then Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
44
- when Time then (value.to_r * 1000).to_i
45
- else value
46
- end
59
+ #
60
+ # This is also where a caller bug is caught once, for every backend: anything else is a
61
+ # TypeError, and a count that cannot cross the ABI as a u64 at all — negative (a `Time`
62
+ # before the epoch included) or past 64 bits — is the same TimestampOutOfRangeError, with
63
+ # the same +out_of_range+ message, the core raises for a value it can see but not embed.
64
+ # Left to the backends those cases diverged: a RangeError from the extension, a wrapped
65
+ # value from Fiddle, a silently truncated one from wasm.
66
+ #
67
+ # The two common arguments go first and cheapest: no argument is the clock, which needs no
68
+ # range check, and an Integer is checked by its bit length rather than against 2**64 - 1: a
69
+ # comparison with a number that size is a bignum comparison, and cost more than the mint.
70
+ private_class_method def self.unix_millis_from(value, out_of_range)
71
+ return Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) if value.nil?
72
+
73
+ millis =
74
+ if value.is_a?(Integer) then value
75
+ elsif value.is_a?(Time) then (value.to_r * 1000).floor
76
+ else raise TypeError, "unix_millis must be a Time, an Integer or nil; got #{value.class}"
77
+ end
78
+ raise TimestampOutOfRangeError, out_of_range unless millis >= 0 && millis.bit_length <= 64
79
+
80
+ millis
81
+ end
82
+
83
+ # Checks a batch +count+ once, for every backend: an Integer the ABI's u32 can carry.
84
+ private_class_method def self.batch_count(count)
85
+ raise TypeError, "count must be an Integer; got #{count.class}" unless count.is_a?(Integer)
86
+ raise ArgumentError, "count must be between 0 and #{U32_MAX}; got #{count}" unless count.between?(0, U32_MAX)
87
+
88
+ count
47
89
  end
48
90
 
49
91
  # Creates a time-sortable UUID version 6 (RFC 9562 §5.6), a field-compatible reordering of
@@ -51,49 +93,66 @@ module HyperUuid
51
93
  # `Time` or Unix-epoch millisecond integer to embed a specific time instead. `clock_seq` and
52
94
  # `node` are randomly generated on every call — unlike version 7, there is no monotonic
53
95
  # counter, so calls within the same millisecond are not guaranteed to sort in creation order.
96
+ #
97
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
98
+ # @raise [TypeError] if +unix_millis+ isn't a Time, an Integer or nil.
54
99
  def self.new_v6(unix_millis = nil)
55
- Uuid.new(Runtime.new_v6(unix_millis_from(unix_millis)))
100
+ Uuid.new(Runtime.new_v6(unix_millis_from(unix_millis, Runtime::V6_TIMESTAMP_OUT_OF_RANGE)), true)
56
101
  end
57
102
 
58
103
  # Creates `count` time-sortable version 6 UUIDs sharing one timestamp capture — one FFI call
59
104
  # and one random-bytes fetch instead of `count` of each. Defaults to the current time; pass
60
105
  # an explicit `Time` or Unix-epoch millisecond integer to embed a specific time instead.
106
+ #
107
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
108
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
61
109
  def self.new_v6_batch(count, unix_millis = nil)
62
- bytes = Runtime.new_v6_batch(count, unix_millis_from(unix_millis))
63
- Array.new(count) { |i| Uuid.new(bytes[i * 16, 16]) }
110
+ bytes = new_v6_batch_bytes(count, unix_millis)
111
+ Array.new(count) { |i| Uuid.new(bytes[i * 16, 16], true) }
64
112
  end
65
113
 
66
114
  # Creates a time-sortable UUID version 7 (RFC 9562 §6.2). Defaults to the current time; pass
67
115
  # an explicit `Time` or Unix-epoch millisecond integer (non-negative, fitting in 48 bits) to
68
116
  # embed a specific time instead.
117
+ #
118
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 48-bit v7 field.
119
+ # @raise [TypeError] if +unix_millis+ isn't a Time, an Integer or nil.
69
120
  def self.new_v7(unix_millis = nil)
70
- Uuid.new(Runtime.new_v7(unix_millis_from(unix_millis)))
121
+ Uuid.new(Runtime.new_v7(unix_millis_from(unix_millis, Runtime::V7_TIMESTAMP_OUT_OF_RANGE)), true)
71
122
  end
72
123
 
73
124
  # Creates `count` time-sortable version 7 UUIDs sharing one timestamp capture and one
74
125
  # contiguous block of the monotonic counter — one FFI call and one random-bytes fetch
75
126
  # instead of `count` of each. Defaults to the current time; pass an explicit `Time` or
76
127
  # Unix-epoch millisecond integer to embed a specific time instead.
128
+ #
129
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
130
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 48-bit v7 field.
77
131
  def self.new_v7_batch(count, unix_millis = nil)
78
- bytes = Runtime.new_v7_batch(count, unix_millis_from(unix_millis))
79
- Array.new(count) { |i| Uuid.new(bytes[i * 16, 16]) }
132
+ bytes = new_v7_batch_bytes(count, unix_millis)
133
+ Array.new(count) { |i| Uuid.new(bytes[i * 16, 16], true) }
80
134
  end
81
135
 
82
136
  # Returns `count` version 7 UUIDs as one binary String of raw RFC 9562-ordered bytes,
83
137
  # 16 per UUID, instead of an Array of Uuid objects.
84
138
  #
85
- # Roughly 11x faster than #new_v7_batch for a 1000-UUID batch (about 35 us versus 400 us).
86
- # The difference is not the native call — that is identical — it is that #new_v7_batch then
87
- # allocates `count` Uuid objects and `count` String slices on top of it. This hands back the
88
- # bytes the native core already produced, untouched.
139
+ # Far faster than #new_v7_batch for a large batch — the README's "Bulk generation into
140
+ # bytes" section carries the measured figures. The difference is not the native call —
141
+ # that is identical — it is that #new_v7_batch then allocates `count` Uuid objects and
142
+ # their byte Strings on top of it. This hands back the bytes the native core already
143
+ # produced, untouched.
89
144
  #
90
145
  # Use it when bytes are the destination: a BYTEA/uniqueidentifier bind parameter, a wire
91
146
  # format, a bulk COPY. If you need Uuid objects, keep using #new_v7_batch — slicing this
92
147
  # String into them yourself just moves the same allocations into your own code.
93
148
  #
94
149
  # Slice it with `bytes[i * 16, 16]`, which is what #new_v7_batch does internally.
150
+ #
151
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
152
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 48-bit v7 field.
95
153
  def self.new_v7_batch_bytes(count, unix_millis = nil)
96
- Runtime.new_v7_batch(count, unix_millis_from(unix_millis))
154
+ count = batch_count(count)
155
+ Runtime.new_v7_batch(count, unix_millis_from(unix_millis, Runtime::V7_TIMESTAMP_OUT_OF_RANGE))
97
156
  end
98
157
 
99
158
  # Returns `count` version 6 UUIDs as one binary String of raw RFC 9562-ordered bytes,
@@ -103,8 +162,42 @@ module HyperUuid
103
162
  # clock_seq and node are independently random per item; unlike version 7 there is no
104
163
  # monotonic counter, so items minted in the same millisecond are not guaranteed to sort in
105
164
  # creation order.
165
+ #
166
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
167
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
106
168
  def self.new_v6_batch_bytes(count, unix_millis = nil)
107
- Runtime.new_v6_batch(count, unix_millis_from(unix_millis))
169
+ count = batch_count(count)
170
+ Runtime.new_v6_batch(count, unix_millis_from(unix_millis, Runtime::V6_TIMESTAMP_OUT_OF_RANGE))
171
+ end
172
+
173
+ # The version of the native core actually loaded, as "major.minor.patch" — read from the
174
+ # library itself (its `hyperuuid_version` export), not from this gem, so a consumer can
175
+ # prove the core behind the doors is the one this binding was built against (and name the
176
+ # mismatch against HyperUuid::VERSION when it is not). The cheapest possible probe that the
177
+ # backend resolved at all: takes nothing, mints nothing, cannot fail once the backend loads.
178
+ #
179
+ # @raise [LoadError] if no backend could be loaded for this platform.
180
+ def self.native_version
181
+ word = Runtime.packed_version
182
+ "#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
183
+ end
184
+
185
+ # Whether a backend actually loaded and exports the ABI this binding was built against —
186
+ # what a consumer with a fallback of its own (SecureRandom.uuid, say) checks before
187
+ # committing to these doors. Probed once (a native_version round trip), cached, and never
188
+ # raises: a missing shared library, an unsupported platform, an older core without the
189
+ # version export, or a wasm module the wasmtime gem cannot instantiate all answer false.
190
+ # The doors themselves keep their own behavior — the first call on an unavailable backend
191
+ # raises its precise error — and so does require-time selection under HYPERUUID_WASM=1
192
+ # without wasmtime; this only answers the question quietly.
193
+ def self.available?
194
+ return @available unless @available.nil?
195
+
196
+ @available = begin
197
+ native_version.is_a?(String)
198
+ rescue LoadError, StandardError
199
+ false
200
+ end
108
201
  end
109
202
  end
110
203
 
@@ -122,6 +215,10 @@ end
122
215
  # forces it (and fails loudly if wasmtime is missing); otherwise it is only ever chosen when
123
216
  # there is no native library for this platform at all and wasmtime happens to be available,
124
217
  # so no supported platform's behavior changes by its existence.
218
+ #
219
+ # Both variables are read for presence, not value — set to anything at all, "0" and the
220
+ # empty string included, they force their backend — and HYPERUUID_WASM wins when both are
221
+ # set.
125
222
  HyperUuid::BACKEND =
126
223
  if ENV["HYPERUUID_WASM"]
127
224
  begin
@@ -138,9 +235,12 @@ HyperUuid::BACKEND =
138
235
  # Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
139
236
  # extension per supported Ruby ABI under lib/hyperuuid/<minor>/ (see the Rakefile's
140
237
  # native:gem task for why an ABI-per-file is unavoidable — Magnus has no `abi3`
141
- # equivalent). CI's in-job staging and a local `cargo build --release --features ruby`
142
- # instead drop a single extension flat at lib/. Trying the versioned path first and the
143
- # flat one second means neither has to know the other exists.
238
+ # equivalent), and `rake native:dev` — the local dev loop — stages its own build at that
239
+ # same versioned path. CI's in-job staging additionally drops a single extension flat at
240
+ # lib/, which is also where a hand copy of `cargo ruby-ext`'s output goes (the alias only
241
+ # builds into rust/target/ruby/release/; nothing but that rake task copies it here).
242
+ # Trying the versioned path first and the flat one second means neither has to know the
243
+ # other exists.
144
244
  #
145
245
  # A miss on both is not an error: it means this Ruby/platform combination has no
146
246
  # precompiled extension, which is precisely what the Fiddle backend below is for.
@@ -157,8 +257,9 @@ HyperUuid::BACKEND =
157
257
  else
158
258
  # No shared library for this platform either. wasmtime, if the consumer has it,
159
259
  # is the only backend left that can run here; without it, stay on Fiddle so the
160
- # first call raises its own precise "not found" LoadError rather than a vaguer one
161
- # from here.
260
+ # first call raises its own precise error — a "not found" LoadError naming the
261
+ # path, or NativePlatform::UnsupportedPlatformError naming the platform — rather
262
+ # than a vaguer one from here. HyperUuid.available? is the quiet way to ask.
162
263
  begin
163
264
  require "wasmtime"
164
265
  require_relative "hyperuuid/wasm_runtime"