Skip to content

Commit

Permalink
chore: make state handling unit testable
Browse files Browse the repository at this point in the history
  • Loading branch information
broofa committed Jul 16, 2024
1 parent 62dc290 commit 475eb8b
Show file tree
Hide file tree
Showing 2 changed files with 115 additions and 74 deletions.
97 changes: 61 additions & 36 deletions src/test/v7.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as assert from 'assert';
import test, { describe } from 'node:test';
import { Version7Options } from '../_types.js';
import parse from '../parse.js';
import stringify, { unsafeStringify } from '../stringify.js';
import v7 from '../v7.js';
import v7, { updateV7State } from '../v7.js';

/**
* fixture bit layout:
Expand Down Expand Up @@ -48,25 +49,7 @@ describe('v7', () => {
0x8f
);

// const expectedBytes = parse('017f22e2-79b0-7cc3-98c4-dc0c0c07398f');
const expectedBytes = Uint8Array.of(
1,
127,
34,
226,
121,
176,
124,
195,
152,
196,
220,
12,
12,
7,
57,
143
);
const expectedBytes = parse('017f22e2-79b0-7cc3-98c4-dc0c0c07398f');

test('subsequent UUIDs are different', () => {
const id1 = v7();
Expand All @@ -80,7 +63,7 @@ describe('v7', () => {
msecs: msecsFixture,
seq: seqFixture,
});
assert.strictEqual(id, '017f22e2-79b0-7661-ac62-6c0c0c07398f');
assert.strictEqual(id, '017f22e2-79b0-7cc3-98c4-dc0c0c07398f');
});

test('explicit options.rng produces expected result', () => {
Expand All @@ -89,7 +72,7 @@ describe('v7', () => {
msecs: msecsFixture,
seq: seqFixture,
});
assert.strictEqual(id, '017f22e2-79b0-7661-ac62-6c0c0c07398f');
assert.strictEqual(id, '017f22e2-79b0-7cc3-98c4-dc0c0c07398f');
});

test('explicit options.msecs produces expected result', () => {
Expand All @@ -112,7 +95,7 @@ describe('v7', () => {
stringify(buffer);

assert.deepEqual(unsafeStringify(buffer), unsafeStringify(expectedBytes));
// assert.strictEqual(buffer, result);
assert.strictEqual(buffer, result);
});

test('fills two UUIDs into a buffer as expected', () => {
Expand Down Expand Up @@ -157,7 +140,7 @@ describe('v7', () => {
msecs += 1;
}

id = v7({ msecs });
id = v7({ msecs, seq: i });

if (prior !== undefined) {
assert.ok(prior < id, `${prior} < ${id}`);
Expand All @@ -167,18 +150,60 @@ describe('v7', () => {
}
});

test('handles seq rollover', () => {
const msecs = msecsFixture;
const a = v7({
msecs,
seq: 0x7fffffff,
});

v7({ msecs });

const c = v7({ msecs });

assert.ok(a < c, `${a} < ${c}`);
test('internal state handling', () => {
const tests = [
{
// new time interval (first uuid)
state: { msecs: 1, seq: 123 },
now: 2,
expected: {
msecs: 2, // time interval should update
seq: 0x6c318c4, // sequence should be randomized
},
},
{
// same time interval (subsequent uuid)
state: { msecs: 1, seq: 123 },
now: 1,
expected: {
msecs: 1, // timestamp unchanged
seq: 124, // sequence incremented
},
},
{
// same time interval (sequence rollover)
state: { msecs: 1, seq: 0x7fffffff },
now: 1,
expected: {
msecs: 2, // timestamp increments
seq: 0, // sequence rolls over
},
},
{
// time regression (should only increment sequence)
state: { msecs: 2, seq: 123 },
now: 1,
expected: {
msecs: 2, // time unchanged
seq: 124, // sequence rolls over
},
},
{
// time regression (sequence rollover)
state: { msecs: 2, seq: 0x7fffffff },
now: 1,
expected: {
// timestamp increments (crazy, right? system clock goes backwards,
// but the timestamp moves forward to maintain monotonicity... and
// this is why we have unit tests!)
msecs: 3,
seq: 0, // sequence rolls over
},
},
];
for (const { state, now, expected } of tests) {
assert.deepStrictEqual(updateV7State(state, now, randomBytesFixture), expected);
}
});

test('can supply seq', () => {
Expand Down
92 changes: 54 additions & 38 deletions src/v7.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ import { UUIDTypes, Version7Options } from './_types.js';
import rng from './rng.js';
import { unsafeStringify } from './stringify.js';

// Internally-maintained generator state
let _msecs = -Infinity; // -Infinity here forces seq initialization
let _seq = 0;
type V7State = {
msecs: number;
seq: number;
};

const _state: V7State = {
msecs: -Infinity, // time, milliseconds
seq: 0, // sequence number (31-bits)
};

function v7(options?: Version7Options, buf?: undefined, offset?: number): string;
function v7(options?: Version7Options, buf?: Uint8Array, offset?: number): Uint8Array;
function v7(options?: Version7Options, buf?: Uint8Array, offset?: number): UUIDTypes {
let bytes: Uint8Array;

if (options) {
// console.log('options', options);
// Options supplied = UUID is independent of internal state
// w/ options, ake a UUID independent of internal state
bytes = v7Bytes(
options.random ?? options.rng?.() ?? rng(),
options.msecs,
Expand All @@ -22,26 +27,44 @@ function v7(options?: Version7Options, buf?: Uint8Array, offset?: number): UUIDT
offset
);
} else {
// console.log('no options');
// No options = Use internal state
const msecs = Date.now();
const now = Date.now();
const rnds = rng();

let rnds: Uint8Array | undefined;
updateV7State(_state, now, rnds);

// Randomize sequence counter when time interval changes
if (msecs > _msecs) {
rnds = rng();
_seq = (rnds[6] << 23) | (rnds[7] << 16) | (rnds[8] << 8) | rnds[9];
} else {
_seq++;
}
bytes = v7Bytes(rnds, _state.msecs, _state.seq, buf, offset);
}

_msecs = msecs;
return buf ? bytes : unsafeStringify(bytes);
}

bytes = v7Bytes(rnds || rng(), _msecs, _seq, buf, offset);
// PRIVATE API. This is only exported for testing purposes and should not be
// depended upon by external code. May change without notice.
export function updateV7State(state: V7State, now: number, rnds: Uint8Array) {
if (now > state.msecs) {
// Time has moved on! Pick a new random sequence number
state.seq = (rnds[6] << 23) | (rnds[7] << 16) | (rnds[8] << 8) | rnds[9];
state.msecs = now;
} else {
if (state.seq === 0x7fffffff) {
// Sequence rollover, increment time to maintain monotonicity.
//
// Note:Timestamp is bumped to preserve monotonicity. This is allowed by the
// RFC and under normal circumstances will self-correct as the system clock
// catches up.
state.msecs++;
state.seq = 0;
} else {
// Time hasn't moved on (or has regressed for whatever reasons), just
// increment the sequence number
//
// NOTE: We don't update the time here to avoid breaking monotonicity
state.seq = (state.seq + 1) & 0x7fffffff;
}
}

return buf ? bytes : unsafeStringify(bytes);
return state;
}

function v7Bytes(
Expand All @@ -51,19 +74,12 @@ function v7Bytes(
buf = new Uint8Array(16),
offset = 0
) {
// Defaults
msecs ??= Date.now();

seq ??= ((rnds[6] * 0x7f) << 24) | (rnds[7] << 16) | (rnds[8] << 8) | rnds[9];

if (seq > 0x7fffffff) {
seq = 0x7fffffff;
}

// const seqHigh = (seq >>> 20) & 0xfff; // 12 bits
// const seqLow = seq & 0xfffff; // 20 bits
const seqHigh = (seq >>> 19) & 0xfff; // 12 bits
const seqLow = seq & 0x7ffff; // 19 bits
console.log('SEQ', seqHigh.toString(16), seqLow.toString(16));
// Mask seq to 31 bits
seq &= 0x7fffffff;

// [bytes 0-5] 48 bits of local timestamp
buf[offset++] = (msecs / 0x10000000000) & 0xff;
Expand All @@ -73,20 +89,20 @@ function v7Bytes(
buf[offset++] = (msecs / 0x100) & 0xff;
buf[offset++] = msecs & 0xff;

// [byte 6] - set 4 bits of version (7) with first 4 bits seq_hi
buf[offset++] = ((seqHigh >>> 8) & 0x0f) | 0x70;
// [byte 6] - `version` | seq bits 31-28
buf[offset++] = 0x70 | ((seq >>> 27) & 0x0f);

// [byte 7] remaining 8 bits of seq_hi
buf[offset++] = seqHigh & 0xff;
// [byte 7] seq bits 27-20
buf[offset++] = (seq >>> 19) & 0xff;

// [byte 8] - variant (2 bits), first 6 bits seqLow
buf[offset++] = ((seqLow >>> 13) & 0x3f) | 0x80;
// [byte 8] - `variant` (2 bits) | seq bits 19-14
buf[offset++] = ((seq >>> 13) & 0x3f) | 0x80;

// [byte 9] 8 bits seqLow
buf[offset++] = (seqLow >>> 5) & 0xff;
// [byte 9] seq bits 13-6
buf[offset++] = (seq >>> 5) & 0xff;

// [byte 10] remaining 5 bits seqLow, 3 bits random
buf[offset++] = ((seqLow << 3) & 0xff) | (rnds[10] & 0x03);
// [byte 10] seq bits 5-0 | random (3 bits)
buf[offset++] = ((seq << 3) & 0xff) | (rnds[10] & 0x07);

// [bytes 11-15] always random
buf[offset++] = rnds[11];
Expand Down

0 comments on commit 475eb8b

Please sign in to comment.