@smonn/ids 0.6.0 → 0.7.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 (64) hide show
  1. package/README.md +155 -0
  2. package/dist/adapter-types-oHCCSgOO.d.mts +12 -0
  3. package/dist/adapter-types-oHCCSgOO.d.mts.map +1 -0
  4. package/dist/cli.mjs +72 -68
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/{codec-shell-dWpxoFmy.mjs → codec-shell-DH-UO4UR.mjs} +8 -8
  7. package/dist/codec-shell-DH-UO4UR.mjs.map +1 -0
  8. package/dist/drizzle-CeSni5PB.d.mts.map +1 -1
  9. package/dist/drizzle.d.mts +2 -1
  10. package/dist/drizzle.mjs +3 -2
  11. package/dist/drizzle.mjs.map +1 -1
  12. package/dist/error-Cp5qYZcv.mjs +52 -0
  13. package/dist/error-Cp5qYZcv.mjs.map +1 -0
  14. package/dist/error-DTr4i6Ic.d.mts +44 -0
  15. package/dist/error-DTr4i6Ic.d.mts.map +1 -0
  16. package/dist/express.d.mts +2 -9
  17. package/dist/express.d.mts.map +1 -1
  18. package/dist/express.mjs.map +1 -1
  19. package/dist/fastify.d.mts +88 -0
  20. package/dist/fastify.d.mts.map +1 -0
  21. package/dist/fastify.mjs +91 -0
  22. package/dist/fastify.mjs.map +1 -0
  23. package/dist/hono.d.mts +2 -9
  24. package/dist/hono.d.mts.map +1 -1
  25. package/dist/hono.mjs.map +1 -1
  26. package/dist/index.d.mts +2 -1
  27. package/dist/index.d.mts.map +1 -1
  28. package/dist/index.mjs +3 -2
  29. package/dist/kysely.d.mts +2 -1
  30. package/dist/kysely.d.mts.map +1 -1
  31. package/dist/kysely.mjs +3 -2
  32. package/dist/kysely.mjs.map +1 -1
  33. package/dist/{opaque-goLnFoo7.mjs → opaque-uvjOFY_0.mjs} +9 -8
  34. package/dist/opaque-uvjOFY_0.mjs.map +1 -0
  35. package/dist/opaque.d.mts +2 -1
  36. package/dist/opaque.d.mts.map +1 -1
  37. package/dist/opaque.mjs +3 -2
  38. package/dist/prisma.d.mts +2 -1
  39. package/dist/prisma.d.mts.map +1 -1
  40. package/dist/prisma.mjs +3 -2
  41. package/dist/prisma.mjs.map +1 -1
  42. package/dist/{reverse--n4D2yxu.mjs → reverse-BgFU6JHw.mjs} +3 -3
  43. package/dist/reverse-BgFU6JHw.mjs.map +1 -0
  44. package/dist/reverse.d.mts +2 -1
  45. package/dist/reverse.d.mts.map +1 -1
  46. package/dist/reverse.mjs +3 -2
  47. package/dist/signed.d.mts +56 -0
  48. package/dist/signed.d.mts.map +1 -0
  49. package/dist/signed.mjs +100 -0
  50. package/dist/signed.mjs.map +1 -0
  51. package/dist/{timestamp-Bgzxx8bE.mjs → timestamp-B5_UCzc6.mjs} +3 -3
  52. package/dist/{timestamp-Bgzxx8bE.mjs.map → timestamp-B5_UCzc6.mjs.map} +1 -1
  53. package/dist/{timestamp-bytes-B57RM7Ho.mjs → timestamp-bytes-BBY7JI33.mjs} +2 -2
  54. package/dist/{timestamp-bytes-B57RM7Ho.mjs.map → timestamp-bytes-BBY7JI33.mjs.map} +1 -1
  55. package/dist/{wrapped-Dw5mHQhn.mjs → wrapped-0vL72Nje.mjs} +20 -22
  56. package/dist/wrapped-0vL72Nje.mjs.map +1 -0
  57. package/dist/wrapped.d.mts +5 -3
  58. package/dist/wrapped.d.mts.map +1 -1
  59. package/dist/wrapped.mjs +3 -2
  60. package/package.json +8 -1
  61. package/dist/codec-shell-dWpxoFmy.mjs.map +0 -1
  62. package/dist/opaque-goLnFoo7.mjs.map +0 -1
  63. package/dist/reverse--n4D2yxu.mjs.map +0 -1
  64. package/dist/wrapped-Dw5mHQhn.mjs.map +0 -1
