@opensaas/stack-core 0.36.0 → 0.38.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 (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +121 -0
  3. package/CLAUDE.md +21 -3
  4. package/dist/access/access-filter.d.ts +30 -118
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +70 -206
  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 +67 -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/errors.d.ts +12 -8
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +16 -12
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-visibility.d.ts +2 -1
  27. package/dist/access/field-visibility.d.ts.map +1 -1
  28. package/dist/access/field-visibility.js +91 -17
  29. package/dist/access/field-visibility.js.map +1 -1
  30. package/dist/access/index.d.ts +1 -2
  31. package/dist/access/index.d.ts.map +1 -1
  32. package/dist/access/index.js +1 -1
  33. package/dist/access/index.js.map +1 -1
  34. package/dist/access/relationship-count.d.ts +1 -1
  35. package/dist/config/index.d.ts +1 -1
  36. package/dist/config/index.d.ts.map +1 -1
  37. package/dist/config/types.d.ts +126 -0
  38. package/dist/config/types.d.ts.map +1 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +38 -27
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/fields/index.d.ts.map +1 -1
  43. package/dist/fields/index.js +28 -5
  44. package/dist/fields/index.js.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/query/index.d.ts +29 -0
  49. package/dist/query/index.d.ts.map +1 -1
  50. package/dist/query/index.js +27 -0
  51. package/dist/query/index.js.map +1 -1
  52. package/dist/query/relationship-options.d.ts +1 -1
  53. package/dist/query/relationship-options.js +1 -1
  54. package/package.json +1 -1
  55. package/src/access/access-filter.test.ts +205 -275
  56. package/src/access/access-filter.ts +84 -267
  57. package/src/access/declared-dependencies.test.ts +277 -0
  58. package/src/access/declared-dependencies.ts +122 -37
  59. package/src/access/depth-limits.ts +8 -7
  60. package/src/access/errors.ts +16 -12
  61. package/src/access/field-visibility.ts +99 -14
  62. package/src/access/index.ts +1 -7
  63. package/src/access/relationship-count.ts +1 -1
  64. package/src/config/index.ts +2 -0
  65. package/src/config/types.ts +130 -0
  66. package/src/context/index.ts +52 -33
  67. package/src/fields/index.ts +35 -5
  68. package/src/index.ts +2 -0
  69. package/src/query/index.ts +53 -0
  70. package/src/query/relationship-options.ts +1 -1
  71. package/tests/access-relationships.test.ts +18 -16
  72. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  73. package/tests/context.test.ts +27 -0
  74. package/tests/field-types.test.ts +12 -0
  75. package/tests/needs-declared-dependencies.test.ts +7 -4
  76. package/tests/resolve-chain.test.ts +11 -11
  77. package/tsconfig.tsbuildinfo +1 -1
@@ -6,6 +6,7 @@ import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
6
6
  import { ResolveOutputCycleError } from './errors.js'
7
7
  import type { DeclaredOnlyTree } from './declared-dependencies.js'
8
8
  import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
9
+ import type { FieldSelectionScope } from '../query/index.js'
9
10
  // NOTE: `context/index.ts` imports `filterReadableFields` from this module
10
11
  // (via the `access/index.ts` barrel) — this is an intentional cyclic
11
12
  // dependency, the same shape and for the same reason as the one documented in
@@ -23,6 +24,14 @@ import { buildDbDelegate } from '../context/index.js'
23
24
  * virtual fields. None of this can move into phase 1: virtual fields are
24
25
  * computed in JavaScript and field access can depend on the fetched row.
25
26
  *
27
+ * A computed field — any field carrying a `resolveOutput` hook, virtual or
28
+ * not — is produced only where the read is going to return it (ADR-0027). A
29
+ * fragment `query`'s own field selection is the only thing that restricts a
30
+ * level this way; a bare or `include`-based read, and any relation reached
31
+ * purely to satisfy a `needs` declaration, still compute every field, as
32
+ * before. A field the read is not going to return does no work at all —
33
+ * neither its read-access evaluation nor its hook.
34
+ *
26
35
  * Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
27
36
  * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
28
37
  * glossary in `CONTEXT.md`.
@@ -96,8 +105,11 @@ function deriveResolveOutputContext(
96
105
  * from the result.
97
106
  *
98
107
  * `accessItem` is the row used to evaluate field access; `hookItem` is the
99
- * object passed to the hook as `item` (these differ for virtual fields, which
100
- * see the already-filtered output so they can read sibling fields).
108
+ * object passed to the hook as `item`. For a stored field, both are
109
+ * `workingItem` (the row's own stored/fetched columns). For a virtual field,
110
+ * `hookItem` is `computedFieldItem` instead — the same stored columns with
111
+ * every skipped-or-denied key removed — so it never sees another computed
112
+ * field's resolved value (ADR-0027).
101
113
  */
102
114
  async function resolveReadableFieldValue(params: {
103
115
  fieldConfig: FieldConfig | undefined
@@ -189,6 +201,13 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
189
201
  // returned — after resolveOutput has had a chance to read them — so a
190
202
  // declared dependency never widens what the caller receives.
191
203
  declaredOnly: DeclaredOnlyTree = emptyDeclaredOnlyTree(),
204
+ // The fragment scope this level was reached under (ADR-0027), and the same
205
+ // tree one level down for each nested relation. `undefined` — the default,
206
+ // and what a bare/`include`-based read passes at every level — means
207
+ // unrestricted: every field on the list is computed, unchanged from
208
+ // before ADR-0027. Only a `query` fragment's own field selection ever
209
+ // restricts a level.
210
+ selection?: FieldSelectionScope,
192
211
  ): Promise<Partial<T>> {
193
212
  const filtered: Record<string, unknown> = {}
194
213
 
@@ -214,6 +233,15 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
214
233
  workingItem[fieldName] = assembled
215
234
  }
216
235
 
236
+ // Keys denied by field-level read access during the pass below — as opposed
237
+ // to a key merely skipped by `selection` or held back only for
238
+ // `declaredOnly` stripping. Tracked separately because a denied key must
239
+ // stay invisible to a computed field's hook (below), while a declared-only
240
+ // key must stay VISIBLE to one — that is the entire point of declaring it
241
+ // (ADR-0025) — even though `selection` above skipped adding it to
242
+ // `filtered` because the caller's fragment never asked for it.
243
+ const accessDeniedKeys = new Set<string>()
244
+
217
245
  // Process existing fields from the database result
218
246
  for (const [fieldName, value] of Object.entries(workingItem)) {
219
247
  const fieldConfig = fieldConfigs[fieldName]
@@ -224,8 +252,19 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
224
252
  continue
225
253
  }
226
254
 
255
+ // Projection-aware skip (ADR-0027): a fragment read that does not select
256
+ // this field does no work for it at all — no field-level read-access
257
+ // check, no resolveOutput, no recursion into a relation — because the
258
+ // read is never going to return it. `selection` is only ever restricted
259
+ // by a fragment's own field selection; a bare/`include`-based read, and a
260
+ // relation reached only to satisfy another field's `needs`, pass no
261
+ // selection at all and compute every field here, unchanged.
262
+ if (selection?.fields && !selection.fields.has(fieldName)) {
263
+ continue
264
+ }
265
+
227
266
  // Handle relationship fields - recursively filter fields within related items
228
- // Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
267
+ // Note: Access control filtering is now done at database level via buildAccessScopedInclude
229
268
  // This only handles field-level access (hiding sensitive fields)
230
269
  //
231
270
  // Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
@@ -251,6 +290,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
251
290
  })
252
291
 
253
292
  if (!canRead) {
293
+ accessDeniedKeys.add(fieldName)
254
294
  continue
255
295
  }
256
296
 
@@ -260,6 +300,12 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
260
300
  // back to an empty tree when this relation isn't declaration-related at
261
301
  // all — the common case.
262
302
  const nestedDeclaredOnly = declaredOnly.nested[fieldName] ?? emptyDeclaredOnlyTree()
303
+ // This relation's own fragment scope, if the caller's fragment named it
304
+ // with a nested Fragment/RelationSelector. `undefined` (a bare `true`
305
+ // selector, or no `selection` at all) means the nested list computes
306
+ // unrestricted — matching what naming a relation without narrowing it
307
+ // further has always meant.
308
+ const nestedSelection = selection?.nested[fieldName]
263
309
 
264
310
  if (relatedConfig) {
265
311
  // For many relationships (arrays) - recursively filter fields in each item
@@ -275,6 +321,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
275
321
  depth + 1,
276
322
  relatedConfig.listName,
277
323
  nestedDeclaredOnly,
324
+ nestedSelection,
278
325
  ),
279
326
  ),
280
327
  )
