Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(uuid/unstable): @std/uuid/v7 #5887

Merged
merged 31 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d80bb5b
feat(uuid): add uuid v7 generation and validation
Liam-Tait Sep 1, 2024
0fd0f4a
remove as string
Liam-Tait Sep 1, 2024
60ec367
fmt
Liam-Tait Sep 1, 2024
5f7309f
Merge branch 'main' into liam/uuid-v7
kt3k Sep 2, 2024
d663119
update mod-exports check
kt3k Sep 2, 2024
3652189
mark more items experimental
kt3k Sep 2, 2024
e9b591e
fix test name
kt3k Sep 2, 2024
ac0d472
call getRandomValues once
Liam-Tait Sep 2, 2024
0d66388
Merge remote-tracking branch 'upstream/main' into liam/uuid-v7
Liam-Tait Sep 2, 2024
32046e6
add checks for user provided timestamp
Liam-Tait Sep 2, 2024
73aa56b
fmt
Liam-Tait Sep 2, 2024
76c85ae
consolidate checks
Liam-Tait Sep 2, 2024
bb06deb
fix missing options.timestamp
Liam-Tait Sep 2, 2024
ae96347
consolidate error check
Liam-Tait Sep 3, 2024
9d2620b
use pre-shifted variant and version
Liam-Tait Sep 3, 2024
53b320c
add extractTimestamp function for UUIDv7
Liam-Tait Sep 3, 2024
79b7e63
remove random option from uuid v7 generate
Liam-Tait Sep 3, 2024
894a3fb
fix import statements for extractTimestamp function in uuid/v7.ts
Liam-Tait Sep 3, 2024
bd2e4da
Merge remote-tracking branch 'upstream/main' into liam/uuid-v7
Liam-Tait Sep 3, 2024
1106f16
remove bad comment
Liam-Tait Sep 3, 2024
3c15bcf
tweaks
iuioiua Sep 3, 2024
e26c9f0
add uuid v7 module doc
Liam-Tait Sep 3, 2024
fae3b72
fmt
Liam-Tait Sep 3, 2024
38c2395
align extractTimestamp invalid uuid error message with style guide
Liam-Tait Sep 3, 2024
39830fc
fmt
Liam-Tait Sep 3, 2024
4875b4a
add experimental tags
Liam-Tait Sep 3, 2024
1a9ca8c
Merge remote-tracking branch 'upstream/main' into liam/uuid-v7
Liam-Tait Sep 3, 2024
7dfa002
use timestamp argument instead of options generate v7 uuid
Liam-Tait Sep 4, 2024
26012fb
fmt
Liam-Tait Sep 4, 2024
a1bb980
Merge remote-tracking branch 'upstream/main' into liam/uuid-v7
Liam-Tait Sep 4, 2024
3acb89f
tweak
iuioiua Sep 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions _tools/check_mod_exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ for await (
/uuid(\/|\\)v3\.ts$/,
/uuid(\/|\\)v4\.ts$/,
/uuid(\/|\\)v5\.ts$/,
/uuid(\/|\\)v7\.ts$/,
/yaml(\/|\\)schema\.ts$/,
/test\.ts$/,
/\.d\.ts$/,
Expand Down
3 changes: 2 additions & 1 deletion uuid/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"./v1": "./v1.ts",
"./v3": "./v3.ts",
"./v4": "./v4.ts",
"./v5": "./v5.ts"
"./v5": "./v5.ts",
"./v7": "./v7.ts"
}
}
26 changes: 26 additions & 0 deletions uuid/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ import { generate as generateV1, validate as validateV1 } from "./v1.ts";
import { generate as generateV3, validate as validateV3 } from "./v3.ts";
import { validate as validateV4 } from "./v4.ts";
import { generate as generateV5, validate as validateV5 } from "./v5.ts";
import {
extractTimestamp as extractTimestampV7,
generate as generateV7,
validate as validateV7,
} from "./v7.ts";

/**
* Generator and validator for
Expand Down Expand Up @@ -106,3 +111,24 @@ export const v5 = {
generate: generateV5,
validate: validateV5,
};

/**
* Generator and validator for
* {@link https://www.rfc-editor.org/rfc/rfc9562.html#section-5.7 | UUIDv7}.
*
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example Usage
* ```ts
* import { v7 } from "@std/uuid";
* import { assert } from "@std/assert";
*
* const uuid = v7.generate();
* assert(v7.validate(uuid));
* ```
*/
export const v7 = {
generate: generateV7,
validate: validateV7,
extractTimestamp: extractTimestampV7,
};
117 changes: 117 additions & 0 deletions uuid/v7.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.

/**
* Functions for working with UUID Version 7 strings.
*
* UUID Version 7 is defined in {@link https://www.rfc-editor.org/rfc/rfc9562.html#section-5.7 | RFC 9562}.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example
* ```ts
* import { generate, validate, extractTimestamp } from "@std/uuid/v7";
* import { assert, assertEquals } from "@std/assert";
*
* const uuid = generate();
* assert(validate(uuid));
* assertEquals(extractTimestamp("017f22e2-79b0-7cc3-98c4-dc0c0c07398f"), 1645557742000);
* ```
*
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
* @module
*/

import { bytesToUuid } from "./_common.ts";

