@opensaas/stack-core 0.39.2 → 0.41.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 (165) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +189 -0
  3. package/CLAUDE.md +103 -0
  4. package/dist/access/access-filter.d.ts +157 -14
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +470 -42
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +982 -17
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +54 -1
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +84 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/engine.test.d.ts +2 -0
  19. package/dist/access/engine.test.d.ts.map +1 -0
  20. package/dist/access/engine.test.js +56 -0
  21. package/dist/access/engine.test.js.map +1 -0
  22. package/dist/access/errors.d.ts +62 -0
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +85 -2
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-access.d.ts +12 -0
  27. package/dist/access/field-access.d.ts.map +1 -1
  28. package/dist/access/field-access.js +22 -2
  29. package/dist/access/field-access.js.map +1 -1
  30. package/dist/access/field-access.test.js +66 -0
  31. package/dist/access/field-access.test.js.map +1 -1
  32. package/dist/access/field-visibility.d.ts +2 -1
  33. package/dist/access/field-visibility.d.ts.map +1 -1
  34. package/dist/access/field-visibility.js +107 -15
  35. package/dist/access/field-visibility.js.map +1 -1
  36. package/dist/access/index.d.ts +9 -2
  37. package/dist/access/index.d.ts.map +1 -1
  38. package/dist/access/index.js +11 -2
  39. package/dist/access/index.js.map +1 -1
  40. package/dist/access/multi-column-read-write.test.js.map +1 -1
  41. package/dist/access/query-validation.d.ts +31 -0
  42. package/dist/access/query-validation.d.ts.map +1 -1
  43. package/dist/access/query-validation.js +75 -16
  44. package/dist/access/query-validation.js.map +1 -1
  45. package/dist/access/relationship-count.d.ts +41 -3
  46. package/dist/access/relationship-count.d.ts.map +1 -1
  47. package/dist/access/relationship-count.js +44 -7
  48. package/dist/access/relationship-count.js.map +1 -1
  49. package/dist/access/relationship-count.test.js +62 -0
  50. package/dist/access/relationship-count.test.js.map +1 -1
  51. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  52. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  53. package/dist/access/synthetic-include-read.test.js +121 -0
  54. package/dist/access/synthetic-include-read.test.js.map +1 -0
  55. package/dist/config/types.d.ts +98 -32
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/context/hook-pipeline.d.ts +2 -2
  58. package/dist/context/hook-pipeline.d.ts.map +1 -1
  59. package/dist/context/hook-pipeline.js.map +1 -1
  60. package/dist/context/index.d.ts +35 -2
  61. package/dist/context/index.d.ts.map +1 -1
  62. package/dist/context/index.js +121 -27
  63. package/dist/context/index.js.map +1 -1
  64. package/dist/context/nested-operations.d.ts +2 -2
  65. package/dist/context/nested-operations.d.ts.map +1 -1
  66. package/dist/context/nested-operations.js +74 -15
  67. package/dist/context/nested-operations.js.map +1 -1
  68. package/dist/context/write-pipeline.d.ts +3 -2
  69. package/dist/context/write-pipeline.d.ts.map +1 -1
  70. package/dist/context/write-pipeline.js +31 -34
  71. package/dist/context/write-pipeline.js.map +1 -1
  72. package/dist/fields/index.d.ts +13 -1
  73. package/dist/fields/index.d.ts.map +1 -1
  74. package/dist/fields/index.js +50 -5
  75. package/dist/fields/index.js.map +1 -1
  76. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  77. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  78. package/dist/fields/relationship-foreign-key.test.js +18 -0
  79. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  80. package/dist/hooks/index.d.ts +16 -15
  81. package/dist/hooks/index.d.ts.map +1 -1
  82. package/dist/hooks/index.js.map +1 -1
  83. package/dist/index.d.ts +4 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +14 -1
  86. package/dist/index.js.map +1 -1
  87. package/dist/lib/case-utils.d.ts +15 -0
  88. package/dist/lib/case-utils.d.ts.map +1 -1
  89. package/dist/lib/case-utils.js +21 -0
  90. package/dist/lib/case-utils.js.map +1 -1
  91. package/dist/lib/case-utils.test.js +23 -1
  92. package/dist/lib/case-utils.test.js.map +1 -1
  93. package/dist/lib/prisma-errors.d.ts +38 -0
  94. package/dist/lib/prisma-errors.d.ts.map +1 -0
  95. package/dist/lib/prisma-errors.js +53 -0
  96. package/dist/lib/prisma-errors.js.map +1 -0
  97. package/dist/mcp/constants.d.ts +23 -0
  98. package/dist/mcp/constants.d.ts.map +1 -0
  99. package/dist/mcp/constants.js +23 -0
  100. package/dist/mcp/constants.js.map +1 -0
  101. package/dist/mcp/field-schema.d.ts +8 -0
  102. package/dist/mcp/field-schema.d.ts.map +1 -0
  103. package/dist/mcp/field-schema.js +69 -0
  104. package/dist/mcp/field-schema.js.map +1 -0
  105. package/dist/mcp/handler.d.ts.map +1 -1
  106. package/dist/mcp/handler.js +76 -90
  107. package/dist/mcp/handler.js.map +1 -1
  108. package/dist/mcp/projection.d.ts +62 -0
  109. package/dist/mcp/projection.d.ts.map +1 -0
  110. package/dist/mcp/projection.js +390 -0
  111. package/dist/mcp/projection.js.map +1 -0
  112. package/dist/utils/password.d.ts +3 -1
  113. package/dist/utils/password.d.ts.map +1 -1
  114. package/dist/utils/password.js +4 -2
  115. package/dist/utils/password.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/access/access-filter.test.ts +1528 -18
  118. package/src/access/access-filter.ts +708 -42
  119. package/src/access/denied-relation-visibility.test.ts +173 -0
  120. package/src/access/engine.test.ts +67 -0
  121. package/src/access/engine.ts +109 -1
  122. package/src/access/errors.ts +97 -2
  123. package/src/access/field-access.test.ts +78 -0
  124. package/src/access/field-access.ts +34 -2
  125. package/src/access/field-visibility.ts +171 -25
  126. package/src/access/index.ts +25 -0
  127. package/src/access/multi-column-read-write.test.ts +3 -2
  128. package/src/access/query-validation.ts +136 -9
  129. package/src/access/relationship-count.test.ts +75 -0
  130. package/src/access/relationship-count.ts +53 -10
  131. package/src/access/synthetic-include-read.test.ts +179 -0
  132. package/src/config/types.ts +98 -32
  133. package/src/context/hook-pipeline.ts +4 -2
  134. package/src/context/index.ts +203 -42
  135. package/src/context/nested-operations.ts +96 -27
  136. package/src/context/write-pipeline.ts +60 -36
  137. package/src/fields/index.ts +55 -5
  138. package/src/fields/relationship-foreign-key.test.ts +29 -0
  139. package/src/hooks/index.ts +22 -15
  140. package/src/index.ts +17 -1
  141. package/src/lib/case-utils.test.ts +32 -0
  142. package/src/lib/case-utils.ts +26 -0
  143. package/src/lib/prisma-errors.ts +86 -0
  144. package/src/mcp/constants.ts +24 -0
  145. package/src/mcp/field-schema.ts +84 -0
  146. package/src/mcp/handler.ts +109 -107
  147. package/src/mcp/projection.ts +497 -0
  148. package/src/utils/password.ts +5 -3
  149. package/tests/access-relationships.test.ts +3 -3
  150. package/tests/access.test.ts +83 -0
  151. package/tests/context.test.ts +452 -17
  152. package/tests/field-types.test.ts +113 -0
  153. package/tests/hook-context-secured.test.ts +424 -0
  154. package/tests/mcp-fields-projection-access.test.ts +250 -0
  155. package/tests/mcp-handler.test.ts +695 -4
  156. package/tests/nested-access-and-hooks.test.ts +389 -0
  157. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  158. package/tests/password-types.test.ts +28 -0
  159. package/tests/password.test.ts +16 -2
  160. package/tests/prisma-errors.test.ts +141 -0
  161. package/tests/singleton.test.ts +9 -1
  162. package/tests/to-one-include-access-filter.test.ts +172 -0
  163. package/tests/with-session.test.ts +243 -0
  164. package/tests/write-pipeline.test.ts +29 -0
  165. package/tsconfig.tsbuildinfo +1 -1
