Skip to content

Commit 9dbd1cd

Browse files
broofarobinpokorny
andauthored
fix!: refactor v7 internal state and options logic, fixes #764 (#779)
Co-authored-by: Robert Kieffer <[email protected]> Co-authored-by: Robin Pokorny <[email protected]>
1 parent 7eff835 commit 9dbd1cd

File tree

5 files changed

+207
-242
lines changed

5 files changed

+207
-242
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -338,10 +338,10 @@ Create an RFC version 7 (random) UUID
338338
| | |
339339
| --- | --- |
340340
| [`options`] | `Object` with one or more of the following properties: |
341-
| [`options.msecs`] | RFC "timestamp" field (`Number` of milliseconds, unix epoch) |
341+
| [`options.msecs`] | RFC "timestamp" field (`Number` of milliseconds, unix epoch). Default = `Date.now()` |
342342
| [`options.random`] | `Array` of 16 random bytes (0-255) |
343343
| [`options.rng`] | Alternative to `options.random`, a `Function` that returns an `Array` of 16 random bytes (0-255) |
344-
| [`options.seq`] | 31 bit monotonic sequence counter as `Number` between 0 - 0x7fffffff |
344+
| [`options.seq`] | 32-bit sequence `Number` between 0 - 0xffffffff. This may be provided to help insure uniqueness for UUIDs generated within the same millisecond time interval. Default = random value. |
345345
| [`buffer`] | `Array \| Buffer` If specified, uuid will be written here in byte-form, starting at `offset` |
346346
| [`offset` = 0] | `Number` Index to start writing UUID bytes in `buffer` |
347347
| _returns_ | UUID `String` if no `buffer` is specified, otherwise returns `buffer` |

