uniku 0.0.12 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +63 -13
  2. package/build/{bytes-BBjKLOBR.mjs → bytes-DzcOKIx1.mjs} +1 -1
  3. package/build/{bytes-BBjKLOBR.mjs.map → bytes-DzcOKIx1.mjs.map} +1 -1
  4. package/build/cuid2/cuid2.d.mts.map +1 -1
  5. package/build/cuid2/cuid2.mjs +1 -1
  6. package/build/cuid2/cuid2.mjs.map +1 -1
  7. package/build/ksuid/ksuid.d.mts.map +1 -1
  8. package/build/ksuid/ksuid.mjs +1 -1
  9. package/build/ksuid/ksuid.mjs.map +1 -1
  10. package/build/nanoid/nanoid.d.mts.map +1 -1
  11. package/build/nanoid/nanoid.mjs +1 -1
  12. package/build/nanoid/nanoid.mjs.map +1 -1
  13. package/build/random-CGMm8owG.mjs +2 -0
  14. package/build/random-CGMm8owG.mjs.map +1 -0
  15. package/build/tsconfig.tsbuildinfo +1 -1
  16. package/build/typeid/typeid.d.mts +38 -0
  17. package/build/typeid/typeid.d.mts.map +1 -0
  18. package/build/typeid/typeid.mjs +2 -0
  19. package/build/typeid/typeid.mjs.map +1 -0
  20. package/build/ulid/ulid.d.mts.map +1 -1
  21. package/build/ulid/ulid.mjs +1 -1
  22. package/build/ulid/ulid.mjs.map +1 -1
  23. package/build/uuid/v4.mjs +1 -1
  24. package/build/uuid/v7.d.mts.map +1 -1
  25. package/build/uuid/v7.mjs +1 -1
  26. package/build/uuid/v7.mjs.map +1 -1
  27. package/build/{uuid-QysI8pWE.mjs → uuid-Ub6ladEr.mjs} +1 -1
  28. package/build/{uuid-QysI8pWE.mjs.map → uuid-Ub6ladEr.mjs.map} +1 -1
  29. package/package.json +21 -31
  30. package/src/common/bytes.ts +59 -0
  31. package/src/common/random.ts +66 -0
  32. package/src/cuid2/cuid2.ts +211 -0
  33. package/src/errors.ts +56 -0
  34. package/src/ksuid/base62.ts +103 -0
  35. package/src/ksuid/ksuid.ts +195 -0
  36. package/src/nanoid/nanoid.ts +257 -0
  37. package/src/typeid/typeid.ts +283 -0
  38. package/src/ulid/crockford.ts +249 -0
  39. package/src/ulid/ulid.ts +184 -0
  40. package/src/uuid/common/hex.ts +24 -0
  41. package/src/uuid/common/uuid.ts +157 -0
  42. package/src/uuid/v4.ts +119 -0
  43. package/src/uuid/v7.ts +182 -0
  44. package/build/random-DcKA4vF7.mjs +0 -2
  45. package/build/random-DcKA4vF7.mjs.map +0 -1
