@smonn/ids 0.5.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 +555 -18
- package/dist/adapter-types-oHCCSgOO.d.mts +12 -0
- package/dist/adapter-types-oHCCSgOO.d.mts.map +1 -0
- package/dist/cli.mjs +246 -63
- 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 +44 -0
- package/dist/drizzle-CeSni5PB.d.mts.map +1 -0
- package/dist/drizzle.d.mts +3 -0
- package/dist/drizzle.mjs +43 -0
- package/dist/drizzle.mjs.map +1 -0
- 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 +85 -0
- package/dist/express.d.mts.map +1 -0
- package/dist/express.mjs +90 -0
- package/dist/express.mjs.map +1 -0
- 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 +68 -0
- package/dist/hono.d.mts.map +1 -0
- package/dist/hono.mjs +63 -0
- package/dist/hono.mjs.map +1 -0
- 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 +56 -0
- package/dist/kysely.d.mts.map +1 -0
- package/dist/kysely.mjs +43 -0
- package/dist/kysely.mjs.map +1 -0
- package/dist/{opaque-B4ps7Pqk.mjs → opaque-uvjOFY_0.mjs} +37 -20
- package/dist/opaque-uvjOFY_0.mjs.map +1 -0
- package/dist/opaque.d.mts +34 -9
- package/dist/opaque.d.mts.map +1 -1
- package/dist/opaque.mjs +3 -2
- package/dist/prisma.d.mts +85 -0
- package/dist/prisma.d.mts.map +1 -0
- package/dist/prisma.mjs +54 -0
- package/dist/prisma.mjs.map +1 -0
- package/dist/reverse-BgFU6JHw.mjs +87 -0
- package/dist/reverse-BgFU6JHw.mjs.map +1 -0
- package/dist/reverse.d.mts +77 -0
- package/dist/reverse.d.mts.map +1 -0
- package/dist/reverse.mjs +3 -0
- 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-0vL72Nje.mjs +361 -0
- package/dist/wrapped-0vL72Nje.mjs.map +1 -0
- package/dist/wrapped.d.mts +89 -9
- package/dist/wrapped.d.mts.map +1 -1
- package/dist/wrapped.mjs +3 -336
- package/package.json +45 -3
- package/dist/codec-shell-dWpxoFmy.mjs.map +0 -1
- package/dist/opaque-B4ps7Pqk.mjs.map +0 -1
- package/dist/wrapped.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:
|
|
@@ -164,6 +206,167 @@ The `pattern` describes the **canonical form only** — it matches `generate()`
|
|
|
164
206
|
|
|
165
207
|
`example` is produced by calling `generate()` on each invocation, so it is fresh (non-deterministic) and always matches the returned `pattern`. One consequence: a codec wired with an injected `now` outside the 48-bit range — the same misconfiguration that breaks `generate()` — makes `toJsonSchema()` throw too.
|
|
166
208
|
|
|
209
|
+
### "Validate a route param in Hono"
|
|
210
|
+
|
|
211
|
+
`@smonn/ids/hono` provides `idParam` — a middleware factory that validates a named route param against a codec and exposes the canonical `Id<Brand>` to the handler. Hono is an **optional peer dependency**; install it separately alongside `@smonn/ids`.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
pnpm add hono
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import { idParam } from "@smonn/ids/hono";
|
|
219
|
+
import { createTimestampId } from "@smonn/ids";
|
|
220
|
+
|
|
221
|
+
const usr = createTimestampId("usr");
|
|
222
|
+
|
|
223
|
+
// Default: throws HTTPException → app.onError handles rendering (HTML, JSON, problem+json, …)
|
|
224
|
+
app.get("/users/:id", idParam("id", usr), (c) => {
|
|
225
|
+
const id = c.get("id"); // Id<"usr">, canonical
|
|
226
|
+
// …
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Override: consumer fully owns the error response
|
|
230
|
+
app.get(
|
|
231
|
+
"/orgs/:id",
|
|
232
|
+
idParam("id", org, {
|
|
233
|
+
onError: (failure, c) => c.json({ error: failure.reason }, failure.status),
|
|
234
|
+
}),
|
|
235
|
+
handler,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Or a lightweight status remap without a full handler
|
|
239
|
+
app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } }), handler);
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Default error-channel behavior:** on failure the adapter throws `HTTPException(status)` — it does **not** write a response body itself. This lets the app's existing `app.onError` handler control content negotiation (HTML, JSON, problem+json, redirect, etc.) exactly as it would for any other error.
|
|
243
|
+
|
|
244
|
+
**`options.onError`:** when provided, the hook owns the response entirely — the adapter neither throws nor writes anything.
|
|
245
|
+
|
|
246
|
+
**`options.status`:** remaps the default HTTP status for a failure reason without requiring a full handler.
|
|
247
|
+
|
|
248
|
+
**400 vs 404 defaults:**
|
|
249
|
+
|
|
250
|
+
- **Brand mismatch (`invalid_prefix`) → `reason: "brand_mismatch"`, status 404.** The resource cannot exist under this route — a `usr_` ID makes no sense on `/orders/:id`.
|
|
251
|
+
- **Malformed or missing ID (`invalid_base32` or `not_string`) → `reason: "malformed"`, status 400.** The ID is absent or unreadable — a bad request, not a missing resource.
|
|
252
|
+
|
|
253
|
+
`idParam` calls `safeParse` at the boundary (lenient: accepts mixed case and Crockford aliases), so the handler always receives a canonical, normalized `Id<Brand>` — never the raw URL string. Works with any codec variant's structural `safeParse`.
|
|
254
|
+
|
|
255
|
+
### "Validate a route param in Express"
|
|
256
|
+
|
|
257
|
+
`@smonn/ids/express` provides the same `idParam` factory for Express. Express is an **optional peer dependency**; install it separately alongside `@smonn/ids`.
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
pnpm add express
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { idParam, IdParamError } from "@smonn/ids/express";
|
|
265
|
+
import { createTimestampId } from "@smonn/ids";
|
|
266
|
+
|
|
267
|
+
const usr = createTimestampId("usr");
|
|
268
|
+
|
|
269
|
+
// Default: calls next(err) with an IdParamError → app error-handling middleware renders it
|
|
270
|
+
app.get("/users/:id", idParam("id", usr), (req, res) => {
|
|
271
|
+
const id = res.locals.id; // Id<"usr">, canonical
|
|
272
|
+
// …
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Error-handling middleware receives the typed error
|
|
276
|
+
app.use((err, req, res, next) => {
|
|
277
|
+
if (err instanceof IdParamError) {
|
|
278
|
+
res.status(err.status).json({ error: err.reason });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
next(err);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Override: consumer fully owns the error response
|
|
285
|
+
app.get(
|
|
286
|
+
"/orgs/:id",
|
|
287
|
+
idParam("id", org, {
|
|
288
|
+
onError: (failure, req, res) => res.status(failure.status).json({ error: failure.reason }),
|
|
289
|
+
}),
|
|
290
|
+
handler,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Or a lightweight status remap without a full handler
|
|
294
|
+
app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } }), handler);
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
**Default error-channel behavior:** on failure the adapter calls `next(err)` with an `IdParamError` carrying `status` and `reason` — it does **not** write a response body itself. This lets the app's existing error-handling middleware control rendering exactly as it would for any other error.
|
|
298
|
+
|
|
299
|
+
**`options.onError`:** when provided, the hook owns the response entirely — the adapter does not call `next(err)`.
|
|
300
|
+
|
|
301
|
+
**`options.status`:** remaps the default HTTP status for a failure reason without requiring a full handler.
|
|
302
|
+
|
|
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.
|
|
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
|
+
|
|
167
370
|
### "Don't leak creation time in IDs that customers can see"
|
|
168
371
|
|
|
169
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.
|
|
@@ -171,7 +374,7 @@ The Timestamp codec exposes the creation timestamp by design — that's what mak
|
|
|
171
374
|
```ts
|
|
172
375
|
import { createOpaqueTimestampId, importOpaqueKey } from "@smonn/ids/opaque";
|
|
173
376
|
|
|
174
|
-
const key = await importOpaqueKey(new Uint8Array(16)); //
|
|
377
|
+
const key = await importOpaqueKey(new Uint8Array(16)); // returns an OpaqueKey handle
|
|
175
378
|
const invoices = createOpaqueTimestampId("inv", { key });
|
|
176
379
|
|
|
177
380
|
const id = await invoices.generate(); // "inv_…", timestamp not extractable without the key
|
|
@@ -188,6 +391,47 @@ Encryption is AES-CBC with a zero IV. That's deliberately safe here because the
|
|
|
188
391
|
|
|
189
392
|
To store or transport key material outside the library, `encodeOpaqueKey` / `decodeOpaqueKey` round-trip raw bytes in `hex` or `base64url` — distinct from the Crockford base32 used in ID payloads. The CLI's `keygen` subcommand emits keys in this format (see [CLI](#cli)).
|
|
190
393
|
|
|
394
|
+
### "Newest-first IDs for descending range scans"
|
|
395
|
+
|
|
396
|
+
Most KV stores (DynamoDB, Cloud Datastore, range-scan KV) only support forward lexicographic scans natively — reading the most recent entries first would otherwise require a full reverse scan or a separate sort step. The Reverse Timestamp codec solves this by bitwise-inverting the 48-bit timestamp field before encoding, so newer IDs sort lexicographically before older ones.
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
import { createReverseTimestampId } from "@smonn/ids/reverse";
|
|
400
|
+
|
|
401
|
+
const events = createReverseTimestampId("evt");
|
|
402
|
+
|
|
403
|
+
const id = events.generate(); // "evt_…", sorts newest-first
|
|
404
|
+
events.extractTimestamp(id); // Date — inversion is reversed to recover the original ms
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**Range-bound direction is flipped.** Because a newer timestamp maps to a lexicographically smaller ID, a time-range scan over [t_old, t_new] passes the newer timestamp as the lower bound and the older timestamp as the upper bound:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
const start = new Date("2026-01-01T00:00:00Z"); // older
|
|
411
|
+
const end = new Date("2026-02-01T00:00:00Z"); // newer
|
|
412
|
+
|
|
413
|
+
// Reverse Timestamp: lower bound = newer time, upper bound = older time
|
|
414
|
+
sql`SELECT * FROM events WHERE id BETWEEN ${events.minIdForTime(end)} AND ${events.maxIdForTime(start)}`;
|
|
415
|
+
|
|
416
|
+
// compare: Timestamp codec (ascending) passes start/end in the natural forward direction
|
|
417
|
+
// sql`... WHERE id BETWEEN ${plainEvents.minIdForTime(start)} AND ${plainEvents.maxIdForTime(end)}`
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
`minIdForTime(t)` is always the lexicographically smallest ID at millisecond `t` (random portion all `0x00`) and `maxIdForTime(t)` is the largest (random portion all `0xff`). Under reversal, a newer `t` produces a smaller `minIdForTime` result, so the bounds are swapped relative to the Timestamp codec — see [ADR-0010](./docs/adr/0010-reverse-timestamp-inversion.md) for the detailed rationale.
|
|
421
|
+
|
|
422
|
+
**Same-millisecond order remains non-deterministic.** The random 10-byte tail is not affected by the timestamp inversion. Two IDs generated in the same millisecond have independent random tails and do not sort deterministically relative to each other — the same caveat as the Timestamp codec (ADR-0002).
|
|
423
|
+
|
|
424
|
+
No key material is required. The inversion is a deterministic byte transform; `generate`, `generateAt`, and `extractTimestamp` are fully synchronous.
|
|
425
|
+
|
|
426
|
+
## Choosing a codec variant
|
|
427
|
+
|
|
428
|
+
| Codec | Import | Sort direction | Key required | Timestamp extractable | Range query support |
|
|
429
|
+
| ----------------- | -------------------- | ------------------------- | ------------------ | -------------------------- | -------------------------------------------------------------- |
|
|
430
|
+
| Timestamp | `@smonn/ids` | Ascending (oldest-first) | No | Always | `minIdForTime(t_old)` → `maxIdForTime(t_new)` |
|
|
431
|
+
| Reverse Timestamp | `@smonn/ids/reverse` | Descending (newest-first) | No | Always | `minIdForTime(t_new)` → `maxIdForTime(t_old)` (bounds flipped) |
|
|
432
|
+
| Opaque Timestamp | `@smonn/ids/opaque` | None (encrypted) | Yes (AES key) | With key only | None — encrypted payloads do not sort by time |
|
|
433
|
+
| Wrapped key | `@smonn/ids/wrapped` | None | Yes (wrapping key) | N/A — not timestamp-family | None |
|
|
434
|
+
|
|
191
435
|
## What this is **not** for
|
|
192
436
|
|
|
193
437
|
- **Internal surrogate primary keys.** If nobody outside your service ever sees the ID, the brand prefix and lenient parsing are dead weight. Use a `bigint` sequence.
|
|
@@ -199,6 +443,9 @@ To store or transport key material outside the library, `encodeOpaqueKey` / `dec
|
|
|
199
443
|
|
|
200
444
|
```ts
|
|
201
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)
|
|
202
449
|
createTimestampId, // (brand: string, opts?: TimestampOptions) => TimestampCodec<Brand>
|
|
203
450
|
type Id, // branded string type
|
|
204
451
|
type TimestampCodec, // returned by createTimestampId
|
|
@@ -209,16 +456,43 @@ import {
|
|
|
209
456
|
} from "@smonn/ids";
|
|
210
457
|
|
|
211
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"
|
|
212
462
|
createOpaqueTimestampId, // (brand: string, opts: OpaqueTimestampOptions) => OpaqueTimestampCodec<Brand>
|
|
213
|
-
importOpaqueKey, // (bytes: Uint8Array) => Promise<
|
|
463
|
+
importOpaqueKey, // (bytes: Uint8Array) => Promise<OpaqueKey>
|
|
214
464
|
encodeOpaqueKey, // (bytes: Uint8Array, format: OpaqueKeyFormat) => string
|
|
215
465
|
decodeOpaqueKey, // (encoded: string, format: OpaqueKeyFormat) => Uint8Array
|
|
216
466
|
type OpaqueTimestampCodec, // returned by createOpaqueTimestampId
|
|
217
|
-
type OpaqueTimestampOptions, // { key, now?, rng?, allowDuplicateBrand? } constructor options
|
|
467
|
+
type OpaqueTimestampOptions, // { key: OpaqueKey, now?, rng?, allowDuplicateBrand? } constructor options
|
|
468
|
+
type OpaqueKey, // opaque imported handle for AES key material
|
|
218
469
|
type OpaqueKeyFormat, // "hex" | "base64url"
|
|
219
470
|
} from "@smonn/ids/opaque";
|
|
220
471
|
|
|
221
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"
|
|
476
|
+
createReverseTimestampId, // (brand: string, opts?: ReverseTimestampOptions) => ReverseTimestampCodec<Brand>
|
|
477
|
+
type ReverseTimestampCodec, // returned by createReverseTimestampId
|
|
478
|
+
type ReverseTimestampOptions, // { now?, rng?, allowDuplicateBrand? } constructor options
|
|
479
|
+
} from "@smonn/ids/reverse";
|
|
480
|
+
|
|
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"
|
|
222
496
|
createWrappedKeyId, // (brand: string, opts: { kind: "u32" | "i32" | "u64" | "i64", keys }) => WrappedKeyCodec<Brand, Kind>
|
|
223
497
|
importWrappingKey, // (bytes: Uint8Array) => Promise<WrappingKey>
|
|
224
498
|
encodeWrappingKey, // (bytes: Uint8Array, format: WrappingKeyFormat) => string
|
|
@@ -227,31 +501,294 @@ import {
|
|
|
227
501
|
type WrappingKey, // opaque imported handle for wrapping key material
|
|
228
502
|
type WrappingKeyFormat, // "hex" | "base64url"
|
|
229
503
|
} from "@smonn/ids/wrapped";
|
|
504
|
+
|
|
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"
|
|
509
|
+
idColumn, // (codec: IdColumnCodec<Brand>) => PgCustomColumnBuilder (Drizzle column)
|
|
510
|
+
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
511
|
+
} from "@smonn/ids/drizzle";
|
|
512
|
+
|
|
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"
|
|
517
|
+
idField, // (codec: IdColumnCodec<Brand>) => IdTransform<Brand> — read/write transforms for Prisma $extends
|
|
518
|
+
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
519
|
+
type IdTransform, // { read(value: unknown): Id<Brand>; write(value: Id<Brand>): string }
|
|
520
|
+
} from "@smonn/ids/prisma";
|
|
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
|
+
|
|
531
|
+
import {
|
|
532
|
+
idParam, // (paramName: string, codec, options?) => Hono MiddlewareHandler — throws HTTPException by default; onError/status options override
|
|
533
|
+
type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
|
|
534
|
+
type IdParamOptions, // { onError?, status? }
|
|
535
|
+
} from "@smonn/ids/hono";
|
|
536
|
+
|
|
537
|
+
import {
|
|
538
|
+
idParam, // (paramName: string, codec, options?) => Express middleware — calls next(err) with IdParamError by default; onError/status options override
|
|
539
|
+
IdParamError, // Error subclass with .reason and .status — forwarded via next(err) by default
|
|
540
|
+
type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
|
|
541
|
+
type IdParamOptions, // { onError?, status? }
|
|
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";
|
|
230
550
|
```
|
|
231
551
|
|
|
232
|
-
`@smonn/ids/wrapped` ships the Wrapped key codec for `u32`, `i32`, `u64`, and `i64` lookup keys. `wrap(lookupKey)`
|
|
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.
|
|
553
|
+
|
|
554
|
+
**Integer kinds and value types.** The 32-bit kinds (`u32`, `i32`) use safe JavaScript `number` values in their fixed-width ranges. The 64-bit kinds (`u64`, `i64`) always use `bigint` — even when the magnitude would fit in a `number` — to prevent silent truncation or sign erasure.
|
|
233
555
|
|
|
234
556
|
```ts
|
|
557
|
+
import { createWrappedKeyId, importWrappingKey } from "@smonn/ids/wrapped";
|
|
558
|
+
|
|
235
559
|
const key = await importWrappingKey(new Uint8Array(32));
|
|
236
|
-
const invoices = createWrappedKeyId("inv", { kind: "u64", keys: [key] });
|
|
237
560
|
|
|
238
|
-
|
|
239
|
-
const
|
|
561
|
+
// u32: number, range [0, 4294967295]
|
|
562
|
+
const u32Ids = createWrappedKeyId("inv", { kind: "u32", keys: [key] });
|
|
563
|
+
const u32Id = await u32Ids.wrap(42); // number → Id<"inv">
|
|
564
|
+
const u32Key = await u32Ids.unwrap(u32Id); // → 42 (number)
|
|
565
|
+
|
|
566
|
+
// i32: number, range [-2147483648, 2147483647]
|
|
567
|
+
const i32Ids = createWrappedKeyId("rec", { kind: "i32", keys: [key] });
|
|
568
|
+
const i32Id = await i32Ids.wrap(-7); // number → Id<"rec">
|
|
569
|
+
const i32Key = await i32Ids.unwrap(i32Id); // → -7 (number)
|
|
570
|
+
|
|
571
|
+
// u64: bigint, range [0n, 18446744073709551615n]
|
|
572
|
+
const u64Ids = createWrappedKeyId("ord", { kind: "u64", keys: [key] });
|
|
573
|
+
const u64Id = await u64Ids.wrap(42n); // bigint → Id<"ord">
|
|
574
|
+
const u64Key = await u64Ids.unwrap(u64Id); // → 42n (bigint)
|
|
575
|
+
|
|
576
|
+
// i64: bigint, range [-9223372036854775808n, 9223372036854775807n]
|
|
577
|
+
const i64Ids = createWrappedKeyId("evt", { kind: "i64", keys: [key] });
|
|
578
|
+
const i64Id = await i64Ids.wrap(-1n); // bigint → Id<"evt">
|
|
579
|
+
const i64Key = await i64Ids.unwrap(i64Id); // → -1n (bigint)
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
**Keyring rotation.** Pass a non-empty ordered list of wrapping keys at construction. The first entry is the _current_ key — the only one `wrap` uses. `unwrap` trials every entry in order until the verification tag matches, so IDs wrapped under any listed key are still unwrappable. Removing an entry from the list revokes all IDs wrapped under it.
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
const oldKey = await importWrappingKey(rawOldSecret);
|
|
586
|
+
const newKey = await importWrappingKey(rawNewSecret);
|
|
587
|
+
|
|
588
|
+
// Before rotation: only oldKey in the ring
|
|
589
|
+
const legacy = createWrappedKeyId("inv", { kind: "u32", keys: [oldKey] });
|
|
590
|
+
const id = await legacy.wrap(7);
|
|
591
|
+
|
|
592
|
+
// After rotation: newKey is current; oldKey is still accepted on unwrap
|
|
593
|
+
const rotated = createWrappedKeyId("inv", { kind: "u32", keys: [newKey, oldKey] });
|
|
594
|
+
await rotated.unwrap(id); // succeeds — tried oldKey and matched
|
|
595
|
+
await rotated.wrap(7); // uses newKey → different public ID
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
**Equality leakage.** The Wrapped key codec is deterministic: the same lookup key wrapped under the same wrapping key always yields the same public ID. An observer without operator material can tell that two identical public IDs wrap the same lookup key, but cannot recover the lookup key or wrapping key from the ID alone. This is an accepted trade-off for fitting an 8-byte integer lane and an 8-byte verification tag into the 16-byte payload. UUID-sized values (128 bits) are out of scope for this compact branch — there is no room for them alongside the verification tag.
|
|
599
|
+
|
|
600
|
+
**Fail-closed verification.** `is`, `parse`, and `safeParse` are structural — they check only the prefix and base32 payload shape, with no key material required. Cryptographic verification only happens in `unwrap` and `safeUnwrap`.
|
|
601
|
+
|
|
602
|
+
- `unwrap(id)` takes a trusted `Id<Brand>` and **throws** if payload verification fails. Use it when the ID is already known to be structurally valid (e.g. freshly loaded from your database).
|
|
603
|
+
- `safeUnwrap(input)` accepts untrusted input, structurally parses first, then verifies — without throwing. It returns one of:
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
// Success
|
|
607
|
+
{
|
|
608
|
+
ok: true;
|
|
609
|
+
id: Id<Brand>;
|
|
610
|
+
lookupKey: number | bigint;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Structural parse failure (wrong brand, invalid base32, etc.)
|
|
614
|
+
{
|
|
615
|
+
ok: false;
|
|
616
|
+
error: "not_string" | "invalid_prefix" | "invalid_base32";
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Payload verified but tag mismatch (tampered, wrong keyring, or revoked key)
|
|
620
|
+
{
|
|
621
|
+
ok: false;
|
|
622
|
+
error: "verification_failed";
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Use `safeUnwrap` at API boundaries where the input is untrusted:
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
const result = await invoices.safeUnwrap(req.body.invoiceId);
|
|
630
|
+
|
|
631
|
+
if (!result.ok) {
|
|
632
|
+
if (result.error === "verification_failed") return 403; // tampered or wrong key
|
|
633
|
+
return 400; // malformed ID
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const { id, lookupKey } = result; // Id<"inv">, number
|
|
240
637
|
```
|
|
241
638
|
|
|
242
639
|
### Codec methods
|
|
243
640
|
|
|
244
|
-
| Method | `TimestampCodec<Brand>` | `OpaqueTimestampCodec<Brand>` | Description |
|
|
245
|
-
| ---------------------- | ----------------------- | ----------------------------- | ----------------------------------------------------------------------------- |
|
|
246
|
-
| `generate()` | sync | async | Produce a fresh ID |
|
|
247
|
-
| `generateAt(date)` | sync | async | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
|
|
248
|
-
| `
|
|
249
|
-
| `
|
|
250
|
-
| `
|
|
251
|
-
| `
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
| `
|
|
641
|
+
| Method | `TimestampCodec<Brand>` | `ReverseTimestampCodec<Brand>` | `OpaqueTimestampCodec<Brand>` | `WrappedKeyCodec<Brand, Kind>` | Description |
|
|
642
|
+
| ---------------------- | ----------------------- | ------------------------------ | ----------------------------- | ------------------------------ | ----------------------------------------------------------------------------- |
|
|
643
|
+
| `generate()` | sync | sync | async | — | Produce a fresh ID |
|
|
644
|
+
| `generateAt(date)` | sync | sync | async | — | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
|
|
645
|
+
| `wrap(lookupKey)` | — | — | — | async | Wrap a lookup key into a public ID using the current wrapping key |
|
|
646
|
+
| `unwrap(id)` | — | — | — | async | Verify and recover the lookup key; throws on verification failure |
|
|
647
|
+
| `safeUnwrap(input)` | — | — | — | async | Non-throwing: structurally parse then verify; returns parse or verify error |
|
|
648
|
+
| `is(value)` | sync | sync | sync | sync | Strict type guard: `true` only for already-canonical strings |
|
|
649
|
+
| `parse(value)` | sync | sync | sync | sync | Lenient: normalise to canonical, or throw |
|
|
650
|
+
| `safeParse(value)` | sync | sync | sync | sync | Lenient: normalise to canonical, or return `{ ok: false, error }` |
|
|
651
|
+
| `extractTimestamp(id)` | sync | sync | async | — | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
|
|
652
|
+
| `minIdForTime(date)` | sync | sync † | — | — | Tight lower bound for any ID generated at `date` (for range queries) |
|
|
653
|
+
| `maxIdForTime(date)` | sync | sync † | — | — | Tight upper bound for any ID generated at `date` (for range queries) |
|
|
654
|
+
| `toJsonSchema()` | sync | sync | sync | sync | JSON Schema (`type`/`pattern`/`description`/`example`) for the canonical form |
|
|
655
|
+
|
|
656
|
+
† Under the Reverse Timestamp codec, a newer timestamp maps to a lexicographically smaller ID — pass `minIdForTime(t_new)` as the lower bound and `maxIdForTime(t_old)` as the upper bound for a [t_old, t_new] scan.
|
|
657
|
+
|
|
658
|
+
## ORM adapters
|
|
659
|
+
|
|
660
|
+
### Drizzle (`@smonn/ids/drizzle`)
|
|
661
|
+
|
|
662
|
+
`@smonn/ids/drizzle` is a subpath export that provides a Drizzle custom column type bound to a codec. It requires `drizzle-orm` as a peer dependency — installing `@smonn/ids` alone does not require Drizzle.
|
|
663
|
+
|
|
664
|
+
```bash
|
|
665
|
+
pnpm add drizzle-orm
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
import { pgTable } from "drizzle-orm/pg-core";
|
|
670
|
+
import { idColumn } from "@smonn/ids/drizzle";
|
|
671
|
+
import { createTimestampId } from "@smonn/ids";
|
|
672
|
+
|
|
673
|
+
const usr = createTimestampId("usr");
|
|
674
|
+
|
|
675
|
+
export const users = pgTable("users", {
|
|
676
|
+
id: idColumn(usr).primaryKey(),
|
|
677
|
+
});
|
|
678
|
+
// users.id is typed as Id<"usr"> end-to-end
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
`idColumn(codec)` works with any codec variant — `TimestampCodec`, `OpaqueTimestampCodec`, and `WrappedKeyCodec` all satisfy the required interface.
|
|
682
|
+
|
|
683
|
+
**Write path:** `Id<Brand>` is already canonical, so it is passed to the driver unchanged.
|
|
684
|
+
|
|
685
|
+
**Read path:** values from the database are normalised via `codec.safeParse()` rather than the strict `is()` check. Data at rest should already be canonical per [ADR-0003](./docs/adr/0003-canonical-strict-is.md), but `safeParse` is a safe boundary in case stale non-canonical values exist. An unrecognised value throws at read time so corrupt data surfaces immediately rather than silently.
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
import {
|
|
689
|
+
idColumn, // (codec: IdColumnCodec<Brand>) => PgCustomColumnBuilder
|
|
690
|
+
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
691
|
+
} from "@smonn/ids/drizzle";
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
### Kysely (`@smonn/ids/kysely`)
|
|
695
|
+
|
|
696
|
+
`@smonn/ids/kysely` is a subpath export that provides a Kysely column adapter bound to a codec. It requires `kysely` as a peer dependency — installing `@smonn/ids` alone does not require Kysely.
|
|
697
|
+
|
|
698
|
+
```bash
|
|
699
|
+
pnpm add kysely
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
import { idColumn, type IdColumnType } from "@smonn/ids/kysely";
|
|
704
|
+
import { createTimestampId } from "@smonn/ids";
|
|
705
|
+
|
|
706
|
+
const usr = createTimestampId("usr");
|
|
707
|
+
const usrCol = idColumn(usr);
|
|
708
|
+
|
|
709
|
+
// Use IdColumnType in your Database interface:
|
|
710
|
+
interface Database {
|
|
711
|
+
users: { id: IdColumnType<"usr"> };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Kysely has no runtime transformer — fromDriver/toDriver do NOT fire automatically.
|
|
715
|
+
// You must call fromDriver() manually on every read result.
|
|
716
|
+
// The `as unknown as string` cast is required because TypeScript already sees
|
|
717
|
+
// row.id as Id<"usr"> (from the Database interface), even though the raw DB
|
|
718
|
+
// value is a plain string at runtime.
|
|
719
|
+
const row = await db.selectFrom("users").selectAll().executeTakeFirstOrThrow();
|
|
720
|
+
const id = usrCol.fromDriver(row.id as unknown as string);
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
`idColumn(codec)` works with any codec variant — `TimestampCodec`, `OpaqueTimestampCodec`, and `WrappedKeyCodec` all satisfy the required interface.
|
|
724
|
+
|
|
725
|
+
**Write path:** `Id<Brand>` is already canonical, so `toDriver` passes it to the driver unchanged.
|
|
726
|
+
|
|
727
|
+
**Read path:** `fromDriver` normalises the raw DB string via `codec.safeParse()`. An unrecognised value throws at read time so corrupt data surfaces immediately rather than silently.
|
|
728
|
+
|
|
729
|
+
```ts
|
|
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"
|
|
734
|
+
idColumn, // (codec: IdColumnCodec<Brand>) => { toDriver, fromDriver }
|
|
735
|
+
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
736
|
+
type IdColumnType, // ColumnType<Id<Brand>, Id<Brand>, Id<Brand>>
|
|
737
|
+
} from "@smonn/ids/kysely";
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
### Prisma (`@smonn/ids/prisma`)
|
|
741
|
+
|
|
742
|
+
`@smonn/ids/prisma` is a subpath export that provides a read/write transform pair for integrating `Id<Brand>` with Prisma's `$extends` extension model. It requires `@prisma/client` as a peer dependency — installing `@smonn/ids` alone does not require Prisma.
|
|
743
|
+
|
|
744
|
+
```bash
|
|
745
|
+
pnpm add @prisma/client
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
```ts
|
|
749
|
+
import { idField } from "@smonn/ids/prisma";
|
|
750
|
+
import { createTimestampId } from "@smonn/ids";
|
|
751
|
+
import type { Id } from "@smonn/ids";
|
|
752
|
+
|
|
753
|
+
const usr = createTimestampId("usr");
|
|
754
|
+
const userIdField = idField(usr);
|
|
755
|
+
|
|
756
|
+
const xprisma = prisma.$extends({
|
|
757
|
+
result: {
|
|
758
|
+
user: {
|
|
759
|
+
id: {
|
|
760
|
+
needs: { id: true },
|
|
761
|
+
compute(user) {
|
|
762
|
+
// Cast required — see Prisma caveat below
|
|
763
|
+
return userIdField.read(user.id) as Id<"usr">;
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// Write path: Id<Brand> is already canonical — pass it directly
|
|
771
|
+
await xprisma.user.create({ data: { id: userIdField.write(usr.generate()), name: "Alice" } });
|
|
772
|
+
|
|
773
|
+
// Read path: validated and typed as Id<"usr"> (with cast)
|
|
774
|
+
const user = await xprisma.user.findFirstOrThrow();
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
`idField(codec)` works with any codec variant — `TimestampCodec`, `OpaqueTimestampCodec`, `ReverseTimestampCodec`, and `WrappedKeyCodec` all satisfy the required interface.
|
|
778
|
+
|
|
779
|
+
**Write path:** `Id<Brand>` is already canonical, so `write` is an identity function — it returns the string unchanged.
|
|
780
|
+
|
|
781
|
+
**Read path:** values from the database are normalised via `codec.safeParse()` rather than the strict `is()` check. Data at rest should already be canonical per [ADR-0003](./docs/adr/0003-canonical-strict-is.md), but `safeParse` is a safe boundary in case stale non-canonical values exist. An unrecognised value throws at read time so corrupt data surfaces immediately rather than silently.
|
|
782
|
+
|
|
783
|
+
**Prisma casting caveat:** Prisma's `$extends` result component can add typed computed accessors to model instances, but cannot retroactively re-type an existing schema field at the Prisma Client level. The `read` function asserts `Id<Brand>` at the TypeScript level, but Prisma's generated types for the model field will not reflect this branding. Callers will need an explicit `as Id<"brand">` cast at consumption sites. This is a Prisma type-system constraint, not a library limitation — see the JSDoc on `idField` for the canonical example.
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
import {
|
|
787
|
+
idField, // (codec: IdColumnCodec<Brand>) => IdTransform<Brand>
|
|
788
|
+
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
789
|
+
type IdTransform, // { read(value: unknown): Id<Brand>; write(value: Id<Brand>): string }
|
|
790
|
+
} from "@smonn/ids/prisma";
|
|
791
|
+
```
|
|
255
792
|
|
|
256
793
|
## CLI
|
|
257
794
|
|
|
@@ -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
|