@@ -290,6 +337,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
290
337
  depth + 1,
291
338
  relatedConfig.listName,
292
339
  nestedDeclaredOnly,
340
+ nestedSelection,
293
341
  )
294
342
  }
295
343
  } else {
@@ -314,7 +362,36 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
314
362
 
315
363
  if (result.readable) {
316
364
  filtered[fieldName] = result.value
365
+ } else {
366
+ accessDeniedKeys.add(fieldName)
367
+ }
368
+ }
369
+
370
+ // The item a virtual field's hook sees: stored columns and fetched
371
+ // relations (from `workingItem`, never a resolved value — no hook's output
372
+ // is ever written back into `workingItem`). A key is visible here if it
373
+ // either survived into `filtered` (selected and allowed) OR exists only to
374
+ // satisfy a `needs` declaration (`declaredOnly` — that IS the point of
375
+ // declaring it: fetched for a hook, never for the caller, ADR-0025).
376
+ // Everything else — field-level denied, or skipped by `selection` and
377
+ // declared by no one — is deleted. A computed field reaches for exactly its
378
+ // own declared dependencies and nothing another field's hook produced
379
+ // (ADR-0027): reaching for a sibling that was denied or skipped-and-
380
+ // undeclared finds nothing there, the same as reaching for one never
381
+ // declared at all, and reaching for a sibling that DID survive finds its
382
+ // raw stored form, never another hook's resolved value — a virtual field
383
+ // computed earlier in declaration order is exactly as invisible as one
384
+ // computed later.
385
+ const computedFieldItem: Record<string, unknown> = { ...workingItem }
386
+ for (const key of Object.keys(workingItem)) {
387
+ if (['id', 'createdAt', 'updatedAt'].includes(key)) continue
388
+ if (accessDeniedKeys.has(key)) {
389
+ delete computedFieldItem[key]
390
+ continue
317
391
  }
392
+ if (key in filtered) continue
393
+ if (declaredOnly.keys.has(key)) continue
394
+ delete computedFieldItem[key]
318
395
  }