package/README.md CHANGED
@@ -70,6 +70,48 @@ const userId = r.id; // Id<"usr">, canonical
70
70
 
71
71
  `ParseError` is exported as a literal union so the switch is exhaustive at compile time.
72
72
 
73
+ ### "Handle structured errors from this library"
74
+
75
+ `parse()`, `unwrap()`, the ORM adapter read paths, and the codec constructors all throw `IdsError` on failure — a single class with a stable `code` field for programmatic branching. Use `isIdsError()` to safely identify them without depending on `instanceof` across module copies:
76
+
77
+ ```ts
78
+ import { isIdsError } from "@smonn/ids";
79
+
80
+ try {
81
+ users.parse(rawInput);
82
+ } catch (err) {
83
+ if (isIdsError(err)) {
84
+ switch (err.code) {
85
+ case "invalid_id": // parse failed; err.cause is the ParseError string
86
+ return 400;
87
+ case "invalid_brand": // bad codec construction — fix the brand string
88
+ throw err;
89
+ // ...
90
+ }
91
+ }
92
+ throw err;
93
+ }
94
+ ```
95
+
96
+ **Error codes** (`IdsErrorCode` — stable contract; `message` is non-contractual):
97
+
98
+ | `code` | meaning | thrown by | remedy |
99
+ | ------------------------- | ----------------------------------------------- | --------------------------------------------------------- | ------------------------------------------ |
100
+ | `invalid_brand` | brand is not three lowercase `a–z` characters | `create*Id(brand)` construction | Fix the brand string |
101
+ | `invalid_key_format` | format is not `hex` or `base64url` | `decodeOpaqueKey`, `decodeWrappingKey` | Pass `"hex"` or `"base64url"` |
102
+ | `invalid_key_encoding` | key string is malformed for its declared format | `decodeOpaqueKey`, `decodeWrappingKey` | Re-encode the key with the matching format |
103
+ | `invalid_key_length` | raw key is not 16, 24, or 32 bytes | `importOpaqueKey`, `importWrappingKey`, decoder functions | Use a valid AES key size |
104
+ | `invalid_kind` | wrapped kind is not `u32`/`i32`/`u64`/`i64` | `createWrappedKeyId({ kind })` | Use one of the four supported kinds |
105
+ | `empty_keyring` | the wrapping keyring is empty | `createWrappedKeyId({ keys })` | Supply at least one `WrappingKey` |
106
+ | `duplicate_keyring_entry` | two keyring entries share the same raw secret | `createWrappedKeyId({ keys })` | Deduplicate the key list |
107
+ | `invalid_lookup_key` | lookup key is out of range or the wrong JS type | `wrap(lookupKey)` | Check the kind's range and JS type |
108
+ | `verification_failed` | no keyring entry verifies the payload tag | `unwrap(id)` | Check keyring; tamper or wrong key |
109
+ | `invalid_id` | string is not a valid ID for this brand | `parse()`, ORM adapter read paths | Use `safeParse()` for untrusted input |
110
+
111
+ `invalid_id` carries the originating `ParseError` string on `cause` — check `err.cause` for `"not_string"`, `"invalid_prefix"`, or `"invalid_base32"` when you need to distinguish the failure mode.
112
+
113
+ `isIdsError()` uses a non-enumerable brand symbol rather than bare `instanceof`, so it works correctly even when multiple copies of `@smonn/ids` are loaded in the same process (the ESM + CJS dual-package hazard).
114
+
73
115
  ### "Sort and date-stamp records using just the ID"
74
116
 
75
117
  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:
@@ -260,6 +302,71 @@ app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } })
260
302
 
261
303
  The 400 vs 404 defaults are identical to the Hono adapter: `reason: "brand_mismatch"` → 404, `reason: "malformed"` → 400. The canonical `Id<Brand>` is stored in `res.locals` under `paramName` and available to downstream handlers.
