uniku 0.2.0 → 0.3.1

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 (72) hide show
  1. package/README.md +87 -22
  2. package/build/{bytes-CbbFzz-v.mjs → bytes-xqWxFYsM.mjs} +1 -1
  3. package/build/{bytes-CbbFzz-v.mjs.map → bytes-xqWxFYsM.mjs.map} +1 -1
  4. package/build/cuid/v2.d.mts +2 -0
  5. package/build/cuid/v2.mjs +1 -0
  6. package/build/cuid2/cuid2.d.mts +5 -1
  7. package/build/cuid2/cuid2.d.mts.map +1 -1
  8. package/build/cuid2/cuid2.mjs +1 -1
  9. package/build/cuid2/cuid2.mjs.map +1 -1
  10. package/build/errors-Dgoyi-ci.d.mts +38 -0
  11. package/build/errors-Dgoyi-ci.d.mts.map +1 -0
  12. package/build/errors.d.mts +2 -38
  13. package/build/errors.mjs +1 -1
  14. package/build/errors.mjs.map +1 -1
  15. package/build/generators.d.mts +17 -0
  16. package/build/generators.d.mts.map +1 -0
  17. package/build/generators.mjs +2 -0
  18. package/build/generators.mjs.map +1 -0
  19. package/build/ksuid/ksuid.d.mts +3 -5
  20. package/build/ksuid/ksuid.d.mts.map +1 -1
  21. package/build/ksuid/ksuid.mjs +1 -1
  22. package/build/ksuid/ksuid.mjs.map +1 -1
  23. package/build/nanoid/nanoid.d.mts +3 -6
  24. package/build/nanoid/nanoid.d.mts.map +1 -1
  25. package/build/nanoid/nanoid.mjs +1 -1
  26. package/build/nanoid/nanoid.mjs.map +1 -1
  27. package/build/objectid/objectid.d.mts +3 -5
  28. package/build/objectid/objectid.d.mts.map +1 -1
  29. package/build/objectid/objectid.mjs +1 -1
  30. package/build/objectid/objectid.mjs.map +1 -1
  31. package/build/{random-C_nRoaUe.mjs → random-Chp-Nkzi.mjs} +1 -1
  32. package/build/{random-C_nRoaUe.mjs.map → random-Chp-Nkzi.mjs.map} +1 -1
  33. package/build/tsid/tsid.d.mts +82 -0
  34. package/build/tsid/tsid.d.mts.map +1 -0
  35. package/build/tsid/tsid.mjs +2 -0
  36. package/build/tsid/tsid.mjs.map +1 -0
  37. package/build/typeid/typeid.d.mts +1 -1
  38. package/build/typeid/typeid.d.mts.map +1 -1
  39. package/build/typeid/typeid.mjs.map +1 -1
  40. package/build/ulid/ulid.d.mts +3 -5
  41. package/build/ulid/ulid.d.mts.map +1 -1
  42. package/build/ulid/ulid.mjs +1 -1
  43. package/build/ulid/ulid.mjs.map +1 -1
  44. package/build/uuid/v4.d.mts +3 -6
  45. package/build/uuid/v4.d.mts.map +1 -1
  46. package/build/uuid/v4.mjs +1 -1
  47. package/build/uuid/v4.mjs.map +1 -1
  48. package/build/uuid/v7.d.mts +6 -5
  49. package/build/uuid/v7.d.mts.map +1 -1
  50. package/build/uuid/v7.mjs +1 -1
  51. package/build/uuid/v7.mjs.map +1 -1
  52. package/build/uuid-CnQIYoQi.mjs +2 -0
  53. package/build/uuid-CnQIYoQi.mjs.map +1 -0
  54. package/build/validation-CTNpXm94.mjs +2 -0
  55. package/build/validation-CTNpXm94.mjs.map +1 -0
  56. package/package.json +27 -5
  57. package/src/common/validation.ts +7 -0
  58. package/src/cuid/v2.ts +19 -0
  59. package/src/cuid2/cuid2.ts +11 -3
  60. package/src/generators.ts +17 -0
  61. package/src/ksuid/ksuid.ts +19 -17
  62. package/src/objectid/objectid.ts +25 -17
  63. package/src/tsid/crockford64.ts +75 -0
  64. package/src/tsid/tsid.ts +340 -0
  65. package/src/ulid/crockford.ts +2 -2
  66. package/src/ulid/ulid.ts +35 -24
  67. package/src/uuid/common/uuid.ts +12 -1
  68. package/src/uuid/v4.ts +25 -28
  69. package/src/uuid/v7.ts +75 -47
  70. package/build/errors.d.mts.map +0 -1
  71. package/build/uuid-C9VcXh5N.mjs +0 -2
  72. package/build/uuid-C9VcXh5N.mjs.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import { writeTimestamp32 } from '../common/bytes'
