@smonn/ids 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,16 +6,16 @@ Public-facing branded IDs for TypeScript apps.
6
6
  pnpm add @smonn/ids
7
7
  ```
8
8
 
9
- Each ID looks like `usr_01h7b3k9rqxn4cw3p9r8t2sgkz`: a three-letter brand, an underscore, then 26 Crockford base32 characters of payload. The Timestamp codec encodes 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. The Opaque codec (`@smonn/ids/opaque`) keeps the same wire shape but encrypts the payload under a key, so the timestamp isn't readable from the ID.
9
+ Each ID looks like `usr_01h7b3k9rqxn4cw3p9r8t2sgkz`: a three-letter brand, an underscore, then 26 Crockford base32 characters of payload. The Timestamp codec encodes 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. The Opaque Timestamp codec (`@smonn/ids/opaque`) keeps the same wire shape but encrypts the payload under a key, so the timestamp isn't readable from the ID.
10
10
 
11
11
  ## What this is for
12
12
 
13
13
  ### "Give my entities IDs that are safe to expose in URLs, dashboards, and support tickets"
14
14
 
15
15
  ```ts
16
- import { createId } from "@smonn/ids";
16
+ import { createTimestampId } from "@smonn/ids";
17
17
 
18
- const users = createId("usr");
18
+ const users = createTimestampId("usr");
19
19
  const id = users.generate(); // "usr_01h7b3k9rqxn4cw3p9r8t2sgkz"
20
20
  ```
21
21
 
@@ -24,10 +24,10 @@ The three-letter brand tells you what kind of thing the ID refers to without an
24
24
  ### "Catch me passing a `UserId` where I needed an `OrgId`"
25
25
 
26
26
  ```ts
27
- import { type Id, createId } from "@smonn/ids";
27
+ import { type Id, createTimestampId } from "@smonn/ids";
28
28
 
29
- const users = createId("usr");
30
- const orgs = createId("org");
29
+ const users = createTimestampId("usr");
30
+ const orgs = createTimestampId("org");
31
31
 