262
304
 
305
+ ### "Validate a route param in Fastify"
306
+
307
+ `@smonn/ids/fastify` provides the same `idParam` factory for Fastify. Fastify is an **optional peer dependency**; install it separately alongside `@smonn/ids`.
308
+
309
+ ```bash
310
+ pnpm add fastify
311
+ ```
312
+
313
+ ```ts
314
+ import { idParam, IdParamError } from "@smonn/ids/fastify";
315
+ import { createTimestampId } from "@smonn/ids";
316
+
317
+ const usr = createTimestampId("usr");
318
+
319
+ // Default: throws IdParamError → setErrorHandler renders it
320
+ fastify.get<{ Params: { id: string } }>(
321
+ "/users/:id",
322
+ {
323
+ preHandler: idParam("id", usr),
324
+ },
325
+ (request, reply) => {
326
+ const id = request.params.id; // string; use `as Id<"usr">` if the narrowed type is needed
327
+ // …
328
+ },
329
+ );
330
+
331
+ // Error handler receives the typed error
332
+ fastify.setErrorHandler((err, request, reply) => {
333
+ if (err instanceof IdParamError) {
334
+ reply.status(err.statusCode).send({ error: err.reason });
335
+ return;
336
+ }
337
+ reply.send(err);
338
+ });
339
+
340
+ // Override: consumer fully owns the error response
341
+ fastify.get(
342
+ "/orgs/:id",
343
+ {
344
+ preHandler: idParam("id", org, {
345
+ onError: (failure, request, reply) =>
346
+ reply.status(failure.status).send({ error: failure.reason }),
347
+ }),
348
+ },
349
+ handler,
350
+ );
351
+
352
+ // Or a lightweight status remap without a full handler
353
+ fastify.get(
354
+ "/things/:id",
355
+ {
356
+ preHandler: idParam("id", thing, { status: { brand_mismatch: 400 } }),
357
+ },
358
+ handler,
359
+ );
360
+ ```
361
+
362
+ **Default error-channel behavior:** on failure the adapter throws `IdParamError` carrying `statusCode` and `reason` — it does **not** write a response body itself. Fastify's `setErrorHandler` receives the error and controls rendering exactly as it would for any other error.
363
+
364
+ **`options.onError`:** when provided, the hook owns the response entirely — the adapter does not throw.
365
+
366
+ **`options.status`:** remaps the default HTTP status for a failure reason without requiring a full handler.
367
+
368
+ The 400 vs 404 defaults are identical to the Hono and Express adapters: `reason: "brand_mismatch"` → 404, `reason: "malformed"` → 400. The canonical `Id<Brand>` is stored in `request.params` under `paramName`. Works with any codec variant's structural `safeParse`.
369
+
263
370
  ### "Don't leak creation time in IDs that customers can see"
264
371
 
265
372
  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.
@@ -336,6 +443,9 @@ No key material is required. The inversion is a deterministic byte transform; `g
336
443
 
337
444
  ```ts
