Skip to content

Commit

Permalink
refactor: clearer custom timestamp generation logic
Browse files Browse the repository at this point in the history
  • Loading branch information
TheEdoRan committed Jul 4, 2024
1 parent d4ed041 commit fa20f9c
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 55 deletions.
71 changes: 16 additions & 55 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,13 @@
function addHyphens(id: string) {
return (
id.substring(0, 8) +
"-" +
id.substring(8, 12) +
"-" +
id.substring(12, 16) +
"-" +
id.substring(16, 20) +
"-" +
id.substring(20)
);
}

// Generates the 12 [rand_a] and 62 bits [rand_b] parts in number and bigint formats.
function genRandParts() {
const v4 = crypto.randomUUID();

return {
randA: parseInt(v4.slice(15, 18), 16),
randB: BigInt("0x" + v4.replace(/-/g, "")) & ((1n << 62n) - 1n),
};
}
import { TimestampUUIDv7 } from "./timestamp-uuid";
import { addHyphens, genRandParts } from "./utils";

export class UUIDv7 {
#lastTimestamp: number = -1;
#lastCustomTimestamp: number = -1;
#lastRandA: number;
#lastRandB: bigint;
#lastUUID: bigint = -1n;
#encodeAlphabet: string;
#timestampUUID: TimestampUUIDv7;

/**
* Generates a new `UUIDv7` instance.
Expand All @@ -46,6 +25,7 @@ export class UUIDv7 {
}

this.#encodeAlphabet = opts?.encodeAlphabet ?? "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
this.#timestampUUID = new TimestampUUIDv7();
}

/**
Expand All @@ -56,29 +36,25 @@ export class UUIDv7 {
gen(customTimestamp?: number) {
const hasCustomTimestamp = typeof customTimestamp === "number";

if (hasCustomTimestamp && (customTimestamp < 0 || customTimestamp > 2 ** 48 - 1)) {
throw new Error("uuidv7 gen error: custom timestamp must be between 0 and 2 ** 48 - 1");
if (hasCustomTimestamp) {
return this.#timestampUUID.gen(customTimestamp);
}

let uuid = this.#lastUUID;

while (this.#lastUUID >= uuid) {
const timestamp = hasCustomTimestamp ? customTimestamp : Date.now();
const timestamp = Date.now();

let randA: number;
let randB: bigint;

// Generate new [rand_a] and [rand_b] parts if (or):
// - custom timestamp is provided and is different from the last custom stored one;
// - custom timestamp is not provided and current one is ahead of the last stored one.
if (hasCustomTimestamp ? timestamp !== this.#lastCustomTimestamp : timestamp > this.#lastTimestamp) {
// Generate new [rand_a] and [rand_b] parts if current timestamp one is ahead of the last stored one.
if (timestamp > this.#lastTimestamp) {
const parts = genRandParts();
randA = parts.randA;
randB = parts.randB;
} else if (!hasCustomTimestamp && timestamp < this.#lastTimestamp) {
// If custom timestamp is not provided and current timestamp is behind the last stored one,
// it means that the system clock went backwards. So wait until it goes ahead before generating new UUIDs.
// If custom timestamp is provided, this doesn't matter, since timestamp is always fixed.
} else if (timestamp < this.#lastTimestamp) {
// The system clock went backwards. So wait until it goes ahead before generating new UUIDs.
continue;
} else {
// Otherwise, current timestamp is the same as the previous stored one.
Expand All @@ -101,16 +77,10 @@ export class UUIDv7 {
randA = randA + 1;

// If the [rand_a] part overflows its 12 bits,
// Skip this loop iteration, since both [rand_a] and [rand_b] counters have overflowed.
// This ensures monotonicity per instance.
if (randA > 2 ** 12 - 1) {
// if custom timestamp is provided, generate new [rand_a] part.
// This breaks monotonicity but keeps custom timestamp the same and generates a new ID.
if (hasCustomTimestamp) {
randA = genRandParts().randA;
} else {
// if custom timestamp is not provided, skip this loop iteration, since both
// [rand_a] and [rand_b] counters have overflowed. This ensures monotonicity per instance.
continue;
}
continue;
}
}
}
Expand All @@ -133,19 +103,10 @@ export class UUIDv7 {
this.#lastRandA = randA;
this.#lastRandB = randB;

// If custom timestamp is provided, always break the loop, since a valid UUIDv7 for this timestamp
// was generated.
if (hasCustomTimestamp) {
this.#lastCustomTimestamp = timestamp;
break;
} else {
this.#lastTimestamp = timestamp;
}
this.#lastTimestamp = timestamp;
}

if (!hasCustomTimestamp) {
this.#lastUUID = uuid;
}
this.#lastUUID = uuid;

return addHyphens(uuid.toString(16).padStart(32, "0"));
}
Expand Down
71 changes: 71 additions & 0 deletions src/timestamp-uuid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { addHyphens, genRandParts } from "./utils";

export class TimestampUUIDv7 {
#lastTimestamp: number = -1;
#lastRandA: number;
#lastRandB: bigint;

/**
* Generates a new UUIDv7 with custom timestamp.
*
* @param timestamp Custom timestamp in milliseconds
* @returns
*/
gen(timestamp: number) {
if (timestamp < 0 || timestamp > 2 ** 48 - 1) {
throw new Error("uuidv7 gen error: custom timestamp must be between 0 and 2 ** 48 - 1");
}

let uuid = 0n;
let randA: number;
let randB: bigint;

if (timestamp !== this.#lastTimestamp) {
const parts = genRandParts();
randA = parts.randA;
randB = parts.randB;
} else {
// Keep the same [rand_a] part by default.
randA = this.#lastRandA;

// Random increment value is between 1 and 2 ** 32 (4,294,967,296).
randB = this.#lastRandB + BigInt(crypto.getRandomValues(new Uint32Array(1))[0]! + 1);

// In the rare case that [rand_b] overflows its 62 bits after the increment,
if (randB > 2n ** 62n - 1n) {
const newParts = genRandParts();
// When [rand_b] overflows its 62 bits, always generate a new random part for it.
randB = newParts.randB;

// this will use [rand_a] part as an additional counter, incrementing it by 1.
randA = randA + 1;

// If the [rand_a] part overflows its 12 bits, use a new value for it.
if (randA > 2 ** 12 - 1) {
randA = newParts.randA;
}
}
}

// [unix_ts_ms] timestamp in milliseconds - 48 bits
uuid = BigInt(timestamp) << 80n;

// [ver] version "7" - 4 bits
uuid = uuid | (0b0111n << 76n);

// [rand_a] secondary randomly seeded counter - 12 bits
uuid = uuid | (BigInt(randA) << 64n);

// [var] variant 0b10 - 2 bits
uuid = uuid | (0b10n << 62n);

// [rand_b] primary randomly seeded counter - 62 bits
uuid = uuid | randB;

this.#lastRandA = randA;
this.#lastRandB = randB;
this.#lastTimestamp = timestamp;

return addHyphens(uuid.toString(16).padStart(32, "0"));
}
}
23 changes: 23 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export function addHyphens(id: string) {
return (
id.substring(0, 8) +
"-" +
id.substring(8, 12) +
"-" +
id.substring(12, 16) +
"-" +
id.substring(16, 20) +
"-" +
id.substring(20)
);
}

// Generates the 12 [rand_a] and 62 bits [rand_b] parts in number and bigint formats.
export function genRandParts() {
const v4 = crypto.randomUUID();

return {
randA: parseInt(v4.slice(15, 18), 16),
randB: BigInt("0x" + v4.replace(/-/g, "")) & ((1n << 62n) - 1n),
};
}

0 comments on commit fa20f9c

Please sign in to comment.