UNPKG

@vyckr/ttid

Version:

A lightweight, time-based identifier generator that tracks creation, update, and deletion timestamps using a progressive format.

142 lines (106 loc) 5.06 kB
export default class TTID { /** * Multiplier applied to high-resolution timestamps to preserve sub-millisecond * precision when encoding to base-36. A value of 10,000 gives ~0.1ms resolution. */ private static readonly PRECISION = 10_000 /** * Encoding base for timestamp segments. Base-36 uses digits 0–9 and letters A–Z, * yielding compact 11-character timestamps for current Unix millisecond values. */ private static readonly BASE = 36 /** * Segment placeholder used when an ID is deleted without a prior update, * preserving the three-segment TTID structure. */ private static readonly PLACEHOLDER = 'X' /** Minimum accepted timestamp (ms since epoch): 2020-01-01T00:00:00.000Z */ private static readonly MIN_TIMESTAMP_MS = 1_577_836_800_000 /** Maximum accepted timestamp (ms since epoch): 2200-01-01T00:00:00.000Z */ private static readonly MAX_TIMESTAMP_MS = 7_258_118_400_000 /** Cached compiled regex for TTID format validation, avoiding repeated recompilation. */ private static readonly TTID_PATTERN = /^[A-Z0-9]{11}(-[A-Z0-9]{1,11}){0,2}$/i /** Cached compiled regex for UUID format validation. */ private static readonly UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i private static timeNow(): number { return (performance.now() + performance.timeOrigin) * this.PRECISION } /** * Checks whether a string is a valid TTID. * @param _id - The string to validate. * @returns The creation `Date` if valid, or `null` if not. */ static isTTID(_id: string): Date | null { if (!_id || _id.length > 36) return null if (!this.TTID_PATTERN.test(_id)) return null try { const { createdAt, updatedAt, deletedAt } = this.decodeTime(_id) if (updatedAt !== undefined) new Date(updatedAt) if (deletedAt !== undefined) new Date(deletedAt) return new Date(createdAt) } catch { return null } } /** * Checks whether a string is a valid UUID (any version or variant). * @param _id - The string to validate. * @returns A `RegExpMatchArray` if valid, or `null` if not. */ static isUUID(_id: string): RegExpMatchArray | null { return _id.match(this.UUID_PATTERN) } /** * Generates a new TTID or advances an existing one through its lifecycle. * * - No arguments: creates a new single-segment TTID. * - `_id` only: updates the TTID, producing a two-segment ID. * - `_id` + `del: true`: marks the TTID as deleted, producing a three-segment ID. * * @param _id - An existing TTID to update or delete. Omit to create a new one. * @param del - When `true`, marks the TTID as deleted. * @returns The new or advanced TTID. * @throws {Error} If `_id` is already deleted (three-segment) or is not a valid TTID. */ static generate(_id?: string, del: boolean = false): _ttid { if (_id && this.isTTID(_id) && _id.split('-').length === 3) { throw new Error('This identifier can no longer be modified') } const time = this.timeNow() if (_id && this.isTTID(_id) && del) { const [created, updated] = _id.split('-') const deleted = time.toString(this.BASE) return `${created}-${updated ?? this.PLACEHOLDER}-${deleted}`.toUpperCase() as _ttid } if (_id && this.isTTID(_id)) { const [created] = _id.split('-') const updated = time.toString(this.BASE) return `${created}-${updated}`.toUpperCase() as _ttid } if (_id && !this.isTTID(_id)) throw new Error('Invalid TTID!') return time.toString(this.BASE).toUpperCase() as _ttid } /** * Decodes the timestamps embedded in a TTID. * @param _id - A valid TTID string. * @returns An object with `createdAt`, and optionally `updatedAt` and `deletedAt` (ms since epoch). * @throws {Error} If the format is invalid or any segment encodes an out-of-range timestamp. */ static decodeTime(_id: string): _timestamps { if (!this.TTID_PATTERN.test(_id)) throw new Error('Invalid Format!') const [created, updated, deleted] = _id.split('-') const convertToMilliseconds = (timeCode: string): number => { const ms = Number((parseInt(timeCode, this.BASE) / this.PRECISION).toFixed(0)) if (!isFinite(ms) || ms < this.MIN_TIMESTAMP_MS || ms > this.MAX_TIMESTAMP_MS) { throw new Error('Invalid timestamp encoding') } return ms } const timestamps: _timestamps = { createdAt: convertToMilliseconds(created) } if (updated && updated !== this.PLACEHOLDER) timestamps.updatedAt = convertToMilliseconds(updated) if (deleted) timestamps.deletedAt = convertToMilliseconds(deleted) return timestamps } }