@opensaas/stack-core 0.39.2 → 0.40.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 (130) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +150 -0
  3. package/CLAUDE.md +51 -0
  4. package/dist/access/access-filter.d.ts +83 -10
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +139 -18
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +224 -12
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/engine.d.ts +43 -1
  11. package/dist/access/engine.d.ts.map +1 -1
  12. package/dist/access/engine.js +58 -0
  13. package/dist/access/engine.js.map +1 -1
  14. package/dist/access/engine.test.d.ts +2 -0
  15. package/dist/access/engine.test.d.ts.map +1 -0
  16. package/dist/access/engine.test.js +56 -0
  17. package/dist/access/engine.test.js.map +1 -0
  18. package/dist/access/errors.d.ts +28 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +38 -2
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-access.d.ts +12 -0
  23. package/dist/access/field-access.d.ts.map +1 -1
  24. package/dist/access/field-access.js +22 -2
  25. package/dist/access/field-access.js.map +1 -1
  26. package/dist/access/field-access.test.js +66 -0
  27. package/dist/access/field-access.test.js.map +1 -1
  28. package/dist/access/field-visibility.d.ts +2 -1
  29. package/dist/access/field-visibility.d.ts.map +1 -1
  30. package/dist/access/field-visibility.js +45 -3
  31. package/dist/access/field-visibility.js.map +1 -1
  32. package/dist/access/index.d.ts +5 -2
  33. package/dist/access/index.d.ts.map +1 -1
  34. package/dist/access/index.js +4 -2
  35. package/dist/access/index.js.map +1 -1
  36. package/dist/config/types.d.ts +76 -10
  37. package/dist/config/types.d.ts.map +1 -1
  38. package/dist/context/index.d.ts +19 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +61 -17
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/context/nested-operations.d.ts.map +1 -1
  43. package/dist/context/nested-operations.js +54 -13
  44. package/dist/context/nested-operations.js.map +1 -1
  45. package/dist/context/write-pipeline.d.ts +3 -2
  46. package/dist/context/write-pipeline.d.ts.map +1 -1
  47. package/dist/context/write-pipeline.js +8 -5
  48. package/dist/context/write-pipeline.js.map +1 -1
  49. package/dist/fields/index.d.ts +13 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +50 -5
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  54. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  55. package/dist/fields/relationship-foreign-key.test.js +18 -0
  56. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lib/case-utils.d.ts +15 -0
  62. package/dist/lib/case-utils.d.ts.map +1 -1
  63. package/dist/lib/case-utils.js +21 -0
  64. package/dist/lib/case-utils.js.map +1 -1
  65. package/dist/lib/case-utils.test.js +23 -1
  66. package/dist/lib/case-utils.test.js.map +1 -1
  67. package/dist/lib/prisma-errors.d.ts +38 -0
  68. package/dist/lib/prisma-errors.d.ts.map +1 -0
  69. package/dist/lib/prisma-errors.js +53 -0
  70. package/dist/lib/prisma-errors.js.map +1 -0
  71. package/dist/mcp/constants.d.ts +23 -0
  72. package/dist/mcp/constants.d.ts.map +1 -0
  73. package/dist/mcp/constants.js +23 -0
  74. package/dist/mcp/constants.js.map +1 -0
  75. package/dist/mcp/field-schema.d.ts +8 -0
  76. package/dist/mcp/field-schema.d.ts.map +1 -0
  77. package/dist/mcp/field-schema.js +69 -0
  78. package/dist/mcp/field-schema.js.map +1 -0
  79. package/dist/mcp/handler.d.ts.map +1 -1
  80. package/dist/mcp/handler.js +76 -90
  81. package/dist/mcp/handler.js.map +1 -1
  82. package/dist/mcp/projection.d.ts +62 -0
  83. package/dist/mcp/projection.d.ts.map +1 -0
  84. package/dist/mcp/projection.js +390 -0
  85. package/dist/mcp/projection.js.map +1 -0
  86. package/dist/utils/password.d.ts +3 -1
  87. package/dist/utils/password.d.ts.map +1 -1
  88. package/dist/utils/password.js +4 -2
  89. package/dist/utils/password.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/access/access-filter.test.ts +278 -12
  92. package/src/access/access-filter.ts +207 -19
  93. package/src/access/engine.test.ts +67 -0
  94. package/src/access/engine.ts +81 -1
  95. package/src/access/errors.ts +42 -2
  96. package/src/access/field-access.test.ts +78 -0
  97. package/src/access/field-access.ts +34 -2
  98. package/src/access/field-visibility.ts +75 -10
  99. package/src/access/index.ts +14 -0
  100. package/src/config/types.ts +76 -10
  101. package/src/context/index.ts +95 -15
  102. package/src/context/nested-operations.ts +61 -16
  103. package/src/context/write-pipeline.ts +8 -4
  104. package/src/fields/index.ts +55 -5
  105. package/src/fields/relationship-foreign-key.test.ts +29 -0
  106. package/src/index.ts +17 -1
  107. package/src/lib/case-utils.test.ts +32 -0
  108. package/src/lib/case-utils.ts +26 -0
  109. package/src/lib/prisma-errors.ts +86 -0
  110. package/src/mcp/constants.ts +24 -0
  111. package/src/mcp/field-schema.ts +84 -0
  112. package/src/mcp/handler.ts +109 -107
  113. package/src/mcp/projection.ts +497 -0
  114. package/src/utils/password.ts +5 -3
  115. package/tests/access-relationships.test.ts +3 -3
  116. package/tests/access.test.ts +83 -0
  117. package/tests/context.test.ts +259 -12
  118. package/tests/field-types.test.ts +113 -0
  119. package/tests/mcp-fields-projection-access.test.ts +250 -0
  120. package/tests/mcp-handler.test.ts +695 -4
  121. package/tests/nested-access-and-hooks.test.ts +65 -0
  122. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  123. package/tests/password-types.test.ts +28 -0
  124. package/tests/password.test.ts +16 -2
  125. package/tests/prisma-errors.test.ts +141 -0
  126. package/tests/singleton.test.ts +9 -1
  127. package/tests/to-one-include-access-filter.test.ts +172 -0
  128. package/tests/with-session.test.ts +243 -0
  129. package/tests/write-pipeline.test.ts +29 -0
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -1,10 +1,12 @@
1
1
  import type { Session, AccessContext } from './types.js'
