uniku 0.4.2 → 0.5.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 (77) hide show
  1. package/README.md +13 -12
  2. package/build/cuid2/cuid2.d.mts +1 -1
  3. package/build/cuid2/cuid2.d.mts.map +1 -1
  4. package/build/cuid2/cuid2.mjs +1 -1
  5. package/build/cuid2/cuid2.mjs.map +1 -1
  6. package/build/errors-Da-8dh4J.d.mts +67 -0
  7. package/build/errors-Da-8dh4J.d.mts.map +1 -0
  8. package/build/errors.d.mts +2 -2
  9. package/build/errors.mjs +1 -1
  10. package/build/errors.mjs.map +1 -1
  11. package/build/generators-BfJLt7Qf.d.mts +17 -0
  12. package/build/generators-BfJLt7Qf.d.mts.map +1 -0
  13. package/build/generators.d.mts +2 -17
  14. package/build/ksuid/ksuid.d.mts +10 -4
  15. package/build/ksuid/ksuid.d.mts.map +1 -1
  16. package/build/ksuid/ksuid.mjs +1 -1
  17. package/build/ksuid/ksuid.mjs.map +1 -1
  18. package/build/nanoid/nanoid.d.mts +13 -7
  19. package/build/nanoid/nanoid.d.mts.map +1 -1
  20. package/build/nanoid/nanoid.mjs +1 -1
  21. package/build/nanoid/nanoid.mjs.map +1 -1
  22. package/build/objectid/objectid.d.mts +10 -3
  23. package/build/objectid/objectid.d.mts.map +1 -1
  24. package/build/objectid/objectid.mjs +1 -1
  25. package/build/objectid/objectid.mjs.map +1 -1
  26. package/build/timestamp-ChrSuQCR.mjs +2 -0
  27. package/build/timestamp-ChrSuQCR.mjs.map +1 -0
  28. package/build/tsid/tsid.d.mts +1 -1
  29. package/build/tsid/tsid.d.mts.map +1 -1
  30. package/build/tsid/tsid.mjs +1 -1
  31. package/build/tsid/tsid.mjs.map +1 -1
  32. package/build/typeid/typeid.d.mts +4 -2
  33. package/build/typeid/typeid.d.mts.map +1 -1
  34. package/build/typeid/typeid.mjs +1 -1
  35. package/build/typeid/typeid.mjs.map +1 -1
  36. package/build/ulid/ulid.d.mts +1 -1
  37. package/build/ulid/ulid.d.mts.map +1 -1
  38. package/build/ulid/ulid.mjs +1 -1
  39. package/build/ulid/ulid.mjs.map +1 -1
  40. package/build/uuid/v4.d.mts +1 -1
  41. package/build/uuid/v4.d.mts.map +1 -1
  42. package/build/uuid/v4.mjs +1 -1
  43. package/build/uuid/v4.mjs.map +1 -1
  44. package/build/uuid/v7.d.mts +8 -2
  45. package/build/uuid/v7.d.mts.map +1 -1
  46. package/build/uuid/v7.mjs +1 -1
  47. package/build/uuid/v7.mjs.map +1 -1
  48. package/build/uuid-BPebYihz.mjs +2 -0
  49. package/build/uuid-BPebYihz.mjs.map +1 -0
  50. package/build/xid/xid.d.mts +12 -2
  51. package/build/xid/xid.d.mts.map +1 -1
  52. package/build/xid/xid.mjs +1 -1
  53. package/build/xid/xid.mjs.map +1 -1
  54. package/package.json +1 -1
  55. package/src/common/timestamp.ts +70 -0
  56. package/src/cuid2/cuid2.ts +5 -2
  57. package/src/errors.ts +75 -10
  58. package/src/ksuid/base62.ts +7 -7
  59. package/src/ksuid/ksuid.ts +19 -15
  60. package/src/nanoid/nanoid.ts +41 -18
  61. package/src/objectid/hex.ts +6 -4
  62. package/src/objectid/objectid.ts +24 -11
  63. package/src/tsid/crockford64.ts +7 -6
  64. package/src/tsid/tsid.ts +20 -13
  65. package/src/typeid/typeid.ts +106 -16
  66. package/src/ulid/crockford.ts +8 -6
  67. package/src/ulid/ulid.ts +11 -7
  68. package/src/uuid/common/uuid.ts +12 -4
  69. package/src/uuid/v4.ts +5 -2
  70. package/src/uuid/v7.ts +25 -9
  71. package/src/xid/base32hex.ts +8 -7
  72. package/src/xid/xid.ts +25 -12
  73. package/build/errors-Dgoyi-ci.d.mts +0 -38
  74. package/build/errors-Dgoyi-ci.d.mts.map +0 -1
  75. package/build/generators.d.mts.map +0 -1
  76. package/build/uuid-CnQIYoQi.mjs +0 -2
  77. package/build/uuid-CnQIYoQi.mjs.map +0 -1
