@smonn/ids 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +36 -44
  2. package/dist/{adapter-types-CIc-4O-P.d.mts → adapter-types-Bia_w9sg.d.mts} +2 -2
  3. package/dist/{adapter-types-CIc-4O-P.d.mts.map → adapter-types-Bia_w9sg.d.mts.map} +1 -1
  4. package/dist/cli.mjs +165 -92
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/{codec-shell-DvrTDa65.mjs → codec-shell-BRZkuQeP.mjs} +93 -7
  7. package/dist/codec-shell-BRZkuQeP.mjs.map +1 -0
  8. package/dist/{digest-Drnof-l_.mjs → digest-CLJEGBxo.mjs} +7 -4
  9. package/dist/{digest-Drnof-l_.mjs.map → digest-CLJEGBxo.mjs.map} +1 -1
  10. package/dist/digest.d.mts +19 -2
  11. package/dist/digest.d.mts.map +1 -1
  12. package/dist/digest.mjs +1 -1
  13. package/dist/drizzle.d.mts +3 -3
  14. package/dist/{error-Dqyho9vp.d.mts → error-CifcKKOG.d.mts} +2 -2
  15. package/dist/{error-Dqyho9vp.d.mts.map → error-CifcKKOG.d.mts.map} +1 -1
  16. package/dist/express.d.mts +2 -2
  17. package/dist/fastify.d.mts +2 -2
  18. package/dist/graphql.d.mts +2 -2
  19. package/dist/hono.d.mts +2 -2
  20. package/dist/index.d.mts +19 -2
  21. package/dist/index.d.mts.map +1 -1
  22. package/dist/index.mjs +1 -1
  23. package/dist/{key-material-DsukgnR5.mjs → key-material-1wOKJ1o-.mjs} +2 -2
  24. package/dist/key-material-1wOKJ1o-.mjs.map +1 -0
  25. package/dist/kysely.d.mts +3 -3
  26. package/dist/mikro-orm.d.mts +3 -3
  27. package/dist/nestjs.d.mts +2 -2
  28. package/dist/{opaque-D7y5cgzT.mjs → opaque-COAcIIY4.mjs} +14 -5
  29. package/dist/opaque-COAcIIY4.mjs.map +1 -0
  30. package/dist/opaque.d.mts +26 -2
  31. package/dist/opaque.d.mts.map +1 -1
  32. package/dist/opaque.mjs +1 -1
  33. package/dist/prisma.d.mts +3 -3
  34. package/dist/{reverse-DrAofYWV.mjs → reverse-CT-El3hi.mjs} +7 -4
  35. package/dist/{reverse-DrAofYWV.mjs.map → reverse-CT-El3hi.mjs.map} +1 -1
  36. package/dist/reverse.d.mts +19 -2
  37. package/dist/reverse.d.mts.map +1 -1
  38. package/dist/reverse.mjs +1 -1
  39. package/dist/{rng-Clos6uC0.mjs → rng-6GyNT4zS.mjs} +2 -2
  40. package/dist/{rng-Clos6uC0.mjs.map → rng-6GyNT4zS.mjs.map} +1 -1
  41. package/dist/{signed-B2Aa3zMg.mjs → signed-Dkdteu1y.mjs} +8 -5
  42. package/dist/{signed-B2Aa3zMg.mjs.map → signed-Dkdteu1y.mjs.map} +1 -1
  43. package/dist/signed.d.mts +19 -2
  44. package/dist/signed.d.mts.map +1 -1
  45. package/dist/signed.mjs +1 -1
  46. package/dist/{timestamp-YPd58344.mjs → timestamp-RXXwHfHO.mjs} +7 -4
  47. package/dist/{timestamp-YPd58344.mjs.map → timestamp-RXXwHfHO.mjs.map} +1 -1
  48. package/dist/typeorm.d.mts +2 -2
  49. package/dist/{types-wplmOgOK.d.mts → types-hGBnCpJj.d.mts} +3 -3
  50. package/dist/{types-wplmOgOK.d.mts.map → types-hGBnCpJj.d.mts.map} +1 -1
  51. package/dist/{wrapped-BjmVzuYc.mjs → wrapped-Oj2hC1vB.mjs} +15 -4
  52. package/dist/wrapped-Oj2hC1vB.mjs.map +1 -0
  53. package/dist/wrapped.d.mts +27 -2
  54. package/dist/wrapped.d.mts.map +1 -1
  55. package/dist/wrapped.mjs +1 -1
  56. package/package.json +1 -1
  57. package/dist/codec-shell-DvrTDa65.mjs.map +0 -1
  58. package/dist/key-material-DsukgnR5.mjs.map +0 -1
  59. package/dist/opaque-D7y5cgzT.mjs.map +0 -1
  60. package/dist/wrapped-BjmVzuYc.mjs.map +0 -1
package/README.md CHANGED
@@ -8,10 +8,7 @@ Public-facing branded IDs for TypeScript apps. Type-safe, sortable, and codec-pl
8
8
  pnpm add @smonn/ids