2
2
  import { randomBytes, randomUint32 } from '../common/random'
3
+ import { isIntegerInRange, isWritableRange } from '../common/validation'
3
4
  import { BufferError, InvalidInputError } from '../errors'
4
5
  import { decodeObjectIdHex, encodeObjectIdHex } from './hex'
5
6
 
@@ -85,17 +86,13 @@ function freshRandom(): Uint8Array {
85
86
  return bytes
86
87
  }
87
88
 
88
- function objectIdBytes(secs: number, random: Uint8Array, counter: number, buf?: Uint8Array, offset = 0): Uint8Array {
89
- if (!buf) {
90
- buf = new Uint8Array(OBJECTID_BYTES)
91
- offset = 0
92
- } else if (offset < 0 || offset + OBJECTID_BYTES > buf.length) {
93
- throw new BufferError(
94
- 'OBJECTID_BUFFER_OUT_OF_BOUNDS',
95
- `ObjectID byte range ${offset}:${offset + OBJECTID_BYTES - 1} is out of buffer bounds`,
96
- )
97
- }
98
-
89
+ function writeObjectIdBytesUnchecked(
90
+ secs: number,
91
+ random: Uint8Array,
92
+ counter: number,
93
+ buf: Uint8Array,
94
+ offset: number,
95
+ ): void {
99
96
  // Timestamp (32-bit big-endian seconds since Unix epoch) -> bytes 0-3
100
97
  writeTimestamp32(buf, offset, secs)
101
98
 
@@ -108,8 +105,6 @@ function objectIdBytes(secs: number, random: Uint8Array, counter: number, buf?:
108
105
  buf[offset + TIMESTAMP_BYTES + RANDOM_BYTES] = (counter >>> 16) & 0xff
109
106
  buf[offset + TIMESTAMP_BYTES + RANDOM_BYTES + 1] = (counter >>> 8) & 0xff
110
107
  buf[offset + TIMESTAMP_BYTES + RANDOM_BYTES + COUNTER_BYTES - 1] = counter & 0xff
111
-
112
- return buf
113
108
  }
114
109
 
115
110
  /*
@@ -143,12 +138,12 @@ function objectIdFn<TBuf extends Uint8Array = Uint8Array>(
143
138
  }
144
139
 
145
140
  const optSecs = options.secs
146
- if (optSecs !== undefined && (optSecs < 0 || optSecs > MAX_SECS)) {
141
+ if (optSecs !== undefined && !isIntegerInRange(optSecs, 0, MAX_SECS)) {
147
142
  throw new InvalidInputError('OBJECTID_TIMESTAMP_OUT_OF_RANGE', `Timestamp must be between 0 and ${MAX_SECS}`)
148
143
  }
149
144
 
150
145
  const optCounter = options.counter
151
- if (optCounter !== undefined && (optCounter < 0 || optCounter > MAX_COUNTER)) {
146
+ if (optCounter !== undefined && !isIntegerInRange(optCounter, 0, MAX_COUNTER)) {
152
147
  throw new InvalidInputError('OBJECTID_COUNTER_OUT_OF_RANGE', `Counter must be between 0 and ${MAX_COUNTER}`)
153
148
  }
154
149
 
@@ -183,11 +178,18 @@ function objectIdFn<TBuf extends Uint8Array = Uint8Array>(
183
178
  }
184
179
 
185
180
  if (buf) {
186
- objectIdBytes(secs, random, counter, buf, offset)
181
+ if (!isWritableRange(buf, offset, OBJECTID_BYTES)) {
182
+ throw new BufferError(
183
+ 'OBJECTID_BUFFER_OUT_OF_BOUNDS',
184
+ `ObjectID byte range ${offset}:${offset + OBJECTID_BYTES - 1} is out of buffer bounds`,
185
+ )
186
+ }
187
+ writeObjectIdBytesUnchecked(secs, random, counter, buf, offset)
187
188
  return buf
188
189
  }
189
190
 
190
- const bytes = objectIdBytes(secs, random, counter)
191
+ const bytes = new Uint8Array(OBJECTID_BYTES)
192
+ writeObjectIdBytesUnchecked(secs, random, counter, bytes, 0)
191
193
  return encodeObjectIdHex(bytes)
192
194
  }
193
195
 
@@ -202,6 +204,12 @@ function toBytes(id: string): Uint8Array {
202
204
  * Convert 12 bytes to an ObjectID hex string.
203
205
  */
204
206
  function fromBytes(bytes: Uint8Array): string {
207
+ if (bytes.length !== OBJECTID_BYTES) {
208
+ throw new BufferError(
209
+ 'OBJECTID_BYTES_INVALID_LENGTH',
210
+ `ObjectID bytes must be exactly ${OBJECTID_BYTES} bytes, got ${bytes.length}`,
211
+ )
212
+ }
205
213
  return encodeObjectIdHex(bytes)
206
214
  }
207
215
 
@@ -0,0 +1,75 @@
1
+ import { ParseError } from '../errors'
2
+
3
+ /**
4
+ * Crockford Base32 codec for TSID, operating directly on the primary `bigint`
5
+ * value rather than an intermediate byte array (unlike ulid/crockford.ts) -
6
+ * tsid's primary type never routes through a `Uint8Array` during string
7
+ * conversion.
8
+ *
9
+ * TSID binary format: 64 bits (8 bytes), encoded as 13 Crockford Base32 characters.
10
+ * 13 chars * 5 bits = 65 bits of capacity for a 64-bit value, leaving exactly 1 bit
11
+ * of headroom on the leading character. That headroom bit is always 0, so the
12
+ * leading character only carries 4 significant real bits (values 0-15, i.e. the
13
+ * alphabet's first 16 symbols "0"-"9"/"A"-"F") rather than the full 32-symbol
14
+ * range - the same overflow-prevention technique ULID uses for its own leading
15
+ * `[0-7]` restriction (2 headroom bits there vs 1 here). Confirmed against
16
+ * `tsid-ts`'s own `toCanonicalString`: `(number >> BigInt(60 - i * 5)) & 0x1f`.
17
+ */
18
+
19
+ const TSID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
20
+ const TSID_STRING_LEN = 13
21
+ const TSID_LEADING_MAX = 15 // 4 significant bits on the leading character (see above)
22
+
23
+ // Pre-computed decode table (case-insensitive) covering ASCII, so lookups never
24
+ // go out of bounds and a single `=== 255` check rejects invalid input.
25
+ const DECODING = new Uint8Array(128).fill(255)
26
+ for (let i = 0; i < TSID_ALPHABET.length; i += 1) {
27
+ const char = TSID_ALPHABET[i]
28
+ const upperCode = TSID_ALPHABET.charCodeAt(i)
29
+ DECODING[upperCode] = i
30
+ // Only letters have a lowercase counterpart - applying +32 to digit codes
31
+ // would land on unrelated uppercase letters (e.g. '5' + 32 -> 'U', which is
32
+ // excluded from this alphabet and would never get overwritten back to 255).
33
+ if (char >= 'A' && char <= 'Z') {
34
+ DECODING[upperCode + 32] = i
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Encode a TSID `bigint` value to its 13-character canonical Crockford Base32 string.
40
+ */
41
+ export function encodeTsidString(value: bigint): string {
42
+ let result = ''
43
+ for (let i = 0; i < TSID_STRING_LEN; i += 1) {
44
+ const shift = BigInt(60 - i * 5)
45
+ const group = Number((value >> shift) & 0x1fn)
46
+ result += TSID_ALPHABET[group]
47
+ }
48
+ return result
49
+ }
50
+
51
+ /**
52
+ * Decode a 13-character canonical Crockford Base32 string to a TSID `bigint` value.
53
+ */
54
+ export function decodeTsidString(str: string): bigint {
55
+ if (str.length !== TSID_STRING_LEN) {
56
+ throw new ParseError('TSID_INVALID_LENGTH', `TSID string must be ${TSID_STRING_LEN} characters, got ${str.length}`)
57
+ }
58
+
59
+ let value = 0n
60
+ for (let i = 0; i < TSID_STRING_LEN; i += 1) {
61
+ const code = str.charCodeAt(i)
62
+ const group = code < 128 ? DECODING[code] : 255
63
+ if (group === 255) {
64
+ throw new ParseError('TSID_INVALID_CHAR', `Invalid TSID character: ${str[i]}`)
65
+ }
66
+ if (i === 0 && group > TSID_LEADING_MAX) {
67
+ throw new ParseError(
68
+ 'TSID_LEADING_CHAR_OUT_OF_RANGE',
69
+ `TSID leading character must be one of 0-9, A-F, got: ${str[i]}`,
70
+ )
71
+ }
72
+ value = (value << 5n) | BigInt(group)
73
+ }
74
+ return value
75
+ }
@@ -0,0 +1,340 @@
1
+ import { randomUint32 } from '../common/random'
2
+ import { isIntegerInRange, isWritableRange } from '../common/validation'
3
+ import { BufferError, InvalidInputError } from '../errors'
4
+ import { decodeTsidString, encodeTsidString } from './crockford64'
5
+
6
+ /**
7
+ * TSID (Time-Sorted Unique Identifier)
8
+ *
9
+ * A 64-bit Snowflake-style identifier consisting of:
10
+ * - 42 bits: millisecond timestamp relative to a custom epoch (default 2020-01-01T00:00:00.000Z)
11
+ * - 10 bits: node ID (default; configurable via `nodeBits`)
12
+ * - 12 bits: per-millisecond counter (default; `22 - nodeBits`)
13
+ *
14
+ * Unlike every other uniku generator, `tsid()` returns a `bigint` by default -
15
+ * this is a deliberate design decision reflecting that TSID's value proposition
16
+ * is native numeric storage (e.g. a database BIGINT primary key), not a
17
+ * string-first identifier that happens to have a byte encoding. `toString`/
18
+ * `fromString` are the boundary conversions to/from the 13-character canonical
19
+ * Crockford Base32 string.
20
+ *
21
+ * Encoded as 8 big-endian bytes in buffer mode.
22
+ */
23
+
24
+ const TSID_EPOCH = 1577836800000 // 2020-01-01T00:00:00.000Z
25
+ const TSID_BYTES = 8
26
+ const RANDOM_BITS = 22 // combined node + counter bits
27
+ const DEFAULT_NODE_BITS = 10
28
+ const DEFAULT_COUNTER_BITS = RANDOM_BITS - DEFAULT_NODE_BITS
29
+ const DEFAULT_NODE_MASK = (1 << DEFAULT_NODE_BITS) - 1
30
+ const DEFAULT_COUNTER_MASK = (1 << DEFAULT_COUNTER_BITS) - 1
31
+ const MAX_NODE_BITS = 20
32
+ const MAX_TIMESTAMP_DIFF = (1n << 42n) - 1n
33
+
34
+ export type TsidOptions = {
35
+ /**
36
+ * Timestamp in milliseconds since Unix epoch.
37
+ * Defaults to Date.now().
38
+ */
39
+ msecs?: number
40
+ /**
41
+ * Custom epoch in milliseconds since Unix epoch.
42
+ * Defaults to 1577836800000 (2020-01-01T00:00:00.000Z).
43
+ */
44
+ epoch?: number
45
+ /**
46
+ * Node ID (0 to 2^nodeBits - 1).
47
+ * Defaults to a lazily-initialized, persistent random value.
48
+ */
49
+ node?: number
50
+ /**
51
+ * Number of bits allocated to the node ID (0-20).
52
+ * The remaining bits (22 - nodeBits) are allocated to the counter.
53
+ * Defaults to 10.
54
+ */
55
+ nodeBits?: number
56
+ /**
57
+ * Per-millisecond counter (0 to 2^(22 - nodeBits) - 1).
58
+ * Defaults to a fresh random value on a new millisecond, or the previous
59
+ * value incremented by 1 within the same millisecond.
60
+ */
61
+ counter?: number
62
+ }
63
+
64
+ export type Tsid = {
65
+ (): bigint
66
+ <TBuf extends Uint8Array = Uint8Array>(options: TsidOptions | undefined, buf: TBuf, offset?: number): TBuf
67
+ (options?: TsidOptions, buf?: undefined, offset?: number): bigint
68
+ toBytes(id: bigint): Uint8Array
69
+ fromBytes(bytes: Uint8Array): bigint
70
+ toString(id: bigint): string
71
+ fromString(str: string): bigint
72
+ timestamp(id: bigint, epoch?: number): number
73
+ isValid(id: unknown): id is bigint
74
+ /** The nil TSID (all zeros) */
75
+ NIL: bigint
76
+ /** The max TSID (maximum valid 64-bit value) */
77
+ MAX: bigint
78
+ }
79
+
80
+ type TsidState = {
81
+ node: number | undefined
82
+ msecs: number
83
+ counter: number
84
+ }
85
+
86
+ /**
87
+ * Module-level state for the lazily-initialized persistent node ID and the
88
+ * per-millisecond-reset counter.
89
+ *
90
+ * IMPORTANT: This state persists across all tsid() calls in the module's lifetime.
91
+ * - In serverless/edge functions with warm starts, state persists between invocations.
92
+ * - For isolated state, pass explicit `msecs`, `epoch`, `node`, `nodeBits`, or
93
+ * `counter` via options - doing so bypasses `state` entirely.
94
+ * - Tests should mock Date.now() or provide explicit options for deterministic behavior.
95
+ */
96
+ const state: TsidState = {
97
+ node: undefined,
98
+ msecs: -Infinity,
99
+ counter: 0,
100
+ }
101
+
102
+ function pack(msecsDiff: bigint, node: number, counterBits: number, counter: number): bigint {
103
+ return (msecsDiff << BigInt(RANDOM_BITS)) | (BigInt(node) << BigInt(counterBits)) | BigInt(counter)
104
+ }
105
+
106
+ function writeTsidBytesUnchecked(value: bigint, buf: Uint8Array, offset: number): void {
107
+ for (let i = 0; i < TSID_BYTES; i += 1) {
108
+ buf[offset + i] = Number((value >> BigInt((TSID_BYTES - 1 - i) * 8)) & 0xffn)
109
+ }
110
+ }
111
+
112
+ type ResolvedTsidOptions = {
113
+ msecsDiff: bigint
114
+ node: number
115
+ counterBits: number
116
+ counter: number
117
+ }
118
+
119
+ function resolveTsidOptions(options: TsidOptions): ResolvedTsidOptions {
120
+ const nodeBits = options.nodeBits ?? DEFAULT_NODE_BITS
121
+ if (!isIntegerInRange(nodeBits, 0, MAX_NODE_BITS)) {
122
+ throw new InvalidInputError('TSID_NODE_BITS_OUT_OF_RANGE', `nodeBits must be between 0 and ${MAX_NODE_BITS}`)
123
+ }
124
+
125
+ const counterBits = RANDOM_BITS - nodeBits
126
+ const nodeMask = (1 << nodeBits) - 1
127
+ const counterMask = (1 << counterBits) - 1
128
+ const optNode = options.node
129
+ if (optNode !== undefined && !isIntegerInRange(optNode, 0, nodeMask)) {
130
+ throw new InvalidInputError('TSID_NODE_OUT_OF_RANGE', `node must be between 0 and ${nodeMask}`)
131
+ }
132
+
133
+ const optCounter = options.counter
134
+ if (optCounter !== undefined && !isIntegerInRange(optCounter, 0, counterMask)) {
135
+ throw new InvalidInputError('TSID_COUNTER_OUT_OF_RANGE', `counter must be between 0 and ${counterMask}`)
136
+ }
137
+
138
+ const epoch = options.epoch ?? TSID_EPOCH
139
+ if (!Number.isInteger(epoch)) {
140
+ throw new InvalidInputError('TSID_EPOCH_INVALID', 'epoch must be a finite integer')
141
+ }
142
+ const msecs = options.msecs ?? Date.now()
143
+ if (!Number.isInteger(msecs)) {
144
+ throw new InvalidInputError('TSID_TIMESTAMP_INVALID', 'msecs must be a finite integer')
145
+ }
146
+ const msecsDiff = BigInt(msecs) - BigInt(epoch)
147
+ if (msecsDiff < 0n || msecsDiff > MAX_TIMESTAMP_DIFF) {
148
+ throw new InvalidInputError(
149
+ 'TSID_TIMESTAMP_OUT_OF_RANGE',
150
+ `msecs - epoch must be between 0 and ${MAX_TIMESTAMP_DIFF}`,
151
+ )
152
+ }
153
+
154
+ return {
155
+ msecsDiff,
156
+ node: optNode ?? randomUint32() & nodeMask,
157
+ counterBits,
158
+ counter: optCounter ?? randomUint32() & counterMask,
159
+ }
160
+ }
161
+
162
+ /*
163
+ * Overload: no buffer => return a TSID bigint.
164
+ */
165
+ function tsidFn(options?: TsidOptions, buf?: undefined, offset?: number): bigint
166
+ /*
167
+ * Overload: caller provides a buffer slice to fill with TSID bytes.
168
+ */
169
+ function tsidFn<TBuf extends Uint8Array = Uint8Array>(
170
+ options: TsidOptions | undefined,
171
+ buf: TBuf,
172
+ offset?: number,
173
+ ): TBuf
174
+ function tsidFn<TBuf extends Uint8Array = Uint8Array>(options?: TsidOptions, buf?: TBuf, offset = 0): bigint | TBuf {
175
+ let msecsDiff: bigint
176
+ let node: number
177
+ let counterBits: number
178
+ let counter: number
179
+
180
+ if (options) {
181
+ const resolved = resolveTsidOptions(options)
182
+ msecsDiff = resolved.msecsDiff
183
+ node = resolved.node
184
+ counterBits = resolved.counterBits
185
+ counter = resolved.counter
186
+ } else {
187
+ // Lazily initialize the persistent node ID on first no-option call.
188
+ if (state.node === undefined) {
189
+ state.node = randomUint32() & DEFAULT_NODE_MASK
190
+ }
191
+ node = state.node
192
+ counterBits = DEFAULT_COUNTER_BITS
193
+
194
+ /**
195
+ * Note: by default, Cloudflare Workers "freezes" time during request handling
196
+ * to prevent side-channel attacks, so Date.now() returns the same value for
197
+ * the entire duration of a request. Monotonic ordering within such a request
198
+ * relies entirely on the counter (and its clock-drift-ahead overflow below).
199
+ */
200
+ const now = Date.now()
201
+ if (now > state.msecs) {
202
+ state.msecs = now
203
+ state.counter = randomUint32() & DEFAULT_COUNTER_MASK
204
+ } else {
205
+ state.counter += 1
206
+ if (state.counter > DEFAULT_COUNTER_MASK) {
207
+ // Counter overflowed within this real millisecond: advance the internal
208
+ // virtual timestamp ahead of wall-clock time rather than throwing. A
209
+ // 12-bit counter (4096 values/ms) is a realistic throughput ceiling.
210
+ state.msecs += 1
211
+ state.counter = 0
212
+ }
213
+ }
214
+ counter = state.counter
215
+ msecsDiff = BigInt(state.msecs) - BigInt(TSID_EPOCH)
216
+ }
217
+
218
+ const packed = pack(msecsDiff, node, counterBits, counter)
219
+
220
+ if (buf) {
221
+ if (!isWritableRange(buf, offset, TSID_BYTES)) {
222
+ throw new BufferError(
223
+ 'TSID_BUFFER_OUT_OF_BOUNDS',
224
+ `TSID byte range ${offset}:${offset + TSID_BYTES - 1} is out of buffer bounds`,
225
+ )
226
+ }
227
+ writeTsidBytesUnchecked(packed, buf, offset)
228
+ return buf
229
+ }
230
+
231
+ return packed
232
+ }
233
+
234
+ /**
235
+ * Convert a TSID bigint to 8 bytes.
236
+ */
237
+ function toBytes(id: bigint): Uint8Array {
238
+ assertValidTsid(id)
239
+ const bytes = new Uint8Array(TSID_BYTES)
240
+ writeTsidBytesUnchecked(id, bytes, 0)
241
+ return bytes
242
+ }
243
+
244
+ /**
245
+ * Convert 8 bytes to a TSID bigint.
246
+ */
247
+ function fromBytes(bytes: Uint8Array): bigint {
248
+ if (bytes.length !== TSID_BYTES) {
249
+ throw new BufferError(
250
+ 'TSID_BYTES_INVALID_LENGTH',
251
+ `TSID bytes must be exactly ${TSID_BYTES} bytes, got ${bytes.length}`,
252
+ )
253
+ }
254
+
255
+ let value = 0n
256
+ for (let i = 0; i < TSID_BYTES; i += 1) {
257
+ value = (value << 8n) | BigInt(bytes[i])
258
+ }
259
+ return value
260
+ }
261
+
262
+ /**
263
+ * Extract the timestamp (milliseconds since Unix epoch) from a TSID.
264
+ * The epoch is not self-describing in the packed value - pass the same
265
+ * `epoch` used at generation time if it was overridden from the default.
266
+ */
267
+ function timestamp(id: bigint, epoch: number = TSID_EPOCH): number {
268
+ assertValidTsid(id)
269
+ if (!Number.isInteger(epoch)) {
270
+ throw new InvalidInputError('TSID_EPOCH_INVALID', 'epoch must be a finite integer')
271
+ }
272
+ return epoch + Number(id >> BigInt(RANDOM_BITS))
273
+ }
274
+
275
+ /**
276
+ * Validate that a value is a TSID bigint within the 64-bit range.
277
+ */
278
+ function isValid(id: unknown): id is bigint {
279
+ return typeof id === 'bigint' && id >= 0n && id <= MAX
280
+ }
281
+
282
+ const NIL = 0n
283
+ const MAX = (1n << 64n) - 1n
284
+
285
+ function assertValidTsid(id: bigint): void {
286
+ if (id < NIL || id > MAX) {
287
+ throw new InvalidInputError('TSID_VALUE_OUT_OF_RANGE', `TSID value must be between ${NIL} and ${MAX}`)
288
+ }
289
+ }
290
+
291
+ function toString(id: bigint): string {
292
+ assertValidTsid(id)
293
+ return encodeTsidString(id)
294
+ }
295
+
296
+ /**
297
+ * Generate a TSID bigint or write the bytes into a buffer.
298
+ *
299
+ * TSID (Time-Sorted Unique Identifier) is a 64-bit Snowflake-style identifier
300
+ * combining a millisecond timestamp, a node ID, and a per-millisecond counter.
301
+ * Unlike every other uniku generator, it returns a `bigint` by default, since
302
+ * TSID's entire value proposition is native numeric storage (e.g. a database
303
+ * BIGINT primary key).
304
+ *
305
+ * @example
306
+ * ```ts
307
+ * import { tsid } from 'uniku/tsid'
308
+ *
309
+ * const id = tsid()
310
+ * // => 862301223059968074n
311
+ *
312
+ * // Canonical string form
313
+ * const str = tsid.toString(id)
314
+ * // => "0QXW2CK4XZM2A"
315
+ * tsid.fromString(str) === id // true
316
+ *
317
+ * // Extract timestamp
318
+ * const ts = tsid.timestamp(id)
319
+ * console.log(new Date(ts))
320
+ *
321
+ * // Validate
322
+ * tsid.isValid(id) // true
323
+ *
324
+ * // Convert to/from bytes (8 bytes)
325
+ * const bytes = tsid.toBytes(id)
326
+ * const restored = tsid.fromBytes(bytes)
327
+ * ```
328
+ */
329
+ export const tsid: Tsid = Object.assign(tsidFn, {
330
+ toBytes,
331
+ fromBytes,
332
+ toString,
333
+ fromString: decodeTsidString,
334
+ timestamp,
335
+ isValid,
336
+ NIL,
337
+ MAX,
338
+ })
339
+
340
+ export { BufferError, InvalidInputError, ParseError, UniqueIdError } from '../errors'
@@ -231,8 +231,8 @@ export function decodeToBytes(str: string): Uint8Array {
231
231
  * Encode 16 bytes to a 26-character ULID string.
232
232
  */
233
233
  export function bytesToUlid(bytes: Uint8Array): string {
234
- if (bytes.length < 16) {
235
- throw new BufferError('ULID_BYTES_TOO_SHORT', 'Byte array must be at least 16 bytes')
234
+ if (bytes.length !== 16) {
235
+ throw new BufferError('ULID_BYTES_INVALID_LENGTH', `ULID bytes must be exactly 16 bytes, got ${bytes.length}`)
236
236
  }
237
237
 
238
238
  // Timestamp: bytes 0-5 -> 10 characters
package/src/ulid/ulid.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { incrementBytesInPlace, writeTimestamp48 } from '../common/bytes'
2
2
  import { rng } from '../common/random'
3
+ import { isIntegerInRange, isWritableRange } from '../common/validation'
3
4
  import { BufferError, InvalidInputError } from '../errors'
4
5
  import { bytesToUlid, decodeToBytes, decodeUlidTime, encodeRandom, encodeTime } from './crockford'
5
6
 
@@ -32,6 +33,9 @@ export type Ulid = {
32
33
 
33
34
  // Validation regex: first char [0-7] to prevent overflow, rest from Crockford alphabet
34
35
  const ULID_REGEX = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i
36
+ const ULID_BYTES = 16
37
+ const RANDOM_BYTES = 10
38
+ const MAX_MSECS = 0xffffffffffff
35
39
 
36
40
  type UlidState = {
37
41
  msecs: number
@@ -48,29 +52,27 @@ type UlidState = {
48
52
  */
49
53
  const state: UlidState = {
50
54
  msecs: -Infinity,
51
- lastRandom: new Uint8Array(10),
55
+ lastRandom: new Uint8Array(RANDOM_BYTES),
52
56
  }
53
57
 
54
- function ulidBytes(time: number, random: Uint8Array, buf?: Uint8Array, offset = 0): Uint8Array {
55
- if (!buf) {
56
- buf = new Uint8Array(16)
57
- offset = 0
58
- } else if (offset < 0 || offset + 16 > buf.length) {
59
- throw new BufferError(
60
- 'ULID_BUFFER_OUT_OF_BOUNDS',
61
- `ULID byte range ${offset}:${offset + 15} is out of buffer bounds`,
62
- )
63
- }
64
-
58
+ function writeUlidBytesUnchecked(time: number, random: Uint8Array, buf: Uint8Array, offset: number): void {
65
59
  // Timestamp (48-bit big-endian milliseconds since Unix epoch) -> bytes 0-5
66
60
  writeTimestamp48(buf, offset, time)
67
61
 
68
62
  // Random (80 bits) -> bytes 6-15
69
- for (let i = 0; i < 10; i += 1) {
63
+ for (let i = 0; i < RANDOM_BYTES; i += 1) {
70
64
  buf[offset + 6 + i] = random[i]
71
65
  }
66
+ }
72
67
 
73
- return buf
68
+ function writeUlidBytes(time: number, random: Uint8Array, buf: Uint8Array, offset: number): void {
69
+ if (!isWritableRange(buf, offset, ULID_BYTES)) {
70
+ throw new BufferError(
71
+ 'ULID_BUFFER_OUT_OF_BOUNDS',
72
+ `ULID byte range ${offset}:${offset + ULID_BYTES - 1} is out of buffer bounds`,
73
+ )
74
+ }
75
+ writeUlidBytesUnchecked(time, random, buf, offset)
74
76
  }
75
77
 
76
78
  /*
@@ -97,27 +99,36 @@ function ulidFn<TBuf extends Uint8Array = Uint8Array>(options?: UlidOptions, buf
97
99
  * - all ULIDs generated within a single request will have the same timestamp.
98
100
  * - the monotonic ordering will rely entirely on incrementing the random portion.
99
101
  */
100
- const defaultTime = Date.now()
101
-
102
102
  if (options) {
103
103
  // Explicit options provided - use them directly without monotonic state
104
- time = options.msecs ?? defaultTime
105
- if (options.random) {
106
- if (options.random.length < 10) {
107
- throw new InvalidInputError('ULID_RANDOM_BYTES_TOO_SHORT', 'Random bytes length must be >= 10 for ULID')
104
+ const optMsecs = options.msecs
105
+ if (optMsecs !== undefined && !isIntegerInRange(optMsecs, 0, MAX_MSECS)) {
106
+ throw new InvalidInputError(
107
+ 'ULID_TIMESTAMP_OUT_OF_RANGE',
108
+ `Timestamp must be an integer between 0 and ${MAX_MSECS}`,
109
+ )
110
+ }
111
+ time = optMsecs ?? Date.now()
112
+ const optRandom = options.random
113
+ if (optRandom) {
114
+ if (optRandom.length < RANDOM_BYTES) {
115
+ throw new InvalidInputError(
116
+ 'ULID_RANDOM_BYTES_TOO_SHORT',
117
+ `Random bytes length must be >= ${RANDOM_BYTES} for ULID`,
118
+ )
108
119
  }
109
- random = options.random
120
+ random = optRandom
110
121
  } else {
111
122
  random = rng()
112
123
  }
113
124
  } else {
114
- time = defaultTime
125
+ time = Date.now()
115
126
 
116
127
  if (time > state.msecs) {
117
128
  // New millisecond: generate fresh random
118
129
  random = rng()
119
130
  state.msecs = time
120
- state.lastRandom.set(random.subarray(0, 10))
131
+ state.lastRandom.set(random.subarray(0, RANDOM_BYTES))
121
132
  } else {
122
133
  // Same millisecond or clock rollback: preserve last timestamp and increment random portion.
123
134
  time = state.msecs
@@ -133,7 +144,7 @@ function ulidFn<TBuf extends Uint8Array = Uint8Array>(options?: UlidOptions, buf
133
144
  }
134
145
 
135
146
  if (buf) {
136
- ulidBytes(time, random, buf, offset)
147
+ writeUlidBytes(time, random, buf, offset)
137
148
  return buf
138
149
  }
139
150
 
@@ -1,4 +1,4 @@
1
- import { ParseError } from '../../errors'
1
+ import { BufferError, ParseError } from '../../errors'
2
2
  import { hexValue } from './hex'
3
3
 
4
4
  const UUID_BYTE_LENGTH = 16
@@ -93,6 +93,17 @@ const UUID_CHAR_IS_HIGH: boolean[] = [
93
93
  const HEX_TABLE: string[] = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0'))
94
94
 
95
95
  export function formatUuid(bytes: Uint8Array): string {
96
+ if (bytes.length !== UUID_BYTE_LENGTH) {
97
+ throw new BufferError(
98
+ 'UUID_BYTES_INVALID_LENGTH',
99
+ `UUID bytes must be exactly ${UUID_BYTE_LENGTH} bytes, got ${bytes.length}`,
100
+ )
101
+ }
102
+
103
+ return formatUuidUnchecked(bytes)
104
+ }
105
+
106
+ export function formatUuidUnchecked(bytes: Uint8Array): string {
96
107
  // Direct string concatenation - optimized for V8's string builder.
97
108
  // This approach avoids loop overhead and intermediate allocations.
98
109
  // See: https://github.com/uuidjs/uuid/pull/434