zodbridge 0.2.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/LICENSE +21 -0
- package/README.md +361 -0
- package/dist/async/index.cjs +146 -0
- package/dist/async/index.cjs.map +1 -0
- package/dist/async/index.d.cts +49 -0
- package/dist/async/index.d.ts +49 -0
- package/dist/async/index.js +65 -0
- package/dist/async/index.js.map +1 -0
- package/dist/chunk-3HB2CM5G.js +213 -0
- package/dist/chunk-3HB2CM5G.js.map +1 -0
- package/dist/chunk-7DUCUUPF.js +274 -0
- package/dist/chunk-7DUCUUPF.js.map +1 -0
- package/dist/chunk-U3W6PGFE.js +241 -0
- package/dist/chunk-U3W6PGFE.js.map +1 -0
- package/dist/context-B0f9mQWu.d.cts +140 -0
- package/dist/context-B0f9mQWu.d.ts +140 -0
- package/dist/index.cjs +786 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +204 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/resolver/index.cjs +250 -0
- package/dist/resolver/index.cjs.map +1 -0
- package/dist/resolver/index.d.cts +101 -0
- package/dist/resolver/index.d.ts +101 -0
- package/dist/resolver/index.js +17 -0
- package/dist/resolver/index.js.map +1 -0
- package/dist/rules-msHkZDR8.d.cts +59 -0
- package/dist/rules-msHkZDR8.d.ts +59 -0
- package/dist/serialize/index.cjs +258 -0
- package/dist/serialize/index.cjs.map +1 -0
- package/dist/serialize/index.d.cts +84 -0
- package/dist/serialize/index.d.ts +84 -0
- package/dist/serialize/index.js +3 -0
- package/dist/serialize/index.js.map +1 -0
- package/package.json +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 typescriptmapper contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# zodbridge
|
|
2
|
+
|
|
3
|
+
[](https://github.com/khanhx/zodmapper/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/khanhx/zodmapper)
|
|
5
|
+
[](https://www.npmjs.com/package/zodbridge)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
Schema-first, **decorator-free**, **Zod 4**-driven mapping toolkit for the
|
|
9
|
+
DB-entity ↔ response-DTO boundary. No `reflect-metadata`, no class decorators —
|
|
10
|
+
your Zod schema is the single source of truth.
|
|
11
|
+
|
|
12
|
+
Four composable pillars, each available as its own sub-export:
|
|
13
|
+
|
|
14
|
+
1. **Two-way mapper** (`createMap`) — entity → DTO with auto-reversible renames.
|
|
15
|
+
2. **Serialize / deserialize** — JSON-safe codec (Date ↔ ISO, BigInt ↔ string, Map ↔ entries).
|
|
16
|
+
3. **Resolver graph** (`createResolver`) — dependency-aware, memoizing, cycle-guarded lazy field resolution over an injected fetch adapter.
|
|
17
|
+
4. **Async mapping** (`forwardAsync`) — resolver-backed fields + a dynamic `select` set, validated with async Zod.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install zodbridge zod
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> **Zod 4 required.** `zod` is a peer dependency pinned to `^4`. The codec
|
|
24
|
+
> dispatches on Zod 4's internal `_zod.def.type`; **Zod 3 is not supported.**
|
|
25
|
+
|
|
26
|
+
TypeScript ≥ 5.0 is required for the `const` type parameter `createMap` uses to
|
|
27
|
+
preserve bare rule literals.
|
|
28
|
+
|
|
29
|
+
## Contents
|
|
30
|
+
|
|
31
|
+
- [1. Two-way mapper — `createMap`](#1-two-way-mapper--createmap)
|
|
32
|
+
- [Built-in key case conversion](#built-in-key-case-conversion)
|
|
33
|
+
- [2. Serialize / deserialize](#2-serialize--deserialize)
|
|
34
|
+
- [3. Resolver graph — `createResolver`](#3-resolver-graph--createresolver)
|
|
35
|
+
- [Schema-driven resolvers](#schema-driven-resolvers-validated-output)
|
|
36
|
+
- [4. Async mapping — `forwardAsync`](#4-async-mapping--forwardasync)
|
|
37
|
+
- [Map ops & resolver extras](#map-ops--resolver-extras)
|
|
38
|
+
- [Security](#security)
|
|
39
|
+
- [API surface](#api-surface)
|
|
40
|
+
- [Examples](#examples)
|
|
41
|
+
- [Contributing](#contributing)
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 1. Two-way mapper — `createMap`
|
|
46
|
+
|
|
47
|
+
The dest Zod schema defines the DTO shape. Rules describe how each dest field is
|
|
48
|
+
produced from the source entity:
|
|
49
|
+
|
|
50
|
+
- `'sourceKey'` — string **rename** (auto-reversible, dot-paths supported).
|
|
51
|
+
- `(source) => value` — **computed** function (one-way).
|
|
52
|
+
- `{ to, from }` — explicit **both-directions** (reversible).
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { createMap } from "zodbridge";
|
|
56
|
+
import { z } from "zod";
|
|
57
|
+
|
|
58
|
+
const UserDto = z.object({
|
|
59
|
+
id: z.number(),
|
|
60
|
+
fullName: z.string(),
|
|
61
|
+
createdAt: z.string(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const userMap = createMap(UserDto, {
|
|
65
|
+
fullName: (u: { first: string; last: string }) => `${u.first} ${u.last}`,
|
|
66
|
+
createdAt: "created_at", // rename
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const dto = userMap.forward({ id: 1, first: "Ada", last: "Lovelace", created_at: "2024-01-01" });
|
|
70
|
+
// → { id: 1, fullName: "Ada Lovelace", createdAt: "2024-01-01" } (Zod-validated)
|
|
71
|
+
|
|
72
|
+
userMap.reverse(dto);
|
|
73
|
+
// → { created_at: "2024-01-01" } (only reversible-mapped source fields)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`reverse` emits **only the mapped fields**; a one-way function rule throws
|
|
77
|
+
`OneWayRuleError`. Use a `{ to, from }` pair to make a transform reversible.
|
|
78
|
+
|
|
79
|
+
### Built-in key case conversion
|
|
80
|
+
|
|
81
|
+
Every mapper instance also has `toSnakeCase` / `toCamelCase` — they recursively
|
|
82
|
+
re-case **all** property keys of a value to the target case, no matter the input
|
|
83
|
+
casing (snake, camel, Pascal, kebab, SCREAMING_SNAKE all normalize). Only keys
|
|
84
|
+
change; values pass through. Mixed-casing objects are converted per key — a key
|
|
85
|
+
that can't be tokenized is left as-is.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
userMap.toSnakeCase({ firstName: 1, CreatedAt: 2, "kebab-key": 3 });
|
|
89
|
+
// → { first_name: 1, created_at: 2, kebab_key: 3 }
|
|
90
|
+
|
|
91
|
+
userMap.toCamelCase({ first_name: 1, post_tags: [{ tag_name: "x" }] });
|
|
92
|
+
// → { firstName: 1, postTags: [{ tagName: "x" }] } (recurses objects + arrays)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The same functions are exported standalone (and per-key variants too):
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { toSnakeCase, toCamelCase, toSnakeKey, toCamelKey } from "zodbridge";
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Notes:
|
|
102
|
+
- **Collisions:** if two source keys normalize to the same target (e.g. `userId`
|
|
103
|
+
and `userID` → `user_id`), the last one in iteration order wins.
|
|
104
|
+
- **Safety:** a source key that normalizes to `constructor` or `prototype` is
|
|
105
|
+
dropped (never emitted) to prevent prototype pollution.
|
|
106
|
+
- **Idempotency:** `toSnakeCase` is idempotent. `toCamelCase` is too, except for
|
|
107
|
+
keys that produce adjacent single-letter words (`a-b` → `aBCD` → `aBcd`) — an
|
|
108
|
+
unusual shape for real DTO keys.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 2. Serialize / deserialize
|
|
113
|
+
|
|
114
|
+
JSON-safe round-trip driven by the schema. `serialize` converts rich values to
|
|
115
|
+
JSON-safe plain values; `deserialize` rebuilds them and validates with Zod.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { serialize, deserialize } from "zodbridge/serialize";
|
|
119
|
+
import { z } from "zod";
|
|
120
|
+
|
|
121
|
+
const Event = z.object({ id: z.bigint(), at: z.date(), tally: z.map(z.string(), z.bigint()) });
|
|
122
|
+
|
|
123
|
+
const wire = serialize({ id: 42n, at: new Date(), tally: new Map([["a", 1n]]) }, Event);
|
|
124
|
+
JSON.stringify(wire); // safe — no Date/BigInt/Map left
|
|
125
|
+
|
|
126
|
+
deserialize(wire, Event); // → original rich values, Zod-validated
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`deserialize` is **sync-only**; a schema with async refinements throws
|
|
130
|
+
`AsyncSchemaError`. Malformed wire input surfaces a typed `CodecError` (never a
|
|
131
|
+
raw throw), and BigInt-string length / Map-entry count are bounded against DoS.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## 3. Resolver graph — `createResolver`
|
|
136
|
+
|
|
137
|
+
Declarative, dependency-aware field resolution. Each field's resolver may read
|
|
138
|
+
the seed, call the injected `adapter`, depend on other fields (`resolve`), or
|
|
139
|
+
fall back to alternate sources (`fallback`). Results are **memoized** (one fetch
|
|
140
|
+
per field, even under `Promise.all`), and cycles are guarded.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { createResolver } from "zodbridge/resolver";
|
|
144
|
+
|
|
145
|
+
interface Fields { orgId: string; org: { id: string; plan: string }; plan: string }
|
|
146
|
+
interface Api { getOrg: (id: string) => Promise<{ id: string; plan: string }> }
|
|
147
|
+
|
|
148
|
+
// your injected data source (DB client, HTTP API, etc.)
|
|
149
|
+
const api: Api = {
|
|
150
|
+
getOrg: async (id) => ({ id, plan: "pro" }),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const ctx = createResolver<Fields, Api>({
|
|
154
|
+
adapter: api,
|
|
155
|
+
seed: { orgId: "o1" },
|
|
156
|
+
resolvers: {
|
|
157
|
+
org: (c) => c.adapter.getOrg(c.get("orgId") ?? ""),
|
|
158
|
+
plan: async (c) => (await c.resolve("org"))?.plan,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await ctx.resolve("plan"); // → "pro" (fetches org once, derives plan)
|
|
163
|
+
await ctx.resolveMany("org", "plan"); // { org, plan } — org NOT re-fetched
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- `get(field)` — synchronous peek of seed + already-resolved values (no I/O).
|
|
167
|
+
- `path(field, [..keys])` — pure nested peek (never resolves, never fetches).
|
|
168
|
+
- `fallback([deps])` — resolve untouched deps, then retry the calling field.
|
|
169
|
+
|
|
170
|
+
A resolver that **returns `undefined`** caches `undefined` (forgiving absence).
|
|
171
|
+
A resolver that **throws** rejects and is **not cached** — auth/DB failures stay
|
|
172
|
+
loud and a later retry can succeed.
|
|
173
|
+
|
|
174
|
+
**Cycles never hang.** Mutually dependent fields are safe: if `conversation`'s
|
|
175
|
+
resolver does `await ctx.resolve("reservation")` and `reservation`'s does
|
|
176
|
+
`await ctx.resolve("conversation")`, a re-entry of a field already being resolved
|
|
177
|
+
in the same chain returns `undefined` (so a `strategies` candidate falls through
|
|
178
|
+
to its next route) — it never deadlocks. Concurrent
|
|
179
|
+
`Promise.all` over a shared dependency is unaffected and still dedups to one fetch.
|
|
180
|
+
|
|
181
|
+
### Schema-driven resolvers (validated output)
|
|
182
|
+
|
|
183
|
+
Instead of a hand-written `Fields` type, derive the resolvable fields and their
|
|
184
|
+
schemas from `createMap` instances. Each resolver's **output is validated** against
|
|
185
|
+
its map's schema (unknown keys stripped) before caching — bad adapter data and
|
|
186
|
+
over-exposure are caught at the boundary. Pass scalar dependency fields (with no
|
|
187
|
+
DTO) via an optional `fields` object.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { createResolver } from "zodbridge/resolver";
|
|
191
|
+
import { createMap } from "zodbridge";
|
|
192
|
+
import { z } from "zod";
|
|
193
|
+
|
|
194
|
+
const userMap = createMap(z.object({ id: z.string(), name: z.string() }));
|
|
195
|
+
const orgMap = createMap(z.object({ orgId: z.string(), tier: z.number() }));
|
|
196
|
+
|
|
197
|
+
const api = {
|
|
198
|
+
getOrg: async (orgId: string) => ({ orgId, tier: 2 }),
|
|
199
|
+
getUser: async (orgId: string) => ({ id: `u-${orgId}`, name: "Ada" }),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const ctx = createResolver({
|
|
203
|
+
adapter: api,
|
|
204
|
+
maps: { user: userMap, org: orgMap }, // validated DTO fields
|
|
205
|
+
fields: z.object({ orgId: z.string() }), // scalar deps (no DTO)
|
|
206
|
+
seed: { orgId: "o1" },
|
|
207
|
+
resolvers: {
|
|
208
|
+
org: (c) => c.adapter.getOrg(c.get("orgId") ?? ""), // typed as orgMap's DTO
|
|
209
|
+
user: async (c) => {
|
|
210
|
+
const org = await c.resolve("org"); // chaining
|
|
211
|
+
return c.adapter.getUser(org?.orgId ?? ""); // typed as userMap's DTO
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await ctx.resolve("user"); // → { id: "u-o1", name: "Ada" } (validated, or rejects)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- Resolver output is parsed; a value that fails its schema **rejects and evicts**
|
|
220
|
+
(same loud-failure policy as a thrown resolver).
|
|
221
|
+
- **Seed is not validated** — it is trusted caller input (allows partial/scalar seed).
|
|
222
|
+
- The hand-written `createResolver<Fields, Adapter>(...)` signature still works
|
|
223
|
+
unchanged (no runtime validation when no schema is attached).
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 4. Async mapping — `forwardAsync`
|
|
228
|
+
|
|
229
|
+
Combine the mapper and the resolver. The `fromResolver` rule pulls a field from
|
|
230
|
+
the resolver graph; an optional `select` set restricts which fields (and which
|
|
231
|
+
resolver calls) run.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { createMap, fromResolver } from "zodbridge";
|
|
235
|
+
import { forwardAsync } from "zodbridge/async";
|
|
236
|
+
import { createResolver } from "zodbridge/resolver";
|
|
237
|
+
import { z } from "zod";
|
|
238
|
+
|
|
239
|
+
const PostDto = z.object({
|
|
240
|
+
id: z.string(),
|
|
241
|
+
author: z.object({ id: z.string(), name: z.string() }),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const postMap = createMap(PostDto, { id: "post_id", author: fromResolver("author") });
|
|
245
|
+
|
|
246
|
+
// the resolver that backs the `fromResolver("author")` rule
|
|
247
|
+
const resolver = createResolver<{ author: { id: string; name: string } }, object>({
|
|
248
|
+
adapter: {},
|
|
249
|
+
resolvers: { author: async () => ({ id: "a1", name: "Ada" }) },
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await forwardAsync(postMap, { post_id: "p1" }, { resolver });
|
|
253
|
+
// → { id: "p1", author: { id: "a1", name: "Ada" } } (author resolved + validated)
|
|
254
|
+
|
|
255
|
+
await forwardAsync(postMap, { post_id: "p1" }, { resolver, select: ["id"] });
|
|
256
|
+
// → { id: "p1" } — author's resolver never fires
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Validation uses `safeParseAsync`, so field-level async `.refine` runs. A partial
|
|
260
|
+
`select` validates against a sub-schema derived from the raw `.shape` (Zod 4's
|
|
261
|
+
`.pick()/.partial()` throw on refined schemas, so they are never called).
|
|
262
|
+
Object-level cross-field refines run only on a **full** select. If any
|
|
263
|
+
`fromResolver` resolver throws, the whole `forwardAsync` promise rejects.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Security
|
|
268
|
+
|
|
269
|
+
- **`select` is NOT an authorization boundary.** It is a performance/shape knob.
|
|
270
|
+
Enforce field-level authorization *before* passing any untrusted selection.
|
|
271
|
+
- **Over-exposure.** Resolver-backed fields are parsed through their own dest
|
|
272
|
+
sub-schema, and Zod objects `.strip()` unknown keys by default — so extra
|
|
273
|
+
adapter-row columns (`passwordHash`, …) are dropped. **`z.any()`,
|
|
274
|
+
`.passthrough()`, and `z.record()` DISABLE this** and leak the value verbatim.
|
|
275
|
+
Use closed object schemas for response DTOs.
|
|
276
|
+
- **Resolver errors propagate.** Your adapter must enforce authorization and
|
|
277
|
+
throw on denial; the resolver will not swallow it.
|
|
278
|
+
- **Prototype pollution.** The dot-path helpers reject `__proto__`, `prototype`,
|
|
279
|
+
and `constructor` segments, so mapping untrusted data cannot poison
|
|
280
|
+
`Object.prototype`.
|
|
281
|
+
- **DoS guards.** `deserialize` bounds BigInt-string length and Map-entry count
|
|
282
|
+
before transforming attacker-controlled input.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## API surface
|
|
287
|
+
|
|
288
|
+
| Import path | Exports |
|
|
289
|
+
|-----------------------------|---------|
|
|
290
|
+
| `zodbridge` | `createMap`, `fromResolver`, `withDefault`, `forwardMany`, `safeForward`, `pick`, `omit`, `compose`, `toSnakeCase`/`toCamelCase`/`toKebabCase`/`toPascalCase`/`toConstantCase` (+ per-key), `serialize`, `deserialize`, `deserializeAsync`, errors, `VERSION` |
|
|
291
|
+
| `zodbridge/serialize`| `serialize`, `deserialize`, `deserializeAsync`, `CodecError`, `AsyncSchemaError`, leaf transforms (incl. `setToArray`) |
|
|
292
|
+
| `zodbridge/resolver` | `createResolver`, `strategies`, `SKIP`, `GraphContext`, types |
|
|
293
|
+
| `zodbridge/async` | `forwardAsync`, `normalizeSelect`, `UnknownSelectKeyError` |
|
|
294
|
+
|
|
295
|
+
### Map ops & resolver extras
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
import { createMap, compose, pick, withDefault } from "zodbridge";
|
|
299
|
+
import { createResolver, strategies, SKIP } from "zodbridge/resolver";
|
|
300
|
+
|
|
301
|
+
const UserDto = z.object({ id: z.number(), name: z.string(), role: z.string() });
|
|
302
|
+
const userMap = createMap(UserDto, {
|
|
303
|
+
name: "full_name",
|
|
304
|
+
role: withDefault("member", "user_role"), // default when source missing
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
userMap.forwardMany([{ id: 1, full_name: "Ada" }]); // batch -> validated DTO[]
|
|
308
|
+
userMap.safeForward({ id: "bad" }); // { success: false, error }
|
|
309
|
+
pick(userMap, ["id", "name"]); // sub-DTO view (new map)
|
|
310
|
+
|
|
311
|
+
// resolver: try the fastest source first; first DEFINED value wins.
|
|
312
|
+
// A candidate skips by returning `undefined` or the `SKIP` sentinel — `null`
|
|
313
|
+
// is a winning value (return `SKIP` to skip when the value would be null).
|
|
314
|
+
createResolver({
|
|
315
|
+
adapter,
|
|
316
|
+
resolvers: {
|
|
317
|
+
conversation: strategies(
|
|
318
|
+
(c) => c.adapter.byReservation(c.get("reservationId")), // undefined -> skip
|
|
319
|
+
(c) => c.adapter.byConversationId(c.get("conversationId")) ?? SKIP, // skip even if null
|
|
320
|
+
),
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
// force a fresh fetch (e.g. after a write):
|
|
324
|
+
await ctx.refresh("org");
|
|
325
|
+
|
|
326
|
+
// attach a resolver graph to the map itself:
|
|
327
|
+
const m = createMap(schema, rules, { resolvers: { org: (c) => c.adapter.getOrg() } });
|
|
328
|
+
const ctx = m.toResolver(adapter, seed); // validated against the map's schema
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Ships dual ESM + CJS with `.d.ts` for every entry. Zero runtime dependencies.
|
|
332
|
+
|
|
333
|
+
## Examples
|
|
334
|
+
|
|
335
|
+
Runnable, self-contained use-cases live in [`examples/`](./examples):
|
|
336
|
+
|
|
337
|
+
| # | Example | Shows |
|
|
338
|
+
|---|---------|-------|
|
|
339
|
+
| 01 | basic mapping | `forward`/`reverse`, rename + computed + pair rules |
|
|
340
|
+
| 02 | case conversion | `toSnakeCase`/`toCamelCase`, value preservation |
|
|
341
|
+
| 03 | serialize/deserialize | Date/BigInt/Map/Set JSON round-trip |
|
|
342
|
+
| 04 | resolver graph | memoized resolution + `refresh` |
|
|
343
|
+
| 05 | smart resolve | `strategies()` fastest-first multi-source |
|
|
344
|
+
| 06 | async + select | `forwardAsync` with dynamic `select` |
|
|
345
|
+
| 07 | map ops & views | batch/safe, `withDefault`, `pick`/`omit`/`compose`, `map.toResolver` |
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
npm run example examples/04-resolver-graph.ts # run one (tsx)
|
|
349
|
+
npm run verify:examples # typecheck them all
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Contributing
|
|
353
|
+
|
|
354
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md). In short: TDD, keep the **100% coverage
|
|
355
|
+
gate** green, add a [changeset](https://github.com/changesets/changesets), and
|
|
356
|
+
keep changes additive. Security issues → [SECURITY.md](./SECURITY.md) (private
|
|
357
|
+
reporting, not public issues).
|
|
358
|
+
|
|
359
|
+
## License
|
|
360
|
+
|
|
361
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var zod = require('zod');
|
|
4
|
+
|
|
5
|
+
// src/async/forwardAsync.ts
|
|
6
|
+
|
|
7
|
+
// src/map/rules.ts
|
|
8
|
+
function isStringRule(rule) {
|
|
9
|
+
return typeof rule === "string";
|
|
10
|
+
}
|
|
11
|
+
function isFromResolverRule(rule) {
|
|
12
|
+
return typeof rule === "object" && rule !== null && rule.__kind === "fromResolver";
|
|
13
|
+
}
|
|
14
|
+
function isPairRule(rule) {
|
|
15
|
+
return typeof rule === "object" && rule !== null && typeof rule.to === "function" && typeof rule.from === "function";
|
|
16
|
+
}
|
|
17
|
+
function isDefaultRule(rule) {
|
|
18
|
+
return typeof rule === "object" && rule !== null && rule.__kind === "default";
|
|
19
|
+
}
|
|
20
|
+
function isFnRule(rule) {
|
|
21
|
+
return typeof rule === "function";
|
|
22
|
+
}
|
|
23
|
+
var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
24
|
+
function isForbidden(key) {
|
|
25
|
+
return FORBIDDEN_KEYS.has(key);
|
|
26
|
+
}
|
|
27
|
+
function splitPath(path) {
|
|
28
|
+
return path.split(".");
|
|
29
|
+
}
|
|
30
|
+
function getPath(source, path) {
|
|
31
|
+
let current = source;
|
|
32
|
+
for (const segment of splitPath(path)) {
|
|
33
|
+
if (isForbidden(segment)) return void 0;
|
|
34
|
+
if (current === null || current === void 0) return void 0;
|
|
35
|
+
if (typeof current !== "object") return void 0;
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment)) return void 0;
|
|
37
|
+
current = current[segment];
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
function setPath(target, path, value) {
|
|
42
|
+
const segments = splitPath(path);
|
|
43
|
+
let current = target;
|
|
44
|
+
for (let i = 0; i < segments.length; i++) {
|
|
45
|
+
const segment = segments[i];
|
|
46
|
+
if (isForbidden(segment)) return;
|
|
47
|
+
if (i === segments.length - 1) {
|
|
48
|
+
current[segment] = value;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const next = current[segment];
|
|
52
|
+
if (typeof next !== "object" || next === null) {
|
|
53
|
+
const fresh = {};
|
|
54
|
+
current[segment] = fresh;
|
|
55
|
+
current = fresh;
|
|
56
|
+
} else {
|
|
57
|
+
current = next;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/map/createMap.ts
|
|
63
|
+
function applyForwardRule(rule, destKey, source) {
|
|
64
|
+
if (rule === void 0) {
|
|
65
|
+
return getPath(source, destKey);
|
|
66
|
+
}
|
|
67
|
+
if (isStringRule(rule)) {
|
|
68
|
+
return getPath(source, rule);
|
|
69
|
+
}
|
|
70
|
+
if (isPairRule(rule)) {
|
|
71
|
+
return rule.to(source);
|
|
72
|
+
}
|
|
73
|
+
if (isDefaultRule(rule)) {
|
|
74
|
+
const read = getPath(source, rule.source ?? destKey);
|
|
75
|
+
return read === void 0 ? rule.value : read;
|
|
76
|
+
}
|
|
77
|
+
if (isFnRule(rule)) {
|
|
78
|
+
return rule(source);
|
|
79
|
+
}
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/async/forwardAsync.ts
|
|
84
|
+
var UnknownSelectKeyError = class extends Error {
|
|
85
|
+
keys;
|
|
86
|
+
constructor(keys) {
|
|
87
|
+
super(
|
|
88
|
+
`forwardAsync select contains key(s) not in the dest schema: ${keys.join(", ")}`
|
|
89
|
+
);
|
|
90
|
+
this.name = "UnknownSelectKeyError";
|
|
91
|
+
this.keys = keys;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
function normalizeSelect(select, shape) {
|
|
95
|
+
if (select === void 0) return void 0;
|
|
96
|
+
const deduped = Array.from(new Set(select));
|
|
97
|
+
const unknown = deduped.filter(
|
|
98
|
+
(k) => !Object.prototype.hasOwnProperty.call(shape, k)
|
|
99
|
+
);
|
|
100
|
+
if (unknown.length > 0) throw new UnknownSelectKeyError(unknown);
|
|
101
|
+
return deduped;
|
|
102
|
+
}
|
|
103
|
+
async function stripField(value, fieldSchema) {
|
|
104
|
+
const result = await fieldSchema.safeParseAsync(value);
|
|
105
|
+
if (!result.success) return value;
|
|
106
|
+
return result.data;
|
|
107
|
+
}
|
|
108
|
+
async function forwardAsync(map, source, options) {
|
|
109
|
+
const { resolver, select } = options;
|
|
110
|
+
const { schema, rules, shape } = map;
|
|
111
|
+
const selected = normalizeSelect(select, shape);
|
|
112
|
+
const targetKeys = selected ?? Object.keys(shape);
|
|
113
|
+
const assembled = {};
|
|
114
|
+
for (const key of targetKeys) {
|
|
115
|
+
const rule = rules[key];
|
|
116
|
+
if (isFromResolverRule(rule)) {
|
|
117
|
+
const field = rule.field;
|
|
118
|
+
const raw = await resolver.resolve(field);
|
|
119
|
+
if (raw !== void 0) {
|
|
120
|
+
const value2 = await stripField(raw, shape[key]);
|
|
121
|
+
setPath(assembled, key, value2);
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const produced = applyForwardRule(rule, key, source);
|
|
126
|
+
const value = produced instanceof Promise ? await produced : produced;
|
|
127
|
+
if (value !== void 0) setPath(assembled, key, value);
|
|
128
|
+
}
|
|
129
|
+
const validator = selected === void 0 ? schema : buildSubSchema(shape, selected);
|
|
130
|
+
const result = await validator.safeParseAsync(assembled);
|
|
131
|
+
if (!result.success) throw result.error;
|
|
132
|
+
return result.data;
|
|
133
|
+
}
|
|
134
|
+
function buildSubSchema(shape, selected) {
|
|
135
|
+
const picked = {};
|
|
136
|
+
for (const key of selected) {
|
|
137
|
+
picked[key] = shape[key];
|
|
138
|
+
}
|
|
139
|
+
return zod.z.object(picked);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
exports.UnknownSelectKeyError = UnknownSelectKeyError;
|
|
143
|
+
exports.forwardAsync = forwardAsync;
|
|
144
|
+
exports.normalizeSelect = normalizeSelect;
|
|
145
|
+
//# sourceMappingURL=index.cjs.map
|
|
146
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/map/rules.ts","../../src/map/createMap.ts","../../src/async/forwardAsync.ts"],"names":["value","z"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Rule kinds for {@link createMap} and the dot-path get/set helpers they use.\n *\n * Rule kinds (by JS type):\n * - `string` — rename: copy `source[ruleValue]` to the dest key. Auto-reversible.\n * - `(source) => value` — computed function, one-way (no reverse unless paired).\n * - `{ to, from }` — explicit both-directions, reversible.\n * - `FromResolver` — value pulled from a resolver graph (async path, Phase 5).\n */\n\n/** A function rule: derive a dest value from the whole source object. */\nexport type FnRule<S, V> = (source: S) => V;\n\n/** A reversible rule with explicit forward (`to`) and inverse (`from`) maps. */\nexport interface PairRule<S, V> {\n to: (source: S) => V;\n from: (value: V) => unknown;\n}\n\n/** Marker for a dest field resolved from a resolver graph (consumed by Phase 5). */\nexport interface FromResolver {\n readonly __kind: \"fromResolver\";\n readonly field: string;\n}\n\n/**\n * Default-value rule: read `source` (a source key, defaults to the dest key)\n * and fall back to `value` when the source is `undefined`. Reverse-safe: the\n * default is dropped on reverse and only the read key round-trips.\n */\nexport interface DefaultRule<V = unknown> {\n readonly __kind: \"default\";\n readonly value: V;\n readonly source?: string;\n}\n\n/** Any rule a dest key may be configured with. */\nexport type Rule<S = any, V = any> =\n | string\n | FnRule<S, V>\n | PairRule<S, V>\n | FromResolver\n | DefaultRule<V>;\n\n/** Build a {@link FromResolver} marker for `field` in the resolver graph. */\nexport function fromResolver(field: string): FromResolver {\n return { __kind: \"fromResolver\", field };\n}\n\n/**\n * Build a {@link DefaultRule}: use `source` (or the dest key) from the entity,\n * falling back to `value` when absent.\n */\nexport function withDefault<V>(value: V, source?: string): DefaultRule<V> {\n return { __kind: \"default\", value, source };\n}\n\n// --- type guards ---\n\nexport function isStringRule(rule: Rule): rule is string {\n return typeof rule === \"string\";\n}\n\nexport function isFromResolverRule(rule: Rule): rule is FromResolver {\n return (\n typeof rule === \"object\" &&\n rule !== null &&\n (rule as FromResolver).__kind === \"fromResolver\"\n );\n}\n\nexport function isPairRule(rule: Rule): rule is PairRule<any, any> {\n return (\n typeof rule === \"object\" &&\n rule !== null &&\n typeof (rule as PairRule<any, any>).to === \"function\" &&\n typeof (rule as PairRule<any, any>).from === \"function\"\n );\n}\n\nexport function isDefaultRule(rule: Rule): rule is DefaultRule {\n return (\n typeof rule === \"object\" &&\n rule !== null &&\n (rule as DefaultRule).__kind === \"default\"\n );\n}\n\nexport function isFnRule(rule: Rule): rule is FnRule<any, any> {\n return typeof rule === \"function\";\n}\n\n// --- prototype-pollution-safe dot-path helpers ---\n\n/** Path segments that, if written, could poison `Object.prototype`. */\nconst FORBIDDEN_KEYS = new Set([\"__proto__\", \"prototype\", \"constructor\"]);\n\nfunction isForbidden(key: string): boolean {\n return FORBIDDEN_KEYS.has(key);\n}\n\n/** Split a dot-path into segments. A plain key (no dot) yields a single segment. */\nexport function splitPath(path: string): string[] {\n return path.split(\".\");\n}\n\n/**\n * Read a nested value by dot-path. Returns `undefined` if any segment is\n * missing or if a segment is a forbidden prototype key (read is skipped, not\n * served from the prototype chain).\n */\nexport function getPath(source: unknown, path: string): unknown {\n let current: unknown = source;\n for (const segment of splitPath(path)) {\n if (isForbidden(segment)) return undefined;\n if (current === null || current === undefined) return undefined;\n if (typeof current !== \"object\") return undefined;\n if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined;\n current = (current as Record<string, unknown>)[segment];\n }\n return current;\n}\n\n/**\n * Write a nested value by dot-path, auto-vivifying intermediate objects.\n * Forbidden prototype keys at any segment are skipped entirely, so a hostile\n * `__proto__.isAdmin` path can never mutate `Object.prototype`.\n */\nexport function setPath(target: Record<string, unknown>, path: string, value: unknown): void {\n const segments = splitPath(path);\n let current: Record<string, unknown> = target;\n for (let i = 0; i < segments.length; i++) {\n const segment = segments[i] as string;\n if (isForbidden(segment)) return;\n if (i === segments.length - 1) {\n current[segment] = value;\n return;\n }\n const next = current[segment];\n if (typeof next !== \"object\" || next === null) {\n const fresh: Record<string, unknown> = {};\n current[segment] = fresh;\n current = fresh;\n } else {\n current = next as Record<string, unknown>;\n }\n }\n}\n","/**\n * `createMap(destSchema, rules)` — synchronous, Zod-validated entity -> DTO\n * mapper. The dest Zod schema is the shape source of truth. Simple string\n * renames and `{ to, from }` pairs auto-reverse; computed function rules are\n * one-way unless paired.\n */\nimport type { z } from \"zod\";\nimport { createResolver } from \"../resolver/createResolver.js\";\nimport type { ResolverContext, ResolverRegistry } from \"../resolver/context.js\";\nimport {\n type CamelCased,\n type SnakeCased,\n toCamelCase,\n toSnakeCase,\n} from \"./case-convert.js\";\nimport {\n type SafeResult,\n forwardMany as forwardManyOp,\n reverseMany as reverseManyOp,\n safeForward as safeForwardOp,\n} from \"./map-ops.js\";\nimport {\n type DefaultRule,\n type FromResolver,\n type Rule,\n getPath,\n isDefaultRule,\n isFnRule,\n isFromResolverRule,\n isPairRule,\n isStringRule,\n setPath,\n} from \"./rules.js\";\n\n/**\n * Resolver graph declared on a map: each field of the dest DTO `Dest` may have\n * a resolver typed against `Dest` and the adapter `TAdapter`.\n */\nexport type MapResolvers<Dest = Record<string, unknown>, TAdapter = unknown> =\n ResolverRegistry<Dest, TAdapter>;\n\n/** Options for {@link createMap}, parameterized by the dest DTO and adapter type. */\nexport interface CreateMapOptions<Dest = Record<string, unknown>, TAdapter = unknown> {\n /**\n * Resolver graph for this map's fields, against the map's own dest schema.\n * Enables `map.toResolver(adapter)` and resolver-backed `forwardAsync` without\n * a separate `createResolver` call. Each resolver's output is typed against the\n * DTO field it produces. NOTE: `ctx.adapter` is typed as the concrete adapter\n * only at the `toResolver(adapter)` call site — inside these declarations it is\n * `unknown` (the adapter is not known at `createMap` time); cast it if needed.\n */\n resolvers?: MapResolvers<Dest, TAdapter>;\n}\n\n/** Thrown when `reverse` meets a one-way function rule that has no inverse. */\nexport class OneWayRuleError extends Error {\n readonly key: string;\n constructor(key: string) {\n super(\n `Cannot reverse dest key \"${key}\": it is mapped by a one-way function ` +\n `rule. Use a { to, from } pair rule to make it reversible.`,\n );\n this.name = \"OneWayRuleError\";\n this.key = key;\n }\n}\n\n/** Thrown when `toResolver` is called on a map that declared no resolvers. */\nexport class MapHasNoResolversError extends Error {\n constructor() {\n super(\n \"createMap.toResolver requires a resolver graph: pass \" +\n \"`createMap(schema, rules, { resolvers })`.\",\n );\n this.name = \"MapHasNoResolversError\";\n }\n}\n\n/** Thrown at build time when two rules reverse onto the same source key. */\nexport class ReverseKeyCollisionError extends Error {\n constructor(sourceKey: string, a: string, b: string) {\n super(\n `Reverse-key collision: dest keys \"${a}\" and \"${b}\" both map back to ` +\n `source key \"${sourceKey}\". Disambiguate one of them.`,\n );\n this.name = \"ReverseKeyCollisionError\";\n }\n}\n\ntype AnyZodObject = z.ZodObject<any>;\n\n/** Rule map: each dest key may carry a rule; unmapped keys are copied by name. */\nexport type RuleMap<Dest> = Partial<Record<keyof Dest & string, Rule>>;\n\nexport interface TypeMapper<S extends AnyZodObject, R extends RuleMap<z.infer<S>>> {\n /** Build a validated DTO from a source entity (sync path). */\n forward(source: Record<string, unknown>): z.infer<S>;\n /** Forward every item in a list, returning the validated DTO array. */\n forwardMany(sources: Array<Record<string, unknown>>): Array<z.infer<S>>;\n /** Forward without throwing: `{ success, data }` or `{ success: false, error }`. */\n safeForward(source: Record<string, unknown>): SafeResult<z.infer<S>>;\n /** Invert reversible rules; returns only mapped source fields. */\n reverse(dto: z.infer<S>): Partial<Record<string, unknown>>;\n /** Reverse every DTO in a list. */\n reverseMany(dtos: Array<z.infer<S>>): Array<Partial<Record<string, unknown>>>;\n /**\n * Recursively re-case ALL property keys of `value` to `snake_case`,\n * regardless of their original casing. Keys only; values pass through.\n */\n toSnakeCase<T>(value: T): SnakeCased<T>;\n /**\n * Recursively re-case ALL property keys of `value` to `camelCase`,\n * regardless of their original casing. Keys only; values pass through.\n */\n toCamelCase<T>(value: T): CamelCased<T>;\n /**\n * Build a resolver graph from THIS map's schema fields plus the resolver\n * graph declared in `createMap`'s options. The resolver validates each field's\n * output against the matching dest sub-schema (over-exposure protection).\n * Throws if no `resolvers` were declared on the map.\n */\n toResolver<TAdapter>(\n adapter: TAdapter,\n seed?: Partial<z.infer<S>>,\n ): ResolverContext<z.infer<S>, TAdapter>;\n /** The dest schema (shape source of truth). */\n readonly schema: S;\n /** Configured rules, frozen. */\n readonly rules: R;\n /** Raw `.shape` of the dest object — used by the async path (Phase 5). */\n readonly shape: Record<string, z.ZodType>;\n /** Resolver graph declared on this map (if any), for `toResolver`/forwardAsync. */\n readonly resolvers?: MapResolvers<z.infer<S>>;\n}\n\n/** Apply a single forward rule for `destKey`, returning the mapped value. */\nexport function applyForwardRule(\n rule: Rule | undefined,\n destKey: string,\n source: Record<string, unknown>,\n): unknown {\n if (rule === undefined) {\n // No rule: copy by matching dest key from source (dot-path aware).\n return getPath(source, destKey);\n }\n if (isStringRule(rule)) {\n return getPath(source, rule);\n }\n if (isPairRule(rule)) {\n return rule.to(source);\n }\n if (isDefaultRule(rule)) {\n const read = getPath(source, rule.source ?? destKey);\n return read === undefined ? rule.value : read;\n }\n if (isFnRule(rule)) {\n return rule(source);\n }\n // FromResolver markers are resolved only on the async path; sync forward\n // treats them as absent (the field is filled by forwardAsync).\n return undefined;\n}\n\n/**\n * Build a two-way mapper. The `const` type parameter on `R` preserves bare\n * object-literal rule values (string-literal renames) without the caller\n * writing `as const` (requires TypeScript >= 5.0).\n */\nexport function createMap<\n S extends AnyZodObject,\n const R extends RuleMap<z.infer<S>>,\n>(\n schema: S,\n rules: R = {} as R,\n options: CreateMapOptions<z.infer<S>> = {},\n): TypeMapper<S, R> {\n const shape = schema.shape as Record<string, z.ZodType>;\n const destKeys = Object.keys(shape);\n const mapResolvers = options.resolvers;\n\n // Build-time reverse-collision detection: any two reversible rules whose\n // inverse targets the same source key are a configuration error.\n const reverseTargets = new Map<string, string>();\n for (const destKey of Object.keys(rules) as Array<keyof R & string>) {\n const rule = rules[destKey] as Rule | undefined;\n if (rule !== undefined && isStringRule(rule)) {\n const existing = reverseTargets.get(rule);\n if (existing) throw new ReverseKeyCollisionError(rule, existing, destKey);\n reverseTargets.set(rule, destKey);\n }\n }\n\n function forward(source: Record<string, unknown>): z.infer<S> {\n const out: Record<string, unknown> = {};\n for (const destKey of destKeys) {\n const rule = rules[destKey as keyof R & string] as Rule | undefined;\n if (isFromResolverRule(rule as Rule)) continue; // async-only\n const value = applyForwardRule(rule, destKey, source);\n if (value !== undefined) setPath(out, destKey, value);\n }\n return schema.parse(out) as z.infer<S>;\n }\n\n function reverse(dto: z.infer<S>): Partial<Record<string, unknown>> {\n const out: Record<string, unknown> = {};\n const dtoObj = dto as Record<string, unknown>;\n for (const destKey of Object.keys(rules) as Array<keyof R & string>) {\n const rule = rules[destKey] as Rule | undefined;\n if (rule === undefined) continue;\n const destValue = getPath(dtoObj, destKey);\n if (isStringRule(rule)) {\n setPath(out, rule, destValue);\n } else if (isPairRule(rule)) {\n setPath(out, destKey, rule.from(destValue));\n } else if (isDefaultRule(rule)) {\n // Round-trip the read key; the default itself is forward-only.\n setPath(out, (rule as DefaultRule).source ?? destKey, destValue);\n } else if (isFromResolverRule(rule as Rule)) {\n // Resolver-backed fields have no source inverse; skip.\n continue;\n } else {\n // One-way function rule, no inverse.\n throw new OneWayRuleError(destKey);\n }\n }\n return out;\n }\n\n type Dest = z.infer<S>;\n\n function toResolver<TAdapter>(\n adapter: TAdapter,\n seed?: Partial<Dest>,\n ): ResolverContext<Dest, TAdapter> {\n if (!mapResolvers) {\n throw new MapHasNoResolversError();\n }\n // Per-field schemas from this map's own shape (over-exposure protection).\n const schemas: Record<string, z.ZodType> = {};\n for (const key of Object.keys(shape)) {\n schemas[key] = shape[key] as z.ZodType;\n }\n return createResolver<Dest, TAdapter>({\n adapter,\n seed,\n resolvers: mapResolvers as ResolverRegistry<Dest, TAdapter>,\n schemas: schemas as Record<keyof Dest, z.ZodType>,\n });\n }\n\n const mapper: TypeMapper<S, R> = {\n forward,\n reverse,\n forwardMany: (sources) => forwardManyOp(mapper, sources) as Array<Dest>,\n reverseMany: (dtos) => reverseManyOp(mapper, dtos),\n safeForward: (source) => safeForwardOp(mapper, source) as SafeResult<Dest>,\n toSnakeCase,\n toCamelCase,\n toResolver,\n schema,\n rules: Object.freeze({ ...rules }) as R,\n shape,\n resolvers: mapResolvers,\n };\n return mapper;\n}\n\nexport type { FromResolver };\n","/**\n * `forwardAsync` — the async mapping path. Consumes a resolver graph (Phase 4)\n * for `fromResolver` fields, honors a dynamic `select` set so only requested\n * fields (and their resolver deps) do I/O, and validates with Zod\n * `safeParseAsync` (so async `.refine` runs).\n *\n * Zod 4 constraint: `.pick()`/`.partial()` THROW on object schemas containing\n * refinements, which is exactly the async-refined shape this targets. So a\n * partial `select` is validated against a sub-schema built from the RAW\n * `.shape` (`z.object(pickedShape)`), never by calling `.pick()` on the refined\n * wrapper. Field-level refines on selected fields still run; object-level\n * cross-field refines run only on a full select (documented limitation).\n */\nimport { z } from \"zod\";\nimport { applyForwardRule } from \"../map/createMap.js\";\nimport {\n type FromResolver,\n type Rule,\n isFromResolverRule,\n setPath,\n} from \"../map/rules.js\";\nimport type { ResolverContext } from \"../resolver/context.js\";\n\n/** Thrown when `select` names a key absent from the dest schema. */\nexport class UnknownSelectKeyError extends Error {\n readonly keys: string[];\n constructor(keys: string[]) {\n super(\n `forwardAsync select contains key(s) not in the dest schema: ${keys.join(\", \")}`,\n );\n this.name = \"UnknownSelectKeyError\";\n this.keys = keys;\n }\n}\n\ntype AnyZodObject = z.ZodObject<any>;\n\ninterface ForwardAsyncMap<S extends AnyZodObject> {\n readonly schema: S;\n readonly rules: Record<string, Rule | undefined>;\n readonly shape: Record<string, z.ZodType>;\n}\n\nexport interface ForwardAsyncOptions<Fields, TAdapter> {\n resolver: ResolverContext<Fields, TAdapter>;\n /** Only these dest keys are computed; unselected resolvers never fire. */\n select?: string[];\n /** Opaque extra passed to sync function rules via the source (unused hook). */\n context?: unknown;\n}\n\n/**\n * Validate and de-duplicate a `select` list against the dest shape.\n * Unknown keys -> typed {@link UnknownSelectKeyError} (never a raw Zod trace).\n */\nexport function normalizeSelect(\n select: string[] | undefined,\n shape: Record<string, z.ZodType>,\n): string[] | undefined {\n if (select === undefined) return undefined;\n const deduped = Array.from(new Set(select));\n const unknown = deduped.filter(\n (k) => !Object.prototype.hasOwnProperty.call(shape, k),\n );\n if (unknown.length > 0) throw new UnknownSelectKeyError(unknown);\n return deduped;\n}\n\n/**\n * Strip a resolver-produced value through its own dest field sub-schema so\n * unknown adapter-row keys (e.g. `passwordHash`) are dropped. NOTE: a field\n * typed `z.any()`/`.passthrough()`/`z.record()` disables this — those leak the\n * value verbatim by design (documented in the README security section).\n */\nasync function stripField(value: unknown, fieldSchema: z.ZodType): Promise<unknown> {\n const result = await fieldSchema.safeParseAsync(value);\n if (!result.success) return value; // let the full validation surface the error\n return result.data;\n}\n\n/**\n * Build a validated DTO asynchronously. `fromResolver` fields pull from the\n * resolver; a resolver throw rejects the whole promise (fail-fast). Returns the\n * full DTO, or a `Partial<Dto>` when `select` is given.\n */\nexport async function forwardAsync<S extends AnyZodObject, Fields, TAdapter>(\n map: ForwardAsyncMap<S>,\n source: Record<string, unknown>,\n options: ForwardAsyncOptions<Fields, TAdapter>,\n): Promise<Partial<z.infer<S>>> {\n const { resolver, select } = options;\n const { schema, rules, shape } = map;\n\n const selected = normalizeSelect(select, shape);\n const targetKeys = selected ?? Object.keys(shape);\n\n // Fields resolve sequentially (await per key). The resolver's promise-cache\n // (Phase 4) makes Promise.all safe too, but sequential keeps resolver\n // invocations non-overlapping and is the documented default.\n const assembled: Record<string, unknown> = {};\n for (const key of targetKeys) {\n const rule = rules[key];\n if (isFromResolverRule(rule as Rule)) {\n const field = (rule as FromResolver).field;\n // A resolver throw propagates out of forwardAsync (fail-fast).\n const raw = await resolver.resolve(field as keyof Fields);\n if (raw !== undefined) {\n // `key` is always a key of `shape` (it came from shape keys or a\n // select list validated against shape), so its field schema exists.\n const value = await stripField(raw, shape[key] as z.ZodType);\n setPath(assembled, key, value);\n }\n continue;\n }\n // Sync/computed rules reuse the Phase 3 primitive; an async function rule\n // returns a promise, which we await.\n const produced = applyForwardRule(rule, key, source);\n const value = produced instanceof Promise ? await produced : produced;\n if (value !== undefined) setPath(assembled, key, value);\n }\n\n // Validate. Full select -> the original schema (all refines run). Partial\n // select -> a sub-schema built from raw .shape (avoids .pick on refined).\n const validator =\n selected === undefined ? schema : buildSubSchema(shape, selected);\n const result = await validator.safeParseAsync(assembled);\n if (!result.success) throw result.error;\n return result.data as Partial<z.infer<S>>;\n}\n\n/**\n * Construct a partial-select validator from the dest schema's raw `.shape`,\n * via `z.object(pickedShape)`. Refined wrappers are never `.pick()`-ed (which\n * throws in Zod 4); object-level cross-field refines are intentionally omitted\n * here and run only on a full select.\n */\nfunction buildSubSchema(\n shape: Record<string, z.ZodType>,\n selected: string[],\n): z.ZodType {\n const picked: Record<string, z.ZodType> = {};\n for (const key of selected) {\n // `selected` was validated against `shape` by normalizeSelect, so each\n // key is present.\n picked[key] = shape[key] as z.ZodType;\n }\n return z.object(picked);\n}\n"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { R as Rule } from '../rules-msHkZDR8.cjs';
|
|
3
|
+
import { R as ResolverContext } from '../context-B0f9mQWu.cjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `forwardAsync` — the async mapping path. Consumes a resolver graph (Phase 4)
|
|
7
|
+
* for `fromResolver` fields, honors a dynamic `select` set so only requested
|
|
8
|
+
* fields (and their resolver deps) do I/O, and validates with Zod
|
|
9
|
+
* `safeParseAsync` (so async `.refine` runs).
|
|
10
|
+
*
|
|
11
|
+
* Zod 4 constraint: `.pick()`/`.partial()` THROW on object schemas containing
|
|
12
|
+
* refinements, which is exactly the async-refined shape this targets. So a
|
|
13
|
+
* partial `select` is validated against a sub-schema built from the RAW
|
|
14
|
+
* `.shape` (`z.object(pickedShape)`), never by calling `.pick()` on the refined
|
|
15
|
+
* wrapper. Field-level refines on selected fields still run; object-level
|
|
16
|
+
* cross-field refines run only on a full select (documented limitation).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Thrown when `select` names a key absent from the dest schema. */
|
|
20
|
+
declare class UnknownSelectKeyError extends Error {
|
|
21
|
+
readonly keys: string[];
|
|
22
|
+
constructor(keys: string[]);
|
|
23
|
+
}
|
|
24
|
+
type AnyZodObject = z.ZodObject<any>;
|
|
25
|
+
interface ForwardAsyncMap<S extends AnyZodObject> {
|
|
26
|
+
readonly schema: S;
|
|
27
|
+
readonly rules: Record<string, Rule | undefined>;
|
|
28
|
+
readonly shape: Record<string, z.ZodType>;
|
|
29
|
+
}
|
|
30
|
+
interface ForwardAsyncOptions<Fields, TAdapter> {
|
|
31
|
+
resolver: ResolverContext<Fields, TAdapter>;
|
|
32
|
+
/** Only these dest keys are computed; unselected resolvers never fire. */
|
|
33
|
+
select?: string[];
|
|
34
|
+
/** Opaque extra passed to sync function rules via the source (unused hook). */
|
|
35
|
+
context?: unknown;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validate and de-duplicate a `select` list against the dest shape.
|
|
39
|
+
* Unknown keys -> typed {@link UnknownSelectKeyError} (never a raw Zod trace).
|
|
40
|
+
*/
|
|
41
|
+
declare function normalizeSelect(select: string[] | undefined, shape: Record<string, z.ZodType>): string[] | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Build a validated DTO asynchronously. `fromResolver` fields pull from the
|
|
44
|
+
* resolver; a resolver throw rejects the whole promise (fail-fast). Returns the
|
|
45
|
+
* full DTO, or a `Partial<Dto>` when `select` is given.
|
|
46
|
+
*/
|
|
47
|
+
declare function forwardAsync<S extends AnyZodObject, Fields, TAdapter>(map: ForwardAsyncMap<S>, source: Record<string, unknown>, options: ForwardAsyncOptions<Fields, TAdapter>): Promise<Partial<z.infer<S>>>;
|
|
48
|
+
|
|
49
|
+
export { type ForwardAsyncOptions, UnknownSelectKeyError, forwardAsync, normalizeSelect };
|