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.
- package/README.md +141 -22
- package/build/{bytes-DzcOKIx1.mjs → bytes-xqWxFYsM.mjs} +1 -1
- package/build/{bytes-DzcOKIx1.mjs.map → bytes-xqWxFYsM.mjs.map} +1 -1
- package/build/cuid/v2.d.mts +2 -0
- package/build/cuid/v2.mjs +1 -0
- package/build/cuid2/cuid2.d.mts +5 -1
- package/build/cuid2/cuid2.d.mts.map +1 -1
- package/build/cuid2/cuid2.mjs +1 -1
- package/build/cuid2/cuid2.mjs.map +1 -1
- package/build/errors-Dgoyi-ci.d.mts +38 -0
- package/build/errors-Dgoyi-ci.d.mts.map +1 -0
- package/build/errors.d.mts +2 -38
- package/build/errors.mjs +1 -1
- package/build/errors.mjs.map +1 -1
- package/build/generators.d.mts +17 -0
- package/build/generators.d.mts.map +1 -0
- package/build/generators.mjs +2 -0
- package/build/generators.mjs.map +1 -0
- package/build/ksuid/ksuid.d.mts +3 -5
- package/build/ksuid/ksuid.d.mts.map +1 -1
- package/build/ksuid/ksuid.mjs +1 -1
- package/build/ksuid/ksuid.mjs.map +1 -1
- package/build/nanoid/nanoid.d.mts +3 -6
- package/build/nanoid/nanoid.d.mts.map +1 -1
- package/build/nanoid/nanoid.mjs +1 -1
- package/build/nanoid/nanoid.mjs.map +1 -1
- package/build/objectid/objectid.d.mts +60 -0
- package/build/objectid/objectid.d.mts.map +1 -0
- package/build/objectid/objectid.mjs +2 -0
- package/build/objectid/objectid.mjs.map +1 -0
- package/build/random-Chp-Nkzi.mjs +2 -0
- package/build/{random-CGMm8owG.mjs.map → random-Chp-Nkzi.mjs.map} +1 -1
- package/build/tsid/tsid.d.mts +82 -0
- package/build/tsid/tsid.d.mts.map +1 -0
- package/build/tsid/tsid.mjs +2 -0
- package/build/tsid/tsid.mjs.map +1 -0
- package/build/typeid/typeid.d.mts +1 -1
- package/build/typeid/typeid.d.mts.map +1 -1
- package/build/typeid/typeid.mjs.map +1 -1
- package/build/ulid/ulid.d.mts +3 -5
- package/build/ulid/ulid.d.mts.map +1 -1
- package/build/ulid/ulid.mjs +1 -1
- package/build/ulid/ulid.mjs.map +1 -1
- package/build/uuid/v4.d.mts +3 -5
- package/build/uuid/v4.d.mts.map +1 -1
- package/build/uuid/v4.mjs +1 -1
- package/build/uuid/v4.mjs.map +1 -1
- package/build/uuid/v7.d.mts +3 -5
- package/build/uuid/v7.d.mts.map +1 -1
- package/build/uuid/v7.mjs +1 -1
- package/build/uuid/v7.mjs.map +1 -1
- package/build/{uuid-Ub6ladEr.mjs → uuid-BBjwHv7J.mjs} +1 -1
- package/build/uuid-BBjwHv7J.mjs.map +1 -0
- package/package.json +35 -5
- package/src/cuid/v2.ts +19 -0
- package/src/cuid2/cuid2.ts +4 -0
- package/src/generators.ts +17 -0
- package/src/objectid/hex.ts +89 -0
- package/src/objectid/objectid.ts +266 -0
- package/src/tsid/crockford64.ts +75 -0
- package/src/tsid/tsid.ts +310 -0
- package/build/errors.d.mts.map +0 -1
- package/build/random-CGMm8owG.mjs +0 -2
- package/build/tsconfig.tsbuildinfo +0 -1
- 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
|
+
}
|
package/src/tsid/tsid.ts
ADDED
|
@@ -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'
|
package/build/errors.d.mts.map
DELETED
|
@@ -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
|