@@ -0,0 +1,211 @@
1
+ import { sha3_512 } from '@noble/hashes/sha3.js'
2
+ import { randomUint32 } from '../common/random'
3
+ import { InvalidInputError } from '../errors'
4
+
5
+ export type Cuid2Options = {
6
+ /**
7
+ * Length of the generated ID (2-32 characters).
8
+ * Default: 24
9
+ */
10
+ length?: number
11
+ /**
12
+ * Custom random bytes for deterministic testing.
13
+ * Must be at least 1 byte. For adequate entropy, use at least 16 bytes.
14
+ * Note: The fingerprint always uses cryptographically secure random bytes,
15
+ * regardless of this option.
16
+ */
17
+ random?: Uint8Array
18
+ }
19
+
20
+ export type Cuid2 = {
21
+ (options?: Cuid2Options): string
22
+ isValid(id: unknown): id is string
23
+ }
24
+
25
+ const DEFAULT_LENGTH = 24
26
+ const MAX_LENGTH = 32
27
+ const MIN_LENGTH = 2
28
+
29
+ // Maximum initial counter value from cuid2 spec - provides ~29 bits of
30
+ // initial entropy to prevent cross-process collisions at startup
31
+ const INITIAL_COUNT_MAX = 476782367
32
+
33
+ // Validation regex: first char must be a-z, rest can be a-z or 0-9
34
+ const CUID2_REGEX = /^[a-z][0-9a-z]+$/
35
+
36
+ // Base36 alphabet
37
+ const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
38
+ const LETTER_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
39
+
40
+ // Reusable TextEncoder instance (stateless, safe to share)
41
+ const textEncoder = new TextEncoder()
42
+
43
+ /**
44
+ * Module-level state for counter and fingerprint.
45
+ * Counter is initialized lazily on first call to prevent unnecessary crypto operations.
46
+ * Fingerprint is also generated lazily on first call.
47
+ */
48
+ const state: { counter: number | undefined; fingerprint: string | undefined } = {
49
+ counter: undefined,
50
+ fingerprint: undefined,
51
+ }
52
+
53
+ /**
54
+ * Initialize counter using crypto for consistency with other entropy sources.
55
+ */
56
+ function initializeCounter(): number {
57
+ return randomUint32() % (INITIAL_COUNT_MAX + 1)
58
+ }
59
+
60
+ // --- Base36 utilities ---
61
+
62
+ function bufToBigInt(buf: Uint8Array): bigint {
63
+ let value = 0n
64
+ for (const byte of buf) {
65
+ value = value * 256n + BigInt(byte)
66
+ }
67
+ return value
68
+ }
69
+
70
+ function bigIntToBase36(value: bigint): string {
71
+ if (value === 0n) return '0'
72
+ const chars: string[] = []
73
+ while (value > 0n) {
74
+ chars.push(ALPHABET[Number(value % 36n)])
75
+ value = value / 36n
76
+ }
77
+ return chars.reverse().join('')
78
+ }
79
+
80
+ function randomLetter(random: () => number): string {
81
+ return LETTER_ALPHABET[Math.floor(random() * 26)]
82
+ }
83
+
84
+ function createEntropy(length: number, random: () => number): string {
85
+ const chars = new Array<string>(length)
86
+ for (let i = 0; i < length; i++) {
87
+ chars[i] = ALPHABET[Math.floor(random() * 36)]
88
+ }
89
+ return chars.join('')
90
+ }
91
+
92
+ // --- SHA3 hash wrapper ---
93
+
94
+ function hash(input: string): Uint8Array {
95
+ return sha3_512(textEncoder.encode(input))
96
+ }
97
+
98
+ // --- Fingerprint generation ---
99
+
100
+ const BIG_LENGTH = 32
101
+
102
+ function createFingerprint(): string {
103
+ // Always use CSPRNG for fingerprint to ensure security regardless of custom random option
104
+ const random = getCryptoRandom
105
+ const globals = Object.keys(globalThis).toString()
106
+ const sourceString = globals + createEntropy(BIG_LENGTH, random)
107
+ const hashed = hash(sourceString)
108
+ return bigIntToBase36(bufToBigInt(hashed)).slice(1, BIG_LENGTH + 1)
109
+ }
110
+
111
+ // --- Random function factory ---
112
+
113
+ /**
114
+ * Get a random number in [0, 1) using CUID2's own random pool.
115
+ */
116
+ function getCryptoRandom(): number {
117
+ return randomUint32() / 0x100000000
118
+ }
119
+
120
+ function getRandomFn(random?: Uint8Array): () => number {
121
+ if (random) {
122
+ if (random.length === 0) {
123
+ throw new InvalidInputError('CUID2_RANDOM_BYTES_EMPTY', 'Random byte array cannot be empty')
124
+ }
125
+ let index = 0
126
+ return () => {
127
+ const value = random[index % random.length] / 256
128
+ index += 1
129
+ return value
130
+ }
131
+ }
132
+ return getCryptoRandom
133
+ }
134
+
135
+ // --- Main generator ---
136
+
137
+ function cuid2Fn(options?: Cuid2Options): string {
138
+ const length = options?.length ?? DEFAULT_LENGTH
139
+
140
+ if (length < MIN_LENGTH || length > MAX_LENGTH) {
141
+ throw new InvalidInputError(
142
+ 'CUID2_LENGTH_OUT_OF_RANGE',
143
+ `CUID2 length must be between ${MIN_LENGTH} and ${MAX_LENGTH}. Received: ${length}`,
144
+ )
145
+ }
146
+
147
+ const random = getRandomFn(options?.random)
148
+
149
+ // Initialize counter lazily on first call
150
+ if (state.counter === undefined) {
151
+ state.counter = initializeCounter()
152
+ }
153
+
154
+ // Initialize fingerprint lazily on first call (always uses CSPRNG)
155
+ if (state.fingerprint === undefined) {
156
+ state.fingerprint = createFingerprint()
157
+ }
158
+
159
+ const firstLetter = randomLetter(random)
160
+ const time = Date.now().toString(36)
161
+ state.counter += 1
162
+ const count = state.counter.toString(36)
163
+ const salt = createEntropy(length, random)
164
+
165
+ const hashInput = time + salt + count + state.fingerprint
166
+ const hashed = hash(hashInput)
167
+ const base36Hash = bigIntToBase36(bufToBigInt(hashed))
168
+
169
+ // Drop first char of hash to avoid histogram bias, prepend random letter
170
+ return firstLetter + base36Hash.slice(1, length)
171
+ }
172
+
173
+ // --- Validation (type guard) ---
174
+
175
+ function isValid(id: unknown): id is string {
176
+ return typeof id === 'string' && id.length >= MIN_LENGTH && id.length <= MAX_LENGTH && CUID2_REGEX.test(id)
177
+ }
178
+
179
+ /**
180
+ * Generate a CUID2 string.
181
+ *
182
+ * CUID2 is a secure, collision-resistant identifier that hashes multiple
183
+ * entropy sources using SHA3-512. Unlike time-ordered IDs (ULID, UUID v7),
184
+ * CUID2 prevents enumeration attacks by making IDs non-predictable.
185
+ *
186
+ * Note: CUID2 does not provide toBytes/fromBytes because it is a string-native
187
+ * format with no canonical binary representation (unlike UUID's 16-byte format).
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * import { cuid2 } from 'uniku/cuid2'
192
+ *
193
+ * const id = cuid2()
194
+ * // => "pfh0haxfpzowht3oi213cqos"
195
+ *
196
+ * // Custom length
197
+ * const shortId = cuid2({ length: 10 })
198
+ * // => "tz4a98xxat"
199
+ *
200
+ * // Validation (type guard)
201
+ * const maybeId: unknown = getUserInput()
202
+ * if (cuid2.isValid(maybeId)) {
203
+ * console.log(maybeId.length) // TypeScript knows maybeId is string
204
+ * }
205
+ * ```
206
+ */
207
+ export const cuid2: Cuid2 = Object.assign(cuid2Fn, {
208
+ isValid,
209
+ })
210
+
211
+ export { InvalidInputError, UniqueIdError } from '../errors'
package/src/errors.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Base error for all uniku errors.
3
+ * Provides `_tag` for discriminated matching (compatible with Effect's `catchTag`)
4
+ * and `code` for machine-readable error identification.
5
+ */
6
+ export abstract class UniqueIdError extends Error {
7
+ abstract readonly _tag: string
8
+ abstract readonly code: string
9
+
10
+ constructor(message: string) {
11
+ super(message)
12
+ this.name = this.constructor.name
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Thrown when generator arguments are invalid (bad size, alphabet, length, timestamp, version).
18
+ */
19
+ export class InvalidInputError extends UniqueIdError {
20
+ readonly _tag = 'InvalidInputError' as const
21
+
22
+ constructor(
23
+ readonly code: string,
24
+ message: string,
25
+ ) {
26
+ super(message)
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Thrown when parsing/decoding an ID string that has invalid format or characters.
32
+ */
33
+ export class ParseError extends UniqueIdError {
34
+ readonly _tag = 'ParseError' as const
35
+
36
+ constructor(
37
+ readonly code: string,
38
+ message: string,
39
+ ) {
40
+ super(message)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Thrown when a byte array or buffer is too short or an offset is out of bounds.
46
+ */
47
+ export class BufferError extends UniqueIdError {
48
+ readonly _tag = 'BufferError' as const
49
+
50
+ constructor(
51
+ readonly code: string,
52
+ message: string,
53
+ ) {
54
+ super(message)
55
+ }
56
+ }
@@ -0,0 +1,103 @@
1
+ import { BufferError, ParseError } from '../errors'
2
+
3
+ /**
4
+ * Base62 encoding/decoding for KSUID.
5
+ * Alphabet: 0-9A-Za-z (standard Base62 ordering)
6
+ *
7
+ * KSUID binary format: 20 bytes (160 bits)
8
+ * - 4 bytes: timestamp (seconds since KSUID epoch)
9
+ * - 16 bytes: payload (cryptographically random)
10
+ *
11
+ * String format: 27 characters of Base62
12
+ */
13
+
14
+ // Base62 alphabet: digits (0-9), uppercase (A-Z), lowercase (a-z)
15
+ const BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
16
+ const BASE = 62n
17
+ const MAX_KSUID_VALUE = (1n << 160n) - 1n
18
+ const KSUID_BYTES = 20
19
+ const KSUID_STRING_LEN = 27
20
+
21
+ // Pre-computed decode table covering every UTF-16 code unit charCodeAt can
22
+ // return, so lookups never go out of bounds and a single `=== 255` check
23
+ // rejects invalid input (including non-ASCII) without a per-character range
24
+ // check. Costs 64 KiB once at module load; valid inputs only touch the first
25
+ // 128 bytes, so cache behavior is unaffected.
26
+ // Note: Base62 is case-sensitive - 'A' (value 10) and 'a' (value 36) are different
27
+ const DECODING = new Uint8Array(65536)
28
+ DECODING.fill(255) // 255 = invalid marker
29
+
30
+ for (let i = 0; i < BASE62_ALPHABET.length; i += 1) {
31
+ DECODING[BASE62_ALPHABET.charCodeAt(i)] = i
32
+ }
33
+
34
+ /**
35
+ * Encode a 20-byte KSUID to a 27-character Base62 string.
36
+ *
37
+ * Algorithm: Convert the 160-bit number to base 62 using BigInt.
38
+ * V8 has highly optimized BigInt operations for this size.
39
+ */
40
+ export function encodeBase62(bytes: Uint8Array): string {
41
+ if (bytes.length < KSUID_BYTES) {
42
+ throw new BufferError(
43
+ 'KSUID_BYTES_TOO_SHORT',
44
+ `KSUID bytes must be at least ${KSUID_BYTES} bytes, got ${bytes.length}`,
45
+ )
46
+ }
47
+
48
+ // Convert bytes to BigInt (big-endian)
49
+ let num = 0n
50
+ for (let i = 0; i < KSUID_BYTES; i += 1) {
51
+ num = (num << 8n) | BigInt(bytes[i])
52
+ }
53
+
54
+ // Convert to Base62 string (build from right to left)
55
+ // Direct string concatenation is faster than array + join in V8
56
+ let encoded = ''
57
+ while (num > 0n) {
58
+ const remainder = num % BASE
59
+ num = num / BASE
60
+ encoded = BASE62_ALPHABET[Number(remainder)] + encoded
61
+ }
62
+
63
+ // Pad the result with the zero-character ('0') to ensure a fixed length
64
+ return encoded.padStart(KSUID_STRING_LEN, '0')
65
+ }
66
+
67
+ /**
68
+ * Decode a 27-character Base62 string to a 20-byte KSUID.
69
+ *
70
+ * Algorithm: Convert Base62 string to BigInt, then to bytes.
71
+ * V8 has highly optimized BigInt operations.
72
+ */
73
+ export function decodeBase62(str: string): Uint8Array {
74
+ if (str.length !== KSUID_STRING_LEN) {
75
+ throw new ParseError(
76
+ 'KSUID_INVALID_LENGTH',
77
+ `KSUID string must be ${KSUID_STRING_LEN} characters, got ${str.length}`,
78
+ )
79
+ }
80
+
81
+ // Convert Base62 string to BigInt
82
+ let num = 0n
83
+ for (let i = 0; i < KSUID_STRING_LEN; i += 1) {
84
+ const value = DECODING[str.charCodeAt(i)]
85
+ if (value === 255) {
86
+ throw new ParseError('KSUID_INVALID_CHAR', `Invalid KSUID character: ${str[i]}`)
87
+ }
88
+ num = num * BASE + BigInt(value)
89
+ }
90
+
91
+ if (num > MAX_KSUID_VALUE) {
92
+ throw new ParseError('KSUID_OVERFLOW', 'KSUID string exceeds 160-bit range')
93
+ }
94
+
95
+ // Convert BigInt to bytes (big-endian)
96
+ const bytes = new Uint8Array(KSUID_BYTES)
97
+ for (let i = KSUID_BYTES - 1; i >= 0; i -= 1) {
98
+ bytes[i] = Number(num & 0xffn)
99
+ num = num >> 8n
100
+ }
101
+
102
+ return bytes
103
+ }
@@ -0,0 +1,195 @@
1
+ import { writeTimestamp32 } from '../common/bytes'
2
+ import { rng } from '../common/random'
3
+ import { BufferError, InvalidInputError } from '../errors'
4
+ import { decodeBase62, encodeBase62 } from './base62'
5
+
6
+ /**
7
+ * KSUID (K-Sortable Unique Identifier)
8
+ *
9
+ * A 160-bit identifier consisting of:
10
+ * - 4 bytes: timestamp (seconds since KSUID epoch: May 13, 2014)
11
+ * - 16 bytes: cryptographically random payload
12
+ *
13
+ * Encoded as a 27-character Base62 string.
14
+ */
15
+
16
+ // KSUID epoch: May 13, 2014 00:00:00 UTC (Unix timestamp in seconds)
17
+ const KSUID_EPOCH = 1400000000
18
+
19
+ const KSUID_BYTES = 20
20
+ const KSUID_STRING_LEN = 27
21
+ const TIMESTAMP_BYTES = 4
22
+ const PAYLOAD_BYTES = 16
23
+ const KSUID_MAX_SECS = KSUID_EPOCH + 0xffffffff
24
+ // 2^160 - 1 in Base62 ('aWgEPTl1tmebfsQzFP4bxwgy80V'), derived from the
25
+ // encoder so it cannot drift from the decoder's 160-bit overflow bound.
26
+ const KSUID_MAX_STRING = encodeBase62(new Uint8Array(KSUID_BYTES).fill(0xff))
27
+
28
+ // Validation regex: 27 alphanumeric characters
29
+ // Note: Both cases are valid Base62 characters, but they decode to different values
30
+ // (e.g., 'A' = 10, 'a' = 36). The regex validates format, not semantic equivalence.
31
+ const KSUID_REGEX = /^[0-9A-Za-z]{27}$/
32
+
33
+ export type KsuidOptions = {
34
+ /**
35
+ * 16 bytes of random data to use for KSUID payload.
36
+ */
37
+ random?: Uint8Array
38
+ /**
39
+ * Timestamp in seconds since Unix epoch.
40
+ * Defaults to Math.floor(Date.now() / 1000).
41
+ * KSUID natively uses second precision.
42
+ */
43
+ secs?: number
44
+ }
45
+
46
+ export type Ksuid = {
47
+ (): string
48
+ <TBuf extends Uint8Array = Uint8Array>(options: KsuidOptions | undefined, buf: TBuf, offset?: number): TBuf
49
+ (options?: KsuidOptions, buf?: undefined, offset?: number): string
50
+ toBytes(id: string): Uint8Array
51
+ fromBytes(bytes: Uint8Array): string
52
+ timestamp(id: string): number
53
+ isValid(id: unknown): id is string
54
+ /** The nil KSUID (all zeros) */
55
+ NIL: string
56
+ /** The max KSUID (maximum valid value) */
57
+ MAX: string
58
+ }
59
+
60
+ /**
61
+ * Write KSUID bytes to a buffer.
62
+ */
63
+ function ksuidBytes(timestamp: number, payload: Uint8Array, buf?: Uint8Array, offset = 0): Uint8Array {
64
+ if (!buf) {
65
+ buf = new Uint8Array(KSUID_BYTES)
66
+ offset = 0
67
+ } else if (offset < 0 || offset + KSUID_BYTES > buf.length) {
68
+ throw new BufferError(
69
+ 'KSUID_BUFFER_OUT_OF_BOUNDS',
70
+ `KSUID byte range ${offset}:${offset + KSUID_BYTES - 1} is out of buffer bounds`,
71
+ )
72
+ }
73
+
74
+ // Timestamp (32-bit big-endian seconds since KSUID epoch) -> bytes 0-3
75
+ writeTimestamp32(buf, offset, timestamp)
76
+
77
+ // Payload (128 bits) -> bytes 4-19
78
+ for (let i = 0; i < PAYLOAD_BYTES; i += 1) {
79
+ buf[offset + TIMESTAMP_BYTES + i] = payload[i]
80
+ }
81
+
82
+ return buf
83
+ }
84
+
85
+ /*
86
+ * Overload: no buffer => return a KSUID string.
87
+ */
88
+ function ksuidFn(options?: KsuidOptions, buf?: undefined, offset?: number): string
89
+ /*
90
+ * Overload: caller provides a buffer slice to fill with KSUID bytes.
91
+ */
92
+ function ksuidFn<TBuf extends Uint8Array = Uint8Array>(
93
+ options: KsuidOptions | undefined,
94
+ buf: TBuf,
95
+ offset?: number,
96
+ ): TBuf
97
+ function ksuidFn<TBuf extends Uint8Array = Uint8Array>(options?: KsuidOptions, buf?: TBuf, offset = 0): string | TBuf {
98
+ const random = options?.random
99
+ if (random && random.length < PAYLOAD_BYTES) {
100
+ throw new InvalidInputError('KSUID_RANDOM_BYTES_TOO_SHORT', 'Random bytes length must be >= 16 for KSUID')
101
+ }
102
+
103
+ let timestamp: number
104
+ const secs = options?.secs
105
+ if (secs !== undefined) {
106
+ if (secs < KSUID_EPOCH) {
107
+ throw new InvalidInputError('KSUID_TIMESTAMP_TOO_LOW', 'Timestamp must be >= KSUID epoch')
108
+ }
109
+
110
+ if (secs > KSUID_MAX_SECS) {
111
+ throw new InvalidInputError('KSUID_TIMESTAMP_TOO_HIGH', 'Timestamp must be <= maximum KSUID timestamp')
112
+ }
113
+
114
+ timestamp = secs - KSUID_EPOCH
115
+ } else {
116
+ /**
117
+ * Note: by default, Cloudflare Workers "freezes" time during request handling to prevent
118
+ * side-channel attacks. This means that Date.now() will return the same value for the entire
119
+ * duration of a request.
120
+ * Implications:
121
+ * - all KSUIDs generated within a single request will have the same timestamp.
122
+ */
123
+ timestamp = Math.floor(Date.now() / 1000) - KSUID_EPOCH
124
+ }
125
+
126
+ const payload = random ?? rng()
127
+
128
+ if (buf) {
129
+ ksuidBytes(timestamp, payload, buf, offset)
130
+ return buf
131
+ }
132
+
133
+ const bytes = ksuidBytes(timestamp, payload)
134
+
135
+ // String mode: create bytes then encode to Base62
136
+ return encodeBase62(bytes)
137
+ }
138
+
139
+ /**
140
+ * Convert a KSUID string to 20 bytes.
141
+ *
142
+ * Note: Base62 is case-sensitive. 'A' (value 10) and 'a' (value 36) decode
143
+ * to different byte values. This function accepts both cases as valid input.
144
+ */
145
+ function toBytes(id: string): Uint8Array {
146
+ return decodeBase62(id)
147
+ }
148
+
149
+ /**
150
+ * Convert 20 bytes to a KSUID string.
151
+ */
152
+ function fromBytes(bytes: Uint8Array): string {
153
+ return encodeBase62(bytes)
154
+ }
155
+
156
+ /**
157
+ * Extract the timestamp from a KSUID string.
158
+ * Returns Unix timestamp in milliseconds for API consistency with ulid/uuidv7.
159
+ * Note: KSUID only has second precision, so the returned value will always end in 000.
160
+ */
161
+ function timestamp(id: string): number {
162
+ const bytes = decodeBase62(id)
163
+ // First 4 bytes are big-endian timestamp (seconds since KSUID epoch)
164
+ const secs = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0
165
+ // Add KSUID epoch and convert to milliseconds
166
+ return (secs + KSUID_EPOCH) * 1000
167
+ }
168
+
169
+ /**
170
+ * Validate a KSUID string format and 160-bit numeric range.
171
+ */
172
+ function isValid(id: unknown): id is string {
173
+ return (
174
+ typeof id === 'string' &&
175
+ id.length === KSUID_STRING_LEN &&
176
+ KSUID_REGEX.test(id) &&
177
+ // Fixed-length Base62 strings preserve numeric order because the alphabet is ASCII-sorted.
178
+ id <= KSUID_MAX_STRING
179
+ )
180
+ }
181
+
182
+ /**
183
+ * Generate a KSUID string or write the bytes into a buffer.
184
+ * Also includes helpers to convert to and from byte arrays.
185
+ */
186
+ export const ksuid: Ksuid = Object.assign(ksuidFn, {
187
+ toBytes,
188
+ fromBytes,
189
+ timestamp,
190
+ isValid,
191
+ NIL: '000000000000000000000000000',
192
+ MAX: KSUID_MAX_STRING,
193
+ })
194
+
195
+ export { BufferError, InvalidInputError, ParseError, UniqueIdError } from '../errors'