@smonn/ids 0.6.0 → 0.8.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 +159 -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 +97 -0
  20. package/dist/fastify.d.mts.map +1 -0
  21. package/dist/fastify.mjs +98 -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 +167 -0
  48. package/dist/signed.d.mts.map +1 -0
  49. package/dist/signed.mjs +257 -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,33 @@ 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
+ createSignedTimestampId, // (brand: string, opts: SignedTimestampOptions) => SignedTimestampCodec<Brand>
486
+ IdsError, // re-exported from @smonn/ids/signed for convenience
487
+ isIdsError, // re-exported from @smonn/ids/signed for convenience
488
+ type SigningKey, // opaque SigningKey handle (HKDF-derived)
489
+ type SigningKeyFormat, // "hex" | "base64url"
490
+ type IdsErrorCode, // re-exported from @smonn/ids/signed for convenience
491
+ type SignedTimestampCodec, // returned by createSignedTimestampId
492
+ type SignedTimestampOptions, // { keys: SigningKey[], now?, rng?, allowDuplicateBrand? } constructor options
493
+ type SafeVerifyResult, // { ok: true, id: Id<Brand> } | { ok: false, error: ParseError | "verification_failed" }
494
+ } from "@smonn/ids/signed";
495
+
496
+ import {
497
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
498
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
499
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
366
500
  createWrappedKeyId, // (brand: string, opts: { kind: "u32" | "i32" | "u64" | "i64", keys }) => WrappedKeyCodec<Brand, Kind>
367
501
  importWrappingKey, // (bytes: Uint8Array) => Promise<WrappingKey>
368
502
  encodeWrappingKey, // (bytes: Uint8Array, format: WrappingKeyFormat) => string
@@ -373,16 +507,31 @@ import {
373
507
  } from "@smonn/ids/wrapped";
374
508
 
375
509
  import {
510
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
511
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
512
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
376
513
  idColumn, // (codec: IdColumnCodec<Brand>) => PgCustomColumnBuilder (Drizzle column)
377
514
  type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
378
515
  } from "@smonn/ids/drizzle";
379
516
 
380
517
  import {
518
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
519
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
520
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
381
521
  idField, // (codec: IdColumnCodec<Brand>) => IdTransform<Brand> — read/write transforms for Prisma $extends
382
522
  type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
383
523
  type IdTransform, // { read(value: unknown): Id<Brand>; write(value: Id<Brand>): string }
384
524
  } from "@smonn/ids/prisma";
385
525
 
526
+ import {
527
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
528
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
529
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
530
+ idColumn, // (codec: IdColumnCodec<Brand>) => { toDriver, fromDriver }
531
+ type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
532
+ type IdColumnType, // ColumnType<Id<Brand>, Id<Brand>, Id<Brand>>
533
+ } from "@smonn/ids/kysely";
534
+
386
535
  import {
387
536
  idParam, // (paramName: string, codec, options?) => Hono MiddlewareHandler — throws HTTPException by default; onError/status options override
388
537
  type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
@@ -395,6 +544,13 @@ import {
395
544
  type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
396
545
  type IdParamOptions, // { onError?, status? }
397
546
  } from "@smonn/ids/express";
547
+
548
+ import {
549
+ idParam, // (paramName: string, codec, options?) => Fastify preHandler — throws IdParamError by default; onError/status options override
550
+ IdParamError, // Error subclass with .reason and .statusCode — thrown into setErrorHandler by default
551
+ type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
552
+ type IdParamOptions, // { onError?, status? }
553
+ } from "@smonn/ids/fastify";
398
554
  ```
399
555
 
400
556
  `@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 +732,9 @@ const id = usrCol.fromDriver(row.id as unknown as string);
576
732
 
577
733
  ```ts
578
734
  import {
735
+ IdsError, // re-exported for convenience — same class as "@smonn/ids"
736
+ isIdsError, // re-exported for convenience — same guard as "@smonn/ids"
737
+ type IdsErrorCode, // re-exported for convenience — same union as "@smonn/ids"
579
738
  idColumn, // (codec: IdColumnCodec<Brand>) => { toDriver, fromDriver }
580
739
  type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
581
740
  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);