@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
text/typescript
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
}
}