32
32
  function loadUser(id: Id<"usr">) {
33
33
  /* ... */
@@ -105,7 +105,7 @@ Caveat: two IDs generated in the same millisecond by the same process have indep
105
105
  ### "Inject a fixed clock and RNG so my tests are deterministic"
106
106
 
107
107
  ```ts
108
- const users = createId("usr", {
108
+ const users = createTimestampId("usr", {
109
109
  now: () => new Date("2026-01-01T00:00:00Z").getTime(),
110
110
  rng: (target) => {}, // leave target as zero-filled
111
111
  });
@@ -113,14 +113,14 @@ const users = createId("usr", {
113
113
  users.generate(); // deterministic snapshot-friendly output
114
114
  ```
115
115
 
116
- Both `Options` fields are optional. Defaults are `Date.now` and an entropy harvester built on `crypto.randomUUID` (faster than `crypto.getRandomValues` for the 10-byte fills this library needs). `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.
116
+ Both injection fields (`now?` and `rng?`) are optional. Defaults are `Date.now` and an entropy harvester built on `crypto.randomUUID` (faster than `crypto.getRandomValues` for the 10-byte fills this library needs). `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.
117
117
 
118
118
  ### "Catch a double-registered brand before it bites in production"
119
119
 
120
- The intended pattern is one codec per brand per process, constructed at module init. Calling `createId(brand)` a second time for the same brand usually means a bundling or import bug (accidental re-export, a test re-importing without resetting). In development (`process.env.NODE_ENV !== "production"`), the second call emits a one-shot `console.warn`; the brand-tracking registry is skipped in production. The same registry covers cross-codec collisions: `createId("usr")` followed by `createOpaqueId("usr")` warns too, because codec choice is a per-brand commitment ([ADR-0007](./docs/adr/0007-wire-indistinguishable-codec-variants.md)). Tests that intentionally re-create codecs can opt out:
120
+ The intended pattern is one codec per brand per process, constructed at module init. Calling `createTimestampId(brand)` a second time for the same brand usually means a bundling or import bug (accidental re-export, a test re-importing without resetting). In development (`process.env.NODE_ENV !== "production"`), the second call emits a one-shot `console.warn`; the brand-tracking registry is skipped in production. The same registry covers cross-codec collisions: `createTimestampId("usr")` followed by `createOpaqueTimestampId("usr")` warns too, because codec choice is a per-brand commitment ([ADR-0007](./docs/adr/0007-wire-indistinguishable-codec-variants.md)). Tests that intentionally re-create codecs can opt out:
121
121
 
122
122
  ```ts
123
- const users = createId("usr", { allowDuplicateBrand: true });
123
+ const users = createTimestampId("usr", { allowDuplicateBrand: true });
124
124
  ```
125
125
 
126
126
  The check is a heuristic, not a guarantee. Two physical copies of `@smonn/ids` loaded into the same process (the worst-case bundling bug) each keep their own registry, so neither warns — it catches re-imports of a single module copy, not duplicate copies of the module itself.
@@ -166,13 +166,13 @@ The `pattern` describes the **canonical form only** — it matches `generate()`
166
166
 
167
167
  ### "Don't leak creation time in IDs that customers can see"
168
168
 
169
- The Timestamp codec exposes the creation timestamp by design — that's what makes `ORDER BY id` work. If that's a leak you can't accept (invoice IDs revealing billing cadence, signup IDs revealing acquisition velocity), use the Opaque codec at `@smonn/ids/opaque`. Same `<brand>_<26 chars>` wire shape, but the payload is AES-encrypted under a key you supply.
169
+ The Timestamp codec exposes the creation timestamp by design — that's what makes `ORDER BY id` work. If that's a leak you can't accept (invoice IDs revealing billing cadence, signup IDs revealing acquisition velocity), use the Opaque Timestamp codec at `@smonn/ids/opaque`. Same `<brand>_<26 chars>` wire shape, but the payload is AES-encrypted under a key you supply.
170
170
 
171
171
  ```ts
172
- import { createOpaqueId, importOpaqueKey } from "@smonn/ids/opaque";
172
+ import { createOpaqueTimestampId, importOpaqueKey } from "@smonn/ids/opaque";
173
173
 
174
174
  const key = await importOpaqueKey(new Uint8Array(16)); // 128- or 256-bit raw key
175
- const invoices = createOpaqueId("inv", { key });
175
+ const invoices = createOpaqueTimestampId("inv", { key });
176
176
 
177
177
  const id = await invoices.generate(); // "inv_…", timestamp not extractable without the key
178
178
  await invoices.extractTimestamp(id); // Date — same codec, same key required
@@ -181,7 +181,7 @@ await invoices.extractTimestamp(id); // Date — same codec, same key required
181
181
  Three differences from the Timestamp codec:
182
182
 
183
183
  - **Async key-dependent methods.** WebCrypto is async-only, so `generate`, `generateAt`, and `extractTimestamp` return `Promise`s. `is`, `parse`, `safeParse`, `toJsonSchema`, and the Standard Schema adapter stay sync — they work on the wire form only ([ADR-0006](./docs/adr/0006-async-keyed-codec-contract.md)).
184
- - **No `minIdForTime` / `maxIdForTime`.** Encrypted payloads don't sort by time. If you need time-range scans on Opaque-coded entities, store the timestamp in a separate column.
184
+ - **No `minIdForTime` / `maxIdForTime`.** Encrypted payloads don't sort by time. If you need time-range scans on entities using the Opaque Timestamp codec, store the timestamp in a separate column.
185
185
  - **Wire-indistinguishable from the Timestamp codec.** Codec choice is a per-brand commitment; the brand registry warns if you register the same brand against both in dev ([ADR-0007](./docs/adr/0007-wire-indistinguishable-codec-variants.md)).
186
186
 
187
187
  Encryption is AES-CBC with a zero IV. That's deliberately safe here because the plaintext already carries 80 bits of entropy per ID; see [ADR-0004](./docs/adr/0004-aes-cbc-strip-trick.md) for the full rationale.
@@ -193,45 +193,45 @@ To store or transport key material outside the library, `encodeOpaqueKey` / `dec
193
193
  - **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.
194
194
  - **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.
195
195
  - **Distributed-trace / request-correlation IDs.** Use OpenTelemetry-format IDs.
196
- - **Hiding creation time with the Timestamp codec.** Anyone with one ID at a known creation time can compute the epoch offset. A custom epoch wouldn't help and isn't supported. To hide creation time per-ID, use the Opaque codec (above).
196
+ - **Hiding creation time with the Timestamp codec.** Anyone with one ID at a known creation time can compute the epoch offset. A custom epoch wouldn't help and isn't supported. To hide creation time per-ID, use the Opaque Timestamp codec (above).
197
197
 
198
198
  ## API surface
199
199
 
200
200
  ```ts
201
201
  import {
202
- createId, // (brand: string, opts?: Partial<Options>) => Codec<Brand>
202
+ createTimestampId, // (brand: string, opts?: TimestampOptions) => TimestampCodec<Brand>
203
203
  type Id, // branded string type
204
- type Codec, // returned by createId
205
- type Options, // { now, rng, allowDuplicateBrand } injection points
204
+ type TimestampCodec, // returned by createTimestampId
205
+ type TimestampOptions, // { now?, rng?, allowDuplicateBrand? } constructor options
206
206
  type ParseError, // "not_string" | "invalid_prefix" | "invalid_base32"
207
207
  type ParseResult, // safeParse return type
208
208
  type JsonSchema, // toJsonSchema return type
209
209
  } from "@smonn/ids";
210
210
 
211
211
  import {
212
- createOpaqueId, // (brand: string, opts: { key, now?, rng?, allowDuplicateBrand? }) => OpaqueCodec<Brand>
212
+ createOpaqueTimestampId, // (brand: string, opts: OpaqueTimestampOptions) => OpaqueTimestampCodec<Brand>
213
213
  importOpaqueKey, // (bytes: Uint8Array) => Promise<CryptoKey>
214
214
  encodeOpaqueKey, // (bytes: Uint8Array, format: OpaqueKeyFormat) => string
215
215
  decodeOpaqueKey, // (encoded: string, format: OpaqueKeyFormat) => Uint8Array
216
- type OpaqueCodec, // returned by createOpaqueId
217
- type OpaqueOptions, // { key, now, rng, allowDuplicateBrand } injection points
216
+ type OpaqueTimestampCodec, // returned by createOpaqueTimestampId
217
+ type OpaqueTimestampOptions, // { key, now?, rng?, allowDuplicateBrand? } constructor options
218
218
  type OpaqueKeyFormat, // "hex" | "base64url"
219
219
  } from "@smonn/ids/opaque";
220
220
  ```
221
221
 
222
222
  ### Codec methods
223
223
 
224
- | Method | `Codec<Brand>` | `OpaqueCodec<Brand>` | Description |
225
- | ---------------------- | -------------- | -------------------- | ----------------------------------------------------------------------------- |
226
- | `generate()` | sync | async | Produce a fresh ID |
227
- | `generateAt(date)` | sync | async | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
228
- | `is(value)` | sync | sync | Strict type guard: `true` only for already-canonical strings |
229
- | `parse(value)` | sync | sync | Lenient: normalise to canonical, or throw |
230
- | `safeParse(value)` | sync | sync | Lenient: normalise to canonical, or return `{ ok: false, error }` |
231
- | `extractTimestamp(id)` | sync | async | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
232
- | `minIdForTime(date)` | sync | — | Tight lower bound for any ID generated at `date` (for range queries) |
233
- | `maxIdForTime(date)` | sync | — | Tight upper bound for any ID generated at `date` (for range queries) |
234
- | `toJsonSchema()` | sync | sync | JSON Schema (`type`/`pattern`/`description`/`example`) for the canonical form |
224
+ | Method | `TimestampCodec<Brand>` | `OpaqueTimestampCodec<Brand>` | Description |
225
+ | ---------------------- | ----------------------- | ----------------------------- | ----------------------------------------------------------------------------- |
226
+ | `generate()` | sync | async | Produce a fresh ID |
227
+ | `generateAt(date)` | sync | async | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
228
+ | `is(value)` | sync | sync | Strict type guard: `true` only for already-canonical strings |
229
+ | `parse(value)` | sync | sync | Lenient: normalise to canonical, or throw |
230
+ | `safeParse(value)` | sync | sync | Lenient: normalise to canonical, or return `{ ok: false, error }` |
231
+ | `extractTimestamp(id)` | sync | async | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
232
+ | `minIdForTime(date)` | sync | — | Tight lower bound for any ID generated at `date` (for range queries) |
233
+ | `maxIdForTime(date)` | sync | — | Tight upper bound for any ID generated at `date` (for range queries) |
234
+ | `toJsonSchema()` | sync | sync | JSON Schema (`type`/`pattern`/`description`/`example`) for the canonical form |
235
235
 
236
236
  ## CLI
237
237
 
@@ -249,7 +249,7 @@ canonical: usr_01h7b3k9rqxn1cw3p9r8t2sgkz
249
249
  input: canonical
250
250
  ```
251
251
 
252
- Accepts non-canonical input (uppercase, Crockford aliases). Assumes the **Timestamp codec** — if the brand uses the **Opaque codec**, pass `--opaque` and set `IDS_KEY` (below); otherwise the timestamp line is meaningless garbage.
252
+ Accepts non-canonical input (uppercase, Crockford aliases). Assumes the **Timestamp codec** — if the brand uses the **Opaque Timestamp codec**, pass `--opaque` and set `IDS_KEY` (below); otherwise the timestamp line is meaningless garbage.
253
253
 
254
254
  ```bash
255
255
  IDS_KEY=<hex-or-base64url-key> npx @smonn/ids inspect inv_… --opaque
@@ -268,7 +268,7 @@ usr_…
268
268
  usr_…
269
269
  ```
270
270
 
271
- Flags: `--count` / `-c N` (default 1). Uses the Timestamp codec unless `--opaque` is set.
271
+ Flags: `--count` / `-c N` (default 1, max 10000). Uses the Timestamp codec unless `--opaque` is set.
272
272
 
273
273
  ```bash
274
274
  IDS_KEY=<hex-or-base64url-key> npx @smonn/ids generate inv --opaque --count 2