9
9
  ```
10
10
 
11
- Each ID looks like `usr_01h7b3k9rqxn4cw3p9r8t2sgkw`: a three-letter brand, an
12
- underscore, then 26 Crockford base32 characters of payload. The default
13
- Timestamp codec encodes a 48-bit millisecond Unix timestamp followed by 80
14
- random bits — the same byte layout as a [ULID](https://github.com/ulid/spec).
11
+ Each ID looks like `usr_06f80z92d2dbsqqg28t5cy4tqg`: a three-letter brand, an underscore, then 26 Crockford base32 characters of payload. The default Timestamp codec encodes a 48-bit millisecond Unix timestamp followed by 80 random bits — the same byte layout as a [ULID](https://github.com/ulid/spec).
15
12
 
16
13
  ## Quickstart
17
14
 
@@ -21,7 +18,7 @@ import { type Id, createTimestampId } from "@smonn/ids";
21
18
  const users = createTimestampId("usr");
22
19
 
23
20
  // Generate — sortable by creation time via ORDER BY id
24
- const id = users.generate(); // "usr_01h7b3k9rqxn4cw3p9r8t2sgkw"
21
+ const id = users.generate(); // "usr_06f80z92d2dbsqqg28t5cy4tqg"
25
22
 
26
23
  // Branded: Id<"usr"> and Id<"org"> are not interchangeable
27
24
  function loadUser(id: Id<"usr">) {
@@ -29,35 +26,28 @@ function loadUser(id: Id<"usr">) {
29
26
  }
30
27
 
31
28
  // Validate untrusted input — lenient in, canonical out
32
- const r = users.safeParse("USR_01H7B3K9RQXN1CW3P9R8T2SGKW");
29
+ const r = users.safeParse("USR_06F80Z92D2DBSQQG28T5CY4TQG");
33
30
  if (r.ok) {
34
- r.id; // "usr_01h7b3k9rqxn1cw3p9r8t2sgkw" as Id<"usr">
31
+ r.id; // "usr_06f80z92d2dbsqqg28t5cy4tqg" as Id<"usr">
35
32
  }
36
33
  ```
37
34
 