@@ -1,8 +1,9 @@
1
- import type { Session, AccessContext, PrismaFilter } from './types.js'
1
+ import type { Session, AccessContext, PrismaFilter, FieldAccess } from './types.js'
2
2
  import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
3
3
  import type { FilterOperator, RelationshipCountFilterMarker } from '../filter/types.js'
4
4
  import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
5
5
  import { checkAccess, getRelatedListConfig } from './engine.js'
6
+ import { isFieldReadableForPredicate } from './field-access.js'
6
7
  import { getDbKey } from '../lib/case-utils.js'
7
8
 
8
9
  /**
@@ -14,8 +15,10 @@ import { getDbKey } from '../lib/case-utils.js'
14
15
  * `_count` (`_count: { select: { orders: { where: <access filter> } } }`), so no
15
16
  * per-row query is issued and the count can never include rows the session
16
17
  * cannot read. This module is the single place the related list's
17
- * operation-level `query` access is folded into that `_count`, mirroring how
18
- * `buildAccessScopedInclude` folds it into relation includes.
18
+ * operation-level `query` access, AND the counting list's own field-level
19
+ * `read` access on the relationship field being counted (issue #1111), are
20
+ * folded into that `_count`, mirroring how `buildAccessScopedInclude` folds
21
+ * both into relation includes.
19
22
  *
20
23
  * It also resolves the count Filter spec's markers: Prisma cannot compare a
21
24
  * relation count in a `where`, so a to-many relationship's Filter spec emits a
@@ -46,11 +49,56 @@ export function isToManyRelationshipField(field: FieldConfig | undefined): boole
46
49
  }
47
50
 
48
51
  /** The per-relation entry the count `_count.select` uses for one relationship. */
