@opensaas/stack-core 0.37.0 → 0.39.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +191 -0
- package/CLAUDE.md +42 -3
- package/dist/access/access-filter.d.ts +70 -113
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +183 -210
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +62 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +61 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +91 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -5
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +89 -41
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +223 -215
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +55 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +41 -117
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +236 -272
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +117 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +97 -12
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +99 -45
- package/src/access/index.ts +17 -5
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +3 -11
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +311 -276
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +82 -235
- package/src/query/relationship-options.ts +1 -1
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +727 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/query/index.ts
CHANGED
|
@@ -4,15 +4,6 @@ import { getDbKey } from '../lib/case-utils.js'
|
|
|
4
4
|
// Internal helpers
|
|
5
5
|
// ─────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Unwrap the item type from a field type, stripping null, undefined, and
|
|
9
|
-
* Array wrappers so we can constrain nested fragment shapes.
|
|
10
|
-
*
|
|
11
|
-
* Examples:
|
|
12
|
-
* User | null → User
|
|
13
|
-
* User[] → User
|
|
14
|
-
* (User | null)[] → User
|
|
15
|
-
*/
|
|
16
7
|
type UnwrapItem<T> = NonNullable<T> extends Array<infer U> ? NonNullable<U> : NonNullable<T>
|
|
17
8
|
|
|
18
9
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -20,44 +11,9 @@ type UnwrapItem<T> = NonNullable<T> extends Array<infer U> ? NonNullable<U> : No
|
|
|
20
11
|
// ─────────────────────────────────────────────────────────────
|
|
21
12
|
|
|
22
13
|
/**
|
|
23
|
-
* A selector for a relationship field
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* 1. A `Fragment` directly (shorthand — no extra Prisma args on the nested query).
|
|
27
|
-
* 2. An object `{ query, where?, orderBy?, take?, skip? }` to combine a fragment
|
|
28
|
-
* with Prisma filter/ordering/pagination applied to the nested relationship.
|
|
29
|
-
*
|
|
30
|
-
* @example Shorthand (most common)
|
|
31
|
-
* ```ts
|
|
32
|
-
* const postFrag = defineFragment<Post>()({
|
|
33
|
-
* id: true,
|
|
34
|
-
* author: authorFragment, // shorthand
|
|
35
|
-
* } as const)
|
|
36
|
-
* ```
|
|
37
|
-
*
|
|
38
|
-
* @example With nested filtering
|
|
39
|
-
* ```ts
|
|
40
|
-
* const postFrag = defineFragment<Post>()({
|
|
41
|
-
* id: true,
|
|
42
|
-
* comments: {
|
|
43
|
-
* query: commentFragment,
|
|
44
|
-
* where: { approved: true },
|
|
45
|
-
* orderBy: { createdAt: 'desc' },
|
|
46
|
-
* take: 5,
|
|
47
|
-
* },
|
|
48
|
-
* } as const)
|
|
49
|
-
* ```
|
|
50
|
-
*
|
|
51
|
-
* @example Variables via factory function
|
|
52
|
-
* ```ts
|
|
53
|
-
* function makePostFragment(status: string) {
|
|
54
|
-
* return defineFragment<Post>()({
|
|
55
|
-
* id: true,
|
|
56
|
-
* comments: { query: commentFragment, where: { status } },
|
|
57
|
-
* } as const)
|
|
58
|
-
* }
|
|
59
|
-
* type PostData = ResultOf<ReturnType<typeof makePostFragment>>
|
|
60
|
-
* ```
|
|
14
|
+
* A selector for a relationship field: either a {@link Fragment} directly
|
|
15
|
+
* (shorthand), or `{ query, where?, orderBy?, take?, skip? }` to combine a
|
|
16
|
+
* fragment with Prisma filter/ordering/pagination on the nested relationship.
|
|
61
17
|
*/
|
|
62
18
|
export type RelationSelector<TRelated extends Record<string, unknown>> =
|
|
63
19
|
| Fragment<TRelated, FieldSelection<TRelated>>
|
|
@@ -70,26 +26,9 @@ export type RelationSelector<TRelated extends Record<string, unknown>> =
|
|
|
70
26
|
}
|
|
71
27
|
|
|
72
28
|
/**
|
|
73
|
-
* A field selection for model type `TItem`.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* - `true` — include the scalar/primitive field as-is
|
|
77
|
-
* - A `Fragment` — include a relationship and recurse (shorthand)
|
|
78
|
-
* - A `RelationSelector` — include a relationship with optional Prisma filter/ordering
|
|
79
|
-
*
|
|
80
|
-
* Only keys present in `TItem` are accepted. For relationship (object) fields
|
|
81
|
-
* you may pass a Fragment, a RelationSelector, or `true` (returns the raw Prisma
|
|
82
|
-
* value and loses type narrowing).
|
|
83
|
-
*
|
|
84
|
-
* @example
|
|
85
|
-
* ```ts
|
|
86
|
-
* const sel: FieldSelection<Post> = {
|
|
87
|
-
* id: true,
|
|
88
|
-
* title: true,
|
|
89
|
-
* author: authorFragment,
|
|
90
|
-
* comments: { query: commentFragment, where: { approved: true } },
|
|
91
|
-
* }
|
|
92
|
-
* ```
|
|
29
|
+
* A field selection for model type `TItem`. Passing `true` for a
|
|
30
|
+
* relationship field returns the raw Prisma value and loses type narrowing —
|
|
31
|
+
* use a {@link Fragment} or {@link RelationSelector} to keep it typed.
|
|
93
32
|
*/
|
|
94
33
|
export type FieldSelection<T> = {
|
|
95
34
|
readonly [K in keyof T]?: UnwrapItem<T[K]> extends Record<string, unknown>
|
|
@@ -99,19 +38,7 @@ export type FieldSelection<T> = {
|
|
|
99
38
|
|
|
100
39
|
/**
|
|
101
40
|
* A reusable, composable field-selection descriptor for model type `TItem`.
|
|
102
|
-
*
|
|
103
|
-
* Create with {@link defineFragment}. Compose by referencing another Fragment
|
|
104
|
-
* (or a {@link RelationSelector}) as the value for a relationship key.
|
|
105
|
-
*
|
|
106
|
-
* @example
|
|
107
|
-
* ```ts
|
|
108
|
-
* const userFragment = defineFragment<User>()({ id: true, name: true } as const)
|
|
109
|
-
* const postFragment = defineFragment<Post>()({
|
|
110
|
-
* id: true,
|
|
111
|
-
* title: true,
|
|
112
|
-
* author: userFragment,
|
|
113
|
-
* } as const)
|
|
114
|
-
* ```
|
|
41
|
+
* Create with {@link defineFragment}.
|
|
115
42
|
*/
|
|
116
43
|
export type Fragment<TItem, TFields extends FieldSelection<TItem> = FieldSelection<TItem>> = {
|
|
117
44
|
readonly _type: 'fragment'
|
|
@@ -122,11 +49,7 @@ export type Fragment<TItem, TFields extends FieldSelection<TItem> = FieldSelecti
|
|
|
122
49
|
// Internal type helpers
|
|
123
50
|
// ─────────────────────────────────────────────────────────────
|
|
124
51
|
|
|
125
|
-
/**
|
|
126
|
-
* @internal
|
|
127
|
-
* Extract the Fragment from either a Fragment directly or a RelationSelector object.
|
|
128
|
-
* Returns `never` for scalar `true` selections (so they fall to the scalar branch).
|
|
129
|
-
*/
|
|
52
|
+
/** @internal Returns `never` for scalar `true` selections, so they fall to the scalar branch in {@link SelectedFields}. */
|
|
130
53
|
type ExtractFragment<TSelector> =
|
|
131
54
|
TSelector extends Fragment<infer TItem, infer TFields>
|
|
132
55
|
? Fragment<TItem, TFields>
|
|
@@ -134,16 +57,12 @@ type ExtractFragment<TSelector> =
|
|
|
134
57
|
? Fragment<TItem, TFields>
|
|
135
58
|
: never
|
|
136
59
|
|
|
137
|
-
/**
|
|
138
|
-
* @internal
|
|
139
|
-
* Map a FieldSelection over a model type, computing the picked output type.
|
|
140
|
-
*/
|
|
60
|
+
/** @internal */
|
|
141
61
|
type SelectedFields<TItem, TFields extends FieldSelection<TItem>> = {
|
|
142
62
|
[K in keyof TFields & keyof TItem]: [ExtractFragment<TFields[K]>] extends [never]
|
|
143
|
-
? //
|
|
63
|
+
? // tuple wrapping avoids the vacuous `never extends T` pitfall
|
|
144
64
|
TItem[K]
|
|
145
|
-
:
|
|
146
|
-
TItem[K] extends Array<unknown>
|
|
65
|
+
: TItem[K] extends Array<unknown>
|
|
147
66
|
? ResultOf<ExtractFragment<TFields[K]>>[]
|
|
148
67
|
: null extends TItem[K]
|
|
149
68
|
? ResultOf<ExtractFragment<TFields[K]>> | null
|
|
@@ -157,24 +76,8 @@ type SelectedFields<TItem, TFields extends FieldSelection<TItem>> = {
|
|
|
157
76
|
// ─────────────────────────────────────────────────────────────
|
|
158
77
|
|
|
159
78
|
/**
|
|
160
|
-
* Infer the TypeScript result type from a Fragment
|
|
161
|
-
*
|
|
162
|
-
* Analogous to `gql.tada`'s `ResultOf` helper — given a fragment definition,
|
|
163
|
-
* `ResultOf` tells you exactly what shape you will receive at runtime.
|
|
164
|
-
*
|
|
165
|
-
* - Scalar fields selected with `true` retain their original Prisma type.
|
|
166
|
-
* - Relationship fields selected with a nested Fragment/RelationSelector are
|
|
167
|
-
* recursively narrowed.
|
|
168
|
-
* - Nullability and array wrappers from the original model type are preserved.
|
|
169
|
-
*
|
|
170
|
-
* @example
|
|
171
|
-
* ```ts
|
|
172
|
-
* type UserData = ResultOf<typeof userFragment>
|
|
173
|
-
* // → { id: string; name: string }
|
|
174
|
-
*
|
|
175
|
-
* type PostData = ResultOf<typeof postFragment>
|
|
176
|
-
* // → { id: string; title: string; author: { id: string; name: string } | null }
|
|
177
|
-
* ```
|
|
79
|
+
* Infer the TypeScript result type from a {@link Fragment} — the shape
|
|
80
|
+
* `runQuery`/`runQueryOne` return at runtime for that fragment.
|
|
178
81
|
*/
|
|
179
82
|
export type ResultOf<F> =
|
|
180
83
|
F extends Fragment<infer TItem, infer TFields> ? SelectedFields<TItem, TFields> : never
|
|
@@ -183,20 +86,14 @@ export type ResultOf<F> =
|
|
|
183
86
|
* Arguments accepted by {@link runQuery}.
|
|
184
87
|
*/
|
|
185
88
|
export type QueryArgs = {
|
|
186
|
-
/** Prisma where filter
|
|
89
|
+
/** Prisma where filter — the access control layer additionally scopes results. */
|
|
187
90
|
where?: Record<string, unknown>
|
|
188
|
-
/** Prisma orderBy clause. Pass a single object or an array for multi-column ordering. */
|
|
189
91
|
orderBy?: Record<string, 'asc' | 'desc'> | Array<Record<string, 'asc' | 'desc'>>
|
|
190
|
-
/** Maximum number of records to return. */
|
|
191
92
|
take?: number
|
|
192
|
-
/** Number of records to skip (for pagination). */
|
|
193
93
|
skip?: number
|
|
194
94
|
}
|
|
195
95
|
|
|
196
|
-
/**
|
|
197
|
-
* Minimal context shape required by the query runners.
|
|
198
|
-
* Compatible with the full `AccessContext` produced by `getContext()`.
|
|
199
|
-
*/
|
|
96
|
+
/** Compatible with the full `AccessContext` produced by `getContext()`. */
|
|
200
97
|
export interface QueryRunnerContext {
|
|
201
98
|
db: {
|
|
202
99
|
[key: string]: {
|
|
@@ -211,64 +108,9 @@ export interface QueryRunnerContext {
|
|
|
211
108
|
// ─────────────────────────────────────────────────────────────
|
|
212
109
|
|
|
213
110
|
/**
|
|
214
|
-
* Create a type-safe, reusable fragment for a given model type.
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* (from the explicit type parameter) and the field selection (from the
|
|
218
|
-
* argument), without requiring you to repeat yourself.
|
|
219
|
-
*
|
|
220
|
-
* @example Basic usage
|
|
221
|
-
* ```ts
|
|
222
|
-
* import type { User } from '.prisma/client'
|
|
223
|
-
* import { defineFragment } from '@opensaas/stack-core'
|
|
224
|
-
*
|
|
225
|
-
* export const userFragment = defineFragment<User>()({
|
|
226
|
-
* id: true,
|
|
227
|
-
* name: true,
|
|
228
|
-
* email: true,
|
|
229
|
-
* } as const)
|
|
230
|
-
* ```
|
|
231
|
-
*
|
|
232
|
-
* @example Compose fragments
|
|
233
|
-
* ```ts
|
|
234
|
-
* import type { Post } from '.prisma/client'
|
|
235
|
-
*
|
|
236
|
-
* export const postFragment = defineFragment<Post>()({
|
|
237
|
-
* id: true,
|
|
238
|
-
* title: true,
|
|
239
|
-
* author: userFragment,
|
|
240
|
-
* } as const)
|
|
241
|
-
* ```
|
|
242
|
-
*
|
|
243
|
-
* @example Nested filtering with RelationSelector
|
|
244
|
-
* ```ts
|
|
245
|
-
* export const postWithApprovedComments = defineFragment<Post>()({
|
|
246
|
-
* id: true,
|
|
247
|
-
* title: true,
|
|
248
|
-
* comments: {
|
|
249
|
-
* query: commentFragment,
|
|
250
|
-
* where: { approved: true },
|
|
251
|
-
* orderBy: { createdAt: 'desc' },
|
|
252
|
-
* take: 5,
|
|
253
|
-
* },
|
|
254
|
-
* } as const)
|
|
255
|
-
* ```
|
|
256
|
-
*
|
|
257
|
-
* @example Variables via factory function
|
|
258
|
-
* ```ts
|
|
259
|
-
* function makePostFragment(status: string) {
|
|
260
|
-
* return defineFragment<Post>()({
|
|
261
|
-
* id: true,
|
|
262
|
-
* comments: { query: commentFragment, where: { status } },
|
|
263
|
-
* } as const)
|
|
264
|
-
* }
|
|
265
|
-
* type PostData = ResultOf<ReturnType<typeof makePostFragment>>
|
|
266
|
-
*
|
|
267
|
-
* const posts = await context.db.post.findMany({
|
|
268
|
-
* query: makePostFragment('approved'),
|
|
269
|
-
* where: { published: true },
|
|
270
|
-
* })
|
|
271
|
-
* ```
|
|
111
|
+
* Create a type-safe, reusable fragment for a given model type. Curried so
|
|
112
|
+
* TypeScript can infer the model type from the explicit type parameter and
|
|
113
|
+
* the field selection from the argument.
|
|
272
114
|
*/
|
|
273
115
|
export function defineFragment<TItem>() {
|
|
274
116
|
return function <TFields extends FieldSelection<TItem>>(
|
|
@@ -279,7 +121,7 @@ export function defineFragment<TItem>() {
|
|
|
279
121
|
}
|
|
280
122
|
|
|
281
123
|
// ─────────────────────────────────────────────────────────────
|
|
282
|
-
// Runtime helpers
|
|
124
|
+
// Runtime helpers
|
|
283
125
|
// ─────────────────────────────────────────────────────────────
|
|
284
126
|
|
|
285
127
|
/** @internal */
|
|
@@ -293,14 +135,10 @@ export function isFragment(value: unknown): value is Fragment<unknown, FieldSele
|
|
|
293
135
|
}
|
|
294
136
|
|
|
295
137
|
/**
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* scalar columns by default. Only relationship fields backed by a Fragment or
|
|
301
|
-
* RelationSelector generate include entries (recursively).
|
|
302
|
-
*
|
|
303
|
-
* Exported for use in `context/index.ts` when the `query` parameter is present.
|
|
138
|
+
* Build the Prisma `include` map for a field selection's nested fragments.
|
|
139
|
+
* Scalar fields (`true`) need no include entry — Prisma returns all scalar
|
|
140
|
+
* columns by default; only relationship fields backed by a Fragment or
|
|
141
|
+
* RelationSelector generate one (recursively).
|
|
304
142
|
* @internal
|
|
305
143
|
*/
|
|
306
144
|
export function buildInclude(fields: FieldSelection<unknown>): Record<string, unknown> | undefined {
|
|
@@ -312,7 +150,6 @@ export function buildInclude(fields: FieldSelection<unknown>): Record<string, un
|
|
|
312
150
|
|
|
313
151
|
const val = value as Record<string, unknown>
|
|
314
152
|
|
|
315
|
-
// ── Shorthand: Fragment directly ──────────────────────────
|
|
316
153
|
if (isFragment(val)) {
|
|
317
154
|
hasIncludes = true
|
|
318
155
|
const nestedInclude = buildInclude(val._fields as FieldSelection<unknown>)
|
|
@@ -320,7 +157,6 @@ export function buildInclude(fields: FieldSelection<unknown>): Record<string, un
|
|
|
320
157
|
continue
|
|
321
158
|
}
|
|
322
159
|
|
|
323
|
-
// ── RelationSelector: { query, where?, orderBy?, take?, skip? } ──
|
|
324
160
|
if ('query' in val && isFragment(val.query)) {
|
|
325
161
|
hasIncludes = true
|
|
326
162
|
const selector = val as {
|
|
@@ -345,12 +181,62 @@ export function buildInclude(fields: FieldSelection<unknown>): Record<string, un
|
|
|
345
181
|
return hasIncludes ? include : undefined
|
|
346
182
|
}
|
|
347
183
|
|
|
184
|
+
/**
|
|
185
|
+
* A snapshot of which field names a fragment selects at one nesting level,
|
|
186
|
+
* plus the same tree one level down for every relation selected via a nested
|
|
187
|
+
* Fragment/RelationSelector. `fields: undefined` means "unrestricted" — every
|
|
188
|
+
* field at this level is going to be returned, which is what a bare or
|
|
189
|
+
* `include`-based read means for the whole tree (only a `query` fragment ever
|
|
190
|
+
* produces a restricted scope, and only as deep as it names).
|
|
191
|
+
*
|
|
192
|
+
* Used to make computed-field evaluation (`filterReadableFields`) and
|
|
193
|
+
* declared-dependency folding (`foldDeclaredDependencies`) projection-aware
|
|
194
|
+
* (ADR-0027): a field not named by the scope at its level is never computed
|
|
195
|
+
* and its `needs` are never folded, because the read is never going to
|
|
196
|
+
* return it.
|
|
197
|
+
* @internal
|
|
198
|
+
*/
|
|
199
|
+
export type FieldSelectionScope = {
|
|
200
|
+
readonly fields: ReadonlySet<string> | undefined
|
|
201
|
+
readonly nested: Readonly<Record<string, FieldSelectionScope>>
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build the `FieldSelectionScope` for one fragment's field selection,
|
|
206
|
+
* recursing into nested Fragment/RelationSelector entries the same way
|
|
207
|
+
* `buildInclude` does. A relation named with the bare `true` shorthand (no
|
|
208
|
+
* narrower nested Fragment) gets no entry in `nested`, so a level reached
|
|
209
|
+
* through it is treated as unrestricted — the caller asked for "everything"
|
|
210
|
+
* there and gave no narrower shape to restrict it with.
|
|
211
|
+
* @internal
|
|
212
|
+
*/
|
|
213
|
+
export function buildFieldSelectionScope(fields: FieldSelection<unknown>): FieldSelectionScope {
|
|
214
|
+
const fieldNames = new Set(Object.keys(fields as Record<string, unknown>))
|
|
215
|
+
const nested: Record<string, FieldSelectionScope> = {}
|
|
216
|
+
|
|
217
|
+
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
|
218
|
+
if (value === null || value === true || typeof value !== 'object') continue
|
|
219
|
+
const val = value as Record<string, unknown>
|
|
220
|
+
|
|
221
|
+
if (isFragment(val)) {
|
|
222
|
+
nested[key] = buildFieldSelectionScope(val._fields as FieldSelection<unknown>)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if ('query' in val && isFragment(val.query)) {
|
|
227
|
+
nested[key] = buildFieldSelectionScope(
|
|
228
|
+
(val.query as Fragment<unknown, FieldSelection<unknown>>)
|
|
229
|
+
._fields as FieldSelection<unknown>,
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { fields: fieldNames, nested }
|
|
235
|
+
}
|
|
236
|
+
|
|
348
237
|
/**
|
|
349
238
|
* Recursively pick only the fields requested by a fragment from a raw Prisma
|
|
350
|
-
* result object
|
|
351
|
-
* produced by `ResultOf<F>`.
|
|
352
|
-
*
|
|
353
|
-
* Exported for use in `context/index.ts`.
|
|
239
|
+
* result object, so the runtime shape matches `ResultOf<F>`.
|
|
354
240
|
* @internal
|
|
355
241
|
*/
|
|
356
242
|
export function pickFields<TItem, TFields extends FieldSelection<TItem>>(
|
|
@@ -371,7 +257,6 @@ export function pickFields<TItem, TFields extends FieldSelection<TItem>>(
|
|
|
371
257
|
|
|
372
258
|
const val = value as Record<string, unknown>
|
|
373
259
|
|
|
374
|
-
// ── Shorthand: Fragment directly ──────────────────────────
|
|
375
260
|
if (isFragment(val)) {
|
|
376
261
|
if (Array.isArray(fieldValue)) {
|
|
377
262
|
result[key] = fieldValue.map((elem) =>
|
|
@@ -385,7 +270,6 @@ export function pickFields<TItem, TFields extends FieldSelection<TItem>>(
|
|
|
385
270
|
continue
|
|
386
271
|
}
|
|
387
272
|
|
|
388
|
-
// ── RelationSelector: { query, where?, ... } ──────────────
|
|
389
273
|
if ('query' in val && isFragment(val.query)) {
|
|
390
274
|
const nestedFrag = val.query as Fragment<unknown, FieldSelection<unknown>>
|
|
391
275
|
if (Array.isArray(fieldValue)) {
|
|
@@ -413,29 +297,9 @@ export function pickFields<TItem, TFields extends FieldSelection<TItem>>(
|
|
|
413
297
|
|
|
414
298
|
/**
|
|
415
299
|
* Execute a fragment-based query against a list, returning all matching
|
|
416
|
-
* records shaped to the fragment's field selection.
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
* control rules defined in your config are still enforced.
|
|
420
|
-
*
|
|
421
|
-
* **Tip:** You can also call `context.db.post.findMany({ query: fragment, ... })`
|
|
422
|
-
* directly — both forms produce the same result.
|
|
423
|
-
*
|
|
424
|
-
* @param context - An `AccessContext` (or any object with a compatible `db`).
|
|
425
|
-
* @param listKey - The PascalCase list name (e.g. `'Post'`, `'BlogPost'`).
|
|
426
|
-
* @param fragment - A fragment created with {@link defineFragment}.
|
|
427
|
-
* @param args - Optional query arguments (where, orderBy, take, skip).
|
|
428
|
-
* @returns An array typed to exactly the fragment's field selection.
|
|
429
|
-
*
|
|
430
|
-
* @example
|
|
431
|
-
* ```ts
|
|
432
|
-
* const posts = await runQuery(context, 'Post', postFragment, {
|
|
433
|
-
* where: { published: true },
|
|
434
|
-
* orderBy: { createdAt: 'desc' },
|
|
435
|
-
* take: 10,
|
|
436
|
-
* })
|
|
437
|
-
* // posts: Array<ResultOf<typeof postFragment>>
|
|
438
|
-
* ```
|
|
300
|
+
* records shaped to the fragment's field selection. Calls
|
|
301
|
+
* `context.db[listKey].findMany()` under the hood, so access control still
|
|
302
|
+
* applies.
|
|
439
303
|
*/
|
|
440
304
|
export async function runQuery<TItem, TFields extends FieldSelection<TItem>>(
|
|
441
305
|
context: QueryRunnerContext,
|
|
@@ -465,25 +329,8 @@ export async function runQuery<TItem, TFields extends FieldSelection<TItem>>(
|
|
|
465
329
|
|
|
466
330
|
/**
|
|
467
331
|
* Execute a fragment-based query that returns a single record (or `null`).
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
* control rules are still enforced.
|
|
471
|
-
*
|
|
472
|
-
* **Tip:** You can also call `context.db.post.findUnique({ where: { id }, query: fragment })`
|
|
473
|
-
* directly.
|
|
474
|
-
*
|
|
475
|
-
* @param context - An `AccessContext` (or any object with a compatible `db`).
|
|
476
|
-
* @param listKey - The PascalCase list name (e.g. `'Post'`).
|
|
477
|
-
* @param fragment - A fragment created with {@link defineFragment}.
|
|
478
|
-
* @param where - A Prisma where clause to identify the record.
|
|
479
|
-
* @returns The matched record shaped to the fragment, or `null`.
|
|
480
|
-
*
|
|
481
|
-
* @example
|
|
482
|
-
* ```ts
|
|
483
|
-
* const post = await runQueryOne(context, 'Post', postFragment, { id: postId })
|
|
484
|
-
* if (!post) return notFound()
|
|
485
|
-
* // post: ResultOf<typeof postFragment>
|
|
486
|
-
* ```
|
|
332
|
+
* Calls `context.db[listKey].findFirst()` under the hood, so access control
|
|
333
|
+
* still applies.
|
|
487
334
|
*/
|
|
488
335
|
export async function runQueryOne<TItem, TFields extends FieldSelection<TItem>>(
|
|
489
336
|
context: QueryRunnerContext,
|
|
@@ -23,7 +23,7 @@ export interface RelationshipOptionsArgs {
|
|
|
23
23
|
* editor — the read primitive behind the `relationshipOptions` serverAction
|
|
24
24
|
* op. Selects only `id` and the resolved label field (via
|
|
25
25
|
* {@link getLabelFieldName}), so the fragment carries no relation keys and
|
|
26
|
-
* `
|
|
26
|
+
* `buildAccessScopedInclude` never has anything to scope.
|
|
27
27
|
*
|
|
28
28
|
* Operation-level `query` access on `relatedListKey` still applies — a denied
|
|
29
29
|
* list resolves to `[]` (via the underlying access-controlled `findMany`).
|
package/src/utils/password.ts
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
1
|
import bcrypt from 'bcryptjs'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* Default bcrypt cost factor (rounds)
|
|
5
|
-
* Higher values = more secure but slower
|
|
6
|
-
* 10 is a good balance for production use
|
|
7
|
-
*/
|
|
3
|
+
// Bcrypt cost factor: higher is slower but more resistant to brute force.
|
|
8
4
|
const DEFAULT_COST_FACTOR = 10
|
|
9
5
|
|
|
10
|
-
/**
|
|
11
|
-
* Hash a plain text password using bcrypt
|
|
12
|
-
*
|
|
13
|
-
* @param plainPassword - The plain text password to hash
|
|
14
|
-
* @param costFactor - The bcrypt cost factor (default: 10)
|
|
15
|
-
* @returns Promise resolving to the hashed password
|
|
16
|
-
*
|
|
17
|
-
* @example
|
|
18
|
-
* ```typescript
|
|
19
|
-
* const hashed = await hashPassword('mypassword')
|
|
20
|
-
* // Returns: $2a$10$...
|
|
21
|
-
* ```
|
|
22
|
-
*/
|
|
23
6
|
export async function hashPassword(
|
|
24
7
|
plainPassword: string,
|
|
25
8
|
costFactor: number = DEFAULT_COST_FACTOR,
|
|
@@ -31,21 +14,6 @@ export async function hashPassword(
|
|
|
31
14
|
return bcrypt.hash(plainPassword, costFactor)
|
|
32
15
|
}
|
|
33
16
|
|
|
34
|
-
/**
|
|
35
|
-
* Compare a plain text password with a hashed password
|
|
36
|
-
*
|
|
37
|
-
* @param plainPassword - The plain text password to compare
|
|
38
|
-
* @param hashedPassword - The hashed password to compare against
|
|
39
|
-
* @returns Promise resolving to true if passwords match, false otherwise
|
|
40
|
-
*
|
|
41
|
-
* @example
|
|
42
|
-
* ```typescript
|
|
43
|
-
* const isValid = await comparePassword('mypassword', hashedPassword)
|
|
44
|
-
* if (isValid) {
|
|
45
|
-
* // Password is correct
|
|
46
|
-
* }
|
|
47
|
-
* ```
|
|
48
|
-
*/
|
|
49
17
|
export async function comparePassword(
|
|
50
18
|
plainPassword: string,
|
|
51
19
|
hashedPassword: string,
|
|
@@ -61,38 +29,21 @@ export async function comparePassword(
|
|
|
61
29
|
try {
|
|
62
30
|
return await bcrypt.compare(plainPassword, hashedPassword)
|
|
63
31
|
} catch (error) {
|
|
64
|
-
//
|
|
32
|
+
// Fail closed on a malformed hash or other bcrypt error, rather than
|
|
33
|
+
// letting it propagate as an authentication-bypassing exception.
|
|
65
34
|
console.error('Password comparison failed:', error)
|
|
66
35
|
return false
|
|
67
36
|
}
|
|
68
37
|
}
|
|
69
38
|
|
|
70
|
-
/**
|
|
71
|
-
* Check if a string appears to be a bcrypt hash
|
|
72
|
-
* Bcrypt hashes follow the format: $2a$10$...
|
|
73
|
-
*
|
|
74
|
-
* @param value - The string to check
|
|
75
|
-
* @returns True if the string looks like a bcrypt hash
|
|
76
|
-
*/
|
|
77
39
|
export function isHashedPassword(value: string): boolean {
|
|
78
40
|
if (typeof value !== 'string') return false
|
|
79
41
|
|
|
80
|
-
// Bcrypt hashes start with $2a$, $2b$, or $2y$ followed by cost factor
|
|
81
|
-
// and are typically 60 characters long
|
|
82
42
|
return /^\$2[aby]\$\d{2}\$.{53}$/.test(value)
|
|
83
43
|
}
|
|
84
44
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
* This allows password field values to be used as strings while also
|
|
88
|
-
* providing a convenient compare() method for authentication
|
|
89
|
-
*
|
|
90
|
-
* @example
|
|
91
|
-
* ```typescript
|
|
92
|
-
* const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
93
|
-
* const isValid = await user.password.compare('plaintextPassword')
|
|
94
|
-
* ```
|
|
95
|
-
*/
|
|
45
|
+
// Wraps a bcrypt hash so a password field's value can still be used as a
|
|
46
|
+
// plain string while also exposing compare() for authentication.
|
|
96
47
|
export class HashedPassword {
|
|
97
48
|
constructor(private readonly hash: string) {
|
|
98
49
|
if (!hash || typeof hash !== 'string') {
|
|
@@ -100,28 +51,15 @@ export class HashedPassword {
|
|
|
100
51
|
}
|
|
101
52
|
}
|
|
102
53
|
|
|
103
|
-
/**
|
|
104
|
-
* Compare a plain text password with this hashed password
|
|
105
|
-
*
|
|
106
|
-
* @param plainPassword - The plain text password to compare
|
|
107
|
-
* @returns Promise resolving to true if passwords match
|
|
108
|
-
*/
|
|
109
54
|
async compare(plainPassword: string): Promise<boolean> {
|
|
110
55
|
return comparePassword(plainPassword, this.hash)
|
|
111
56
|
}
|
|
112
57
|
|
|
113
|
-
/**
|
|
114
|
-
* Get the underlying hash string
|
|
115
|
-
* This allows the HashedPassword to be used anywhere a string is expected
|
|
116
|
-
*/
|
|
117
58
|
toString(): string {
|
|
118
59
|
return this.hash
|
|
119
60
|
}
|
|
120
61
|
|
|
121
|
-
|
|
122
|
-
* Get the underlying hash when used in string contexts
|
|
123
|
-
* This allows the HashedPassword to be coerced to a string automatically
|
|
124
|
-
*/
|
|
62
|
+
// Implicit coercion (template literals, string concatenation) resolves here.
|
|
125
63
|
[Symbol.toPrimitive](hint: string): string {
|
|
126
64
|
if (hint === 'string' || hint === 'default') {
|
|
127
65
|
return this.hash
|
|
@@ -129,18 +67,11 @@ export class HashedPassword {
|
|
|
129
67
|
return this.hash
|
|
130
68
|
}
|
|
131
69
|
|
|
132
|
-
|
|
133
|
-
* Return the hash for JSON serialization
|
|
134
|
-
* This ensures the hash is properly serialized when converting to JSON
|
|
135
|
-
*/
|
|
70
|
+
// JSON.stringify calls this when present, so the hash serializes as a plain string.
|
|
136
71
|
toJSON(): string {
|
|
137
72
|
return this.hash
|
|
138
73
|
}
|
|
139
74
|
|
|
140
|
-
/**
|
|
141
|
-
* Get the underlying hash value
|
|
142
|
-
* This allows accessing the raw hash string
|
|
143
|
-
*/
|
|
144
75
|
valueOf(): string {
|
|
145
76
|
return this.hash
|
|
146
77
|
}
|
|
@@ -44,9 +44,6 @@ function hasFieldMethod(field: FieldConfig, method: string): boolean {
|
|
|
44
44
|
return typeof value === 'function'
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
* Build the canonical error message for a missing contract method.
|
|
49
|
-
*/
|
|
50
47
|
function buildMessage(
|
|
51
48
|
fieldType: string,
|
|
52
49
|
method: FieldConfigValidationError['missingMethod'],
|
|
@@ -102,19 +99,16 @@ export function validateFieldConfig(
|
|
|
102
99
|
}
|
|
103
100
|
|
|
104
101
|
if (field.type === 'relationship') {
|
|
105
|
-
// Relationships render through the relationship path only.
|
|
106
102
|
requireMethod('getPrismaRelation')
|
|
107
103
|
return errors
|
|
108
104
|
}
|
|
109
105
|
|
|
110
106
|
if (field.virtual === true || field.type === 'virtual') {
|
|
111
|
-
// Virtual fields are not persisted, so getPrismaType is intentionally absent.
|
|
112
107
|
requireMethod('getTypeScriptType')
|
|
113
108
|
requireMethod('getZodSchema')
|
|
114
109
|
return errors
|
|
115
110
|
}
|
|
116
111
|
|
|
117
|
-
// Stored scalar fields must implement the full generation contract.
|
|
118
112
|
requireMethod('getPrismaType')
|
|
119
113
|
requireMethod('getTypeScriptType')
|
|
120
114
|
requireMethod('getZodSchema')
|
package/src/validation/schema.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import type { FieldConfig } from '../config/types.js'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Generate Zod schema from field configurations
|
|
6
|
-
*/
|
|
7
4
|
export function generateZodSchema(
|
|
8
5
|
fieldConfigs: Record<string, FieldConfig>,
|
|
9
6
|
operation: 'create' | 'update' = 'create',
|
|
@@ -11,7 +8,6 @@ export function generateZodSchema(
|
|
|
11
8
|
const shape: Record<string, z.ZodTypeAny> = {}
|
|
12
9
|
|
|
13
10
|
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
14
|
-
// Skip system fields, relationships, and virtual fields
|
|
15
11
|
// Virtual fields don't accept input - they only compute output
|
|
16
12
|
if (
|
|
17
13
|
['id', 'createdAt', 'updatedAt'].includes(fieldName) ||
|
|
@@ -21,7 +17,6 @@ export function generateZodSchema(
|
|
|
21
17
|
continue
|
|
22
18
|
}
|
|
23
19
|
|
|
24
|
-
// Use the field's schema generator
|
|
25
20
|
if (fieldConfig.getZodSchema) {
|
|
26
21
|
shape[fieldName] = fieldConfig.getZodSchema(fieldName, operation)
|
|
27
22
|
} else {
|
|
@@ -33,10 +28,6 @@ export function generateZodSchema(
|
|
|
33
28
|
return z.object(shape)
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
/**
|
|
37
|
-
* Validate data against field configurations using Zod
|
|
38
|
-
* Returns structured errors by field
|
|
39
|
-
*/
|
|
40
31
|
export function validateWithZod(
|
|
41
32
|
data: Record<string, unknown>,
|
|
42
33
|
fieldConfigs: Record<string, FieldConfig>,
|
|
@@ -50,7 +41,6 @@ export function validateWithZod(
|
|
|
50
41
|
return { success: true }
|
|
51
42
|
}
|
|
52
43
|
|
|
53
|
-
// Convert Zod errors to field-specific error messages
|
|
54
44
|
const errors: Record<string, string> = {}
|
|
55
45
|
for (const issue of result.error.issues) {
|
|
56
46
|
const fieldPath = issue.path.join('.')
|