diff --git a/src/test/v1-random.test.ts b/src/test/v1-random.test.ts deleted file mode 100644 index 3d26fc9d..00000000 --- a/src/test/v1-random.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as assert from 'assert'; -import test, { describe } from 'node:test'; -import v1 from '../v1.js'; - -// Since the clockseq is cached in the module this test must run in a separate file in order to -// initialize the v1 clockseq with controlled random data. -describe('v1-random', () => { - const randomBytesFixture = Uint8Array.of( - 0x10, - 0x91, - 0x56, - 0xbe, - 0xc4, - 0xfb, - 0xc1, - 0xea, - 0x71, - 0xb4, - 0xef, - 0xe1, - 0x67, - 0x1c, - 0x58, - 0x36 - ); - - test('explicit options.random produces expected id', () => { - const id = v1({ - msecs: 1321651533573, - nsecs: 5432, - random: randomBytesFixture, - }); - assert.strictEqual(id, 'd9428888-122b-11e1-81ea-119156bec4fb'); - }); -}); diff --git a/src/test/v1.test.ts b/src/test/v1.test.ts index 1deec5fa..ad2707d8 100644 --- a/src/test/v1.test.ts +++ b/src/test/v1.test.ts @@ -1,20 +1,56 @@ import * as assert from 'assert'; import test, { describe } from 'node:test'; -import v1 from '../v1.js'; +import parse from '../parse.js'; +import v1, { updateV1State } from '../v1.js'; // Verify ordering of v1 ids created with explicit times const TIME = 1321644961388; // 2011-11-18 11:36:01.388-08:00 +// Fixture values for testing with the rfc v7 UUID example: +// https://www.rfc-editor.org/rfc/rfc9562.html#name-example-of-a-uuidv1-value +const RFC_V1 = 'c232ab00-9414-11ec-b3c8-9f68deced846'; +const RFC_V1_BYTES = parse(RFC_V1); + +// `options` for producing the above RFC UUID +const RFC_OPTIONS = { + msecs: 0x17f22e279b0, + nsecs: 0, + clockseq: 0x33c8, + node: Uint8Array.of(0x9f, 0x68, 0xde, 0xce, 0xd8, 0x46), +}; + +// random bytes for producing the above RFC UUID +const RFC_RANDOM = Uint8Array.of( + // unused + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + + // clock seq + RFC_OPTIONS.clockseq >> 8, + RFC_OPTIONS.clockseq & 0xff, + + // node + ...RFC_OPTIONS.node +); + +// Compare v1 timestamp fields chronologically +function compareV1TimeField(a: string, b: string) { + a = a.split('-').slice(0, 3).reverse().join(''); + b = b.split('-').slice(0, 3).reverse().join(''); + return a < b ? -1 : a > b ? 1 : 0; +} + describe('v1', () => { test('v1 sort order (default)', () => { const ids = [v1(), v1(), v1(), v1(), v1()]; - const sorted = [...ids].sort((a, b) => { - a = a.split('-').reverse().join('-'); - b = b.split('-').reverse().join('-'); - return a < b ? -1 : a > b ? 1 : 0; - }); - + const sorted = [...ids].sort(compareV1TimeField); assert.deepEqual(ids, sorted); }); @@ -28,140 +64,108 @@ describe('v1', () => { v1({ msecs: TIME + 28 * 24 * 3600 * 1000 }), ]; - const sorted = [...ids].sort((a, b) => { - a = a.split('-').reverse().join('-'); - b = b.split('-').reverse().join('-'); - return a < b ? -1 : a > b ? 1 : 0; - }); - + const sorted = [...ids].sort(compareV1TimeField); assert.deepEqual(ids, sorted); }); - test('msec', () => { - assert.ok( - v1({ msecs: TIME }) !== v1({ msecs: TIME }), - 'IDs created at same msec are different' - ); + test('v1(options)', () => { + assert.equal(v1({ msecs: RFC_OPTIONS.msecs, random: RFC_RANDOM }), RFC_V1, 'minimal options'); + assert.equal(v1(RFC_OPTIONS), RFC_V1, 'full options'); }); - test('exception thrown when > 10k ids created in 1ms', () => { - assert.throws(function () { - v1({ msecs: TIME, nsecs: 10000 }); - }, 'throws when > 10K ids created in 1 ms'); - }); - - test('clock regression by msec', () => { - // Verify clock regression bumps clockseq - const uidt = v1({ msecs: TIME }); - const uidtb = v1({ msecs: TIME - 1 }); - assert.ok( - parseInt(uidtb.split('-')[3], 16) - parseInt(uidt.split('-')[3], 16) === 1, - 'Clock regression by msec increments the clockseq' - ); - }); - - test('clock regression by nsec', () => { - // Verify clock regression bumps clockseq - const uidtn = v1({ msecs: TIME, nsecs: 10 }); - const uidtnb = v1({ msecs: TIME, nsecs: 9 }); - assert.ok( - parseInt(uidtnb.split('-')[3], 16) - parseInt(uidtn.split('-')[3], 16) === 1, - 'Clock regression by nsec increments the clockseq' - ); - }); - - const fullOptions = { - msecs: 1321651533573, - nsecs: 5432, - clockseq: 0x385c, - node: Uint8Array.of(0x61, 0xcd, 0x3c, 0xbb, 0x32, 0x10), - }; - - test('explicit options produce expected id', () => { - // Verify explicit options produce expected id - const id = v1(fullOptions); - assert.ok( - id === 'd9428888-122b-11e1-b85c-61cd3cbb3210', - 'Explicit options produce expected id' - ); + test('v1(options) equality', () => { + assert.notEqual(v1({ msecs: TIME }), v1({ msecs: TIME }), 'UUIDs with minimal options differ'); + assert.equal(v1(RFC_OPTIONS), v1(RFC_OPTIONS), 'UUIDs with full options are identical'); }); - test('explicit options.random produces expected id', () => { - function rng() { - return Uint8Array.of( - 0x10, - 0x91, - 0x56, - 0xbe, - 0xc4, - 0xfb, - 0xc1, - 0xea, - 0x71, - 0xb4, - 0xef, - 0xe1, - 0x67, - 0x1c, - 0x58, - 0x36 - ); - } - - const id = v1({ - msecs: 1321651533573, - nsecs: 5432, - rng, - }); - assert.strictEqual(id, 'd9428888-122b-11e1-81ea-119156bec4fb'); - }); - - test('ids spanning 1ms boundary are 100ns apart', () => { - // Verify adjacent ids across a msec boundary are 1 time unit apart - const u0 = v1({ msecs: TIME, nsecs: 9999 }); - const u1 = v1({ msecs: TIME + 1, nsecs: 0 }); - - const before = u0.split('-')[0]; - const after = u1.split('-')[0]; - const dt = parseInt(after, 16) - parseInt(before, 16); - assert.ok(dt === 1, 'Ids spanning 1ms boundary are 100ns apart'); - }); - - const expectedBytes = Uint8Array.of( - 217, - 66, - 136, - 136, - 18, - 43, - 17, - 225, - 184, - 92, - 97, - 205, - 60, - 187, - 50, - 16 - ); - test('fills one UUID into a buffer as expected', () => { const buffer = new Uint8Array(16); - const result = v1(fullOptions, buffer); - assert.deepEqual(buffer, expectedBytes); + const result = v1(RFC_OPTIONS, buffer); + assert.deepEqual(buffer, RFC_V1_BYTES); assert.strictEqual(buffer, result); }); test('fills two UUIDs into a buffer as expected', () => { const buffer = new Uint8Array(32); - v1(fullOptions, buffer, 0); - v1(fullOptions, buffer, 16); + v1(RFC_OPTIONS, buffer, 0); + v1(RFC_OPTIONS, buffer, 16); const expectedBuf = new Uint8Array(32); - expectedBuf.set(expectedBytes); - expectedBuf.set(expectedBytes, 16); + expectedBuf.set(RFC_V1_BYTES); + expectedBuf.set(RFC_V1_BYTES, 16); assert.deepEqual(buffer, expectedBuf); }); }); + +test('v1() state transitions', () => { + // Test fixture for internal state passed into updateV1State function + const PRE_STATE = { + msecs: 10, + nsecs: 20, + clockseq: 0x1234, + node: Uint8Array.of(0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc), + }; + + // Note: The test code, below, passes RFC_RANDOM as the `rnds` argument for + // convenience. This allows us to test that fields have been initialized from + // the rnds argument by testing for RFC_OPTIONS values in the output state. + + const tests = [ + { + title: 'initial state', + state: {}, + now: 10, + expected: { + msecs: 10, // -> now + nsecs: 0, // -> init + clockseq: RFC_OPTIONS.clockseq, // -> random + node: RFC_OPTIONS.node, // -> random + }, + }, + { + title: 'same time interval', + state: { ...PRE_STATE }, + now: PRE_STATE.msecs, + expected: { + ...PRE_STATE, + nsecs: 21, // -> +1 + }, + }, + { + title: 'new time interval', + state: { ...PRE_STATE }, + now: PRE_STATE.msecs + 1, + expected: { + ...PRE_STATE, + msecs: PRE_STATE.msecs + 1, // -> +1 + nsecs: 0, // -> init + }, + }, + { + title: 'same time interval (nsecs overflow)', + state: { ...PRE_STATE, nsecs: 9999 }, + now: PRE_STATE.msecs, + expected: { + ...PRE_STATE, + nsecs: 0, // -> init + clockseq: RFC_OPTIONS.clockseq, // -> init + node: RFC_OPTIONS.node, // -> init + }, + }, + { + title: 'time regression', + state: { ...PRE_STATE }, + now: PRE_STATE.msecs - 1, + expected: { + ...PRE_STATE, + msecs: PRE_STATE.msecs - 1, // -> now + clockseq: RFC_OPTIONS.clockseq, // -> init + node: RFC_OPTIONS.node, // -> init + }, + }, + ]; + for (const { title, state, now, expected } of tests) { + assert.deepStrictEqual(updateV1State(state, now, RFC_RANDOM), expected, `Failed: ${title}`); + } +}); diff --git a/src/test/v6.test.ts b/src/test/v6.test.ts index aaad4521..5ae3929e 100644 --- a/src/test/v6.test.ts +++ b/src/test/v6.test.ts @@ -9,29 +9,29 @@ describe('v6', () => { const V6_ID = '1ef21d2f-1207-6660-8c4f-419efbd44d48'; const fullOptions = { - msecs: 1321651533573, - nsecs: 5432, + msecs: 0x133b891f705, + nsecs: 0x1538, clockseq: 0x385c, node: Uint8Array.of(0x61, 0xcd, 0x3c, 0xbb, 0x32, 0x10), }; const EXPECTED_BYTES = Uint8Array.of( - 30, - 17, - 34, - 189, - 148, - 40, - 104, - 136, - 184, - 92, - 97, - 205, - 60, - 187, - 50, - 16 + 0x1e, + 0x11, + 0x22, + 0xbd, + 0x94, + 0x28, + 0x68, + 0x88, + 0xb8, + 0x5c, + 0x61, + 0xcd, + 0x3c, + 0xbb, + 0x32, + 0x10 ); test('default behavior', () => { diff --git a/src/v1.ts b/src/v1.ts index 49183a37..7d3fbad1 100644 --- a/src/v1.ts +++ b/src/v1.ts @@ -11,19 +11,18 @@ type V1State = { node?: Uint8Array; // node id (47-bit random) clockseq?: number; // sequence number (14-bit) - // v1 & v6 timestamps count 100-nanosecond intervals since the Gregorian - // epoch, (1582-10-15 00:00). JS Numbers aren't precise enough for this, so we - // represent them internally using 'msecs' (integer milliseconds, unix epoch) - // and 'nsecs' (100-nanoseconds offset from `msecs`). - - msecs: number; // timestamp (milliseconds, unix epoch) - nsecs: number; // timestamp (100-nanoseconds offset from 'msecs') + // v1 & v6 timestamps are a pain to deal with. They specify time from the + // Gregorian epoch in 100ns intervals, which requires values with 57+ bits of + // precision. But that's outside the precision of IEEE754 floats (i.e. JS + // numbers). To work around this, we represent them internally using 'msecs' + // (milliseconds since unix epoch) and 'nsecs' (100-nanoseconds offset from + // `msecs`). + + msecs?: number; // timestamp (milliseconds, unix epoch) + nsecs?: number; // timestamp (100-nanoseconds offset from 'msecs') }; -const _state: V1State = { - msecs: -Infinity, - nsecs: 0, -}; +const _state: V1State = {}; function v1(options?: Version1Options, buf?: undefined, offset?: number): string; function v1(options?: Version1Options, buf?: Uint8Array, offset?: number): Uint8Array; @@ -76,6 +75,9 @@ function v1(options?: Version1Options, buf?: Uint8Array, offset?: number): UUIDT } export function updateV1State(state: V1State, now: number, rnds: Uint8Array) { + state.nsecs ??= 0; + state.msecs ??= -Infinity; + // Update timestamp if (now === state.msecs) { // Same msec-interval = simulate higher clock resolution by bumping `nsecs` @@ -85,8 +87,8 @@ export function updateV1State(state: V1State, now: number, rnds: Uint8Array) { // Check for `nsecs` overflow (nsecs is capped at 10K intervals / msec) if (state.nsecs >= 10000) { // Prior to uuid@11 this would throw an error, however the RFCs allow for - // selecting a new node in this case. This slightly breaks monotonicity - // at msec granularity, but that's not a significant concern. + // changing the node in this case. This slightly breaks monotonicity at + // msec granularity, but that's not a significant concern. // https://www.rfc-editor.org/rfc/rfc9562.html#section-6.1-2.16 state.node = undefined; state.nsecs = 0; @@ -94,10 +96,19 @@ export function updateV1State(state: V1State, now: number, rnds: Uint8Array) { } else if (now > state.msecs) { // Reset nsec counter when clock advances to a new msec interval state.nsecs = 0; + } else if (now < state.msecs) { + // Handle clock regression + // https://www.rfc-editor.org/rfc/rfc9562.html#section-6.1-2.7 + // + // Note: Unsetting node here causes both it and clockseq to be randomized, + // below. + state.node = undefined; } - state.msecs = now; - // Init node (do this after timestamp update which may reset the node) + // Init node and clock sequence (do this after timestamp update which may + // reset the node) https://www.rfc-editor.org/rfc/rfc9562.html#section-5.1-7 + // + // Note: if (!state.node) { state.node = rnds.slice(10, 16); @@ -107,19 +118,10 @@ export function updateV1State(state: V1State, now: number, rnds: Uint8Array) { // Clock sequence must be randomized // https://www.rfc-editor.org/rfc/rfc9562.html#section-5.1-8 - state.clockseq = undefined; + state.clockseq = ((rnds[8] << 8) | rnds[9]) & 0x3fff; } - // Init clock sequence to random value (do this after node initialization, - // which may reset the clock sequence) - // https://www.rfc-editor.org/rfc/rfc9562.html#section-5.1-7 - if (state.clockseq === undefined) { - state.clockseq = ((rnds[6] << 8) | rnds[7]) & 0x3fff; - } else if (now < state.msecs) { - // Bump clockseq on clock regression - // https://www.rfc-editor.org/rfc/rfc9562.html#section-5.1-7 - state.clockseq = (state.clockseq + 1) & 0x3fff; - } + state.msecs = now; return state; } @@ -140,8 +142,8 @@ function v1Bytes( } msecs ??= Date.now(); nsecs ??= 0; - clockseq ??= ((rnds[6] << 8) | rnds[7]) & 0x3fff; - node ??= rnds.slice(0, 6); + clockseq ??= ((rnds[8] << 8) | rnds[9]) & 0x3fff; + node ??= rnds.slice(10, 16); // Offset to Gregorian epoch // https://www.rfc-editor.org/rfc/rfc9562.html#section-5.1-1