2
2
  import type { FieldAccess, FieldAccessControl } from './types.js'
3
+ import type { OpenSaasConfig } from '../config/types.js'
3
4
  // `ValidationError` is referenced only inside function bodies (call-time), never
4
5
  // at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
5
6
  // under ESM live bindings.
6
7
  import { ValidationError } from '../hooks/index.js'
7
8
  import { InvalidFieldAccessResultError } from './errors.js'
9
+ import { resolveSyntheticReverseRelation } from './engine.js'
8
10
 
9
11
  /**
10
12
  * Marks a throw caused by touching {@link createPoisonedItem}'s `item`, as
@@ -196,6 +198,17 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
196
198
  item?: Record<string, unknown>
197
199
  context: AccessContext & { _isSudo?: boolean }
198
200
  inputData?: Record<string, unknown>
201
+ /**
202
+ * The list being written and the full config — used ONLY to recognise a
203
+ * synthetic reverse-relation key (`from_<List>_<field>`, #978) among the
204
+ * undeclared keys sudo would otherwise pass through unchecked. Both
205
+ * production call sites (the write pipeline, nested-operations) supply
206
+ * these; a direct unit test that omits them keeps the pre-#978 sudo
207
+ * behaviour of passing any undeclared key through, since it has no config
208
+ * to resolve a synthetic key against.
209
+ */
210
+ listName?: string
211
+ config?: OpenSaasConfig
199
212
  },
