@smonn/ids 0.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.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/release.yml +42 -0
- package/.oxfmtrc.json +4 -0
- package/.oxlintrc.json +11 -0
- package/AGENTS.md +15 -0
- package/CONTEXT.md +51 -0
- package/CONTRIBUTING.md +53 -0
- package/LICENSE +20 -0
- package/README.md +131 -0
- package/docs/adr/0001-brand-format.md +10 -0
- package/docs/adr/0002-payload-layout.md +26 -0
- package/docs/adr/0003-canonical-strict-is.md +12 -0
- package/docs/agents/domain.md +37 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +15 -0
- package/package.json +36 -0
- package/src/base32.ts +54 -0
- package/src/id.test.ts +124 -0
- package/src/id.ts +133 -0
- package/src/index.ts +8 -0
- package/src/invariant.ts +3 -0
- package/tsconfig.json +31 -0
- package/tsdown.config.ts +10 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,8 @@
|
|
|
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).
|
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
|
@@ -0,0 +1,42 @@
|
|
|
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
ADDED
package/.oxlintrc.json
ADDED
package/AGENTS.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon Ingeson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the “Software”), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @smonn/ids
|
|
2
|
+
|
|
3
|
+
Public-facing branded IDs for TypeScript apps.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @smonn/ids
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Each ID looks like `usr_01h7b3k9rqxn4cw3p9r8t2sgkz`: a three-letter brand, an underscore, then 26 Crockford base32 characters encoding a 48-bit millisecond Unix timestamp followed by 80 random bits. Same byte layout as a [ULID](https://github.com/ulid/spec); see [ADR-0002](./docs/adr/0002-payload-layout.md) for the deliberate divergences.
|
|
10
|
+
|
|
11
|
+
## What this is for
|
|
12
|
+
|
|
13
|
+
### "Give my entities IDs that are safe to expose in URLs, dashboards, and support tickets"
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createId } from "@smonn/ids";
|
|
17
|
+
|
|
18
|
+
const users = createId("usr");
|
|
19
|
+
const id = users.generate(); // "usr_01h7b3k9rqxn4cw3p9r8t2sgkz"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The three-letter brand tells you what kind of thing the ID refers to without an out-of-band lookup. No leaking row counts via sequential PKs, no slug collisions, no "is this a user or an org?" ambiguity in a stack trace.
|
|
23
|
+
|
|
24
|
+
### "Catch me passing a `UserId` where I needed an `OrgId`"
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { type Id, createId } from "@smonn/ids";
|
|
28
|
+
|
|
29
|
+
const users = createId("usr");
|
|
30
|
+
const orgs = createId("org");
|
|
31
|
+
|
|
32
|
+
function loadUser(id: Id<"usr">) {
|
|
33
|
+
/* ... */
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
loadUser(orgs.generate()); // ❌ Type 'Id<"org">' is not assignable to 'Id<"usr">'.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`Id<Brand>` is nominally tagged. `Id<"usr">` and `Id<"org">` are not interchangeable — even though both are strings at runtime, the type system treats them as distinct.
|
|
40
|
+
|
|
41
|
+
### "A support agent emailed me an ID — accept it even if they typed it wrong"
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
users.safeParse("usr_01h7b3k9rqxn1cw3p9r8t2sgkz"); // canonical
|
|
45
|
+
users.safeParse("USR_01H7B3K9RQXN1CW3P9R8T2SGKZ"); // uppercase
|
|
46
|
+
users.safeParse("usr_Olh7b3k9rqxnIcw3p9r8t2sgkz"); // o, I, l aliased
|
|
47
|
+
// → { ok: true, id: "usr_01h7b3k9rqxn1cw3p9r8t2sgkz" } for all three
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`safeParse` accepts mixed case and the Crockford-spec visual aliases (`o → 0`, `i → 1`, `l → 1`), and always returns the **canonical form** — lowercase, aliases resolved. Equality checks on canonical strings work as expected.
|
|
51
|
+
|
|
52
|
+
### "Validate an ID arriving from a URL or request body"
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const r = users.safeParse(input);
|
|
56
|
+
|
|
57
|
+
if (!r.ok) {
|
|
58
|
+
switch (r.error) {
|
|
59
|
+
case "not_string":
|
|
60
|
+
return 400; // wasn't a string at all
|
|
61
|
+
case "invalid_prefix":
|
|
62
|
+
return 404; // wrong kind of ID (or not an ID)
|
|
63
|
+
case "invalid_base32":
|
|
64
|
+
return 400; // prefix matched but payload is malformed
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const userId = r.id; // Id<"usr">, canonical
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`ParseError` is exported as a literal union so the switch is exhaustive at compile time.
|
|
72
|
+
|
|
73
|
+
### "Sort and date-stamp records using just the ID"
|
|
74
|
+
|
|
75
|
+
The first 6 bytes of the payload are a big-endian millisecond Unix timestamp, so `ORDER BY id` sorts by creation time without a separate `created_at` column. To extract the timestamp from an existing ID:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
users.extractTimestamp(id); // Date
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The timestamp layout (millisecond precision, big-endian, Unix epoch) is part of the public contract — see [ADR-0002](./docs/adr/0002-payload-layout.md).
|
|
82
|
+
|
|
83
|
+
Caveat: two IDs generated in the same millisecond by the same process have independent random tails and do **not** sort deterministically relative to each other. If you need stable intra-millisecond ordering, this library isn't the right tool.
|
|
84
|
+
|
|
85
|
+
### "Inject a fixed clock and RNG so my tests are deterministic"
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const users = createId("usr", {
|
|
89
|
+
now: () => new Date("2026-01-01T00:00:00Z"),
|
|
90
|
+
rng: (bytes) => new Uint8Array(bytes),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
users.generate(); // deterministic snapshot-friendly output
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both `Options` fields are optional. Defaults are `() => new Date()` and `crypto.getRandomValues`.
|
|
97
|
+
|
|
98
|
+
## What this is **not** for
|
|
99
|
+
|
|
100
|
+
- **Internal surrogate primary keys.** If nobody outside your service ever sees the ID, the brand prefix and lenient parsing are dead weight. Use a `bigint` sequence.
|
|
101
|
+
- **Wire-compatible ULIDs.** The byte layout is ULID-shaped but the encoding is lowercase and wrapped in a brand envelope. Stock ULID parsers will reject these.
|
|
102
|
+
- **Distributed-trace / request-correlation IDs.** Use OpenTelemetry-format IDs.
|
|
103
|
+
- **Hiding when your system launched.** Anyone with one known-time ID can compute the epoch offset. A custom epoch isn't supported, and wouldn't help anyway.
|
|
104
|
+
|
|
105
|
+
## API surface
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import {
|
|
109
|
+
createId, // (brand: string, opts?: Partial<Options>) => Codec<Brand>
|
|
110
|
+
type Id, // branded string type
|
|
111
|
+
type Codec, // returned by createId
|
|
112
|
+
type Options, // { now, rng } injection points
|
|
113
|
+
type ParseError, // "not_string" | "invalid_prefix" | "invalid_base32"
|
|
114
|
+
type ParseResult, // safeParse return type
|
|
115
|
+
} from "@smonn/ids";
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `Codec<Brand>`
|
|
119
|
+
|
|
120
|
+
| Method | Description |
|
|
121
|
+
| ---------------------- | ----------------------------------------------------------------- |
|
|
122
|
+
| `generate()` | Produce a fresh ID |
|
|
123
|
+
| `is(value)` | Strict type guard: `true` only for already-canonical strings |
|
|
124
|
+
| `parse(value)` | Lenient: normalise to canonical, or throw |
|
|
125
|
+
| `safeParse(value)` | Lenient: normalise to canonical, or return `{ ok: false, error }` |
|
|
126
|
+
| `extractTimestamp(id)` | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
|
|
127
|
+
|
|
128
|
+
## Design
|
|
129
|
+
|
|
130
|
+
- [`CONTEXT.md`](./CONTEXT.md) — glossary of the project's vocabulary
|
|
131
|
+
- [`docs/adr/`](./docs/adr/) — recorded design decisions
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
|
@@ -0,0 +1,26 @@
|
|
|
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.
|
|
@@ -0,0 +1,12 @@
|
|
|
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.
|
|
@@ -0,0 +1,37 @@
|
|
|
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…_
|
|
@@ -0,0 +1,22 @@
|
|
|
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`.
|
|
@@ -0,0 +1,15 @@
|
|
|
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/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smonn/ids",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/index.mjs",
|
|
8
|
+
"./package.json": "./package.json"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@changesets/cli": "2.31.0",
|
|
12
|
+
"@types/node": "25.9.1",
|
|
13
|
+
"@vitest/coverage-v8": "4.1.7",
|
|
14
|
+
"knip": "6.14.2",
|
|
15
|
+
"oxfmt": "0.52.0",
|
|
16
|
+
"oxlint": "1.67.0",
|
|
17
|
+
"tsdown": "0.22.1",
|
|
18
|
+
"typescript": "6.0.3",
|
|
19
|
+
"vitest": "4.1.7"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"lint": "oxlint",
|
|
26
|
+
"lint:fix": "oxlint --fix",
|
|
27
|
+
"fmt": "oxfmt",
|
|
28
|
+
"fmt:check": "oxfmt --check",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"build": "tsdown",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"test:coverage": "vitest run --coverage",
|
|
34
|
+
"knip": "knip"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/base32.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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
ADDED
package/src/invariant.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
ADDED