319
396
 
320
397
  // Process virtual fields - compute values from other fields
@@ -330,22 +407,29 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
330
407
  continue
331
408
  }
332
409
 
333
- // Virtual fields must have a resolveOutput hook to compute their value;
334
- // without one there is nothing to add to the result.
410
+ // Projection-aware skip (ADR-0027): same rule as the stored-field pass
411
+ // above a fragment that does not select this virtual field does no
412
+ // work for it at all.
413
+ if (selection?.fields && !selection.fields.has(fieldName)) {
414
+ continue
415
+ }
416
+
417
+ // A virtual field with no resolveOutput hook can never produce a value
418
+ // on ANY read — there is nothing to compute, so there is nothing to do,
419
+ // including evaluating its read access (ADR-0027 reconciles the
420
+ // access-only evaluation this branch used to preserve: a field that
421
+ // never has output has no side effect worth preserving access for).
335
422
  if (!(fieldConfig.hooks?.resolveOutput && listKey)) {
336
- // Still evaluate read access to preserve any access-fn side effects.
337
- await checkFieldAccess(fieldConfig.access, 'read', { ...args, item: workingItem })
338
423
  continue
339
424
  }
340
425
 
341
- // Check read access and compute the value via the shared helper. Virtual
342
- // fields see the already-filtered item so they can read sibling fields.
426
+ // Check read access and compute the value via the shared helper.
343
427
  const result = await resolveReadableFieldValue({
344
428
  fieldConfig,
345
429
  fieldName,
346
430
  value: undefined, // Virtual fields don't have a database value
347
431
  accessItem: workingItem,
348
- hookItem: filtered,
432
+ hookItem: computedFieldItem,
349
433
  listKey,
350
434
  args,
351
435
  config,
@@ -357,10 +441,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
357
441
  }
358
442
 
359
443
  // Strip relations that were fetched ONLY to satisfy a `needs` declaration
360
- // (ADR-0025), now that every resolveOutput hook at this level including
361
- // virtual fields, which read the assembled `filtered` object above has
362
- // had the chance to see them. A declared dependency is private plumbing,
363
- // not an implicit `include`: it never widens what the caller receives.
444
+ // (ADR-0025), now that every resolveOutput hook at this level has had the
445
+ // chance to see them (via `computedFieldItem`, never `filtered` itselfa
446
+ // declared dependency is read from stored columns, not from another
447
+ // field's resolved output). A declared dependency is private plumbing, not
448
+ // an implicit `include`: it never widens what the caller receives.
364
449
  for (const key of declaredOnly.keys) {
365
450
  delete filtered[key]
366
451
  }
@@ -22,13 +22,7 @@ export {
22
22
  // Canonical field-level access evaluation (shared by read and write paths).
23
23
  export { checkFieldAccess, filterWritableFields } from './field-access.js'
24
24
  // Phase 1 — Access Filter (pre-query row/relation scoping).
25
- export {
26
- buildIncludeWithAccessControl,
27
- mergeIncludeWithAccessControl,
28
- stripVirtualFieldsFromInclude,
29
- toPrismaInclude,
30
- } from './access-filter.js'
31
- export type { AccessIncludeResult } from './access-filter.js'
25
+ export { buildAccessScopedInclude, stripVirtualFieldsFromInclude } from './access-filter.js'
32
26
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
33
27
  export { filterReadableFields } from './field-visibility.js'
34
28
  // Declared Dependencies — folding `needs` into an include without widening
@@ -15,7 +15,7 @@ import { getDbKey } from '../lib/case-utils.js'
15
15
  * per-row query is issued and the count can never include rows the session
16
16
  * cannot read. This module is the single place the related list's
17
17
  * operation-level `query` access is folded into that `_count`, mirroring how
18
- * `buildIncludeWithAccessControl` folds it into relation includes.
18
+ * `buildAccessScopedInclude` folds it into relation includes.
19
19
  *
20
20
  * It also resolves the count Filter spec's markers: Prisma cannot compare a
21
21
  * relation count in a `where`, so a to-many relationship's Filter spec emits a
@@ -146,6 +146,8 @@ export type {
146
146
  FieldHooks,
147
147
  FieldsWithTypeInfo,
148
148
  DatabaseConfig,
149
+ ListIndex,
150
+ ListIndexFieldRef,
149
151
  SessionConfig,
150
152
  UIConfig,
151
153
  ListUIConfig,
@@ -1109,6 +1109,28 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
1109
1109
  */
1110
1110
  isIndexed?: boolean | 'unique'
1111
1111
  db?: {
1112
+ /**
1113
+ * Controls DB-level nullability of the foreign key column (and its
1114
+ * relation field) independently of the many side's own shape. Only
1115
+ * meaningful on the FK-owning (single) side of a relationship — the
1116
+ * many side has no column of its own to make non-nullable and rejects
1117
+ * this option.
1118
+ *
1119
+ * @default true (nullable, matching every relationship generated before
1120
+ * this option existed)
1121
+ *
1122
+ * @example
1123
+ * ```typescript
1124
+ * // Every session genuinely belongs to a user — make the FK required
1125
+ * user: relationship({
1126
+ * ref: 'User.sessions',
1127
+ * db: { isNullable: false },
1128
+ * })
1129
+ * // Generates: userId String (was String?)
1130
+ * // user User @relation(...) (was User?)
1131
+ * ```
1132
+ */
1133
+ isNullable?: boolean
1112
1134
  /**
1113
1135
  * Controls foreign key placement and column name for bidirectional relationships
1114
1136
  * Can be a boolean or an object with a map property
@@ -1246,6 +1268,16 @@ export type PrismaRelationResult = {
1246
1268
  * for the many side or the non-FK side it is `[relationLine]`.
1247
1269
  */
1248
1270
  modelLines: string[]
1271
+ /**
1272
+ * The Prisma-level foreign key field name this side owns (e.g. `authorId`),
1273
+ * regardless of whether it is indexed. `undefined` when this side doesn't
1274
+ * own a foreign key column at all (the many side, or the non-FK side of a
1275
+ * one-to-one). Lets other generator passes resolve a relationship field
1276
+ * name to its physical column — e.g. a model-level composite index
1277
+ * ({@link ListIndex}) naming a relationship field — without duplicating
1278
+ * {@link foreignKeyIndex}'s narrower, indexing-conditional presence.
1279
+ */
1280
+ foreignKeyField?: string
1249
1281
  /**
1250
1282
  * Foreign key index to add to the owning model, if this side owns an
1251
1283
  * indexed foreign key.
@@ -1802,6 +1834,43 @@ export type Hooks<
1802
1834
  validateInput?: (args: ValidateHookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
1803
1835
  }
1804
1836
 
1837
+ /**
1838
+ * A single field reference within a model-level {@link ListIndex}. Either
1839
+ * just the OpenSaaS field name, or an object naming it alongside a sort
1840
+ * direction.
1841
+ */
1842
+ export type ListIndexFieldRef =
1843
+ | string
1844
+ | {
1845
+ field: string
1846
+ /** Sort direction for this column within the index/constraint. */
1847
+ sort?: 'asc' | 'desc'
1848
+ }
1849
+
1850
+ /**
1851
+ * A model-level composite `@@unique`/`@@index` constraint spanning two or
1852
+ * more of a list's own fields. See {@link ListConfig.db}'s `indexes` for the
1853
+ * full explanation and examples (#864).
1854
+ */
1855
+ export type ListIndex = {
1856
+ /**
1857
+ * The fields participating in this index/constraint, in declaration order.
1858
+ * OpenSaaS field names, not raw database column names.
1859
+ */
1860
+ fields: ListIndexFieldRef[]
1861
+ /**
1862
+ * Emit `@@unique([...])` instead of `@@index([...])`.
1863
+ * @default false
1864
+ */
1865
+ unique?: boolean
1866
+ /**
1867
+ * Constraint/index name, emitted as Prisma's `map:` argument — lets an
1868
+ * existing live constraint be adopted under its current name rather than
1869
+ * renamed.
1870
+ */
1871
+ name?: string
1872
+ }
1873
+
1805
1874
  // Generic `any` default allows ListConfig to work with any list item type
1806
1875
  // This is needed because the item type varies per list and is inferred from Prisma models
1807
1876
  /**
@@ -1879,6 +1948,67 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
1879
1948
  * ```
1880
1949
  */
1881
1950
  timestamps?: boolean
1951
+ /**
1952
+ * Model-level composite `@@unique`/`@@index` constraints spanning two or
1953
+ * more of this list's own fields (#864).
1954
+ *
1955
+ * Field-level `isIndexed` (on a scalar or relationship field) can only
1956
+ * ever produce a single-column index — it has no way to express a
1957
+ * constraint or index that spans more than one column. `db.indexes` is
1958
+ * that multi-column case: each entry names two or more of the list's own
1959
+ * OpenSaaS field names, not raw database column names. The generator
1960
+ * resolves each to its underlying Prisma column — a scalar field's own
1961
+ * name (its Prisma field name is unaffected by `db.map`), or a
1962
+ * relationship field's foreign key column (`<field>Id`) when this side
1963
+ * owns it.
1964
+ *
1965
+ * A composite **unique** is the load-bearing case: it's the
1966
+ * database-level backstop for a business rule two concurrent writes could
1967
+ * otherwise both slip past (e.g. "one booking per student per
1968
+ * production") — something a hook's existence check cannot close on its
1969
+ * own, and can't be retrofitted once duplicate rows exist. A composite
1970
+ * **index** (non-unique) is the equivalent performance-only case (a hot
1971
+ * multi-column lookup path).
1972
+ *
1973
+ * An entry naming a field the list doesn't have, a virtual field, a
1974
+ * to-many relationship, or the non-FK side of a one-to-one relationship
1975
+ * fails `pnpm generate` with an error naming the list, the entry, and the
1976
+ * bad field — it is never silently dropped or emitted as invalid Prisma.
1977
+ *
1978
+ * @example One audition per student per production (composite unique)
1979
+ * ```typescript
1980
+ * Audition: list({
1981
+ * fields: {
1982
+ * student: relationship({ ref: 'Student.auditions' }),
1983
+ * production: relationship({ ref: 'Production.auditions' }),
1984
+ * },
1985
+ * db: {
1986
+ * indexes: [{ fields: ['student', 'production'], unique: true }],
1987
+ * },
1988
+ * })
1989
+ * // Generates: @@unique([studentId, productionId])
1990
+ * ```
1991
+ *
1992
+ * @example Hot lookup path (composite index) with a sort direction and an adopted constraint name
1993
+ * ```typescript
1994
+ * AuthVerification: list({
1995
+ * fields: {
1996
+ * identifier: text(),
1997
+ * createdAt: timestamp(),
1998
+ * },
1999
+ * db: {
2000
+ * indexes: [
2001
+ * {
2002
+ * fields: ['identifier', { field: 'createdAt', sort: 'desc' }],
2003
+ * name: 'AuthVerification_identifier_createdAt_idx',
2004
+ * },
2005
+ * ],
2006
+ * },
2007
+ * })
2008
+ * // Generates: @@index([identifier, createdAt(sort: Desc)], map: "AuthVerification_identifier_createdAt_idx")
2009
+ * ```
2010
+ */
2011
+ indexes?: ListIndex[]
1882
2012
  }
1883
2013
  /**
1884
2014
  * MCP server configuration for this list
@@ -4,8 +4,7 @@ import {
4
4
  checkAccess,
5
5
  mergeFilters,
6
6
  filterReadableFields,
7
- buildIncludeWithAccessControl,
8
- mergeIncludeWithAccessControl,
7
+ buildAccessScopedInclude,
9
8
  stripVirtualFieldsFromInclude,
10
9
  foldDeclaredDependencies,
11
10
  } from '../access/index.js'
@@ -13,8 +12,8 @@ import type { DeclaredOnlyTree } from '../access/index.js'
13
12
  import { ValidationError, DatabaseError } from '../hooks/index.js'
14
13
  import { getDbKey } from '../lib/case-utils.js'
15
14
  import type { PrismaClientLike } from '../access/types.js'
16
- import { buildInclude, pickFields, isFragment } from '../query/index.js'
17
- import type { FieldSelection } from '../query/index.js'
15
+ import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js'
16
+ import type { FieldSelection, FieldSelectionScope } from '../query/index.js'
18
17
  import { getRelationshipOptions } from '../query/relationship-options.js'
19
18
  import {
20
19
  runWritePipeline,
@@ -921,11 +920,19 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
921
920
  *
922
921
  * A fragment's own `include` and a sudo caller's `include` are folded and
923
922
  * used as-is, matching their existing (unmerged) treatment. A non-sudo
924
- * caller include is folded and then merged through the same
925
- * access-scoping pipeline as before. A bare read stays on the exact
926
- * ADR-0024 path `include: undefined`, no related `query` access
927
- * evaluated unless folding actually added something, which only happens
928
- * when a field on this list declares `needs`.
923
+ * caller include is folded and then scoped by `buildAccessScopedInclude`
924
+ * (ADR-0026) caller-directed, so a relation named nowhere in the folded
925
+ * tree never has its list's `query` access evaluated at all. A bare read
926
+ * stays on the exact ADR-0024 path `include: undefined`, no related
927
+ * `query` access evaluated unless folding actually added something, which
928
+ * only happens when a field on this list declares `needs`.
929
+ *
930
+ * Also returns the `FieldSelectionScope` a fragment's own field selection
931
+ * produces (ADR-0027), so the caller can pass it to `filterReadableFields`
932
+ * and make computation itself projection-aware, not only the fold above.
933
+ * `undefined` for every non-fragment path: a caller `include` (sudo or not)
934
+ * and a bare read both mean "compute every field," matching what they
935
+ * already fetch.
929
936
  */
930
937
  async function resolveReadInclude(
931
938
  callerInclude: Record<string, unknown> | undefined,
@@ -935,38 +942,43 @@ async function resolveReadInclude(
935
942
  listConfig: ListConfig<any>,
936
943
  context: AccessContext & { _isSudo?: boolean },
937
944
  config: OpenSaasConfig,
938
- ): Promise<{ include: Record<string, unknown> | undefined; declaredOnly: DeclaredOnlyTree }> {
945
+ ): Promise<{
946
+ include: Record<string, unknown> | undefined
947
+ declaredOnly: DeclaredOnlyTree
948
+ selection: FieldSelectionScope | undefined
949
+ }> {
939
950
  if (fragmentFields !== undefined) {
940
951
  const fragmentInclude = buildInclude(fragmentFields) ?? undefined
941
- return foldDeclaredDependencies(fragmentInclude, listConfig.fields, config)
952
+ const selection = buildFieldSelectionScope(fragmentFields)
953
+ const folded = foldDeclaredDependencies(
954
+ fragmentInclude,
955
+ listConfig.fields,
956
+ config,
957
+ listName,
958
+ [listName],
959
+ selection,
960
+ )
961
+ return { ...folded, selection }
942
962
  }
943
963
 
944
964
  if (context._isSudo) {
945
- return foldDeclaredDependencies(callerInclude, listConfig.fields, config)
965
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
966
+ return { ...folded, selection: undefined }
946
967
  }
947
968
 
948
- const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config)
969
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
949
970
  if (!folded.include) {
950
- return folded
971
+ return { ...folded, selection: undefined }
951
972
  }
952
973
 
953
- const accessControlledInclude = await buildIncludeWithAccessControl(
954
- listConfig.fields,
955
- { session: context.session, context },
956
- config,
957
- 0,
958
- // Seed the cycle guard with the root list so a relationship cycle back
959
- // to it (self-referential or longer) stops re-descending.
960
- [listName],
961
- )
962
- const include = mergeIncludeWithAccessControl(
974
+ const include = await buildAccessScopedInclude(
963
975
  folded.include,
964
- accessControlledInclude,
965
976
  listConfig.fields,
977
+ { session: context.session, context },
966
978
  config,
967
979
  listName,
968
980
  )
969
- return { include, declaredOnly: folded.declaredOnly }
981
+ return { include, declaredOnly: folded.declaredOnly, selection: undefined }
970
982
  }
971
983
 
972
984
  /**
@@ -1032,7 +1044,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1032
1044
  // Resolve `include`, folding any declared dependencies (`needs`,
1033
1045
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1034
1046
  // already produces — see `resolveReadInclude`'s doc comment.
1035
- let { include, declaredOnly } = await resolveReadInclude(
1047
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1036
1048
  args.include,
1037
1049
  fragment ? fragment._fields : undefined,
1038
1050
  listName,
@@ -1044,8 +1056,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1044
1056
  // Virtual fields have no database column. Whichever path produced
1045
1057
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
1046
1058
  // virtual key must never reach Prisma — it would throw "Unknown field"
1047
- // (#628). The virtual value is still computed unconditionally below by
1048
- // `filterReadableFields`, independent of what was requested here.
1059
+ // (#628). Below, `filterReadableFields` computes a virtual field's value
1060
+ // exactly when `selection` says the read is going to return it (ADR-0027)
1061
+ // — every one of them for a bare/`include`-based read (`selection` is
1062
+ // `undefined`), only the ones a fragment named otherwise.
1049
1063
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1050
1064
 
1051
1065
  // Execute query with optimized includes
@@ -1074,6 +1088,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1074
1088
  0,
1075
1089
  listName,
1076
1090
  declaredOnly,
1091
+ selection,
1077
1092
  )
1078
1093
 
1079
1094
  // When a fragment is provided, pick only the requested fields from the result
@@ -1145,7 +1160,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1145
1160
  // Resolve `include`, folding any declared dependencies (`needs`,
1146
1161
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1147
1162
  // already produces — see `resolveReadInclude`'s doc comment.
1148
- let { include, declaredOnly } = await resolveReadInclude(
1163
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1149
1164
  args?.include,
1150
1165
  fragment ? fragment._fields : undefined,
1151
1166
  listName,
@@ -1157,8 +1172,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1157
1172
  // Virtual fields have no database column. Whichever path produced
1158
1173
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
1159
1174
  // virtual key must never reach Prisma — it would throw "Unknown field"
1160
- // (#628). The virtual value is still computed unconditionally below by
1161
- // `filterReadableFields`, independent of what was requested here.
1175
+ // (#628). Below, `filterReadableFields` computes a virtual field's value
1176
+ // exactly when `selection` says the read is going to return it (ADR-0027)
1177
+ // — every one of them for a bare/`include`-based read (`selection` is
1178
+ // `undefined`), only the ones a fragment named otherwise.
1162
1179
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1163
1180
 
1164
1181
  // Execute query with optimized includes
@@ -1188,6 +1205,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1188
1205
  0,
1189
1206
  listName,
1190
1207
  declaredOnly,
1208
+ selection,
1191
1209
  ),
1192
1210
  ),
1193
1211
  )
@@ -1461,7 +1479,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1461
1479
  // Resolve `include`, folding any declared dependencies (`needs`,
1462
1480
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1463
1481
  // already produces — see `resolveReadInclude`'s doc comment.
1464
- let { include, declaredOnly } = await resolveReadInclude(
1482
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1465
1483
  args?.include,
1466
1484
  fragment ? fragment._fields : undefined,
1467
1485
  listName,
@@ -1493,6 +1511,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1493
1511
  0,
1494
1512
  listName,
1495
1513
  declaredOnly,
1514
+ selection,
1496
1515
  )
1497
1516
  // When a fragment is provided, pick only the requested fields from the result
1498
1517
  if (fragment) {
@@ -1387,10 +1387,16 @@ function getPrismaRelation(
1387
1387
  ? ` @map("${field.db.foreignKey.map}")`
1388
1388
  : ` @map("${fieldName}")`
1389
1389
 
1390
- let fkLine = ` ${fkPaddedName} String?${uniqueModifier}${mapModifier}`
1390
+ // Nullability: explicit db.isNullable overrides the default (nullable),
1391
+ // matching the scalar fields' `db.isNullable` convention. It moves the FK
1392
+ // column and its relation field together — they can never disagree.
1393
+ const isNullable = field.db?.isNullable ?? true
1394
+ const nullModifier = isNullable ? '?' : ''
1395
+
1396
+ let fkLine = ` ${fkPaddedName} String${nullModifier}${uniqueModifier}${mapModifier}`
1391
1397
  let relationLine = targetField
1392
- ? ` ${paddedName} ${targetList}? @relation(fields: [${foreignKeyField}], references: [id])`
1393
- : ` ${paddedName} ${targetList}? @relation("${listKey}_${fieldName}", fields: [${foreignKeyField}], references: [id])`
1398
+ ? ` ${paddedName} ${targetList}${nullModifier} @relation(fields: [${foreignKeyField}], references: [id])`
1399
+ : ` ${paddedName} ${targetList}${nullModifier} @relation("${listKey}_${fieldName}", fields: [${foreignKeyField}], references: [id])`
1394
1400
 
1395
1401
  if (field.db?.extendPrismaSchema) {
1396
1402
  const extended = field.db.extendPrismaSchema({ fkLine, relationLine })
@@ -1402,10 +1408,24 @@ function getPrismaRelation(
1402
1408
  const indexType = field.isIndexed ?? true
1403
1409
  const foreignKeyIndex = indexType !== false ? { foreignKeyField, indexType } : undefined
1404
1410
 
1405
- return { modelLines: [fkLine, relationLine], foreignKeyIndex, backRelation }
1411
+ return { modelLines: [fkLine, relationLine], foreignKeyField, foreignKeyIndex, backRelation }
1412
+ }
1413
+
1414
+ // Non-FK side of a one-to-one relationship: just the relation field. This
1415
+ // side has no foreign key column, so `db.isNullable` (which only makes
1416
+ // sense paired with a column) cannot be honoured here — reject rather than
1417
+ // silently ignore a developer's stated intent (the FK-owning side is
1418
+ // determined by `db.foreignKey`/alphabetical ordering, not by which field
1419
+ // declares `isNullable`).
1420
+ if (field.db?.isNullable === false) {
1421
+ throw new Error(
1422
+ `db.isNullable can only be used on the foreign-key-owning side of a relationship. ` +
1423
+ `"${listKey}.${fieldName}" does not own the foreign key for this one-to-one relationship — ` +
1424
+ `set db.isNullable on "${targetList}.${targetField}" instead, or make this side own the ` +
1425
+ `foreign key via db.foreignKey.`,
1426
+ )
1406
1427
  }
1407
1428
 
1408
- // Non-FK side of a one-to-one relationship: just the relation field
1409
1429
  let relationLine = ` ${paddedName} ${targetList}?`
1410
1430
  if (field.db?.extendPrismaSchema) {
1411
1431
  relationLine = field.db.extendPrismaSchema({ relationLine }).relationLine
@@ -1451,6 +1471,16 @@ export function relationship<
1451
1471
  }
1452
1472
  }
1453
1473
 
1474
+ // Validate db.isNullable usage: only the FK-owning (single) side of a
1475
+ // relationship has a column to make non-nullable — the many side always
1476
+ // generates an array field with no nullability of its own.
1477
+ if (options.db?.isNullable !== undefined && options.many) {
1478
+ throw new Error(
1479
+ 'db.isNullable can only be used on single relationships (many: false or undefined). ' +
1480
+ 'Many-side of a relationship has no foreign key column to make non-nullable.',
1481
+ )
1482
+ }
1483
+
1454
1484
  const field: RelationshipField<TTypeInfo> = {
1455
1485
  type: 'relationship',
1456
1486
  ...options,
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export type {
19
19
  OutputConfig,
20
20
  ListConfig,
21
21
  DatabaseConfig,
22
+ ListIndex,
23
+ ListIndexFieldRef,
22
24
  FieldConfig,
23
25
  OperationAccess,
24
26
  // Custom Bulk actions (issue #736) — declared per list in