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