38
- `safeParse` accepts mixed case and the Crockford visual aliases (`o → 0`,
39
- `i → 1`, `l → 1`) and always returns the canonical lowercase form. See the
40
- [Timestamp codec guide](https://ids.smonn.se/codecs/timestamp/) for sorting,
41
- backfills (`generateAt`), range queries, structured errors, Standard Schema, and
42
- JSON Schema.
35
+ `safeParse` accepts mixed case and the Crockford visual aliases (`o → 0`, `i → 1`, `l → 1`) and always returns the canonical lowercase form. See the [Timestamp codec guide](https://ids.smonn.se/codecs/timestamp/) for sorting, backfills (`generateAt`), range queries, structured errors, Standard Schema, and JSON Schema.
43
36
 
44
37
  ## Choosing a codec
45
38
 
46
- All six codecs share the same `<brand>_<26 chars>` wire shape but make different
47
- trade-offs. They are wire-indistinguishable — `safeParse`, `is`, and `parse`
48
- cannot distinguish an Opaque Timestamp ID from a Timestamp ID at runtime.
49
- Cross-codec confusion is undetectable by the library; the consumer is
50
- responsible for routing a given ID to the correct codec for the brand. Codec
51
- choice is therefore a per-brand commitment.
52
-
53
- | Codec | Import | Sort direction | Key required | Timestamp extractable |
54
- | ----------------- | -------------------- | ------------------------- | ------------------ | -------------------------- |
55
- | Timestamp | `@smonn/ids` | Ascending (oldest-first) | No | Always (plaintext) |
56
- | Reverse Timestamp | `@smonn/ids/reverse` | Descending (newest-first) | No | Always (plaintext) |
57
- | Signed Timestamp | `@smonn/ids/signed` | Ascending (oldest-first) | Yes (signing key) | Always (plaintext) |
58
- | Opaque Timestamp | `@smonn/ids/opaque` | None (encrypted) | Yes (AES key) | With key only |
59
- | Wrapped key | `@smonn/ids/wrapped` | None | Yes (wrapping key) | N/A — not timestamp-family |
60
- | Digest | `@smonn/ids/digest` | None | Yes (digest key) | N/A — not timestamp-family |
39
+ All six codecs share the same `<brand>_<26 chars>` wire shape but make different trade-offs. They are wire-indistinguishable — `safeParse`, `is`, and `parse` cannot distinguish an Opaque Timestamp ID from a Timestamp ID at runtime. Cross-codec confusion is undetectable by the library; the consumer is responsible for routing a given ID to the correct codec for the brand. Codec choice is therefore a per-brand commitment.
40
+
41
+ | Codec | Import | Sort direction | Key required | Timestamp extractable |
42
+ | --- | --- | --- | --- | --- |
43
+ | Timestamp | `@smonn/ids` | Ascending (oldest-first) | No | Always (plaintext) |
44
+ | Reverse Timestamp | `@smonn/ids/reverse` | Descending (newest-first) | No | Always (plaintext) |
45
+ | Signed Timestamp | `@smonn/ids/signed` | Ascending (oldest-first) | Yes (signing key) | Always (plaintext) |
46
+ | Opaque Timestamp | `@smonn/ids/opaque` | None (encrypted) | Yes (AES key) | With key only |
47
+ | Wrapped key | `@smonn/ids/wrapped` | None | Yes (wrapping key) | N/A — not timestamp-family |
48
+ | Digest | `@smonn/ids/digest` | None | Yes (digest key) | N/A — not timestamp-family |
49
+
50
+ The Timestamp codec is the default and ships from the root `@smonn/ids` entry — it has no `/timestamp` subpath by design. If you try `import ... from "@smonn/ids/timestamp"` you will get a module-resolution error; use `@smonn/ids` directly. Every other codec uses a named subpath (`/reverse`, `/signed`, `/opaque`, `/wrapped`, `/digest`); this asymmetry is intentional and permanent.
61
51
 
62
52
  - **Newest-first scans** on forward-only KV stores → [Reverse Timestamp](https://ids.smonn.se/codecs/reverse/)
63
53
  - **Tamper-evident share links** verified without a DB lookup → [Signed Timestamp](https://ids.smonn.se/codecs/signed/) (integrity)
@@ -69,40 +59,33 @@ Try them all live in the [playground](https://ids.smonn.se/playground/).
69
59
 
70
60
  ## Integrations
71
61
 
72
- Framework and ORM adapters ship as optional subpath exports (each requires its
73
- own peer dependency):
62
+ Framework and ORM adapters ship as optional subpath exports (each requires its own peer dependency):
74
63
 
75
64
  - **HTTP route params:** [Hono](https://ids.smonn.se/adapters/hono/), [Express](https://ids.smonn.se/adapters/express/), [Fastify](https://ids.smonn.se/adapters/fastify/) — `idParam` middleware; [NestJS](https://ids.smonn.se/adapters/nestjs/) — `ParseIdPipe`
76
65
  - **ORM columns:** [Drizzle](https://ids.smonn.se/adapters/drizzle/) — `idColumn`, [Kysely](https://ids.smonn.se/adapters/kysely/) — `idColumn`, [MikroORM](https://ids.smonn.se/adapters/mikro-orm/) — `idType`, [Prisma](https://ids.smonn.se/adapters/prisma/) — `idField`, [TypeORM](https://ids.smonn.se/adapters/typeorm/) — `idTransformer`
77
66
  - **GraphQL:** [GraphQL](https://ids.smonn.se/adapters/graphql/) — `idScalar` custom scalar
78
67
  - **CLI:** brand-agnostic `inspect` / `generate` / `keygen` — `npx @smonn/ids --help` ([docs](https://ids.smonn.se/cli/))
79
68
 
80
- Every codec also implements [Standard Schema v1](https://standardschema.dev/), so
81
- it slots into Zod, Valibot, ArkType, tRPC, and any validator-aware library.
69
+ Every codec also implements [Standard Schema v1](https://standardschema.dev/), so it slots into Zod, Valibot, ArkType, tRPC, and any validator-aware library.
70
+
71
+ **Native `uuid` column storage:** an `Id<Brand>` can be persisted into a native `uuid` column via `codec.toUUID(id)` and read back as a branded ID via `codec.fromUUID(value)` or `codec.safeFromUUID(value)` — a lossless round-trip useful when migrating off UUID primary keys while keeping existing column types and indexes.
82
72
 
83
73
  ## What this is **not** for
84
74
 
85
- - **Internal surrogate primary keys.** If nobody outside your service sees the
86
- ID, the brand prefix and lenient parsing are dead weight. Use a `bigint`
87
- sequence.
88
- - **Wire-compatible ULIDs.** The byte layout is ULID-shaped, but the encoding is
89
- lowercase and brand-wrapped. Stock ULID parsers will reject these.
75
+ - **Internal surrogate primary keys.** If nobody outside your service sees the ID, the brand prefix and lenient parsing are dead weight. Use a `bigint` sequence.
76
+ - **Wire-compatible ULIDs.** The byte layout is ULID-shaped, but the encoding is lowercase and brand-wrapped. Stock ULID parsers will reject these.
77
+ - **Spec-valid UUIDv7 output.** `toUUID` produces a **raw, unversioned** UUID — all 128 payload bits are preserved verbatim (lossless round-trip), which means the version/variant nibble positions hold real data, not `0x7`/`0b10`. It is **not** a spec-valid UUIDv7. Only the Timestamp codec happens to produce a UUID whose leading 48 bits are a real millisecond timestamp; only Timestamp and Reverse Timestamp produce time-sortable UUIDs. Importing a non-time-ordered UUID (e.g. a UUIDv4) into a timestamp-family codec via `fromUUID` yields a structurally valid `Id<Brand>` with a meaningless timestamp and random sort order — the same wire-indistinguishable contract that already governs codec variants.
90
78
  - **Distributed-trace / request-correlation IDs.** Use OpenTelemetry-format IDs.
91
- - **Hiding creation time with the Timestamp codec.** Anyone with one ID at a
92
- known creation time can compute the epoch offset. Use the Opaque Timestamp
93
- codec to hide creation time per-ID.
79
+ - **Hiding creation time with the Timestamp codec.** Anyone with one ID at a known creation time can compute the epoch offset. Use the Opaque Timestamp codec to hide creation time per-ID.
94
80
 
95
81
  ## API surface
96
82
 
97
- Exports from the main `@smonn/ids` entry point only. Codec-specific subpath
98
- exports (`@smonn/ids/reverse`, `@smonn/ids/opaque`, `@smonn/ids/signed`,
99
- `@smonn/ids/wrapped`, `@smonn/ids/digest`) and adapter subpaths are not listed
100
- here.
83
+ Exports from the main `@smonn/ids` entry point only. Codec-specific subpath exports (`@smonn/ids/reverse`, `@smonn/ids/opaque`, `@smonn/ids/signed`, `@smonn/ids/wrapped`, `@smonn/ids/digest`) and adapter subpaths are not listed here.
101
84
 
102
85
  ### Types
103
86
 
104
87
  - `Id<Brand>` — Canonical branded ID string for `Brand`; produced by `generate()` and `safeParse()`.
105
- - `ParseError` — Parse failure reason string (`"not_string"`, `"invalid_prefix"`, or `"invalid_base32"`) returned by `safeParse()`.
88
+ - `ParseError` — Parse failure reason string returned by `safeParse()` (`"not_string"`, `"invalid_prefix"`, or `"invalid_base32"`) and by `safeFromUUID()` (`"not_string"` or `"invalid_uuid"`).
106
89
  - `ParseResult<Brand>` — Discriminated union returned by `safeParse()`: `{ ok: true; id: Id<Brand> }` or `{ ok: false; error: ParseError }`.
107
90
  - `JsonSchema` — Shape of the object returned by a codec's `toJsonSchema()`.
108
91
  - `IdsErrorCode` — String-literal union of the eleven stable error codes carried by `IdsError`.
@@ -119,9 +102,18 @@ here.
119
102
  - `isIdsError(value)` — Type guard for `IdsError`; uses an internal brand to survive ESM/CJS dual-package duplication where bare `instanceof` fails.
120
103
  - `createTimestampId(brand, options?)` — Creates a Timestamp codec for `brand` (three lowercase `a–z` characters).
121
104
 
105
+ ### Shared codec methods (all variants)
106
+
107
+ Every codec instance exposes the following UUID interop methods in addition to the codec-specific ones documented on the [full docs site](https://ids.smonn.se):
108
+
109
+ - `toUUID(id)` — Takes a trusted `Id<Brand>`, returns the 16-byte payload reinterpreted as a canonical lowercase-hyphenated UUID `string`. Total — cannot fail. The brand is shed; the output is a plain `string`, not a branded type.
110
+ - `fromUUID(value)` — Takes an untrusted `string`, returns `Id<Brand>`. Throws `IdsError` (`code: "invalid_id"`) with a `ParseError` on `cause` (`"invalid_uuid"`, or `"not_string"` for untyped JavaScript callers) on malformed input.
111
+ - `safeFromUUID(value)` — Takes `unknown`, returns `ParseResult<Brand>` (`{ ok: true; id }` or `{ ok: false; error: ParseError }`). Never throws.
112
+
122
113
  ## Links
123
114
 
124
115
  - **[Documentation](https://ids.smonn.se)** — full guides, API reference, and playground
116
+ - **[SPEC.md](./SPEC.md)** — descriptive wire-format specification
125
117
  - **[Design decisions](./docs/adr/)** — recorded ADRs
126
118
  - **[CONTEXT.md](./CONTEXT.md)** — glossary of the project's vocabulary
127
119
  - **[Contributing](./CONTRIBUTING.md)** · **[Security](./SECURITY.md)**
@@ -1,4 +1,4 @@
1
- import { i as ParseResult } from "./types-wplmOgOK.mjs";
1
+ import { i as ParseResult } from "./types-hGBnCpJj.mjs";
2
2
 
3
3
  //#region src/adapters/adapter-types.d.ts
4
4
  /** Discriminated failure value passed to `onError` and emitted to the framework's error handler. */
@@ -17,4 +17,4 @@ type IdCodec<Brand extends string> = {
17
17
  type IdColumnCodec<Brand extends string> = IdCodec<Brand>;
18
18
  //#endregion
19
19
  export { IdColumnCodec as n, IdParamFailure as r, IdCodec as t };
20
- //# sourceMappingURL=adapter-types-CIc-4O-P.d.mts.map
20
+ //# sourceMappingURL=adapter-types-Bia_w9sg.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"adapter-types-CIc-4O-P.d.mts","names":[],"sources":["../src/adapters/adapter-types.ts"],"mappings":";;;;KAIY,cAAA;EAAA,SACG,MAAA;EAAA,SAAmC,MAAA;AAAA;EAAA,SACnC,MAAA;EAAA,SAA8B,MAAA;AAAA;;KAGjC,OAAA;EACV,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;AAAA;AADzC;AAAA,KAKY,aAAA,yBAAsC,OAAA,CAAQ,KAAA"}
1
+ {"version":3,"file":"adapter-types-Bia_w9sg.d.mts","names":[],"sources":["../src/adapters/adapter-types.ts"],"mappings":";;;;KAIY,cAAA;EAAA,SACG,MAAA;EAAA,SAAmC,MAAA;AAAA;EAAA,SACnC,MAAA;EAAA,SAA8B,MAAA;AAAA;;KAGjC,OAAA;EACV,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;AAAA;AADzC;AAAA,KAKY,aAAA,yBAAsC,OAAA,CAAQ,KAAA"}
package/dist/cli.mjs CHANGED
@@ -1,11 +1,81 @@
1
1
  #!/usr/bin/env node
2
2
  import { n as isIdsError } from "./error-Cp5qYZcv.mjs";
3
- import { t as createTimestampId } from "./timestamp-YPd58344.mjs";
4
- import { i as importOpaqueKey, n as decodeOpaqueKey, r as encodeOpaqueKey, t as createOpaqueTimestampId } from "./opaque-D7y5cgzT.mjs";
5
- import { t as createReverseTimestampId } from "./reverse-DrAofYWV.mjs";
6
- import { i as importSigningKey, n as decodeSigningKey, r as encodeSigningKey, t as createSignedTimestampId } from "./signed-B2Aa3zMg.mjs";
7
- import { i as importWrappingKey, n as decodeWrappingKey, r as encodeWrappingKey, t as createWrappedKeyId } from "./wrapped-BjmVzuYc.mjs";
8
- import { i as importDigestKey, n as decodeDigestKey, r as encodeDigestKey, t as createDigestId } from "./digest-Drnof-l_.mjs";
3
+ import { t as createTimestampId } from "./timestamp-RXXwHfHO.mjs";
4
+ import { i as importOpaqueKey, n as decodeOpaqueKey, r as encodeOpaqueKey, t as createOpaqueTimestampId } from "./opaque-COAcIIY4.mjs";
5
+ import { t as createReverseTimestampId } from "./reverse-CT-El3hi.mjs";
6
+ import { i as importSigningKey, n as decodeSigningKey, r as encodeSigningKey, t as createSignedTimestampId } from "./signed-Dkdteu1y.mjs";
7
+ import { i as importWrappingKey, n as decodeWrappingKey, r as encodeWrappingKey, t as createWrappedKeyId } from "./wrapped-Oj2hC1vB.mjs";
8
+ import { i as importDigestKey, n as decodeDigestKey, r as encodeDigestKey, t as createDigestId } from "./digest-CLJEGBxo.mjs";
9
+ //#region src/cli/format.ts
10
+ const invalidIdPrefix = "invalid_id: ";
11
+ function formatCliError(err) {
12
+ return isIdsError(err) ? `${err.code}: ${err.message}` : err instanceof Error ? err.message : String(err);
13
+ }
14
+ function formatWrappedInspectOutput(result) {
15
+ const inputLine = describeInputForm(result.input, result.canonical);
16
+ return [
17
+ `brand: ${result.brand}`,
18
+ `lookup-key: ${result.lookupKey.toString()}`,
19
+ `canonical: ${result.canonical}`,
20
+ `uuid: ${result.uuid}`,
21
+ `input: ${inputLine}`,
22
+ ""
23
+ ].join("\n");
24
+ }
25
+ function formatSignedInspectOutput(result) {
26
+ const relative = formatRelative(result.timestamp.getTime(), result.nowMs);
27
+ const inputLine = describeInputForm(result.input, result.canonical);
28
+ const lines = [`brand: ${result.brand}`, `timestamp: ${result.timestamp.toISOString()} (${relative})`];
29
+ lines.push(`verification: ${result.verification}`);
30
+ lines.push(`canonical: ${result.canonical}`, `uuid: ${result.uuid}`, `input: ${inputLine}`, "");
31
+ return lines.join("\n");
32
+ }
33
+ function formatInspectOutput(result) {
34
+ const relative = formatRelative(result.timestamp.getTime(), result.nowMs);
35
+ const inputLine = describeInputForm(result.input, result.canonical);
36
+ return [
37
+ `brand: ${result.brand}`,
38
+ `timestamp: ${result.timestamp.toISOString()} (${relative})`,
39
+ `canonical: ${result.canonical}`,
40
+ `uuid: ${result.uuid}`,
41
+ `input: ${inputLine}`,
42
+ ""
43
+ ].join("\n");
44
+ }
45
+ function describeInputForm(input, canonical) {
46
+ if (input === canonical) return "canonical";
47
+ const notes = [];
48
+ if (input !== input.toLowerCase()) notes.push("was uppercase");
49
+ if (/[ilo]/i.test(input.slice(4))) notes.push("used Crockford aliases");
50
+ return `not canonical (${notes.join(" + ")})`;
51
+ }
52
+ const msPerMinute = 60 * 1e3;
53
+ const msPerHour = 60 * msPerMinute;
54
+ const msPerDay = 24 * msPerHour;
55
+ const daysPerMonth = 30.44;
56
+ const monthsPerYear = 12;
57
+ function formatRelative(thenMs, nowMs) {
58
+ const diff = nowMs - thenMs;
59
+ const abs = Math.abs(diff);
60
+ const suffix = diff < 0 ? "from now" : "ago";
61
+ const head = headUnits(abs);
62
+ return head === "" ? "just now" : `${head} ${suffix}`;
63
+ }
64
+ function headUnits(abs) {
65
+ if (abs < 6e4) return "";
66
+ if (abs < 36e5) return unit(Math.round(abs / msPerMinute), "minute");
67
+ if (abs < 864e5) return unit(Math.round(abs / msPerHour), "hour");
68
+ if (abs < 864e5 * daysPerMonth) return unit(Math.round(abs / msPerDay), "day");
69
+ const totalMonths = Math.round(abs / (msPerDay * daysPerMonth));
70
+ if (totalMonths < monthsPerYear) return unit(totalMonths, "month");
71
+ const years = Math.floor(totalMonths / monthsPerYear);
72
+ const months = totalMonths % monthsPerYear;
73
+ return months === 0 ? unit(years, "year") : `${unit(years, "year")} ${unit(months, "month")}`;
74
+ }
75
+ function unit(n, name) {
76
+ return `${n} ${n === 1 ? name : `${name}s`}`;
77
+ }
78
+ //#endregion
9
79
  //#region src/cli/key-io.ts
10
80
  function isLoadKeyError(value) {
11
81
  if (typeof value !== "object" || value === null) return false;
@@ -46,7 +116,7 @@ async function loadKey(opts, format, facet) {
46
116
  } catch (err) {
47
117
  return {
48
118
  kind: "import-failure",
49
- message: err.message
119
+ message: formatCliError(err)
50
120
  };
51
121
  }
52
122
  }
@@ -135,7 +205,10 @@ const knownFlags = /* @__PURE__ */ new Set([
135
205
  "--key-format",
136
206
  "--count",
137
207
  "-c",
138
- "--bits"
208
+ "--bits",
209
+ "--uuid",
210
+ "--from-uuid",
211
+ "--brand"
139
212
  ]);
140
213
  function unsupportedFlagForCommand(command, flags, allowed) {
141
214
  for (const flag of flags) if (!allowed.has(flag)) return knownFlags.has(flag) ? `unsupported flag for ${command}: ${flag}` : `unsupported flag: ${flag}`;
@@ -178,77 +251,10 @@ function isNsError(result) {
178
251
  return result === "--ns requires a value";
179
252
  }
180
253
  //#endregion
181
- //#region src/cli/format.ts
182
- function formatCliError(err) {
183
- return isIdsError(err) ? `${err.code}: ${err.message}` : err instanceof Error ? err.message : String(err);
184
- }
185
- function formatWrappedInspectOutput(result) {
186
- const inputLine = describeInputForm(result.input, result.canonical);
187
- return [
188
- `brand: ${result.brand}`,
189
- `lookup-key: ${result.lookupKey.toString()}`,
190
- `canonical: ${result.canonical}`,
191
- `input: ${inputLine}`,
192
- ""
193
- ].join("\n");
194
- }
195
- function formatSignedInspectOutput(result) {
196
- const relative = formatRelative(result.timestamp.getTime(), result.nowMs);
197
- const inputLine = describeInputForm(result.input, result.canonical);
198
- const lines = [`brand: ${result.brand}`, `timestamp: ${result.timestamp.toISOString()} (${relative})`];
199
- lines.push(`verification: ${result.verification}`);
200
- lines.push(`canonical: ${result.canonical}`, `input: ${inputLine}`, "");
201
- return lines.join("\n");
202
- }
203
- function formatInspectOutput(result) {
204
- const relative = formatRelative(result.timestamp.getTime(), result.nowMs);
205
- const inputLine = describeInputForm(result.input, result.canonical);
206
- return [
207
- `brand: ${result.brand}`,
208
- `timestamp: ${result.timestamp.toISOString()} (${relative})`,
209
- `canonical: ${result.canonical}`,
210
- `input: ${inputLine}`,
211
- ""
212
- ].join("\n");
213
- }
214
- function describeInputForm(input, canonical) {
215
- if (input === canonical) return "canonical";
216
- const notes = [];
217
- if (input !== input.toLowerCase()) notes.push("was uppercase");
218
- if (/[ilo]/i.test(input.slice(4))) notes.push("used Crockford aliases");
219
- return `not canonical (${notes.join(" + ")})`;
220
- }
221
- const msPerMinute = 60 * 1e3;
222
- const msPerHour = 60 * msPerMinute;
223
- const msPerDay = 24 * msPerHour;
224
- const daysPerMonth = 30.44;
225
- const monthsPerYear = 12;
226
- function formatRelative(thenMs, nowMs) {
227
- const diff = nowMs - thenMs;
228
- const abs = Math.abs(diff);
229
- const suffix = diff < 0 ? "from now" : "ago";
230
- const head = headUnits(abs);
231
- return head === "" ? "just now" : `${head} ${suffix}`;
232
- }
233
- function headUnits(abs) {
234
- if (abs < 6e4) return "";
235
- if (abs < 36e5) return unit(Math.round(abs / msPerMinute), "minute");
236
- if (abs < 864e5) return unit(Math.round(abs / msPerHour), "hour");
237
- if (abs < 864e5 * daysPerMonth) return unit(Math.round(abs / msPerDay), "day");
238
- const totalMonths = Math.round(abs / (msPerDay * daysPerMonth));
239
- if (totalMonths < monthsPerYear) return unit(totalMonths, "month");
240
- const years = Math.floor(totalMonths / monthsPerYear);
241
- const months = totalMonths % monthsPerYear;
242
- return months === 0 ? unit(years, "year") : `${unit(years, "year")} ${unit(months, "month")}`;
243
- }
244
- function unit(n, name) {
245
- return `${n} ${n === 1 ? name : `${name}s`}`;
246
- }
247
- //#endregion
248
254
  //#region src/cli/variants.ts
249
255
  function standardValidate(codec, input) {
250
256
  const result = codec["~standard"].validate(input);
251
- if (result.issues) return { issue: result.issues[0].message };
257
+ if (result.issues) return { issue: invalidIdPrefix + result.issues[0].message };
252
258
  return { value: result.value };
253
259
  }
254
260
  const timestampVariant = {
@@ -395,6 +401,7 @@ const digestVariant = {
395
401
  });
396
402
  return {
397
403
  safeParse: (v) => codec.safeParse(v),
404
+ toUUID: (id) => codec.toUUID(id),
398
405
  async generate() {
399
406
  const material = await (opts.readStdin ?? (() => Promise.resolve("")))();
400
407
  return codec.digest(material);
@@ -420,7 +427,11 @@ const generatePolicy = {
420
427
  signedVariant,
421
428
  digestVariant
422
429
  ],
423
- intrinsicFlags: ["--count", "-c"]
430
+ intrinsicFlags: [
431
+ "--count",
432
+ "-c",
433
+ "--uuid"
434
+ ]
424
435
  };
425
436
  const inspectPolicy = {
426
437
  default: timestampVariant,
@@ -430,7 +441,7 @@ const inspectPolicy = {
430
441
  opaqueVariant,
431
442
  signedVariant
432
443
  ],
433
- intrinsicFlags: []
444
+ intrinsicFlags: ["--from-uuid", "--brand"]
434
445
  };
435
446
  const keygenPolicy = {
436
447
  default: opaqueVariant,
@@ -492,8 +503,9 @@ async function buildCodec(variant, brand, values, opts) {
492
503
  function usageInspect() {
493
504
  return [
494
505
  "Usage: ids inspect, i <id> [--opaque] [--wrapped --kind u32|i32|u64|i64] [--reverse] [--signed] [--key-format hex|base64url]",
506
+ " ids inspect --from-uuid <uuid> --brand <brand>",
495
507
  "",
496
- " Decode an ID and print brand, timestamp (or lookup key), and canonical form.",
508
+ " Decode an ID and print brand, timestamp (or lookup key), canonical form, and UUID.",
497
509
  " --opaque reads the AES key from IDS_KEY (hex by default; IDS_KEY_FORMAT or --key-format).",
498
510
  " --wrapped reads the wrapping key from IDS_WRAPPING_KEY (hex by default; IDS_WRAPPING_KEY_FORMAT or --key-format).",
499
511
  " --kind is required with --wrapped: u32, i32, u64, or i64.",
@@ -501,12 +513,14 @@ function usageInspect() {
501
513
  " --signed decodes a Signed Timestamp ID; reads signing key from IDS_SIGNING_KEY (hex by default; IDS_SIGNING_KEY_FORMAT or --key-format).",
502
514
  " Without IDS_SIGNING_KEY, --signed prints the timestamp only (no verification). With IDS_SIGNING_KEY, prints verification: ok or failed.",
503
515
  " Note: --digest is not supported for inspect (Digest IDs are one-way; there is no reverse path).",
516
+ " --from-uuid <uuid> converts a UUID back to a canonical Id<Brand>. Requires --brand <brand>.",
517
+ " --brand <brand> specifies the entity type brand for --from-uuid (e.g. usr).",
504
518
  ""
505
519
  ].join("\n");
506
520
  }
507
521
  function usageGenerate() {
508
522
  return [
509
- `Usage: ids generate, g <brand> [--count, -c N] [--opaque] [--reverse] [--signed] [--digest --ns <ns>] [--key-format hex|base64url]`,
523
+ `Usage: ids generate, g <brand> [--count, -c N] [--opaque] [--reverse] [--signed] [--digest --ns <ns>] [--uuid] [--key-format hex|base64url]`,
510
524
  "",
511
525
  ` Mint 1..${maxGenerateCount} canonical IDs for the given brand.`,
512
526
  " --opaque reads the AES key from IDS_KEY (hex by default; IDS_KEY_FORMAT or --key-format).",
@@ -517,6 +531,7 @@ function usageGenerate() {
517
531
  " Reads the digest key from IDS_DIGEST_KEY (hex by default; IDS_DIGEST_KEY_FORMAT or --key-format).",
518
532
  " Same material + ns + key always produces the same ID. Digest IDs are one-way.",
519
533
  " --count N > 1 is rejected: same material always produces the same ID.",
534
+ " --uuid emits the raw UUID form of each generated ID instead of the canonical ID.",
520
535
  ""
521
536
  ].join("\n");
522
537
  }
@@ -537,7 +552,8 @@ function usage() {
537
552
  "",
538
553
  "Subcommands:",
539
554
  " inspect, i <id> [--opaque] [--wrapped --kind u32|i32|u64|i64] [--reverse] [--signed] [--key-format hex|base64url]",
540
- " Decode an ID and print brand, timestamp (or lookup key), and canonical form.",
555
+ " inspect, i --from-uuid <uuid> --brand <brand>",
556
+ " Decode an ID and print brand, timestamp (or lookup key), canonical form, and UUID.",
541
557
  " --opaque reads the AES key from IDS_KEY (hex by default; IDS_KEY_FORMAT or --key-format).",
542
558
  " --wrapped reads the wrapping key from IDS_WRAPPING_KEY (hex by default; IDS_WRAPPING_KEY_FORMAT or --key-format).",
543
559
  " --kind is required with --wrapped: u32, i32, u64, or i64.",
@@ -545,7 +561,9 @@ function usage() {
545
561
  " --signed decodes a Signed Timestamp ID; reads signing key from IDS_SIGNING_KEY (hex by default; IDS_SIGNING_KEY_FORMAT or --key-format).",
546
562
  " Without IDS_SIGNING_KEY, --signed prints the timestamp only (no verification). With IDS_SIGNING_KEY, prints verification: ok or failed.",
547
563
  " Note: --digest is not supported for inspect (Digest IDs are one-way; there is no reverse path).",
548
- " generate, g <brand> [--count, -c N] [--opaque] [--reverse] [--signed] [--digest --ns <ns>] [--key-format hex|base64url]",
564
+ " --from-uuid <uuid> converts a UUID back to a canonical Id<Brand>. Requires --brand <brand>.",
565
+ " --brand <brand> specifies the entity type brand for --from-uuid (e.g. usr).",
566
+ " generate, g <brand> [--count, -c N] [--opaque] [--reverse] [--signed] [--digest --ns <ns>] [--uuid] [--key-format hex|base64url]",
549
567
  ` Mint 1..${maxGenerateCount} canonical IDs for the given brand.`,
550
568
  " --opaque reads the AES key from IDS_KEY (hex by default; IDS_KEY_FORMAT or --key-format).",
551
569
  " --reverse mints Reverse Timestamp IDs (newest-first sort order).",
@@ -555,6 +573,7 @@ function usage() {
555
573
  " Reads the digest key from IDS_DIGEST_KEY (hex by default; IDS_DIGEST_KEY_FORMAT or --key-format).",
556
574
  " Same material + ns + key always produces the same ID. Digest IDs are one-way.",
557
575
  " --count N > 1 is rejected: same material always produces the same ID.",
576
+ " --uuid emits the raw UUID form of each generated ID instead of the canonical ID.",
558
577
  " keygen, k [--wrapped] [--signed] [--digest] [--bits 128|192|256] [--key-format hex|base64url]",
559
578
  " Emit a random key for importOpaqueKey, importWrappingKey, importSigningKey, or importDigestKey (key on stdout; warning on stderr).",
560
579
  " Safe handling: redirect stdout to a 0600 file (e.g. ids keygen > key.hex && chmod 0600 key.hex);",
@@ -634,7 +653,14 @@ async function runGenerate(args, opts) {
634
653
  opts.stderr(codec.message + "\n");
635
654
  return codec.kind === "usage" ? 2 : 1;
636
655
  }
637
- for (let i = 0; i < count; i++) opts.stdout(await codec.generate() + "\n");
656
+ const emitUuid = flags.has("--uuid");
657
+ for (let i = 0; i < count; i++) {
658
+ const id = await codec.generate();
659
+ if (emitUuid) {
660
+ const uuid = codec.toUUID(id);
661
+ opts.stdout(uuid + "\n");
662
+ } else opts.stdout(id + "\n");
663
+ }
638
664
  return 0;
639
665
  }
640
666
  //#endregion
@@ -656,6 +682,32 @@ async function runInspect(args, opts) {
656
682
  opts.stderr(errors[0] + "\n");
657
683
  return 2;
658
684
  }
685
+ const fromUuidValue = values.get("--from-uuid");
686
+ if (fromUuidValue !== void 0) {
687
+ if (fromUuidValue === "") {
688
+ opts.stderr("--from-uuid requires a value\n");
689
+ return 2;
690
+ }
691
+ const brandValue = values.get("--brand");
692
+ if (brandValue === void 0 || brandValue === "") {
693
+ opts.stderr("--from-uuid requires --brand\n");
694
+ return 2;
695
+ }
696
+ let tsCodec;
697
+ try {
698
+ tsCodec = createTimestampId(brandValue, codecOpts(opts));
699
+ } catch (err) {
700
+ opts.stderr(formatCliError(err) + "\n");
701
+ return 1;
702
+ }
703
+ const result = tsCodec.safeFromUUID(fromUuidValue);
704
+ if (!result.ok) {
705
+ opts.stderr("invalid_uuid: not a valid RFC 9562 UUID\n");
706
+ return 1;
707
+ }
708
+ opts.stdout(result.id + "\n");
709
+ return 0;
710
+ }
659
711
  const [input] = positionals;
660
712
  if (input === void 0) {
661
713
  opts.stderr(usageInspect());
@@ -680,6 +732,7 @@ async function runInspect(args, opts) {
680
732
  let verifyTimestamp;
681
733
  let verifyCanonical;
682
734
  let verifyNowMs;
735
+ let verifyTsCodec;
683
736
  if (cap.mode === "verify") {
684
737
  const fmtCheck = parseKeyFormat(values, opts, variant.key);
685
738
  if (isKeyFormatError(fmtCheck)) {
@@ -695,9 +748,10 @@ async function runInspect(args, opts) {
695
748
  }
696
749
  const structValidation = tsCodec["~standard"].validate(input);
697
750
  if (structValidation.issues) {
698
- opts.stderr(structValidation.issues[0].message + "\n");
751
+ opts.stderr(invalidIdPrefix + structValidation.issues[0].message + "\n");
699
752
  return 1;
700
753
  }
754
+ verifyTsCodec = tsCodec;
701
755
  verifyCanonical = structValidation.value;
702
756
  verifyTimestamp = tsCodec.extractTimestamp(verifyCanonical);
703
757
  verifyNowMs = (opts.now ?? Date.now)();
@@ -705,10 +759,12 @@ async function runInspect(args, opts) {
705
759
  const codecOrError = await buildCodec(variant, brand, values, opts);
706
760
  if (isCodecError(codecOrError)) {
707
761
  if (cap.mode === "verify") {
762
+ const uuid = verifyTsCodec.toUUID(verifyCanonical);
708
763
  opts.stdout(formatSignedInspectOutput({
709
764
  brand,
710
765
  timestamp: verifyTimestamp,
711
766
  canonical: verifyCanonical,
767
+ uuid,
712
768
  input,
713
769
  nowMs: verifyNowMs,
714
770
  verification: "unavailable"
@@ -728,15 +784,20 @@ async function runInspect(args, opts) {
728
784
  }
729
785
  canonical = parsed.value;
730
786
  }
787
+ function codecToUUID(id) {
788
+ return codecOrError.toUUID(id);
789
+ }
731
790
  switch (cap.mode) {
732
791
  case "readable": {
733
792
  const timestamp = cap.extractTimestamp(codecOrError, canonical);
734
793
  const nowMs = (opts.now ?? Date.now)();
794
+ const uuid = codecToUUID(canonical);
735
795
  opts.stderr(cap.note + "\n");
736
796
  opts.stdout(formatInspectOutput({
737
797
  brand,
738
798
  timestamp,
739
799
  canonical,
800
+ uuid,
740
801
  input,
741
802
  nowMs
742
803
  }));
@@ -745,11 +806,13 @@ async function runInspect(args, opts) {
745
806
  case "keyed-readable": {
746
807
  const timestamp = await cap.extractTimestamp(codecOrError, canonical);
747
808
  const nowMs = (opts.now ?? Date.now)();
809
+ const uuid = codecToUUID(canonical);
748
810
  opts.stderr(cap.note + "\n");
749
811
  opts.stdout(formatInspectOutput({
750
812
  brand,
751
813
  timestamp,
752
814
  canonical,
815
+ uuid,
753
816
  input,
754
817
  nowMs
755
818
  }));
@@ -763,15 +826,18 @@ async function runInspect(args, opts) {
763
826
  opts.stderr(formatCliError(err) + "\n");
764
827
  return 1;
765
828
  }
829
+ const uuid = codecToUUID(canonical);
766
830
  opts.stdout(formatWrappedInspectOutput({
767
831
  brand,
768
832
  lookupKey,
769
833
  canonical,
834
+ uuid,
770
835
  input
771
836
  }));
772
837
  return 0;
773
838
  }
774
839
  case "verify": {
840
+ const uuid = verifyTsCodec.toUUID(verifyCanonical);
775
841
  const verifyResult = await cap.safeVerify(codecOrError, input);
776
842
  if (!verifyResult.ok) {
777
843
  /* v8 ignore next 4 -- defensive: both codecs share the same wire parse so ParseError
@@ -784,6 +850,7 @@ async function runInspect(args, opts) {
784
850
  brand,
785
851
  timestamp: verifyTimestamp,
786
852
  canonical: verifyCanonical,
853
+ uuid,
787
854
  input,
788
855
  nowMs: verifyNowMs,
789
856
  verification: "failed"
@@ -795,6 +862,7 @@ async function runInspect(args, opts) {
795
862
  brand,
796
863
  timestamp: verifyTimestamp,
797
864
  canonical: verifyResult.id,
865
+ uuid,
798
866
  input,
799
867
  nowMs: verifyNowMs,
800
868
  verification: "ok"
@@ -876,15 +944,20 @@ const commands = [
876
944
  }
877
945
  ];
878
946
  async function run(opts) {
879
- const [subcommand, ...rest] = opts.argv;
880
- const command = commands.find((candidate) => candidate.names.includes(subcommand ?? ""));
881
- if (command !== void 0) return command.run(rest, opts);
882
- if (subcommand === void 0 || subcommand === "--help" || subcommand === "-h") {
883
- opts.stdout(usage());
884
- return 0;
947
+ try {
948
+ const [subcommand, ...rest] = opts.argv;
949
+ const command = commands.find((candidate) => candidate.names.includes(subcommand ?? ""));
950
+ if (command !== void 0) return await command.run(rest, opts);
951
+ if (subcommand === void 0 || subcommand === "--help" || subcommand === "-h") {
952
+ opts.stdout(usage());
953
+ return 0;
954
+ }
955
+ opts.stderr(usage());
956
+ return 2;
957
+ } catch (err) {
958
+ opts.stderr(formatCliError(err) + "\n");
959
+ return 1;
885
960
  }
886
- opts.stderr(usage());
887
- return 2;
888
961
  }
889
962
  //#endregion
890
963
  //#region bin/cli.ts