338
445
  import {
446
+ IdsError, // class — thrown by caller-reachable failures; carries a stable `code`
447
+ isIdsError, // (value: unknown) => value is IdsError — brand check, survives dual-package
448
+ type IdsErrorCode, // "invalid_brand" | "invalid_key_format" | ... (10 members)
339
449
  createTimestampId, // (brand: string, opts?: TimestampOptions) => TimestampCodec<Brand>
340
450
  type Id, // branded string type
341
451
  type TimestampCodec, // returned by createTimestampId
@@ -346,6 +456,9 @@ import {
346
456
  } from "@smonn/ids";
347
457
 
348
458
  import {
459
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
460
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
461
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
349
462
  createOpaqueTimestampId, // (brand: string, opts: OpaqueTimestampOptions) => OpaqueTimestampCodec<Brand>
350
463
  importOpaqueKey, // (bytes: Uint8Array) => Promise<OpaqueKey>
351
464
  encodeOpaqueKey, // (bytes: Uint8Array, format: OpaqueKeyFormat) => string
@@ -357,12 +470,29 @@ import {
357
470
  } from "@smonn/ids/opaque";
358
471
 
359
472
  import {
473
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
474
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
475
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
360
476
  createReverseTimestampId, // (brand: string, opts?: ReverseTimestampOptions) => ReverseTimestampCodec<Brand>
361
477
  type ReverseTimestampCodec, // returned by createReverseTimestampId
362
478
  type ReverseTimestampOptions, // { now?, rng?, allowDuplicateBrand? } constructor options
363
479
  } from "@smonn/ids/reverse";
364
480
 
365
481
  import {
482
+ importSigningKey, // (bytes: Uint8Array) => Promise<SigningKey>
483
+ encodeSigningKey, // (bytes: Uint8Array, format: SigningKeyFormat) => string
484
+ decodeSigningKey, // (encoded: string, format: SigningKeyFormat) => Uint8Array
485
+ IdsError, // re-exported from @smonn/ids/signed for convenience
486
+ isIdsError, // re-exported from @smonn/ids/signed for convenience
487
+ type SigningKey, // opaque SigningKey handle (HKDF-derived)
488
+ type SigningKeyFormat, // "hex" | "base64url"
489
+ type IdsErrorCode, // re-exported from @smonn/ids/signed for convenience
490
+ } from "@smonn/ids/signed";
491
+
492
+ import {
493
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
494
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
495
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
366
496
  createWrappedKeyId, // (brand: string, opts: { kind: "u32" | "i32" | "u64" | "i64", keys }) => WrappedKeyCodec<Brand, Kind>
367
497
  importWrappingKey, // (bytes: Uint8Array) => Promise<WrappingKey>
368
498
  encodeWrappingKey, // (bytes: Uint8Array, format: WrappingKeyFormat) => string
@@ -373,16 +503,31 @@ import {
373
503
  } from "@smonn/ids/wrapped";
374
504
 
375
505
  import {
506
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
507
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
508
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
376
509
  idColumn, // (codec: IdColumnCodec<Brand>) => PgCustomColumnBuilder (Drizzle column)
377
510
  type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
378
511
  } from "@smonn/ids/drizzle";
379
512
 
380
513
  import {
514
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
515
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
516
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
381
517
  idField, // (codec: IdColumnCodec<Brand>) => IdTransform<Brand> — read/write transforms for Prisma $extends
382
518
  type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
383
519
  type IdTransform, // { read(value: unknown): Id<Brand>; write(value: Id<Brand>): string }
384
520
  } from "@smonn/ids/prisma";
385
521
 
522
+ import {
523
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
524
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
525
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
526
+ idColumn, // (codec: IdColumnCodec<Brand>) => { toDriver, fromDriver }
527
+ type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
528
+ type IdColumnType, // ColumnType<Id<Brand>, Id<Brand>, Id<Brand>>
529
+ } from "@smonn/ids/kysely";
530
+
386
531
  import {
387
532
  idParam, // (paramName: string, codec, options?) => Hono MiddlewareHandler — throws HTTPException by default; onError/status options override
388
533
  type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
@@ -395,6 +540,13 @@ import {
395
540
  type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
396
541
  type IdParamOptions, // { onError?, status? }
397
542
  } from "@smonn/ids/express";
543
+
544
+ import {
545
+ idParam, // (paramName: string, codec, options?) => Fastify preHandler — throws IdParamError by default; onError/status options override
546
+ IdParamError, // Error subclass with .reason and .statusCode — thrown into setErrorHandler by default
547
+ type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
548
+ type IdParamOptions, // { onError?, status? }
549
+ } from "@smonn/ids/fastify";
398
550
  ```
399
551
 
400
552
  `@smonn/ids/wrapped` ships the Wrapped key codec for `u32`, `i32`, `u64`, and `i64` lookup keys. `wrap(lookupKey)` returns a public ID; `unwrap(id)` verifies the payload and returns the lookup key; `safeUnwrap(input)` is the non-throwing path for untrusted input.
@@ -576,6 +728,9 @@ const id = usrCol.fromDriver(row.id as unknown as string);
576
728
 
577
729
  ```ts
578
730
  import {
731
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
732
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
733
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
579
734
  idColumn, // (codec: IdColumnCodec<Brand>) => { toDriver, fromDriver }
580
735
  type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
581
736
  type IdColumnType, // ColumnType<Id<Brand>, Id<Brand>, Id<Brand>>
@@ -0,0 +1,12 @@
1
+ //#region src/adapter-types.d.ts
2
+ /** Discriminated failure value passed to `onError` and emitted to the framework's error handler. */
3
+ type IdParamFailure = {
4
+ readonly reason: "brand_mismatch";
5
+ readonly status: number;
6
+ } | {
7
+ readonly reason: "malformed";
8
+ readonly status: number;
9
+ };
10
+ //#endregion
11
+ export { IdParamFailure as t };
12
+ //# sourceMappingURL=adapter-types-oHCCSgOO.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-types-oHCCSgOO.d.mts","names":[],"sources":["../src/adapter-types.ts"],"mappings":";;KACY,cAAA;EAAA,SACG,MAAA;EAAA,SAAmC,MAAA;AAAA;EAAA,SACnC,MAAA;EAAA,SAA8B,MAAA;AAAA"}
package/dist/cli.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { t as createTimestampId } from "./timestamp-Bgzxx8bE.mjs";
3
- import { i as importOpaqueKey, n as decodeOpaqueKey, r as encodeOpaqueKey, t as createOpaqueTimestampId } from "./opaque-goLnFoo7.mjs";
4
- import { t as createReverseTimestampId } from "./reverse--n4D2yxu.mjs";
5
- import { i as importWrappingKey, n as decodeWrappingKey, r as encodeWrappingKey, t as createWrappedKeyId } from "./wrapped-Dw5mHQhn.mjs";
2
+ import { n as isIdsError } from "./error-Cp5qYZcv.mjs";
3
+ import { t as createTimestampId } from "./timestamp-B5_UCzc6.mjs";
4
+ import { i as importOpaqueKey, n as decodeOpaqueKey, r as encodeOpaqueKey, t as createOpaqueTimestampId } from "./opaque-uvjOFY_0.mjs";
5
+ import { t as createReverseTimestampId } from "./reverse-BgFU6JHw.mjs";
6
+ import { i as importWrappingKey, n as decodeWrappingKey, r as encodeWrappingKey, t as createWrappedKeyId } from "./wrapped-0vL72Nje.mjs";
6
7
  //#region src/cli/codec-options.ts
7
8
  function codecOpts(opts) {
8
9
  const o = { allowDuplicateBrand: true };
@@ -11,6 +12,65 @@ function codecOpts(opts) {
11
12
  return o;
12
13
  }
13
14
  //#endregion
15
+ //#region src/cli/format.ts
16
+ function formatCliError(err) {
17
+ return isIdsError(err) ? `${err.code}: ${err.message}` : err instanceof Error ? err.message : String(err);
18
+ }
19
+ function formatWrappedInspectOutput(result) {
20
+ const inputLine = describeInputForm(result.input, result.canonical);
21
+ return [
22
+ `brand: ${result.brand}`,
23
+ `lookup-key: ${result.lookupKey.toString()}`,
24
+ `canonical: ${result.canonical}`,
25
+ `input: ${inputLine}`,
26
+ ""
27
+ ].join("\n");
28
+ }
29
+ function formatInspectOutput(result) {
30
+ const relative = formatRelative(result.timestamp.getTime(), result.nowMs);
31
+ const inputLine = describeInputForm(result.input, result.canonical);
32
+ return [
33
+ `brand: ${result.brand}`,
34
+ `timestamp: ${result.timestamp.toISOString()} (${relative})`,
35
+ `canonical: ${result.canonical}`,
36
+ `input: ${inputLine}`,
37
+ ""
38
+ ].join("\n");
39
+ }
40
+ function describeInputForm(input, canonical) {
41
+ if (input === canonical) return "canonical";
42
+ const notes = [];
43
+ if (input !== input.toLowerCase()) notes.push("was uppercase");
44
+ if (/[ilo]/i.test(input.slice(4))) notes.push("used Crockford aliases");
45
+ return `not canonical (${notes.join(" + ")})`;
46
+ }
47
+ const msPerMinute = 60 * 1e3;
48
+ const msPerHour = 60 * msPerMinute;
49
+ const msPerDay = 24 * msPerHour;
50
+ const daysPerMonth = 30.44;
51
+ const monthsPerYear = 12;
52
+ function formatRelative(thenMs, nowMs) {
53
+ const diff = nowMs - thenMs;
54
+ const abs = Math.abs(diff);
55
+ const suffix = diff < 0 ? "from now" : "ago";
56
+ const head = headUnits(abs);
57
+ return head === "" ? "just now" : `${head} ${suffix}`;
58
+ }
59
+ function headUnits(abs) {
60
+ if (abs < msPerMinute) return "";
61
+ if (abs < msPerHour) return unit(Math.round(abs / msPerMinute), "minute");
62
+ if (abs < msPerDay) return unit(Math.round(abs / msPerHour), "hour");
63
+ if (abs < msPerDay * daysPerMonth) return unit(Math.round(abs / msPerDay), "day");
64
+ const totalMonths = Math.round(abs / (msPerDay * daysPerMonth));
65
+ if (totalMonths < monthsPerYear) return unit(totalMonths, "month");
66
+ const years = Math.floor(totalMonths / monthsPerYear);
67
+ const months = totalMonths % monthsPerYear;
68
+ return months === 0 ? unit(years, "year") : `${unit(years, "year")} ${unit(months, "month")}`;
69
+ }
70
+ function unit(n, name) {
71
+ return `${n} ${n === 1 ? name : `${name}s`}`;
72
+ }
73
+ //#endregion
14
74
  //#region src/cli/constants.ts
15
75
  const maxGenerateCount = 1e4;
16
76
  //#endregion
@@ -215,7 +275,7 @@ function runGenerate(args, opts) {
215
275
  try {
216
276
  codec = createReverseTimestampId(brand ?? "", codecOpts(opts));
217
277
  } catch (err) {
218
- opts.stderr(err.message + "\n");
278
+ opts.stderr(formatCliError(err) + "\n");
219
279
  return Promise.resolve(1);
220
280
  }
221
281
  for (let i = 0; i < count; i++) opts.stdout(codec.generate() + "\n");
@@ -225,7 +285,7 @@ function runGenerate(args, opts) {
225
285
  try {
226
286
  codec = createTimestampId(brand ?? "", codecOpts(opts));
227
287
  } catch (err) {
228
- opts.stderr(err.message + "\n");
288
+ opts.stderr(formatCliError(err) + "\n");
229
289
  return Promise.resolve(1);
230
290
  }
231
291
  for (let i = 0; i < count; i++) opts.stdout(codec.generate() + "\n");
@@ -244,69 +304,13 @@ async function runOpaqueGenerate(brand, count, format, opts) {
244
304
  ...codecOpts(opts)
245
305
  });
246
306
  } catch (err) {
247
- opts.stderr(err.message + "\n");
307
+ opts.stderr(formatCliError(err) + "\n");
248
308
  return 1;
249
309
  }
250
310
  for (let i = 0; i < count; i++) opts.stdout(await codec.generate() + "\n");
251
311
  return 0;
252
312
  }
253
313
  //#endregion
254
- //#region src/cli/format.ts
255
- function formatWrappedInspectOutput(result) {
256
- const inputLine = describeInputForm(result.input, result.canonical);
257
- return [
258
- `brand: ${result.brand}`,
259
- `lookup-key: ${result.lookupKey.toString()}`,
260
- `canonical: ${result.canonical}`,
261
- `input: ${inputLine}`,
262
- ""
263
- ].join("\n");
264
- }
265
- function formatInspectOutput(result) {
266
- const relative = formatRelative(result.timestamp.getTime(), result.nowMs);
267
- const inputLine = describeInputForm(result.input, result.canonical);
268
- return [
269
- `brand: ${result.brand}`,
270
- `timestamp: ${result.timestamp.toISOString()} (${relative})`,
271
- `canonical: ${result.canonical}`,
272
- `input: ${inputLine}`,
273
- ""
274
- ].join("\n");
275
- }
276
- function describeInputForm(input, canonical) {
277
- if (input === canonical) return "canonical";
278
- const notes = [];
279
- if (input !== input.toLowerCase()) notes.push("was uppercase");
280
- if (/[ilo]/i.test(input.slice(4))) notes.push("used Crockford aliases");
281
- return `not canonical (${notes.join(" + ")})`;
282
- }
283
- const msPerMinute = 60 * 1e3;
284
- const msPerHour = 60 * msPerMinute;
285
- const msPerDay = 24 * msPerHour;
286
- const daysPerMonth = 30.44;
287
- const monthsPerYear = 12;
288
- function formatRelative(thenMs, nowMs) {
289
- const diff = nowMs - thenMs;
290
- const abs = Math.abs(diff);
291
- const suffix = diff < 0 ? "from now" : "ago";
292
- const head = headUnits(abs);
293
- return head === "" ? "just now" : `${head} ${suffix}`;
294
- }
295
- function headUnits(abs) {
296
- if (abs < msPerMinute) return "";
297
- if (abs < msPerHour) return unit(Math.round(abs / msPerMinute), "minute");
298
- if (abs < msPerDay) return unit(Math.round(abs / msPerHour), "hour");
299
- if (abs < msPerDay * daysPerMonth) return unit(Math.round(abs / msPerDay), "day");
300
- const totalMonths = Math.round(abs / (msPerDay * daysPerMonth));
301
- if (totalMonths < monthsPerYear) return unit(totalMonths, "month");
302
- const years = Math.floor(totalMonths / monthsPerYear);
303
- const months = totalMonths % monthsPerYear;
304
- return months === 0 ? unit(years, "year") : `${unit(years, "year")} ${unit(months, "month")}`;
305
- }
306
- function unit(n, name) {
307
- return `${n} ${n === 1 ? name : `${name}s`}`;
308
- }
309
- //#endregion
310
314
  //#region src/cli/wrapping-key.ts
311
315
  function parseKeyFormatFlag(values) {
312
316
  const fromFlag = values.get("--key-format");
@@ -434,7 +438,7 @@ function runInspect(args, opts) {
434
438
  try {
435
439
  reverseCodec = createReverseTimestampId(brand, codecOpts(opts));
436
440
  } catch (err) {
437
- opts.stderr(err.message + "\n");
441
+ opts.stderr(formatCliError(err) + "\n");
438
442
  return Promise.resolve(1);
439
443
  }
440
444
  const reverseValidation = reverseCodec["~standard"].validate(input);
@@ -458,7 +462,7 @@ function runInspect(args, opts) {
458
462
  try {
459
463
  codec = createTimestampId(brand, codecOpts(opts));
460
464
  } catch (err) {
461
- opts.stderr(err.message + "\n");
465
+ opts.stderr(formatCliError(err) + "\n");
462
466
  return Promise.resolve(1);
463
467
  }
464
468
  const validation = codec["~standard"].validate(input);
@@ -492,7 +496,7 @@ async function runWrappedInspect(brand, input, kind, format, opts) {
492
496
  allowDuplicateBrand: true
493
497
  });
494
498
  } catch (err) {
495
- opts.stderr(err.message + "\n");
499
+ opts.stderr(formatCliError(err) + "\n");
496
500
  return 1;
497
501
  }
498
502
  const validation = codec["~standard"].validate(input);
@@ -505,7 +509,7 @@ async function runWrappedInspect(brand, input, kind, format, opts) {
505
509
  try {
506
510
  lookupKey = await codec.unwrap(canonical);
507
511
  } catch (err) {
508
- opts.stderr(err.message + "\n");
512
+ opts.stderr(formatCliError(err) + "\n");
509
513
  return 1;
510
514
  }
511
515
  opts.stdout(formatWrappedInspectOutput({
@@ -529,7 +533,7 @@ async function runOpaqueInspect(brand, input, format, opts) {
529
533
  ...codecOpts(opts)
530
534
  });
531
535
  } catch (err) {
532
- opts.stderr(err.message + "\n");
536
+ opts.stderr(formatCliError(err) + "\n");
533
537
  return 1;
534
538
  }
535
539
  const validation = codec["~standard"].validate(input);