@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.
Files changed (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +62 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. package/tsconfig.tsbuildinfo +1 -1
@@ -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
- * Two forms are accepted:
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
- * Each key maps to:
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
- ? // Scalar field (value is `true`) — tuple wrapping avoids the vacuous `never extends T` pitfall
63
+ ? // tuple wrapping avoids the vacuous `never extends T` pitfall
144
64
  TItem[K]
145
- : // Relationship field — preserve array/null/undefined wrappers from the model
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. The access control layer will additionally scope results. */
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
- * The function is curried so that TypeScript can infer both the model type
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 — exported for use in context/index.ts
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
- * Walk a field selection and build the Prisma `include` map needed to eagerly
297
- * load all nested relationship fragments/selectors.
298
- *
299
- * Scalar fields (`true`) do not require an include entry — Prisma returns all
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. This ensures the runtime shape exactly matches the type
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
- * Under the hood this calls `context.db[listKey].findMany()`, so all access
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
- * Under the hood this calls `context.db[listKey].findFirst()`, so all access
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
- * `buildIncludeWithAccessControl`'s depth-5 auto-include never runs.
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`).
@@ -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
- // Invalid hash format or other bcrypt error
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
- * HashedPassword class wraps a bcrypt hash and provides a compare method
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')
@@ -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('.')