49
- type CountAccessEntry =
52
+ export type CountAccessEntry =
50
53
  | { kind: 'all' } // related list fully readable → count every related row
51
54
  | { kind: 'scoped'; where: PrismaFilter } // count only rows matching the access filter
52
55
  | { kind: 'denied' } // related list not readable at all → count is always 0
53
56
 
57
+ /**
58
+ * Resolve one related list's operation-level `query` access, and — when the
59
+ * counting list has a field of its own for the relationship being counted —
60
+ * that field's own field-level `read` access, into the entry its `_count`
61
+ * select needs. The shared decision both `relationshipCountAccessEntry` below
62
+ * (field → related list, for this module's own admin-list-view and
63
+ * count-filter callers) and `access-filter.ts`'s caller-`_count` scoping
64
+ * (issue #1087, which already has the related list resolved — including a
65
+ * synthetic back-relation's, which has no field of its own on the counting
66
+ * list) build on.
67
+ *
68
+ * `fieldAccess` is the COUNTING list's own relationship field access — not
69
+ * the related list's — mirroring the check `filterReadableFields`
70
+ * (`field-visibility.ts`) runs before returning an ordinary `include` of that
71
+ * same field (issue #1111: a `_count` used to leak the true count of a
72
+ * relationship whose field-level `read` access denied the relationship
73
+ * itself, even when the related list's own rows were otherwise fully
74
+ * readable). Evaluated via `isFieldReadableForPredicate` — the pre-query
75
+ * evaluator #915 already built for the identical problem on `where`/`orderBy`
76
+ * — since there is no fetched row yet at `_count`-select-build time; a rule
77
+ * that depends on one denies here, the same as it would there. Checked BEFORE
78
+ * the related list's `query` access so a field-level denial short-circuits
79
+ * without needing the related list's access rule at all. Omitted entirely
80
+ * (`undefined`) for a synthetic back-relation, which has no field of its own
81
+ * on the counting list to evaluate.
82
+ */
83
+ export async function resolveCountAccessEntryForList(
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
85
+ relatedListConfig: ListConfig<any>,
86
+ args: CountArgs,
87
+ fieldAccess?: FieldAccess,
88
+ ): Promise<CountAccessEntry> {
89
+ if (fieldAccess) {
90
+ const canReadField = await isFieldReadableForPredicate(fieldAccess, args)
91
+ if (!canReadField) return { kind: 'denied' }
92
+ }
93
+
94
+ const queryAccess = relatedListConfig.access?.operation?.query
95
+ const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
96
+
97
+ if (result === false) return { kind: 'denied' }
98
+ if (typeof result === 'object') return { kind: 'scoped', where: result }
99
+ return { kind: 'all' }
100
+ }
101
+
54
102
  /**
55
103
  * Resolve the related list's operation-level `query` access for one to-many
56
104
  * relationship into the entry its `_count` select needs. Returns `null` when the
@@ -69,12 +117,7 @@ async function relationshipCountAccessEntry(
69
117
  const related = getRelatedListConfig(ref, config)
70
118
  if (!related) return null
71
119
 
72
- const queryAccess = related.listConfig.access?.operation?.query
73
- const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
74
-
75
- if (result === false) return { kind: 'denied' }
76
- if (typeof result === 'object') return { kind: 'scoped', where: result }
77
- return { kind: 'all' }
120
+ return resolveCountAccessEntryForList(related.listConfig, args, field.access)
78
121
  }
79
122
 
80
123
  /**
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterReadableFields } from './field-visibility.js'
3
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
4
+ import type { AccessContext } from './types.js'
5
+
6
+ /**
7
+ * Regression coverage for issue #1082, Field Visibility half.
8
+ *
9
+ * `access-filter.test.ts` covers the pre-query scoping of a synthetic
10
+ * back-relation (`buildAccessScopedInclude`); these tests cover what happens
11
+ * to the rows it fetches. Before this fix, `filterReadableFields` recognised
12
+ * only a DECLARED relationship field (`fieldConfig?.type === 'relationship'`)
13
+ * as something to recurse into — a synthetic key has no declared field on the
14
+ * list it's fetched through, so it fell to the generic non-relationship
15
+ * branch and was copied into the result verbatim: no field-level `read` gate
16
+ * on the related rows, and no virtual field computed on them.
17
+ */
18
+
19
+ // A relationship field pointing at another list.
20
+ function rel(ref: string, many = false): FieldConfig {
21
+ return { type: 'relationship', ref, many } as unknown as FieldConfig
22
+ }
23
+
24
+ // A virtual field computed via resolveOutput.
25
+ function virtualField(
26
+ resolveOutput: (args: { item: Record<string, unknown> }) => unknown,
27
+ ): FieldConfig {
28
+ return {
29
+ type: 'virtual',
30
+ virtual: true,
31
+ hooks: { resolveOutput },
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
33
+ } as any as FieldConfig
34
+ }
35
+
36
+ function makeContext(): AccessContext {
37
+ return {
38
+ session: null,
39
+ _isSudo: false,
40
+ _resolveOutputChain: [],
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
42
+ } as any
43
+ }
44
+
45
+ // Term ← Bill.term (list-only ref, no field on Term) — schema generation
46
+ // synthesizes `from_Bill_term` on Term, which no list config declares.
47
+ function syntheticConfig(): OpenSaasConfig {
48
+ return {
49
+ db: { provider: 'sqlite' },
50
+ lists: {
51
+ Term: {
52
+ fields: { name: { type: 'text' } as FieldConfig },
53
+ access: { operation: { query: () => true } },
54
+ },
55
+ Bill: {
56
+ fields: {
57
+ amount: { type: 'integer' } as FieldConfig,
58
+ term: rel('Term'),
59
+ internalNotes: {
60
+ type: 'text',
61
+ access: { read: () => false },
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
63
+ } as any as FieldConfig,
64
+ label: virtualField(({ item }) => `Bill #${item.amount}`),
65
+ },
66
+ access: { operation: { query: () => true } },
67
+ },
68
+ },
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
70
+ } as any as OpenSaasConfig
71
+ }
72
+
73
+ describe('filterReadableFields — synthetic back-relation (#1082)', () => {
74
+ it('applies the owning list field-level read access to rows fetched through a synthetic key', async () => {
75
+ const config = syntheticConfig()
76
+ const termRow = {
77
+ id: 't1',
78
+ name: 'Term 1',
79
+ from_Bill_term: [{ id: 'b1', amount: 5, internalNotes: 'secret' }],
80
+ }
81
+
82
+ const result = await filterReadableFields(
83
+ termRow,
84
+ config.lists.Term.fields,
85
+ { session: null, context: makeContext() },
86
+ config,
87
+ 0,
88
+ 'Term',
89
+ )
90
+
91
+ const bills = result.from_Bill_term as Record<string, unknown>[]
92
+ expect(bills).toHaveLength(1)
93
+ expect(bills[0].amount).toBe(5)
94
+ expect('internalNotes' in bills[0]).toBe(false)
95
+ })
96
+
97
+ it('computes a virtual field on rows fetched through a synthetic key', async () => {
98
+ const config = syntheticConfig()
99
+ const termRow = {
100
+ id: 't1',
101
+ name: 'Term 1',
102
+ from_Bill_term: [{ id: 'b1', amount: 5 }],
103
+ }
104
+
105
+ const result = await filterReadableFields(
106
+ termRow,
107
+ config.lists.Term.fields,
108
+ { session: null, context: makeContext() },
109
+ config,
110
+ 0,
111
+ 'Term',
112
+ )
113
+
114
+ const bills = result.from_Bill_term as Record<string, unknown>[]
115
+ expect(bills[0].label).toBe('Bill #5')
116
+ })
117
+
118
+ it('recurses through a to-one value fetched through a synthetic key the same as a to-many', async () => {
119
+ const config = syntheticConfig()
120
+ const termRow = {
121
+ id: 't1',
122
+ name: 'Term 1',
123
+ from_Bill_term: { id: 'b1', amount: 5, internalNotes: 'secret' },
124
+ }
125
+
126
+ const result = await filterReadableFields(
127
+ termRow,
128
+ config.lists.Term.fields,
129
+ { session: null, context: makeContext() },
130
+ config,
131
+ 0,
132
+ 'Term',
133
+ )
134
+
135
+ const bill = result.from_Bill_term as Record<string, unknown>
136
+ expect(bill.amount).toBe(5)
137
+ expect(bill.label).toBe('Bill #5')
138
+ expect('internalNotes' in bill).toBe(false)
139
+ })
140
+
141
+ it('leaves a declared, non-relationship field on the row untouched by the synthetic path', async () => {
142
+ // `name` is a declared scalar field on Term itself — the synthetic
143
+ // fallback only ever applies to a key ABSENT from `fieldConfigs`, so it
144
+ // must never interfere with the list's own ordinary fields.
145
+ const config = syntheticConfig()
146
+ const termRow = { id: 't1', name: 'Term 1' }
147
+
148
+ const result = await filterReadableFields(
149
+ termRow,
150
+ config.lists.Term.fields,
151
+ { session: null, context: makeContext() },
152
+ config,
153
+ 0,
154
+ 'Term',
155
+ )
156
+
157
+ expect(result).toEqual({ id: 't1', name: 'Term 1' })
158
+ })
159
+
160
+ it('leaves an undeclared, non-synthetic key untouched (no config supplied)', async () => {
161
+ // Mirrors the pre-#1082 narrow-unit-test contract: without `config`
162
+ // there is nothing to resolve a synthetic key against, so the field
163
+ // passes through as a plain value — the same fallback
164
+ // `filterWritableFields` documents for its own `config`-less callers.
165
+ const config = syntheticConfig()
166
+ const termRow = { id: 't1', name: 'Term 1', from_Bill_term: [{ id: 'b1', amount: 5 }] }
167
+
168
+ const result = await filterReadableFields(
169
+ termRow,
170
+ config.lists.Term.fields,
171
+ { session: null, context: makeContext() },
172
+ undefined,
173
+ 0,
174
+ 'Term',
175
+ )
176
+
177
+ expect(result.from_Bill_term).toEqual([{ id: 'b1', amount: 5 }])
178
+ })
179
+ })
@@ -21,7 +21,7 @@ export type FieldResolveInputHookArgs<
21
21
  inputData: TTypeInfo['inputs']['create']
