@smonn/ids 0.4.0 → 0.6.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 +416 -14
- package/dist/bytes-lhzKVaBV.mjs +53 -0
- package/dist/bytes-lhzKVaBV.mjs.map +1 -0
- package/dist/cli.mjs +196 -17
- package/dist/cli.mjs.map +1 -1
- package/dist/{codec-shell-C0arqqX3.mjs → codec-shell-dWpxoFmy.mjs} +2 -23
- package/dist/codec-shell-dWpxoFmy.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 +2 -0
- package/dist/drizzle.mjs +42 -0
- package/dist/drizzle.mjs.map +1 -0
- package/dist/express.d.mts +92 -0
- package/dist/express.d.mts.map +1 -0
- package/dist/express.mjs +90 -0
- package/dist/express.mjs.map +1 -0
- package/dist/hono.d.mts +75 -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.mjs +1 -1
- package/dist/kysely.d.mts +55 -0
- package/dist/kysely.d.mts.map +1 -0
- package/dist/kysely.mjs +42 -0
- package/dist/kysely.mjs.map +1 -0
- package/dist/{opaque-CX-Lc5B9.mjs → opaque-goLnFoo7.mjs} +32 -64
- package/dist/opaque-goLnFoo7.mjs.map +1 -0
- package/dist/opaque.d.mts +33 -9
- package/dist/opaque.d.mts.map +1 -1
- package/dist/opaque.mjs +1 -1
- package/dist/prisma.d.mts +84 -0
- package/dist/prisma.d.mts.map +1 -0
- package/dist/prisma.mjs +53 -0
- package/dist/prisma.mjs.map +1 -0
- package/dist/reverse--n4D2yxu.mjs +87 -0
- package/dist/reverse--n4D2yxu.mjs.map +1 -0
- package/dist/reverse.d.mts +76 -0
- package/dist/reverse.d.mts.map +1 -0
- package/dist/reverse.mjs +2 -0
- package/dist/{timestamp-BjdAetut.mjs → timestamp-Bgzxx8bE.mjs} +3 -2
- package/dist/{timestamp-BjdAetut.mjs.map → timestamp-Bgzxx8bE.mjs.map} +1 -1
- package/dist/timestamp-bytes-B57RM7Ho.mjs +26 -0
- package/dist/timestamp-bytes-B57RM7Ho.mjs.map +1 -0
- package/dist/wrapped-Dw5mHQhn.mjs +363 -0
- package/dist/wrapped-Dw5mHQhn.mjs.map +1 -0
- package/dist/wrapped.d.mts +133 -0
- package/dist/wrapped.d.mts.map +1 -0
- package/dist/wrapped.mjs +2 -0
- package/package.json +43 -7
- package/dist/codec-shell-C0arqqX3.mjs.map +0 -1
- package/dist/opaque-CX-Lc5B9.mjs.map +0 -1
package/dist/drizzle.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { customType } from "drizzle-orm/pg-core";
|
|
2
|
+
//#region src/drizzle.ts
|
|
3
|
+
/**
|
|
4
|
+
* Drizzle custom column type that stores an `Id<Brand>` as a canonical `text` value.
|
|
5
|
+
*
|
|
6
|
+
* **Write path:** passes the `Id<Brand>` directly to the driver — it is already
|
|
7
|
+
* the canonical string form.
|
|
8
|
+
*
|
|
9
|
+
* **Read path:** normalises the raw DB string via `codec.safeParse()`, not strict
|
|
10
|
+
* `is()`. Data at rest should already be canonical per ADR-0003, but `safeParse`
|
|
11
|
+
* is a safe boundary in case stale non-canonical values exist. Throws if the
|
|
12
|
+
* value from the database does not parse as a valid `Id<Brand>`.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { idColumn } from "@smonn/ids/drizzle";
|
|
17
|
+
* import { createTimestampId } from "@smonn/ids";
|
|
18
|
+
*
|
|
19
|
+
* const usr = createTimestampId("usr");
|
|
20
|
+
* export const users = pgTable("users", { id: idColumn(usr).primaryKey() });
|
|
21
|
+
* // users.id is Id<"usr"> end-to-end
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
function idColumn(codec) {
|
|
25
|
+
return customType({
|
|
26
|
+
dataType() {
|
|
27
|
+
return "text";
|
|
28
|
+
},
|
|
29
|
+
toDriver(value) {
|
|
30
|
+
return value;
|
|
31
|
+
},
|
|
32
|
+
fromDriver(value) {
|
|
33
|
+
const result = codec.safeParse(value);
|
|
34
|
+
if (!result.ok) throw new Error(`[ids] invalid ID from database: ${result.error}`);
|
|
35
|
+
return result.id;
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { idColumn };
|
|
41
|
+
|
|
42
|
+
//# sourceMappingURL=drizzle.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drizzle.mjs","names":[],"sources":["../src/drizzle.ts"],"sourcesContent":["import {\n customType,\n type ConvertCustomConfig,\n type PgCustomColumnBuilder,\n} from \"drizzle-orm/pg-core\";\nimport type { Id, ParseResult } from \"./types.js\";\n\n/**\n * Minimum codec interface required by the Drizzle adapter.\n *\n * Any codec variant satisfies this type — TimestampCodec, OpaqueTimestampCodec,\n * and WrappedKeyCodec all expose `safeParse`. The adapter never calls\n * `extractTimestamp`, `wrap`/`unwrap`, or any key-dependent method.\n *\n * Kysely and Prisma adapter issues should use this same codec contract shape.\n */\nexport type IdColumnCodec<Brand extends string> = {\n safeParse(value: unknown): ParseResult<Brand>;\n};\n\n/**\n * Drizzle custom column type that stores an `Id<Brand>` as a canonical `text` value.\n *\n * **Write path:** passes the `Id<Brand>` directly to the driver — it is already\n * the canonical string form.\n *\n * **Read path:** normalises the raw DB string via `codec.safeParse()`, not strict\n * `is()`. Data at rest should already be canonical per ADR-0003, but `safeParse`\n * is a safe boundary in case stale non-canonical values exist. Throws if the\n * value from the database does not parse as a valid `Id<Brand>`.\n *\n * @example\n * ```ts\n * import { idColumn } from \"@smonn/ids/drizzle\";\n * import { createTimestampId } from \"@smonn/ids\";\n *\n * const usr = createTimestampId(\"usr\");\n * export const users = pgTable(\"users\", { id: idColumn(usr).primaryKey() });\n * // users.id is Id<\"usr\"> end-to-end\n * ```\n */\nexport function idColumn<Brand extends string>(\n codec: IdColumnCodec<Brand>,\n): PgCustomColumnBuilder<ConvertCustomConfig<\"\", { data: Id<Brand>; driverData: string }>> {\n return customType<{ data: Id<Brand>; driverData: string }>({\n dataType() {\n return \"text\";\n },\n toDriver(value: Id<Brand>): string {\n return value;\n },\n fromDriver(value: string): Id<Brand> {\n const result = codec.safeParse(value);\n if (!result.ok) {\n throw new Error(`[ids] invalid ID from database: ${result.error}`);\n }\n return result.id;\n },\n })();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAgB,SACd,OACyF;CACzF,OAAO,WAAoD;EACzD,WAAW;GACT,OAAO;EACT;EACA,SAAS,OAA0B;GACjC,OAAO;EACT;EACA,WAAW,OAA0B;GACnC,MAAM,SAAS,MAAM,UAAU,KAAK;GACpC,IAAI,CAAC,OAAO,IACV,MAAM,IAAI,MAAM,mCAAmC,OAAO,OAAO;GAEnE,OAAO,OAAO;EAChB;CACF,CAAC,CAAC,CAAC;AACL"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { i as ParseResult, t as Id } from "./types-g7CiQDyE.mjs";
|
|
2
|
+
import { NextFunction, Request, Response } from "express";
|
|
3
|
+
|
|
4
|
+
//#region src/express.d.ts
|
|
5
|
+
type IdCodec<Brand extends string> = {
|
|
6
|
+
safeParse(value: unknown): ParseResult<Brand>;
|
|
7
|
+
};
|
|
8
|
+
/** Discriminated failure value passed to `onError` and emitted to Express error pipeline via `next(err)`. */
|
|
9
|
+
type IdParamFailure = {
|
|
10
|
+
readonly reason: "brand_mismatch";
|
|
11
|
+
readonly status: number;
|
|
12
|
+
} | {
|
|
13
|
+
readonly reason: "malformed";
|
|
14
|
+
readonly status: number;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Typed error forwarded to Express's error pipeline (`next(err)`) on validation failure.
|
|
18
|
+
* Inspect `err.reason` and `err.status` in error-handling middleware.
|
|
19
|
+
*/
|
|
20
|
+
declare class IdParamError extends Error {
|
|
21
|
+
readonly status: number;
|
|
22
|
+
readonly reason: "brand_mismatch" | "malformed";
|
|
23
|
+
constructor(reason: "brand_mismatch" | "malformed", status: number);
|
|
24
|
+
}
|
|
25
|
+
/** Options for `idParam`. All fields are optional. */
|
|
26
|
+
type IdParamOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* Called instead of forwarding to `next(err)` when provided. The hook owns the response
|
|
29
|
+
* entirely — the adapter does not call `next(err)` itself.
|
|
30
|
+
*/
|
|
31
|
+
onError?: (failure: IdParamFailure, req: Request, res: Response, next: NextFunction) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Remap the default HTTP status for a failure reason without a full handler.
|
|
34
|
+
* e.g. `{ brand_mismatch: 400 }` treats both failure kinds as 400.
|
|
35
|
+
*/
|
|
36
|
+
status?: {
|
|
37
|
+
brand_mismatch?: number;
|
|
38
|
+
malformed?: number;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Express middleware that validates a named route param against a codec via `safeParse`.
|
|
43
|
+
*
|
|
44
|
+
* **Default (no options):** calls `next(err)` with an `IdParamError` carrying `status` and `reason`,
|
|
45
|
+
* so the app's existing error-handling middleware controls rendering. The adapter does not write
|
|
46
|
+
* a response body itself.
|
|
47
|
+
*
|
|
48
|
+
* **`options.onError`:** when provided, the hook owns the response entirely — the adapter does
|
|
49
|
+
* not call `next(err)`.
|
|
50
|
+
*
|
|
51
|
+
* **`options.status`:** remaps the default HTTP status for a reason without a full handler.
|
|
52
|
+
*
|
|
53
|
+
* - **Brand mismatch (`invalid_prefix`) → `reason: "brand_mismatch"`, default 404**
|
|
54
|
+
* - **Malformed or missing ID → `reason: "malformed"`, default 400**
|
|
55
|
+
*
|
|
56
|
+
* On success, stores the canonical `Id<Brand>` in `res.locals` under `paramName`
|
|
57
|
+
* and calls `next()`.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { idParam, IdParamError } from "@smonn/ids/express";
|
|
62
|
+
* import { createTimestampId } from "@smonn/ids";
|
|
63
|
+
*
|
|
64
|
+
* const usr = createTimestampId("usr");
|
|
65
|
+
*
|
|
66
|
+
* // Default: forwards error to app error-handling middleware
|
|
67
|
+
* app.get("/users/:id", idParam("id", usr), (req, res) => {
|
|
68
|
+
* const id = res.locals.id; // Id<"usr">, canonical
|
|
69
|
+
* });
|
|
70
|
+
*
|
|
71
|
+
* // Error-handling middleware receives the typed error
|
|
72
|
+
* app.use((err, req, res, next) => {
|
|
73
|
+
* if (err instanceof IdParamError) {
|
|
74
|
+
* res.status(err.status).json({ error: err.reason });
|
|
75
|
+
* return;
|
|
76
|
+
* }
|
|
77
|
+
* next(err);
|
|
78
|
+
* });
|
|
79
|
+
*
|
|
80
|
+
* // Override: consumer fully owns the response
|
|
81
|
+
* app.get("/orgs/:id", idParam("id", org, {
|
|
82
|
+
* onError: (failure, req, res) => res.status(failure.status).json({ error: failure.reason }),
|
|
83
|
+
* }), handler);
|
|
84
|
+
*
|
|
85
|
+
* // Or a lightweight status remap without a full handler
|
|
86
|
+
* app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } }), handler);
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
declare function idParam<ParamKey extends string, Brand extends string>(paramName: ParamKey, codec: IdCodec<Brand>, options?: IdParamOptions): (req: Request, res: Response<unknown, Record<ParamKey, Id<Brand>>>, next: NextFunction) => void;
|
|
90
|
+
//#endregion
|
|
91
|
+
export { IdParamError, IdParamFailure, IdParamOptions, idParam };
|
|
92
|
+
//# sourceMappingURL=express.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.d.mts","names":[],"sources":["../src/express.ts"],"mappings":";;;;KAGK,OAAA;EACH,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;AAAA;;KAI7B,cAAA;EAAA,SACG,MAAA;EAAA,SAAmC,MAAA;AAAA;EAAA,SACnC,MAAA;EAAA,SAA8B,MAAA;AAAA;;AANJ;AAIzC;;cAQa,YAAA,SAAqB,KAAA;EAAA,SACvB,MAAA;EAAA,SACA,MAAA;EAET,WAAA,CAAY,MAAA,kCAAwC,MAAA;AAAA;;KAS1C,cAAA;EAnBiC;AAM7C;;;EAkBE,OAAA,IAAW,OAAA,EAAS,cAAA,EAAgB,GAAA,EAAK,OAAA,EAAS,GAAA,EAAK,QAAA,EAAU,IAAA,EAAM,YAAA;;;;;EAKvE,MAAA;IAAW,cAAA;IAAyB,SAAA;EAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;AAAA;AAmDtC;;;;;;;;;;;;;;;;;;;;;;;;;;iBAAgB,OAAA,gDACd,SAAA,EAAW,QAAA,EACX,KAAA,EAAO,OAAA,CAAQ,KAAA,GACf,OAAA,GAAU,cAAA,IACR,GAAA,EAAK,OAAA,EAAS,GAAA,EAAK,QAAA,UAAkB,MAAA,CAAO,QAAA,EAAU,EAAA,CAAG,KAAA,KAAU,IAAA,EAAM,YAAA"}
|
package/dist/express.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//#region src/express.ts
|
|
2
|
+
/**
|
|
3
|
+
* Typed error forwarded to Express's error pipeline (`next(err)`) on validation failure.
|
|
4
|
+
* Inspect `err.reason` and `err.status` in error-handling middleware.
|
|
5
|
+
*/
|
|
6
|
+
var IdParamError = class extends Error {
|
|
7
|
+
status;
|
|
8
|
+
reason;
|
|
9
|
+
constructor(reason, status) {
|
|
10
|
+
super(`ID validation failed: ${reason}`);
|
|
11
|
+
this.name = "IdParamError";
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Express middleware that validates a named route param against a codec via `safeParse`.
|
|
18
|
+
*
|
|
19
|
+
* **Default (no options):** calls `next(err)` with an `IdParamError` carrying `status` and `reason`,
|
|
20
|
+
* so the app's existing error-handling middleware controls rendering. The adapter does not write
|
|
21
|
+
* a response body itself.
|
|
22
|
+
*
|
|
23
|
+
* **`options.onError`:** when provided, the hook owns the response entirely — the adapter does
|
|
24
|
+
* not call `next(err)`.
|
|
25
|
+
*
|
|
26
|
+
* **`options.status`:** remaps the default HTTP status for a reason without a full handler.
|
|
27
|
+
*
|
|
28
|
+
* - **Brand mismatch (`invalid_prefix`) → `reason: "brand_mismatch"`, default 404**
|
|
29
|
+
* - **Malformed or missing ID → `reason: "malformed"`, default 400**
|
|
30
|
+
*
|
|
31
|
+
* On success, stores the canonical `Id<Brand>` in `res.locals` under `paramName`
|
|
32
|
+
* and calls `next()`.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { idParam, IdParamError } from "@smonn/ids/express";
|
|
37
|
+
* import { createTimestampId } from "@smonn/ids";
|
|
38
|
+
*
|
|
39
|
+
* const usr = createTimestampId("usr");
|
|
40
|
+
*
|
|
41
|
+
* // Default: forwards error to app error-handling middleware
|
|
42
|
+
* app.get("/users/:id", idParam("id", usr), (req, res) => {
|
|
43
|
+
* const id = res.locals.id; // Id<"usr">, canonical
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* // Error-handling middleware receives the typed error
|
|
47
|
+
* app.use((err, req, res, next) => {
|
|
48
|
+
* if (err instanceof IdParamError) {
|
|
49
|
+
* res.status(err.status).json({ error: err.reason });
|
|
50
|
+
* return;
|
|
51
|
+
* }
|
|
52
|
+
* next(err);
|
|
53
|
+
* });
|
|
54
|
+
*
|
|
55
|
+
* // Override: consumer fully owns the response
|
|
56
|
+
* app.get("/orgs/:id", idParam("id", org, {
|
|
57
|
+
* onError: (failure, req, res) => res.status(failure.status).json({ error: failure.reason }),
|
|
58
|
+
* }), handler);
|
|
59
|
+
*
|
|
60
|
+
* // Or a lightweight status remap without a full handler
|
|
61
|
+
* app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } }), handler);
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
function idParam(paramName, codec, options) {
|
|
65
|
+
return (req, res, next) => {
|
|
66
|
+
const raw = req.params[paramName];
|
|
67
|
+
const result = codec.safeParse(raw);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
const reason = result.error === "invalid_prefix" ? "brand_mismatch" : "malformed";
|
|
70
|
+
const defaultStatus = reason === "brand_mismatch" ? 404 : 400;
|
|
71
|
+
const status = options?.status?.[reason] ?? defaultStatus;
|
|
72
|
+
const failure = {
|
|
73
|
+
reason,
|
|
74
|
+
status
|
|
75
|
+
};
|
|
76
|
+
if (options?.onError) {
|
|
77
|
+
options.onError(failure, req, res, next);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
next(new IdParamError(reason, status));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
res.locals[paramName] = result.id;
|
|
84
|
+
next();
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
export { IdParamError, idParam };
|
|
89
|
+
|
|
90
|
+
//# sourceMappingURL=express.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.mjs","names":[],"sources":["../src/express.ts"],"sourcesContent":["import type { NextFunction, Request, Response } from \"express\";\nimport type { Id, ParseResult } from \"./types.js\";\n\ntype IdCodec<Brand extends string> = {\n safeParse(value: unknown): ParseResult<Brand>;\n};\n\n/** Discriminated failure value passed to `onError` and emitted to Express error pipeline via `next(err)`. */\nexport type IdParamFailure =\n | { readonly reason: \"brand_mismatch\"; readonly status: number }\n | { readonly reason: \"malformed\"; readonly status: number };\n\n/**\n * Typed error forwarded to Express's error pipeline (`next(err)`) on validation failure.\n * Inspect `err.reason` and `err.status` in error-handling middleware.\n */\nexport class IdParamError extends Error {\n readonly status: number;\n readonly reason: \"brand_mismatch\" | \"malformed\";\n\n constructor(reason: \"brand_mismatch\" | \"malformed\", status: number) {\n super(`ID validation failed: ${reason}`);\n this.name = \"IdParamError\";\n this.reason = reason;\n this.status = status;\n }\n}\n\n/** Options for `idParam`. All fields are optional. */\nexport type IdParamOptions = {\n /**\n * Called instead of forwarding to `next(err)` when provided. The hook owns the response\n * entirely — the adapter does not call `next(err)` itself.\n */\n onError?: (failure: IdParamFailure, req: Request, res: Response, next: NextFunction) => void;\n /**\n * Remap the default HTTP status for a failure reason without a full handler.\n * e.g. `{ brand_mismatch: 400 }` treats both failure kinds as 400.\n */\n status?: { brand_mismatch?: number; malformed?: number };\n};\n\n/**\n * Express middleware that validates a named route param against a codec via `safeParse`.\n *\n * **Default (no options):** calls `next(err)` with an `IdParamError` carrying `status` and `reason`,\n * so the app's existing error-handling middleware controls rendering. The adapter does not write\n * a response body itself.\n *\n * **`options.onError`:** when provided, the hook owns the response entirely — the adapter does\n * not call `next(err)`.\n *\n * **`options.status`:** remaps the default HTTP status for a reason without a full handler.\n *\n * - **Brand mismatch (`invalid_prefix`) → `reason: \"brand_mismatch\"`, default 404**\n * - **Malformed or missing ID → `reason: \"malformed\"`, default 400**\n *\n * On success, stores the canonical `Id<Brand>` in `res.locals` under `paramName`\n * and calls `next()`.\n *\n * @example\n * ```ts\n * import { idParam, IdParamError } from \"@smonn/ids/express\";\n * import { createTimestampId } from \"@smonn/ids\";\n *\n * const usr = createTimestampId(\"usr\");\n *\n * // Default: forwards error to app error-handling middleware\n * app.get(\"/users/:id\", idParam(\"id\", usr), (req, res) => {\n * const id = res.locals.id; // Id<\"usr\">, canonical\n * });\n *\n * // Error-handling middleware receives the typed error\n * app.use((err, req, res, next) => {\n * if (err instanceof IdParamError) {\n * res.status(err.status).json({ error: err.reason });\n * return;\n * }\n * next(err);\n * });\n *\n * // Override: consumer fully owns the response\n * app.get(\"/orgs/:id\", idParam(\"id\", org, {\n * onError: (failure, req, res) => res.status(failure.status).json({ error: failure.reason }),\n * }), handler);\n *\n * // Or a lightweight status remap without a full handler\n * app.get(\"/things/:id\", idParam(\"id\", thing, { status: { brand_mismatch: 400 } }), handler);\n * ```\n */\nexport function idParam<ParamKey extends string, Brand extends string>(\n paramName: ParamKey,\n codec: IdCodec<Brand>,\n options?: IdParamOptions,\n): (req: Request, res: Response<unknown, Record<ParamKey, Id<Brand>>>, next: NextFunction) => void {\n return (req, res, next): void => {\n const raw = req.params[paramName];\n const result = codec.safeParse(raw);\n if (!result.ok) {\n const reason =\n result.error === \"invalid_prefix\" ? (\"brand_mismatch\" as const) : (\"malformed\" as const);\n const defaultStatus = reason === \"brand_mismatch\" ? 404 : 400;\n const status = options?.status?.[reason] ?? defaultStatus;\n const failure: IdParamFailure = { reason, status };\n if (options?.onError) {\n options.onError(failure, req, res, next);\n return;\n }\n next(new IdParamError(reason, status));\n return;\n }\n (res.locals as Record<string, unknown>)[paramName] = result.id;\n next();\n };\n}\n"],"mappings":";;;;;AAgBA,IAAa,eAAb,cAAkC,MAAM;CACtC;CACA;CAEA,YAAY,QAAwC,QAAgB;EAClE,MAAM,yBAAyB,QAAQ;EACvC,KAAK,OAAO;EACZ,KAAK,SAAS;EACd,KAAK,SAAS;CAChB;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgEA,SAAgB,QACd,WACA,OACA,SACiG;CACjG,QAAQ,KAAK,KAAK,SAAe;EAC/B,MAAM,MAAM,IAAI,OAAO;EACvB,MAAM,SAAS,MAAM,UAAU,GAAG;EAClC,IAAI,CAAC,OAAO,IAAI;GACd,MAAM,SACJ,OAAO,UAAU,mBAAoB,mBAA8B;GACrE,MAAM,gBAAgB,WAAW,mBAAmB,MAAM;GAC1D,MAAM,SAAS,SAAS,SAAS,WAAW;GAC5C,MAAM,UAA0B;IAAE;IAAQ;GAAO;GACjD,IAAI,SAAS,SAAS;IACpB,QAAQ,QAAQ,SAAS,KAAK,KAAK,IAAI;IACvC;GACF;GACA,KAAK,IAAI,aAAa,QAAQ,MAAM,CAAC;GACrC;EACF;EACA,IAAK,OAAmC,aAAa,OAAO;EAC5D,KAAK;CACP;AACF"}
|
package/dist/hono.d.mts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { i as ParseResult, t as Id } from "./types-g7CiQDyE.mjs";
|
|
2
|
+
import { Context, MiddlewareHandler } from "hono";
|
|
3
|
+
|
|
4
|
+
//#region src/hono.d.ts
|
|
5
|
+
type IdCodec<Brand extends string> = {
|
|
6
|
+
safeParse(value: unknown): ParseResult<Brand>;
|
|
7
|
+
};
|
|
8
|
+
/** Discriminated failure value passed to `onError` and emitted to `app.onError` via HTTPException. */
|
|
9
|
+
type IdParamFailure = {
|
|
10
|
+
readonly reason: "brand_mismatch";
|
|
11
|
+
readonly status: number;
|
|
12
|
+
} | {
|
|
13
|
+
readonly reason: "malformed";
|
|
14
|
+
readonly status: number;
|
|
15
|
+
};
|
|
16
|
+
/** Options for `idParam`. All fields are optional. */
|
|
17
|
+
type IdParamOptions = {
|
|
18
|
+
/**
|
|
19
|
+
* Called instead of throwing when provided. The hook owns the response entirely —
|
|
20
|
+
* the adapter neither throws nor writes a body.
|
|
21
|
+
*/
|
|
22
|
+
onError?: (failure: IdParamFailure, c: Context) => Response | Promise<Response>;
|
|
23
|
+
/**
|
|
24
|
+
* Remap the default HTTP status for a failure reason without a full handler.
|
|
25
|
+
* e.g. `{ brand_mismatch: 400 }` treats both failure kinds as 400.
|
|
26
|
+
*/
|
|
27
|
+
status?: {
|
|
28
|
+
brand_mismatch?: number;
|
|
29
|
+
malformed?: number;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Hono middleware that validates a named route param against a codec via `safeParse`.
|
|
34
|
+
*
|
|
35
|
+
* **Default (no options):** throws `HTTPException(status)` so the app's existing `onError` handler
|
|
36
|
+
* controls rendering and content negotiation. The adapter does not write a response body itself.
|
|
37
|
+
*
|
|
38
|
+
* **`options.onError`:** when provided, the hook owns the response entirely — the adapter neither
|
|
39
|
+
* throws nor writes a response.
|
|
40
|
+
*
|
|
41
|
+
* **`options.status`:** remaps the default HTTP status for a reason without a full handler.
|
|
42
|
+
*
|
|
43
|
+
* - **Brand mismatch (`invalid_prefix`) → `reason: "brand_mismatch"`, default 404**
|
|
44
|
+
* - **Malformed or missing ID → `reason: "malformed"`, default 400**
|
|
45
|
+
*
|
|
46
|
+
* On success, stores the canonical `Id<Brand>` in the Hono context under `paramName`
|
|
47
|
+
* and calls `next()`.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* import { idParam } from "@smonn/ids/hono";
|
|
52
|
+
* import { createTimestampId } from "@smonn/ids";
|
|
53
|
+
*
|
|
54
|
+
* const usr = createTimestampId("usr");
|
|
55
|
+
*
|
|
56
|
+
* // Default: throws HTTPException → app.onError renders it
|
|
57
|
+
* app.get("/users/:id", idParam("id", usr), (c) => {
|
|
58
|
+
* const id = c.get("id"); // Id<"usr">, canonical
|
|
59
|
+
* });
|
|
60
|
+
*
|
|
61
|
+
* // Override: consumer fully owns the response
|
|
62
|
+
* app.get("/orgs/:id", idParam("id", org, {
|
|
63
|
+
* onError: (failure, c) => c.json({ error: failure.reason }, failure.status),
|
|
64
|
+
* }), handler);
|
|
65
|
+
*
|
|
66
|
+
* // Or a lightweight status remap without a full handler
|
|
67
|
+
* app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } }), handler);
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
declare function idParam<ParamKey extends string, Brand extends string>(paramName: ParamKey, codec: IdCodec<Brand>, options?: IdParamOptions): MiddlewareHandler<{
|
|
71
|
+
Variables: Record<ParamKey, Id<Brand>>;
|
|
72
|
+
}>;
|
|
73
|
+
//#endregion
|
|
74
|
+
export { IdParamFailure, IdParamOptions, idParam };
|
|
75
|
+
//# sourceMappingURL=hono.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hono.d.mts","names":[],"sources":["../src/hono.ts"],"mappings":";;;;KAIK,OAAA;EACH,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;AAAA;;KAI7B,cAAA;EAAA,SACG,MAAA;EAAA,SAAmC,MAAA;AAAA;EAAA,SACnC,MAAA;EAAA,SAA8B,MAAA;AAAA;;KAGjC,cAAA;EALZ;;;;EAUE,OAAA,IAAW,OAAA,EAAS,cAAA,EAAgB,CAAA,EAAG,OAAA,KAAY,QAAA,GAAW,OAAA,CAAQ,QAAA;;;;;EAKtE,MAAA;IAAW,cAAA;IAAyB,SAAA;EAAA;AAAA;;;;;;;;;;;;;;;;;;;AAAA;AAyCtC;;;;;;;;;;;;;;;;;;;iBAAgB,OAAA,gDACd,SAAA,EAAW,QAAA,EACX,KAAA,EAAO,OAAA,CAAQ,KAAA,GACf,OAAA,GAAU,cAAA,GACT,iBAAA;EAAoB,SAAA,EAAW,MAAA,CAAO,QAAA,EAAU,EAAA,CAAG,KAAA;AAAA"}
|
package/dist/hono.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
2
|
+
//#region src/hono.ts
|
|
3
|
+
/**
|
|
4
|
+
* Hono middleware that validates a named route param against a codec via `safeParse`.
|
|
5
|
+
*
|
|
6
|
+
* **Default (no options):** throws `HTTPException(status)` so the app's existing `onError` handler
|
|
7
|
+
* controls rendering and content negotiation. The adapter does not write a response body itself.
|
|
8
|
+
*
|
|
9
|
+
* **`options.onError`:** when provided, the hook owns the response entirely — the adapter neither
|
|
10
|
+
* throws nor writes a response.
|
|
11
|
+
*
|
|
12
|
+
* **`options.status`:** remaps the default HTTP status for a reason without a full handler.
|
|
13
|
+
*
|
|
14
|
+
* - **Brand mismatch (`invalid_prefix`) → `reason: "brand_mismatch"`, default 404**
|
|
15
|
+
* - **Malformed or missing ID → `reason: "malformed"`, default 400**
|
|
16
|
+
*
|
|
17
|
+
* On success, stores the canonical `Id<Brand>` in the Hono context under `paramName`
|
|
18
|
+
* and calls `next()`.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { idParam } from "@smonn/ids/hono";
|
|
23
|
+
* import { createTimestampId } from "@smonn/ids";
|
|
24
|
+
*
|
|
25
|
+
* const usr = createTimestampId("usr");
|
|
26
|
+
*
|
|
27
|
+
* // Default: throws HTTPException → app.onError renders it
|
|
28
|
+
* app.get("/users/:id", idParam("id", usr), (c) => {
|
|
29
|
+
* const id = c.get("id"); // Id<"usr">, canonical
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* // Override: consumer fully owns the response
|
|
33
|
+
* app.get("/orgs/:id", idParam("id", org, {
|
|
34
|
+
* onError: (failure, c) => c.json({ error: failure.reason }, failure.status),
|
|
35
|
+
* }), handler);
|
|
36
|
+
*
|
|
37
|
+
* // Or a lightweight status remap without a full handler
|
|
38
|
+
* app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } }), handler);
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
function idParam(paramName, codec, options) {
|
|
42
|
+
return async (c, next) => {
|
|
43
|
+
const raw = c.req.param(paramName);
|
|
44
|
+
const result = codec.safeParse(raw);
|
|
45
|
+
if (!result.ok) {
|
|
46
|
+
const reason = result.error === "invalid_prefix" ? "brand_mismatch" : "malformed";
|
|
47
|
+
const defaultStatus = reason === "brand_mismatch" ? 404 : 400;
|
|
48
|
+
const status = options?.status?.[reason] ?? defaultStatus;
|
|
49
|
+
const failure = {
|
|
50
|
+
reason,
|
|
51
|
+
status
|
|
52
|
+
};
|
|
53
|
+
if (options?.onError) return options.onError(failure, c);
|
|
54
|
+
throw new HTTPException(status);
|
|
55
|
+
}
|
|
56
|
+
c.set(paramName, result.id);
|
|
57
|
+
await next();
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { idParam };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=hono.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hono.mjs","names":[],"sources":["../src/hono.ts"],"sourcesContent":["import { HTTPException } from \"hono/http-exception\";\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport type { Id, ParseResult } from \"./types.js\";\n\ntype IdCodec<Brand extends string> = {\n safeParse(value: unknown): ParseResult<Brand>;\n};\n\n/** Discriminated failure value passed to `onError` and emitted to `app.onError` via HTTPException. */\nexport type IdParamFailure =\n | { readonly reason: \"brand_mismatch\"; readonly status: number }\n | { readonly reason: \"malformed\"; readonly status: number };\n\n/** Options for `idParam`. All fields are optional. */\nexport type IdParamOptions = {\n /**\n * Called instead of throwing when provided. The hook owns the response entirely —\n * the adapter neither throws nor writes a body.\n */\n onError?: (failure: IdParamFailure, c: Context) => Response | Promise<Response>;\n /**\n * Remap the default HTTP status for a failure reason without a full handler.\n * e.g. `{ brand_mismatch: 400 }` treats both failure kinds as 400.\n */\n status?: { brand_mismatch?: number; malformed?: number };\n};\n\n/**\n * Hono middleware that validates a named route param against a codec via `safeParse`.\n *\n * **Default (no options):** throws `HTTPException(status)` so the app's existing `onError` handler\n * controls rendering and content negotiation. The adapter does not write a response body itself.\n *\n * **`options.onError`:** when provided, the hook owns the response entirely — the adapter neither\n * throws nor writes a response.\n *\n * **`options.status`:** remaps the default HTTP status for a reason without a full handler.\n *\n * - **Brand mismatch (`invalid_prefix`) → `reason: \"brand_mismatch\"`, default 404**\n * - **Malformed or missing ID → `reason: \"malformed\"`, default 400**\n *\n * On success, stores the canonical `Id<Brand>` in the Hono context under `paramName`\n * and calls `next()`.\n *\n * @example\n * ```ts\n * import { idParam } from \"@smonn/ids/hono\";\n * import { createTimestampId } from \"@smonn/ids\";\n *\n * const usr = createTimestampId(\"usr\");\n *\n * // Default: throws HTTPException → app.onError renders it\n * app.get(\"/users/:id\", idParam(\"id\", usr), (c) => {\n * const id = c.get(\"id\"); // Id<\"usr\">, canonical\n * });\n *\n * // Override: consumer fully owns the response\n * app.get(\"/orgs/:id\", idParam(\"id\", org, {\n * onError: (failure, c) => c.json({ error: failure.reason }, failure.status),\n * }), handler);\n *\n * // Or a lightweight status remap without a full handler\n * app.get(\"/things/:id\", idParam(\"id\", thing, { status: { brand_mismatch: 400 } }), handler);\n * ```\n */\nexport function idParam<ParamKey extends string, Brand extends string>(\n paramName: ParamKey,\n codec: IdCodec<Brand>,\n options?: IdParamOptions,\n): MiddlewareHandler<{ Variables: Record<ParamKey, Id<Brand>> }> {\n return async (c, next) => {\n const raw = c.req.param(paramName);\n const result = codec.safeParse(raw);\n if (!result.ok) {\n const reason =\n result.error === \"invalid_prefix\" ? (\"brand_mismatch\" as const) : (\"malformed\" as const);\n const defaultStatus = reason === \"brand_mismatch\" ? 404 : 400;\n const status = options?.status?.[reason] ?? defaultStatus;\n const failure: IdParamFailure = { reason, status };\n if (options?.onError) {\n return options.onError(failure, c);\n }\n throw new HTTPException(status as ConstructorParameters<typeof HTTPException>[0]);\n }\n c.set(paramName, result.id);\n await next();\n return;\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiEA,SAAgB,QACd,WACA,OACA,SAC+D;CAC/D,OAAO,OAAO,GAAG,SAAS;EACxB,MAAM,MAAM,EAAE,IAAI,MAAM,SAAS;EACjC,MAAM,SAAS,MAAM,UAAU,GAAG;EAClC,IAAI,CAAC,OAAO,IAAI;GACd,MAAM,SACJ,OAAO,UAAU,mBAAoB,mBAA8B;GACrE,MAAM,gBAAgB,WAAW,mBAAmB,MAAM;GAC1D,MAAM,SAAS,SAAS,SAAS,WAAW;GAC5C,MAAM,UAA0B;IAAE;IAAQ;GAAO;GACjD,IAAI,SAAS,SACX,OAAO,QAAQ,QAAQ,SAAS,CAAC;GAEnC,MAAM,IAAI,cAAc,MAAwD;EAClF;EACA,EAAE,IAAI,WAAW,OAAO,EAAE;EAC1B,MAAM,KAAK;CAEb;AACF"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as createTimestampId } from "./timestamp-
|
|
1
|
+
import { t as createTimestampId } from "./timestamp-Bgzxx8bE.mjs";
|
|
2
2
|
export { createTimestampId };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { t as Id } from "./types-g7CiQDyE.mjs";
|
|
2
|
+
import { t as IdColumnCodec } from "./drizzle-CeSni5PB.mjs";
|
|
3
|
+
import { ColumnType } from "kysely";
|
|
4
|
+
|
|
5
|
+
//#region src/kysely.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Kysely column type mapping for `Id<Brand>`.
|
|
8
|
+
*
|
|
9
|
+
* Use this in your Kysely `Database` interface to type a column as `Id<Brand>` at
|
|
10
|
+
* the TypeScript level. Pair it with `idColumn(codec)` for runtime read/write
|
|
11
|
+
* transformation.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import type { IdColumnType } from "@smonn/ids/kysely";
|
|
16
|
+
* import type { Id } from "@smonn/ids";
|
|
17
|
+
*
|
|
18
|
+
* interface Database {
|
|
19
|
+
* users: { id: IdColumnType<"usr"> };
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
type IdColumnType<Brand extends string> = ColumnType<Id<Brand>, Id<Brand>, Id<Brand>>;
|
|
24
|
+
/**
|
|
25
|
+
* Kysely column adapter bound to a codec.
|
|
26
|
+
*
|
|
27
|
+
* Returns an object with `fromDriver` / `toDriver` helpers that mirror the read/write
|
|
28
|
+
* contract of the Drizzle adapter — same error message, same strictness (safeParse on
|
|
29
|
+
* read, identity on write).
|
|
30
|
+
*
|
|
31
|
+
* **Write path:** passes the `Id<Brand>` directly to the driver — it is already
|
|
32
|
+
* the canonical string form.
|
|
33
|
+
*
|
|
34
|
+
* **Read path:** normalises the raw DB string via `codec.safeParse()`. Throws if
|
|
35
|
+
* the value does not parse as a valid `Id<Brand>`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { idColumn } from "@smonn/ids/kysely";
|
|
40
|
+
* import { createTimestampId } from "@smonn/ids";
|
|
41
|
+
*
|
|
42
|
+
* const usr = createTimestampId("usr");
|
|
43
|
+
* const usrCol = idColumn(usr);
|
|
44
|
+
*
|
|
45
|
+
* // In a query result handler:
|
|
46
|
+
* const id = usrCol.fromDriver(row.id);
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
declare function idColumn<Brand extends string>(codec: IdColumnCodec<Brand>): {
|
|
50
|
+
toDriver(value: Id<Brand>): string;
|
|
51
|
+
fromDriver(value: string): Id<Brand>;
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { type IdColumnCodec, IdColumnType, idColumn };
|
|
55
|
+
//# sourceMappingURL=kysely.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kysely.d.mts","names":[],"sources":["../src/kysely.ts"],"mappings":";;;;;;AAuBA;;;;;;;;;;;;;;;;KAAY,YAAA,yBAAqC,UAAA,CAAW,EAAA,CAAG,KAAA,GAAQ,EAAA,CAAG,KAAA,GAAQ,EAAA,CAAG,KAAA;;;;;AAAA;AA2BrF;;;;;;;;;;;;;;;;;;;;iBAAgB,QAAA,uBACd,KAAA,EAAO,aAAA,CAAc,KAAA;EAErB,QAAA,CAAS,KAAA,EAAO,EAAA,CAAG,KAAA;EACnB,UAAA,CAAW,KAAA,WAAgB,EAAA,CAAG,KAAA;AAAA"}
|
package/dist/kysely.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//#region src/kysely.ts
|
|
2
|
+
/**
|
|
3
|
+
* Kysely column adapter bound to a codec.
|
|
4
|
+
*
|
|
5
|
+
* Returns an object with `fromDriver` / `toDriver` helpers that mirror the read/write
|
|
6
|
+
* contract of the Drizzle adapter — same error message, same strictness (safeParse on
|
|
7
|
+
* read, identity on write).
|
|
8
|
+
*
|
|
9
|
+
* **Write path:** passes the `Id<Brand>` directly to the driver — it is already
|
|
10
|
+
* the canonical string form.
|
|
11
|
+
*
|
|
12
|
+
* **Read path:** normalises the raw DB string via `codec.safeParse()`. Throws if
|
|
13
|
+
* the value does not parse as a valid `Id<Brand>`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { idColumn } from "@smonn/ids/kysely";
|
|
18
|
+
* import { createTimestampId } from "@smonn/ids";
|
|
19
|
+
*
|
|
20
|
+
* const usr = createTimestampId("usr");
|
|
21
|
+
* const usrCol = idColumn(usr);
|
|
22
|
+
*
|
|
23
|
+
* // In a query result handler:
|
|
24
|
+
* const id = usrCol.fromDriver(row.id);
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
function idColumn(codec) {
|
|
28
|
+
return {
|
|
29
|
+
toDriver(value) {
|
|
30
|
+
return value;
|
|
31
|
+
},
|
|
32
|
+
fromDriver(value) {
|
|
33
|
+
const result = codec.safeParse(value);
|
|
34
|
+
if (!result.ok) throw new Error(`[ids] invalid ID from database: ${result.error}`);
|
|
35
|
+
return result.id;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { idColumn };
|
|
41
|
+
|
|
42
|
+
//# sourceMappingURL=kysely.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kysely.mjs","names":[],"sources":["../src/kysely.ts"],"sourcesContent":["import type { ColumnType } from \"kysely\";\nimport type { IdColumnCodec } from \"./drizzle.js\";\nimport type { Id } from \"./types.js\";\n\nexport type { IdColumnCodec } from \"./drizzle.js\";\n\n/**\n * Kysely column type mapping for `Id<Brand>`.\n *\n * Use this in your Kysely `Database` interface to type a column as `Id<Brand>` at\n * the TypeScript level. Pair it with `idColumn(codec)` for runtime read/write\n * transformation.\n *\n * @example\n * ```ts\n * import type { IdColumnType } from \"@smonn/ids/kysely\";\n * import type { Id } from \"@smonn/ids\";\n *\n * interface Database {\n * users: { id: IdColumnType<\"usr\"> };\n * }\n * ```\n */\nexport type IdColumnType<Brand extends string> = ColumnType<Id<Brand>, Id<Brand>, Id<Brand>>;\n\n/**\n * Kysely column adapter bound to a codec.\n *\n * Returns an object with `fromDriver` / `toDriver` helpers that mirror the read/write\n * contract of the Drizzle adapter — same error message, same strictness (safeParse on\n * read, identity on write).\n *\n * **Write path:** passes the `Id<Brand>` directly to the driver — it is already\n * the canonical string form.\n *\n * **Read path:** normalises the raw DB string via `codec.safeParse()`. Throws if\n * the value does not parse as a valid `Id<Brand>`.\n *\n * @example\n * ```ts\n * import { idColumn } from \"@smonn/ids/kysely\";\n * import { createTimestampId } from \"@smonn/ids\";\n *\n * const usr = createTimestampId(\"usr\");\n * const usrCol = idColumn(usr);\n *\n * // In a query result handler:\n * const id = usrCol.fromDriver(row.id);\n * ```\n */\nexport function idColumn<Brand extends string>(\n codec: IdColumnCodec<Brand>,\n): {\n toDriver(value: Id<Brand>): string;\n fromDriver(value: string): Id<Brand>;\n} {\n return {\n toDriver(value: Id<Brand>): string {\n return value;\n },\n fromDriver(value: string): Id<Brand> {\n const result = codec.safeParse(value);\n if (!result.ok) {\n throw new Error(`[ids] invalid ID from database: ${result.error}`);\n }\n return result.id;\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAkDA,SAAgB,SACd,OAIA;CACA,OAAO;EACL,SAAS,OAA0B;GACjC,OAAO;EACT;EACA,WAAW,OAA0B;GACnC,MAAM,SAAS,MAAM,UAAU,KAAK;GACpC,IAAI,CAAC,OAAO,IACV,MAAM,IAAI,MAAM,mCAAmC,OAAO,OAAO;GAEnE,OAAO,OAAO;EAChB;CACF;AACF"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as toWireId, i as payloadBytesFromId, n as registerBrand, r as payloadBase32Length, s as validateBrand, t as wireMethods } from "./codec-shell-dWpxoFmy.mjs";
|
|
2
|
+
import { r as writeTimestamp, t as readTimestampMs } from "./timestamp-bytes-B57RM7Ho.mjs";
|
|
3
|
+
import { i as encodeHex, n as decodeHex, r as encodeBase64Url, t as decodeBase64Url } from "./bytes-lhzKVaBV.mjs";
|
|
2
4
|
//#region src/layouts/opaque.ts
|
|
3
5
|
const zeroIv = new Uint8Array(16);
|
|
4
6
|
const pkcsPad = 16;
|
|
@@ -51,62 +53,35 @@ function createOpaqueLayoutOps(prefix, key, rng) {
|
|
|
51
53
|
};
|
|
52
54
|
}
|
|
53
55
|
//#endregion
|
|
54
|
-
//#region src/bytes.ts
|
|
55
|
-
const hexDigits = "0123456789abcdef";
|
|
56
|
-
const invalidNibble = 255;
|
|
57
|
-
const hexCharCodeToNibble = new Uint8Array(128).fill(invalidNibble);
|
|
58
|
-
for (let i = 0; i < 10; i++) hexCharCodeToNibble[48 + i] = i;
|
|
59
|
-
for (let i = 0; i < 6; i++) {
|
|
60
|
-
hexCharCodeToNibble[97 + i] = 10 + i;
|
|
61
|
-
hexCharCodeToNibble[65 + i] = 10 + i;
|
|
62
|
-
}
|
|
63
|
-
/** Lowercase hex encoding of raw bytes. */
|
|
64
|
-
function encodeHex(bytes) {
|
|
65
|
-
const codes = new Array(bytes.length * 2);
|
|
66
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
67
|
-
const b = bytes[i];
|
|
68
|
-
codes[i * 2] = hexDigits.charCodeAt(b >>> 4);
|
|
69
|
-
codes[i * 2 + 1] = hexDigits.charCodeAt(b & 15);
|
|
70
|
-
}
|
|
71
|
-
return String.fromCharCode(...codes);
|
|
72
|
-
}
|
|
73
|
-
/** Decodes a hex string to raw bytes. Throws on non-hex input. */
|
|
74
|
-
function decodeHex(encoded) {
|
|
75
|
-
if (encoded.length % 2 !== 0) throw new Error("invalid hex");
|
|
76
|
-
const out = new Uint8Array(encoded.length / 2);
|
|
77
|
-
for (let i = 0; i < out.length; i++) {
|
|
78
|
-
const hiCode = encoded.charCodeAt(i * 2);
|
|
79
|
-
const loCode = encoded.charCodeAt(i * 2 + 1);
|
|
80
|
-
if (hiCode >= hexCharCodeToNibble.length || loCode >= hexCharCodeToNibble.length) throw new Error("invalid hex");
|
|
81
|
-
const hi = hexCharCodeToNibble[hiCode];
|
|
82
|
-
const lo = hexCharCodeToNibble[loCode];
|
|
83
|
-
if (hi === invalidNibble || lo === invalidNibble) throw new Error("invalid hex");
|
|
84
|
-
out[i] = hi << 4 | lo;
|
|
85
|
-
}
|
|
86
|
-
return out;
|
|
87
|
-
}
|
|
88
|
-
/** Base64url encoding without padding. */
|
|
89
|
-
function encodeBase64Url(bytes) {
|
|
90
|
-
let binary = "";
|
|
91
|
-
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
92
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
93
|
-
}
|
|
94
|
-
/** Decodes a base64url string to raw bytes. Throws on invalid input. */
|
|
95
|
-
function decodeBase64Url(encoded) {
|
|
96
|
-
const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
97
|
-
const pad = (4 - base64.length % 4) % 4;
|
|
98
|
-
const binary = atob(base64 + "=".repeat(pad));
|
|
99
|
-
const out = new Uint8Array(binary.length);
|
|
100
|
-
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
101
|
-
return out;
|
|
102
|
-
}
|
|
103
|
-
//#endregion
|
|
104
56
|
//#region src/opaque-key.ts
|
|
105
57
|
const validAesKeyByteLengths = new Set([
|
|
106
58
|
16,
|
|
107
59
|
24,
|
|
108
60
|
32
|
|
109
61
|
]);
|
|
62
|
+
const opaqueKeyInternals = /* @__PURE__ */ new WeakMap();
|
|
63
|
+
/**
|
|
64
|
+
* Imports raw AES key bytes into an {@link OpaqueKey} handle for the Opaque
|
|
65
|
+
* Timestamp codec.
|
|
66
|
+
*
|
|
67
|
+
* Accepts 16, 24, or 32 bytes (AES-128 / AES-192 / AES-256 strength).
|
|
68
|
+
* To store or transport key material, use {@link encodeOpaqueKey} /
|
|
69
|
+
* {@link decodeOpaqueKey} (`"hex"` or `"base64url"` — not Crockford base32).
|
|
70
|
+
*
|
|
71
|
+
* @param bytes - 16, 24, or 32 raw key bytes.
|
|
72
|
+
*/
|
|
73
|
+
async function importOpaqueKey(bytes) {
|
|
74
|
+
assertValidAesKeyByteLength(bytes.length);
|
|
75
|
+
const cryptoKey = await crypto.subtle.importKey("raw", bytes, "AES-CBC", false, ["encrypt", "decrypt"]);
|
|
76
|
+
const key = Object.freeze({});
|
|
77
|
+
opaqueKeyInternals.set(key, cryptoKey);
|
|
78
|
+
return key;
|
|
79
|
+
}
|
|
80
|
+
function getOpaqueKeyCryptoKey(key) {
|
|
81
|
+
const cryptoKey = opaqueKeyInternals.get(key);
|
|
82
|
+
if (cryptoKey === void 0) throw new Error("invalid opaque key");
|
|
83
|
+
return cryptoKey;
|
|
84
|
+
}
|
|
110
85
|
/**
|
|
111
86
|
* Encodes raw AES key bytes for storage in env vars or secret managers.
|
|
112
87
|
*
|
|
@@ -159,28 +134,21 @@ function defaultRng(target) {
|
|
|
159
134
|
crypto.getRandomValues(target);
|
|
160
135
|
}
|
|
161
136
|
/**
|
|
162
|
-
* Imports a raw AES key for use with the Opaque Timestamp codec.
|
|
163
|
-
*
|
|
164
|
-
* @param bytes - Raw key bytes (16, 24, or 32 bytes for AES-128/192/256).
|
|
165
|
-
*/
|
|
166
|
-
function importOpaqueKey(bytes) {
|
|
167
|
-
return crypto.subtle.importKey("raw", bytes, "AES-CBC", false, ["encrypt", "decrypt"]);
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
137
|
* Creates an Opaque Timestamp codec for `brand` (three lowercase a–z characters).
|
|
171
138
|
*
|
|
172
139
|
* @param brand - Entity type brand validated once at construction.
|
|
173
|
-
* @param opts - Required `key`
|
|
140
|
+
* @param opts - Required `key` (an {@link OpaqueKey} from {@link importOpaqueKey}) plus
|
|
141
|
+
* optional `now`, `rng`, and `allowDuplicateBrand` overrides.
|
|
174
142
|
*/
|
|
175
143
|
function createOpaqueTimestampId(brand, opts) {
|
|
176
144
|
validateBrand(brand);
|
|
177
145
|
registerBrand(brand, opts.allowDuplicateBrand);
|
|
178
|
-
const
|
|
146
|
+
const cryptoKey = getOpaqueKeyCryptoKey(opts.key);
|
|
179
147
|
const now = opts.now ?? Date.now;
|
|
180
148
|
const rng = opts.rng ?? defaultRng;
|
|
181
149
|
const prefix = `${brand}_`;
|
|
182
150
|
const wire = wireMethods(prefix);
|
|
183
|
-
const layout = createOpaqueLayoutOps(prefix,
|
|
151
|
+
const layout = createOpaqueLayoutOps(prefix, cryptoKey, rng);
|
|
184
152
|
return {
|
|
185
153
|
generate: () => layout.generateAt(now()),
|
|
186
154
|
generateAt: (date) => layout.generateAt(date.getTime()),
|
|
@@ -193,6 +161,6 @@ function createOpaqueTimestampId(brand, opts) {
|
|
|
193
161
|
};
|
|
194
162
|
}
|
|
195
163
|
//#endregion
|
|
196
|
-
export {
|
|
164
|
+
export { importOpaqueKey as i, decodeOpaqueKey as n, encodeOpaqueKey as r, createOpaqueTimestampId as t };
|
|
197
165
|
|
|
198
|
-
//# sourceMappingURL=opaque-
|
|
166
|
+
//# sourceMappingURL=opaque-goLnFoo7.mjs.map
|