200
213
  ): Promise<Partial<T>> {
201
214
  const filtered: Record<string, unknown> = {}
@@ -286,10 +299,29 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
286
299
  // declares (e.g. back-relations like `from_Enrolment_student`), so allowing
287
300
  // an undeclared key to pass through lets a non-sudo caller drive ungated
288
301
  // nested writes on undeclared back-relations. Mirror Keystone's
289
- // GraphQL-schema behaviour and reject it. `sudo` is the single trusted
290
- // bypass, so undeclared keys still pass through under sudo.
302
+ // GraphQL-schema behaviour and reject it.
291
303
  if (!fieldConfig) {
292
304
  if (isSudo) {
305
+ // #978 — sudo bypasses ACCESS CONTROL, not the hooks/validation a
306
+ // recognised relation is entitled to. A synthetic reverse-relation key
307
+ // (a list-only ref's back-relation) is handed to the caller unchanged
308
+ // so processNestedOperations can run its target list's full pipeline,
309
+ // exactly as it would for a declared relationship field. Any other
310
+ // undeclared key has no such route to hooks — passing it straight to
311
+ // Prisma is the same silent-bypass shape this issue closed for
312
+ // relations, so it is refused even under sudo. `listName`/`config`
313
+ // are omitted only by direct unit tests of this function, which keep
314
+ // the pre-#978 blanket sudo passthrough since they have no config to
315
+ // resolve a synthetic key against.
316
+ if (args.listName && args.config) {
317
+ const synthetic = resolveSyntheticReverseRelation(fieldName, args.listName, args.config)
318
+ if (!synthetic) {
319
+ throw new ValidationError([
320
+ `Cannot ${operation} "${fieldName}": it is not a field of this list. ` +
321
+ `Undeclared data keys are rejected, even under sudo.`,
322
+ ])
323
+ }
324
+ }
293
325
  filtered[fieldName] = value
294
326
  continue
295
327
  }
@@ -7,6 +7,8 @@ import { ResolveOutputCycleError } from './errors.js'
7
7
  import type { DeclaredOnlyTree } from './declared-dependencies.js'
8
8
  import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
9
9
  import type { FieldSelectionScope } from '../query/index.js'
10
+ import type { ToOneAccessVisibilityTree } from './access-filter.js'
11
+ import { emptyToOneAccessVisibilityTree } from './access-filter.js'
10
12
  // NOTE: `context/index.ts` imports `filterReadableFields` from this module
11
13
  // (via the `access/index.ts` barrel) — this is an intentional cyclic
12
14
  // dependency, the same shape and for the same reason as the one documented in
@@ -36,6 +38,22 @@ import { buildDbDelegate } from '../context/index.js'
36
38
  * Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
37
39
  * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
38
40
  * glossary in `CONTEXT.md`.
41
+ *
42
+ * **To-one relation nulling (issue #974).** A to-one relation whose related
43
+ * list's `query` access resolves to a filter cannot be scoped by Prisma's own
44
+ * `where` (it only accepts one on a to-many include — see the "To-one
45
+ * relations" section of `access-filter.ts`'s module doc), so
46
+ * `buildAccessScopedInclude` fetches it unscoped and hands this module a
47
+ * `ToOneAccessVisibilityTree` — already resolved, via one batched existence
48
+ * check per relation across the whole read, by
49
+ * `resolveToOneAccessVisibility`. This module is where that resolution
50
+ * actually becomes the caller-visible `null`: a `kind: 'denied'` key is
51
+ * forced to `null` even though it was never fetched at all (the key is
52
+ * absent from `workingItem`), and a `kind: 'visible'` key's fetched row is
53
+ * nulled out unless its id survived the existence check — in both branches,
54
+ * before any field-level access check or `resolveOutput` hook runs, so the
55
+ * rest of the pipeline sees exactly what a denied to-one read has always
56
+ * meant elsewhere: `null`, never a thrown error.
39
57
  */
40
58
 
41
59
  type ResolveOutputHookRuntime = (args: {
@@ -193,6 +211,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
193
211
  // The fragment scope this level was reached under (ADR-0027, see module doc
194
212
  // above), and the same tree one level down for each nested relation.
195
213
  selection?: FieldSelectionScope,
214
+ // Resolved to-one existence checks at THIS level (issue #974, see module
215
+ // doc above), and the same tree one level down for each nested relation —
216
+ // regardless of that relation's own arity, since a filtered to-one can sit
217
+ // beneath a to-many hop.
218
+ toOneVisibility: ToOneAccessVisibilityTree = emptyToOneAccessVisibilityTree(),
196
219
  ): Promise<Partial<T>> {
197
220
  const filtered: Record<string, unknown> = {}
198
221
 
@@ -286,6 +309,15 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
286
309
  // unrestricted — matching what naming a relation without narrowing it
287
310
  // further has always meant.
288
311
  const nestedSelection = selection?.nested[fieldName]
312
+ // This relation's own resolved to-one visibility, if
313
+ // `buildAccessScopedInclude` flagged anything beneath it (issue #974).
314
+ // Falls back to empty — the common case for a relation with no
315
+ // filtered to-one anywhere in its own nested include.
316
+ const nestedToOneVisibility =
317
+ toOneVisibility.nested[fieldName] ?? emptyToOneAccessVisibilityTree()
318
+ // This key's OWN to-one existence check, if `fieldName` itself is a
319
+ // filtered to-one relation (as opposed to one further down its tree).
320
+ const toOneEntry = toOneVisibility.filters[fieldName]
289
321
 
290
322
  if (relatedConfig) {
291
323
  if (Array.isArray(value)) {
@@ -300,20 +332,28 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
300
332
  relatedConfig.listName,
301
333
  nestedDeclaredOnly,
302
334
  nestedSelection,
335
+ nestedToOneVisibility,
303
336
  ),
304
337
  ),
305
338
  )
306
339
  } else if (typeof value === 'object') {
307
- filtered[fieldName] = await filterReadableFields(
308
- value as Record<string, unknown>,
309
- relatedConfig.listConfig.fields,
310
- args,
311
- config,
312
- depth + 1,
313
- relatedConfig.listName,
314
- nestedDeclaredOnly,
315
- nestedSelection,
316
- )
340
+ const relatedId = (value as Record<string, unknown>).id
341
+ const isVisible =
342
+ !toOneEntry || toOneEntry.kind !== 'visible' || toOneEntry.ids.has(String(relatedId))
343
+
344
+ filtered[fieldName] = isVisible
345
+ ? await filterReadableFields(
346
+ value as Record<string, unknown>,
347
+ relatedConfig.listConfig.fields,
348
+ args,
349
+ config,
350
+ depth + 1,
351
+ relatedConfig.listName,
352
+ nestedDeclaredOnly,
353
+ nestedSelection,
354
+ nestedToOneVisibility,
355
+ )
356
+ : null
317
357
  }
318
358
  } else {
319
359
  filtered[fieldName] = value
@@ -341,6 +381,31 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
341
381
  }
342
382
  }
343
383
 
384
+ // To-one relations `buildAccessScopedInclude` denied outright (issue #974)
385
+ // were never asked of Prisma at all, so `fieldName` has no entry in
386
+ // `workingItem` and the loop above never visits it. Force it present as an
387
+ // explicit `null` here — matching what a denied single-record read means
388
+ // everywhere else in the context — rather than leaving the key silently
389
+ // absent.
390
+ for (const [fieldName, entry] of Object.entries(toOneVisibility.filters)) {
391
+ if (entry.kind !== 'denied') continue
392
+ if (fieldName in filtered || fieldName in workingItem) continue
393
+ if (selection?.fields && !selection.fields.has(fieldName)) continue
394
+
395
+ const fieldConfig = fieldConfigs[fieldName]
396
+ const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
397
+ ...args,
398
+ item: workingItem,
399
+ })
400
+
401
+ if (!canRead) {
402
+ accessDeniedKeys.add(fieldName)
403
+ continue
404
+ }
405
+
406
+ filtered[fieldName] = null
407
+ }
408
+
344
409
  // The item a virtual field's hook sees: stored columns and fetched
