@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.
- package/README.md +155 -0
- package/dist/adapter-types-oHCCSgOO.d.mts +12 -0
- package/dist/adapter-types-oHCCSgOO.d.mts.map +1 -0
- package/dist/cli.mjs +72 -68
- package/dist/cli.mjs.map +1 -1
- package/dist/{codec-shell-dWpxoFmy.mjs → codec-shell-DH-UO4UR.mjs} +8 -8
- package/dist/codec-shell-DH-UO4UR.mjs.map +1 -0
- package/dist/drizzle-CeSni5PB.d.mts.map +1 -1
- package/dist/drizzle.d.mts +2 -1
- package/dist/drizzle.mjs +3 -2
- package/dist/drizzle.mjs.map +1 -1
- package/dist/error-Cp5qYZcv.mjs +52 -0
- package/dist/error-Cp5qYZcv.mjs.map +1 -0
- package/dist/error-DTr4i6Ic.d.mts +44 -0
- package/dist/error-DTr4i6Ic.d.mts.map +1 -0
- package/dist/express.d.mts +2 -9
- package/dist/express.d.mts.map +1 -1
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.d.mts +88 -0
- package/dist/fastify.d.mts.map +1 -0
- package/dist/fastify.mjs +91 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +2 -9
- package/dist/hono.d.mts.map +1 -1
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -2
- package/dist/kysely.d.mts +2 -1
- package/dist/kysely.d.mts.map +1 -1
- package/dist/kysely.mjs +3 -2
- package/dist/kysely.mjs.map +1 -1
- package/dist/{opaque-goLnFoo7.mjs → opaque-uvjOFY_0.mjs} +9 -8
- package/dist/opaque-uvjOFY_0.mjs.map +1 -0
- package/dist/opaque.d.mts +2 -1
- package/dist/opaque.d.mts.map +1 -1
- package/dist/opaque.mjs +3 -2
- package/dist/prisma.d.mts +2 -1
- package/dist/prisma.d.mts.map +1 -1
- package/dist/prisma.mjs +3 -2
- package/dist/prisma.mjs.map +1 -1
- package/dist/{reverse--n4D2yxu.mjs → reverse-BgFU6JHw.mjs} +3 -3
- package/dist/reverse-BgFU6JHw.mjs.map +1 -0
- package/dist/reverse.d.mts +2 -1
- package/dist/reverse.d.mts.map +1 -1
- package/dist/reverse.mjs +3 -2
- package/dist/signed.d.mts +56 -0
- package/dist/signed.d.mts.map +1 -0
- package/dist/signed.mjs +100 -0
- package/dist/signed.mjs.map +1 -0
- package/dist/{timestamp-Bgzxx8bE.mjs → timestamp-B5_UCzc6.mjs} +3 -3
- package/dist/{timestamp-Bgzxx8bE.mjs.map → timestamp-B5_UCzc6.mjs.map} +1 -1
- package/dist/{timestamp-bytes-B57RM7Ho.mjs → timestamp-bytes-BBY7JI33.mjs} +2 -2
- package/dist/{timestamp-bytes-B57RM7Ho.mjs.map → timestamp-bytes-BBY7JI33.mjs.map} +1 -1
- package/dist/{wrapped-Dw5mHQhn.mjs → wrapped-0vL72Nje.mjs} +20 -22
- package/dist/wrapped-0vL72Nje.mjs.map +1 -0
- package/dist/wrapped.d.mts +5 -3
- package/dist/wrapped.d.mts.map +1 -1
- package/dist/wrapped.mjs +3 -2
- package/package.json +8 -1
- package/dist/codec-shell-dWpxoFmy.mjs.map +0 -1
- package/dist/opaque-goLnFoo7.mjs.map +0 -1
- package/dist/reverse--n4D2yxu.mjs.map +0 -1
- 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 {
|
|
3
|
-
import {
|
|
4
|
-
import { t as
|
|
5
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
536
|
+
opts.stderr(formatCliError(err) + "\n");
|
|
533
537
|
return 1;
|
|
534
538
|
}
|
|
535
539
|
const validation = codec["~standard"].validate(input);
|