const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Determines whether a string is a valid
* {@link https://www.rfc-editor.org/rfc/rfc9562.html#section-5.7 | UUIDv7}.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @param id UUID value.
*
* @returns `true` if the string is a valid UUIDv7, otherwise `false`.
*
* @example Usage
* ```ts
* import { validate } from "@std/uuid/v7";
* import { assert, assertFalse } from "@std/assert";
*
* assert(validate("017f22e2-79b0-7cc3-98c4-dc0c0c07398f"));
* assertFalse(validate("fac8c1e0-ad1a-4204-a0d0-8126ae84495d"));
* ```
*/
export function validate(id: string): boolean {
return UUID_RE.test(id);
}

/**
* Generates a {@link https://www.rfc-editor.org/rfc/rfc9562.html#section-5.7 | UUIDv7}.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @throws {RangeError} If the timestamp is not a non-negative integer.
*
* @param timestamp Unix Epoch timestamp in milliseconds.
*
* @returns Returns a UUIDv7 string
*
* @example Usage
* ```ts
* import { generate, validate } from "@std/uuid/v7";
* import { assert } from "@std/assert";
*
* const uuid = generate();
* assert(validate(uuid));
* ```
*/
export function generate(timestamp: number = Date.now()): string {
const bytes = new Uint8Array(16);
const view = new DataView(bytes.buffer);
// Unix timestamp in milliseconds (truncated to 48 bits)
if (!Number.isInteger(timestamp) || timestamp < 0) {
throw new RangeError(
`Cannot generate UUID as timestamp must be a non-negative integer: timestamp ${timestamp}`,
);
}
view.setBigUint64(0, BigInt(timestamp) << 16n);
crypto.getRandomValues(bytes.subarray(6));
// Version (4 bits) Occupies bits 48 through 51 of octet 6.
view.setUint8(6, (view.getUint8(6) & 0b00001111) | 0b01110000);
// Variant (2 bits) Occupies bits 64 through 65 of octet 8.
view.setUint8(8, (view.getUint8(8) & 0b00111111) | 0b10000000);
return bytesToUuid(bytes);
}

/**
* Extracts the timestamp from a UUIDv7.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @param uuid UUIDv7 string to extract the timestamp from.
* @returns Returns the timestamp in milliseconds.
*
* @throws {TypeError} If the UUID is not a valid UUIDv7.
*
* @example Usage
* ```ts
* import { extractTimestamp } from "@std/uuid/v7";
* import { assertEquals } from "@std/assert";
*
* const uuid = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f";
* const timestamp = extractTimestamp(uuid);
* assertEquals(timestamp, 1645557742000);
* ```
*/
export function extractTimestamp(uuid: string): number {
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
if (!validate(uuid)) {
throw new TypeError(
`Cannot extract timestamp because the UUID is not a valid UUIDv7: uuid is "${uuid}"`,
);
}
const timestampHex = uuid.slice(0, 8) + uuid.slice(9, 13);
return parseInt(timestampHex, 16);
}
94 changes: 94 additions & 0 deletions uuid/v7_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import { assert, assertEquals, assertThrows } from "@std/assert";
import { extractTimestamp, generate, validate } from "./v7.ts";
import { stub } from "../testing/mock.ts";

Deno.test("generate() generates a non-empty string", () => {
const u1 = generate();

assertEquals(typeof u1, "string", "returns a string");
assert(u1 !== "", "return string is not empty");
});

Deno.test("generate() generates UUIDs in version 7 format", () => {
for (let i = 0; i < 10000; i++) {
const u = generate();
assert(validate(u), `${u} is not a valid uuid v7`);
}
});

Deno.test("generate() generates a UUIDv7 matching the example test vector", () => {
/**
* Example test vector from the RFC:
* {@see https://www.rfc-editor.org/rfc/rfc9562.html#appendix-A.6}
*/
const timestamp = 0x017F22E279B0;
const random = new Uint8Array([
// rand_a = 0xCC3
0xC,
0xC3,
// rand_b = 0b01, 0x8C4DC0C0C07398F
0x18,
0xC4,
0xDC,
0x0C,
0x0C,
0x07,
0x39,
0x8F,
]);
using _getRandomValuesStub = stub(crypto, "getRandomValues", (array) => {
for (let index = 0; index < (random.length); index++) {
array[index] = random[index]!;
}
return random;
});
const u = generate(timestamp);
assertEquals(u, "017f22e2-79b0-7cc3-98c4-dc0c0c07398f");
});

Deno.test("generate() throws on invalid timestamp", () => {
assertThrows(
() => generate(-1),
RangeError,
"Cannot generate UUID as timestamp must be a non-negative integer: timestamp -1",
);
assertThrows(
() => generate(NaN),
RangeError,
"Cannot generate UUID as timestamp must be a non-negative integer: timestamp NaN",
);
assertThrows(
() => generate(2.3),
RangeError,
"Cannot generate UUID as timestamp must be a non-negative integer: timestamp 2.3",
);
});

Deno.test("validate() checks if a string is a valid v7 UUID", () => {
const u = generate();
const t = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f";
assert(validate(u), `generated ${u} should be valid`);
assert(validate(t), `${t} should be valid`);
});

Deno.test("extractTimestamp(uuid) extracts the timestamp from a UUIDv7", () => {
const u = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f";
const now = Date.now();
const u2 = generate(now);
assertEquals(extractTimestamp(u), 1645557742000);
assertEquals(extractTimestamp(u2), now);
});

Deno.test("extractTimestamp(uuid) throws on invalid UUID", () => {
assertThrows(
() => extractTimestamp("invalid-uuid"),
TypeError,
`Cannot extract timestamp because the UUID is not a valid UUIDv7: uuid is "invalid-uuid"`,
);
assertThrows(
() => extractTimestamp(crypto.randomUUID()),
TypeError,
`Cannot extract timestamp because the UUID is not a valid UUIDv7:`,
);
});