hyperuuid-wasm 0.7.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4acc2b5b222f7604e7b21efe1c5f958e7279a470f546bed3ad00089f033f13f
4
- data.tar.gz: 0c9349c620020edf44cf37afbfff39480d49497ade4e63c94e9549e028ce2abb
3
+ metadata.gz: 8b35d26b737a64df74f7a10783112899a0386bbd7dd7b7aa6387b4cbd5fb3c09
4
+ data.tar.gz: c37cd5f4d1bc6ad5eef8d181aa263ac61212c73f4f73640724f63e53a9797f61
5
5
  SHA512:
6
- metadata.gz: c9f72f6b99806013e5c6557189c15f542ab1a4328528b07a2992770c01d4008111e1591b36cc00f567cdd6a919d89e6e81e7fd4bad8886bcfdcef10f7b1328c9
7
- data.tar.gz: 6d6d7e0f7eecd6d4e62d6af9803090be5b76f90f3ee1d59534d409c92f5ee95b14d4c829af820485d3668108fbe2b500f0dc7385865de00f3a1315e225e22417
6
+ metadata.gz: 1580f8edd7863896d8b646db61e5918aa7efcaa22db88995e2c6aa2d4456f8dd2698e1200e214191830b984aee8fcdd56f22f5da51985a48305401bf10b84201
7
+ data.tar.gz: e5a86147bafa4f1e9bfb266656e073f135286f07750dbb930cf6a8b10e9a3b45060e5a8daf73cd6c7162ee060a75f3e4f02c3a609c63fc8b94bbb26a0a46c653
data/README.md CHANGED
@@ -4,10 +4,10 @@
4
4
  [![RubyGems](https://img.shields.io/gem/v/hyperuuid.svg)](https://rubygems.org/gems/hyperuuid)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/SkunkWerkx/HyperUuid/blob/master/LICENSE)
6
6
 
7
- **Ruby's own stdlib stops at `SecureRandom.uuid` — random v4, full stop. No v5, no v6, no v7. This gem is the whole RFC, with zero gem dependency beyond `Fiddle` (which ships with every Ruby install) — and it's faster than `SecureRandom.uuid` too.**
7
+ **Ruby's own stdlib stops at `SecureRandom.uuid` and `SecureRandom.uuid_v7` — random v4 and v7, one at a time. No v5, no batches, no timestamp read-back, no SQL Server order. This gem fills that in, with zero gem dependency beyond `Fiddle` (which ships with every Ruby install) — and it's faster than `SecureRandom.uuid` too.**
8
8
 
9
- RFC 9562 UUID v4 (random), v5 (deterministic), v6 and v7 (time-sortable) generation, with
10
- two backends sharing one public surface. Ruby 3.3 is the floor. The fast path is a native
9
+ RFC 9562 UUID v4 (random), v5 (deterministic) and v7 (time-sortable) generation, with a
10
+ version 8 SQL Server order for v7 and two backends sharing one public surface. Ruby 3.3 is the floor. The fast path is a native
11
11
  extension built with [Magnus](https://github.com/matsadler/magnus) — the Rust core linked
12
12
  directly into the Ruby VM, auto-selected when loadable — which redefines the low-level
13
13
  `Runtime` methods in place on require; everything above them (`Uuid`, the module doors and
@@ -22,12 +22,13 @@ require "hyperuuid"
22
22
 
23
23
  id = HyperUuid.new_v4
24
24
  id2 = HyperUuid.new_v5(HyperUuid::Namespaces::DNS, "example.com")
25
- id3 = HyperUuid.new_v6
26
- id4 = HyperUuid.new_v7
25
+ id3 = HyperUuid.new_v7
27
26
 
28
- id4.timestamp # recover the embedded UTC Time
29
- id4.timestamp(raise_on_mismatch: false) # nil instead of raising if id4 isn't v6/v7
30
- id4.to_sql_order # byte order SQL Server's uniqueidentifier needs to sort by creation order
27
+ id3.timestamp # recover the embedded UTC Time
28
+ id3.timestamp(raise_on_mismatch: false) # nil instead of raising if id3 isn't an RFC 9562 v7
29
+ id3.rfc?(7) # the RFC 9562 variant and version 7, the guard before trusting its fields
30
+ sql = id3.to_sql_order # a version 8 laid out so SQL Server's uniqueidentifier sorts by creation order
31
+ sql.timestamp(layout: :sql_server) # read straight from SQL order, no round trip
31
32
 
32
33
  # One native call, one random-bytes fetch, one counter reservation for the whole batch:
33
34
  batch = HyperUuid.new_v7_batch(1000)
@@ -39,9 +40,9 @@ batch = HyperUuid.new_v7_batch(1000)
39
40
  |---|---|---|
40
41
  | `new_v4` | `Uuid` | — |
41
42
  | `new_v5(namespace, name)` | `Uuid`, the same one for the same pair | a `Uuid` namespace (`Namespaces::DNS`/`URL`/`OID`/`X500`, or your own) and a `String` name — text is hashed as UTF-8, a binary String as its bytes, and it may be empty |
42
- | `new_v6(time = nil)` `new_v7(time = nil)` | `Uuid` | nothing (now), a `Time`, or an Integer of Unix-epoch milliseconds |
43
- | `new_v6_batch(count, time = nil)` `new_v7_batch(count, time = nil)` | `Array` of `Uuid` | a count, and the same time forms |
44
- | `new_v6_batch_bytes(count, time = nil)` `new_v7_batch_bytes(count, time = nil)` | one binary `String`, 16 bytes per UUID | as the batch doors — see [Bulk generation into bytes](#bulk-generation-into-bytes) |
43
+ | `new_v7(time = nil)` | `Uuid` | nothing (now), a `Time`, or an Integer of Unix-epoch milliseconds |
44
+ | `new_v7_batch(count, time = nil)` | `Array` of `Uuid` | a count (at most `MAX_V7_BATCH`), and the same time forms |
45
+ | `new_v7_batch_bytes(count, time = nil)` | one binary `String`, 16 bytes per UUID | as `new_v7_batch` — see [Bulk generation into bytes](#bulk-generation-into-bytes) |
45
46
  | `native_version` | the loaded core's `"major.minor.patch"` | — |
46
47
  | `available?` | `true`/`false`, never raising | — |
47
48
 
@@ -53,29 +54,94 @@ runtime dependency on the `uuid` gem:
53
54
  | `Uuid.parse(text)` | the 8-4-4-4-12 hyphenated form `#to_s` produces, either case, and nothing else — an `ArgumentError` otherwise |
54
55
  | `Uuid.new(bytes)` | wraps 16 raw RFC 9562-ordered bytes |
55
56
  | `Uuid::NIL` `Uuid::MAX` | the RFC 9562 §5.9/§5.10 special values |
56
- | `#bytes` `#to_s` `#version` `#variant` | the frozen binary String, the hyphenated text, the version nibble, the variant bits |
57
- | `#timestamp(raise_on_mismatch: true)` | the UTC `Time` embedded in a version 6 or 7 UUID; `nil` instead of an `ArgumentError` for any other version when passed `false` |
58
- | `#to_sql_order` `#from_sql_order` | to and from the byte order SQL Server's `uniqueidentifier` sorts by |
57
+ | `#bytes` `#to_s` | the frozen binary String, the hyphenated text |
58
+ | `#version(layout: :rfc9562)` | the version in that layout: the nibble, 0-15, in RFC order; in `:sql_server`, 7, the version a SQL-ordered value was converted from, and 0 when the bytes aren't one |
59
+ | `#variant` | the RFC 9562 §4.1 variant, as a Symbol: `:rfc9562` (every UUID this gem mints, and the only variant with versions), `:ncs` (Nil among them), `:microsoft`, `:future` (Max among them) |
60
+ | `#rfc?(version, layout: :rfc9562)` | whether it is an RFC 9562 UUID of that version: the variant and the version together. A version outside 0-15 is `false` |
61
+ | `#timestamp(raise_on_mismatch: true, layout: :rfc9562)` | the UTC `Time` embedded in an RFC 9562 version 7 UUID held in that layout, in one native call that checks the variant as well as the version (a 7 nibble under another variant has no timestamp, and nor does a v1 or v6 minted elsewhere); `nil` instead of an `ArgumentError` for anything that isn't one when passed `false` |
62
+ | `#to_sql_order` `#from_sql_order` | to and from the layout SQL Server's `uniqueidentifier` sorts by, refusing what doesn't fit it |
59
63
  | `==` `eql?` `hash` `<=>` | value equality and byte-order comparison (`Comparable`) |
60
64
 
61
- `#to_sql_order`/`#from_sql_order` convert a version 6 or 7 UUID to and from the byte order SQL
62
- Server's `uniqueidentifier` needs on the wire to sort by creation order (`#to_sql_order`
63
- dispatches on the UUID's own version, matching `#timestamp`'s convention) — computed once in
64
- the native Rust core rather than reimplemented in Ruby, and verified there (and independently
65
- against the real `System.Data.SqlTypes.SqlGuid` comparator in the C# binding's test suite).
66
- Same-millisecond v6 UUIDs aren't guaranteed to sort correctly afterward — v6 has no counter,
67
- so `clock_seq`/`node` (not the timestamp) decide ties, the same pre-existing RFC 9562 v6
68
- limitation plain order already has. `#from_sql_order` figures out which version to invert by
69
- checking a byte position that's provably collision-free between the two (see the method's own
70
- doc comment).
65
+ `#to_sql_order`/`#from_sql_order` convert a version 7 UUID to and from the layout SQL Server's
66
+ `uniqueidentifier` needs on the wire to sort by creation order — computed once in the native
67
+ Rust core rather than reimplemented in Ruby, and verified there (and independently against the
68
+ real `System.Data.SqlTypes.SqlGuid` comparator in the C# binding's test suite). The converted
69
+ value is an RFC 9562 version 8: the 8 and the RFC variant sit where SQL Server's text form and
70
+ a .NET `Guid` show them, and the v7's 122 other bits (timestamp, counter, random) fill the rest
71
+ in the order SQL Server compares them. Every v7 converts, and the conversion back is exact.
72
+ Either direction refuses a value of the wrong kind with an `ArgumentError`: anything that isn't
73
+ an RFC 9562 v7 for `#to_sql_order`, anything without the layout's 8 and variant for
74
+ `#from_sql_order`.
75
+
76
+ ### Layouts
77
+
78
+ `#version`, `#rfc?` and `#timestamp` take the byte order the value is held in as `layout:`, a
79
+ Symbol: `:rfc9562` (the default, what every other method takes and returns) or `:sql_server`
80
+ (what `#to_sql_order` returns, a version 8 holding a converted v7). `Uuid::LAYOUTS` lists them.
81
+ Anything else is an `ArgumentError`, never a guess. With the default, `#version` and `#rfc?`
82
+ read the value as RFC 9562 order, which looks at the wrong bytes of a SQL-ordered value (one
83
+ from `#to_sql_order`, or a `uniqueidentifier` read back from SQL Server), so pass
84
+ `layout: :sql_server` at every call site that can see one. There is deliberately no
85
+ layout-agnostic check: whoever holds the value knows its order.
86
+
87
+ `#variant` reads RFC 9562 order only and has no layout form. The SQL Server layout keeps the
88
+ variant in the same place, so a SQL-ordered value reads `:rfc9562` there, but that says nothing
89
+ about whether it holds a converted v7; to validate one, or a `uniqueidentifier` read-back,
90
+ `#rfc?(7, layout: :sql_server)` checks the layout's 8 and variant.
91
+
92
+ ```ruby
93
+ sql = HyperUuid.new_v7.to_sql_order
94
+ sql.version(layout: :sql_server) # => 7
95
+ sql.rfc?(7, layout: :sql_server) # => true
96
+ sql.timestamp(layout: :sql_server) # the same Time as sql.from_sql_order.timestamp
97
+ ```
98
+
99
+ The bytes alone cannot say which order they are in, so the layout is yours to know: an
100
+ RFC-ordered value can happen to carry the layout's 8 and variant (a random v4 does one time in
101
+ 16), and `#from_sql_order` then converts it rather than refusing it. The same goes the other
102
+ way: a value already in SQL order carries a 7 where `#to_sql_order` looks for the version about
103
+ one time in 16, and is then converted a second time rather than refused.
104
+
105
+ ### Binding to SQL Server
106
+
107
+ Send `#to_sql_order`'s result as its raw `#bytes`, not as UUID text. SQL Server stores a UUID
108
+ string's first three groups byte-reversed, which loses the creation order
109
+ ([details](https://github.com/SkunkWerkx/HyperUuid#sql-server-ordering)); tiny_tds 3.4 and an ActiveRecord `:uuid` attribute both send text. A binary
110
+ literal lands byte for byte:
111
+
112
+ ```ruby
113
+ sql = HyperUuid.new_v7.to_sql_order
114
+ client.execute("INSERT INTO orders (id) VALUES (0x#{sql.bytes.unpack1('H*')})").do
115
+
116
+ row = client.execute("SELECT CAST(id AS binary(16)) AS id FROM orders").first
117
+ sql = HyperUuid::Uuid.new(row["id"]) # read with layout: :sql_server
118
+ ```
119
+
120
+ In ActiveRecord, map the column as `:binary`, which quotes as `0x…`. Reading needs the `CAST`
121
+ too: tiny_tds returns a `uniqueidentifier` as text with those groups reversed.
122
+
123
+ ### The version 7 batch limit
124
+
125
+ `HyperUuid::MAX_V7_BATCH` is 67,108,864 (2²⁶), the size of the counter that orders version 7
126
+ UUIDs within a millisecond, and the most one `new_v7_batch` or `new_v7_batch_bytes` call
127
+ mints. A larger count is an `ArgumentError` naming the limit, raised before anything is
128
+ allocated. Every batch is in strictly increasing order: the counter is one process-wide
129
+ sequence, so a batch can straddle the point where it wraps back to 0, and the UUIDs from there
130
+ on carry a timestamp one millisecond later than the one supplied rather than sorting before the
131
+ ones ahead of them.
132
+
133
+ That roll-forward orders one batch, not the stream. The next batch or single `new_v7` in the
134
+ same real millisecond starts its counter just past the wrap and carries the supplied timestamp,
135
+ so it sorts before the previous batch's tail, which was stamped a millisecond later. Two single
136
+ `new_v7` calls either side of the wrap in one millisecond sort in reverse the same way. It
137
+ happens at most once per 2²⁶ UUIDs the process mints.
71
138
 
72
139
  ### Errors
73
140
 
74
141
  Two exceptions are this gem's own, and both carry the same message on every backend:
75
142
 
76
- - `HyperUuid::TimestampOutOfRangeError` — the time cannot be embedded: past version 7's
77
- 48-bit millisecond field, past version 6's 60-bit one, or negative (a `Time` before the Unix
78
- epoch included).
143
+ - `HyperUuid::TimestampOutOfRangeError` — the time cannot be embedded in a version 7: past
144
+ its 48-bit millisecond field, or negative (a `Time` before the Unix epoch included).
79
145
  - `HyperUuid::RandomSourceError` — the operating system's random source failed.
80
146
 
81
147
  Through 0.3.0 these were reachable only as `HyperUuid::Runtime::TimestampOutOfRangeError` and
@@ -84,8 +150,11 @@ Through 0.3.0 these were reachable only as `HyperUuid::Runtime::TimestampOutOfRa
84
150
  Everything else is a caller bug and raises Ruby's own error for it, checked once in the shared
85
151
  doors rather than left to whichever backend is live: a `TypeError` for a time that is not a
86
152
  `Time`, an Integer or `nil`, for a count that is not an Integer, for a namespace that is not a
87
- `Uuid` or a name that is not a `String`; an `ArgumentError` for a count outside
88
- 0..2³² − 1.
153
+ `Uuid` or a name that is not a `String`, for a version passed to `#rfc?` that is not an
154
+ Integer; an `ArgumentError` for a count outside 0..`MAX_V7_BATCH`, for a layout other than
155
+ `:rfc9562`/`:sql_server`, and for a batch too large for the platform to address (a 32-bit
156
+ target such as ruby.wasm). The SQL Server conversions' one refusal (a value of the wrong
157
+ version) comes from the core, as an `ArgumentError` worded the same on both backends.
89
158
 
90
159
  ### Is the native core there?
91
160
 
@@ -93,6 +162,8 @@ doors rather than left to whichever backend is live: a `TypeError` for a time th
93
162
  raising — for a consumer with a fallback of its own:
94
163
 
95
164
  ```ruby
165
+ require "securerandom"
166
+
96
167
  id = HyperUuid.available? ? HyperUuid.new_v7.to_s : SecureRandom.uuid
97
168
  ```
98
169
 
@@ -105,18 +176,18 @@ looked for, or `HyperUuid::NativePlatform::UnsupportedPlatformError` naming the
105
176
 
106
177
  ## Why not `SecureRandom.uuid`?
107
178
 
108
- `SecureRandom.uuid` only ever gives you a random v4 UUID — Ruby's stdlib has no built-in v5, v6, or v7 at all. If you need more than that, the choice is really "which gem":
179
+ `SecureRandom.uuid` gives you a random v4 UUID and `SecureRandom.uuid_v7` a v7 (Ruby 3.3 and later, so every Ruby this gem supports). Ruby's stdlib has no v5, nothing to read a timestamp back out, no batch form, and no SQL Server ordering. If you need more than that, the choice is really "which gem":
109
180
 
110
- 1. **Full RFC 9562 coverage, one gem, zero extra dependency.** v4/v5/v6/v7 plus batch generation plus `Nil`/`Max`, and nothing added to your `Gemfile.lock` beyond `Fiddle` — Ruby's own bundled FFI layer, not a third-party C extension to compile — and on the precompiled platform gems not even that.
181
+ 1. **The RFC 9562 versions new systems use, one gem, zero extra dependency.** v4/v5/v7 plus batch generation plus `Nil`/`Max`, and nothing added to your `Gemfile.lock` beyond `Fiddle` — Ruby's own bundled FFI layer, not a third-party C extension to compile — and on the precompiled platform gems not even that.
111
182
  2. **No native-extension compile step.** Third-party UUID gems that go beyond v4 are typically pure Ruby or wrap a C extension compiled at install time; this gem ships its fast path as a prebuilt platform-gem extension and its fallback as a `dlopen`ed prebuilt library — either way, the gem itself compiles nothing at install time ([Install](#install) has the one caveat, which is Fiddle's own).
112
183
  3. **Batch generation.** `new_v7_batch(1000)` shares one timestamp capture, one random-bytes fetch, and one counter reservation across the whole batch instead of paying per-item overhead a thousand times over.
113
184
  4. **Cross-language consistency.** The same Rust core mints v5 namespace UUIDs for Python, Go, C#, and every other binding in this repo — verified in CI to match Python's own `uuid.uuid5` byte-for-byte. If your system isn't Ruby-only, no Ruby-only gem can offer that.
114
185
 
115
- The honest trade-off: this gem is native code, not pure Ruby — a precompiled extension in each platform gem, and on the universal gem a platform-specific `libhyperuuid.so`/`.dylib`/`.dll` loaded through Fiddle — so it runs only where one of those was built. If plain v4 randomness is all you need, `SecureRandom.uuid` is simpler and already in stdlib — that's a completely reasonable choice.
186
+ The honest trade-off: this gem is native code, not pure Ruby — a precompiled extension in each platform gem, and on the universal gem a platform-specific `libhyperuuid.so`/`.dylib`/`.dll` loaded through Fiddle — so it runs only where one of those was built. If one v4 or v7 at a time is all you need, `SecureRandom.uuid` and `SecureRandom.uuid_v7` are simpler and already in stdlib — that's a completely reasonable choice.
116
187
 
117
188
  ## Bulk generation into bytes
118
189
 
119
- `new_v6_batch_bytes` and `new_v7_batch_bytes` return the batch as one binary `String` of raw RFC 9562-ordered bytes — 16 per UUID — instead of an Array of `Uuid` objects:
190
+ `new_v7_batch_bytes` returns the batch as one binary `String` of raw RFC 9562-ordered bytes — 16 per UUID — instead of an Array of `Uuid` objects:
120
191
 
121
192
  ```ruby
122
193
  bytes = HyperUuid.new_v7_batch_bytes(1000)
@@ -141,9 +212,7 @@ Real numbers, `benchmark-ips` on Ruby 4.0.7, linux-x64 on an Intel Core i9-11900
141
212
  |---|---:|---:|
142
213
  | `SecureRandom.uuid` | 943,917 | baseline |
143
214
  | `HyperUuid.new_v4` | 5,236,000 | **5.5x faster** |
144
- | `HyperUuid.new_v6` (explicit ms) | 3,811,000 | **4.0x faster** |
145
215
  | `HyperUuid.new_v7` (explicit ms) | 3,785,000 | **4.0x faster** |
146
- | `HyperUuid.new_v6` (current time) | 3,677,000 | **3.9x faster** |
147
216
  | `HyperUuid.new_v7` (current time) | 3,574,000 | **3.8x faster** |
148
217
  | `HyperUuid.new_v5` | 1,942,000 | 2.1x faster |
149
218
 
@@ -151,18 +220,16 @@ A `HyperUuid.new_v4` — real entropy, correct version/variant bits, minted by t
151
220
 
152
221
  The "current time" rows deserve a footnote, because they will not look like this everywhere. Here they land beside the explicit-ms rows: the only difference between the two is one `Process.clock_gettime(CLOCK_REALTIME)` wall-clock read, and on this machine that read is too cheap to see. On a machine with a slow clock it is the whole story — where a virtualized clock defeats the vDSO fast path the same read costs ~1µs, which puts both current-time rows at parity with `SecureRandom.uuid` while the explicit-ms rows stay well ahead of it. `SecureRandom.uuid` never reads a clock — random v4 is the only thing it does. If your clock is slow and you are minting many, read it once and pass the timestamp, or use the batch doors.
153
222
 
154
- The Fiddle fallback (`HYPERUUID_PURE=1`, and any platform without a prebuilt extension) keeps its own diet — a reused thread-local scratch buffer instead of two GC-finalizer-registering mallocs per call, zero-copy `String` passes for read-only inputs, an unsynchronized fast path past the load mutex — landing at 1.16x slower than `SecureRandom.uuid` for v4 (1.24 µs against 1.07 µs in its own run) and about 1.3x slower for v6/v7, with the same structural story as before: `Fiddle`'s interpreted marshalling is the floor, and the batch doors are how you amortize it.
223
+ The Fiddle fallback (`HYPERUUID_PURE=1`, and any platform without a prebuilt extension) keeps its own diet — a reused thread-local scratch buffer instead of two GC-finalizer-registering mallocs per call, zero-copy `String` passes for read-only inputs, an unsynchronized fast path past the load mutex — landing at 1.16x slower than `SecureRandom.uuid` for v4 (1.24 µs against 1.07 µs in its own run) and about 1.3x slower for v7, with the same structural story as before: `Fiddle`'s interpreted marshalling is the floor, and the batch doors are how you amortize it.
155
224
 
156
225
  Batch generation still amortizes per-call cost on both backends — one native call for the whole batch:
157
226
 
158
227
  | Call | i/s (Magnus backend) |
159
228
  |---|---:|
160
- | `new_v6` × 1000 (individual) | 3,761 |
161
- | `new_v6_batch(1000)` | 4,857 (**1.3x**) |
162
229
  | `new_v7` × 1000 (individual) | 3,742 |
163
230
  | `new_v7_batch(1000)` | 4,850 (**1.3x**) |
164
231
 
165
- The multiplier is small on this backend for the best reason available: the individual calls are cheap, so there is little waste left to amortize, and what `new_v7_batch` spends its 206 µs on is building a thousand `Uuid` objects — the byte form above does the same native work in 5 µs. On the Fiddle backend, where each call costs 1.4 µs, the same batch is 6.7x the loop. If you need v5/v6/v7, need many at once, or need this Ruby service's IDs to agree byte-for-byte with a Go or Python service's, that's what this gem is for — and now it's the fast option too, not just the capable one.
232
+ The multiplier is small on this backend for the best reason available: the individual calls are cheap, so there is little waste left to amortize, and what `new_v7_batch` spends its 206 µs on is building a thousand `Uuid` objects — the byte form above does the same native work in 5 µs. On the Fiddle backend, where each call costs 1.4 µs, the same batch is 6.7x the loop. If you need v5/v7, need many at once, or need this Ruby service's IDs to agree byte-for-byte with a Go or Python service's, that's what this gem is for — and now it's the fast option too, not just the capable one.
166
233
 
167
234
  ## Backends
168
235
 
@@ -248,8 +315,7 @@ one at `require` time:
248
315
  | 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | platform |
249
316
  | 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
250
317
 
251
- What stands behind each cell: CI runs the whole suite through each Magnus extension on every
252
- push — Ruby 3.4 and 4.0 on all seven platform-gem platforms, the two musl ones inside each
318
+ What stands behind each cell: CI runs the whole suite through each Magnus extension — Ruby 4.0 on every pull request and Ruby 3.4 in the weekly and release builds, on all seven platform-gem platforms, the two musl ones inside each
253
319
  Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on every one of them, plus on Ruby
254
320
  3.3 inside Alpine. Intel macOS has no CI leg: its library is cross-built and tested at the
255
321
  core, and Ruby there runs the universal gem's Fiddle backend over it. Anywhere else — a platform with no native build at
@@ -326,7 +392,7 @@ bundle exec rbwasm build --ruby-version 4.0 -o ruby.wasm
326
392
  Load `ruby.wasm` with [`@ruby/wasm-wasi`](https://www.npmjs.com/package/@ruby/wasm-wasi)
327
393
  (`DefaultRubyVM` in a browser, `RubyVM.instantiateModule` under Node), then
328
394
  `require "/bundle/setup"` and `require "hyperuuid"` as anywhere else. `HyperUuid::BACKEND` is
329
- `:native`: every door, the batch and raw-bytes forms, and `new_v6`/`new_v7` with no argument,
395
+ `:native`: every door, the batch and raw-bytes forms, and `new_v7` with no argument,
330
396
  which read the clock through WASI.
331
397
 
332
398
  - **Ruby 3.4 and 4.0**, one archive each, the same minors the platform gems cover, picked by
@@ -367,6 +433,7 @@ bundle install
367
433
  bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
368
434
  bundle exec rspec # BACKEND == :native
369
435
  HYPERUUID_PURE=1 bundle exec rspec # BACKEND == :fiddle
436
+ HYPERUUID_BIG_BATCH=1 bundle exec rspec spec/inspection_spec.rb # adds the 1 GiB MAX_V7_BATCH batch
370
437
  bundle exec rake docs:check # every public object carries a doc comment
371
438
  ruby benchmark/uuid_benchmark.rb # prints the backend it measured
372
439
  ```
@@ -393,11 +460,12 @@ flags in the forge's `build-magnus.sh`, which is also what CI uses on every plat
393
460
 
394
461
  The musl build has no host to run on outside a container. With the core built for musl at
395
462
  `rust/target/musl/linux-musl-x64/libhyperuuid.so`, this runs the Fiddle suite against it on
396
- Alpine, with the checkout mounted read-only:
463
+ Alpine, with the checkout mounted read-only (`corpus/` comes along: `spec/corpus_spec.rb` finds
464
+ it by walking up from the spec):
397
465
 
398
466
  ```sh
399
467
  docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
400
- mkdir /work && cp -r /src/ruby /work/ruby
468
+ mkdir /work && cp -r /src/ruby /src/corpus /work/
401
469
  mkdir -p /work/ruby/lib/hyperuuid/native/linux-musl-x64
402
470
  cp /src/rust/target/musl/linux-musl-x64/libhyperuuid.so /work/ruby/lib/hyperuuid/native/linux-musl-x64/
403
471
  cd /work/ruby && rm -f Gemfile.lock
@@ -411,7 +479,7 @@ Fiddle that Ruby 3.3 ships:
411
479
 
412
480
  ```sh
413
481
  docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
414
- mkdir /work && cp -r /src/ruby /work/ruby
482
+ mkdir /work && cp -r /src/ruby /src/corpus /work/
415
483
  mkdir -p /work/ruby/lib/hyperuuid/native/linux-musl-x64
416
484
  cp /src/rust/target/musl/linux-musl-x64/libhyperuuid.so /work/ruby/lib/hyperuuid/native/linux-musl-x64/
417
485
  cd /work/ruby && rm -f Gemfile Gemfile.lock
@@ -1,12 +1,11 @@
1
1
  module HyperUuid
2
2
  # Raised when the operating system's random source fails while a UUID is being minted —
3
- # the one way `new_v4`, `new_v6`, `new_v7` and their batch forms can fail once their
3
+ # the one way `new_v4`, `new_v7` and its batch forms can fail once their
4
4
  # arguments are valid. Carries the same message on every backend.
5
5
  class RandomSourceError < StandardError; end
6
6
 
7
- # Raised when a timestamp cannot be embedded in the UUID being minted: a version 7 value
8
- # past RFC 9562's 48-bit millisecond field, a version 6 value past its 60-bit field, or
9
- # any negative one (a `Time` before the Unix epoch included). Carries the same message on
10
- # every backend.
7
+ # Raised when a timestamp cannot be embedded in the version 7 UUID being minted: one past
8
+ # RFC 9562's 48-bit millisecond field, or a negative one (a `Time` before the Unix epoch
9
+ # included). Carries the same message on every backend.
11
10
  class TimestampOutOfRangeError < StandardError; end
12
11
  end
@@ -22,12 +22,27 @@ module HyperUuid
22
22
  RandomSourceError = HyperUuid::RandomSourceError
23
23
  TimestampOutOfRangeError = HyperUuid::TimestampOutOfRangeError
24
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"
25
+ # One copy of the out-of-range message for the Fiddle backend and for the doors' own
26
+ # range check in hyperuuid.rb; the Magnus extension (rust/src/ruby_ext.rs) carries the
27
+ # same text, and spec/native_backend_spec.rb pins that the two agree.
29
28
  V7_TIMESTAMP_OUT_OF_RANGE = "unix_millis must fit within the RFC 9562 48-bit field"
30
29
 
30
+ # The two batch-size refusals, shared the same way: a version 7 batch past the 26-bit
31
+ # counter space (the core's return code 4, which the doors in hyperuuid.rb refuse before
32
+ # either backend sees it), and a batch whose byte length the platform cannot address
33
+ # (code 3, reachable only on a 32-bit target such as ruby.wasm). Both are argument
34
+ # errors, never a random source failure.
35
+ V7_BATCH_TOO_LARGE = "a single version 7 batch takes at most 67108864 UUIDs (the 26-bit counter space)"
36
+ BATCH_TOO_LARGE_TO_ADDRESS = "the batch is too large to address on this platform"
37
+
38
+ # The SQL Server order conversions' one refusal, in the core's own words (the Magnus
39
+ # extension raises its SqlOrderError's text): a value that isn't a UUID of the
40
+ # conversion's version (code 5), an ArgumentError. Every version 7 converts.
41
+ SQL_ORDER_WRONG_VERSION = "the value is not a UUID of the version this conversion takes"
42
+
43
+ # The core's v7::MAX_BATCH, which hyperuuid.rb publishes as HyperUuid::MAX_V7_BATCH.
44
+ MAX_V7_BATCH = 1 << 26
45
+
31
46
  NATIVE_DIR = File.join(__dir__, "native")
32
47
 
33
48
  @mutex = Mutex.new
@@ -54,32 +69,6 @@ module HyperUuid
54
69
  out[0, 16]
55
70
  end
56
71
 
57
- def new_v6(unix_millis)
58
- out = scratch
59
- rc = functions[:new_v6].call(unix_millis, out)
60
- case rc
61
- when 0 then out[0, 16]
62
- when 2 then raise TimestampOutOfRangeError, V6_TIMESTAMP_OUT_OF_RANGE
63
- else raise random_source_failure("uuid_new_v6")
64
- end
65
- end
66
-
67
- def v6_unix_millis(bytes)
68
- functions[:v6_unix_millis].call(bytes)
69
- end
70
-
71
- def new_v6_batch(count, unix_millis)
72
- return "" if count.zero?
73
-
74
- out = buffer(count * 16)
75
- rc = functions[:new_v6_batch].call(unix_millis, count, out)
76
- case rc
77
- when 0 then out[0, count * 16]
78
- when 2 then raise TimestampOutOfRangeError, V6_TIMESTAMP_OUT_OF_RANGE
79
- else raise random_source_failure("uuid_new_v6_batch")
80
- end
81
- end
82
-
83
72
  def new_v7(unix_millis)
84
73
  out = scratch
85
74
  rc = functions[:new_v7].call(unix_millis, out)
@@ -96,37 +85,65 @@ module HyperUuid
96
85
 
97
86
  def new_v7_batch(count, unix_millis)
98
87
  return "" if count.zero?
88
+ # Before the buffer: a count past the counter space would otherwise allocate up to
89
+ # 64 GiB only for the core to refuse it with code 4.
90
+ raise ArgumentError, V7_BATCH_TOO_LARGE if count > MAX_V7_BATCH
99
91
 
100
92
  out = buffer(count * 16)
101
93
  rc = functions[:new_v7_batch].call(unix_millis, count, out)
102
94
  case rc
103
95
  when 0 then out[0, count * 16]
104
96
  when 2 then raise TimestampOutOfRangeError, V7_TIMESTAMP_OUT_OF_RANGE
97
+ when 3 then raise ArgumentError, BATCH_TOO_LARGE_TO_ADDRESS
98
+ when 4 then raise ArgumentError, V7_BATCH_TOO_LARGE
105
99
  else raise random_source_failure("uuid_new_v7_batch")
106
100
  end
107
101
  end
108
102
 
109
- def v7_to_sql_order(bytes)
110
- rewrite(:v7_to_sql_order, bytes)
103
+ # The inspection exports. +layout+ is the core's layout code (1 RFC 9562, 2 SQL Server),
104
+ # which Uuid has already validated; +bytes+ are 16, held in that layout's order.
105
+ def version(bytes, layout)
106
+ functions[:version].call(bytes, layout)
111
107
  end
112
108
 
113
- def v7_to_rfc_order(bytes)
114
- rewrite(:v7_to_rfc_order, bytes)
109
+ # The core's variant code: 1 NCS, 2 RFC 9562, 3 Microsoft, 4 future (Uuid#variant maps
110
+ # it to a Symbol, for both backends).
111
+ def variant(bytes)
112
+ functions[:variant].call(bytes)
113
+ end
114
+
115
+ # +version+ is 0-15 here: Uuid#rfc? answers false for anything else without asking.
116
+ def is_rfc(bytes, version, layout)
117
+ functions[:is_rfc].call(bytes, version, layout) == 1
118
+ end
119
+
120
+ # The Unix ms of an RFC 9562 version 7 UUID held in +layout+'s order, or nil for
121
+ # anything else — another version (a version 6 or 1 included), another variant. One
122
+ # core call; the version it answers with is not needed above Runtime, so it is dropped
123
+ # here.
124
+ def get_timestamp(bytes, layout)
125
+ out = millis_scratch
126
+ version = functions[:get_timestamp].call(bytes, layout, out)
127
+ version.zero? ? nil : out[0, 8].unpack1("Q")
115
128
  end
116
129
 
117
- def v6_to_sql_order(bytes)
118
- rewrite(:v6_to_sql_order, bytes)
130
+ def v7_unix_millis_in(bytes, layout)
131
+ functions[:v7_unix_millis_in].call(bytes, layout)
119
132
  end
120
133
 
121
- def v6_to_rfc_order(bytes)
122
- rewrite(:v6_to_rfc_order, bytes)
134
+ def v7_to_sql_order(bytes)
135
+ rewrite(:v7_to_sql_order, bytes)
136
+ end
137
+
138
+ def v7_to_rfc_order(bytes)
139
+ rewrite(:v7_to_rfc_order, bytes)
123
140
  end
124
141
 
125
142
  # The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
126
143
  # library's zero-argument hyperuuid_version export. HyperUuid.native_version unpacks
127
144
  # it; like every method here, each backend replaces this one in place.
128
145
  def packed_version
129
- functions[:version].call
146
+ functions[:packed_version].call
130
147
  end
131
148
 
132
149
  # The one failure every generating export shares, with the one message every backend
@@ -187,6 +204,12 @@ module HyperUuid
187
204
  Thread.current[:hyperuuid_scratch] ||= buffer(16)
188
205
  end
189
206
 
207
+ # The u64 get_timestamp writes, per thread for the same reason as #scratch, and apart from
208
+ # it so the UUID bytes passed in are never the buffer being written.
209
+ def millis_scratch
210
+ Thread.current[:hyperuuid_millis_scratch] ||= buffer(8)
211
+ end
212
+
190
213
  # Every Fiddle allocation goes through here, and loads the library first: that is what
191
214
  # keeps a missing library reported as missing_library_message instead of as whatever
192
215
  # touching Fiddle raises first. The scratch buffer pays for it once per thread.
@@ -196,12 +219,16 @@ module HyperUuid
196
219
  end
197
220
 
198
221
  # The in-place byte-order rewrites are the one shape that must copy in: the native
199
- # call genuinely mutates the buffer, and the input String is frozen.
222
+ # call genuinely mutates the buffer, and the input String is frozen. A refusal leaves
223
+ # the buffer as it was and raises what the Magnus extension raises for it.
200
224
  def rewrite(symbol, bytes)
201
225
  buf = scratch
202
226
  buf[0, 16] = bytes
203
- functions[symbol].call(buf)
204
- buf[0, 16]
227
+ case (rc = functions[symbol].call(buf))
228
+ when 0 then buf[0, 16]
229
+ when 5 then raise ArgumentError, SQL_ORDER_WRONG_VERSION
230
+ else raise "uuid_#{symbol}: unexpected return code #{rc}"
231
+ end
205
232
  end
206
233
 
207
234
  # Loaded lazily and exactly once, mirroring Java's class initializer / Swift's lazy
@@ -224,21 +251,6 @@ module HyperUuid
224
251
  [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP],
225
252
  Fiddle::TYPE_INT
226
253
  ),
227
- new_v6: Fiddle::Function.new(
228
- handle["uuid_new_v6"],
229
- [Fiddle::TYPE_UINT64_T, Fiddle::TYPE_VOIDP],
230
- Fiddle::TYPE_INT
231
- ),
232
- v6_unix_millis: Fiddle::Function.new(
233
- handle["uuid_v6_unix_millis"],
234
- [Fiddle::TYPE_VOIDP],
235
- Fiddle::TYPE_UINT64_T
236
- ),
237
- new_v6_batch: Fiddle::Function.new(
238
- handle["uuid_new_v6_batch"],
239
- [Fiddle::TYPE_UINT64_T, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP],
240
- Fiddle::TYPE_INT
241
- ),
242
254
  new_v7: Fiddle::Function.new(
243
255
  handle["uuid_new_v7"],
244
256
  [Fiddle::TYPE_UINT64_T, Fiddle::TYPE_VOIDP],
@@ -257,25 +269,36 @@ module HyperUuid
257
269
  v7_to_sql_order: Fiddle::Function.new(
258
270
  handle["uuid_v7_to_sql_order"],
259
271
  [Fiddle::TYPE_VOIDP],
260
- Fiddle::TYPE_VOID
272
+ Fiddle::TYPE_INT
261
273
  ),
262
274
  v7_to_rfc_order: Fiddle::Function.new(
263
275
  handle["uuid_v7_to_rfc_order"],
264
276
  [Fiddle::TYPE_VOIDP],
265
- Fiddle::TYPE_VOID
277
+ Fiddle::TYPE_INT
266
278
  ),
267
- v6_to_sql_order: Fiddle::Function.new(
268
- handle["uuid_v6_to_sql_order"],
269
- [Fiddle::TYPE_VOIDP],
270
- Fiddle::TYPE_VOID
279
+ version: Fiddle::Function.new(
280
+ handle["uuid_version"],
281
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T],
282
+ Fiddle::TYPE_UINT32_T
271
283
  ),
272
- v6_to_rfc_order: Fiddle::Function.new(
273
- handle["uuid_v6_to_rfc_order"],
274
- [Fiddle::TYPE_VOIDP],
275
- Fiddle::TYPE_VOID
284
+ variant: Fiddle::Function.new(handle["uuid_variant"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_UINT32_T),
285
+ is_rfc: Fiddle::Function.new(
286
+ handle["uuid_is_rfc"],
287
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_UINT32_T],
288
+ Fiddle::TYPE_UINT32_T
289
+ ),
290
+ get_timestamp: Fiddle::Function.new(
291
+ handle["uuid_get_timestamp"],
292
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP],
293
+ Fiddle::TYPE_UINT32_T
294
+ ),
295
+ v7_unix_millis_in: Fiddle::Function.new(
296
+ handle["uuid_v7_unix_millis_in"],
297
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T],
298
+ Fiddle::TYPE_UINT64_T
276
299
  ),
277
300
  # The one export that mints nothing: the zero-argument version probe.
278
- version: Fiddle::Function.new(handle["hyperuuid_version"], [], Fiddle::TYPE_UINT32_T),
301
+ packed_version: Fiddle::Function.new(handle["hyperuuid_version"], [], Fiddle::TYPE_UINT32_T),
279
302
  }
280
303
  end
281
304
  end
@@ -38,6 +38,17 @@ module HyperUuid
38
38
  HYPHENATED = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
39
39
  private_constant :HYPHENATED
40
40
 
41
+ # The byte orders a Uuid can hold, as the +layout:+ keyword of #version, #rfc? and
42
+ # #timestamp takes them, mapped to the native core's layout codes. +:rfc9562+ is RFC 9562
43
+ # order, what every other method here takes and returns. +:sql_server+ is the layout
44
+ # #to_sql_order returns, a version 8 that holds a converted version 7.
45
+ LAYOUTS = { rfc9562: 1, sql_server: 2 }.freeze
46
+
47
+ # The Symbols #variant returns, indexed by the native core's variant code (1-4; 0 is
48
+ # reserved and never returned). The one place that code becomes a Symbol, for both backends.
49
+ VARIANTS = [nil, :ncs, :rfc9562, :microsoft, :future].freeze
50
+ private_constant :VARIANTS
51
+
41
52
  # Parses an 8-4-4-4-12 hyphenated hex UUID string — exactly the shape #to_s produces, in
42
53
  # either case. Nothing else parses: not the bare 32 hex digits, not hyphens anywhere but
43
54
  # those four positions, not braces or a `urn:uuid:` prefix.
@@ -51,14 +62,57 @@ module HyperUuid
51
62
  new([string.delete("-")].pack("H*"))
52
63
  end
53
64
 
54
- # The RFC 9562 version nibble (bits 48-51, the high nibble of octet 6).
55
- def version
56
- (bytes.getbyte(6) >> 4) & 0x0F
65
+ # The version of this UUID, read by the native core in +layout+'s byte order.
66
+ #
67
+ # In +:rfc9562+ (the default) it is the version nibble (bits 48-51, the high nibble of
68
+ # octet 6), 0 through 15: Nil reads as 0 and Max as 15. It says nothing about the variant;
69
+ # #rfc? checks both. In +:sql_server+, the layout #to_sql_order returns, it is the version
70
+ # the value was converted from, 7, when the bytes carry the layout's version 8 and RFC
71
+ # variant, and 0 when they don't. It reads the bytes as given: an
72
+ # RFC-ordered value can happen to carry them (a random v4 does one time in 16), so the
73
+ # layout is the caller's to know.
74
+ #
75
+ # With the default layout, the value is read as RFC 9562 order. A SQL-ordered value — from
76
+ # #to_sql_order, or a +uniqueidentifier+ read back from SQL Server — needs
77
+ # +layout: :sql_server+, because the RFC-order read looks at the wrong bytes there. There
78
+ # is deliberately no layout-agnostic check: whoever holds the value knows its order.
79
+ #
80
+ # @raise [ArgumentError] if +layout+ isn't one of LAYOUTS' keys.
81
+ def version(layout: :rfc9562)
82
+ Runtime.version(bytes, layout_code(layout))
57
83
  end
58
84
 
59
- # The RFC 9562 variant bits (top two bits of octet 8). +0b10+ means RFC 9562/4122.
85
+ # The variant of this UUID (RFC 9562 §4.1), read by the native core in RFC 9562 order, as a
86
+ # Symbol: +:rfc9562+ (+10xx+, every UUID this gem mints, and the only variant that has
87
+ # versions), +:ncs+ (+0xxx+, Nil among them), +:microsoft+ (+110x+) or +:future+ (+111x+,
88
+ # Max among them).
89
+ #
90
+ # RFC 9562 order only, with no layout form. The SQL Server layout keeps the variant in the
91
+ # same place, so a SQL-ordered value reads +:rfc9562+ here, but that says nothing about
92
+ # which version it holds. To validate one, or a +uniqueidentifier+ read back from SQL
93
+ # Server, use <tt>rfc?(7, layout: :sql_server)</tt>, which checks the layout's version 8
94
+ # and variant.
60
95
  def variant
61
- (bytes.getbyte(8) >> 6) & 0b11
96
+ VARIANTS.fetch(Runtime.variant(bytes))
97
+ end
98
+
99
+ # Whether this is an RFC 9562 UUID of version +version+, read in +layout+'s byte order —
100
+ # the RFC 9562 variant and that version together, the guard to run before trusting a
101
+ # value's version-specific fields. In +:sql_server+ +version+ is the version the value was
102
+ # converted from, so only 7 can be true. A version outside 0-15 is simply false.
103
+ #
104
+ # With the default layout, the value is read as RFC 9562 order. A SQL-ordered value — from
105
+ # #to_sql_order, or a +uniqueidentifier+ read back from SQL Server — needs
106
+ # +layout: :sql_server+, because the RFC-order read looks at the wrong bytes there. There
107
+ # is deliberately no layout-agnostic check: whoever holds the value knows its order.
108
+ #
109
+ # @raise [TypeError] if +version+ isn't an Integer.
110
+ # @raise [ArgumentError] if +layout+ isn't one of LAYOUTS' keys.
111
+ def rfc?(version, layout: :rfc9562)
112
+ raise TypeError, "version must be an Integer; got #{version.class}" unless version.is_a?(Integer)
113
+
114
+ code = layout_code(layout)
115
+ version.between?(0, 15) && Runtime.is_rfc(bytes, version, code)
62
116
  end
63
117
 
64
118
  # The 8-4-4-4-12 hyphenated hex string representation.
@@ -68,92 +122,61 @@ module HyperUuid
68
122
  end
69
123
  alias_method :to_str, :to_s
70
124
 
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.
125
+ # The UTC timestamp embedded in an RFC 9562 version 7 UUID, held in +layout+'s byte order:
126
+ # +:rfc9562+ (the default), or +:sql_server+ for a value #to_sql_order returned (the
127
+ # timestamp of the version 7 it was converted from). One native call that checks the
128
+ # variant as well as the version: a 7 in the version nibble under any variant but
129
+ # +:rfc9562+ is not a version 7 UUID, so it has no timestamp (#rfc? is the same check on
130
+ # its own). Every other version has none either, a version 6 or 1 from another generator
131
+ # included.
132
+ #
133
+ # Raises by default for anything that isn't an RFC 9562 version 7 UUID; pass
134
+ # `raise_on_mismatch: false` to get `nil` back instead — for a caller that doesn't already
135
+ # know (or want to separately check) whether this UUID is time-based.
75
136
  #
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,
86
- "timestamp is only defined for version 6 or 7 UUIDs, got version #{version}" if raise_on_mismatch
87
-
88
- return nil
89
- end
90
- Time.at(millis / 1000, millis % 1000, :millisecond).utc
137
+ # @raise [ArgumentError] if this isn't an RFC 9562 version 7 UUID in +layout+ (unless
138
+ # +raise_on_mismatch+ is false), or +layout+ isn't one of LAYOUTS' keys.
139
+ def timestamp(raise_on_mismatch: true, layout: :rfc9562)
140
+ millis = Runtime.get_timestamp(bytes, layout_code(layout))
141
+ return Time.at(millis / 1000, millis % 1000, :millisecond).utc if millis
142
+ return nil unless raise_on_mismatch
143
+
144
+ raise ArgumentError, timestamp_mismatch(layout)
91
145
  end
92
146
 
93
- # Converts an RFC 9562-ordered version 6 or 7 UUID to the byte order SQL Server's
94
- # `uniqueidentifier` needs on the wire to sort by creation order. Dispatches on `version`
95
- # the same way #timestamp does.
147
+ # Converts an RFC 9562 version 7 UUID to the layout SQL Server's `uniqueidentifier` needs
148
+ # on the wire to sort by creation order: an RFC 9562 version 8 UUID (§5.8, the version for
149
+ # custom layouts, where §6.11 sends a byte order other than the network one).
96
150
  #
97
151
  # `System.Data.SqlTypes.SqlGuid` comparison — and therefore T-SQL `ORDER BY` on a
98
152
  # `uniqueidentifier` column — doesn't compare a GUID's 16 bytes left to right; it uses a
99
153
  # fixed, non-sequential byte significance order (octets 10,11,12,13,14,15,8,9,6,7,4,5,
100
- # 0,1,2,3, most significant first). Computed once in the native Rust core and verified
101
- # there (and independently, against the real SqlGuid comparator, in this project's C#
102
- # test suite); this binding calls the same native functions rather than reimplementing
103
- # the byte math.
154
+ # 0,1,2,3, most significant first). The layout writes the version nibble 8 at octet 7 and
155
+ # the RFC variant at octet 8, where SQL Server's text form and a .NET `Guid` show them, and
156
+ # fills the other 122 bits in that comparison's order: the 48-bit millisecond timestamp,
157
+ # the 26-bit counter and the 48 random bits as one intact block. A version 7 has exactly
158
+ # those 122 free bits, so every one converts, and #from_sql_order gives back the exact
159
+ # original. Computed once in the native Rust core and verified there (and independently,
160
+ # against the real SqlGuid comparator, in this project's C# test suite); this binding calls
161
+ # the same native functions rather than reimplementing the bit math.
104
162
  #
105
- # For v7, this moves the timestamp and counter — the two fields that determine creation
106
- # order — into that comparison's most-significant bytes, and moves the trailing entropy,
107
- # which carries no ordering information, into the least-significant ones as one intact
108
- # block. For v6, which has no monotonic counter the way v7 does, the only field that
109
- # determines creation order is the 60-bit timestamp itself, so that moves into the most
110
- # significant bytes instead, with `clock_seq`/`node` (independently random per call, not
111
- # a counter, so no ordering value either way) relocated into the rest. v6's much simpler
112
- # byte layout needs no bit-level repacking to do this — just whole-octet-group
113
- # relocation — unlike v7's, and its version/variant land at different sql-order offsets
114
- # as a result (octet 8's top nibble / octet 6's top two bits, not 7/8).
115
- #
116
- # **v6-specific caveat, unlike v7:** two version 6 UUIDs minted at the same millisecond
117
- # have identical timestamp bits, so they aren't guaranteed to sort in creation order any
118
- # more than plain RFC order already does — a pre-existing RFC 9562 v6 limitation, not one
119
- # this transform introduces.
120
- #
121
- # Meaningful only for a genuine version 6 or 7 UUID.
163
+ # @raise [ArgumentError] if this isn't an RFC 9562 version 7 UUID.
122
164
  def to_sql_order
123
- case version
124
- when 7 then self.class.new(Runtime.v7_to_sql_order(bytes), true)
125
- when 6 then self.class.new(Runtime.v6_to_sql_order(bytes), true)
126
- else raise ArgumentError, "to_sql_order is only defined for version 6 or 7 UUIDs, got version #{version}"
127
- end
165
+ self.class.new(Runtime.v7_to_sql_order(bytes), true)
128
166
  end
129
167
 
130
- # Inverse of #to_sql_order — converts a SQL-Server-ordered version 6 or 7 UUID back to
131
- # RFC 9562 order.
168
+ # Inverse of #to_sql_order — converts a SQL-Server-ordered value back to the RFC 9562
169
+ # version 7 UUID it was converted from, exactly. Refuses a value without the layout's
170
+ # version 8 and RFC variant, an RFC-ordered version 7 among them.
132
171
  #
133
- # A SQL-ordered value's version nibble sits at a different octet depending on which
134
- # version produced it (octet 7's top nibble = 7 for v7-sql-order, octet 8's top nibble =
135
- # 6 for v6-sql-order — #version itself assumes RFC order's octet 6 and can't tell these
136
- # apart), so this checks both fixed positions directly rather than calling #version.
172
+ # The layout is the caller's to know: bytes alone cannot say which order they are in. A
173
+ # value that was never SQL-ordered can still carry the layout's 8 and variant, and is then
174
+ # converted rather than refused (a random RFC-ordered v4 does one time in 16, since its
175
+ # octet 8 already carries the RFC variant and its octet 7 is random).
137
176
  #
138
- # Order matters here and isn't arbitrary: octet 8 must be checked *first*. For v6-sql-order
139
- # it's deterministic (top nibble always 0x6, by construction), and for v7-sql-order it's
140
- # also deterministic but structurally excluded from ever reading 0x6 (its top two bits are
141
- # the fixed variant `10`, so the nibble only ever lands in 0x8-0xB) — no collision either
142
- # way. Octet 7, by contrast, is *not* safe to check first: for v7-sql-order it's
143
- # deterministically 0x7, but for v6-sql-order it holds `clock_seq`'s fully random low
144
- # byte, which has a real (~1-in-16) chance of a top nibble that also happens to read 0x7 —
145
- # confirmed by an actual test failure during development, not a hypothetical. Checking
146
- # octet 8 first rules v6 in or out unambiguously before octet 7's reading can matter.
177
+ # @raise [ArgumentError] if this isn't a SQL-ordered value converted from version 7.
147
178
  def from_sql_order
148
- octet8_version = (bytes.getbyte(8) >> 4) & 0x0F
149
- octet7_version = (bytes.getbyte(7) >> 4) & 0x0F
150
- if octet8_version == 6
151
- self.class.new(Runtime.v6_to_rfc_order(bytes), true)
152
- elsif octet7_version == 7
153
- self.class.new(Runtime.v7_to_rfc_order(bytes), true)
154
- else
155
- raise ArgumentError, "from_sql_order: not a recognized version 6 or 7 SQL-ordered UUID"
156
- end
179
+ self.class.new(Runtime.v7_to_rfc_order(bytes), true)
157
180
  end
158
181
 
159
182
  # Whether +other+ wraps the same 16 raw bytes.
@@ -178,5 +201,24 @@ module HyperUuid
178
201
  def inspect
179
202
  "#<HyperUuid::Uuid #{self}>"
180
203
  end
204
+
205
+ private
206
+
207
+ # Why #timestamp found none: the message is worked out only once it is going to be raised.
208
+ def timestamp_mismatch(layout)
209
+ return "timestamp: not a SQL-ordered version 7 UUID" if layout == :sql_server
210
+
211
+ found = version
212
+ message = "timestamp is only defined for version 7 UUIDs, got version #{found}"
213
+ found == 7 ? "#{message} with the #{variant.inspect} variant" : message
214
+ end
215
+
216
+ # The native core's code for +layout+, the one place a layout is checked: an unknown one
217
+ # is refused here, never passed through to the core.
218
+ def layout_code(layout)
219
+ LAYOUTS.fetch(layout) do
220
+ raise ArgumentError, "layout must be one of #{LAYOUTS.keys.map(&:inspect).join(', ')}; got #{layout.inspect}"
221
+ end
222
+ end
181
223
  end
182
224
  end
data/lib/hyperuuid.rb CHANGED
@@ -4,27 +4,42 @@ require_relative "hyperuuid/namespaces"
4
4
  require_relative "hyperuuid/native_platform"
5
5
  require_relative "hyperuuid/runtime"
6
6
 
7
- # RFC 9562 UUID v4 (random), v5 (deterministic), v6 and v7 (time-sortable) generation from one
8
- # Rust core, reached through whichever of two backends this install can load: the core
9
- # linked straight into a Magnus native extension (shipped precompiled in the platform gems),
10
- # or the native libhyperuuid shared library called through Fiddle (the universal gem bundles
11
- # one per supported platform, picked by HyperUuid::NativePlatform). No runtime bridge in
12
- # either, and nothing compiled at install time. HyperUuid::BACKEND names the one that loaded; the selection logic
13
- # is at the bottom of this file.
7
+ # RFC 9562 UUID v4 (random), v5 (deterministic) and v7 (time-sortable) generation, with a
8
+ # version 8 SQL Server order for v7, from one Rust core, reached through whichever of two
9
+ # backends this install can load: the core linked straight into a Magnus native extension
10
+ # (shipped precompiled in the platform gems), or the native libhyperuuid shared library
11
+ # called through Fiddle (the universal gem bundles one per supported platform, picked by
12
+ # HyperUuid::NativePlatform). No runtime bridge in either, and nothing compiled at install
13
+ # time. HyperUuid::BACKEND names the one that loaded; the selection logic is at the bottom of
14
+ # this file.
14
15
  #
15
16
  # The doors below — with Uuid, Namespaces and the two exception classes — are the public
16
17
  # surface, and are shared by every backend, argument validation included: a caller bug raises
17
18
  # the same error whichever backend is live.
18
19
  module HyperUuid
19
- # This gem's own version — distinct from the RFC 9562 UUID *versions* (v4/v5/v6/v7) the
20
- # rest of this module generates.
21
- VERSION = "0.7.0"
20
+ # This gem's own version — distinct from the RFC 9562 UUID *versions* (v4/v5/v7) the rest
21
+ # of this module generates.
22
+ VERSION = "0.9.0"
22
23
 
23
24
  # The widest batch count, and the longest v5 name in bytes, the native ABI carries (a u32).
24
25
  # (The millisecond count is a u64; unix_millis_from refuses anything wider.)
25
26
  U32_MAX = 0xFFFF_FFFF
26
27
  private_constant :U32_MAX
27
28
 
29
+ # The most UUIDs one version 7 batch (#new_v7_batch, #new_v7_batch_bytes) mints:
30
+ # 67,108,864, the size of the 26-bit counter that orders UUIDs within a millisecond. A larger
31
+ # count is refused with ArgumentError before anything is allocated.
32
+ #
33
+ # Every batch up to this size is in strictly increasing order: the counter is one
34
+ # process-wide sequence, so a batch can straddle the point where it wraps back to 0, and the
35
+ # UUIDs from there on carry a timestamp one millisecond later than the one supplied.
36
+ # The roll-forward orders one batch, not the stream: the next batch or single #new_v7 in
37
+ # the same real millisecond starts its counter just past the wrap and carries the supplied
38
+ # timestamp, so it sorts before this batch's tail, which was stamped a millisecond later.
39
+ # Two single #new_v7 calls either side of the wrap in one millisecond sort in reverse the
40
+ # same way. It happens at most once per 2**26 UUIDs the process mints.
41
+ MAX_V7_BATCH = Runtime::MAX_V7_BATCH
42
+
28
43
  # Creates a random UUID version 4 (RFC 9562 §5.4).
29
44
  def self.new_v4
30
45
  Uuid.new(Runtime.new_v4, true)
@@ -53,7 +68,7 @@ module HyperUuid
53
68
  # Converts +value+ to a Unix-epoch millisecond integer: +nil+ becomes the current time, a
54
69
  # +Time+ is converted exactly (via its own Rational seconds, avoiding float rounding), and
55
70
  # an Integer millisecond count passes through unchanged. Shared by every
56
- # `new_v6`/`new_v7`/batch door below so a caller can pass either a `Time` or a raw
71
+ # `new_v7`/batch door below so a caller can pass either a `Time` or a raw
57
72
  # millisecond count interchangeably.
58
73
  #
59
74
  # This is also where a caller bug is caught once, for every backend: anything else is a
@@ -87,29 +102,6 @@ module HyperUuid
87
102
  count
88
103
  end
89
104
 
90
- # Creates a time-sortable UUID version 6 (RFC 9562 §5.6), a field-compatible reordering of
91
- # version 1 for better sort/index locality. Defaults to the current time; pass an explicit
92
- # `Time` or Unix-epoch millisecond integer to embed a specific time instead. `clock_seq` and
93
- # `node` are randomly generated on every call — unlike version 7, there is no monotonic
94
- # counter, so calls within the same millisecond are not guaranteed to sort in creation order.
95
- #
96
- # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
97
- # @raise [TypeError] if +unix_millis+ isn't a Time, an Integer or nil.
98
- def self.new_v6(unix_millis = nil)
99
- Uuid.new(Runtime.new_v6(unix_millis_from(unix_millis, Runtime::V6_TIMESTAMP_OUT_OF_RANGE)), true)
100
- end
101
-
102
- # Creates `count` time-sortable version 6 UUIDs sharing one timestamp capture — one FFI call
103
- # and one random-bytes fetch instead of `count` of each. Defaults to the current time; pass
104
- # an explicit `Time` or Unix-epoch millisecond integer to embed a specific time instead.
105
- #
106
- # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
107
- # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
108
- def self.new_v6_batch(count, unix_millis = nil)
109
- bytes = new_v6_batch_bytes(count, unix_millis)
110
- Array.new(count) { |i| Uuid.new(bytes[i * 16, 16], true) }
111
- end
112
-
113
105
  # Creates a time-sortable UUID version 7 (RFC 9562 §6.2). Defaults to the current time; pass
114
106
  # an explicit `Time` or Unix-epoch millisecond integer (non-negative, fitting in 48 bits) to
115
107
  # embed a specific time instead.
@@ -125,7 +117,18 @@ module HyperUuid
125
117
  # instead of `count` of each. Defaults to the current time; pass an explicit `Time` or
126
118
  # Unix-epoch millisecond integer to embed a specific time instead.
127
119
  #
128
- # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
120
+ # Every batch is in strictly increasing order. The counter is one process-wide sequence, so
121
+ # a batch can straddle the point where it wraps back to 0; the UUIDs from there on carry a
122
+ # timestamp one millisecond later than the one supplied rather than sorting before the ones
123
+ # ahead of them. That is also why one batch takes at most MAX_V7_BATCH.
124
+ #
125
+ # The roll-forward orders one batch, not the stream: the next batch or single #new_v7 in
126
+ # the same real millisecond starts its counter just past the wrap and carries the supplied
127
+ # timestamp, so it sorts before this batch's tail, which was stamped a millisecond later.
128
+ # Two single #new_v7 calls either side of the wrap in one millisecond sort in reverse the
129
+ # same way. It happens at most once per 2**26 UUIDs the process mints.
130
+ #
131
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and MAX_V7_BATCH.
129
132
  # @raise [TimestampOutOfRangeError] if the time is negative or past the 48-bit v7 field.
130
133
  def self.new_v7_batch(count, unix_millis = nil)
131
134
  bytes = new_v7_batch_bytes(count, unix_millis)
@@ -145,28 +148,17 @@ module HyperUuid
145
148
  # format, a bulk COPY. If you need Uuid objects, keep using #new_v7_batch — slicing this
146
149
  # String into them yourself just moves the same allocations into your own code.
147
150
  #
148
- # Slice it with `bytes[i * 16, 16]`, which is what #new_v7_batch does internally.
151
+ # Slice it with `bytes[i * 16, 16]`, which is what #new_v7_batch does internally. The
152
+ # order, the possible extra millisecond and the MAX_V7_BATCH limit are #new_v7_batch's.
149
153
  #
150
- # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
154
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and MAX_V7_BATCH.
151
155
  # @raise [TimestampOutOfRangeError] if the time is negative or past the 48-bit v7 field.
152
156
  def self.new_v7_batch_bytes(count, unix_millis = nil)
153
- count = batch_count(count)
154
- Runtime.new_v7_batch(count, unix_millis_from(unix_millis, Runtime::V7_TIMESTAMP_OUT_OF_RANGE))
155
- end
157
+ # First, so any count past the limit (2**32 and beyond included) names the limit itself.
158
+ raise ArgumentError, Runtime::V7_BATCH_TOO_LARGE if count.is_a?(Integer) && count > MAX_V7_BATCH
156
159
 
157
- # Returns `count` version 6 UUIDs as one binary String of raw RFC 9562-ordered bytes,
158
- # 16 per UUID. The version 6 counterpart to #new_v7_batch_bytes, with the same rationale and
159
- # the same guidance about when it is the right call.
160
- #
161
- # clock_seq and node are independently random per item; unlike version 7 there is no
162
- # monotonic counter, so items minted in the same millisecond are not guaranteed to sort in
163
- # creation order.
164
- #
165
- # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
166
- # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
167
- def self.new_v6_batch_bytes(count, unix_millis = nil)
168
160
  count = batch_count(count)
169
- Runtime.new_v6_batch(count, unix_millis_from(unix_millis, Runtime::V6_TIMESTAMP_OUT_OF_RANGE))
161
+ Runtime.new_v7_batch(count, unix_millis_from(unix_millis, Runtime::V7_TIMESTAMP_OUT_OF_RANGE))
170
162
  end
171
163
 
172
164
  # The version of the native core actually loaded, as "major.minor.patch" — read from the
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperuuid-wasm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Buvinghausen