@smonn/ids 0.0.0 → 0.0.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 +3 -3
- package/dist/index.d.mts +28 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +153 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -2
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.github/workflows/ci.yml +0 -35
- package/.github/workflows/release.yml +0 -42
- package/.oxfmtrc.json +0 -4
- package/.oxlintrc.json +0 -11
- package/AGENTS.md +0 -15
- package/CONTEXT.md +0 -51
- package/CONTRIBUTING.md +0 -53
- package/docs/adr/0001-brand-format.md +0 -10
- package/docs/adr/0002-payload-layout.md +0 -26
- package/docs/adr/0003-canonical-strict-is.md +0 -12
- package/docs/agents/domain.md +0 -37
- package/docs/agents/issue-tracker.md +0 -22
- package/docs/agents/triage-labels.md +0 -15
- package/src/base32.ts +0 -54
- package/src/id.test.ts +0 -124
- package/src/id.ts +0 -133
- package/src/index.ts +0 -8
- package/src/invariant.ts +0 -3
- package/tsconfig.json +0 -31
- package/tsdown.config.ts +0 -10
- package/vitest.config.ts +0 -9
package/README.md
CHANGED
|
@@ -86,14 +86,14 @@ Caveat: two IDs generated in the same millisecond by the same process have indep
|
|
|
86
86
|
|
|
87
87
|
```ts
|
|
88
88
|
const users = createId("usr", {
|
|
89
|
-
now: () => new Date("2026-01-01T00:00:00Z"),
|
|
90
|
-
rng: (
|
|
89
|
+
now: () => new Date("2026-01-01T00:00:00Z").getTime(),
|
|
90
|
+
rng: (target) => {}, // leave target as zero-filled
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
users.generate(); // deterministic snapshot-friendly output
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
Both `Options` fields are optional. Defaults are `
|
|
96
|
+
Both `Options` fields are optional. Defaults are `Date.now` and a wrapper around `crypto.getRandomValues`. `now` returns milliseconds since the Unix epoch. `rng` writes random bytes into the provided target (a 10-byte view into the codec's persistent buffer), so a custom RNG never has to allocate.
|
|
97
97
|
|
|
98
98
|
## What this is **not** for
|
|
99
99
|
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/id.d.ts
|
|
2
|
+
type Options = {
|
|
3
|
+
now: () => number;
|
|
4
|
+
rng: (target: Uint8Array) => void;
|
|
5
|
+
};
|
|
6
|
+
type Prefix<Brand extends string> = `${Brand}_`;
|
|
7
|
+
type Id<Brand extends string> = `${Prefix<Brand>}${string}` & {
|
|
8
|
+
readonly __brand: Brand;
|
|
9
|
+
};
|
|
10
|
+
type ParseError = "not_string" | "invalid_prefix" | "invalid_base32";
|
|
11
|
+
type ParseResult<Brand extends string> = {
|
|
12
|
+
ok: true;
|
|
13
|
+
id: Id<Brand>;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
error: ParseError;
|
|
17
|
+
};
|
|
18
|
+
type Codec<Brand extends string> = {
|
|
19
|
+
generate(): Id<Brand>;
|
|
20
|
+
is(value: unknown): value is Id<Brand>;
|
|
21
|
+
parse(value: unknown): Id<Brand>;
|
|
22
|
+
safeParse(value: unknown): ParseResult<Brand>;
|
|
23
|
+
extractTimestamp(id: Id<Brand>): Date;
|
|
24
|
+
};
|
|
25
|
+
declare function createId<Brand extends string>(brand: Brand, opts?: Partial<Options>): Codec<Brand>;
|
|
26
|
+
//#endregion
|
|
27
|
+
export { type Codec, type Id, type Options, type ParseError, type ParseResult, createId };
|
|
28
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/id.ts"],"mappings":";KAEY,OAAA;EACV,GAAA;EACA,GAAA,GAAM,MAAA,EAAQ,UAAA;AAAA;AAAA,KA2CX,MAAA,4BAAkC,KAAA;AAAA,KAE3B,EAAA,4BAA8B,MAAA,CAAO,KAAA;EAAA,SACtC,OAAA,EAAS,KAAA;AAAA;AAAA,KAGR,UAAA;AAAA,KAEA,WAAA;EACN,EAAA;EAAU,EAAA,EAAI,EAAA,CAAG,KAAA;AAAA;EACjB,EAAA;EAAW,KAAA,EAAO,UAAA;AAAA;AAAA,KAEZ,KAAA;EACV,QAAA,IAAY,EAAA,CAAG,KAAA;EACf,EAAA,CAAG,KAAA,YAAiB,KAAA,IAAS,EAAA,CAAG,KAAA;EAChC,KAAA,CAAM,KAAA,YAAiB,EAAA,CAAG,KAAA;EAC1B,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;EACvC,gBAAA,CAAiB,EAAA,EAAI,EAAA,CAAG,KAAA,IAAS,IAAA;AAAA;AAAA,iBAmBnB,QAAA,uBACd,KAAA,EAAO,KAAA,EACP,IAAA,GAAM,OAAA,CAAQ,OAAA,IACb,KAAA,CAAM,KAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
//#region src/base32.ts
|
|
2
|
+
const alphabet = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
3
|
+
const valueToCharCode = new Uint8Array(32);
|
|
4
|
+
for (let i = 0; i < 32; i++) valueToCharCode[i] = alphabet.charCodeAt(i);
|
|
5
|
+
const charCodeToValue = new Uint8Array(256).fill(255);
|
|
6
|
+
for (let i = 0; i < 32; i++) {
|
|
7
|
+
const code = alphabet.charCodeAt(i);
|
|
8
|
+
charCodeToValue[code] = i;
|
|
9
|
+
if (code >= 97 && code <= 122) charCodeToValue[code - 32] = i;
|
|
10
|
+
}
|
|
11
|
+
charCodeToValue["o".charCodeAt(0)] = charCodeToValue["O".charCodeAt(0)] = 0;
|
|
12
|
+
charCodeToValue["i".charCodeAt(0)] = charCodeToValue["I".charCodeAt(0)] = 1;
|
|
13
|
+
charCodeToValue["l".charCodeAt(0)] = charCodeToValue["L".charCodeAt(0)] = 1;
|
|
14
|
+
function encodeBase32(bytes) {
|
|
15
|
+
const codes = new Array(Math.floor(bytes.length * 8 / 5) + 1);
|
|
16
|
+
let chi = 0;
|
|
17
|
+
let bits = 0;
|
|
18
|
+
let value = 0;
|
|
19
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
20
|
+
value = value << 8 | bytes[i];
|
|
21
|
+
bits += 8;
|
|
22
|
+
while (bits >= 5) {
|
|
23
|
+
bits -= 5;
|
|
24
|
+
codes[chi++] = valueToCharCode[value >>> bits & 31];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
codes[chi] = valueToCharCode[value << 5 - bits & 31];
|
|
28
|
+
return String.fromCharCode.apply(null, codes);
|
|
29
|
+
}
|
|
30
|
+
function decodeBase32(str) {
|
|
31
|
+
const result = new Uint8Array(Math.floor(str.length * 5 / 8));
|
|
32
|
+
let bits = 0;
|
|
33
|
+
let value = 0;
|
|
34
|
+
let index = 0;
|
|
35
|
+
for (let i = 0; i < str.length; i++) {
|
|
36
|
+
const v = charCodeToValue[str.charCodeAt(i)];
|
|
37
|
+
value = value << 5 | v;
|
|
38
|
+
bits += 5;
|
|
39
|
+
if (bits >= 8) {
|
|
40
|
+
bits -= 8;
|
|
41
|
+
result[index++] = value >>> bits & 255;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/id.ts
|
|
48
|
+
const hexCharCodeToNibble = new Uint8Array(128);
|
|
49
|
+
for (let i = 0; i < 10; i++) hexCharCodeToNibble[48 + i] = i;
|
|
50
|
+
for (let i = 0; i < 6; i++) hexCharCodeToNibble[97 + i] = 10 + i;
|
|
51
|
+
const defaultOptions = {
|
|
52
|
+
now: Date.now,
|
|
53
|
+
rng: (target) => {
|
|
54
|
+
const s = crypto.randomUUID();
|
|
55
|
+
target[0] = hexCharCodeToNibble[s.charCodeAt(0)] << 4 | hexCharCodeToNibble[s.charCodeAt(1)];
|
|
56
|
+
target[1] = hexCharCodeToNibble[s.charCodeAt(2)] << 4 | hexCharCodeToNibble[s.charCodeAt(3)];
|
|
57
|
+
target[2] = hexCharCodeToNibble[s.charCodeAt(4)] << 4 | hexCharCodeToNibble[s.charCodeAt(5)];
|
|
58
|
+
target[3] = hexCharCodeToNibble[s.charCodeAt(6)] << 4 | hexCharCodeToNibble[s.charCodeAt(7)];
|
|
59
|
+
target[4] = hexCharCodeToNibble[s.charCodeAt(9)] << 4 | hexCharCodeToNibble[s.charCodeAt(10)];
|
|
60
|
+
target[5] = hexCharCodeToNibble[s.charCodeAt(11)] << 4 | hexCharCodeToNibble[s.charCodeAt(12)];
|
|
61
|
+
target[6] = hexCharCodeToNibble[s.charCodeAt(24)] << 4 | hexCharCodeToNibble[s.charCodeAt(25)];
|
|
62
|
+
target[7] = hexCharCodeToNibble[s.charCodeAt(26)] << 4 | hexCharCodeToNibble[s.charCodeAt(27)];
|
|
63
|
+
target[8] = hexCharCodeToNibble[s.charCodeAt(28)] << 4 | hexCharCodeToNibble[s.charCodeAt(29)];
|
|
64
|
+
target[9] = hexCharCodeToNibble[s.charCodeAt(30)] << 4 | hexCharCodeToNibble[s.charCodeAt(31)];
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const timestampByteLength = 6;
|
|
68
|
+
const randomByteLength = 10;
|
|
69
|
+
const totalByteLength = 16;
|
|
70
|
+
const base32Length = Math.ceil(totalByteLength * 8 / 5);
|
|
71
|
+
const timestampBase32Length = Math.ceil(timestampByteLength * 8 / 5);
|
|
72
|
+
const replacePattern = /[ilo]/g;
|
|
73
|
+
const aliasTestPattern = /[ilo]/;
|
|
74
|
+
const replaceMap = {
|
|
75
|
+
o: "0",
|
|
76
|
+
i: "1",
|
|
77
|
+
l: "1"
|
|
78
|
+
};
|
|
79
|
+
const replacer = (match) => {
|
|
80
|
+
if (match !== "o" && match !== "i" && match !== "l") throw new Error("invalid match");
|
|
81
|
+
return replaceMap[match];
|
|
82
|
+
};
|
|
83
|
+
const base32Pattern = new RegExp(`^[${alphabet}]{${base32Length}}$`);
|
|
84
|
+
const brandPattern = /^[a-z]{3}$/;
|
|
85
|
+
function createId(brand, opts = {}) {
|
|
86
|
+
if (!brandPattern.test(brand)) throw new Error("invalid brand, expected three lowercase a-z characters");
|
|
87
|
+
const options = {
|
|
88
|
+
...defaultOptions,
|
|
89
|
+
...opts
|
|
90
|
+
};
|
|
91
|
+
const prefix = `${brand}_`;
|
|
92
|
+
const buffer = new Uint8Array(totalByteLength);
|
|
93
|
+
const randomView = new Uint8Array(buffer.buffer, timestampByteLength, randomByteLength);
|
|
94
|
+
return {
|
|
95
|
+
generate: () => generate(prefix, options, buffer, randomView),
|
|
96
|
+
is: (value) => is(prefix, value),
|
|
97
|
+
parse: (value) => parse(prefix, value),
|
|
98
|
+
safeParse: (value) => safeParse(prefix, value),
|
|
99
|
+
extractTimestamp: (id) => extractTimestamp(prefix, id)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function safeParse(prefix, value) {
|
|
103
|
+
if (typeof value !== "string") return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: "not_string"
|
|
106
|
+
};
|
|
107
|
+
const lowercase = value.toLowerCase();
|
|
108
|
+
if (!lowercase.startsWith(prefix)) return {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: "invalid_prefix"
|
|
111
|
+
};
|
|
112
|
+
const sliced = lowercase.slice(prefix.length);
|
|
113
|
+
const base32 = aliasTestPattern.test(sliced) ? sliced.replaceAll(replacePattern, replacer) : sliced;
|
|
114
|
+
if (!base32Pattern.test(base32)) return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: "invalid_base32"
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
id: prefix + base32
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function parse(prefix, value) {
|
|
124
|
+
const result = safeParse(prefix, value);
|
|
125
|
+
if (result.ok) return result.id;
|
|
126
|
+
throw new Error(`Invalid ID: ${result.error}`);
|
|
127
|
+
}
|
|
128
|
+
function is(prefix, value) {
|
|
129
|
+
if (typeof value !== "string") return false;
|
|
130
|
+
if (!value.startsWith(prefix)) return false;
|
|
131
|
+
return base32Pattern.test(value.slice(prefix.length));
|
|
132
|
+
}
|
|
133
|
+
function generate(prefix, options, buffer, randomView) {
|
|
134
|
+
let ms = options.now();
|
|
135
|
+
if (ms < 0) throw new Error("timestamp is negative");
|
|
136
|
+
if (ms >= 2 ** (timestampByteLength * 8)) throw new Error("timestamp exceeds 48-bit range");
|
|
137
|
+
for (let i = timestampByteLength - 1; i >= 0; i--) {
|
|
138
|
+
buffer[i] = ms % 256;
|
|
139
|
+
ms = Math.floor(ms / 256);
|
|
140
|
+
}
|
|
141
|
+
options.rng(randomView);
|
|
142
|
+
return prefix + encodeBase32(buffer);
|
|
143
|
+
}
|
|
144
|
+
function extractTimestamp(prefix, id) {
|
|
145
|
+
const bytes = decodeBase32(id.slice(prefix.length, prefix.length + timestampBase32Length));
|
|
146
|
+
let ms = 0;
|
|
147
|
+
for (const byte of bytes) ms = ms * 256 + byte;
|
|
148
|
+
return new Date(ms);
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
export { createId };
|
|
152
|
+
|
|
153
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/base32.ts","../src/id.ts"],"sourcesContent":["/*\n This is based on Crockford's Base32 spec: https://www.crockford.com/base32.html\n One difference is that it uses lowercase instead of uppercase when encoding.\n\n These functions are internal: callers (id.ts) guarantee that input is a\n 16-byte buffer for encode, or a string of characters drawn from the alphabet\n for decode. Invalid input produces silent garbage rather than a thrown error,\n consistent with the trust-the-type rule in ADR-0003.\n*/\n\nexport const alphabet = \"0123456789abcdefghjkmnpqrstvwxyz\";\n\n// 0–31 → ASCII char code, for write-into-codes-then-fromCharCode encoding.\nconst valueToCharCode = new Uint8Array(32);\nfor (let i = 0; i < 32; i++) valueToCharCode[i] = alphabet.charCodeAt(i);\n\n// charCode → 0–31 value. Covers both cases and the Crockford o/i/l aliases.\nconst INVALID = 0xff;\nconst charCodeToValue = new Uint8Array(256).fill(INVALID);\nfor (let i = 0; i < alphabet.length; i++) {\n const code = alphabet.charCodeAt(i);\n charCodeToValue[code] = i;\n if (code >= 97 && code <= 122) charCodeToValue[code - 32] = i;\n}\ncharCodeToValue[\"o\".charCodeAt(0)] = charCodeToValue[\"O\".charCodeAt(0)] = 0;\ncharCodeToValue[\"i\".charCodeAt(0)] = charCodeToValue[\"I\".charCodeAt(0)] = 1;\ncharCodeToValue[\"l\".charCodeAt(0)] = charCodeToValue[\"L\".charCodeAt(0)] = 1;\n\nexport function encodeBase32(bytes: Uint8Array): string {\n // Build an Array<number> of char codes and pass it to fromCharCode.apply.\n // Faster than `result += char` (avoids cons-string overhead) and than\n // Uint8Array variants (apply has a fast path for plain Arrays).\n // oxlint-disable-next-line no-new-array\n const codes = new Array<number>(Math.floor((bytes.length * 8) / 5) + 1);\n let chi = 0;\n let bits = 0;\n let value = 0;\n\n for (let i = 0; i < bytes.length; i++) {\n value = (value << 8) | bytes[i]!;\n bits += 8;\n while (bits >= 5) {\n bits -= 5;\n codes[chi++] = valueToCharCode[(value >>> bits) & 0x1f]!;\n }\n }\n codes[chi] = valueToCharCode[(value << (5 - bits)) & 0x1f]!;\n return String.fromCharCode.apply(null, codes);\n}\n\nexport function decodeBase32(str: string): Uint8Array {\n const result = new Uint8Array(Math.floor((str.length * 5) / 8));\n let bits = 0;\n let value = 0;\n let index = 0;\n\n for (let i = 0; i < str.length; i++) {\n const v = charCodeToValue[str.charCodeAt(i)]!;\n value = (value << 5) | v;\n bits += 5;\n if (bits >= 8) {\n bits -= 8;\n result[index++] = (value >>> bits) & 0xff;\n }\n }\n return result;\n}\n","import { alphabet, decodeBase32, encodeBase32 } from \"./base32.js\";\n\nexport type Options = {\n now: () => number;\n rng: (target: Uint8Array) => void;\n};\n\n// hex charCode → 0–15 nibble, for decoding UUIDv4 strings into bytes.\n// Covers ['0'-'9' = 48–57] and ['a'-'f' = 97–102]; UUIDs are lowercase per spec.\nconst hexCharCodeToNibble = new Uint8Array(128);\nfor (let i = 0; i < 10; i++) hexCharCodeToNibble[48 + i] = i;\nfor (let i = 0; i < 6; i++) hexCharCodeToNibble[97 + i] = 10 + i;\n\nconst defaultOptions: Options = {\n now: Date.now,\n // crypto.randomUUID is ~7× faster than crypto.getRandomValues in Node 24\n // (~84 ns vs ~610 ns for a 16-byte fill — likely because the UUID path has\n // a tight fixed-format fast path). We use the 122 random bits of a UUIDv4\n // string as our entropy source, harvesting 10 fully-random bytes from\n // positions where no version (hex 12) or variant (hex 16) bits sit.\n // String layout: \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\" — bytes 0–5 are\n // string[0..7]+string[9..12], bytes 6–9 are string[24..31].\n rng: (target) => {\n const s = crypto.randomUUID();\n target[0] =\n (hexCharCodeToNibble[s.charCodeAt(0)]! << 4) | hexCharCodeToNibble[s.charCodeAt(1)]!;\n target[1] =\n (hexCharCodeToNibble[s.charCodeAt(2)]! << 4) | hexCharCodeToNibble[s.charCodeAt(3)]!;\n target[2] =\n (hexCharCodeToNibble[s.charCodeAt(4)]! << 4) | hexCharCodeToNibble[s.charCodeAt(5)]!;\n target[3] =\n (hexCharCodeToNibble[s.charCodeAt(6)]! << 4) | hexCharCodeToNibble[s.charCodeAt(7)]!;\n target[4] =\n (hexCharCodeToNibble[s.charCodeAt(9)]! << 4) | hexCharCodeToNibble[s.charCodeAt(10)]!;\n target[5] =\n (hexCharCodeToNibble[s.charCodeAt(11)]! << 4) | hexCharCodeToNibble[s.charCodeAt(12)]!;\n target[6] =\n (hexCharCodeToNibble[s.charCodeAt(24)]! << 4) | hexCharCodeToNibble[s.charCodeAt(25)]!;\n target[7] =\n (hexCharCodeToNibble[s.charCodeAt(26)]! << 4) | hexCharCodeToNibble[s.charCodeAt(27)]!;\n target[8] =\n (hexCharCodeToNibble[s.charCodeAt(28)]! << 4) | hexCharCodeToNibble[s.charCodeAt(29)]!;\n target[9] =\n (hexCharCodeToNibble[s.charCodeAt(30)]! << 4) | hexCharCodeToNibble[s.charCodeAt(31)]!;\n },\n};\n\ntype Prefix<Brand extends string> = `${Brand}_`;\n\nexport type Id<Brand extends string> = `${Prefix<Brand>}${string}` & {\n readonly __brand: Brand;\n};\n\nexport type ParseError = \"not_string\" | \"invalid_prefix\" | \"invalid_base32\";\n\nexport type ParseResult<Brand extends string> =\n | { ok: true; id: Id<Brand> }\n | { ok: false; error: ParseError };\n\nexport type Codec<Brand extends string> = {\n generate(): Id<Brand>;\n is(value: unknown): value is Id<Brand>;\n parse(value: unknown): Id<Brand>;\n safeParse(value: unknown): ParseResult<Brand>;\n extractTimestamp(id: Id<Brand>): Date;\n};\n\nconst timestampByteLength = 6;\nconst randomByteLength = 10;\nconst totalByteLength = timestampByteLength + randomByteLength;\nconst base32Length = Math.ceil((totalByteLength * 8) / 5);\nconst timestampBase32Length = Math.ceil((timestampByteLength * 8) / 5);\nconst replacePattern = /[ilo]/g;\nconst aliasTestPattern = /[ilo]/;\nconst replaceMap = { o: \"0\", i: \"1\", l: \"1\" } as const;\nconst replacer = (match: string): string => {\n if (match !== \"o\" && match !== \"i\" && match !== \"l\") throw new Error(\"invalid match\");\n return replaceMap[match];\n};\n\nconst base32Pattern = new RegExp(`^[${alphabet}]{${base32Length}}$`);\nconst brandPattern = /^[a-z]{3}$/;\n\nexport function createId<Brand extends string>(\n brand: Brand,\n opts: Partial<Options> = {},\n): Codec<Brand> {\n if (!brandPattern.test(brand)) {\n throw new Error(\"invalid brand, expected three lowercase a-z characters\");\n }\n\n const options = {\n ...defaultOptions,\n ...opts,\n } satisfies Options;\n\n const prefix: Prefix<Brand> = `${brand}_`;\n // Per-codec scratch buffer. Reused across generate() calls — generate is\n // synchronous, so successive callers see the buffer overwritten, not the\n // previous result. encodeBase32 reads the buffer and returns an independent\n // string, so the caller never sees the buffer itself.\n const buffer = new Uint8Array(totalByteLength);\n const randomView = new Uint8Array(buffer.buffer, timestampByteLength, randomByteLength);\n\n return {\n generate: () => generate(prefix, options, buffer, randomView),\n is: (value: unknown) => is(prefix, value),\n parse: (value: unknown) => parse(prefix, value),\n safeParse: (value: unknown) => safeParse(prefix, value),\n extractTimestamp: (id: Id<Brand>) => extractTimestamp(prefix, id),\n };\n}\n\nfunction safeParse<Brand extends string>(\n prefix: Prefix<Brand>,\n value: unknown,\n): ParseResult<Brand> {\n if (typeof value !== \"string\") return { ok: false, error: \"not_string\" };\n const lowercase = value.toLowerCase();\n if (!lowercase.startsWith(prefix)) return { ok: false, error: \"invalid_prefix\" };\n\n const sliced = lowercase.slice(prefix.length);\n const base32 = aliasTestPattern.test(sliced)\n ? sliced.replaceAll(replacePattern, replacer)\n : sliced;\n\n if (!base32Pattern.test(base32)) return { ok: false, error: \"invalid_base32\" };\n\n const id = (prefix + base32) as Id<Brand>;\n return { ok: true, id };\n}\n\nfunction parse<Brand extends string>(prefix: Prefix<Brand>, value: unknown): Id<Brand> {\n const result = safeParse(prefix, value);\n if (result.ok) return result.id;\n throw new Error(`Invalid ID: ${result.error}`);\n}\n\nfunction is<Brand extends string>(prefix: Prefix<Brand>, value: unknown): value is Id<Brand> {\n if (typeof value !== \"string\") return false;\n if (!value.startsWith(prefix)) return false;\n return base32Pattern.test(value.slice(prefix.length));\n}\n\nfunction generate<Brand extends string>(\n prefix: Prefix<Brand>,\n options: Options,\n buffer: Uint8Array,\n randomView: Uint8Array,\n): Id<Brand> {\n let ms = options.now();\n if (ms < 0) throw new Error(\"timestamp is negative\");\n if (ms >= 2 ** (timestampByteLength * 8)) throw new Error(\"timestamp exceeds 48-bit range\");\n // write the timestamp in big-endian; encoded via mod-256 to avoid 32-bit bitwise coercion\n for (let i = timestampByteLength - 1; i >= 0; i--) {\n buffer[i] = ms % 256;\n ms = Math.floor(ms / 256);\n }\n options.rng(randomView);\n return (prefix + encodeBase32(buffer)) as Id<Brand>;\n}\n\nfunction extractTimestamp<Brand extends string>(prefix: Prefix<Brand>, id: Id<Brand>): Date {\n const base32 = id.slice(prefix.length, prefix.length + timestampBase32Length);\n const bytes = decodeBase32(base32);\n let ms = 0;\n for (const byte of bytes) {\n ms = ms * 256 + byte;\n }\n return new Date(ms);\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smonn/ids",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.1",
|
|
4
4
|
"license": "MIT",
|
|
5
|
+
"author": "Simon Ingeson (https://github.com/smonn)",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/smonn/ids.git"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
5
13
|
"type": "module",
|
|
6
14
|
"exports": {
|
|
7
15
|
".": "./dist/index.mjs",
|
|
@@ -12,6 +20,7 @@
|
|
|
12
20
|
"@types/node": "25.9.1",
|
|
13
21
|
"@vitest/coverage-v8": "4.1.7",
|
|
14
22
|
"knip": "6.14.2",
|
|
23
|
+
"mitata": "1.0.34",
|
|
15
24
|
"oxfmt": "0.52.0",
|
|
16
25
|
"oxlint": "1.67.0",
|
|
17
26
|
"tsdown": "0.22.1",
|
|
@@ -31,6 +40,9 @@
|
|
|
31
40
|
"test": "vitest run",
|
|
32
41
|
"test:watch": "vitest",
|
|
33
42
|
"test:coverage": "vitest run --coverage",
|
|
34
|
-
"knip": "knip"
|
|
43
|
+
"knip": "knip",
|
|
44
|
+
"bench:build": "tsdown -c tsdown.bench.config.ts",
|
|
45
|
+
"bench": "pnpm -s bench:build 1>&2 && node bench/dist/index.mjs",
|
|
46
|
+
"bench:compare": "pnpm -s bench:build 1>&2 && node bench/dist/compare.mjs"
|
|
35
47
|
}
|
|
36
48
|
}
|
package/.changeset/README.md
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# Changesets
|
|
2
|
-
|
|
3
|
-
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
|
4
|
-
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
|
5
|
-
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
|
6
|
-
|
|
7
|
-
We have a quick list of common questions to get you started engaging with this project in
|
|
8
|
-
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
package/.changeset/config.json
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
|
3
|
-
"changelog": "@changesets/cli/changelog",
|
|
4
|
-
"commit": false,
|
|
5
|
-
"fixed": [],
|
|
6
|
-
"linked": [],
|
|
7
|
-
"access": "public",
|
|
8
|
-
"baseBranch": "main",
|
|
9
|
-
"updateInternalDependencies": "patch",
|
|
10
|
-
"ignore": []
|
|
11
|
-
}
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
pull_request:
|
|
5
|
-
push:
|
|
6
|
-
branches: [main]
|
|
7
|
-
|
|
8
|
-
concurrency:
|
|
9
|
-
group: ${{ github.workflow }}-${{ github.ref }}
|
|
10
|
-
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
11
|
-
|
|
12
|
-
jobs:
|
|
13
|
-
ci:
|
|
14
|
-
runs-on: ubuntu-latest
|
|
15
|
-
steps:
|
|
16
|
-
- uses: actions/checkout@v4
|
|
17
|
-
|
|
18
|
-
- uses: pnpm/action-setup@v4
|
|
19
|
-
|
|
20
|
-
- uses: actions/setup-node@v4
|
|
21
|
-
with:
|
|
22
|
-
node-version: 24
|
|
23
|
-
cache: pnpm
|
|
24
|
-
|
|
25
|
-
- run: pnpm install --frozen-lockfile
|
|
26
|
-
|
|
27
|
-
- run: pnpm lint
|
|
28
|
-
|
|
29
|
-
- run: pnpm fmt:check
|
|
30
|
-
|
|
31
|
-
- run: pnpm typecheck
|
|
32
|
-
|
|
33
|
-
- run: pnpm test
|
|
34
|
-
|
|
35
|
-
- run: pnpm build
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
name: Release
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
|
|
7
|
-
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
release:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
environment: npm
|
|
13
|
-
permissions:
|
|
14
|
-
contents: write
|
|
15
|
-
pull-requests: write
|
|
16
|
-
id-token: write
|
|
17
|
-
steps:
|
|
18
|
-
- uses: actions/checkout@v4
|
|
19
|
-
with:
|
|
20
|
-
fetch-depth: 0
|
|
21
|
-
|
|
22
|
-
- uses: pnpm/action-setup@v4
|
|
23
|
-
|
|
24
|
-
- uses: actions/setup-node@v4
|
|
25
|
-
with:
|
|
26
|
-
node-version: 24
|
|
27
|
-
cache: pnpm
|
|
28
|
-
registry-url: https://registry.npmjs.org
|
|
29
|
-
|
|
30
|
-
- run: npm install -g npm@latest
|
|
31
|
-
|
|
32
|
-
- run: pnpm install --frozen-lockfile
|
|
33
|
-
|
|
34
|
-
- run: pnpm build
|
|
35
|
-
|
|
36
|
-
- name: Create release PR or publish
|
|
37
|
-
uses: changesets/action@v1
|
|
38
|
-
with:
|
|
39
|
-
publish: pnpm changeset publish
|
|
40
|
-
env:
|
|
41
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
42
|
-
NPM_CONFIG_PROVENANCE: "true"
|
package/.oxfmtrc.json
DELETED
package/.oxlintrc.json
DELETED
package/AGENTS.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# smonn-ids
|
|
2
|
-
|
|
3
|
-
## Agent skills
|
|
4
|
-
|
|
5
|
-
### Issue tracker
|
|
6
|
-
|
|
7
|
-
Issues live in GitHub Issues on `smonn/ids`, accessed via the `gh` CLI. See `docs/agents/issue-tracker.md`.
|
|
8
|
-
|
|
9
|
-
### Triage labels
|
|
10
|
-
|
|
11
|
-
Default canonical vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.
|
|
12
|
-
|
|
13
|
-
### Domain docs
|
|
14
|
-
|
|
15
|
-
Single-context repo: `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
|
package/CONTEXT.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# IDs
|
|
2
|
-
|
|
3
|
-
Library for generating, parsing, and validating public-facing entity IDs in TypeScript apps. IDs are k-sortable by creation time, type-safe at compile time, and tolerant of human transcription (case + visually-ambiguous characters).
|
|
4
|
-
|
|
5
|
-
## Language
|
|
6
|
-
|
|
7
|
-
**Brand**:
|
|
8
|
-
The string that identifies an entity type (e.g. `"usr"`, `"org"`). Exists simultaneously at runtime (the literal characters embedded at the start of every ID) and at the type level (the nominal tag on `Id<Brand>` that prevents cross-type assignment). One concept, two materialisations — the runtime brand IS the type-level brand.
|
|
9
|
-
_Avoid_: prefix, tag (overloaded with HTML/hashtag), type code, object prefix, resource prefix.
|
|
10
|
-
|
|
11
|
-
**Prefix**:
|
|
12
|
-
The brand plus the trailing separator — `"usr_"`. Distinct from the brand itself: the brand is `"usr"`, the prefix is what actually appears at the start of an encoded ID.
|
|
13
|
-
_Avoid_: brand (use **Brand** for the unsuffixed form), header.
|
|
14
|
-
|
|
15
|
-
**Codec**:
|
|
16
|
-
The brand-scoped object returned by `createId(brand)`, exposing `generate`, `is`, `parse`, `safeParse`, and `extractTimestamp`. The brand is validated once at codec creation; the prefix is then captured by each method. One codec per entity type, typically constructed at module init.
|
|
17
|
-
_Avoid_: factory, generator, encoder.
|
|
18
|
-
|
|
19
|
-
**Canonical form**:
|
|
20
|
-
The unique representation of an ID — lowercase, with Crockford base32 aliases (`o`, `i`, `l`) already resolved to `0`, `1`, `1`. Two strings denote the same ID iff their canonical forms are equal. `Id<Brand>` always holds a canonical string: `generate()` produces canonical, `parse()`/`safeParse()` normalise to canonical at the boundary, and `is()` is strict — see [ADR-0003](./docs/adr/0003-canonical-strict-is.md).
|
|
21
|
-
_Avoid_: normalised form (use **Canonical form**), valid form.
|
|
22
|
-
|
|
23
|
-
**Payload**:
|
|
24
|
-
The 16 raw bytes that follow the prefix in an encoded ID: 6 bytes of millisecond-precision Unix timestamp (big-endian) followed by 10 bytes of randomness. ULID-shaped — same byte layout as a [ULID](https://github.com/ulid/spec), but encoded in lowercase Crockford base32 and wrapped in a brand envelope rather than emitted bare.
|
|
25
|
-
_Avoid_: ULID (use **Payload** when talking about our bytes; reserve "ULID" for the spec itself), body, contents.
|
|
26
|
-
|
|
27
|
-
## Example dialogue
|
|
28
|
-
|
|
29
|
-
> **Dev:** I'm storing user IDs in a column. Do I store the string the user typed, or transform it first?
|
|
30
|
-
>
|
|
31
|
-
> **Domain expert:** Store the canonical form. Two strings that decode to the same ID are distinct as JS strings — `===` is wrong unless both are canonical. `safeParse()` returns canonical; that's what goes in the database.
|
|
32
|
-
>
|
|
33
|
-
> **Dev:** So `is()` is the wrong check at the boundary?
|
|
34
|
-
>
|
|
35
|
-
> **Domain expert:** Right. `is()` is strict — it only returns `true` for already-canonical strings. Use it to discriminate between brands on input you already trust. For untrusted external input, use `safeParse()`; it normalises and hands back an `Id<Brand>` you can rely on.
|
|
36
|
-
>
|
|
37
|
-
> **Dev:** What if I have a string I know is a user ID and just want the timestamp out of it?
|
|
38
|
-
>
|
|
39
|
-
> **Domain expert:** It has to be typed as an `Id<"usr">` first — `extractTimestamp` trusts the type. The honest way to get one is `usr.safeParse(...)`. If you cast a raw string to `Id<"usr">`, you own the consequences.
|
|
40
|
-
>
|
|
41
|
-
> **Dev:** Why does everything hang off `usr` instead of being top-level functions?
|
|
42
|
-
>
|
|
43
|
-
> **Domain expert:** `usr` is a codec. One codec per brand, built at module init. The brand is validated once at construction and the prefix is captured by each method. Standalone functions would either re-validate every call or let bad brands silently corrupt data.
|
|
44
|
-
>
|
|
45
|
-
> **Dev:** And the part after `usr_` is just random?
|
|
46
|
-
>
|
|
47
|
-
> **Domain expert:** No — it's the payload. First 6 bytes are a millisecond Unix timestamp, then 10 random bytes. ULID-shaped, but lowercase and wrapped in a brand envelope. That's why IDs sort by creation time.
|
|
48
|
-
|
|
49
|
-
## Flagged ambiguities / known gaps
|
|
50
|
-
|
|
51
|
-
**Same-millisecond sort order is non-deterministic.** Two IDs generated in the same ms by the same process have independent random tails; they sort randomly relative to each other rather than by generation order. This is deliberate — see ADR-0002. Adding ULID-style monotonic increment would require a stateful generator and a breaking change to `Options`, and is a separate design exercise if the need ever arises.
|
package/CONTRIBUTING.md
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# Contributing
|
|
2
|
-
|
|
3
|
-
## Before you open a PR
|
|
4
|
-
|
|
5
|
-
- **Open or comment on a [GitHub issue](https://github.com/smonn/ids/issues) first** if your change is more than a small bug fix or doc tweak. Especially for anything that touches the wire format, the public API, or the validation contract — these have been deliberated and "I built it, please merge" PRs may not be accepted.
|
|
6
|
-
- **Read [`CONTEXT.md`](./CONTEXT.md).** Use its vocabulary in code, commit messages, and PR descriptions; avoid the synonyms listed under each `_Avoid_:` line.
|
|
7
|
-
- **Skim the [ADRs](./docs/adr/).** They record the constraints your change has to live with.
|
|
8
|
-
|
|
9
|
-
## Closed design questions
|
|
10
|
-
|
|
11
|
-
These were considered and rejected for specific reasons. If you have a genuinely new argument, raise it as an issue with that argument explicit — don't ship a PR that silently reopens the decision.
|
|
12
|
-
|
|
13
|
-
- **Brand width or charset.** Fixed at three lowercase a–z chars. Changing it invalidates every previously-issued ID. See [ADR-0001](./docs/adr/0001-brand-format.md).
|
|
14
|
-
- **Payload byte split, byte order, precision, or epoch.** Fixed at 6 bytes big-endian ms Unix timestamp + 10 random bytes. Same wire-format constraint. See [ADR-0002](./docs/adr/0002-payload-layout.md).
|
|
15
|
-
- **Lenient `is()`.** `is()` is canonical-only by design; the lenient path is `safeParse()`. Restoring lenient `is()` would re-open the footgun ADR-0003 closed. See [ADR-0003](./docs/adr/0003-canonical-strict-is.md).
|
|
16
|
-
- **Monotonicity inside `generate()`.** A stable intra-ms sort would force a breaking change to `Options.rng`. If you need this, design it as a separate opt-in API (e.g. `createMonotonicId`) and propose it in an issue first.
|
|
17
|
-
- **Custom epoch.** 48 bits of ms gives ~8919 years of headroom from 1970; there's no bit-budget motivation to rebase. A custom epoch would turn time into a magic number every downstream consumer would have to remember. See [ADR-0002](./docs/adr/0002-payload-layout.md).
|
|
18
|
-
|
|
19
|
-
## Setup
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
pnpm install
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Requires Node ≥ 24 and pnpm.
|
|
26
|
-
|
|
27
|
-
## Dev loop
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
pnpm test # vitest run
|
|
31
|
-
pnpm test:watch # vitest in watch mode
|
|
32
|
-
pnpm test:coverage # vitest with v8 coverage
|
|
33
|
-
pnpm typecheck # tsc --noEmit
|
|
34
|
-
pnpm lint # oxlint
|
|
35
|
-
pnpm fmt:check # oxfmt --check
|
|
36
|
-
pnpm build # tsdown
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm fmt:check` before opening a PR.
|
|
40
|
-
|
|
41
|
-
## Style
|
|
42
|
-
|
|
43
|
-
- **Don't mock the clock or RNG.** Inject them via `Options` (`now`, `rng`) — see the existing tests for how.
|
|
44
|
-
- **New exports → update the API surface section in [`README.md`](./README.md).**
|
|
45
|
-
- **New domain concept → add a glossary entry to [`CONTEXT.md`](./CONTEXT.md)**, including any synonyms you want future contributors to avoid.
|
|
46
|
-
- **New design decision that's hard to reverse, surprising without context, and the result of a real trade-off → add a new ADR** under `docs/adr/`, numbered sequentially.
|
|
47
|
-
- **Commit subjects:** `<scope>: <what changed>` (e.g. `id: tighten is() to canonical-only`).
|
|
48
|
-
|
|
49
|
-
## Tests
|
|
50
|
-
|
|
51
|
-
- Add a test for any new public behaviour.
|
|
52
|
-
- Add boundary tests for any new numeric input (compare with `extracts ms at the 48-bit boundary` and friends in `id.test.ts`).
|
|
53
|
-
- Use deterministic `rng` and `now` in tests that assert on the encoded form — never snapshot a fully-random ID.
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Brand format: three lowercase a–z characters
|
|
2
|
-
|
|
3
|
-
Public-facing IDs need a short, fixed-width type identifier that humans can scan, that doesn't collide with the Crockford base32 payload, and that survives in URLs. We use exactly three lowercase a–z characters, validated at runtime: three gives 17,576 brands (more than any single app needs), lowercase removes case-normalisation from the brand portion, and excluding digits keeps the brand visually distinct from the payload. The brand width is part of the wire format — changing it invalidates every previously-issued ID.
|
|
4
|
-
|
|
5
|
-
## Considered Options
|
|
6
|
-
|
|
7
|
-
- **Variable width** — rejected: forces `split("_")` and a brand registry; parsing ambiguity
|
|
8
|
-
- **Alphanumeric brands** (e.g. `s3_…`) — rejected: visual collision with the payload
|
|
9
|
-
- **2 chars** — rejected: too few combinations as an app grows
|
|
10
|
-
- **4+ chars** — rejected: URL cost without scaling benefit
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# Payload layout: ULID-shaped, with deliberate divergences
|
|
2
|
-
|
|
3
|
-
The payload is laid out exactly like a ULID: 48-bit millisecond Unix timestamp (big-endian) followed by 80 random bits, encoded as 26 Crockford base32 characters. We adopt the ULID byte split because it's already k-sortable, fits cleanly into 26 base32 chars, and gives ~80 bits of randomness per millisecond (collision-safe for any plausible single-app throughput).
|
|
4
|
-
|
|
5
|
-
Three deliberate divergences from the spec:
|
|
6
|
-
|
|
7
|
-
- **Lowercase encoding.** The brand is lowercase a–z (see [ADR-0001](./0001-brand-format.md)) and lowercasing the payload keeps the whole ID visually uniform. Decoding remains case-insensitive.
|
|
8
|
-
- **Brand envelope.** IDs are emitted with a `<brand>_` prefix rather than as bare 26-char strings. Off-the-shelf ULID parsers will not accept these and shouldn't be expected to.
|
|
9
|
-
- **No monotonicity.** Two IDs generated in the same millisecond by the same process do not sort deterministically. The ULID spec's monotonic-increment recommendation would require a stateful generator and break the `Options.rng` shape. Sort stability within a single ms is a non-goal for public-facing entity IDs.
|
|
10
|
-
|
|
11
|
-
## Timestamp contract
|
|
12
|
-
|
|
13
|
-
`Codec.extractTimestamp(id)` is a public, supported method — its existence makes the timestamp layout part of the stability contract, not an implementation detail. Specifically:
|
|
14
|
-
|
|
15
|
-
- **Position:** first 6 bytes of the payload (immediately after the prefix, before the random bytes)
|
|
16
|
-
- **Encoding:** unsigned big-endian integer
|
|
17
|
-
- **Precision:** milliseconds
|
|
18
|
-
- **Epoch:** Unix (1970-01-01T00:00:00Z) — not a custom epoch
|
|
19
|
-
|
|
20
|
-
Unix is non-negotiable. 48 bits of ms gives ~8919 years of headroom from 1970, so there is no bit-budget motivation to rebase (the Snowflake/Discord rationale). A custom epoch would burn the only remaining direct ULID compatibility (the timestamp bytes themselves) and turn epoch into a magic number every external consumer of the bytes would have to know.
|
|
21
|
-
|
|
22
|
-
`extractTimestamp` does not validate its input — it trusts the `Id<Brand>` type. Callers holding raw external strings must pass them through `safeParse()` / `parse()` first (see [ADR-0003](./0003-canonical-strict-is.md)).
|
|
23
|
-
|
|
24
|
-
## Consequences
|
|
25
|
-
|
|
26
|
-
The 16-byte payload layout is part of the wire format. Changing the byte split (e.g. 8+8, 4+12), the timestamp precision, the byte order, or the epoch invalidates every previously-issued ID — the same constraint as the brand width.
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Lenient at boundaries, strict everywhere else
|
|
2
|
-
|
|
3
|
-
`Id<Brand>` denotes a **canonical** ID: lowercase, with Crockford base32 aliases (`o`, `i`, `l`) already resolved. The boundary between an untrusted external string and a typed `Id<Brand>` is `parse()` / `safeParse()`; these accept lenient input (mixed case, `o`/`i`/`l` aliases) and return the canonical form. `is()` is strict — it returns `true` only for strings that are already canonical. Once a value carries the `Id<Brand>` type, `===` reliably tests logical equality.
|
|
4
|
-
|
|
5
|
-
## Considered Options
|
|
6
|
-
|
|
7
|
-
- **Lenient `is()`** (rejected) — equivalent to `safeParse().success`. Leaves `Id<Brand>` semantically ambiguous: a value of that type might or might not be canonical, so consumers can't rely on `===` and non-canonical strings can leak into storage if a caller forgets to round-trip through `parse()`.
|
|
8
|
-
|
|
9
|
-
## Consequences
|
|
10
|
-
|
|
11
|
-
- Always call `safeParse()` / `parse()` at the boundary (incoming URL params, form fields, request bodies). Never assert that a raw external string is already an `Id<Brand>`.
|
|
12
|
-
- `is()` is the right guard for trusting an already-typed string (e.g. discriminating across brands within already-validated input). It is the wrong guard for ingesting external input.
|
package/docs/agents/domain.md
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# Domain Docs
|
|
2
|
-
|
|
3
|
-
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
|
4
|
-
|
|
5
|
-
## Before exploring, read these
|
|
6
|
-
|
|
7
|
-
- **`CONTEXT.md`** at the repo root, or
|
|
8
|
-
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
|
9
|
-
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
|
10
|
-
|
|
11
|
-
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
|
12
|
-
|
|
13
|
-
## File structure
|
|
14
|
-
|
|
15
|
-
This repo is **single-context**:
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
/
|
|
19
|
-
├── CONTEXT.md
|
|
20
|
-
├── docs/adr/
|
|
21
|
-
│ ├── 0001-brand-format.md
|
|
22
|
-
│ ├── 0002-payload-layout.md
|
|
23
|
-
│ └── 0003-canonical-strict-is.md
|
|
24
|
-
└── src/
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## Use the glossary's vocabulary
|
|
28
|
-
|
|
29
|
-
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
|
30
|
-
|
|
31
|
-
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
|
32
|
-
|
|
33
|
-
## Flag ADR conflicts
|
|
34
|
-
|
|
35
|
-
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
|
36
|
-
|
|
37
|
-
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# Issue tracker: GitHub
|
|
2
|
-
|
|
3
|
-
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
|
4
|
-
|
|
5
|
-
## Conventions
|
|
6
|
-
|
|
7
|
-
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
|
8
|
-
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
|
9
|
-
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
|
10
|
-
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
|
11
|
-
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
|
12
|
-
- **Close**: `gh issue close <number> --comment "..."`
|
|
13
|
-
|
|
14
|
-
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
|
15
|
-
|
|
16
|
-
## When a skill says "publish to the issue tracker"
|
|
17
|
-
|
|
18
|
-
Create a GitHub issue.
|
|
19
|
-
|
|
20
|
-
## When a skill says "fetch the relevant ticket"
|
|
21
|
-
|
|
22
|
-
Run `gh issue view <number> --comments`.
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# Triage Labels
|
|
2
|
-
|
|
3
|
-
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
|
4
|
-
|
|
5
|
-
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
|
6
|
-
| -------------------------- | -------------------- | ---------------------------------------- |
|
|
7
|
-
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
|
8
|
-
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
|
9
|
-
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
|
10
|
-
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
|
11
|
-
| `wontfix` | `wontfix` | Will not be actioned |
|
|
12
|
-
|
|
13
|
-
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
|
14
|
-
|
|
15
|
-
Edit the right-hand column to match whatever vocabulary you actually use.
|
package/src/base32.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
This is based on Crockford's Base32 spec: https://www.crockford.com/base32.html
|
|
3
|
-
One difference is that it uses lowercase instead of uppercase when encoding.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { invariant } from "./invariant.js";
|
|
7
|
-
|
|
8
|
-
export const alphabet = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
9
|
-
|
|
10
|
-
const numberToCharLookup = alphabet.split("");
|
|
11
|
-
|
|
12
|
-
const charToNumberLookup = new Map<string, number>([
|
|
13
|
-
...numberToCharLookup.map((char, i) => [char, i] as const),
|
|
14
|
-
["o", 0],
|
|
15
|
-
["i", 1],
|
|
16
|
-
["l", 1],
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
export function encodeBase32(bytes: Uint8Array): string {
|
|
20
|
-
let result = "";
|
|
21
|
-
let bits = 0;
|
|
22
|
-
let value = 0;
|
|
23
|
-
|
|
24
|
-
for (const byte of bytes) {
|
|
25
|
-
value = (value << 8) | byte;
|
|
26
|
-
bits += 8;
|
|
27
|
-
while (bits >= 5) {
|
|
28
|
-
bits -= 5;
|
|
29
|
-
result += numberToCharLookup[(value >>> bits) & 0x1f];
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
invariant(bits === 3, "expected three leftover bits");
|
|
33
|
-
result += numberToCharLookup[(value << (5 - bits)) & 0x1f];
|
|
34
|
-
return result;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function decodeBase32(str: string): Uint8Array {
|
|
38
|
-
const result = new Uint8Array(Math.floor((str.length * 5) / 8));
|
|
39
|
-
let bits = 0;
|
|
40
|
-
let value = 0;
|
|
41
|
-
let index = 0;
|
|
42
|
-
|
|
43
|
-
for (const char of str) {
|
|
44
|
-
const v = charToNumberLookup.get(char.toLowerCase());
|
|
45
|
-
invariant(v !== undefined, "invalid base32");
|
|
46
|
-
value = (value << 5) | v;
|
|
47
|
-
bits += 5;
|
|
48
|
-
if (bits >= 8) {
|
|
49
|
-
bits -= 8;
|
|
50
|
-
result[index++] = (value >>> bits) & 0xff;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return result;
|
|
54
|
-
}
|
package/src/id.test.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { expect, describe, it } from "vitest";
|
|
2
|
-
import { createId } from "./id.js";
|
|
3
|
-
|
|
4
|
-
describe("id", () => {
|
|
5
|
-
it("roundtrip", () => {
|
|
6
|
-
const fixed = new Date("2026-05-28T12:00:00Z");
|
|
7
|
-
const usr = createId("usr", { now: () => fixed });
|
|
8
|
-
const id = usr.generate();
|
|
9
|
-
expect(usr.extractTimestamp(id)).toEqual(fixed);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("deterministic snapshot", () => {
|
|
13
|
-
const usr = createId("usr", {
|
|
14
|
-
now: () => new Date(0),
|
|
15
|
-
rng: (n) => new Uint8Array(n),
|
|
16
|
-
});
|
|
17
|
-
expect(usr.generate()).toBe("usr_" + "0".repeat(26)); // adjust to actual
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("extracts ms=0 (epoch boundary)", () => {
|
|
21
|
-
const usr = createId("usr", {
|
|
22
|
-
now: () => new Date(0),
|
|
23
|
-
rng: (n) => new Uint8Array(n),
|
|
24
|
-
});
|
|
25
|
-
expect(usr.extractTimestamp(usr.generate())).toEqual(new Date(0));
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("extracts ms at the 48-bit boundary", () => {
|
|
29
|
-
const maxMs = 2 ** 48 - 1;
|
|
30
|
-
const usr = createId("usr", {
|
|
31
|
-
now: () => new Date(maxMs),
|
|
32
|
-
rng: (n) => new Uint8Array(n),
|
|
33
|
-
});
|
|
34
|
-
expect(usr.extractTimestamp(usr.generate())).toEqual(new Date(maxMs));
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("rejects timestamps that overflow 48 bits", () => {
|
|
38
|
-
const usr = createId("usr", {
|
|
39
|
-
now: () => new Date(2 ** 48),
|
|
40
|
-
rng: (n) => new Uint8Array(n),
|
|
41
|
-
});
|
|
42
|
-
expect(() => usr.generate()).toThrow();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("rejects pre-epoch timestamps", () => {
|
|
46
|
-
const usr = createId("usr", {
|
|
47
|
-
now: () => new Date(-1),
|
|
48
|
-
rng: (n) => new Uint8Array(n),
|
|
49
|
-
});
|
|
50
|
-
expect(() => usr.generate()).toThrow();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("handles maximal random bytes", () => {
|
|
54
|
-
const usr = createId("usr", {
|
|
55
|
-
now: () => new Date(0),
|
|
56
|
-
rng: (n) => new Uint8Array(n).fill(0xff),
|
|
57
|
-
});
|
|
58
|
-
const id = usr.generate();
|
|
59
|
-
expect(usr.is(id)).toBe(true);
|
|
60
|
-
expect(usr.extractTimestamp(id)).toEqual(new Date(0));
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("is() accepts only canonical form", () => {
|
|
64
|
-
const usr = createId("usr");
|
|
65
|
-
expect(usr.is("usr_01h7b3k9rqxn1cw3p9r8t2sgkz")).toBe(true);
|
|
66
|
-
expect(usr.is("USR_01H7B3K9RQXN1CW3P9R8T2SGKZ")).toBe(false); // uppercase
|
|
67
|
-
expect(usr.is("usr_Olh7b3k9rqxnIcw3p9r8t2sgkz")).toBe(false); // contains o/i/l aliases
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("parse() normalises lenient input to canonical form", () => {
|
|
71
|
-
const usr = createId("usr");
|
|
72
|
-
expect(usr.parse("USR_01H7B3K9rqxn4cw3p9r8t2sgkz")).toEqual("usr_01h7b3k9rqxn4cw3p9r8t2sgkz");
|
|
73
|
-
expect(usr.parse("usr_Olh7b3k9rqxnIcw3p9r8t2sgkz")).toEqual("usr_01h7b3k9rqxn1cw3p9r8t2sgkz");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("safeParse() returns canonical form on success", () => {
|
|
77
|
-
const usr = createId("usr");
|
|
78
|
-
expect(usr.safeParse("usr_Olh7b3k9rqxnIcw3p9r8t2sgkz")).toEqual({
|
|
79
|
-
ok: true,
|
|
80
|
-
id: "usr_01h7b3k9rqxn1cw3p9r8t2sgkz",
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("safeParse() fails on bad input", () => {
|
|
85
|
-
const usr = createId("usr");
|
|
86
|
-
expect(usr.safeParse(null)).toEqual({ ok: false, error: "not_string" });
|
|
87
|
-
expect(usr.safeParse("org_Olh7b3k9rqxnIcw3p9r8t2sgkz")).toEqual({
|
|
88
|
-
ok: false,
|
|
89
|
-
error: "invalid_prefix",
|
|
90
|
-
});
|
|
91
|
-
expect(usr.safeParse("usr_01h7b3k9rqxn1cw3p9r8t2sgk!")).toEqual({
|
|
92
|
-
ok: false,
|
|
93
|
-
error: "invalid_base32",
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("cross-brand rejection", () => {
|
|
98
|
-
const org = createId("org");
|
|
99
|
-
const usr = createId("usr");
|
|
100
|
-
const orgId = org.generate();
|
|
101
|
-
expect(usr.is(orgId)).toBe(false);
|
|
102
|
-
expect(() => usr.parse(orgId)).toThrow();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("brands containing o/i/l", () => {
|
|
106
|
-
const log = createId("log");
|
|
107
|
-
const logId = log.generate();
|
|
108
|
-
expect(log.is(logId)).toBe(true);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("is() does not accept malformed inputs", () => {
|
|
112
|
-
const usr = createId("usr");
|
|
113
|
-
expect(usr.is(null)).toBe(false);
|
|
114
|
-
expect(usr.is("usr_")).toBe(false);
|
|
115
|
-
expect(usr.is("usr_!!!")).toBe(false);
|
|
116
|
-
expect(usr.is("usr_" + "a".repeat(25))).toBe(false); // wrong length
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("fails if brand is not exactly three a-z characters", () => {
|
|
120
|
-
expect(() => createId("a")).toThrow();
|
|
121
|
-
expect(() => createId("aaaa")).toThrow();
|
|
122
|
-
expect(() => createId("!@?")).toThrow();
|
|
123
|
-
});
|
|
124
|
-
});
|
package/src/id.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { alphabet, decodeBase32, encodeBase32 } from "./base32.js";
|
|
2
|
-
import { invariant } from "./invariant.js";
|
|
3
|
-
|
|
4
|
-
export type Options = {
|
|
5
|
-
now: () => Date;
|
|
6
|
-
rng: (bytes: number) => Uint8Array;
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
const defaultOptions: Options = {
|
|
10
|
-
now: () => new Date(),
|
|
11
|
-
rng: (bytes: number) => crypto.getRandomValues(new Uint8Array(bytes)),
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export type Prefix<Brand extends string> = `${Brand}_`;
|
|
15
|
-
|
|
16
|
-
export type Id<Brand extends string> = `${Prefix<Brand>}${string}` & {
|
|
17
|
-
readonly __brand: Brand;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type ParseError = "not_string" | "invalid_prefix" | "invalid_base32";
|
|
21
|
-
|
|
22
|
-
export type ParseResult<Brand extends string> =
|
|
23
|
-
| { ok: true; id: Id<Brand> }
|
|
24
|
-
| { ok: false; error: ParseError };
|
|
25
|
-
|
|
26
|
-
export type Codec<Brand extends string> = {
|
|
27
|
-
generate(): Id<Brand>;
|
|
28
|
-
is(value: unknown): value is Id<Brand>;
|
|
29
|
-
parse(value: unknown): Id<Brand>;
|
|
30
|
-
safeParse(value: unknown): ParseResult<Brand>;
|
|
31
|
-
extractTimestamp(id: Id<Brand>): Date;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const timestampByteLength = 6;
|
|
35
|
-
const randomByteLength = 10;
|
|
36
|
-
const totalByteLength = timestampByteLength + randomByteLength;
|
|
37
|
-
const base32Length = Math.ceil((totalByteLength * 8) / 5);
|
|
38
|
-
const replacePattern = /[ilo]/gi;
|
|
39
|
-
const replaceMap = { o: "0", i: "1", l: "1" } as const;
|
|
40
|
-
const replacer = (match: string) => {
|
|
41
|
-
invariant(match === "o" || match === "i" || match === "l", "invalid match");
|
|
42
|
-
return replaceMap[match];
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const base32Pattern = new RegExp(`^[${alphabet}]{${base32Length}}$`);
|
|
46
|
-
const brandPattern = /^[a-z]{3}$/;
|
|
47
|
-
|
|
48
|
-
export function createId<Brand extends string>(
|
|
49
|
-
brand: Brand,
|
|
50
|
-
opts: Partial<Options> = {},
|
|
51
|
-
): Codec<Brand> {
|
|
52
|
-
invariant(brandPattern.test(brand), "invalid brand, expected three lowercase a-z characters");
|
|
53
|
-
|
|
54
|
-
const options = {
|
|
55
|
-
...defaultOptions,
|
|
56
|
-
...opts,
|
|
57
|
-
} satisfies Options;
|
|
58
|
-
|
|
59
|
-
const prefix: Prefix<Brand> = `${brand}_`;
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
generate: () => generate(prefix, options),
|
|
63
|
-
is: (value: unknown) => is(prefix, value),
|
|
64
|
-
parse: (value: unknown) => parse(prefix, value),
|
|
65
|
-
safeParse: (value: unknown) => safeParse(prefix, value),
|
|
66
|
-
extractTimestamp: (id: Id<Brand>) => extractTimestamp(prefix, id),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function safeParse<Brand extends string>(
|
|
71
|
-
prefix: Prefix<Brand>,
|
|
72
|
-
value: unknown,
|
|
73
|
-
): ParseResult<Brand> {
|
|
74
|
-
if (typeof value !== "string") return { ok: false, error: "not_string" };
|
|
75
|
-
const lowercase = value.toLowerCase();
|
|
76
|
-
if (!lowercase.startsWith(prefix)) return { ok: false, error: "invalid_prefix" };
|
|
77
|
-
|
|
78
|
-
const base32 = lowercase.slice(prefix.length).replaceAll(replacePattern, replacer);
|
|
79
|
-
|
|
80
|
-
if (!base32Pattern.test(base32)) return { ok: false, error: "invalid_base32" };
|
|
81
|
-
|
|
82
|
-
const id = (prefix + base32) as Id<Brand>;
|
|
83
|
-
return { ok: true, id };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function parse<Brand extends string>(prefix: Prefix<Brand>, value: unknown): Id<Brand> {
|
|
87
|
-
const result = safeParse(prefix, value);
|
|
88
|
-
if (result.ok) return result.id;
|
|
89
|
-
throw new Error(`Invalid ID: ${result.error}`);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function is<Brand extends string>(prefix: Prefix<Brand>, value: unknown): value is Id<Brand> {
|
|
93
|
-
if (typeof value !== "string") return false;
|
|
94
|
-
if (!value.startsWith(prefix)) return false;
|
|
95
|
-
return base32Pattern.test(value.slice(prefix.length));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function encodeNumberToUint8Array(value: number, bytes: number): Uint8Array {
|
|
99
|
-
invariant(value >= 0, "value is negative");
|
|
100
|
-
invariant(value < 2 ** (bytes * 8), `value exceeds ${bytes * 8}-bit range`);
|
|
101
|
-
const result = new Uint8Array(bytes);
|
|
102
|
-
// iterate backwards to encode in big-endian
|
|
103
|
-
for (let i = bytes - 1; i >= 0; i--) {
|
|
104
|
-
// we encode via 256 as bitwise ops will coerce to 32-bit integers
|
|
105
|
-
result[i] = value % 256;
|
|
106
|
-
value = Math.floor(value / 256);
|
|
107
|
-
}
|
|
108
|
-
return result;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
112
|
-
const result = new Uint8Array(a.length + b.length);
|
|
113
|
-
result.set(a, 0);
|
|
114
|
-
result.set(b, a.length);
|
|
115
|
-
return result;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function generate<Brand extends string>(prefix: Prefix<Brand>, options: Options): Id<Brand> {
|
|
119
|
-
const timestamp = encodeNumberToUint8Array(options.now().getTime(), timestampByteLength);
|
|
120
|
-
const rand = options.rng(randomByteLength);
|
|
121
|
-
return (prefix + encodeBase32(concat(timestamp, rand))) as Id<Brand>;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function extractTimestamp<Brand extends string>(prefix: Prefix<Brand>, id: Id<Brand>): Date {
|
|
125
|
-
const base32 = id.slice(prefix.length);
|
|
126
|
-
const bytes = decodeBase32(base32);
|
|
127
|
-
const timestampBytes = bytes.subarray(0, timestampByteLength);
|
|
128
|
-
let ms = 0;
|
|
129
|
-
for (const byte of timestampBytes) {
|
|
130
|
-
ms = ms * 256 + byte;
|
|
131
|
-
}
|
|
132
|
-
return new Date(ms);
|
|
133
|
-
}
|
package/src/index.ts
DELETED
package/src/invariant.ts
DELETED
package/tsconfig.json
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://www.schemastore.org/tsconfig",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"forceConsistentCasingInFileNames": true,
|
|
5
|
-
"lib": ["es2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator", "ESNext.Promise"],
|
|
6
|
-
"module": "preserve",
|
|
7
|
-
"target": "es2024",
|
|
8
|
-
"moduleResolution": "bundler",
|
|
9
|
-
"strict": true,
|
|
10
|
-
"allowUnusedLabels": false,
|
|
11
|
-
"allowUnreachableCode": false,
|
|
12
|
-
"exactOptionalPropertyTypes": true,
|
|
13
|
-
"noFallthroughCasesInSwitch": true,
|
|
14
|
-
"noImplicitOverride": true,
|
|
15
|
-
"noImplicitReturns": true,
|
|
16
|
-
"declaration": true,
|
|
17
|
-
"noPropertyAccessFromIndexSignature": true,
|
|
18
|
-
"noUncheckedIndexedAccess": true,
|
|
19
|
-
"noUnusedLocals": true,
|
|
20
|
-
"noUnusedParameters": true,
|
|
21
|
-
"isolatedModules": true,
|
|
22
|
-
"isolatedDeclarations": true,
|
|
23
|
-
"esModuleInterop": true,
|
|
24
|
-
"skipLibCheck": true,
|
|
25
|
-
"rewriteRelativeImportExtensions": true,
|
|
26
|
-
"erasableSyntaxOnly": true,
|
|
27
|
-
"verbatimModuleSyntax": true,
|
|
28
|
-
"types": ["node"]
|
|
29
|
-
},
|
|
30
|
-
"include": ["src"]
|
|
31
|
-
}
|
package/tsdown.config.ts
DELETED