README_js.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,10 @@ Create an RFC version 7 (random) UUID
346346
| | |
347347
| --- | --- |
348348
| [`options`] | `Object` with one or more of the following properties: |
349-
| [`options.msecs`] | RFC "timestamp" field (`Number` of milliseconds, unix epoch) |
349+
| [`options.msecs`] | RFC "timestamp" field (`Number` of milliseconds, unix epoch). Default = `Date.now()` |
350350
| [`options.random`] | `Array` of 16 random bytes (0-255) |
351351
| [`options.rng`] | Alternative to `options.random`, a `Function` that returns an `Array` of 16 random bytes (0-255) |
352-
| [`options.seq`] | 31 bit monotonic sequence counter as `Number` between 0 - 0x7fffffff |
352+
| [`options.seq`] | 32-bit sequence `Number` between 0 - 0xffffffff. This may be provided to help insure uniqueness for UUIDs generated within the same millisecond time interval. Default = random value. |
353353
| [`buffer`] | `Array \| Buffer` If specified, uuid will be written here in byte-form, starting at `offset` |
354354
| [`offset` = 0] | `Number` Index to start writing UUID bytes in `buffer` |
355355
| _returns_ | UUID `String` if no `buffer` is specified, otherwise returns `buffer` |

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@
113113
"test:browser": "wdio run ./wdio.conf.js",
114114
"test:node": "npm-run-all --parallel examples:node:**",
115115
"test:pack": "./scripts/testpack.sh",
116-
"test:watch": "node --test --watch dist/esm/test",
117-
"test": "node --test dist/esm/test"
116+
"test:watch": "node --test --enable-source-maps --watch dist/esm/test",
117+
"test": "node --test --enable-source-maps dist/esm/test"
118118
},
119119
"repository": {
120120
"type": "git",

src/test/v7.test.ts

+123-110
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,40 @@
11
import * as assert from 'assert';
22
import test, { describe } from 'node:test';
33
import { Version7Options } from '../_types.js';
4-
import v7 from '../v7.js';
4+
import parse from '../parse.js';
55
import stringify from '../stringify.js';
6+
import v7, { updateV7State } from '../v7.js';
67

7-
/**
8-
* fixture bit layout:
9-
* ref: https://www.rfc-editor.org/rfc/rfc9562.html#name-example-of-a-uuidv7-value
10-
*
11-
* expectedBytes was calculated using this script:
12-
* https://gist.github.com/d5382ac3a1ce4ba9ba40a90d9da8cbf1
13-
*
14-
* -------------------------------
15-
* field bits value
16-
* -------------------------------
17-
* unix_ts_ms 48 0x17F22E279B0
18-
* ver 4 0x7
19-
* rand_a 12 0xCC3
20-
* var 2 b10
21-
* rand_b 62 b01, 0x8C4DC0C0C07398F
22-
* -------------------------------
23-
* total 128
24-
* -------------------------------
25-
* final: 017f22e2-79b0-7cc3-98c4-dc0c0c07398f
26-
*/
8+
// Fixture values for testing with the rfc v7 UUID example:
9+
// https://www.rfc-editor.org/rfc/rfc9562.html#name-example-of-a-uuidv7-value
10+
const RFC_V7 = '017f22e2-79b0-7cc3-98c4-dc0c0c07398f';
11+
const RFC_V7_BYTES = parse('017f22e2-79b0-7cc3-98c4-dc0c0c07398f');
12+
const RFC_MSECS = 0x17f22e279b0;
2713

28-
describe('v7', () => {
29-
const msecsFixture = 1645557742000;
30-
const seqFixture = 0x661b189b;
31-
32-
const randomBytesFixture = Uint8Array.of(
33-
0x10,
34-
0x91,
35-
0x56,
36-
0xbe,
37-
0xc4,
38-
0xfb,
39-
0x0c,
40-
0xc3,
41-
0x18,
42-
0xc4,
43-
0xdc,
44-
0x0c,
45-
0x0c,
46-
0x07,
47-
0x39,
48-
0x8f
49-
);
14+
// `option.seq` for the above RFC uuid
15+
const RFC_SEQ = (0x0cc3 << 20) | (0x98c4dc >> 2);
5016

51-
const expectedBytes = Uint8Array.of(
52-
1,
53-
127,
54-
34,
55-
226,
56-
121,
57-
176,
58-
124,
59-
195,
60-
152,
61-
196,
62-
220,
63-
12,
64-
12,
65-
7,
66-
57,
67-
143
68-
);
17+
// `option,random` for the above RFC uuid
18+
const RFC_RANDOM = Uint8Array.of(
19+
0x10,
20+
0x91,
21+
0x56,
22+
0xbe,
23+
0xc4,
24+
0xfb,
25+
0x0c,
26+
0xc3,
27+
0x18,
28+
0xc4,
29+
0x6c,
30+
0x0c,
31+
0x0c,
32+
0x07,
33+
0x39,
34+
0x8f
35+
);
6936

37+
describe('v7', () => {
7038
test('subsequent UUIDs are different', () => {
7139
const id1 = v7();
7240
const id2 = v7();
@@ -75,25 +43,25 @@ describe('v7', () => {
7543

7644
test('explicit options.random and options.msecs produces expected result', () => {
7745
const id = v7({
78-
random: randomBytesFixture,
79-
msecs: msecsFixture,
80-
seq: seqFixture,
46+
random: RFC_RANDOM,
47+
msecs: RFC_MSECS,
48+
seq: RFC_SEQ,
8149
});
82-
assert.strictEqual(id, '017f22e2-79b0-7cc3-98c4-dc0c0c07398f');
50+
assert.strictEqual(id, RFC_V7);
8351
});
8452

8553
test('explicit options.rng produces expected result', () => {
8654
const id = v7({
87-
rng: () => randomBytesFixture,
88-
msecs: msecsFixture,
89-
seq: seqFixture,
55+
rng: () => RFC_RANDOM,
56+
msecs: RFC_MSECS,
57+
seq: RFC_SEQ,
9058
});
91-
assert.strictEqual(id, '017f22e2-79b0-7cc3-98c4-dc0c0c07398f');
59+
assert.strictEqual(id, RFC_V7);
9260
});
9361

9462
test('explicit options.msecs produces expected result', () => {
9563
const id = v7({
96-
msecs: msecsFixture,
64+
msecs: RFC_MSECS,
9765
});
9866
assert.strictEqual(id.indexOf('017f22e2'), 0);
9967
});
@@ -102,13 +70,15 @@ describe('v7', () => {
10270
const buffer = new Uint8Array(16);
10371
const result = v7(
10472
{
105-
random: randomBytesFixture,
106-
msecs: msecsFixture,
107-
seq: seqFixture,
73+
random: RFC_RANDOM,
74+
msecs: RFC_MSECS,
75+
seq: RFC_SEQ,
10876
},
10977
buffer
11078
);
111-
assert.deepEqual(buffer, expectedBytes);
79+
stringify(buffer);
80+
81+
assert.deepEqual(buffer, RFC_V7_BYTES);
11282
assert.strictEqual(buffer, result);
11383
});
11484

@@ -117,25 +87,25 @@ describe('v7', () => {
11787

11888
v7(
11989
{
120-
random: randomBytesFixture,
121-
msecs: msecsFixture,
122-
seq: seqFixture,
90+
random: RFC_RANDOM,
91+
msecs: RFC_MSECS,
92+
seq: RFC_SEQ,
12393
},
12494
buffer,
12595
0
12696
);
12797
v7(
12898
{
129-
random: randomBytesFixture,
130-
msecs: msecsFixture,
131-
seq: seqFixture,
99+
random: RFC_RANDOM,
100+
msecs: RFC_MSECS,
101+
seq: RFC_SEQ,
132102
},
133103
buffer,
134104
16
135105
);
136106
const expected = new Uint8Array(32);
137-
expected.set(expectedBytes);
138-
expected.set(expectedBytes, 16);
107+
expected.set(RFC_V7_BYTES);
108+
expected.set(RFC_V7_BYTES, 16);
139109
assert.deepEqual(buffer, expected);
140110
});
141111

@@ -146,15 +116,15 @@ describe('v7', () => {
146116
test('lexicographical sorting is preserved', () => {
147117
let id;
148118
let prior;
149-
let msecs = msecsFixture;
119+
let msecs = RFC_MSECS;
150120
for (let i = 0; i < 20000; ++i) {
151121
if (i % 1500 === 0) {
152122
// every 1500 runs increment msecs so seq is
153123
// reinitialized, simulating passage of time
154124
msecs += 1;
155125
}
156126

157-
id = v7({ msecs });
127+
id = v7({ msecs, seq: i });
158128

159129
if (prior !== undefined) {
160130
assert.ok(prior < id, `${prior} < ${id}`);
@@ -164,46 +134,89 @@ describe('v7', () => {
164134
}
165135
});
166136

167-
test('handles seq rollover', () => {
168-
const msecs = msecsFixture;
169-
const a = v7({
170-
msecs,
171-
seq: 0x7fffffff,
172-
});
173-
174-
v7({ msecs });
175-
176-
const c = v7({ msecs });
177-
178-
assert.ok(a < c, `${a} < ${c}`);
137+
test('internal state updates properly', () => {
138+
const tests = [
139+
{
140+
title: 'new time interval',
141+
state: { msecs: 1, seq: 123 },
142+
now: 2,
143+
expected: {
144+
msecs: 2, // time interval should update
145+
seq: 0x6c318c4, // sequence should be randomized
146+
},
147+
},
148+
{
149+
title: 'same time interval',
150+
state: { msecs: 1, seq: 123 },
151+
now: 1,
152+
expected: {
153+
msecs: 1, // timestamp unchanged
154+
seq: 124, // sequence increments
155+
},
156+
},
157+
{
158+
title: 'same time interval (sequence rollover)',
159+
state: { msecs: 1, seq: 0xffffffff },
160+
now: 1,
161+
expected: {
162+
msecs: 2, // timestamp increments
163+
seq: 0, // sequence rolls over
164+
},
165+
},
166+
{
167+
title: 'time regression',
168+
state: { msecs: 2, seq: 123 },
169+
now: 1,
170+
expected: {
171+
msecs: 2, // timestamp unchanged
172+
seq: 124, // sequence increments
173+
},
174+
},
175+
{
176+
title: 'time regression (sequence rollover)',
177+
state: { msecs: 2, seq: 0xffffffff },
178+
now: 1,
179+
expected: {
180+
// timestamp increments (crazy, right? The system clock goes backwards
181+
// but the UUID timestamp moves forward? Weird, but it's what's
182+
// required to maintain monotonicity... and this is why we have unit
183+
// tests!)
184+
msecs: 3,
185+
seq: 0, // sequence rolls over
186+
},
187+
},
188+
];
189+
for (const { title, state, now, expected } of tests) {
190+
assert.deepStrictEqual(updateV7State(state, now, RFC_RANDOM), expected, `Failed: ${title}`);
191+
}
179192
});
180193

181194
test('can supply seq', () => {
182195
let seq = 0x12345;
183196
let uuid = v7({
184-
msecs: msecsFixture,
197+
msecs: RFC_MSECS,
185198
seq,
186199
});
187200

188-
assert.strictEqual(uuid.substr(0, 25), '017f22e2-79b0-7000-891a-2');
201+
assert.strictEqual(uuid.substr(0, 25), '017f22e2-79b0-7000-848d-1');
189202

190203
seq = 0x6fffffff;
191204
uuid = v7({
192-
msecs: msecsFixture,
205+
msecs: RFC_MSECS,
193206
seq,
194207
});
195208

196-
assert.strictEqual(uuid.substr(0, 25), '017f22e2-79b0-7dff-bfff-f');
209+
assert.strictEqual(uuid.substring(0, 25), '017f22e2-79b0-76ff-bfff-f');
197210
});
198211

199212
test('internal seq is reset upon timestamp change', () => {
200213
v7({
201-
msecs: msecsFixture,
214+
msecs: RFC_MSECS,
202215
seq: 0x6fffffff,
203216
});
204217

205218
const uuid = v7({
206-
msecs: msecsFixture + 1,
219+
msecs: RFC_MSECS + 1,
207220
});
208221

209222
assert.ok(uuid.indexOf('fff') !== 15);
@@ -216,18 +229,18 @@ describe('v7', () => {
216229
// convert the given number of bits (LE) to number
217230
const asNumber = (bits: number, data: bigint) => Number(BigInt.asUintN(bits, data));
218231

219-
// flip the nth bit (BE) in a BigInt
232+
// flip the nth bit (BE) in a BigInt
220233
const flip = (data: bigint, n: number) => data ^ (1n << BigInt(127 - n));
221234

222235
// Extract v7 `options` from a (BigInt) UUID
223236
const optionsFrom = (data: bigint): Version7Options => {
224-
const ms = asNumber(48, data >> (128n - 48n));
225-
const hi = asNumber(12, data >> (43n + 19n + 2n));
226-
const lo = asNumber(19, data >> 43n);
227-
const r = BigInt.asUintN(43, data);
237+
const ms = asNumber(48, data >> 80n);
238+
const hi = asNumber(12, data >> 64n);
239+
const lo = asNumber(20, data >> 42n);
240+
const r = BigInt.asUintN(42, data);
228241
return {
229242
msecs: ms,
230-
seq: (hi << 19) | lo,
243+
seq: (hi << 20) | lo,
231244
random: Uint8Array.from([
232245
...Array(10).fill(0),
233246
...Array(6)
@@ -247,8 +260,8 @@ describe('v7', () => {
247260
}
248261
const flipped = flip(data, i);
249262
assert.strictEqual(
250-
asBigInt(v7(optionsFrom(flipped), buf)),
251-
flipped,
263+
asBigInt(v7(optionsFrom(flipped), buf)).toString(16),
264+
flipped.toString(16),
252265
`Unequal uuids at bit ${i}`
253266
);
254267
assert.notStrictEqual(stringify(buf), id);

0 commit comments

Comments
 (0)