@opensaas/stack-core 0.19.1 → 0.20.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.
@@ -0,0 +1,505 @@
1
+ import { getDbKey } from '../lib/case-utils.js'
2
+
3
+ // ─────────────────────────────────────────────────────────────
4
+ // Internal helpers
5
+ // ─────────────────────────────────────────────────────────────
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
+ type UnwrapItem<T> = NonNullable<T> extends Array<infer U> ? NonNullable<U> : NonNullable<T>
17
+
18
+ // ─────────────────────────────────────────────────────────────
19
+ // Core types
20
+ // ─────────────────────────────────────────────────────────────
21
+
22
+ /**
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
+ * ```
61
+ */
62
+ export type RelationSelector<TRelated extends Record<string, unknown>> =
63
+ | Fragment<TRelated, FieldSelection<TRelated>>
64
+ | {
65
+ readonly query: Fragment<TRelated, FieldSelection<TRelated>>
66
+ readonly where?: Record<string, unknown>
67
+ readonly orderBy?: Record<string, 'asc' | 'desc'> | Array<Record<string, 'asc' | 'desc'>>
68
+ readonly take?: number
69
+ readonly skip?: number
70
+ }
71
+
72
+ /**
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
+ * ```
93
+ */
94
+ export type FieldSelection<T> = {
95
+ readonly [K in keyof T]?: UnwrapItem<T[K]> extends Record<string, unknown>
96
+ ? RelationSelector<UnwrapItem<T[K]>> | true
97
+ : true
98
+ }
99
+
100
+ /**
101
+ * 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
+ * ```
115
+ */
116
+ export type Fragment<TItem, TFields extends FieldSelection<TItem> = FieldSelection<TItem>> = {
117
+ readonly _type: 'fragment'
118
+ readonly _fields: TFields
119
+ }
120
+
121
+ // ─────────────────────────────────────────────────────────────
122
+ // Internal type helpers
123
+ // ─────────────────────────────────────────────────────────────
124
+
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
+ */
130
+ type ExtractFragment<TSelector> =
131
+ TSelector extends Fragment<infer TItem, infer TFields>
132
+ ? Fragment<TItem, TFields>
133
+ : TSelector extends { readonly query: Fragment<infer TItem, infer TFields> }
134
+ ? Fragment<TItem, TFields>
135
+ : never
136
+
137
+ /**
138
+ * @internal
139
+ * Map a FieldSelection over a model type, computing the picked output type.
140
+ */
141
+ type SelectedFields<TItem, TFields extends FieldSelection<TItem>> = {
142
+ [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
144
+ TItem[K]
145
+ : // Relationship field — preserve array/null/undefined wrappers from the model
146
+ TItem[K] extends Array<unknown>
147
+ ? ResultOf<ExtractFragment<TFields[K]>>[]
148
+ : null extends TItem[K]
149
+ ? ResultOf<ExtractFragment<TFields[K]>> | null
150
+ : undefined extends TItem[K]
151
+ ? ResultOf<ExtractFragment<TFields[K]>> | undefined
152
+ : ResultOf<ExtractFragment<TFields[K]>>
153
+ }
154
+
155
+ // ─────────────────────────────────────────────────────────────
156
+ // Public type utilities
157
+ // ─────────────────────────────────────────────────────────────
158
+
159
+ /**
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
+ * ```
178
+ */
179
+ export type ResultOf<F> =
180
+ F extends Fragment<infer TItem, infer TFields> ? SelectedFields<TItem, TFields> : never
181
+
182
+ /**
183
+ * Arguments accepted by {@link runQuery}.
184
+ */
185
+ export type QueryArgs = {
186
+ /** Prisma where filter. The access control layer will additionally scope results. */
187
+ where?: Record<string, unknown>
188
+ /** Prisma orderBy clause. Pass a single object or an array for multi-column ordering. */
189
+ orderBy?: Record<string, 'asc' | 'desc'> | Array<Record<string, 'asc' | 'desc'>>
190
+ /** Maximum number of records to return. */
191
+ take?: number
192
+ /** Number of records to skip (for pagination). */
193
+ skip?: number
194
+ }
195
+
196
+ /**
197
+ * Minimal context shape required by the query runners.
198
+ * Compatible with the full `AccessContext` produced by `getContext()`.
199
+ */
200
+ export interface QueryRunnerContext {
201
+ db: {
202
+ [key: string]: {
203
+ findMany: (args?: unknown) => Promise<unknown[]>
204
+ findFirst: (args?: unknown) => Promise<unknown>
205
+ }
206
+ }
207
+ }
208
+
209
+ // ─────────────────────────────────────────────────────────────
210
+ // Fragment factory
211
+ // ─────────────────────────────────────────────────────────────
212
+
213
+ /**
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
+ * ```
272
+ */
273
+ export function defineFragment<TItem>() {
274
+ return function <TFields extends FieldSelection<TItem>>(
275
+ fields: TFields,
276
+ ): Fragment<TItem, TFields> {
277
+ return { _type: 'fragment', _fields: fields }
278
+ }
279
+ }
280
+
281
+ // ─────────────────────────────────────────────────────────────
282
+ // Runtime helpers — exported for use in context/index.ts
283
+ // ─────────────────────────────────────────────────────────────
284
+
285
+ /** @internal */
286
+ export function isFragment(value: unknown): value is Fragment<unknown, FieldSelection<unknown>> {
287
+ return (
288
+ value !== null &&
289
+ typeof value === 'object' &&
290
+ '_type' in value &&
291
+ (value as { _type: unknown })._type === 'fragment'
292
+ )
293
+ }
294
+
295
+ /**
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.
304
+ * @internal
305
+ */
306
+ export function buildInclude(fields: FieldSelection<unknown>): Record<string, unknown> | undefined {
307
+ const include: Record<string, unknown> = {}
308
+ let hasIncludes = false
309
+
310
+ for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
311
+ if (value === null || value === true || typeof value !== 'object') continue
312
+
313
+ const val = value as Record<string, unknown>
314
+
315
+ // ── Shorthand: Fragment directly ──────────────────────────
316
+ if (isFragment(val)) {
317
+ hasIncludes = true
318
+ const nestedInclude = buildInclude(val._fields as FieldSelection<unknown>)
319
+ include[key] = nestedInclude ? { include: nestedInclude } : true
320
+ continue
321
+ }
322
+
323
+ // ── RelationSelector: { query, where?, orderBy?, take?, skip? } ──
324
+ if ('query' in val && isFragment(val.query)) {
325
+ hasIncludes = true
326
+ const selector = val as {
327
+ query: Fragment<unknown, FieldSelection<unknown>>
328
+ where?: Record<string, unknown>
329
+ orderBy?: unknown
330
+ take?: number
331
+ skip?: number
332
+ }
333
+ const nestedInclude = buildInclude(selector.query._fields as FieldSelection<unknown>)
334
+ const includeEntry: Record<string, unknown> = {}
335
+ if (selector.where !== undefined) includeEntry.where = selector.where
336
+ if (selector.orderBy !== undefined) includeEntry.orderBy = selector.orderBy
337
+ if (selector.take !== undefined) includeEntry.take = selector.take
338
+ if (selector.skip !== undefined) includeEntry.skip = selector.skip
339
+ if (nestedInclude) includeEntry.include = nestedInclude
340
+ include[key] = Object.keys(includeEntry).length > 0 ? includeEntry : true
341
+ continue
342
+ }
343
+ }
344
+
345
+ return hasIncludes ? include : undefined
346
+ }
347
+
348
+ /**
349
+ * 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`.
354
+ * @internal
355
+ */
356
+ export function pickFields<TItem, TFields extends FieldSelection<TItem>>(
357
+ item: TItem,
358
+ fields: TFields,
359
+ ): SelectedFields<TItem, TFields> {
360
+ const result: Record<string, unknown> = {}
361
+
362
+ for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
363
+ const fieldValue = (item as Record<string, unknown>)[key]
364
+
365
+ if (value === true) {
366
+ result[key] = fieldValue
367
+ continue
368
+ }
369
+
370
+ if (value === null || typeof value !== 'object') continue
371
+
372
+ const val = value as Record<string, unknown>
373
+
374
+ // ── Shorthand: Fragment directly ──────────────────────────
375
+ if (isFragment(val)) {
376
+ if (Array.isArray(fieldValue)) {
377
+ result[key] = fieldValue.map((elem) =>
378
+ pickFields(elem as unknown, val._fields as FieldSelection<unknown>),
379
+ )
380
+ } else if (fieldValue === null || fieldValue === undefined) {
381
+ result[key] = fieldValue
382
+ } else {
383
+ result[key] = pickFields(fieldValue as unknown, val._fields as FieldSelection<unknown>)
384
+ }
385
+ continue
386
+ }
387
+
388
+ // ── RelationSelector: { query, where?, ... } ──────────────
389
+ if ('query' in val && isFragment(val.query)) {
390
+ const nestedFrag = val.query as Fragment<unknown, FieldSelection<unknown>>
391
+ if (Array.isArray(fieldValue)) {
392
+ result[key] = fieldValue.map((elem) =>
393
+ pickFields(elem as unknown, nestedFrag._fields as FieldSelection<unknown>),
394
+ )
395
+ } else if (fieldValue === null || fieldValue === undefined) {
396
+ result[key] = fieldValue
397
+ } else {
398
+ result[key] = pickFields(
399
+ fieldValue as unknown,
400
+ nestedFrag._fields as FieldSelection<unknown>,
401
+ )
402
+ }
403
+ continue
404
+ }
405
+ }
406
+
407
+ return result as SelectedFields<TItem, TFields>
408
+ }
409
+
410
+ // ─────────────────────────────────────────────────────────────
411
+ // Standalone query runners
412
+ // ─────────────────────────────────────────────────────────────
413
+
414
+ /**
415
+ * 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
+ * ```
439
+ */
440
+ export async function runQuery<TItem, TFields extends FieldSelection<TItem>>(
441
+ context: QueryRunnerContext,
442
+ listKey: string,
443
+ fragment: Fragment<TItem, TFields>,
444
+ args?: QueryArgs,
445
+ ): Promise<SelectedFields<TItem, TFields>[]> {
446
+ const dbKey = getDbKey(listKey)
447
+ const include = buildInclude(fragment._fields as FieldSelection<unknown>)
448
+
449
+ const findManyArgs: Record<string, unknown> = {}
450
+ if (args?.where !== undefined) findManyArgs.where = args.where
451
+ if (args?.orderBy !== undefined) findManyArgs.orderBy = args.orderBy
452
+ if (args?.take !== undefined) findManyArgs.take = args.take
453
+ if (args?.skip !== undefined) findManyArgs.skip = args.skip
454
+ if (include) findManyArgs.include = include
455
+
456
+ const results = await context.db[dbKey].findMany(
457
+ Object.keys(findManyArgs).length > 0 ? findManyArgs : undefined,
458
+ )
459
+
460
+ return results.map((item) => pickFields(item as TItem, fragment._fields)) as SelectedFields<
461
+ TItem,
462
+ TFields
463
+ >[]
464
+ }
465
+
466
+ /**
467
+ * 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
+ * ```
487
+ */
488
+ export async function runQueryOne<TItem, TFields extends FieldSelection<TItem>>(
489
+ context: QueryRunnerContext,
490
+ listKey: string,
491
+ fragment: Fragment<TItem, TFields>,
492
+ where: Record<string, unknown>,
493
+ ): Promise<SelectedFields<TItem, TFields> | null> {
494
+ const dbKey = getDbKey(listKey)
495
+ const include = buildInclude(fragment._fields as FieldSelection<unknown>)
496
+
497
+ const findFirstArgs: Record<string, unknown> = { where }
498
+ if (include) findFirstArgs.include = include
499
+
500
+ const item = await context.db[dbKey].findFirst(findFirstArgs)
501
+
502
+ if (item === null || item === undefined) return null
503
+
504
+ return pickFields(item as TItem, fragment._fields) as SelectedFields<TItem, TFields>
505
+ }