22
22
  item: undefined
23
23
  resolvedData: TTypeInfo['inputs']['create']
24
- context: import('../access/types.js').AccessContext
24
+ context: import('../context/index.js').StackContext
25
25
  }
26
26
  | {
27
27
  listKey: string
@@ -30,7 +30,7 @@ export type FieldResolveInputHookArgs<
30
30
  inputData: TTypeInfo['inputs']['update']
31
31
  item: TTypeInfo['item']
32
32
  resolvedData: TTypeInfo['inputs']['update']
33
- context: import('../access/types.js').AccessContext
33
+ context: import('../context/index.js').StackContext
34
34
  }
35
35
 
36
36
  /** Arguments for {@link FieldHooks.validate} (and its deprecated `validateInput` alias). */
@@ -45,7 +45,7 @@ export type FieldValidateHookArgs<
45
45
  inputData: TTypeInfo['inputs']['create']
46
46
  item: undefined
47
47
  resolvedData: TTypeInfo['inputs']['create']
48
- context: import('../access/types.js').AccessContext
48
+ context: import('../context/index.js').StackContext
49
49
  addValidationError: (msg: string) => void
50
50
  }
51
51
  | {
@@ -55,7 +55,7 @@ export type FieldValidateHookArgs<
55
55
  inputData: TTypeInfo['inputs']['update']
56
56
  item: TTypeInfo['item']
57
57
  resolvedData: TTypeInfo['inputs']['update']
58
- context: import('../access/types.js').AccessContext
58
+ context: import('../context/index.js').StackContext
59
59
  addValidationError: (msg: string) => void
60
60
  }
