diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..882baa9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @babiabeo \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ff4dda8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: ci + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + deno: [canary] + os: + - ubuntu-22.04 + - windows-2022 + - macOS-12 + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: ${{ matrix.deno }} + + - name: Install packages + run: deno add @std/assert + + - name: Run tests + run: deno task test + + lint: + runs-on: ubuntu-22.04 + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: canary + + - name: Check linting + run: deno task lint \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..e094d33 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,16 @@ +name: Publish + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # The OIDC ID token is used for authentication with JSR. + steps: + - uses: actions/checkout@v4 + - run: npx jsr publish \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1331696 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode +deno.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b2c4d8c --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024 David (babiabeo) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b60df56 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# uuid-v7 + +[![JSR](https://jsr.io/badges/@babia/uuid-v7)][jsr] +[![JSR Score](https://jsr.io/badges/@babia/uuid-v7/score)][jsr] +[![CI](https://github.com/babiabeo/uuid-v7/actions/workflows/ci.yml/badge.svg)](https://github.com/babiabeo/uuid-v7/actions/workflows/ci.yml) + +The `uuid-v7` package provides UUIDv7 generator and validator based on [RFC 9562][rfc]. + +UUIDv7 features a time-ordered value field derived from the widely implemented +and well-known Unix Epoch timestamp source, the number of milliseconds since +midnight 1 Jan 1970 UTC, leap seconds excluded. + +## Quick start + +#### Install + +This package is available on [jsr.io][jsr]. + +```sh +# Deno +deno add @babia/uuid-v7 + +# npm +npx jsr add @babia/uuid-v7 + +# yarn +yarn dlx jsr add @babia/uuid-v7 + +# pnpm +pnpm dlx jsr add @babia/uuid-v7 + +# Bun +bunx jsr add @babia/uuid-v7 +``` + +#### Example: Generate a new uuid + +```ts +import { generate } from "@babia/uuid-v7"; + +generate() // => 01912d68-783e-7a03-8467-5661c1243ad4 +``` + +## Documentation + +The documentation for the package can be found here: + +https://jsr.io/@babia/uuid-v7/doc + +## License + +This package is licensed for use under [MIT License](./LICENSE). + +[jsr]: https://jsr.io/@babia/uuid-v7 +[rfc]: https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-7 + +--- + +###### Copyright © 2024 David (babiabeo) \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..d6e8b2f --- /dev/null +++ b/deno.json @@ -0,0 +1,20 @@ +{ + "name": "@babia/uuid-v7", + "version": "0.1.0", + "exports": { + ".": "./mod.ts", + "./validate": "./src/validate.ts", + "./generate": "./src/generate.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.2" + }, + "exclude": [".vscode", "docs", ".github", "README.md", "LICENSE"], + "tasks": { + "check": "deno publish --dry-run --allow-dirty", + "lint": "deno fmt --check && deno lint", + "test": "deno test --trace-leaks --parallel --clean", + "ok": "deno task lint && deno task test && deno task check ", + "doc:view": "deno doc --html --name=\"@babia/uuid-v7\" ./mod.ts" + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..6369f86 --- /dev/null +++ b/mod.ts @@ -0,0 +1,13 @@ +/** + * The `uuid-v7` package provides UUIDv7 generator and validator based on + * {@link https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-7 | RFC 9562}. + * + * UUIDv7 features a time-ordered value field derived from the widely implemented + * and well-known Unix Epoch timestamp source, the number of milliseconds since + * midnight 1 Jan 1970 UTC, leap seconds excluded + * + * @module + */ + +export { generate } from "./src/generate.ts"; +export { validate } from "./src/validate.ts"; diff --git a/src/_stringify.ts b/src/_stringify.ts new file mode 100644 index 0000000..8dce6ff --- /dev/null +++ b/src/_stringify.ts @@ -0,0 +1,33 @@ +// Based on: https://github.com/uuidjs/uuid/blob/1e0f9870db864ca93f7a69db0d468b5e1b7605e7/src/stringify.ts + +const byteToHex: string[] = []; + +for (let i = 0; i < 256; ++i) { + byteToHex.push((i + 0x100).toString(16).slice(1)); +} + +/** Converts uuid from bytes to string. */ +export function stringify(buf: Uint8Array): string { + return ( + byteToHex[buf[0]] + + byteToHex[buf[1]] + + byteToHex[buf[2]] + + byteToHex[buf[3]] + + "-" + + byteToHex[buf[4]] + + byteToHex[buf[5]] + + "-" + + byteToHex[buf[6]] + + byteToHex[buf[7]] + + "-" + + byteToHex[buf[8]] + + byteToHex[buf[9]] + + "-" + + byteToHex[buf[10]] + + byteToHex[buf[11]] + + byteToHex[buf[12]] + + byteToHex[buf[13]] + + byteToHex[buf[14]] + + byteToHex[buf[15]] + ).toLowerCase(); +} diff --git a/src/generate.ts b/src/generate.ts new file mode 100644 index 0000000..32eaed3 --- /dev/null +++ b/src/generate.ts @@ -0,0 +1,73 @@ +import { stringify } from "./_stringify.ts"; + +/** + * Generates an {@link https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-7 | UUID version 7} + * based on Unix timestamp. + * + * @param timestamp The custom timestamp to generate the UUID. + * @returns A verion 7 UUID + * + * @example Usage + * ```ts + * import { generate } from "@babia/uuid-v7"; + * + * const u1 = generate(); // Using the current timestamp + * const u2 = generate(123); // Using the custom timestamp + * ``` + */ +export function generate(timestamp?: number): string { + const uuid = new Uint8Array(16); + const tm = timestamp ?? Date.now(); + const rand = crypto.getRandomValues(new Uint8Array(9)); + const seq = getSeq(tm, rand); + + // [octets 0-5]: timestamp (48 bits) + uuid[0] = (tm / 0x10000000000) & 0xff; + uuid[1] = (tm / 0x100000000) & 0xff; + uuid[2] = (tm / 0x1000000) & 0xff; + uuid[3] = (tm / 0x10000) & 0xff; + uuid[4] = (tm / 0x100) & 0xff; + uuid[5] = tm & 0xff; + + // [octet 6]: ver (4 bits) | seq bits 14-17 (4 bits) + uuid[6] = 0x70 | ((seq >>> 14) & 0x0f); + + // [octet 7]: seq bits 6-13 (8 bits) + uuid[7] = (seq >>> 6) & 0xff; + + // [octet 8]: var (2 bits) | seq bits 0-5 (6 bits) + uuid[8] = 0x80 | (seq & 0x3f); + + // [octets 9-15]: random (56 bits) + uuid[9] = rand[2]; + uuid[10] = rand[3]; + uuid[11] = rand[4]; + uuid[12] = rand[5]; + uuid[13] = rand[6]; + uuid[14] = rand[7]; + uuid[15] = rand[8]; + + return stringify(uuid); +} + +// The last time the function is called +let _lastTime: number = -Infinity; +// The sequence number (18 bits) +let _seq: number | null = null; + +function getSeq(now: number, rand: Uint8Array): number { + _seq ??= ((now & 0x03) << 16) | (rand[0] << 8) | rand[1]; + + if (now > _lastTime) { + _lastTime = now; + return _seq; + } + + _seq = (_seq + 1) & 0x3ffff; + + if (_seq === 0) { + ++_lastTime; + } + + return _seq; +} diff --git a/src/uuid_test.ts b/src/uuid_test.ts new file mode 100644 index 0000000..e56b1c8 --- /dev/null +++ b/src/uuid_test.ts @@ -0,0 +1,47 @@ +import { assert, assertEquals, assertNotEquals } from "@std/assert"; +import { generate } from "./generate.ts"; +import { validate } from "./validate.ts"; + +// https://datatracker.ietf.org/doc/html/rfc9562#name-example-of-a-uuidv7-value +const RFC_TIMESTAMP = 0x017F22E279B0; + +Deno.test("Check if the version of the provided uuid is 7", () => { + assertEquals(validate(generate()), true); + assertEquals(validate(generate(RFC_TIMESTAMP)), true); + assertEquals(validate(crypto.randomUUID()), false); + assertEquals(validate("00000000-0000-0000-0000-000000000000"), false); +}); + +Deno.test("Each uuid is unique", () => { + assertNotEquals(generate(), generate()); +}); + +Deno.test("Timestamp can be equal, but uuids cannot", () => { + const uuids = new Set(); + + for (let i = 0; i < 200; ++i) { + const uuid = generate(RFC_TIMESTAMP); + assert(!uuids.has(uuid)); + uuids.add(uuid); + } +}); + +Deno.test("Check uuids monotonicity", () => { + let u1 = generate(); + + for (let i = 0; i < 10000; ++i) { + const u2 = generate(); + assert(u2 > u1, `Monotonicity failed: ${u2} <= ${u1}`); + u1 = u2; + } +}); + +Deno.test("Check uuids monotonicity with the same timestamp", () => { + let u1 = generate(RFC_TIMESTAMP); + + for (let i = 0; i < 10000; ++i) { + const u2 = generate(RFC_TIMESTAMP); + assert(u2 > u1, `Monotonicity failed: ${u2} <= ${u1}`); + u1 = u2; + } +}); diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..3ac86d3 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,23 @@ +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://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-7 | UUID v7}. + * + * @param uuid The UUID value + * + * @returns `true` if the string is a valid UUID v7, otherwise `false`. + * + * @example Usage + * ```ts + * import { validate } from "@babia/uuid-v7"; + * import { assert, assertFalse } from "@std/assert"; + * + * assert(validate("01912747-539e-7817-a728-739eee071268")); + * assertFalse(validate("943bb280-732e-4ae4-a4a5-c931fc67d891")); + * ``` + */ +export function validate(uuid: string): boolean { + return UUID_RE.test(uuid); +}