345
410
  // relations (from `workingItem`, never a resolved value — no hook's output
346
411
  // is ever written back into `workingItem`). A key is visible here if it
@@ -14,11 +14,14 @@ export type {
14
14
  // Operation-level access primitives and shared ref-parsing helper.
15
15
  export {
16
16
  checkAccess,
17
+ checkCreateAccess,
17
18
  mergeFilters,
18
19
  isBoolean,
19
20
  isPrismaFilter,
20
21
  getRelatedListConfig,
22
+ resolveSyntheticReverseRelation,
21
23
  } from './engine.js'
24
+ export type { SyntheticReverseRelation } from './engine.js'
22
25
  // Canonical field-level access evaluation (shared by read and write paths).
23
26
  export {
24
27
  checkFieldAccess,
@@ -36,6 +39,15 @@ export {
36
39
  buildAccessScopedInclude,
37
40
  buildAccessScopedWhere,
38
41
  stripVirtualFieldsFromInclude,
42
+ resolveToOneAccessVisibility,
43
+ emptyToOneAccessFilterTree,
44
+ emptyToOneAccessVisibilityTree,
45
+ } from './access-filter.js'
46
+ export type {
47
+ ToOneAccessFilterTree,
48
+ ToOneAccessFilterEntry,
49
+ ToOneAccessVisibilityTree,
50
+ ToOneVisibility,
39
51
  } from './access-filter.js'
40
52
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
41
53
  export { filterReadableFields } from './field-visibility.js'
@@ -53,5 +65,7 @@ export { AccessScopeDepthExceededError } from './errors.js'
53
65
  export { ResolveOutputCycleError } from './errors.js'
54
66
  // Thrown when a field-level access control function returns a non-boolean result.
55
67
  export { InvalidFieldAccessResultError } from './errors.js'
68
+ // Thrown when operation-level `create` access control returns a non-boolean result (#1009).
69
+ export { InvalidCreateAccessResultError } from './errors.js'
56
70
  // Thrown when a relation filter's related list denies query access outright (#916).
57
71
  export { RelationFilterAccessDeniedError } from './errors.js'
@@ -591,6 +591,32 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
591
591
  * ```
592
592
  */
593
593
  description?: string
594
+ /**
595
+ * Whether this field belongs in a list/related-list table's DEFAULT
596
+ * column set (issue #1018) — the columns shown when nothing explicitly
597
+ * names them (`ui.listView.initialColumns` on the list, or a
598
+ * relationship's own `ui.itemView.columns`). Naming the field explicitly
599
+ * in either of those always shows it regardless of this flag; it governs
600
+ * only what appears absent an explicit column list.
601
+ *
602
+ * This is a PRESENTATION default, not an access control — a field can be
603
+ * read-denied and still default to `true` here (it simply renders empty
604
+ * for a viewer who can't read it), and setting this to `false` hides a
605
+ * column without restricting who can read the underlying value. The
606
+ * field-level `access.read` deny remains the only real boundary.
607
+ *
608
+ * @default true
609
+ *
610
+ * @example Hide an internal field from default table views without denying read access
611
+ * ```typescript
612
+ * fields: {
613
+ * internalScore: integer({ ui: { listView: { defaultColumn: false } } }),
614
+ * }
615
+ * ```
616
+ */
617
+ listView?: {
618
+ defaultColumn?: boolean
619
+ }
594
620
  /**
595
621
  * Transform field value before sending to client (browser)
596
622
  * Useful for sensitive fields (e.g., passwords) or complex data structures
@@ -844,6 +870,7 @@ export type IntegerField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfi
844
870
  min?: number
845
871
  max?: number
846
872
  }
873
+ isIndexed?: boolean | 'unique'
847
874
  }
848
875
 
849
876
  export type DecimalField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
@@ -885,6 +912,7 @@ export type CheckboxField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConf
885
912
  export type TimestampField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
886
913
  type: 'timestamp'
887
914
  defaultValue?: { kind: 'now' } | Date
915
+ isIndexed?: boolean | 'unique'
888
916
  }
889
917
 
890
918
  export type CalendarDayField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
@@ -989,6 +1017,7 @@ export type SelectField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig
989
1017
  validation?: {
990
1018
  isRequired?: boolean
991
1019
  }
1020
+ isIndexed?: boolean | 'unique'
992
1021
  ui?: {
993
1022
  displayMode?: 'select' | 'segmented-control' | 'radio'
994
1023
  }
@@ -1015,8 +1044,10 @@ export type RelationshipItemViewConfig = {
1015
1044
  * The related list's fields to show as Relationship-table columns, in order.
1016
1045
  *
1017
1046
  * When omitted, the columns default to the related list's own column
1018
- * curation (`ui.listView.initialColumns`, else all non-system fields) minus
1019
- * the back-reference field that points at the parent record.
1047
+ * curation (`ui.listView.initialColumns`, else every field whose own
1048
+ * `ui.listView.defaultColumn` declaration holds see {@link
1049
+ * BaseFieldConfig.ui}) minus the back-reference field that points at the
1050
+ * parent record.
1020
1051
  *
1021
1052
  * @example
1022
1053
  * ```typescript
@@ -1133,13 +1164,20 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
1133
1164
  */
1134
1165
  isNullable?: boolean
1135
1166
  /**
1136
- * Controls foreign key placement and column name for bidirectional relationships
1137
- * Can be a boolean or an object with a map property
1138
- * Only valid on single (non-many) relationships
1139
- * Cannot be true on both sides of a one-to-one relationship
1167
+ * Controls foreign key placement and column name.
1168
+ * Can be a boolean or an object with a map property.
1169
+ * Only valid on single (non-many) relationships.
1170
+ * Cannot be true on both sides of a one-to-one relationship.
1140
1171
  *
1141
- * When a boolean, defaults the foreign key column name to the field name
1142
- * When an object with map, uses the provided column name
1172
+ * The boolean form (the "which side owns the foreign key" sense) is only
1173
+ * meaningful on a bidirectional ref (`ref: 'ListName.fieldName'`) a
1174
+ * list-only ref (`ref: 'ListName'`) always owns the foreign key, so a
1175
+ * boolean here is rejected. The `{ map }` form (the column-name sense)
1176
+ * works on both: it renames the foreign key column without changing
1177
+ * ownership.
1178
+ *
1179
+ * When a boolean, defaults the foreign key column name to the field name.
1180
+ * When an object with map, uses the provided column name.
1143
1181
  *
1144
1182
  * @example
1145
1183
  * ```typescript
@@ -1164,6 +1202,14 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
1164
1202
  * user: relationship({ ref: 'User.account' }) // No foreign key on this side
1165
1203
  * }
1166
1204
  * })
1205
+ *
1206
+ * // List-only ref: rename the foreign key column (ownership is implicit)
1207
+ * Post: list({
1208
+ * fields: {
1209
+ * category: relationship({ ref: 'Category', db: { foreignKey: { map: 'category_id' } } })
1210
+ * // Generates: categoryId String? @map("category_id")
1211
+ * }
1212
+ * })
1167
1213
  * ```
1168
1214
  */
1169
1215
  foreignKey?: boolean | { map?: string }
@@ -1488,6 +1534,18 @@ export interface TypeInfo<
1488
1534
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1489
1535
  export type OperationAccess<T = any> = {
1490
1536
  query?: AccessControl<T>
1537
+ /**
1538
+ * Shares `AccessControl`'s signature (so a filter still type-checks here),
1539
+ * but at runtime `create` accepts a `boolean` result only. There is no
1540
+ * existing row to scope with a filter, and — unlike `update`/`delete`,
1541
+ * which re-check a returned filter against the target row via
1542
+ * `findFirst` — no equivalent re-check exists for a row that doesn't exist
1543
+ * in the database yet. A rule that returns a filter (or any other
1544
+ * non-boolean) throws `InvalidCreateAccessResultError` rather than being
1545
+ * treated as an allow (see #1009, ADR-0022, ADR-0030). To scope create by
1546
+ * ownership, evaluate the condition in a `resolveInput`/`validate` hook,
1547
+ * where the input data is in scope.
1548
+ */
1491
1549
  create?: AccessControl<T>
1492
1550
  update?: AccessControl<T>
1493
1551
  delete?: AccessControl<T>
@@ -1975,6 +2033,12 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
1975
2033
  * the bad field too — no entry is ever silently dropped or emitted as
1976
2034
  * invalid Prisma.
1977
2035
  *
2036
+ * `createdAt`/`updatedAt` are a valid entry even when the list has no
2037
+ * matching declared field — an entry may name either as long as the
2038
+ * list's auto-timestamps (`db.timestamps`, global or per-list) are
2039
+ * enabled for that column, since the auto-injected column has no `@map`
2040
+ * of its own and its Prisma field name is exactly `createdAt`/`updatedAt`.
2041
+ *
1978
2042
  * @example One audition per student per production (composite unique)
1979
2043
  * ```typescript
1980
2044
  * Audition: list({
@@ -2241,14 +2305,16 @@ export interface BulkAction {
2241
2305
  * `ui.listView`.
2242
2306
  *
2243
2307
  * When omitted, the admin UI falls back to its existing defaults: every
2244
- * non-system field is shown as a column and no default sort is applied.
2308
+ * field whose own `ui.listView.defaultColumn` declaration holds is shown as
2309
+ * a column (see {@link BaseFieldConfig.ui}) and no default sort is applied.
2245
2310
  */
2246
2311
  export type ListViewUIConfig = {
2247
2312
  /**
2248
2313
  * The fields to show as columns in the list table, in order.
2249
2314
  *
2250
2315
  * Drives both the column **selection** and their **order**. When omitted,
2251
- * all non-system fields are shown (current default behaviour).
2316
+ * every field whose own `ui.listView.defaultColumn` declaration holds is
2317
+ * shown (current default behaviour).
2252
2318
  *
2253
2319
  * @example
2254
2320
  * ```typescript
@@ -10,10 +10,13 @@ import {
10
10
  foldDeclaredDependencies,
11
11
  validateQueryKeys,
12
12
  validateQueryFieldReadAccess,
13
+ resolveToOneAccessVisibility,
14
+ emptyToOneAccessFilterTree,
13
15
  } from '../access/index.js'
14
- import type { DeclaredOnlyTree } from '../access/index.js'
16
+ import type { DeclaredOnlyTree, ToOneAccessFilterTree } from '../access/index.js'
15
17
  import { ValidationError, DatabaseError } from '../hooks/index.js'
16
18
  import { getDbKey } from '../lib/case-utils.js'
19
+ import { uniqueConstraintOf } from '../lib/prisma-errors.js'
17
20
  import type { PrismaClientLike } from '../access/types.js'
18
21
  import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js'
19
22
  import type { FieldSelection, FieldSelectionScope } from '../query/index.js'
@@ -202,6 +205,14 @@ function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
202
205
  return data
203
206
  }
204
207
 
208
+ // A camelCase field name (e.g. a relationship's `tenantId` foreign key) needs
209
+ // its word boundary split before title-casing, or it reads as one run-together
210
+ // word ("Tenantid") in a user-facing unique-constraint message.
211
+ function humanizeFieldName(fieldName: string): string {
212
+ const spaced = fieldName.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
213
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1)
214
+ }
215
+
205
216
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
206
217
  function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
207
218
  if (
@@ -215,22 +226,22 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
215
226
 
216
227
  // P2002 is Prisma's unique constraint violation code.
217
228
  if (prismaError.code === 'P2002') {
218
- const target = prismaError.meta?.target
229
+ const target = uniqueConstraintOf(prismaError)?.fields
219
230
  const fieldErrors: Record<string, string> = {}
220
231
 
221
- if (target && Array.isArray(target)) {
232
+ if (target && target.length > 0) {
222
233
  for (const fieldName of target) {
223
234
  const fieldConfig = listConfig.fields[fieldName]
224
- const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
225
235
 
226
236
  if (fieldConfig) {
227
- fieldErrors[fieldName] = `This ${label.toLowerCase()} is already in use`
237
+ fieldErrors[fieldName] =
238
+ `This ${humanizeFieldName(fieldName).toLowerCase()} is already in use`
228
239
  } else {
229
240
  fieldErrors[fieldName] = `This value is already in use`
230
241
  }
231
242
  }
232
243
 
233
- const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
244
+ const fieldLabels = target.map(humanizeFieldName).join(', ')
234
245
  return new DatabaseError(
235
246
  `${fieldLabels} must be unique. The value you entered is already in use.`,
236
247
  fieldErrors,
@@ -293,7 +304,8 @@ interface TransactionCapable<TPrisma> {
293
304
  *
294
305
  * Exposes the secured `db` delegate plus the session, raw `prisma`, storage,
295
306
  * plugin services, the generic `serverAction` handler, `sudo()` (bypasses access
296
- * control but still runs hooks), and `transaction()` (interactive, hook-firing
307
+ * control but still runs hooks), `withSession()` (substitutes the session but
308
+ * still runs access control), and `transaction()` (interactive, hook-firing
297
309
  * transaction). All access-checked operations run their list/field hooks.
298
310
  */
299
311
  export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
@@ -329,6 +341,23 @@ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLik
329
341
  options?: TransactionOptions,
330
342
  ) => Promise<T>
331
343
  sudo: () => StackContext<TPrisma>
344
+ /**
345
+ * Derive a context identical to this one except for its session, reusing
346
+ * the already-resolved config and this context's own client (including a
347
+ * transaction client — a call inside `context.transaction()` stays in that
348
+ * transaction) and storage.
349
+ *
350
+ * **This is not an authorisation.** It substitutes who hooks and access
351
+ * control see; it does not change what they decide. The derived context
352
+ * can do exactly what any context built with `session` directly could
353
+ * do — access rules still evaluate against the new session. The caller is
354
+ * responsible for deciding who may invoke this.
355
+ *
356
+ * Orthogonal to `sudo()`: `withSession(s)` preserves the receiver's sudo
357
+ * state (elevated stays elevated), so `context.withSession(s).sudo()` and
358
+ * `context.sudo().withSession(s)` are equivalent.
359
+ */
360
+ withSession: (session: Session | null) => StackContext<TPrisma>
332
361
  _isSudo: boolean
333
362
  }
334
363
 
@@ -765,6 +794,22 @@ export function getContext<
765
794
  )
766
795
  }
767
796
 
797
+ // Substitutes the session; access control and hooks still run against it
798
+ // (orthogonal to `sudo`, so the receiver's sudo state is preserved).
799
+ function withSession(newSession: Session | null): StackContext<TPrisma> {
800
+ return getContext(
801
+ config,
802
+ prisma,
803
+ newSession,
804
+ context.storage,
805
+ _isSudo,
806
+ undefined,
807
+ // ADR-0028: a write issued from inside an owned transaction (e.g.
808
+ // `tx.withSession(s).db.x.create()`) must still defer to that owner.
809
+ context._transactionOwner,
810
+ )
811
+ }
812
+
768
813
  // Interactive, hook-firing transaction (#614). See the `transaction` doc on
769
814
  // `StackContext` above for the atomicity/isolation/retry contract.
770
815
  //
@@ -831,6 +876,7 @@ export function getContext<
831
876
  plugins: context.plugins,
832
877
  serverAction,
833
878
  sudo,
879
+ withSession,
834
880
  transaction,
835
881
  _isSudo,
836
882
  }
@@ -924,6 +970,12 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
924
970
  * `undefined` for every non-fragment path: a caller `include` (sudo or not)
925
971
  * and a bare read both mean "compute every field," matching what they
926
972
  * already fetch.
973
+ *
974
+ * Also returns `toOneAccessFilters` — the to-one relations `buildAccessScopedInclude`
975
+ * flagged as needing a post-query existence check rather than a Prisma-side
976
+ * `where` (issue #974). Only the non-sudo caller-include path can produce a
977
+ * non-empty tree: it's the only path that evaluates a related list's `query`
978
+ * access at all. The fragment and sudo paths always return an empty tree.
927
979
  */
928
980
  async function resolveReadInclude(
929
981
  callerInclude: Record<string, unknown> | undefined,
@@ -937,6 +989,7 @@ async function resolveReadInclude(
937
989
  include: Record<string, unknown> | undefined
938
990
  declaredOnly: DeclaredOnlyTree
939
991
  selection: FieldSelectionScope | undefined
992
+ toOneAccessFilters: ToOneAccessFilterTree
940
993
  }> {
941
994
  if (fragmentFields !== undefined) {
942
995
  const fragmentInclude = buildInclude(fragmentFields) ?? undefined
@@ -949,27 +1002,27 @@ async function resolveReadInclude(
949
1002
  [listName],
950
1003
  selection,
951
1004
  )
952
- return { ...folded, selection }
1005
+ return { ...folded, selection, toOneAccessFilters: emptyToOneAccessFilterTree() }
953
1006
  }
954
1007
 
955
1008
  if (context._isSudo) {
956
1009
  const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
957
- return { ...folded, selection: undefined }
1010
+ return { ...folded, selection: undefined, toOneAccessFilters: emptyToOneAccessFilterTree() }
958
1011
  }
959
1012
 
960
1013
  const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
961
1014
  if (!folded.include) {
962
- return { ...folded, selection: undefined }
1015
+ return { ...folded, selection: undefined, toOneAccessFilters: emptyToOneAccessFilterTree() }
963
1016
  }
964
1017
 
965
- const include = await buildAccessScopedInclude(
1018
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
966
1019
  folded.include,
967
1020
  listConfig.fields,
968
1021
  { session: context.session, context },
969
1022
  config,
970
1023
  listName,
971
1024
  )
972
- return { include, declaredOnly: folded.declaredOnly, selection: undefined }
1025
+ return { include, declaredOnly: folded.declaredOnly, selection: undefined, toOneAccessFilters }
973
1026
  }
974
1027
 
975
1028
  function createFindUnique<TPrisma extends PrismaClientLike>(
@@ -1026,7 +1079,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1026
1079
  // Resolve `include`, folding any declared dependencies (`needs`,
1027
1080
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1028
1081
  // already produces — see `resolveReadInclude`'s doc comment.
1029
- let { include, declaredOnly, selection } = await resolveReadInclude(
1082
+ let { include, declaredOnly, selection, toOneAccessFilters } = await resolveReadInclude(
1030
1083
  args.include,
1031
1084
  fragment ? fragment._fields : undefined,
1032
1085
  listName,
@@ -1056,6 +1109,14 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1056
1109
  return null
1057
1110
  }
1058
1111
 
1112
+ // Resolve which of the to-one relations flagged by `toOneAccessFilters`
1113
+ // actually survive their related list's `query` access (issue #974) —
1114
+ // one batched existence check per relation, before field visibility runs.
1115
+ const toOneVisibility = await resolveToOneAccessVisibility([item], toOneAccessFilters, {
1116
+ session: context.session,
1117
+ context,
1118
+ })
1119
+
1059
1120
  // Pass sudo flag through context to skip field-level access checks
1060
1121
  const filtered = await filterReadableFields(
1061
1122
  item,
@@ -1069,6 +1130,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1069
1130
  listName,
1070
1131
  declaredOnly,
1071
1132
  selection,
1133
+ toOneVisibility,
1072
1134
  )
1073
1135
 
1074
1136
  if (fragment) {
@@ -1178,7 +1240,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1178
1240
  // Resolve `include`, folding any declared dependencies (`needs`,
1179
1241
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1180
1242
  // already produces — see `resolveReadInclude`'s doc comment.
1181
- let { include, declaredOnly, selection } = await resolveReadInclude(
1243
+ let { include, declaredOnly, selection, toOneAccessFilters } = await resolveReadInclude(
1182
1244
  args?.include,
1183
1245
  fragment ? fragment._fields : undefined,
1184
1246
  listName,
@@ -1202,6 +1264,15 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1202
1264
  include,
1203
1265
  })
1204
1266
 
1267
+ // Resolve which of the to-one relations flagged by `toOneAccessFilters`
1268
+ // actually survive their related list's `query` access (issue #974) —
1269
+ // ONE batched existence check per relation across every row in `items`,
1270
+ // before field visibility runs on any of them.
1271
+ const toOneVisibility = await resolveToOneAccessVisibility(items, toOneAccessFilters, {
1272
+ session: context.session,
1273
+ context,
1274
+ })
1275
+
1205
1276
  // Pass sudo flag through context to skip field-level access checks
1206
1277
  const filtered = await Promise.all(
1207
1278
  items.map((item: Record<string, unknown>) =>
@@ -1217,6 +1288,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1217
1288
  listName,
1218
1289
  declaredOnly,
1219
1290
  selection,
1291
+ toOneVisibility,
1220
1292
  ),
1221
1293
  ),
1222
1294
  )
@@ -1501,7 +1573,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1501
1573
  // Resolve `include`, folding any declared dependencies (`needs`,
1502
1574
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1503
1575
  // already produces — see `resolveReadInclude`'s doc comment.
1504
- let { include, declaredOnly, selection } = await resolveReadInclude(
1576
+ let { include, declaredOnly, selection, toOneAccessFilters } = await resolveReadInclude(
1505
1577
  args?.include,
1506
1578
  fragment ? fragment._fields : undefined,
1507
1579
  listName,
@@ -1519,6 +1591,13 @@ function createGet<TPrisma extends PrismaClientLike>(
1519
1591
  })
1520
1592
 
1521
1593
  if (item) {
1594
+ // Resolve which of the to-one relations flagged by `toOneAccessFilters`
1595
+ // actually survive their related list's `query` access (issue #974).
1596
+ const toOneVisibility = await resolveToOneAccessVisibility([item], toOneAccessFilters, {
1597
+ session: context.session,
1598
+ context,
1599
+ })
1600
+
1522
1601
  const filtered = await filterReadableFields(
1523
1602
  item,
1524
1603
  listConfig.fields,
@@ -1531,6 +1610,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1531
1610
  listName,
1532
1611
  declaredOnly,
1533
1612
  selection,
1613
+ toOneVisibility,
1534
1614
  )
1535
1615
  if (fragment) {
1536
1616
  return pickFields(filtered, fragment._fields)