61
61
  | {
@@ -63,7 +63,7 @@ export type FieldValidateHookArgs<
63
63
  fieldKey: TFieldKey
64
64
  operation: 'delete'
65
65
  item: TTypeInfo['item']
66
- context: import('../access/types.js').AccessContext
66
+ context: import('../context/index.js').StackContext
67
67
  addValidationError: (msg: string) => void
68
68
  }
69
69
 
@@ -78,7 +78,7 @@ export type FieldBeforeOperationHookArgs<
78
78
  operation: 'create'
79
79
  inputData: TTypeInfo['inputs']['create']
80
80
  resolvedData: TTypeInfo['inputs']['create']
81
- context: import('../access/types.js').AccessContext
81
+ context: import('../context/index.js').StackContext
82
82
  }
83
83
  | {
84
84
  listKey: string
@@ -87,14 +87,14 @@ export type FieldBeforeOperationHookArgs<
87
87
  inputData: TTypeInfo['inputs']['update']
88
88
  item: TTypeInfo['item']
89
89
  resolvedData: TTypeInfo['inputs']['update']
90
- context: import('../access/types.js').AccessContext
90
+ context: import('../context/index.js').StackContext
91
91
  }
92
92
  | {
93
93
  listKey: string
94
94
  fieldKey: TFieldKey
95
95
  operation: 'delete'
96
96
  item: TTypeInfo['item']
97
- context: import('../access/types.js').AccessContext
97
+ context: import('../context/index.js').StackContext
98
98
  }
