uniku 0.1.0 → 0.3.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 (65) hide show
  1. package/README.md +141 -22
  2. package/build/{bytes-DzcOKIx1.mjs → bytes-xqWxFYsM.mjs} +1 -1
  3. package/build/{bytes-DzcOKIx1.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 +60 -0
  28. package/build/objectid/objectid.d.mts.map +1 -0
  29. package/build/objectid/objectid.mjs +2 -0
  30. package/build/objectid/objectid.mjs.map +1 -0
  31. package/build/random-Chp-Nkzi.mjs +2 -0
  32. package/build/{random-CGMm8owG.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 -5
  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 +3 -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-Ub6ladEr.mjs → uuid-BBjwHv7J.mjs} +1 -1
  53. package/build/uuid-BBjwHv7J.mjs.map +1 -0
  54. package/package.json +35 -5
  55. package/src/cuid/v2.ts +19 -0
  56. package/src/cuid2/cuid2.ts +4 -0
  57. package/src/generators.ts +17 -0
  58. package/src/objectid/hex.ts +89 -0
  59. package/src/objectid/objectid.ts +266 -0
  60. package/src/tsid/crockford64.ts +75 -0
  61. package/src/tsid/tsid.ts +310 -0
  62. package/build/errors.d.mts.map +0 -1
  63. package/build/random-CGMm8owG.mjs +0 -2
  64. package/build/tsconfig.tsbuildinfo +0 -1
  65. package/build/uuid-Ub6ladEr.mjs.map +0 -1
@@ -0,0 +1,266 @@
1
+ import { writeTimestamp32 } from '../common/bytes'
2
+ import { randomBytes, randomUint32 } from '../common/random'
3
+ import { BufferError, InvalidInputError } from '../errors'
4
+ import { decodeObjectIdHex, encodeObjectIdHex } from './hex'
5
+
6
+ /**
7
+ * MongoDB ObjectID
8
+ *
9
+ * A 96-bit (12-byte) identifier consisting of:
10
+ * - 4 bytes: big-endian Unix timestamp in seconds
11
+ * - 5 bytes: per-process random value
12
+ * - 3 bytes: big-endian counter, always incrementing (wraps at 0xFFFFFF back to 0)
13
+ *
14
+ * Encoded as a 24-character lowercase hex string.
15
+ */
16
+
17
+ const OBJECTID_BYTES = 12
18
+ const TIMESTAMP_BYTES = 4
19
+ const RANDOM_BYTES = 5
20
+ const COUNTER_BYTES = 3
21
+ const MAX_SECS = 0xffffffff
22
+ const MAX_COUNTER = 0xffffff
23
+
24
+ // Validation regex: case-insensitive per KTD7, matching bson's ObjectId.isValid()
25
+ const OBJECTID_REGEX = /^[0-9a-f]{24}$/i
26
+
27
+ export type ObjectIdOptions = {
28
+ /**
29
+ * 5 bytes of random data to use for the ObjectID's random field.
30
+ */
31
+ random?: Uint8Array
32
+ /**
33
+ * Timestamp in seconds since Unix epoch.
34
+ * Defaults to Math.floor(Date.now() / 1000).
35
+ */
36
+ secs?: number
37
+ /**
38
+ * 24-bit counter value (0 to 0xFFFFFF).
39
+ */
40
+ counter?: number
41
+ }
42
+
43
+ export type ObjectId = {
44
+ (): string
45
+ <TBuf extends Uint8Array = Uint8Array>(options: ObjectIdOptions | undefined, buf: TBuf, offset?: number): TBuf
46
+ (options?: ObjectIdOptions, buf?: undefined, offset?: number): string
47
+ toBytes(id: string): Uint8Array
48
+ fromBytes(bytes: Uint8Array): string
49
+ timestamp(id: string): number
50
+ isValid(id: unknown): id is string
51
+ /** The nil ObjectID (all zeros) */
52
+ NIL: string
53
+ /** The max ObjectID (all 0xff) */
54
+ MAX: string
55
+ }
56
+
57
+ type ObjectIdState = {
58
+ random: Uint8Array | undefined
59
+ counter: number | undefined
60
+ }
61
+
62
+ /**
63
+ * Module-level state for the per-process random value and the always-incrementing counter.
64
+ *
65
+ * IMPORTANT: This state persists across all objectid() calls in the module's lifetime.
66
+ * - In serverless/edge functions with warm starts, state persists between invocations.
67
+ * Unlike ULID/UUIDv7's per-timestamp sequence reset, the counter continuing to climb
68
+ * across warm starts is exactly the anti-collision behavior the ObjectID spec intends.
69
+ * - For isolated state, pass explicit `random`, `secs`, or `counter` via options.
70
+ * - Tests should mock Date.now() or provide explicit options for deterministic behavior.
71
+ */
72
+ const state: ObjectIdState = {
73
+ random: undefined,
74
+ counter: undefined,
75
+ }
76
+
77
+ /**
78
+ * Draw 5 fresh random bytes into a newly-owned buffer, copying immediately so the
79
+ * result is unaffected by any later pool refill triggered by other random draws
80
+ * (e.g. the counter's `randomUint32()` call) before these bytes are consumed.
81
+ */
82
+ function freshRandom(): Uint8Array {
83
+ const bytes = new Uint8Array(RANDOM_BYTES)
84
+ bytes.set(randomBytes(RANDOM_BYTES))
85
+ return bytes
86
+ }
87
+
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
+
99
+ // Timestamp (32-bit big-endian seconds since Unix epoch) -> bytes 0-3
100
+ writeTimestamp32(buf, offset, secs)
101
+
102
+ // Random (5 bytes) -> bytes 4-8
103
+ for (let i = 0; i < RANDOM_BYTES; i += 1) {
104
+ buf[offset + TIMESTAMP_BYTES + i] = random[i]
105
+ }
106
+
107
+ // Counter (24-bit big-endian) -> bytes 9-11
108
+ buf[offset + TIMESTAMP_BYTES + RANDOM_BYTES] = (counter >>> 16) & 0xff
109
+ buf[offset + TIMESTAMP_BYTES + RANDOM_BYTES + 1] = (counter >>> 8) & 0xff
110
+ buf[offset + TIMESTAMP_BYTES + RANDOM_BYTES + COUNTER_BYTES - 1] = counter & 0xff
111
+
112
+ return buf
113
+ }
114
+
115
+ /*
116
+ * Overload: no buffer => return an ObjectID string.
117
+ */
118
+ function objectIdFn(options?: ObjectIdOptions, buf?: undefined, offset?: number): string
119
+ /*
120
+ * Overload: caller provides a buffer slice to fill with ObjectID bytes.
121
+ */
122
+ function objectIdFn<TBuf extends Uint8Array = Uint8Array>(
123
+ options: ObjectIdOptions | undefined,
124
+ buf: TBuf,
125
+ offset?: number,
126
+ ): TBuf
127
+ function objectIdFn<TBuf extends Uint8Array = Uint8Array>(
128
+ options?: ObjectIdOptions,
129
+ buf?: TBuf,
130
+ offset = 0,
131
+ ): string | TBuf {
132
+ let secs: number
133
+ let random: Uint8Array
134
+ let counter: number
135
+
136
+ if (options) {
137
+ const optRandom = options.random
138
+ if (optRandom && optRandom.length < RANDOM_BYTES) {
139
+ throw new InvalidInputError(
140
+ 'OBJECTID_RANDOM_BYTES_TOO_SHORT',
141
+ `Random bytes length must be >= ${RANDOM_BYTES} for ObjectID`,
142
+ )
143
+ }
144
+
145
+ const optSecs = options.secs
146
+ if (optSecs !== undefined && (optSecs < 0 || optSecs > MAX_SECS)) {
147
+ throw new InvalidInputError('OBJECTID_TIMESTAMP_OUT_OF_RANGE', `Timestamp must be between 0 and ${MAX_SECS}`)
148
+ }
149
+
150
+ const optCounter = options.counter
151
+ if (optCounter !== undefined && (optCounter < 0 || optCounter > MAX_COUNTER)) {
152
+ throw new InvalidInputError('OBJECTID_COUNTER_OUT_OF_RANGE', `Counter must be between 0 and ${MAX_COUNTER}`)
153
+ }
154
+
155
+ // Options bypass persistent state entirely: every field is sourced fresh (given
156
+ // value, or an independently-defaulted one), never read from or written to
157
+ // `state` (see KTD2). This is what makes options-based generation deterministic
158
+ // when all three fields are supplied.
159
+ secs = optSecs ?? Math.floor(Date.now() / 1000)
160
+ random = optRandom ?? freshRandom()
161
+ counter = optCounter ?? randomUint32() & MAX_COUNTER
162
+ } else {
163
+ // Lazily initialize persistent state on first no-option call.
164
+ if (state.random === undefined) {
165
+ state.random = freshRandom()
166
+ }
167
+ if (state.counter === undefined) {
168
+ state.counter = randomUint32() & MAX_COUNTER
169
+ }
170
+
171
+ /**
172
+ * Note: by default, Cloudflare Workers "freezes" time during request handling to prevent
173
+ * side-channel attacks. This means that Date.now() will return the same value for the entire
174
+ * duration of a request.
175
+ * Implications:
176
+ * - all ObjectIDs generated within a single request will share the same timestamp.
177
+ * - monotonic ordering relies entirely on the ever-incrementing counter.
178
+ */
179
+ secs = Math.floor(Date.now() / 1000)
180
+ random = state.random
181
+ state.counter = (state.counter + 1) & MAX_COUNTER
182
+ counter = state.counter
183
+ }
184
+
185
+ if (buf) {
186
+ objectIdBytes(secs, random, counter, buf, offset)
187
+ return buf
188
+ }
189
+
190
+ const bytes = objectIdBytes(secs, random, counter)
191
+ return encodeObjectIdHex(bytes)
192
+ }
193
+
194
+ /**
195
+ * Convert an ObjectID hex string to 12 bytes.
196
+ */
197
+ function toBytes(id: string): Uint8Array {
198
+ return decodeObjectIdHex(id)
199
+ }
200
+
201
+ /**
202
+ * Convert 12 bytes to an ObjectID hex string.
203
+ */
204
+ function fromBytes(bytes: Uint8Array): string {
205
+ return encodeObjectIdHex(bytes)
206
+ }
207
+
208
+ /**
209
+ * Extract the timestamp from an ObjectID string.
210
+ * Returns Unix timestamp in milliseconds for API consistency with ulid/uuidv7/ksuid.
211
+ * Note: ObjectID only has second precision, so the returned value will always end in 000.
212
+ */
213
+ function timestamp(id: string): number {
214
+ const bytes = decodeObjectIdHex(id)
215
+ // First 4 bytes are big-endian timestamp (seconds since Unix epoch)
216
+ const secs = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0
217
+ return secs * 1000
218
+ }
219
+
220
+ /**
221
+ * Validate an ObjectID string format.
222
+ * Case-insensitive (KTD7), matching bson's ObjectId.isValid() and this repo's existing
223
+ * convention (ulid, ksuid) of accepting mixed-case input for parsing, even though
224
+ * generation always emits lowercase (matching MongoDB driver output).
225
+ */
226
+ function isValid(id: unknown): id is string {
227
+ return typeof id === 'string' && OBJECTID_REGEX.test(id)
228
+ }
229
+
230
+ /**
231
+ * Generate a MongoDB ObjectID string or write the bytes into a buffer.
232
+ *
233
+ * ObjectID is a 12-byte identifier consisting of a 4-byte timestamp, a 5-byte
234
+ * per-process random value, and a 3-byte always-incrementing counter, encoded
235
+ * as a 24-character lowercase hex string. It is time-ordered and compatible
236
+ * with MongoDB's own ObjectID implementation.
237
+ *
238
+ * @example
239
+ * ```ts
240
+ * import { objectid } from 'uniku/objectid'
241
+ *
242
+ * const id = objectid()
243
+ * // => "667c3f2a1e2b3c4d5e6f7081"
244
+ *
245
+ * // Extract timestamp
246
+ * const ts = objectid.timestamp(id)
247
+ * console.log(new Date(ts))
248
+ *
249
+ * // Validate
250
+ * objectid.isValid(id) // true
251
+ *
252
+ * // Convert to/from bytes (12 bytes)
253
+ * const bytes = objectid.toBytes(id)
254
+ * const restored = objectid.fromBytes(bytes)
255
+ * ```
256
+ */
257
+ export const objectid: ObjectId = Object.assign(objectIdFn, {
258
+ toBytes,
259
+ fromBytes,
260
+ timestamp,
261
+ isValid,
262
+ NIL: '0'.repeat(24),
263
+ MAX: 'f'.repeat(24),
264
+ })
265
+
266
+ export { BufferError, InvalidInputError, ParseError, UniqueIdError } from '../errors'
@@ -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,310 @@
1
+ import { randomUint32 } from '../common/random'
2
+ import { BufferError, InvalidInputError } from '../errors'
3
+ import { decodeTsidString, encodeTsidString } from './crockford64'
4
+
5
+ /**
6
+ * TSID (Time-Sorted Unique Identifier)
7
+ *
8
+ * A 64-bit Snowflake-style identifier consisting of:
9
+ * - 42 bits: millisecond timestamp relative to a custom epoch (default 2020-01-01T00:00:00.000Z)
10
+ * - 10 bits: node ID (default; configurable via `nodeBits`)
11
+ * - 12 bits: per-millisecond counter (default; `22 - nodeBits`)
12
+ *
13
+ * Unlike every other uniku generator, `tsid()` returns a `bigint` by default -
14
+ * this is a deliberate design decision reflecting that TSID's value proposition
15
+ * is native numeric storage (e.g. a database BIGINT primary key), not a
16
+ * string-first identifier that happens to have a byte encoding. `toString`/
17
+ * `fromString` are the boundary conversions to/from the 13-character canonical
18
+ * Crockford Base32 string.
19
+ *
20
+ * Encoded as 8 big-endian bytes in buffer mode.
21
+ */
22
+
23
+ const TSID_EPOCH = 1577836800000 // 2020-01-01T00:00:00.000Z
24
+ const TSID_BYTES = 8
25
+ const RANDOM_BITS = 22 // combined node + counter bits
26
+ const DEFAULT_NODE_BITS = 10
27
+ const DEFAULT_COUNTER_BITS = RANDOM_BITS - DEFAULT_NODE_BITS
28
+ const DEFAULT_NODE_MASK = (1 << DEFAULT_NODE_BITS) - 1
29
+ const DEFAULT_COUNTER_MASK = (1 << DEFAULT_COUNTER_BITS) - 1
30
+ const MAX_NODE_BITS = 20
31
+ const MAX_TIMESTAMP_DIFF = (1n << 42n) - 1n
32
+
33
+ export type TsidOptions = {
34
+ /**
35
+ * Timestamp in milliseconds since Unix epoch.
36
+ * Defaults to Date.now().
37
+ */
38
+ msecs?: number
39
+ /**
40
+ * Custom epoch in milliseconds since Unix epoch.
41
+ * Defaults to 1577836800000 (2020-01-01T00:00:00.000Z).
42
+ */
43
+ epoch?: number
44
+ /**
45
+ * Node ID (0 to 2^nodeBits - 1).
46
+ * Defaults to a lazily-initialized, persistent random value.
47
+ */
48
+ node?: number
49
+ /**
50
+ * Number of bits allocated to the node ID (0-20).
51
+ * The remaining bits (22 - nodeBits) are allocated to the counter.
52
+ * Defaults to 10.
53
+ */
54
+ nodeBits?: number
55
+ /**
56
+ * Per-millisecond counter (0 to 2^(22 - nodeBits) - 1).
57
+ * Defaults to a fresh random value on a new millisecond, or the previous
58
+ * value incremented by 1 within the same millisecond.
59
+ */
60
+ counter?: number
61
+ }
62
+
63
+ export type Tsid = {
64
+ (): bigint
65
+ <TBuf extends Uint8Array = Uint8Array>(options: TsidOptions | undefined, buf: TBuf, offset?: number): TBuf
66
+ (options?: TsidOptions, buf?: undefined, offset?: number): bigint
67
+ toBytes(id: bigint): Uint8Array
68
+ fromBytes(bytes: Uint8Array): bigint
69
+ toString(id: bigint): string
70
+ fromString(str: string): bigint
71
+ timestamp(id: bigint, epoch?: number): number
72
+ isValid(id: unknown): id is bigint
73
+ /** The nil TSID (all zeros) */
74
+ NIL: bigint
75
+ /** The max TSID (maximum valid 64-bit value) */
76
+ MAX: bigint
77
+ }
78
+
79
+ type TsidState = {
80
+ node: number | undefined
81
+ msecs: number
82
+ counter: number
83
+ }
84
+
85
+ /**
86
+ * Module-level state for the lazily-initialized persistent node ID and the
87
+ * per-millisecond-reset counter.
88
+ *
89
+ * IMPORTANT: This state persists across all tsid() calls in the module's lifetime.
90
+ * - In serverless/edge functions with warm starts, state persists between invocations.
91
+ * - For isolated state, pass explicit `msecs`, `epoch`, `node`, `nodeBits`, or
92
+ * `counter` via options - doing so bypasses `state` entirely.
93
+ * - Tests should mock Date.now() or provide explicit options for deterministic behavior.
94
+ */
95
+ const state: TsidState = {
96
+ node: undefined,
97
+ msecs: -Infinity,
98
+ counter: 0,
99
+ }
100
+
101
+ function pack(msecsDiff: bigint, node: number, counterBits: number, counter: number): bigint {
102
+ return (msecsDiff << BigInt(RANDOM_BITS)) | (BigInt(node) << BigInt(counterBits)) | BigInt(counter)
103
+ }
104
+
105
+ function writeTsidBytes(value: bigint, buf?: Uint8Array, offset = 0): Uint8Array {
106
+ if (!buf) {
107
+ buf = new Uint8Array(TSID_BYTES)
108
+ offset = 0
109
+ } else if (offset < 0 || offset + TSID_BYTES > buf.length) {
110
+ throw new BufferError(
111
+ 'TSID_BUFFER_OUT_OF_BOUNDS',
112
+ `TSID byte range ${offset}:${offset + TSID_BYTES - 1} is out of buffer bounds`,
113
+ )
114
+ }
115
+
116
+ for (let i = 0; i < TSID_BYTES; i += 1) {
117
+ buf[offset + i] = Number((value >> BigInt((TSID_BYTES - 1 - i) * 8)) & 0xffn)
118
+ }
119
+
120
+ return buf
121
+ }
122
+
123
+ /*
124
+ * Overload: no buffer => return a TSID bigint.
125
+ */
126
+ function tsidFn(options?: TsidOptions, buf?: undefined, offset?: number): bigint
127
+ /*
128
+ * Overload: caller provides a buffer slice to fill with TSID bytes.
129
+ */
130
+ function tsidFn<TBuf extends Uint8Array = Uint8Array>(
131
+ options: TsidOptions | undefined,
132
+ buf: TBuf,
133
+ offset?: number,
134
+ ): TBuf
135
+ function tsidFn<TBuf extends Uint8Array = Uint8Array>(options?: TsidOptions, buf?: TBuf, offset = 0): bigint | TBuf {
136
+ let msecsDiff: bigint
137
+ let node: number
138
+ let counterBits: number
139
+ let counter: number
140
+
141
+ if (options) {
142
+ const nodeBits = options.nodeBits ?? DEFAULT_NODE_BITS
143
+ if (nodeBits < 0 || nodeBits > MAX_NODE_BITS) {
144
+ throw new InvalidInputError('TSID_NODE_BITS_OUT_OF_RANGE', `nodeBits must be between 0 and ${MAX_NODE_BITS}`)
145
+ }
146
+ counterBits = RANDOM_BITS - nodeBits
147
+ const nodeMask = (1 << nodeBits) - 1
148
+ const counterMask = (1 << counterBits) - 1
149
+
150
+ const optNode = options.node
151
+ if (optNode !== undefined) {
152
+ if (optNode < 0 || optNode > nodeMask) {
153
+ throw new InvalidInputError('TSID_NODE_OUT_OF_RANGE', `node must be between 0 and ${nodeMask}`)
154
+ }
155
+ node = optNode
156
+ } else {
157
+ node = randomUint32() & nodeMask
158
+ }
159
+
160
+ const optCounter = options.counter
161
+ if (optCounter !== undefined) {
162
+ if (optCounter < 0 || optCounter > counterMask) {
163
+ throw new InvalidInputError('TSID_COUNTER_OUT_OF_RANGE', `counter must be between 0 and ${counterMask}`)
164
+ }
165
+ counter = optCounter
166
+ } else {
167
+ counter = randomUint32() & counterMask
168
+ }
169
+
170
+ const epoch = options.epoch ?? TSID_EPOCH
171
+ const msecs = options.msecs ?? Date.now()
172
+ const diff = BigInt(msecs) - BigInt(epoch)
173
+ if (diff < 0n || diff > MAX_TIMESTAMP_DIFF) {
174
+ throw new InvalidInputError(
175
+ 'TSID_TIMESTAMP_OUT_OF_RANGE',
176
+ `msecs - epoch must be between 0 and ${MAX_TIMESTAMP_DIFF}`,
177
+ )
178
+ }
179
+ msecsDiff = diff
180
+ } else {
181
+ // Lazily initialize the persistent node ID on first no-option call.
182
+ if (state.node === undefined) {
183
+ state.node = randomUint32() & DEFAULT_NODE_MASK
184
+ }
185
+ node = state.node
186
+ counterBits = DEFAULT_COUNTER_BITS
187
+
188
+ /**
189
+ * Note: by default, Cloudflare Workers "freezes" time during request handling
190
+ * to prevent side-channel attacks, so Date.now() returns the same value for
191
+ * the entire duration of a request. Monotonic ordering within such a request
192
+ * relies entirely on the counter (and its clock-drift-ahead overflow below).
193
+ */
194
+ const now = Date.now()
195
+ if (now > state.msecs) {
196
+ state.msecs = now
197
+ state.counter = randomUint32() & DEFAULT_COUNTER_MASK
198
+ } else {
199
+ state.counter += 1
200
+ if (state.counter > DEFAULT_COUNTER_MASK) {
201
+ // Counter overflowed within this real millisecond: advance the internal
202
+ // virtual timestamp ahead of wall-clock time rather than throwing. A
203
+ // 12-bit counter (4096 values/ms) is a realistic throughput ceiling.
204
+ state.msecs += 1
205
+ state.counter = 0
206
+ }
207
+ }
208
+ counter = state.counter
209
+ msecsDiff = BigInt(state.msecs) - BigInt(TSID_EPOCH)
210
+ }
211
+
212
+ const packed = pack(msecsDiff, node, counterBits, counter)
213
+
214
+ if (buf) {
215
+ writeTsidBytes(packed, buf, offset)
216
+ return buf
217
+ }
218
+
219
+ return packed
220
+ }
221
+
222
+ /**
223
+ * Convert a TSID bigint to 8 bytes.
224
+ */
225
+ function toBytes(id: bigint): Uint8Array {
226
+ return writeTsidBytes(id)
227
+ }
228
+
229
+ /**
230
+ * Convert 8 bytes to a TSID bigint.
231
+ */
232
+ function fromBytes(bytes: Uint8Array): bigint {
233
+ if (bytes.length !== TSID_BYTES) {
234
+ throw new BufferError(
235
+ 'TSID_BYTES_INVALID_LENGTH',
236
+ `TSID bytes must be exactly ${TSID_BYTES} bytes, got ${bytes.length}`,
237
+ )
238
+ }
239
+
240
+ let value = 0n
241
+ for (let i = 0; i < TSID_BYTES; i += 1) {
242
+ value = (value << 8n) | BigInt(bytes[i])
243
+ }
244
+ return value
245
+ }
246
+
247
+ /**
248
+ * Extract the timestamp (milliseconds since Unix epoch) from a TSID.
249
+ * The epoch is not self-describing in the packed value - pass the same
250
+ * `epoch` used at generation time if it was overridden from the default.
251
+ */
252
+ function timestamp(id: bigint, epoch: number = TSID_EPOCH): number {
253
+ return epoch + Number(id >> BigInt(RANDOM_BITS))
254
+ }
255
+
256
+ /**
257
+ * Validate that a value is a TSID bigint within the 64-bit range.
258
+ */
259
+ function isValid(id: unknown): id is bigint {
260
+ return typeof id === 'bigint' && id >= 0n && id <= MAX
261
+ }
262
+
263
+ const NIL = 0n
264
+ const MAX = (1n << 64n) - 1n
265
+
266
+ /**
267
+ * Generate a TSID bigint or write the bytes into a buffer.
268
+ *
269
+ * TSID (Time-Sorted Unique Identifier) is a 64-bit Snowflake-style identifier
270
+ * combining a millisecond timestamp, a node ID, and a per-millisecond counter.
271
+ * Unlike every other uniku generator, it returns a `bigint` by default, since
272
+ * TSID's entire value proposition is native numeric storage (e.g. a database
273
+ * BIGINT primary key).
274
+ *
275
+ * @example
276
+ * ```ts
277
+ * import { tsid } from 'uniku/tsid'
278
+ *
279
+ * const id = tsid()
280
+ * // => 862301223059968074n
281
+ *
282
+ * // Canonical string form
283
+ * const str = tsid.toString(id)
284
+ * // => "0QXW2CK4XZM2A"
285
+ * tsid.fromString(str) === id // true
286
+ *
287
+ * // Extract timestamp
288
+ * const ts = tsid.timestamp(id)
289
+ * console.log(new Date(ts))
290
+ *
291
+ * // Validate
292
+ * tsid.isValid(id) // true
293
+ *
294
+ * // Convert to/from bytes (8 bytes)
295
+ * const bytes = tsid.toBytes(id)
296
+ * const restored = tsid.fromBytes(bytes)
297
+ * ```
298
+ */
299
+ export const tsid: Tsid = Object.assign(tsidFn, {
300
+ toBytes,
301
+ fromBytes,
302
+ toString: encodeTsidString,
303
+ fromString: decodeTsidString,
304
+ timestamp,
305
+ isValid,
306
+ NIL,
307
+ MAX,
308
+ })
309
+
310
+ export { BufferError, InvalidInputError, ParseError, UniqueIdError } from '../errors'
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors.d.mts","names":["code: string"],"sources":["../src/errors.ts"],"sourcesContent":[],"mappings":";;AAKA;AAaA;AAcA;AAcA;uBAzCsB,aAAA,SAAsB,KAAA;;;;;;;;cAa/B,iBAAA,SAA0B,aAAA;;;;;;;;cAc1B,UAAA,SAAmB,aAAA;;;;;;;;cAcnB,WAAA,SAAoB,aAAA"}
@@ -1,2 +0,0 @@
1
- const e=globalThis.crypto.getRandomValues.bind(globalThis.crypto),t=new Uint8Array(256);let n=256;function r(){e(t),n=0}function i(){n>252&&r();let e=t[n]*16777216+t[n+1]*65536+t[n+2]*256+t[n+3]>>>0;return n+=4,e}function a(){n>240&&r();let e=n;return n+=16,t.subarray(e,n)}export{a as n,i as t};
2
- //# sourceMappingURL=random-CGMm8owG.mjs.map