@@ -41,8 +41,8 @@ function defaultAlphabetFromPool(size: number): string {
41
41
  export type NanoidOptions = {
42
42
  /**
43
43
  * Random bytes for deterministic output (testing).
44
- * For power-of-2 alphabets (2, 4, 8, 16, 32, 64, 128, 256): exactly `size` bytes needed.
45
- * For other alphabets: ~size * 2 bytes needed (rejection sampling).
44
+ * For power-of-2 alphabets (2, 4, 8, 16, 32, 64, 128, 256): exactly `length` bytes needed.
45
+ * For other alphabets: ~length * 2 bytes needed (rejection sampling).
46
46
  */
47
47
  random?: Uint8Array
48
48
  /**
@@ -53,6 +53,13 @@ export type NanoidOptions = {
53
53
  /**
54
54
  * Length of generated ID. Default: 21. Maximum: 2048.
55
55
  */
56
+ length?: number
57
+ /**
58
+ * Length of generated ID.
59
+ *
60
+ * @deprecated Use `length` instead. Will be removed at v1-rc.
61
+ */
62
+ // TODO(v1-rc): remove this alias (tracked in docs/STABILITY.md).
56
63
  size?: number
57
64
  }
58
65
 
@@ -75,36 +82,45 @@ export type Nanoid = {
75
82
  */
76
83
  function validateAlphabet(alphabet: string): void {
77
84
  if (alphabet.length < 2) {
78
- throw new InvalidInputError('NANOID_ALPHABET_TOO_SHORT', 'Alphabet must contain at least 2 characters')
85
+ throw new InvalidInputError('ALPHABET_OUT_OF_RANGE', 'Alphabet must contain at least 2 characters', {
86
+ strategy: 'nanoid',
87
+ })
79
88
  }
80
89
  if (alphabet.length > 256) {
81
- throw new InvalidInputError('NANOID_ALPHABET_TOO_LONG', 'Alphabet must not exceed 256 characters')
90
+ throw new InvalidInputError('ALPHABET_OUT_OF_RANGE', 'Alphabet must not exceed 256 characters', {
91
+ strategy: 'nanoid',
92
+ })
82
93
  }
83
94
  const seen = new Set<string>()
84
95
  for (const char of alphabet) {
85
96
  const code = char.charCodeAt(0)
86
97
  if (code < 32 || code > 126) {
87
98
  throw new InvalidInputError(
88
- 'NANOID_ALPHABET_INVALID_CHAR',
99
+ 'ALPHABET_INVALID_CHAR',
89
100
  'Alphabet must contain only printable ASCII characters (32-126)',
101
+ {
102
+ strategy: 'nanoid',
103
+ },
90
104
  )
91
105
  }
92
106
  if (seen.has(char)) {
93
- throw new InvalidInputError('NANOID_ALPHABET_DUPLICATE', `Duplicate character in alphabet: "${char}"`)
107
+ throw new InvalidInputError('ALPHABET_DUPLICATE', `Duplicate character in alphabet: "${char}"`, {
108
+ strategy: 'nanoid',
109
+ })
94
110
  }
95
111
  seen.add(char)
96
112
  }
97
113
  }
98
114
 
99
115
  /**
100
- * Validate size parameter
116
+ * Validate length parameter
101
117
  */
102
- function validateSize(size: number): void {
118
+ function validateLength(size: number): void {
103
119
  if (!Number.isInteger(size) || size < 0) {
104
- throw new InvalidInputError('NANOID_SIZE_INVALID', 'Size must be a non-negative integer')
120
+ throw new InvalidInputError('LENGTH_OUT_OF_RANGE', 'Length must be a non-negative integer', { strategy: 'nanoid' })
105
121
  }
106
122
  if (size > MAX_SIZE) {
107
- throw new InvalidInputError('NANOID_SIZE_TOO_LARGE', `Size must not exceed ${MAX_SIZE}`)
123
+ throw new InvalidInputError('LENGTH_OUT_OF_RANGE', `Length must not exceed ${MAX_SIZE}`, { strategy: 'nanoid' })
108
124
  }
109
125
  }
110
126
 
@@ -126,7 +142,12 @@ function nanoidFn(sizeOrOptions?: number | NanoidOptions): string {
126
142
  if (typeof sizeOrOptions === 'number') {
127
143
  size = sizeOrOptions
128
144
  } else {
129
- size = sizeOrOptions.size ?? DEFAULT_SIZE
145
+ if (sizeOrOptions.length !== undefined && sizeOrOptions.size !== undefined) {
146
+ throw new InvalidInputError('CONFLICTING_OPTIONS', 'Pass only one of `length` or `size`, not both', {
147
+ strategy: 'nanoid',
148
+ })
149
+ }
150
+ size = sizeOrOptions.length ?? sizeOrOptions.size ?? DEFAULT_SIZE
130
151
  alphabet = sizeOrOptions.alphabet ?? URL_ALPHABET
131
152
  randomBytes = sizeOrOptions.random
132
153
  if (sizeOrOptions.alphabet !== undefined) {
@@ -134,7 +155,7 @@ function nanoidFn(sizeOrOptions?: number | NanoidOptions): string {
134
155
  }
135
156
  }
136
157
 
137
- validateSize(size)
158
+ validateLength(size)
138
159
 
139
160
  if (size === 0) return ''
140
161
 
@@ -150,8 +171,9 @@ function nanoidFn(sizeOrOptions?: number | NanoidOptions): string {
150
171
  const mask = alphabetLen - 1
151
172
  if (randomBytes && randomBytes.length < size) {
152
173
  throw new InvalidInputError(
153
- 'NANOID_RANDOM_BYTES_INSUFFICIENT',
174
+ 'RANDOM_BYTES_TOO_SHORT',
154
175
  `Insufficient random bytes: need ${size}, have ${randomBytes.length}`,
176
+ { strategy: 'nanoid' },
155
177
  )
156
178
  }
157
179
  const bytes = randomBytes?.subarray(0, size) ?? globalThis.crypto.getRandomValues(new Uint8Array(size))
@@ -176,8 +198,9 @@ function nanoidFn(sizeOrOptions?: number | NanoidOptions): string {
176
198
  if (randomBytes) {
177
199
  if (randomBytes.length - randomOffset < step) {
178
200
  throw new InvalidInputError(
179
- 'NANOID_RANDOM_BYTES_INSUFFICIENT',
201
+ 'RANDOM_BYTES_TOO_SHORT',
180
202
  `Insufficient random bytes: need at least ${step} more, have ${randomBytes.length - randomOffset}`,
203
+ { strategy: 'nanoid' },
181
204
  )
182
205
  }
183
206
  bytes = randomBytes.subarray(randomOffset, randomOffset + step)
@@ -227,7 +250,7 @@ function isValid(id: unknown): id is string {
227
250
  * // => "V1StGXR8_Z5jdHi6B-myT"
228
251
  * ```
229
252
  *
230
- * @example Custom size
253
+ * @example Custom length
231
254
  * ```ts
232
255
  * const shortId = nanoid(10)
233
256
  * // => "IRFa-VaY2b"
@@ -235,7 +258,7 @@ function isValid(id: unknown): id is string {
235
258
  *
236
259
  * @example Custom alphabet (hex)
237
260
  * ```ts
238
- * const hexId = nanoid({ alphabet: '0123456789abcdef', size: 12 })
261
+ * const hexId = nanoid({ alphabet: '0123456789abcdef', length: 12 })
239
262
  * // => "4f90d13a42bc"
240
263
  * ```
241
264
  *
@@ -248,9 +271,9 @@ function isValid(id: unknown): id is string {
248
271
  * }
249
272
  * ```
250
273
  *
251
- * @throws {InvalidInputError} Size must be between 0 and 2048
274
+ * @throws {InvalidInputError} Length must be between 0 and 2048
252
275
  * @throws {InvalidInputError} Alphabet must contain 2-256 unique printable ASCII characters
253
- * @throws {InvalidInputError} Insufficient random bytes for requested size
276
+ * @throws {InvalidInputError} Insufficient random bytes for requested length
254
277
  */
255
278
  export const nanoid: Nanoid = Object.assign(nanoidFn, {
256
279
  isValid,
@@ -45,8 +45,9 @@ for (let i = 0; i < 6; i += 1) {
45
45
  export function encodeObjectIdHex(bytes: Uint8Array): string {
46
46
  if (bytes.length < OBJECTID_BYTES) {
47
47
  throw new BufferError(
48
- 'OBJECTID_BYTES_TOO_SHORT',
48
+ 'BYTES_INVALID_LENGTH',
49
49
  `ObjectID bytes must be at least ${OBJECTID_BYTES} bytes, got ${bytes.length}`,
50
+ { strategy: 'objectid' },
50
51
  )
51
52
  }
52
53
 
@@ -63,8 +64,9 @@ export function encodeObjectIdHex(bytes: Uint8Array): string {
63
64
  export function decodeObjectIdHex(str: string): Uint8Array {
64
65
  if (str.length !== OBJECTID_STRING_LEN) {
65
66
  throw new ParseError(
66
- 'OBJECTID_INVALID_LENGTH',
67
+ 'INVALID_LENGTH',
67
68
  `ObjectID string must be ${OBJECTID_STRING_LEN} characters, got ${str.length}`,
69
+ { strategy: 'objectid' },
68
70
  )
69
71
  }
70
72
 
@@ -76,10 +78,10 @@ export function decodeObjectIdHex(str: string): Uint8Array {
76
78
  const lo = DECODING[str.charCodeAt(loIndex)]
77
79
 
78
80
  if (hi === 255) {
79
- throw new ParseError('OBJECTID_INVALID_CHAR', `Invalid ObjectID character: ${str[hiIndex]}`)
81
+ throw new ParseError('INVALID_CHAR', `Invalid ObjectID character: ${str[hiIndex]}`, { strategy: 'objectid' })
80
82
  }
81
83
  if (lo === 255) {
82
- throw new ParseError('OBJECTID_INVALID_CHAR', `Invalid ObjectID character: ${str[loIndex]}`)
84
+ throw new ParseError('INVALID_CHAR', `Invalid ObjectID character: ${str[loIndex]}`, { strategy: 'objectid' })
83
85
  }
84
86
 
85
87
  bytes[i] = (hi << 4) | lo
@@ -1,5 +1,6 @@
1
1
  import { writeTimestamp32 } from '../common/bytes'
2
2
  import { randomBytes, randomUint32 } from '../common/random'
3
+ import { resolveTimestampSecs } from '../common/timestamp'
3
4
  import { isIntegerInRange, isWritableRange } from '../common/validation'
4
5
  import { BufferError, InvalidInputError } from '../errors'
5
6
  import { decodeObjectIdHex, encodeObjectIdHex } from './hex'
@@ -31,9 +32,17 @@ export type ObjectIdOptions = {
31
32
  */
32
33
  random?: Uint8Array
33
34
  /**
34
- * Timestamp in seconds since Unix epoch.
35
- * Defaults to Math.floor(Date.now() / 1000).
35
+ * Timestamp in milliseconds since the Unix epoch.
36
+ * Defaults to Date.now().
37
+ * ObjectID stores whole seconds, so sub-second precision is truncated.
36
38
  */
39
+ msecs?: number
40
+ /**
41
+ * Timestamp in seconds since the Unix epoch.
42
+ *
43
+ * @deprecated Use `msecs` instead. Will be removed at v1-rc.
44
+ */
45
+ // TODO(v1-rc): remove this alias (tracked in docs/STABILITY.md).
37
46
  secs?: number
38
47
  /**
39
48
  * 24-bit counter value (0 to 0xFFFFFF).
@@ -74,7 +83,7 @@ type ObjectIdState = {
74
83
  * - In serverless/edge functions with warm starts, state persists between invocations.
75
84
  * Unlike ULID/UUIDv7's per-timestamp sequence reset, the counter continuing to climb
76
85
  * across warm starts is exactly the anti-collision behavior the ObjectID spec intends.
77
- * - For isolated state, pass explicit `random`, `secs`, or `counter` via options.
86
+ * - For isolated state, pass explicit `random`, `msecs`, or `counter` via options.
78
87
  * - Tests should mock Date.now() or provide explicit options for deterministic behavior.
79
88
  */
80
89
  const state: ObjectIdState = {
@@ -139,19 +148,21 @@ function objectIdFn<TBuf extends Uint8Array = Uint8Array>(
139
148
  const optRandom = options.random
140
149
  if (optRandom && optRandom.length < RANDOM_BYTES) {
141
150
  throw new InvalidInputError(
142
- 'OBJECTID_RANDOM_BYTES_TOO_SHORT',
151
+ 'RANDOM_BYTES_TOO_SHORT',
143
152
  `Random bytes length must be >= ${RANDOM_BYTES} for ObjectID`,
153
+ {
154
+ strategy: 'objectid',
155
+ },
144
156
  )
145
157
  }
146
158
 
147
- const optSecs = options.secs
148
- if (optSecs !== undefined && !isIntegerInRange(optSecs, 0, MAX_SECS)) {
149
- throw new InvalidInputError('OBJECTID_TIMESTAMP_OUT_OF_RANGE', `Timestamp must be between 0 and ${MAX_SECS}`)
150
- }
159
+ const optSecs = resolveTimestampSecs(options, 0, MAX_SECS, 'objectid')
151
160
 
152
161
  const optCounter = options.counter
153
162
  if (optCounter !== undefined && !isIntegerInRange(optCounter, 0, MAX_COUNTER)) {
154
- throw new InvalidInputError('OBJECTID_COUNTER_OUT_OF_RANGE', `Counter must be between 0 and ${MAX_COUNTER}`)
163
+ throw new InvalidInputError('COUNTER_OUT_OF_RANGE', `Counter must be between 0 and ${MAX_COUNTER}`, {
164
+ strategy: 'objectid',
165
+ })
155
166
  }
156
167
 
157
168
  // Options bypass persistent state entirely: every field is sourced fresh (given
@@ -187,8 +198,9 @@ function objectIdFn<TBuf extends Uint8Array = Uint8Array>(
187
198
  if (buf) {
188
199
  if (!isWritableRange(buf, offset, OBJECTID_BYTES)) {
189
200
  throw new BufferError(
190
- 'OBJECTID_BUFFER_OUT_OF_BOUNDS',
201
+ 'BUFFER_OUT_OF_BOUNDS',
191
202
  `ObjectID byte range ${offset}:${offset + OBJECTID_BYTES - 1} is out of buffer bounds`,
203
+ { strategy: 'objectid' },
192
204
  )
193
205
  }
194
206
  writeObjectIdBytesUnchecked(secs, random, counter, buf, offset)
@@ -213,8 +225,9 @@ function toBytes(id: string): Uint8Array {
213
225
  function fromBytes(bytes: Uint8Array): string {
214
226
  if (bytes.length !== OBJECTID_BYTES) {
215
227
  throw new BufferError(
216
- 'OBJECTID_BYTES_INVALID_LENGTH',
228
+ 'BYTES_INVALID_LENGTH',
217
229
  `ObjectID bytes must be exactly ${OBJECTID_BYTES} bytes, got ${bytes.length}`,
230
+ { strategy: 'objectid' },
218
231
  )
219
232
  }
220
233
  return encodeObjectIdHex(bytes)
@@ -53,7 +53,9 @@ export function encodeTsidString(value: bigint): string {
53
53
  */
54
54
  export function decodeTsidString(str: string): bigint {
55
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}`)
56
+ throw new ParseError('INVALID_LENGTH', `TSID string must be ${TSID_STRING_LEN} characters, got ${str.length}`, {
57
+ strategy: 'tsid',
58
+ })
57
59
  }
58
60
 
59
61
  let value = 0n
@@ -61,13 +63,12 @@ export function decodeTsidString(str: string): bigint {
61
63
  const code = str.charCodeAt(i)
62
64
  const group = code < 128 ? DECODING[code] : 255
63
65
  if (group === 255) {
64
- throw new ParseError('TSID_INVALID_CHAR', `Invalid TSID character: ${str[i]}`)
66
+ throw new ParseError('INVALID_CHAR', `Invalid TSID character: ${str[i]}`, { strategy: 'tsid' })
65
67
  }
66
68
  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
- )
69
+ throw new ParseError('VALUE_OUT_OF_RANGE', `TSID leading character must be one of 0-9, A-F, got: ${str[i]}`, {
70
+ strategy: 'tsid',
71
+ })
71
72
  }
72
73
  value = (value << 5n) | BigInt(group)
73
74
  }
package/src/tsid/tsid.ts CHANGED
@@ -128,7 +128,9 @@ type ResolvedTsidOptions = {
128
128
  function resolveTsidOptions(options: TsidOptions): ResolvedTsidOptions {
129
129
  const nodeBits = options.nodeBits ?? DEFAULT_NODE_BITS
130
130
  if (!isIntegerInRange(nodeBits, 0, MAX_NODE_BITS)) {
131
- throw new InvalidInputError('TSID_NODE_BITS_OUT_OF_RANGE', `nodeBits must be between 0 and ${MAX_NODE_BITS}`)
131
+ throw new InvalidInputError('NODE_BITS_OUT_OF_RANGE', `nodeBits must be between 0 and ${MAX_NODE_BITS}`, {
132
+ strategy: 'tsid',
133
+ })
132
134
  }
133
135
 
134
136
  const counterBits = RANDOM_BITS - nodeBits
@@ -136,28 +138,29 @@ function resolveTsidOptions(options: TsidOptions): ResolvedTsidOptions {
136
138
  const counterMask = (1 << counterBits) - 1
137
139
  const optNode = options.node
138
140
  if (optNode !== undefined && !isIntegerInRange(optNode, 0, nodeMask)) {
139
- throw new InvalidInputError('TSID_NODE_OUT_OF_RANGE', `node must be between 0 and ${nodeMask}`)
141
+ throw new InvalidInputError('NODE_OUT_OF_RANGE', `node must be between 0 and ${nodeMask}`, { strategy: 'tsid' })
140
142
  }
141
143
 
142
144
  const optCounter = options.counter
143
145
  if (optCounter !== undefined && !isIntegerInRange(optCounter, 0, counterMask)) {
144
- throw new InvalidInputError('TSID_COUNTER_OUT_OF_RANGE', `counter must be between 0 and ${counterMask}`)
146
+ throw new InvalidInputError('COUNTER_OUT_OF_RANGE', `counter must be between 0 and ${counterMask}`, {
147
+ strategy: 'tsid',
148
+ })
145
149
  }
146
150
 
147
151
  const epoch = options.epoch ?? TSID_EPOCH
148
152
  if (!Number.isInteger(epoch)) {
149
- throw new InvalidInputError('TSID_EPOCH_INVALID', 'epoch must be a finite integer')
153
+ throw new InvalidInputError('EPOCH_INVALID', 'epoch must be a finite integer', { strategy: 'tsid' })
150
154
  }
151
155
  const msecs = options.msecs ?? Date.now()
152
156
  if (!Number.isInteger(msecs)) {
153
- throw new InvalidInputError('TSID_TIMESTAMP_INVALID', 'msecs must be a finite integer')
157
+ throw new InvalidInputError('TIMESTAMP_OUT_OF_RANGE', 'msecs must be a finite integer', { strategy: 'tsid' })
154
158
  }
155
159
  const msecsDiff = BigInt(msecs) - BigInt(epoch)
156
160
  if (msecsDiff < 0n || msecsDiff > MAX_TIMESTAMP_DIFF) {
157
- throw new InvalidInputError(
158
- 'TSID_TIMESTAMP_OUT_OF_RANGE',
159
- `msecs - epoch must be between 0 and ${MAX_TIMESTAMP_DIFF}`,
160
- )
161
+ throw new InvalidInputError('TIMESTAMP_OUT_OF_RANGE', `msecs - epoch must be between 0 and ${MAX_TIMESTAMP_DIFF}`, {
162
+ strategy: 'tsid',
163
+ })
161
164
  }
162
165
 
163
166
  return {
@@ -229,8 +232,9 @@ function tsidFn<TBuf extends Uint8Array = Uint8Array>(options?: TsidOptions, buf
229
232
  if (buf) {
230
233
  if (!isWritableRange(buf, offset, TSID_BYTES)) {
231
234
  throw new BufferError(
232
- 'TSID_BUFFER_OUT_OF_BOUNDS',
235
+ 'BUFFER_OUT_OF_BOUNDS',
233
236
  `TSID byte range ${offset}:${offset + TSID_BYTES - 1} is out of buffer bounds`,
237
+ { strategy: 'tsid' },
234
238
  )
235
239
  }
236
240
  writeTsidBytesUnchecked(packed, buf, offset)
@@ -256,8 +260,9 @@ function toBytes(id: bigint): Uint8Array {
256
260
  function fromBytes(bytes: Uint8Array): bigint {
257
261
  if (bytes.length !== TSID_BYTES) {
258
262
  throw new BufferError(
259
- 'TSID_BYTES_INVALID_LENGTH',
263
+ 'BYTES_INVALID_LENGTH',
260
264
  `TSID bytes must be exactly ${TSID_BYTES} bytes, got ${bytes.length}`,
265
+ { strategy: 'tsid' },
261
266
  )
262
267
  }
263
268
 
@@ -276,7 +281,7 @@ function fromBytes(bytes: Uint8Array): bigint {
276
281
  function timestamp(id: bigint, epoch: number = TSID_EPOCH): number {
277
282
  assertValidTsid(id)
278
283
  if (!Number.isInteger(epoch)) {
279
- throw new InvalidInputError('TSID_EPOCH_INVALID', 'epoch must be a finite integer')
284
+ throw new InvalidInputError('EPOCH_INVALID', 'epoch must be a finite integer', { strategy: 'tsid' })
280
285
  }
281
286
  return epoch + Number(id >> BigInt(RANDOM_BITS))
282
287
  }
@@ -293,7 +298,9 @@ const MAX = (1n << 64n) - 1n
293
298
 
294
299
  function assertValidTsid(id: bigint): void {
295
300
  if (id < NIL || id > MAX) {
296
- throw new InvalidInputError('TSID_VALUE_OUT_OF_RANGE', `TSID value must be between ${NIL} and ${MAX}`)
301
+ throw new InvalidInputError('VALUE_OUT_OF_RANGE', `TSID value must be between ${NIL} and ${MAX}`, {
302
+ strategy: 'tsid',
303
+ })
297
304
  }
298
305
  }
299
306
 
@@ -1,4 +1,5 @@
1
- import { InvalidInputError, ParseError } from '../errors'
1
+ import { isIntegerInRange, isWritableRange } from '../common/validation'
2
+ import { BufferError, InvalidInputError, ParseError } from '../errors'
2
3
  import type { UuidV7Options } from '../uuid/v7'
3
4
  import { uuidv7 } from '../uuid/v7'
4
5
 
@@ -7,6 +8,15 @@ export type TypeidOptions = UuidV7Options
7
8
  export type Typeid = {
8
9
  /** Generate a TypeID from a lowercase entity prefix and a UUID v7 suffix. */
9
10
  (prefix: string, options?: TypeidOptions): string
11
+ /** Generate a TypeID with explicit options or write its 16 canonical UUID v7 bytes into a caller-owned buffer. */
12
+ <TBuf extends Uint8Array = Uint8Array>(
13
+ prefix: string,
14
+ options: TypeidOptions | undefined,
15
+ buf: TBuf,
16
+ offset?: number,
17
+ ): TBuf
18
+ /** Generate a TypeID string with optional timestamp, counter, or random bytes. */
19
+ (prefix: string, options?: TypeidOptions, buf?: undefined, offset?: number): string
10
20
  /** Convert a TypeID's UUID v7 suffix to its canonical 16-byte representation. */
11
21
  toBytes(id: string): Uint8Array
12
22
  /** Build a TypeID from a prefix and canonical UUID v7 bytes. */
@@ -27,6 +37,11 @@ export type Typeid = {
27
37
 
28
38
  const TYPEID_SUFFIX_LENGTH = 26
29
39
  const TYPEID_UUID_BYTE_LENGTH = 16
40
+ // 48-bit UUID v7 timestamp capacity, mirrored from uuid/v7 alongside the
41
+ // 32-bit counter capacity, so option validation is attributed to the typeid
42
+ // boundary instead of leaking `strategy: 'uuid'` through delegation.
43
+ const MAX_MSECS = 0xffffffffffff
44
+ const MAX_COUNTER = 0xffffffff
30
45
  const TYPEID_ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'
31
46
 
32
47
  const BASE32_DECODE: number[] = Array.from({ length: 128 }, () => -1)
@@ -46,7 +61,9 @@ function assertValidPrefix(prefix: string): void {
46
61
  }
47
62
 
48
63
  if (prefix.length > 63) {
49
- throw new InvalidInputError('TYPEID_PREFIX_TOO_LONG', 'TypeID prefix must be at most 63 characters')
64
+ throw new InvalidInputError('PREFIX_TOO_LONG', 'TypeID prefix must be at most 63 characters', {
65
+ strategy: 'typeid',
66
+ })
50
67
  }
51
68
 
52
69
  for (let i = 0; i < prefix.length; i += 1) {
@@ -56,13 +73,16 @@ function assertValidPrefix(prefix: string): void {
56
73
 
57
74
  if (!isLowercaseLetter && !isUnderscore) {
58
75
  throw new InvalidInputError(
59
- 'TYPEID_PREFIX_INVALID_CHARACTER',
76
+ 'PREFIX_INVALID_CHAR',
60
77
  'TypeID prefix must contain only lowercase ASCII letters and underscores',
78
+ { strategy: 'typeid' },
61
79
  )
62
80
  }
63
81
 
64
82
  if ((i === 0 || i === prefix.length - 1) && !isLowercaseLetter) {
65
- throw new InvalidInputError('TYPEID_PREFIX_INVALID_BOUNDARY', 'TypeID prefix must start and end with a-z')
83
+ throw new InvalidInputError('PREFIX_INVALID_BOUNDARY', 'TypeID prefix must start and end with a-z', {
84
+ strategy: 'typeid',
85
+ })
66
86
  }
67
87
  }
68
88
  }
@@ -72,10 +92,9 @@ function decodeSuffixValue(suffix: string, index: number): number {
72
92
  const value = code < BASE32_DECODE.length ? BASE32_DECODE[code] : -1
73
93
 
74
94
  if (value === -1) {
75
- throw new ParseError(
76
- 'TYPEID_SUFFIX_INVALID_CHARACTER',
77
- `TypeID suffix contains invalid character at position ${index}`,
78
- )
95
+ throw new ParseError('INVALID_CHAR', `TypeID suffix contains invalid character at position ${index}`, {
96
+ strategy: 'typeid',
97
+ })
79
98
  }
80
99
 
81
100
  return value
@@ -83,17 +102,23 @@ function decodeSuffixValue(suffix: string, index: number): number {
83
102
 
84
103
  function assertValidSuffixShape(suffix: string): void {
85
104
  if (suffix.length !== TYPEID_SUFFIX_LENGTH) {
86
- throw new ParseError('TYPEID_SUFFIX_INVALID_LENGTH', `TypeID suffix must be 26 characters, got ${suffix.length}`)
105
+ throw new ParseError('INVALID_LENGTH', `TypeID suffix must be 26 characters, got ${suffix.length}`, {
106
+ strategy: 'typeid',
107
+ })
87
108
  }
88
109
 
89
110
  if (suffix[0] > '7') {
90
- throw new ParseError('TYPEID_SUFFIX_OVERFLOW', 'TypeID suffix must encode a 128-bit value')
111
+ throw new ParseError('VALUE_OUT_OF_RANGE', 'TypeID suffix must encode a 128-bit value', {
112
+ strategy: 'typeid',
113
+ })
91
114
  }
92
115
  }
93
116
 
94
117
  function encodeBytesToSuffix(bytes: Uint8Array): string {
95
118
  if (bytes.length !== TYPEID_UUID_BYTE_LENGTH) {
96
- throw new InvalidInputError('TYPEID_UUID_BYTES_INVALID_LENGTH', `UUID bytes must be 16 bytes, got ${bytes.length}`)
119
+ throw new InvalidInputError('BYTES_INVALID_LENGTH', `UUID bytes must be 16 bytes, got ${bytes.length}`, {
120
+ strategy: 'typeid',
121
+ })
97
122
  }
98
123
 
99
124
  return (
@@ -157,11 +182,15 @@ function decodeSuffixToBytes(suffix: string): Uint8Array {
157
182
 
158
183
  function assertUuidV7Bytes(bytes: Uint8Array): void {
159
184
  if (bytes.length !== TYPEID_UUID_BYTE_LENGTH) {
160
- throw new InvalidInputError('TYPEID_UUID_BYTES_INVALID_LENGTH', `UUID bytes must be 16 bytes, got ${bytes.length}`)
185
+ throw new InvalidInputError('BYTES_INVALID_LENGTH', `UUID bytes must be 16 bytes, got ${bytes.length}`, {
186
+ strategy: 'typeid',
187
+ })
161
188
  }
162
189
 
163
190
  if (bytes[6] >> 4 !== 7 || (bytes[8] & 0xc0) !== 0x80) {
164
- throw new InvalidInputError('TYPEID_UUID_NOT_V7', 'TypeID UUID bytes must encode a UUID v7 value')
191
+ throw new InvalidInputError('UUID_NOT_V7', 'TypeID UUID bytes must encode a UUID v7 value', {
192
+ strategy: 'typeid',
193
+ })
165
194
  }
166
195
  }
167
196
 
@@ -173,7 +202,7 @@ function uuidV7BytesFromSuffix(suffix: string): Uint8Array {
173
202
 
174
203
  function uuidV7BytesFromUuid(uuid: string): Uint8Array {
175
204
  if (!uuidv7.isValid(uuid)) {
176
- throw new InvalidInputError('TYPEID_UUID_NOT_V7', 'TypeID can only wrap UUID v7 values')
205
+ throw new InvalidInputError('UUID_NOT_V7', 'TypeID can only wrap UUID v7 values', { strategy: 'typeid' })
177
206
  }
178
207
 
179
208
  return uuidv7.toBytes(uuid)
@@ -183,7 +212,7 @@ function parseTypeid(id: string): ParsedTypeid {
183
212
  const separatorIndex = id.lastIndexOf('_')
184
213
 
185
214
  if (separatorIndex === 0) {
186
- throw new ParseError('TYPEID_INVALID_FORMAT', 'TypeID must not start with "_"')
215
+ throw new ParseError('INVALID_FORMAT', 'TypeID must not start with "_"', { strategy: 'typeid' })
187
216
  }
188
217
 
189
218
  const prefix = separatorIndex === -1 ? '' : id.slice(0, separatorIndex)
@@ -213,7 +242,68 @@ function timestampFromUuidV7Bytes(bytes: Uint8Array): number {
213
242
  return msecs
214
243
  }
215
244
 
216
- function typeidFn(prefix: string, options?: TypeidOptions): string {
245
+ /**
246
+ * Mirror uuid/v7's option validation at the typeid boundary, so failures are
247
+ * attributed to `strategy: 'typeid'` instead of leaking `strategy: 'uuid'`
248
+ * through delegation.
249
+ */
250
+ function validateTypeidOptions(options: TypeidOptions): void {
251
+ const msecs = options.msecs
252
+ if (msecs !== undefined && !isIntegerInRange(msecs, 0, MAX_MSECS)) {
253
+ throw new InvalidInputError('TIMESTAMP_OUT_OF_RANGE', `Timestamp must be an integer between 0 and ${MAX_MSECS}`, {
254
+ strategy: 'typeid',
255
+ })
256
+ }
257
+
258
+ if (options.counter !== undefined && options.seq !== undefined) {
259
+ throw new InvalidInputError('CONFLICTING_OPTIONS', 'Pass only one of `counter` or `seq`, not both', {
260
+ strategy: 'typeid',
261
+ })
262
+ }
263
+ const counter = options.counter ?? options.seq
264
+ if (counter !== undefined && !isIntegerInRange(counter, 0, MAX_COUNTER)) {
265
+ throw new InvalidInputError('COUNTER_OUT_OF_RANGE', `Counter must be an integer between 0 and ${MAX_COUNTER}`, {
266
+ strategy: 'typeid',
267
+ })
268
+ }
269
+
270
+ const random = options.random
271
+ if (random && random.length < TYPEID_UUID_BYTE_LENGTH) {
272
+ throw new InvalidInputError('RANDOM_BYTES_TOO_SHORT', `Random bytes length must be >= ${TYPEID_UUID_BYTE_LENGTH}`, {
273
+ strategy: 'typeid',
274
+ })
275
+ }
276
+ }
277
+
278
+ function typeidFn(prefix: string, options?: TypeidOptions, buf?: undefined, offset?: number): string
279
+ function typeidFn<TBuf extends Uint8Array = Uint8Array>(
280
+ prefix: string,
281
+ options: TypeidOptions | undefined,
282
+ buf: TBuf,
283
+ offset?: number,
284
+ ): TBuf
285
+ function typeidFn<TBuf extends Uint8Array = Uint8Array>(
286
+ prefix: string,
287
+ options?: TypeidOptions,
288
+ buf?: TBuf,
289
+ offset = 0,
290
+ ): string | TBuf {
291
+ if (options !== undefined) {
292
+ validateTypeidOptions(options)
293
+ }
294
+
295
+ if (buf) {
296
+ if (!isWritableRange(buf, offset, TYPEID_UUID_BYTE_LENGTH)) {
297
+ throw new BufferError(
298
+ 'BUFFER_OUT_OF_BOUNDS',
299
+ `TypeID byte range ${offset}:${offset + TYPEID_UUID_BYTE_LENGTH - 1} is out of buffer bounds`,
300
+ { strategy: 'typeid' },
301
+ )
302
+ }
303
+ assertValidPrefix(prefix)
304
+ return uuidv7(options, buf, offset)
305
+ }
306
+
217
307
  const bytes = uuidv7(options, new Uint8Array(TYPEID_UUID_BYTE_LENGTH))
218
308
  return formatTypeidFromUuidV7Bytes(prefix, bytes)
219
309
  }
@@ -25,11 +25,11 @@ const TIME_LEN = 10
25
25
  const ULID_LEN = 26
26
26
 
27
27
  function invalidCharError(str: string, index: number): ParseError {
28
- return new ParseError('ULID_INVALID_CHAR', `Invalid ULID character: ${str[index]}`)
28
+ return new ParseError('INVALID_CHAR', `Invalid ULID character: ${str[index]}`, { strategy: 'ulid' })
29
29
  }
30
30
 
31
31
  function timestampOverflowError(): ParseError {
32
- return new ParseError('ULID_TIMESTAMP_OVERFLOW', 'ULID timestamp exceeds 48 bits')
32
+ return new ParseError('TIMESTAMP_OUT_OF_RANGE', 'ULID timestamp exceeds 48 bits', { strategy: 'ulid' })
33
33
  }
34
34
 
35
35
  function decodeTimeChars(str: string): number {
@@ -106,7 +106,7 @@ export function encodeRandom(bytes: Uint8Array): string {
106
106
  */
107
107
  export function decodeTime(str: string): number {
108
108
  if (str.length !== TIME_LEN) {
109
- throw new ParseError('ULID_INVALID_LENGTH', `ULID timestamp must be ${TIME_LEN} characters`)
109
+ throw new ParseError('INVALID_LENGTH', `ULID timestamp must be ${TIME_LEN} characters`, { strategy: 'ulid' })
110
110
  }
111
111
  return decodeTimeChars(str)
112
112
  }
@@ -116,7 +116,7 @@ export function decodeTime(str: string): number {
116
116
  */
117
117
  export function decodeUlidTime(str: string): number {
118
118
  if (str.length !== ULID_LEN) {
119
- throw new ParseError('ULID_INVALID_LENGTH', `ULID string must be ${ULID_LEN} characters`)
119
+ throw new ParseError('INVALID_LENGTH', `ULID string must be ${ULID_LEN} characters`, { strategy: 'ulid' })
120
120
  }
121
121
  return decodeTimeChars(str)
122
122
  }
@@ -127,7 +127,7 @@ export function decodeUlidTime(str: string): number {
127
127
  */
128
128
  export function decodeToBytes(str: string): Uint8Array {
129
129
  if (str.length !== ULID_LEN) {
130
- throw new ParseError('ULID_INVALID_LENGTH', `ULID string must be ${ULID_LEN} characters`)
130
+ throw new ParseError('INVALID_LENGTH', `ULID string must be ${ULID_LEN} characters`, { strategy: 'ulid' })
131
131
  }
132
132
 
133
133
  const bytes = new Uint8Array(16)
@@ -232,7 +232,9 @@ export function decodeToBytes(str: string): Uint8Array {
232
232
  */
233
233
  export function bytesToUlid(bytes: Uint8Array): string {
234
234
  if (bytes.length !== 16) {
235
- throw new BufferError('ULID_BYTES_INVALID_LENGTH', `ULID bytes must be exactly 16 bytes, got ${bytes.length}`)
235
+ throw new BufferError('BYTES_INVALID_LENGTH', `ULID bytes must be exactly 16 bytes, got ${bytes.length}`, {
236
+ strategy: 'ulid',
237
+ })
236
238
  }
237
239
 
238
240
  // Timestamp: bytes 0-5 -> 10 characters