99
99
 
100
100
  /** Arguments for {@link FieldHooks.afterOperation}. */
@@ -109,7 +109,7 @@ export type FieldAfterOperationHookArgs<
109
109
  inputData: TTypeInfo['inputs']['create']
110
110
  item: TTypeInfo['item']
111
111
  resolvedData: TTypeInfo['inputs']['create']
112
- context: import('../access/types.js').AccessContext
112
+ context: import('../context/index.js').StackContext
113
113
  }
114
114
  | {
115
115
  listKey: string
@@ -119,14 +119,14 @@ export type FieldAfterOperationHookArgs<
119
119
  originalItem: TTypeInfo['item']
120
120
  item: TTypeInfo['item']
121
121
  resolvedData: TTypeInfo['inputs']['update']
122
- context: import('../access/types.js').AccessContext
122
+ context: import('../context/index.js').StackContext
123
123
  }
124
124
  | {
125
125
  listKey: string
126
126
  fieldKey: TFieldKey
127
127
  operation: 'delete'
128
128
  originalItem: TTypeInfo['item']
129
- context: import('../access/types.js').AccessContext
129
+ context: import('../context/index.js').StackContext
130
130
  }
131
131
 
132
132
  /**
@@ -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>
@@ -1551,7 +1609,7 @@ export type ResolveInputHookArgs<
1551
1609
  inputData: TCreateInput
1552
1610
  resolvedData: TCreateInput
1553
1611
  item: undefined
1554
- context: import('../access/types.js').AccessContext
1612
+ context: import('../context/index.js').StackContext
1555
1613
  }
1556
1614
  | {
1557
1615
  listKey: string
@@ -1559,7 +1617,7 @@ export type ResolveInputHookArgs<
1559
1617
  inputData: TUpdateInput
1560
1618
  resolvedData: TUpdateInput
1561
1619
  item: TOutput
1562
- context: import('../access/types.js').AccessContext
1620
+ context: import('../context/index.js').StackContext
1563
1621
  }
1564
1622
 
1565
1623
  /**
@@ -1579,7 +1637,7 @@ export type ValidateHookArgs<
1579
1637
  inputData: TCreateInput
1580
1638
  resolvedData: TCreateInput
1581
1639
  item: undefined
1582
- context: import('../access/types.js').AccessContext
1640
+ context: import('../context/index.js').StackContext
1583
1641
  addValidationError: (msg: string) => void
1584
1642
  }
1585
1643
  | {
@@ -1588,14 +1646,14 @@ export type ValidateHookArgs<
1588
1646
  inputData: TUpdateInput
1589
1647
  resolvedData: TUpdateInput
1590
1648
  item: TOutput
1591
- context: import('../access/types.js').AccessContext
1649
+ context: import('../context/index.js').StackContext
1592
1650
  addValidationError: (msg: string) => void
1593
1651
  }
1594
1652
  | {
1595
1653
  listKey: string
1596
1654
  operation: 'delete'
1597
1655
  item: TOutput
1598
- context: import('../access/types.js').AccessContext
1656
+ context: import('../context/index.js').StackContext
1599
1657
  addValidationError: (msg: string) => void
1600
1658
  }
1601
1659
 
@@ -1615,7 +1673,7 @@ export type BeforeOperationHookArgs<
1615
1673
  operation: 'create'
1616
1674
  inputData: TCreateInput
1617
1675
  resolvedData: TCreateInput
1618
- context: import('../access/types.js').AccessContext
1676
+ context: import('../context/index.js').StackContext
1619
1677
  }
1620
1678
  | {
1621
1679
  listKey: string
@@ -1623,13 +1681,13 @@ export type BeforeOperationHookArgs<
1623
1681
  inputData: TUpdateInput
1624
1682
  item: TOutput
1625
1683
  resolvedData: TUpdateInput
1626
- context: import('../access/types.js').AccessContext
1684
+ context: import('../context/index.js').StackContext
1627
1685
  }
1628
1686
  | {
1629
1687
  listKey: string
1630
1688
  operation: 'delete'
1631
1689
  item: TOutput
1632
- context: import('../access/types.js').AccessContext
1690
+ context: import('../context/index.js').StackContext
1633
1691
  }
1634
1692
 
1635
1693
  /**
@@ -1649,7 +1707,7 @@ export type AfterOperationHookArgs<
1649
1707
  inputData: TCreateInput
1650
1708
  item: TOutput
1651
1709
  resolvedData: TCreateInput
1652
- context: import('../access/types.js').AccessContext
1710
+ context: import('../context/index.js').StackContext
1653
1711
  }
1654
1712
  | {
1655
1713
  listKey: string
@@ -1658,13 +1716,13 @@ export type AfterOperationHookArgs<
1658
1716
  originalItem: TOutput
1659
1717
  item: TOutput
1660
1718
  resolvedData: TUpdateInput
1661
- context: import('../access/types.js').AccessContext
1719
+ context: import('../context/index.js').StackContext
1662
1720
  }
1663
1721
  | {
1664
1722
  listKey: string
1665
1723
  operation: 'delete'
1666
1724
  originalItem: TOutput
1667
- context: import('../access/types.js').AccessContext
1725
+ context: import('../context/index.js').StackContext
1668
1726
  }
1669
1727
 
1670
1728
  /**
@@ -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
@@ -1,5 +1,5 @@
1
1
  import type { ListConfig } from '../config/types.js'
2
- import type { AccessContext } from '../access/types.js'
2
+ import type { StackContext } from './index.js'
3
3
  import {
4
4
  executeResolveInput,
5
5
  executeValidate,
@@ -25,7 +25,9 @@ export interface HookPipelineArgs {
25
25
  listConfig: ListConfig<any>
26
26
  inputData: Record<string, unknown>
27
27
  item: Record<string, unknown> | undefined
28
- context: AccessContext
28
+ // #1176: the full secured context (sudo/withSession/transaction), bound to
29
+ // the write's transaction client — see `bindContextToTransaction`.
30
+ context: StackContext
29
31
  }
30
32
 
31
33
  export interface HookPipelineResult {