From c21d7d23618ece777703016079a1cf561e6e56e9 Mon Sep 17 00:00:00 2001 From: Fajar Abdi Nugraha Date: Sat, 19 Oct 2024 21:29:53 +0700 Subject: [PATCH 1/6] seperate between ulid, monotoniculid and ulid converter --- lib/encode-decode.ts | 73 ++++++++++++++++++++++ lib/index.ts | 2 + lib/ulid-converter.ts | 41 ++++++++++++ lib/ulid-monotonic.ts | 56 +++++++++++++++++ lib/ulid.ts | 142 ++++++++---------------------------------- lib/util.ts | 8 +++ 6 files changed, 207 insertions(+), 115 deletions(-) create mode 100644 lib/encode-decode.ts create mode 100644 lib/ulid-converter.ts create mode 100644 lib/ulid-monotonic.ts diff --git a/lib/encode-decode.ts b/lib/encode-decode.ts new file mode 100644 index 0000000..220beaf --- /dev/null +++ b/lib/encode-decode.ts @@ -0,0 +1,73 @@ +import type { PRNG } from "../types/index.d.ts"; +import { GLOBAL } from "./const.ts"; +import { randomChar } from "./util.ts"; + +export function encodeTime(now: number, len: number = GLOBAL.TIME_LEN): string { + if (now > GLOBAL.TIME_MAX) { + throw new Deno.errors.InvalidData( + "cannot encode time greater than " + GLOBAL.TIME_MAX, + ); + } + if (now < 0) { + throw new Deno.errors.InvalidData("time must be positive"); + } + if (Number.isInteger(now) === false) { + throw new Deno.errors.InvalidData("time must be an integer"); + } + let str = ""; + for (; len > 0; len--) { + const mod = now % GLOBAL.ENCODING_LEN; + str = GLOBAL.ENCODING[mod] + str; + now = (now - mod) / GLOBAL.ENCODING_LEN; + } + + return str; +} + +/** + * Extracts the number of milliseconds since the Unix epoch that had passed when + * the ULID was generated. If the ULID is malformed, an error will be thrown. + * + * @example Decode the time from a ULID + * ```ts + * import { decodeTime, ulid } from "@std/ulid"; + * import { assertEquals } from "@std/assert"; + * + * const timestamp = 150_000; + * const ulidString = ulid(timestamp); + * + * assertEquals(decodeTime(ulidString), timestamp); + * ``` + * + * @param ulid The ULID to extract the timestamp from. + * @returns The number of milliseconds since the Unix epoch that had passed when the ULID was generated. + */ +export function decodeTime(id: string): number { + if (id.length !== GLOBAL.TIME_LEN + GLOBAL.RANDOM_LEN) { + throw new Deno.errors.InvalidData("malformed ulid"); + } + const time = id + .substring(0, GLOBAL.TIME_LEN) + .split("") + .reverse() + .reduce((carry, char, index) => { + const encodingIndex = GLOBAL.ENCODING.indexOf(char); + if (encodingIndex === -1) { + throw new Deno.errors.InvalidData("invalid character found: " + char); + } + return (carry += encodingIndex * Math.pow(GLOBAL.ENCODING_LEN, index)); + }, 0); + if (time > GLOBAL.TIME_MAX) { + throw new Deno.errors.InvalidData("malformed ulid, timestamp too large"); + } + + return time; +} + +export function encodeRandom(len: number, prng: PRNG): string { + let str = ""; + for (; len > 0; len--) { + str = randomChar(prng) + str; + } + return str; +} diff --git a/lib/index.ts b/lib/index.ts index 0ac2ebd..36884d3 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,2 +1,4 @@ +export * from "./ulid-converter.ts"; +export * from "./ulid-monotonic.ts"; export * from "./ulid.ts"; export * from "./util.ts"; diff --git a/lib/ulid-converter.ts b/lib/ulid-converter.ts new file mode 100644 index 0000000..88cd66c --- /dev/null +++ b/lib/ulid-converter.ts @@ -0,0 +1,41 @@ +import { GLOBAL } from "./const.ts"; +import { crockford } from "./crockford.ts"; + +export function ulidToUUID(ulid: string): string { + const isValid = GLOBAL.ULID_REGEX.test(ulid); + if (!isValid) { + throw new Deno.errors.InvalidData("Invalid ULID"); + } + + const uint8Array = crockford.decode(ulid); + const uuid = Array.from(uint8Array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + return ( + uuid.substring(0, 8) + + "-" + + uuid.substring(8, 12) + + "-" + + uuid.substring(12, 16) + + "-" + + uuid.substring(16, 20) + + "-" + + uuid.substring(20) + ); +} + +export function uuidToULID(uuid: string): string { + const isValid = GLOBAL.UUID_REGEX.test(uuid); + if (!isValid) { + throw new Deno.errors.InvalidData("Invalid UUID"); + } + const clean = uuid.replace(/-/g, "") + .match(/.{1,2}/g); + if (!clean) { + throw new Deno.errors.InvalidData("Invalid UUID"); + } + const uint8Array = new Uint8Array(clean.map((byte) => parseInt(byte, 16))); + + return crockford.encode(uint8Array); +} diff --git a/lib/ulid-monotonic.ts b/lib/ulid-monotonic.ts new file mode 100644 index 0000000..257bf06 --- /dev/null +++ b/lib/ulid-monotonic.ts @@ -0,0 +1,56 @@ +import type { PRNG, ULID } from "../types/index.d.ts"; +import { GLOBAL } from "./const.ts"; +import { encodeRandom, encodeTime } from "./encode-decode.ts"; +import { detectPrng, incrementBase32 } from "./util.ts"; + +export function monotonicFactory(prng: PRNG = detectPrng()): ULID { + let lastTime = 0; + let lastRandom: string; + return function ulid(seedTime: number = Date.now()): string { + if (seedTime <= lastTime) { + const incrementedRandom = (lastRandom = incrementBase32(lastRandom)); + return encodeTime(lastTime, GLOBAL.TIME_LEN) + incrementedRandom; + } + lastTime = seedTime; + const newRandom = (lastRandom = encodeRandom(GLOBAL.RANDOM_LEN, prng)); + return encodeTime(seedTime, GLOBAL.TIME_LEN) + newRandom; + }; +} + +/** + * Generate a ULID that monotonically increases even for the same millisecond, + * optionally passing the current time. If the current time is not passed, it + * will default to `Date.now()`. + * + * Unlike the {@linkcode ulid} function, this function is guaranteed to return + * strictly increasing ULIDs, even for the same seed time, but only if the seed + * time only ever increases. If the seed time ever goes backwards, the ULID will + * still be generated, but it will not be guaranteed to be monotonic with + * previous ULIDs for that same seed time. + * + * @example Generate a monotonic ULID + * ```ts no-assert + * import { monotonicUlid } from "@std/ulid"; + * + * monotonicUlid(); // 01HYFKHG5F8RHM2PM3D7NSTDAS + * ``` + * + * @example Generate a monotonic ULID with a seed time + * ```ts no-assert + * import { monotonicUlid } from "@std/ulid"; + * + * // Strict ordering for the same timestamp, by incrementing the least-significant random bit by 1 + * monotonicUlid(150000); // 0000004JFHJJ2Z7X64FN2B4F1Q + * + * // A different timestamp will reset the random bits + * monotonicUlid(150001); // 0000004JFHJJ2Z7X64FN2B4F1P + * + * // A previous seed time will not guarantee ordering, and may result in a + * // ULID lower than one with the same seed time generated previously + * monotonicUlid(150000); // 0000004JFJ7XF6D76ES95SZR0X + * ``` + * + * @param seedTime The time to base the ULID on, in milliseconds since the Unix epoch. Defaults to `Date.now()`. + * @returns A ULID that is guaranteed to be strictly increasing for the same seed time. + */ +export const monotonicUlid = monotonicFactory(); diff --git a/lib/ulid.ts b/lib/ulid.ts index 1739968..5d39d46 100644 --- a/lib/ulid.ts +++ b/lib/ulid.ts @@ -1,67 +1,7 @@ import type { PRNG, ULID } from "../types/index.d.ts"; import { GLOBAL } from "./const.ts"; -import { incrementBase32, randomChar } from "./util.ts"; -import { crockford } from "./crockford.ts"; - -export function encodeTime(now: number, len: number = GLOBAL.TIME_LEN): string { - if (now > GLOBAL.TIME_MAX) { - throw new Deno.errors.InvalidData( - "cannot encode time greater than " + GLOBAL.TIME_MAX, - ); - } - if (now < 0) { - throw new Deno.errors.InvalidData("time must be positive"); - } - if (Number.isInteger(now) === false) { - throw new Deno.errors.InvalidData("time must be an integer"); - } - let str = ""; - for (; len > 0; len--) { - const mod = now % GLOBAL.ENCODING_LEN; - str = GLOBAL.ENCODING[mod] + str; - now = (now - mod) / GLOBAL.ENCODING_LEN; - } - - return str; -} - -export function decodeTime(id: string): number { - if (id.length !== GLOBAL.TIME_LEN + GLOBAL.RANDOM_LEN) { - throw new Deno.errors.InvalidData("malformed ulid"); - } - const time = id - .substring(0, GLOBAL.TIME_LEN) - .split("") - .reverse() - .reduce((carry, char, index) => { - const encodingIndex = GLOBAL.ENCODING.indexOf(char); - if (encodingIndex === -1) { - throw new Deno.errors.InvalidData("invalid character found: " + char); - } - return (carry += encodingIndex * Math.pow(GLOBAL.ENCODING_LEN, index)); - }, 0); - if (time > GLOBAL.TIME_MAX) { - throw new Deno.errors.InvalidData("malformed ulid, timestamp too large"); - } - - return time; -} - -export function encodeRandom(len: number, prng: PRNG): string { - let str = ""; - for (; len > 0; len--) { - str = randomChar(prng) + str; - } - return str; -} - -export function detectPrng(): PRNG { - return () => { - const buffer = new Uint8Array(1); - crypto.getRandomValues(buffer); - return buffer[0] / 0xff; - }; -} +import { encodeRandom, encodeTime } from "./encode-decode.ts"; +import { detectPrng } from "./util.ts"; export function fixULIDBase32(id: string): string { return id.replace(/i/gi, "1") @@ -88,57 +28,29 @@ export function factory(prng: PRNG = detectPrng()): ULID { }; } -export function monotonicFactory(prng: PRNG = detectPrng()): ULID { - let lastTime = 0; - let lastRandom: string; - return function ulid(seedTime: number = Date.now()): string { - if (seedTime <= lastTime) { - const incrementedRandom = (lastRandom = incrementBase32(lastRandom)); - return encodeTime(lastTime, GLOBAL.TIME_LEN) + incrementedRandom; - } - lastTime = seedTime; - const newRandom = (lastRandom = encodeRandom(GLOBAL.RANDOM_LEN, prng)); - return encodeTime(seedTime, GLOBAL.TIME_LEN) + newRandom; - }; -} - +/** + * Generate a ULID, optionally based on a given timestamp. If the timestamp is + * not passed, it will default to `Date.now()`. + * + * Multiple calls to this function with the same seed time will not guarantee + * that the ULIDs will be strictly increasing, even if the seed time is the + * same. For that, use the {@linkcode monotonicUlid} function. + * + * @example Generate a ULID + * ```ts no-assert + * import { ulid } from "@std/ulid"; + * + * ulid(); // 01HYFKMDF3HVJ4J3JZW8KXPVTY + * ``` + * + * @example Generate a ULID with a seed time + * ```ts no-assert + * import { ulid } from "@std/ulid"; + * + * ulid(150000); // 0000004JFG3EKDRE04TVVDJW7K + * ``` + * + * @param seedTime The time to base the ULID on, in milliseconds since the Unix epoch. Defaults to `Date.now()`. + * @returns A ULID. + */ export const ulid: ULID = factory(); - -export function ulidToUUID(ulid: string): string { - const isValid = GLOBAL.ULID_REGEX.test(ulid); - if (!isValid) { - throw new Deno.errors.InvalidData("Invalid ULID"); - } - - const uint8Array = crockford.decode(ulid); - const uuid = Array.from(uint8Array) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); - - return ( - uuid.substring(0, 8) + - "-" + - uuid.substring(8, 12) + - "-" + - uuid.substring(12, 16) + - "-" + - uuid.substring(16, 20) + - "-" + - uuid.substring(20) - ); -} - -export function uuidToULID(uuid: string): string { - const isValid = GLOBAL.UUID_REGEX.test(uuid); - if (!isValid) { - throw new Deno.errors.InvalidData("Invalid UUID"); - } - const clean = uuid.replace(/-/g, "") - .match(/.{1,2}/g); - if (!clean) { - throw new Deno.errors.InvalidData("Invalid UUID"); - } - const uint8Array = new Uint8Array(clean.map((byte) => parseInt(byte, 16))); - - return crockford.encode(uint8Array); -} diff --git a/lib/util.ts b/lib/util.ts index 009a5bd..dcb6796 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -39,3 +39,11 @@ export function randomChar(prng: PRNG): string { return GLOBAL.ENCODING.charAt(rand); } + +export function detectPrng(): PRNG { + return () => { + const buffer = new Uint8Array(1); + crypto.getRandomValues(buffer); + return buffer[0] / 0xff; + }; +} From 885dbf0f8ad0629be4484833e61fd7d0bde61f40 Mon Sep 17 00:00:00 2001 From: Fajar Abdi Nugraha Date: Sat, 19 Oct 2024 21:31:59 +0700 Subject: [PATCH 2/6] fix import package for unit testing --- test/bench.ts | 19 +++++++++-------- test/test.ts | 57 ++++++++++++++++++++++++++------------------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/test/bench.ts b/test/bench.ts index e8da8a4..683728f 100644 --- a/test/bench.ts +++ b/test/bench.ts @@ -1,28 +1,29 @@ -import * as ulid from "../mod.ts"; +import { encodeTime, decodeTime, encodeRandom } from "../lib/encode-decode.ts"; +import { detectPrng, ulid, ulidToUUID, uuidToULID } from "../mod.ts"; -const prng = ulid.detectPrng(); -const uliddValue = ulid.ulid(); +const prng = detectPrng(); +const uliddValue = ulid(); Deno.bench("encodeTime", function () { - ulid.encodeTime(1469918176385); + encodeTime(1469918176385); }); Deno.bench("decodeTime", function () { - ulid.decodeTime(uliddValue); + decodeTime(uliddValue); }); Deno.bench("encodeRandom", function () { - ulid.encodeRandom(10, prng); + encodeRandom(10, prng); }); Deno.bench("generate", function () { - ulid.ulid(1469918176385); + ulid(1469918176385); }); Deno.bench("ulidToUUID", function () { - ulid.ulidToUUID(uliddValue); + ulidToUUID(uliddValue); }); Deno.bench("uuidToULID", function () { - ulid.uuidToULID(crypto.randomUUID()); + uuidToULID(crypto.randomUUID()); }); diff --git a/test/test.ts b/test/test.ts index cfa6cf1..6152045 100644 --- a/test/test.ts +++ b/test/test.ts @@ -5,11 +5,12 @@ import { assertStrictEquals, assertThrows, } from "@std/assert"; -import * as ULID from "../mod.ts"; +import { ulid, incrementBase32, detectPrng, randomChar, monotonicFactory } from "../mod.ts"; +import { encodeTime, encodeRandom, decodeTime } from "../lib/encode-decode.ts"; Deno.test("ulid", async (t) => { await t.step("prng", async (t) => { - const prng = ULID.detectPrng(); + const prng = detectPrng(); await t.step("should produce a number", () => { assertEquals(false, isNaN(prng())); @@ -23,33 +24,33 @@ Deno.test("ulid", async (t) => { await t.step("incremenet base32", async (t) => { await t.step("increments correctly", () => { - assertEquals("A109D", ULID.incrementBase32("A109C")); + assertEquals("A109D", incrementBase32("A109C")); }); await t.step("carries correctly", () => { - assertEquals("A1Z00", ULID.incrementBase32("A1YZZ")); + assertEquals("A1Z00", incrementBase32("A1YZZ")); }); await t.step("double increments correctly", () => { assertEquals( "A1Z01", - ULID.incrementBase32(ULID.incrementBase32("A1YZZ")), + incrementBase32(incrementBase32("A1YZZ")), ); }); await t.step("throws when it cannot increment", () => { assertThrows(() => { - ULID.incrementBase32("ZZZ"); + incrementBase32("ZZZ"); }); }); }); await t.step("randomChar", async (t) => { const sample: Record = {}; - const prng = ULID.detectPrng(); + const prng = detectPrng(); for (let x = 0; x < 320000; x++) { - const char = String(ULID.randomChar(prng)); // for if it were to ever return undefined + const char = String(randomChar(prng)); // for if it were to ever return undefined if (sample[char] === undefined) { sample[char] = 0; } @@ -67,89 +68,89 @@ Deno.test("ulid", async (t) => { await t.step("encodeTime", async (t) => { await t.step("should return expected encoded result", () => { - assertEquals("01ARYZ6S41", ULID.encodeTime(1469918176385, 10)); + assertEquals("01ARYZ6S41", encodeTime(1469918176385, 10)); }); await t.step("should change length properly", () => { - assertEquals("0001AS99AA60", ULID.encodeTime(1470264322240, 12)); + assertEquals("0001AS99AA60", encodeTime(1470264322240, 12)); }); await t.step("should truncate time if not enough length", () => { - assertEquals("AS4Y1E11", ULID.encodeTime(1470118279201, 8)); + assertEquals("AS4Y1E11", encodeTime(1470118279201, 8)); }); await t.step("should throw an error", async (t) => { await t.step("if time greater than (2 ^ 48) - 1", () => { assertThrows(() => { - ULID.encodeTime(Math.pow(2, 48), 8); + encodeTime(Math.pow(2, 48), 8); }, Error); }); await t.step("if time is not a number", () => { assertThrows(() => { // deno-lint-ignore no-explicit-any - ULID.encodeTime("test" as any, 3); + encodeTime("test" as any, 3); }, Error); }); await t.step("if time is infinity", () => { assertThrows(() => { - ULID.encodeTime(Infinity); + encodeTime(Infinity); }, Error); }); await t.step("if time is negative", () => { assertThrows(() => { - ULID.encodeTime(-1); + encodeTime(-1); }, Error); }); await t.step("if time is a float", () => { assertThrows(() => { - ULID.encodeTime(100.1); + encodeTime(100.1); }, Error); }); }); }); await t.step("encodeRandom", async (t) => { - const prng = ULID.detectPrng(); + const prng = detectPrng(); await t.step("should return correct length", () => { - assertEquals(12, ULID.encodeRandom(12, prng).length); + assertEquals(12, encodeRandom(12, prng).length); }); }); await t.step("decodeTime", async (t) => { await t.step("should return correct timestamp", () => { const timestamp = Date.now(); - const id = ULID.ulid(timestamp); - assertEquals(timestamp, ULID.decodeTime(id)); + const id = ulid(timestamp); + assertEquals(timestamp, decodeTime(id)); }); await t.step("should accept the maximum allowed timestamp", () => { assertEquals( 281474976710655, - ULID.decodeTime("7ZZZZZZZZZZZZZZZZZZZZZZZZZ"), + decodeTime("7ZZZZZZZZZZZZZZZZZZZZZZZZZ"), ); }); await t.step("should reject", async (t) => { await t.step("malformed strings of incorrect length", () => { assertThrows(() => { - ULID.decodeTime("FFFF"); + decodeTime("FFFF"); }, Error); }); await t.step("strings with timestamps that are too high", () => { assertThrows(() => { - ULID.decodeTime("80000000000000000000000000"); + decodeTime("80000000000000000000000000"); }, Error); }); await t.step("invalid character", () => { assertThrows(() => { - ULID.decodeTime("&1ARZ3NDEKTSV4RRFFQ69G5FAV"); + decodeTime("&1ARZ3NDEKTSV4RRFFQ69G5FAV"); }, Error); }); }); @@ -157,13 +158,13 @@ Deno.test("ulid", async (t) => { await t.step("ulid", async (t) => { await t.step("should return correct length", () => { - assertEquals(26, ULID.ulid().length); + assertEquals(26, ulid().length); }); await t.step( "should return expected encoded time component result", () => { - assertEquals("01ARYZ6S41", ULID.ulid(1469918176385).substring(0, 10)); + assertEquals("01ARYZ6S41", ulid(1469918176385).substring(0, 10)); }, ); }); @@ -174,7 +175,7 @@ Deno.test("ulid", async (t) => { } await t.step("without seedTime", async (t) => { - const stubbedUlid = ULID.monotonicFactory(stubbedPrng); + const stubbedUlid = monotonicFactory(stubbedPrng); const time = new FakeTime(1469918176385); @@ -198,7 +199,7 @@ Deno.test("ulid", async (t) => { }); await t.step("with seedTime", async (t) => { - const stubbedUlid = ULID.monotonicFactory(stubbedPrng); + const stubbedUlid = monotonicFactory(stubbedPrng); await t.step("first call", () => { assertEquals("01ARYZ6S41YYYYYYYYYYYYYYYY", stubbedUlid(1469918176385)); From 604ed4d4200440de8a6fda7d8679157b448e7154 Mon Sep 17 00:00:00 2001 From: Fajar Abdi Nugraha Date: Sat, 19 Oct 2024 21:32:27 +0700 Subject: [PATCH 3/6] fix formatting --- test/bench.ts | 2 +- test/test.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/test/bench.ts b/test/bench.ts index 683728f..4acafbf 100644 --- a/test/bench.ts +++ b/test/bench.ts @@ -1,4 +1,4 @@ -import { encodeTime, decodeTime, encodeRandom } from "../lib/encode-decode.ts"; +import { decodeTime, encodeRandom, encodeTime } from "../lib/encode-decode.ts"; import { detectPrng, ulid, ulidToUUID, uuidToULID } from "../mod.ts"; const prng = detectPrng(); diff --git a/test/test.ts b/test/test.ts index 6152045..83aa5f9 100644 --- a/test/test.ts +++ b/test/test.ts @@ -5,8 +5,14 @@ import { assertStrictEquals, assertThrows, } from "@std/assert"; -import { ulid, incrementBase32, detectPrng, randomChar, monotonicFactory } from "../mod.ts"; -import { encodeTime, encodeRandom, decodeTime } from "../lib/encode-decode.ts"; +import { + detectPrng, + incrementBase32, + monotonicFactory, + randomChar, + ulid, +} from "../mod.ts"; +import { decodeTime, encodeRandom, encodeTime } from "../lib/encode-decode.ts"; Deno.test("ulid", async (t) => { await t.step("prng", async (t) => { From 76e2256b6ff1c64f6a3114048adbb6214b439136 Mon Sep 17 00:00:00 2001 From: Fajar Abdi Nugraha Date: Sat, 19 Oct 2024 21:34:34 +0700 Subject: [PATCH 4/6] fix implicit type --- lib/ulid-monotonic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ulid-monotonic.ts b/lib/ulid-monotonic.ts index 257bf06..06c4922 100644 --- a/lib/ulid-monotonic.ts +++ b/lib/ulid-monotonic.ts @@ -53,4 +53,4 @@ export function monotonicFactory(prng: PRNG = detectPrng()): ULID { * @param seedTime The time to base the ULID on, in milliseconds since the Unix epoch. Defaults to `Date.now()`. * @returns A ULID that is guaranteed to be strictly increasing for the same seed time. */ -export const monotonicUlid = monotonicFactory(); +export const monotonicUlid: ULID = monotonicFactory(); From 848f139d1cc4faad997c93024e3fea021995b76c Mon Sep 17 00:00:00 2001 From: Fajar Abdi Nugraha Date: Sat, 19 Oct 2024 21:53:13 +0700 Subject: [PATCH 5/6] add jsdoc for exported function --- lib/encode-decode.ts | 25 +++++++++++++++++++++++++ lib/ulid-converter.ts | 25 +++++++++++++++++++++++++ lib/ulid-monotonic.ts | 12 ++++++++++++ lib/ulid.ts | 34 ++++++++++++++++++++++++++++++++++ lib/util.ts | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+) diff --git a/lib/encode-decode.ts b/lib/encode-decode.ts index 220beaf..b820402 100644 --- a/lib/encode-decode.ts +++ b/lib/encode-decode.ts @@ -2,6 +2,21 @@ import type { PRNG } from "../types/index.d.ts"; import { GLOBAL } from "./const.ts"; import { randomChar } from "./util.ts"; +/** + * Extracts the timestamp given a valid ULID. + * + * @example encode the time to ULID + * ```ts + * import { encodeTime } from "@std/ulid"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals("01ARYZ6S41", encodeTime(1469918176385, 10)); + * ``` + * + * @param now The number of milliseconds since the Unix epoch. + * @param len length of the generated string. + * @returns The ULID to extract the timestamp from. + */ export function encodeTime(now: number, len: number = GLOBAL.TIME_LEN): string { if (now > GLOBAL.TIME_MAX) { throw new Deno.errors.InvalidData( @@ -64,6 +79,16 @@ export function decodeTime(id: string): number { return time; } +/** + * Encodes a random string of specified length using the provided PRNG. + * + * This function iterates for the specified length, calling the `randomChar` function with the PRNG + * to generate a random character and prepend it to the string being built. + * + * @param {number} len - The desired length of the random string. + * @param {PRNG} prng - The PRNG to use for generating random characters. + * @returns {string} A random string of the specified length. + */ export function encodeRandom(len: number, prng: PRNG): string { let str = ""; for (; len > 0; len--) { diff --git a/lib/ulid-converter.ts b/lib/ulid-converter.ts index 88cd66c..1f1875a 100644 --- a/lib/ulid-converter.ts +++ b/lib/ulid-converter.ts @@ -1,6 +1,18 @@ import { GLOBAL } from "./const.ts"; import { crockford } from "./crockford.ts"; +/** + * Converts a ULID string to a UUID string. + * + * This function validates the ULID string using a pre-defined regular expression (`GLOBAL.ULID_REGEX`). + * If invalid, it throws an `InvalidData` error. Otherwise, it decodes the ULID string using the `crockford.decode` + * function (assumed to be an external library) and converts the resulting Uint8Array to a UUID string + * in the standard format (e.g., "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"). + * + * @param {string} ulid - The ULID string to convert. + * @returns {string} The corresponding UUID string. + * @throws {Deno.errors.InvalidData} If the provided ULID is invalid. + */ export function ulidToUUID(ulid: string): string { const isValid = GLOBAL.ULID_REGEX.test(ulid); if (!isValid) { @@ -25,6 +37,19 @@ export function ulidToUUID(ulid: string): string { ); } +/** + * Converts a UUID string to a ULID string. + * + * This function validates the UUID string using a pre-defined regular expression (`GLOBAL.UUID_REGEX`). + * If invalid, it throws an `InvalidData` error. Otherwise, it removes hyphens from the UUID string and splits + * it into an array of byte pairs. It then converts each byte pair back to a number using hexadecimal parsing + * and creates a new Uint8Array. Finally, it uses the `crockford.encode` function (assumed to be an external library) + * to encode the Uint8Array into a ULID string. + * + * @param {string} uuid - The UUID string to convert. + * @returns {string} The corresponding ULID string. + * @throws {Deno.errors.InvalidData} If the provided UUID is invalid. + */ export function uuidToULID(uuid: string): string { const isValid = GLOBAL.UUID_REGEX.test(uuid); if (!isValid) { diff --git a/lib/ulid-monotonic.ts b/lib/ulid-monotonic.ts index 06c4922..820a199 100644 --- a/lib/ulid-monotonic.ts +++ b/lib/ulid-monotonic.ts @@ -3,6 +3,18 @@ import { GLOBAL } from "./const.ts"; import { encodeRandom, encodeTime } from "./encode-decode.ts"; import { detectPrng, incrementBase32 } from "./util.ts"; +/** + * Creates a ULID generation factory function that ensures monotonic generation. + * + * This factory function generates ULIDs where the timestamp is always increasing or remains the same, + * and the random part is incremented only if the timestamp doesn't change. This ensures lexicographic sorting + * based on the ULID string. + * + * The default PRNG is chosen by the `detectPrng` function. + * + * @param {PRNG} [prng=detectPrng()] - The PRNG to use for generating random parts of the ULID. + * @returns {ULID} A function that generates monotonic ULIDs. + */ export function monotonicFactory(prng: PRNG = detectPrng()): ULID { let lastTime = 0; let lastRandom: string; diff --git a/lib/ulid.ts b/lib/ulid.ts index 5d39d46..f5c17d7 100644 --- a/lib/ulid.ts +++ b/lib/ulid.ts @@ -3,6 +3,19 @@ import { GLOBAL } from "./const.ts"; import { encodeRandom, encodeTime } from "./encode-decode.ts"; import { detectPrng } from "./util.ts"; +/** + * Fixes a base32 ULID string by replacing invalid characters with their correct counterparts. + * + * This function replaces the following characters: + * - 'i' -> '1' + * - 'l' -> '1' + * - 'o' -> '0' + * - '-' (hyphen) -> '' (empty string) + * + * @param {string} id The ULID string to fix. + * @returns {string} The fixed ULID string. + * @throws {TypeError} If the provided id is not a string. + */ export function fixULIDBase32(id: string): string { return id.replace(/i/gi, "1") .replace(/l/gi, "1") @@ -10,6 +23,17 @@ export function fixULIDBase32(id: string): string { .replace(/-/g, ""); } +/** + * Validates a ULID string based on its format and character set. + * + * This function checks if the provided string: + * - is a string type + * - has the correct length (TIME_LEN + RANDOM_LEN) + * - contains only characters from the defined encoding (all characters are uppercase) + * + * @param {string} id The ULID string to validate. + * @returns {boolean} True if the string is a valid ULID, false otherwise. + */ export function isValid(id: string): boolean { return ( typeof id === "string" && @@ -21,6 +45,16 @@ export function isValid(id: string): boolean { ); } +/** + * Creates a ULID generation factory function. + * + * This factory function takes an optional PRNG (Pseudorandom Number Generator) and returns a function for generating ULIDs. + * + * The default PRNG is chosen by the `detectPrng` function. + * + * @param {PRNG} [prng=detectPrng()] - The PRNG to use for generating random parts of the ULID. + * @returns {ULID} A function that generates ULIDs. + */ export function factory(prng: PRNG = detectPrng()): ULID { return function ulid(seedTime: number = Date.now()): string { return encodeTime(seedTime, GLOBAL.TIME_LEN) + diff --git a/lib/util.ts b/lib/util.ts index dcb6796..e401782 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -1,6 +1,14 @@ import type { PRNG } from "../types/index.d.ts"; import { GLOBAL } from "./const.ts"; +/** + * Function to replace characters in certain positions + * + * @param str The string you want to replace + * @param index The start index of the character is replaced + * @param char new character to be embedded + * @returns String that has been replaced with new value + */ export function replaceCharAt( str: string, index: number, @@ -9,6 +17,15 @@ export function replaceCharAt( return str.substring(0, index) + char + str.substring(index + 1); } +/** + * Increments a base32 encoded string. + * + * This function iterates through the string from the end, incrementing characters based on the defined encoding. + * + * @param {string} str The base32 encoded string to increment. + * @returns {string} The incremented string. + * @throws {Deno.errors.InvalidData} If the string is not correctly encoded or cannot be incremented. + */ export function incrementBase32(str: string): string { let index = str.length; let char; @@ -31,6 +48,15 @@ export function incrementBase32(str: string): string { throw new Deno.errors.InvalidData("cannot increment this string"); } +/** + * Generates a random character from the defined encoding. + * + * This function uses the provided PRNG (Pseudorandom Number Generator) to generate a random integer + * within the range of the encoding length. It then uses that index to retrieve the character from the encoding string. + * + * @param {PRNG} prng - The PRNG to use for generating the random number. + * @returns {string} A random character from the encoding. + */ export function randomChar(prng: PRNG): string { let rand = Math.floor(prng() * GLOBAL.ENCODING_LEN); if (rand === GLOBAL.ENCODING_LEN) { @@ -40,6 +66,15 @@ export function randomChar(prng: PRNG): string { return GLOBAL.ENCODING.charAt(rand); } +/** + * Detects a cryptographically secure random number generator (PRNG). + * + * This function utilizes the `crypto.getRandomValues` function to generate a random byte array. + * It then returns a function that generates a random number between 0 and 1 by dividing + * the first byte of the array by 255 (0xff). + * + * @returns {PRNG} A function that generates a random number between 0 and 1. + */ export function detectPrng(): PRNG { return () => { const buffer = new Uint8Array(1); From 58832d13a39d1e17464af9f051c42cdea431e64f Mon Sep 17 00:00:00 2001 From: Fajar Abdi Nugraha Date: Sat, 19 Oct 2024 21:56:09 +0700 Subject: [PATCH 6/6] add jsdoc to mod file --- mod.ts | 39 +++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 16 ++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/mod.ts b/mod.ts index 487ed64..c62d84a 100644 --- a/mod.ts +++ b/mod.ts @@ -1,2 +1,41 @@ +/** + * Utilities for generating and working with Universally Unique Lexicographically Sortable Identifiers (ULIDs). + * + * To generate a ULID use the {@linkcode ulid} function. This will generate a + * ULID based on the current time. + * + * ```ts no-assert + * import { ulid } from "@fajar/deno-ulid"; + * + * ulid(); + * ``` + * + * {@linkcode ulid} does not guarantee that the ULIDs will be strictly + * increasing for the same current time. If you need to guarantee that the ULIDs + * will be strictly increasing, even for the same current time, use the + * {@linkcode monotonicUlid} function. + * + * ```ts no-assert + * import { monotonicUlid } from "@fajar/deno-ulid"; + * + * monotonicUlid(); // 01HYFKHG5F8RHM2PM3D7NSTDAS + * monotonicUlid(); // 01HYFKHG5F8RHM2PM3D7NSTDAT + * ``` + * + * Because each ULID encodes the time it was generated, you can extract the + * timestamp from a ULID using the {@linkcode decodeTime} function. + * + * ```ts + * import { decodeTime, ulid } from "@fajar/deno-ulid"; + * import { assertEquals } from "@std/assert"; + * + * const timestamp = 150_000; + * const ulidString = ulid(timestamp); + * + * assertEquals(decodeTime(ulidString), timestamp); + * ``` + * + * @module + */ export * from "./lib/index.ts"; export type * from "./types/index.d.ts"; diff --git a/types/index.d.ts b/types/index.d.ts index f25519e..4bbe80f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,7 +1,23 @@ +/** + * A pseudorandom number generator (PRNG) function. + * + * A PRNG is a function that returns a random number between 0 and 1. + * + * @returns {number} A random number between 0 and 1. + */ export interface PRNG { (): number; } +/** + * A function that generates a Universally Unique Lexicographically Sortable Identifier (ULID). + * + * A ULID is a 128-bit unique identifier with a time-based component, designed to be sortable. + * + * @param {number} [seedTime] - An optional timestamp to use as the basis for the ULID. + * If not provided, the current timestamp will be used. + * @returns {string} A generated ULID. + */ export interface ULID { (seedTime?: number): string; }