@monixlite/any-id 0.0.0-stage → 1.0.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 (3) hide show
  1. package/README.md +163 -2
  2. package/package.json +37 -4
  3. package/src/index.js +378 -0
package/README.md CHANGED
@@ -1,3 +1,164 @@
1
- # Temporary Holding Version
1
+ # @monixlite/any-id
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One function for every kind of identifier you actually use in a backend. Zero dependencies, built on Node's `crypto`, nothing is written to disk.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npm install @monixlite/any-id
9
+ ```
10
+
11
+ Node.js 16.9 or newer, CommonJS.
12
+
13
+ ## Types
14
+
15
+ | Type | Length | Sortable by creation time | Notes |
16
+ | --- | --- | --- | --- |
17
+ | `mony` | 21 | yes | Base62, monotonic, checksummed. Default. |
18
+ | `uuidv7` | 36 (32 without hyphens) | yes | RFC 9562 layout, monotonic. |
19
+ | `ulid` | 26 | yes | Crockford Base32, monotonic. |
20
+ | `objectid` | 24 | yes, to the second | MongoDB compatible. |
21
+ | `id` | any | no | Random string over any alphabet. |
22
+
23
+ Anything that has a timestamp can be read back with `AnyID.parse` and `AnyID.timestamp`.
24
+
25
+ ## Usage
26
+
27
+ ```js
28
+ const AnyID = require("@monixlite/any-id");
29
+
30
+
31
+ const user = AnyID.mony("usr");
32
+ const order = AnyID.uuidv7({
33
+ hyphens: false,
34
+ });
35
+ const code = AnyID.id(6, AnyID.alphabet.numeric);
36
+ const token = AnyID.id({
37
+ length: 32,
38
+ alphabet: AnyID.alphabet.hex,
39
+ });
40
+ ```
41
+
42
+ Every type is also a named export, so destructuring works:
43
+
44
+ ```js
45
+ const { mony, uuidv7, ulid, objectID, id } = require("@monixlite/any-id");
46
+ ```
47
+
48
+ ### Options
49
+
50
+ ```js
51
+ {
52
+ length: number,
53
+ alphabet: string,
54
+ prefix: string,
55
+ hyphens: boolean,
56
+ time: number | Date,
57
+ }
58
+ ```
59
+
60
+ | Option | Applies to | Description |
61
+ | --- | --- | --- |
62
+ | `length` | `id` | Output length. Default `16`. |
63
+ | `alphabet` | `id` | 2 to 256 unique characters. Default Base62. |
64
+ | `prefix` | all | Adds `prefix_` in front. Letters and digits, parts joined by `_`. |
65
+ | `hyphens` | `uuidv7` | `false` removes the dashes. |
66
+ | `time` | `mony`, `uuidv7`, `ulid`, `objectid` | `Date` or milliseconds. Use it for backfills and tests. |
67
+
68
+ Shortcuts for the second argument: a number is `length`, a string is `prefix`, a boolean is `hyphens`, a `Date` is `time`.
69
+
70
+ Invalid input never throws and never returns garbage. It returns `null`: an unknown type, a broken prefix, an alphabet with duplicates, a time outside the supported range.
71
+
72
+ ### Alphabets
73
+
74
+ `AnyID.alphabet` ships `base62`, `crockford`, `url`, `numeric` and `hex`.
75
+
76
+ ```js
77
+ AnyID.id(21, AnyID.alphabet.url);
78
+ ```
79
+
80
+ `id` uses a bitmask with rejection sampling over random bytes, so every character is equally likely.
81
+
82
+ ### Custom generators
83
+
84
+ ```js
85
+ const code = AnyID.custom(AnyID.alphabet.numeric, 6);
86
+
87
+ code();
88
+ code(8);
89
+ ```
90
+
91
+ ## mony
92
+
93
+ `mony` is the type I wanted for primary keys: short, URL-safe, sortable, and hard to mistype.
94
+
95
+ ```
96
+ 44 bits millisecond timestamp
97
+ 75 bits random
98
+ = 119 bits, 20 Base62 characters
99
+ 1 char check character (Luhn mod 62)
100
+ ```
101
+
102
+ * **21 characters**, `0-9A-Za-z`, fixed width. Shorter than ULID (26) and UUID (36).
103
+ * **Sorts as a string.** Base62 here follows ASCII order and the width is fixed, so `a < b` on plain strings matches creation order. Good for B-tree indexes, `ORDER BY id` and range scans.
104
+ * **Monotonic inside a process.** Within one millisecond the random part grows by a random step instead of `+1`, so IDs stay strictly increasing without being guessable from the previous one. If the clock steps back, the last timestamp is kept and the sequence continues.
105
+ * **75 random bits per millisecond.** Two processes generating in the same millisecond collide with probability around `n² / 2^76`. Ordering between processes is by millisecond.
106
+ * **Self-checking.** The last character catches every single-character typo and most swapped neighbours. `validate` rejects about 98% of random 21-character Base62 strings before they reach your database.
107
+ * **Good until the year 2527.**
108
+ * **Case-sensitive.** Do not lowercase it.
109
+
110
+ There is no persistence layer. State is a timestamp and a number in memory, nothing else. After a restart the 75 random bits cover you.
111
+
112
+ ## Parsing
113
+
114
+ ```js
115
+ const AnyID = require("@monixlite/any-id");
116
+
117
+
118
+ const user = AnyID.mony("usr");
119
+ const info = AnyID.parse(user);
120
+ const date = AnyID.timestamp(user);
121
+ ```
122
+
123
+ `info` looks like this:
124
+
125
+ ```js
126
+ {
127
+ id: "usr_5xnUsIPYET7dnFDP3m1fT",
128
+ prefix: "usr",
129
+ type: "mony",
130
+ time: 1793765681654,
131
+ random: "14f33840e078bda5e3f",
132
+ timestamp: 2026-11-04T04:14:41.654Z,
133
+ }
134
+ ```
135
+
136
+ `objectid` additionally returns `counter`. `parse` returns `null` for strings it cannot identify, including plain `id` values, since a random string says nothing about itself. Use `validate` for those.
137
+
138
+ ## Validation
139
+
140
+ ```js
141
+ AnyID.validate(value);
142
+ AnyID.validate(value, "mony");
143
+ AnyID.validate(value, "mony", {
144
+ prefix: "usr",
145
+ });
146
+ AnyID.validate(value, "id", {
147
+ length: 10,
148
+ alphabet: AnyID.alphabet.url,
149
+ });
150
+ ```
151
+
152
+ * For `mony`, `uuidv7`, `ulid` and `objectid` it checks the real structure: alphabet, length, version and variant bits, checksum, value range.
153
+ * For `id` it checks the alphabet and the length you pass in.
154
+ * `prefix` set to a string requires exactly that prefix. Set to `null` it requires no prefix. Left out, any valid prefix is accepted.
155
+
156
+ ## Notes
157
+
158
+ * `time` bypasses the monotonic state. IDs made with an explicit time are valid and parseable, but are not guaranteed to be ordered among themselves inside one millisecond.
159
+ * `objectid` follows the MongoDB layout: 4 bytes of seconds, 5 random bytes per process, a 3-byte counter.
160
+ * Generating is synchronous and takes a few microseconds per ID.
161
+
162
+ ## License
163
+
164
+ MIT
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
- "name": "@monixlite/any-id",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
2
+ "name": "@monixlite/any-id",
3
+ "version": "1.0.0",
4
+ "description": "Zero-dependency ID generator for Node.js: Mony, UUIDv7, ULID, ObjectID and Custom-Alphabet IDs",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./package.json": "./package.json"
9
+ },
10
+ "type": "commonjs",
11
+ "files": [
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=16.9.0"
17
+ },
18
+ "sideEffects": false,
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "id",
24
+ "uuidv7",
25
+ "ulid",
26
+ "objectid",
27
+ "mony",
28
+ "unique-id",
29
+ "sortable",
30
+ "time-ordered",
31
+ "k-sortable",
32
+ "monotonic",
33
+ "generator",
34
+ "crypto",
35
+ "monixlite"
36
+ ],
37
+ "author": "MonixLITE",
38
+ "license": "MIT"
6
39
  }
package/src/index.js ADDED
@@ -0,0 +1,378 @@
1
+ const crypto = require("crypto");
2
+
3
+
4
+ const additions = {}; {
5
+ additions.alphabet = {
6
+ base62: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
7
+ crockford: "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
8
+ url: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_",
9
+ numeric: "0123456789",
10
+ hex: "0123456789abcdef",
11
+ };
12
+
13
+ additions.prefix = (/^[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*$/);
14
+
15
+ additions.codec = {
16
+ encode: (num, length) => {
17
+ let out = "";
18
+
19
+ for (var i = 0; i < length; i++) {
20
+ out = (additions.alphabet.base62[Number(num % 62n)] + out);
21
+ num = (num / 62n);
22
+ };
23
+
24
+ return out;
25
+ },
26
+
27
+ decode: (value) => {
28
+ return value.split("").reduce((acc, char) => ((acc * 62n) + BigInt(additions.alphabet.base62.indexOf(char))), 0n);
29
+ },
30
+
31
+ checksum: (value) => {
32
+ let sum = 0;
33
+
34
+ for (var i = (value.length - 1); i >= 0; i--) {
35
+ const addend = (additions.alphabet.base62.indexOf(value[i]) * ((((value.length - 1 - i) % 2) == 0) ? 2 : 1));
36
+
37
+ sum += (Math.floor((addend / 62)) + (addend % 62));
38
+ };
39
+
40
+ return additions.alphabet.base62[((62 - (sum % 62)) % 62)];
41
+ },
42
+ };
43
+
44
+ additions.next = (entity, input) => {
45
+ const cap = (1n << BigInt(entity.size.random));
46
+ const limit = (2 ** entity.size.time);
47
+ const time = ((input instanceof Date) ? input.getTime() : ((typeof (input) == "number") ? Math.floor(input) : ((input == null) ? null : NaN)));
48
+ const entropy = (BigInt(`0x${crypto.randomBytes(Math.ceil((entity.size.random / 8))).toString("hex")}`) & (cap - 1n)); {
49
+ if (time != null) {
50
+ if (!Number.isFinite(time) || time < 0 || time >= limit) return null;
51
+
52
+ return {
53
+ time: time,
54
+ random: entropy,
55
+ };
56
+ };
57
+ };
58
+
59
+
60
+ const now = Date.now(); {
61
+ if (now > entity.time) {
62
+ entity.time = now;
63
+ entity.random = entropy;
64
+ } else {
65
+ entity.random += ((entropy & 0xffffffffn) + 1n);
66
+
67
+ if (entity.random >= cap) {
68
+ entity.time += 1;
69
+ entity.random = entropy;
70
+ };
71
+ };
72
+ };
73
+
74
+
75
+ if (entity.time >= limit) return null;
76
+
77
+ return {
78
+ time: entity.time,
79
+ random: entity.random,
80
+ };
81
+ };
82
+ };
83
+
84
+ const entities = {}; {
85
+ entities["id"] = {
86
+ generate: (opts) => {
87
+ const list = Array.from(opts?.alphabet ?? additions.alphabet.base62);
88
+ const length = (opts?.length ?? 16); {
89
+ if (list.length < 2 || list.length > 256 || new Set(list).size != list.length || !Number.isInteger(length) || length <= 0) return null;
90
+ };
91
+
92
+
93
+ const mask = ((2 << (31 - Math.clz32(((list.length - 1) | 1)))) - 1);
94
+ const out = [];
95
+
96
+ while (out.length < length) {
97
+ for (const byte of crypto.randomBytes((length * 2))) {
98
+ if ((byte & mask) < list.length && out.length < length) out.push(list[(byte & mask)]);
99
+ };
100
+ };
101
+
102
+
103
+ return out.join("");
104
+ },
105
+
106
+ check: (value, opts) => {
107
+ const set = new Set(Array.from(opts?.alphabet ?? additions.alphabet.base62));
108
+ const list = Array.from(value);
109
+
110
+ return (list.length > 0 && (opts?.length == null || list.length == opts.length) && list.every((char) => set.has(char)));
111
+ },
112
+ };
113
+
114
+ entities["uuidv7"] = {
115
+ time: 0,
116
+ random: 0n,
117
+ size: { time: 48, random: 74 },
118
+
119
+ generate: (opts) => {
120
+ const current = additions.next(entities.uuidv7, opts?.time); {
121
+ if (!current) return null;
122
+ };
123
+
124
+
125
+ const hex = ((BigInt(current.time) << 80n) | (0x7n << 76n) | (((current.random >> 62n) & 0xfffn) << 64n) | (0x2n << 62n) | (current.random & 0x3fffffffffffffffn)).toString(16).padStart(32, "0"); {
126
+ if (opts?.hyphens == false) return hex;
127
+ };
128
+
129
+
130
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
131
+ },
132
+
133
+ parse: (body) => {
134
+ if (!(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i).test(body) && !(/^[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}$/i).test(body)) return null;
135
+
136
+
137
+ const num = BigInt(`0x${body.replace(/-/g, "")}`);
138
+
139
+ return {
140
+ time: Number(num >> 80n),
141
+ random: ((((num >> 64n) & 0xfffn) << 62n) | (num & 0x3fffffffffffffffn)).toString(16).padStart(19, "0"),
142
+ };
143
+ },
144
+ };
145
+
146
+ entities["ulid"] = {
147
+ time: 0,
148
+ random: 0n,
149
+ size: { time: 48, random: 80 },
150
+
151
+ generate: (opts) => {
152
+ const current = additions.next(entities.ulid, opts?.time); {
153
+ if (!current) return null;
154
+ };
155
+
156
+
157
+ let num = ((BigInt(current.time) << 80n) | current.random);
158
+ let out = ""; {
159
+ for (var i = 0; i < 26; i++) {
160
+ out = (additions.alphabet.crockford[Number(num & 0x1fn)] + out);
161
+ num = (num >> 5n);
162
+ };
163
+ };
164
+
165
+
166
+ return out;
167
+ },
168
+
169
+ parse: (body) => {
170
+ if (!(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i).test(body)) return null;
171
+
172
+
173
+ const num = body.toUpperCase().split("").reduce((acc, char) => ((acc << 5n) | BigInt(additions.alphabet.crockford.indexOf(char))), 0n);
174
+
175
+ return {
176
+ time: Number(num >> 80n),
177
+ random: (num & ((1n << 80n) - 1n)).toString(16).padStart(20, "0"),
178
+ };
179
+ },
180
+ };
181
+
182
+ entities["objectid"] = {
183
+ counter: crypto.randomInt(0, 0x1000000),
184
+ process: crypto.randomBytes(5),
185
+
186
+ generate: (opts) => {
187
+ const seconds = Math.floor((((opts?.time instanceof Date) ? opts.time.getTime() : (opts?.time ?? Date.now())) / 1000)); {
188
+ if (!Number.isFinite(seconds) || seconds < 0 || seconds > 0xffffffff) return null;
189
+ };
190
+
191
+
192
+ entities.objectid.counter = ((entities.objectid.counter + 1) & 0xffffff);
193
+
194
+
195
+ const buf = Buffer.allocUnsafe(12); {
196
+ buf.writeUInt32BE(seconds, 0);
197
+ entities.objectid.process.copy(buf, 4, 0, 5);
198
+ buf.writeUIntBE(entities.objectid.counter, 9, 3);
199
+ };
200
+
201
+
202
+ return buf.toString("hex");
203
+ },
204
+
205
+ parse: (body) => {
206
+ if (!(/^[0-9a-f]{24}$/i).test(body)) return null;
207
+
208
+
209
+ return {
210
+ time: (parseInt(body.slice(0, 8), 16) * 1000),
211
+ random: body.slice(8, 18).toLowerCase(),
212
+ counter: parseInt(body.slice(18, 24), 16),
213
+ };
214
+ },
215
+ };
216
+
217
+ entities["mony"] = {
218
+ time: 0,
219
+ random: 0n,
220
+ size: { time: 44, random: 75 },
221
+
222
+ generate: (opts) => {
223
+ const current = additions.next(entities.mony, opts?.time); {
224
+ if (!current) return null;
225
+ };
226
+
227
+
228
+ const payload = additions.codec.encode(((BigInt(current.time) << 75n) | current.random), 20);
229
+
230
+ return (payload + additions.codec.checksum(payload));
231
+ },
232
+
233
+ parse: (body) => {
234
+ if (!(/^[0-9A-Za-z]{21}$/).test(body) || additions.codec.checksum(body.slice(0, 20)) != body[20]) return null;
235
+
236
+
237
+ const num = additions.codec.decode(body.slice(0, 20)); {
238
+ if (num >= (1n << 119n)) return null;
239
+ };
240
+
241
+
242
+ return {
243
+ time: Number(num >> 75n),
244
+ random: (num & ((1n << 75n) - 1n)).toString(16).padStart(19, "0"),
245
+ };
246
+ },
247
+ };
248
+ };
249
+
250
+
251
+ const AnyID = (type = "mony", options = {}) => {
252
+ let name = type;
253
+ let config = options; {
254
+ if (typeof (type) == "object" && type !== null) {
255
+ name = (type?.type ?? "mony");
256
+ config = type;
257
+ };
258
+ };
259
+
260
+
261
+ const key = String(name).toLowerCase();
262
+ const entity = (Object.hasOwn(entities, key) ? entities[key] : null); {
263
+ if (!entity) return null;
264
+ };
265
+
266
+
267
+ const parsed = ((typeof (config) == "object" && config !== null && !(config instanceof Date)) ? {
268
+ ...config,
269
+ } : {}); {
270
+ if (typeof (config) == "number") parsed.length = config;
271
+ if (typeof (config) == "string") parsed.prefix = config;
272
+ if (typeof (config) == "boolean") parsed.hyphens = config;
273
+ if (config instanceof Date) parsed.time = config;
274
+ };
275
+
276
+
277
+ if (parsed?.prefix != null && !additions.prefix.test(parsed.prefix)) return null;
278
+
279
+
280
+ const result = entity.generate(parsed); {
281
+ if (result == null) return null;
282
+ if (parsed?.prefix != null) return `${parsed.prefix}_${result}`;
283
+ };
284
+
285
+
286
+ return result;
287
+ }; {
288
+ for (const type of ["uuidv7", "ulid", "objectid", "mony"]) {
289
+ AnyID[type] = (options = {}) => {
290
+ return AnyID(type, options);
291
+ };
292
+ };
293
+
294
+ AnyID.objectID = AnyID.objectid;
295
+ AnyID.objectId = AnyID.objectid;
296
+
297
+
298
+ AnyID.custom = (chars = additions.alphabet.base62, length = 16) => {
299
+ return (len = length) => {
300
+ return AnyID("id", {
301
+ length: len,
302
+ alphabet: chars,
303
+ });
304
+ };
305
+ };
306
+
307
+ AnyID.id = (options = 16, chars = null) => {
308
+ if (chars) return AnyID("id", {
309
+ length: (typeof (options) == "number" ? options : (options?.length ?? 16)),
310
+ alphabet: chars,
311
+ });
312
+
313
+
314
+ return AnyID("id", options);
315
+ };
316
+
317
+
318
+ AnyID.parse = (id, options = {}) => {
319
+ if (typeof (id) != "string" || id.length == 0) return null;
320
+
321
+
322
+ for (const body of (id.includes("_") ? [id, id.slice((id.lastIndexOf("_") + 1))] : [id])) {
323
+ const prefix = ((body == id) ? null : id.slice(0, (id.length - body.length - 1))); {
324
+ if (prefix != null && !additions.prefix.test(prefix)) continue;
325
+ if (options?.prefix !== undefined && options.prefix != prefix) continue;
326
+ };
327
+
328
+
329
+ for (const type of ["uuidv7", "objectid", "ulid", "mony"]) {
330
+ const item = entities[type].parse(body); {
331
+ if (!item) continue;
332
+ };
333
+
334
+
335
+ return {
336
+ id: id,
337
+ prefix: prefix,
338
+ type: type,
339
+ ...item,
340
+ timestamp: (new Date(item.time)),
341
+ };
342
+ };
343
+ };
344
+
345
+
346
+ return null;
347
+ };
348
+
349
+ AnyID.timestamp = (id, options = {}) => {
350
+ return (AnyID.parse(id, options)?.timestamp ?? null);
351
+ };
352
+
353
+ AnyID.validate = (id, type = null, options = {}) => {
354
+ if (typeof (id) != "string") return false;
355
+
356
+
357
+ const key = (type ? String(type).toLowerCase() : null); {
358
+ if (key == "id") {
359
+ const prefix = (options?.prefix ? `${options.prefix}_` : "");
360
+
361
+ return (id.startsWith(prefix) && (entities.id.check(id.slice(prefix.length), options) || (!options?.prefix && entities.id.check(id.slice((id.lastIndexOf("_") + 1)), options))));
362
+ };
363
+ };
364
+
365
+
366
+ const parsed = AnyID.parse(id, options);
367
+
368
+ return (!!parsed && (key == null || parsed.type == key));
369
+ };
370
+
371
+
372
+ AnyID.alphabet = additions.alphabet;
373
+
374
+ AnyID.AnyID = AnyID;
375
+ AnyID.anyID = AnyID;
376
+ };
377
+
378
+ module.exports = AnyID;