@opensaas/stack-core 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +123 -0
  3. package/CLAUDE.md +21 -0
  4. package/dist/access/access-filter.d.ts +46 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +113 -4
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/declared-dependencies.d.ts.map +1 -1
  9. package/dist/access/declared-dependencies.js +4 -9
  10. package/dist/access/declared-dependencies.js.map +1 -1
  11. package/dist/access/engine.d.ts +0 -20
  12. package/dist/access/engine.d.ts.map +1 -1
  13. package/dist/access/engine.js +0 -25
  14. package/dist/access/engine.js.map +1 -1
  15. package/dist/access/errors.d.ts +49 -0
  16. package/dist/access/errors.d.ts.map +1 -1
  17. package/dist/access/errors.js +75 -0
  18. package/dist/access/errors.js.map +1 -1
  19. package/dist/access/field-access.d.ts +39 -1
  20. package/dist/access/field-access.d.ts.map +1 -1
  21. package/dist/access/field-access.js +108 -42
  22. package/dist/access/field-access.js.map +1 -1
  23. package/dist/access/field-access.test.js +229 -2
  24. package/dist/access/field-access.test.js.map +1 -1
  25. package/dist/access/field-transforms.d.ts +0 -27
  26. package/dist/access/field-transforms.d.ts.map +1 -1
  27. package/dist/access/field-visibility.d.ts +0 -4
  28. package/dist/access/field-visibility.d.ts.map +1 -1
  29. package/dist/access/field-visibility.js +13 -39
  30. package/dist/access/field-visibility.js.map +1 -1
  31. package/dist/access/index.d.ts +6 -2
  32. package/dist/access/index.d.ts.map +1 -1
  33. package/dist/access/index.js +12 -2
  34. package/dist/access/index.js.map +1 -1
  35. package/dist/access/query-validation.d.ts +121 -0
  36. package/dist/access/query-validation.d.ts.map +1 -0
  37. package/dist/access/query-validation.js +292 -0
  38. package/dist/access/query-validation.js.map +1 -0
  39. package/dist/access/relationship-count.d.ts.map +1 -1
  40. package/dist/access/relationship-count.js +2 -10
  41. package/dist/access/relationship-count.js.map +1 -1
  42. package/dist/access/relationship-label-filter.d.ts +23 -23
  43. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  44. package/dist/access/relationship-label-filter.js +5 -76
  45. package/dist/access/relationship-label-filter.js.map +1 -1
  46. package/dist/access/relationship-label-filter.test.js +19 -56
  47. package/dist/access/relationship-label-filter.test.js.map +1 -1
  48. package/dist/access/transaction-registry.d.ts +45 -0
  49. package/dist/access/transaction-registry.d.ts.map +1 -0
  50. package/dist/access/transaction-registry.js +37 -0
  51. package/dist/access/transaction-registry.js.map +1 -0
  52. package/dist/access/types.d.ts +55 -59
  53. package/dist/access/types.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +9 -7
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/index.js +10 -18
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/label.d.ts +6 -13
  59. package/dist/config/label.d.ts.map +1 -1
  60. package/dist/config/label.js +6 -13
  61. package/dist/config/label.js.map +1 -1
  62. package/dist/config/nav-count.d.ts +11 -19
  63. package/dist/config/nav-count.d.ts.map +1 -1
  64. package/dist/config/nav-count.js +11 -19
  65. package/dist/config/nav-count.js.map +1 -1
  66. package/dist/config/plugin-engine.d.ts +0 -10
  67. package/dist/config/plugin-engine.d.ts.map +1 -1
  68. package/dist/config/plugin-engine.js +1 -37
  69. package/dist/config/plugin-engine.js.map +1 -1
  70. package/dist/config/types.d.ts +94 -109
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/apply-defaults.d.ts +8 -31
  73. package/dist/context/apply-defaults.d.ts.map +1 -1
  74. package/dist/context/apply-defaults.js +12 -41
  75. package/dist/context/apply-defaults.js.map +1 -1
  76. package/dist/context/hook-pipeline.d.ts +4 -29
  77. package/dist/context/hook-pipeline.d.ts.map +1 -1
  78. package/dist/context/hook-pipeline.js +8 -41
  79. package/dist/context/hook-pipeline.js.map +1 -1
  80. package/dist/context/index.d.ts +4 -15
  81. package/dist/context/index.d.ts.map +1 -1
  82. package/dist/context/index.js +190 -193
  83. package/dist/context/index.js.map +1 -1
  84. package/dist/context/nested-operations.d.ts +7 -26
  85. package/dist/context/nested-operations.d.ts.map +1 -1
  86. package/dist/context/nested-operations.js +73 -171
  87. package/dist/context/nested-operations.js.map +1 -1
  88. package/dist/context/transaction-boundary.d.ts +36 -47
  89. package/dist/context/transaction-boundary.d.ts.map +1 -1
  90. package/dist/context/transaction-boundary.js +92 -79
  91. package/dist/context/transaction-boundary.js.map +1 -1
  92. package/dist/context/write-pipeline.d.ts +25 -64
  93. package/dist/context/write-pipeline.d.ts.map +1 -1
  94. package/dist/context/write-pipeline.js +81 -111
  95. package/dist/context/write-pipeline.js.map +1 -1
  96. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  97. package/dist/fields/format-prisma-default.js +4 -7
  98. package/dist/fields/format-prisma-default.js.map +1 -1
  99. package/dist/fields/index.d.ts +17 -2
  100. package/dist/fields/index.d.ts.map +1 -1
  101. package/dist/fields/index.js +136 -147
  102. package/dist/fields/index.js.map +1 -1
  103. package/dist/filter/collect.d.ts +26 -3
  104. package/dist/filter/collect.d.ts.map +1 -1
  105. package/dist/filter/collect.js +30 -12
  106. package/dist/filter/collect.js.map +1 -1
  107. package/dist/filter/filter.test.js +98 -15
  108. package/dist/filter/filter.test.js.map +1 -1
  109. package/dist/filter/index.d.ts +1 -0
  110. package/dist/filter/index.d.ts.map +1 -1
  111. package/dist/filter/index.js.map +1 -1
  112. package/dist/filter/map.d.ts.map +1 -1
  113. package/dist/filter/map.js +0 -5
  114. package/dist/filter/map.js.map +1 -1
  115. package/dist/filter/parse.d.ts.map +1 -1
  116. package/dist/filter/parse.js +2 -9
  117. package/dist/filter/parse.js.map +1 -1
  118. package/dist/filter/serialize.d.ts.map +1 -1
  119. package/dist/filter/serialize.js +2 -4
  120. package/dist/filter/serialize.js.map +1 -1
  121. package/dist/hooks/index.d.ts +4 -42
  122. package/dist/hooks/index.d.ts.map +1 -1
  123. package/dist/hooks/index.js +5 -59
  124. package/dist/hooks/index.js.map +1 -1
  125. package/dist/index.d.ts +3 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +20 -4
  128. package/dist/index.js.map +1 -1
  129. package/dist/internal.d.ts +1 -0
  130. package/dist/internal.d.ts.map +1 -1
  131. package/dist/internal.js +4 -0
  132. package/dist/internal.js.map +1 -1
  133. package/dist/lib/case-utils.d.ts +0 -32
  134. package/dist/lib/case-utils.d.ts.map +1 -1
  135. package/dist/lib/case-utils.js +0 -32
  136. package/dist/lib/case-utils.js.map +1 -1
  137. package/dist/mcp/handler.d.ts +0 -4
  138. package/dist/mcp/handler.d.ts.map +1 -1
  139. package/dist/mcp/handler.js +12 -69
  140. package/dist/mcp/handler.js.map +1 -1
  141. package/dist/query/index.d.ts +26 -227
  142. package/dist/query/index.d.ts.map +1 -1
  143. package/dist/query/index.js +14 -117
  144. package/dist/query/index.js.map +1 -1
  145. package/dist/utils/password.d.ts +0 -68
  146. package/dist/utils/password.d.ts.map +1 -1
  147. package/dist/utils/password.js +7 -76
  148. package/dist/utils/password.js.map +1 -1
  149. package/dist/validation/field-config.d.ts.map +1 -1
  150. package/dist/validation/field-config.js +0 -6
  151. package/dist/validation/field-config.js.map +1 -1
  152. package/dist/validation/schema.d.ts +0 -7
  153. package/dist/validation/schema.d.ts.map +1 -1
  154. package/dist/validation/schema.js +0 -10
  155. package/dist/validation/schema.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/access/access-filter.ts +152 -5
  158. package/src/access/declared-dependencies.ts +4 -9
  159. package/src/access/engine.ts +0 -25
  160. package/src/access/errors.ts +81 -0
  161. package/src/access/field-access.test.ts +312 -2
  162. package/src/access/field-access.ts +118 -42
  163. package/src/access/field-transforms.ts +8 -39
  164. package/src/access/field-visibility.ts +16 -47
  165. package/src/access/index.ts +20 -2
  166. package/src/access/query-validation.ts +359 -0
  167. package/src/access/relationship-count.ts +2 -10
  168. package/src/access/relationship-label-filter.test.ts +19 -68
  169. package/src/access/relationship-label-filter.ts +27 -104
  170. package/src/access/transaction-registry.ts +58 -0
  171. package/src/access/types.ts +83 -86
  172. package/src/config/index.ts +10 -22
  173. package/src/config/label.ts +6 -13
  174. package/src/config/nav-count.ts +11 -19
  175. package/src/config/plugin-engine.ts +1 -37
  176. package/src/config/types.ts +95 -110
  177. package/src/context/apply-defaults.ts +12 -44
  178. package/src/context/hook-pipeline.ts +12 -67
  179. package/src/context/index.ts +263 -247
  180. package/src/context/nested-operations.ts +94 -226
  181. package/src/context/transaction-boundary.ts +113 -109
  182. package/src/context/write-pipeline.ts +99 -155
  183. package/src/fields/format-prisma-default.ts +4 -7
  184. package/src/fields/index.ts +155 -147
  185. package/src/filter/collect.ts +48 -12
  186. package/src/filter/filter.test.ts +153 -15
  187. package/src/filter/index.ts +1 -0
  188. package/src/filter/map.ts +0 -5
  189. package/src/filter/parse.ts +2 -9
  190. package/src/filter/serialize.ts +2 -4
  191. package/src/hooks/index.ts +5 -59
  192. package/src/index.ts +23 -4
  193. package/src/internal.ts +5 -0
  194. package/src/lib/case-utils.ts +0 -32
  195. package/src/mcp/handler.ts +13 -71
  196. package/src/query/index.ts +29 -235
  197. package/src/utils/password.ts +7 -76
  198. package/src/validation/field-config.ts +0 -6
  199. package/src/validation/schema.ts +0 -10
  200. package/tests/context.test.ts +700 -1
  201. package/tests/field-types.test.ts +222 -0
  202. package/tests/mcp-handler.test.ts +34 -0
  203. package/tests/transaction-boundary-outermost.test.ts +530 -0
  204. package/tsconfig.tsbuildinfo +1 -1
@@ -9,15 +9,16 @@ import {
9
9
  } from './relationship-label-filter.js'
10
10
 
11
11
  /**
12
- * Access-scoped to-one relationship label filters (issue #749). Verifies that
13
- * `author:Ada` `{ author: { is: { name: { contains: 'Ada' } } } }` gets the
14
- * related list's `query` access folded into the nested `is` clause, so a
15
- * session can never distinguish parent rows by a related field it cannot
16
- * itself read.
12
+ * `resolveRelationshipLabelFilters` used to fold the related list's `query`
13
+ * access into a to-one relationship label filter's nested `is` clause
14
+ * (issue #749). Since #916, the engine itself scopes every relation filter in
15
+ * `where` (`buildAccessScopedWhere`), including this exact `{ is: {...} } }`
16
+ * shape, so this resolver's own fold is redundant and has been removed — it
17
+ * is now a pass-through, kept exported only for API compatibility. These
18
+ * tests pin that pass-through behavior; `access-filter.test.ts` and
19
+ * `context.test.ts` cover the actual access-scoping this resolver used to do.
17
20
  */
18
21
 
19
- // Post is fully open; User (the `author` relation target) is scoped so only
20
- // active users are queryable; Widget ships fully closed (no access block).
21
22
  function makeConfig(): OpenSaasConfig {
22
23
  return {
23
24
  db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
@@ -63,8 +64,8 @@ describe('isToOneRelationshipField', () => {
63
64
  })
64
65
  })
65
66
 
66
- describe('resolveRelationshipLabelFilters', () => {
67
- it('returns the where unchanged when there are no label-filter members', async () => {
67
+ describe('resolveRelationshipLabelFilters (pass-through since #916)', () => {
68
+ it('returns a plain where unchanged', async () => {
68
69
  const config = makeConfig()
69
70
  const where = { title: { contains: 'hello' } }
70
71
  const resolved = await resolveRelationshipLabelFilters(
@@ -76,7 +77,7 @@ describe('resolveRelationshipLabelFilters', () => {
76
77
  expect(resolved).toBe(where)
77
78
  })
78
79
 
79
- it("ANDs the related list's access filter into the nested `is` clause", async () => {
80
+ it('returns a to-one label-filter `is` clause unchanged the engine scopes it now', async () => {
80
81
  const config = makeConfig()
81
82
  const where = { author: { is: { name: { contains: 'Ada' } } } }
82
83
  const resolved = await resolveRelationshipLabelFilters(
@@ -85,31 +86,10 @@ describe('resolveRelationshipLabelFilters', () => {
85
86
  { session: null, context: makeContext() },
86
87
  config,
87
88
  )
88
- expect(resolved).toEqual({
89
- author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } },
90
- })
91
- })
92
-
93
- it('leaves the member unchanged when the related list is fully readable', async () => {
94
- const config: OpenSaasConfig = {
95
- db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
96
- lists: {
97
- Tag: list({ fields: { name: text() }, access: { operation: { query: () => true } } }),
98
- Post: list({ fields: { title: text(), tag: relationship({ ref: 'Tag' }) } }),
99
- },
100
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
101
- } as any
102
- const where = { tag: { is: { name: { contains: 'news' } } } }
103
- const resolved = await resolveRelationshipLabelFilters(
104
- where,
105
- config.lists.Post,
106
- { session: null, context: makeContext() },
107
- config,
108
- )
109
- expect(resolved).toEqual(where)
89
+ expect(resolved).toBe(where)
110
90
  })
111
91
 
112
- it('resolves a fully denied related list to a never-matching member (no leak)', async () => {
92
+ it('returns a label filter against a fully denied related list unchanged too', async () => {
113
93
  const config = makeConfig()
114
94
  const where = { widget: { is: { name: { contains: 'Gadget' } } } }
115
95
  const resolved = await resolveRelationshipLabelFilters(
@@ -118,26 +98,10 @@ describe('resolveRelationshipLabelFilters', () => {
118
98
  { session: null, context: makeContext() },
119
99
  config,
120
100
  )
121
- expect(resolved).toEqual({ id: { in: [] } })
122
- })
123
-
124
- it('preserves sibling conditions, ANDing the resolved id constraint for a denied member', async () => {
125
- const config = makeConfig()
126
- const where = {
127
- AND: [{ title: { contains: 'hello' } }, { widget: { is: { name: { contains: 'Gadget' } } } }],
128
- }
129
- const resolved = await resolveRelationshipLabelFilters(
130
- where,
131
- config.lists.Post,
132
- { session: null, context: makeContext() },
133
- config,
134
- )
135
- expect(resolved).toEqual({
136
- AND: [{ title: { contains: 'hello' } }, { id: { in: [] } }],
137
- })
101
+ expect(resolved).toBe(where)
138
102
  })
139
103
 
140
- it('preserves sibling conditions alongside the access-scoped nested `is` clause', async () => {
104
+ it('returns a where with nested AND/label-filter members unchanged', async () => {
141
105
  const config = makeConfig()
142
106
  const where = {
143
107
  AND: [{ title: { contains: 'hello' } }, { author: { is: { name: { contains: 'Ada' } } } }],
@@ -148,30 +112,17 @@ describe('resolveRelationshipLabelFilters', () => {
148
112
  { session: null, context: makeContext() },
149
113
  config,
150
114
  )
151
- expect(resolved).toEqual({
152
- AND: [
153
- { title: { contains: 'hello' } },
154
- { author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
155
- ],
156
- })
115
+ expect(resolved).toBe(where)
157
116
  })
158
117
 
159
- it('composes with a to-many count marker member left untouched by this resolver', async () => {
118
+ it('returns undefined unchanged when there is no where at all', async () => {
160
119
  const config = makeConfig()
161
- const where = {
162
- AND: [{ author: { is: { name: { contains: 'Ada' } } } }, { views: { gt: 10 } }],
163
- }
164
120
  const resolved = await resolveRelationshipLabelFilters(
165
- where,
121
+ undefined,
166
122
  config.lists.Post,
167
123
  { session: null, context: makeContext() },
168
124
  config,
169
125
  )
170
- expect(resolved).toEqual({
171
- AND: [
172
- { author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
173
- { views: { gt: 10 } },
174
- ],
175
- })
126
+ expect(resolved).toBeUndefined()
176
127
  })
177
128
  })
@@ -1,29 +1,31 @@
1
1
  import type { Session, AccessContext } from './types.js'
2
2
  import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
3
- import { checkAccess, getRelatedListConfig } from './engine.js'
4
- import { mergeResolvedMember } from './relationship-count.js'
5
3
 
6
4
  /**
7
5
  * Access-scoped to-one relationship label filters for the admin list view
8
- * (issue #749).
6
+ * (issue #749) — NOW A PASS-THROUGH.
9
7
  *
10
8
  * A to-one relationship's Filter spec (`author:Ada` → `{ author: { is: { name:
11
- * { contains: 'Ada' } } } }`) is a pure mapper (see `relationship()` in
12
- * `fields/index.ts`) it has no way to consult the related list's access
13
- * control, so the nested `is` clause it emits runs as an unscoped Prisma
14
- * sub-filter against the related table. The parent list's own access filter
15
- * still scopes which parent rows are visible, but the nested condition itself
16
- * is evaluated with no reference to the related list's `query` access a
17
- * session could distinguish parent rows by a related field it is not itself
18
- * allowed to read (e.g. binary-searching `author:A`, `author:Ad`, `author:Ada`
19
- * against a `User` list it cannot query).
9
+ * { contains: 'Ada' } } } }`) produces exactly the `{ is: {...} }` shape a
10
+ * relation filter uses. Before #916, the engine did not scope relation
11
+ * filters in `where` at all, so this module was the only place the related
12
+ * list's `query` access was folded into that nested `is` clause (mirroring
13
+ * `relationship-count.ts`'s `_count` folding) otherwise a session could
14
+ * distinguish parent rows by a related field it could not itself read (e.g.
15
+ * binary-searching `author:A`, `author:Ad`, `author:Ada` against a `User`
16
+ * list it cannot query).
20
17
  *
21
- * This module is the single place the related list's operation-level `query`
22
- * access is folded into that nested `is` clause, mirroring how
23
- * `relationship-count.ts` folds it into `_count` selects and count-filter
24
- * markers. A fully denied related list makes the member never match — the
25
- * token cannot be used to confirm or rule out a value on a field the session
26
- * cannot read (narrowing-only, matching the count resolver's denied path).
18
+ * #916 closed that gap in the engine itself: `context.db.*.findMany`/`count`
19
+ * now scope every relation filter in `where` including this exact `is`
20
+ * shape via `buildAccessScopedWhere` (`access-filter.ts`), applied
21
+ * automatically to whatever `where` this module's caller (`ListView.tsx`)
22
+ * hands to the secured context. Folding the same access filter here as well
23
+ * would be redundant work producing an identical result (the engine ANDs the
24
+ * same filter in a second time), so this module's own fold was removed —
25
+ * `resolveRelationshipLabelFilters` now returns `where` unchanged. The
26
+ * exported functions are kept, unchanged in shape, only because they remain
27
+ * part of `@opensaas/stack-core`'s public surface; removing them outright
28
+ * would be a breaking change this fix does not need to make.
27
29
  */
28
30
 
29
31
  type LabelFilterArgs = {
@@ -46,95 +48,16 @@ export function isToOneRelationshipField(field: FieldConfig | undefined): boolea
46
48
  )
47
49
  }
48
50
 
49
- /** Narrow a filter member's field value to a `{ is: {...} } }` clause, if it is one. */
50
- function readIsClause(value: unknown): Record<string, unknown> | null {
51
- if (!value || typeof value !== 'object') return null
52
- const isValue = (value as Record<string, unknown>).is
53
- if (!isValue || typeof isValue !== 'object') return null
54
- return isValue as Record<string, unknown>
55
- }
56
-
57
- /**
58
- * Resolve one to-one relationship label-filter member into an access-scoped
59
- * condition by ANDing the related list's `query` access filter into the nested
60
- * `is` clause. Returns `{ id: { in: [] } }` (never matches) when the related
61
- * list is fully denied, the original member unchanged when it is fully
62
- * readable, and the `is` clause ANDed with the access filter otherwise.
63
- */
64
- async function resolveOneLabelFilter(
65
- field: FieldConfig,
66
- fieldName: string,
67
- isClause: Record<string, unknown>,
68
- args: LabelFilterArgs,
69
- config: OpenSaasConfig,
70
- ): Promise<Record<string, unknown>> {
71
- if (!('ref' in field) || typeof field.ref !== 'string') {
72
- return { [fieldName]: { is: isClause } }
73
- }
74
- const related = getRelatedListConfig(field.ref, config)
75
- if (!related) return { [fieldName]: { is: isClause } }
76
-
77
- const queryAccess = related.listConfig.access?.operation?.query
78
- const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
79
-
80
- if (result === false) return { id: { in: [] } }
81
- if (result === true) return { [fieldName]: { is: isClause } }
82
- return { [fieldName]: { is: { AND: [result, isClause] } } }
83
- }
84
-
85
51
  /**
86
- * Replace any to-one relationship label-filter members in a filter `where`
87
- * with access-scoped equivalents. Label-filter members only ever appear as
88
- * top-level AND members (the relationship Filter spec does not declare
89
- * `freeText`, so it never participates in the free-text OR), so this walks
90
- * only the top level. Returns the `where` unchanged when it contains no such
91
- * members, so lists without to-one relationship filters pay nothing.
52
+ * No longer folds access into label filters see the module doc above.
53
+ * Returns `where` unchanged.
92
54
  */
93
55
  export async function resolveRelationshipLabelFilters(
94
56
  where: Record<string, unknown> | undefined,
95
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
96
- listConfig: ListConfig<any>,
97
- args: LabelFilterArgs,
98
- config: OpenSaasConfig,
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo, kept for API compatibility (see module doc)
58
+ _listConfig: ListConfig<any>,
59
+ _args: LabelFilterArgs,
60
+ _config: OpenSaasConfig,
99
61
  ): Promise<Record<string, unknown> | undefined> {
100
- if (!where) return where
101
-
102
- const andValue = where.AND
103
- const members: Array<Record<string, unknown>> = Array.isArray(andValue)
104
- ? (andValue as Array<Record<string, unknown>>)
105
- : [where]
106
-
107
- const findLabelFilter = (
108
- member: Record<string, unknown>,
109
- ): { field: string; isClause: Record<string, unknown> } | null => {
110
- for (const key of Object.keys(member)) {
111
- const field = listConfig.fields[key]
112
- if (!isToOneRelationshipField(field)) continue
113
- const isClause = readIsClause(member[key])
114
- if (!isClause) continue
115
- return { field: key, isClause }
116
- }
117
- return null
118
- }
119
-
120
- if (!members.some((member) => findLabelFilter(member) !== null)) {
121
- return where
122
- }
123
-
124
- const resolvedMembers: Array<Record<string, unknown>> = []
125
- for (const member of members) {
126
- const found = findLabelFilter(member)
127
- if (!found) {
128
- resolvedMembers.push(member)
129
- continue
130
- }
131
- const field = listConfig.fields[found.field]
132
- const resolved = await resolveOneLabelFilter(field, found.field, found.isClause, args, config)
133
-
134
- const siblings: Record<string, unknown> = { ...member }
135
- delete siblings[found.field]
136
- resolvedMembers.push(mergeResolvedMember(siblings, resolved))
137
- }
138
-
139
- return resolvedMembers.length === 1 ? resolvedMembers[0] : { AND: resolvedMembers }
62
+ return where
140
63
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Deferral registry for transaction-boundary hooks on a JOINED write (ADR-0028,
3
+ * issue #899).
4
+ *
5
+ * A write that joins a transaction it did not open — because the client it was
6
+ * handed exposes no way to open one (a Prisma transaction client, or a plain
7
+ * write issued from a hook whose context is rebound to one) — cannot itself
8
+ * observe when the enclosing transaction settles. Instead of firing
9
+ * `afterTransaction` optimistically at write time, it enqueues a flush here.
10
+ * The transaction owner (`context.transaction()`, or the Write Pipeline when it
11
+ * actually opens the transaction) drains the queue, in enqueue (write) order,
12
+ * once it observes its own transaction settle — supplying the real
13
+ * committed/rolled-back outcome.
14
+ *
15
+ * This is internal plumbing with no public surface: it is threaded through the
16
+ * same context-rebind path ADR-0010/0012 already use for `plugins` and
17
+ * `_resolveOutputChain` (see `AccessContext['_transactionOwner']`), never
18
+ * exposed on the public `StackContext` type.
19
+ */
20
+
21
+ /**
22
+ * The outcome the transaction OWNER observed for the transaction as a whole —
23
+ * distinct from a single write's own {@link import('../hooks/index.js').TransactionOutcome},
24
+ * which additionally carries that write's own persisted `item` or `error`.
25
+ */
26
+ export interface TransactionSettleOutcome {
27
+ status: 'committed' | 'rolled-back'
28
+ error?: unknown
29
+ }
30
+
31
+ /**
32
+ * One deferred write's flush: given the owner's settle outcome, runs that
33
+ * write's `afterTransaction` bracket and appends any hook errors to `errors`.
34
+ */
35
+ export type QueuedTransactionFlush = (
36
+ settle: TransactionSettleOutcome,
37
+ errors: unknown[],
38
+ ) => Promise<void>
39
+
40
+ /**
41
+ * A FIFO queue of deferred transaction-boundary flushes, owned by whichever
42
+ * component opened the enclosing transaction. One registry per owned
43
+ * transaction; a nested `context.transaction()` joins its enclosing owner's
44
+ * registry rather than creating a second one.
45
+ */
46
+ export class TransactionRegistry {
47
+ private readonly queue: QueuedTransactionFlush[] = []
48
+
49
+ enqueue(flush: QueuedTransactionFlush): void {
50
+ this.queue.push(flush)
51
+ }
52
+
53
+ async drain(settle: TransactionSettleOutcome, errors: unknown[]): Promise<void> {
54
+ for (const flush of this.queue) {
55
+ await flush(settle, errors)
56
+ }
57
+ }
58
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Fragment, FieldSelection, ResultOf } from '../query/index.js'
2
+ import type { TransactionRegistry } from './transaction-registry.js'
2
3
 
3
4
  /**
4
5
  * Session interface - can be augmented by developers to add custom fields
@@ -37,9 +38,6 @@ export interface Session {
37
38
  [key: string]: unknown
38
39
  }
39
40
 
40
- /**
41
- * Generic Prisma model delegate type
42
- */
43
41
  export type PrismaModelDelegate = {
44
42
  findUnique: (args: unknown) => Promise<unknown>
45
43
  findFirst: (args: unknown) => Promise<unknown>
@@ -50,12 +48,8 @@ export type PrismaModelDelegate = {
50
48
  count: (args?: unknown) => Promise<number>
51
49
  }
52
50
 
53
- /**
54
- * Generic Prisma client type
55
- * This is intentionally permissive to allow actual PrismaClient types
56
- * Uses `any` because Prisma generates highly complex client types that are difficult to constrain
57
- * This type is used as a generic constraint and the actual type safety comes from TPrisma parameter
58
- */
51
+ // Uses `any` because Prisma generates highly complex client types that are difficult
52
+ // to constrain here; actual type safety comes from the TPrisma generic parameter.
59
53
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
54
  export type PrismaClientLike = any
61
55
 
@@ -102,11 +96,9 @@ export type FindManyQueryArgs = {
102
96
  */
103
97
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
98
  export interface AugmentedFindMany<TOriginal extends (...args: any[]) => any> {
105
- // Overload 1: with query fragment — return type narrows to ResultOf<fragment>[]
106
99
  <TItem, TFields extends FieldSelection<TItem>>(
107
100
  args: FindManyQueryArgs & { query: Fragment<TItem, TFields> },
108
101
  ): Promise<ResultOf<Fragment<TItem, TFields>>[]>
109
- // Overload 2: original Prisma behaviour
110
102
  (...args: Parameters<TOriginal>): ReturnType<TOriginal>
111
103
  }
112
104
 
@@ -144,11 +136,9 @@ export type FindFirstQueryArgs = {
144
136
  */
145
137
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
138
  export interface AugmentedFindFirst<TOriginal extends (...args: any[]) => any> {
147
- // Overload 1: with query fragment — return type narrows to ResultOf<fragment> | null
148
139
  <TItem, TFields extends FieldSelection<TItem>>(
149
140
  args: FindFirstQueryArgs & { query: Fragment<TItem, TFields> },
150
141
  ): Promise<ResultOf<Fragment<TItem, TFields>> | null>
151
- // Overload 2: original Prisma behaviour
152
142
  (...args: Parameters<TOriginal>): ReturnType<TOriginal>
153
143
  }
154
144
 
@@ -171,23 +161,17 @@ export interface AugmentedFindFirst<TOriginal extends (...args: any[]) => any> {
171
161
  */
172
162
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
163
  export interface AugmentedFindUnique<TOriginal extends (...args: any[]) => any> {
174
- // Overload 1: with query fragment — return type narrows to ResultOf<fragment> | null
175
164
  <TItem, TFields extends FieldSelection<TItem>>(args: {
176
165
  where: Record<string, unknown>
177
166
  query: Fragment<TItem, TFields>
178
167
  }): Promise<ResultOf<Fragment<TItem, TFields>> | null>
179
- // Overload 2: original Prisma behaviour
180
168
  (...args: Parameters<TOriginal>): ReturnType<TOriginal>
181
169
  }
182
170
 
183
- /**
184
- * Map Prisma client to access-controlled database context
185
- * Preserves Prisma's type information for each model
186
- */
187
171
  export type AccessControlledDB<TPrisma extends PrismaClientLike> = {
188
172
  [K in keyof TPrisma]: TPrisma[K] extends {
189
- // Uses `any` in conditional type checks to verify Prisma model shape
190
- // This is a standard TypeScript pattern for checking if a property exists with any signature
173
+ // Uses `any` here to check the property exists with any signature, a standard
174
+ // TypeScript pattern for verifying Prisma model shape in a conditional type.
191
175
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
192
176
  findUnique: any
193
177
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -232,17 +216,7 @@ export type AccessControlledDB<TPrisma extends PrismaClientLike> = {
232
216
  [key: string]: any
233
217
  }
234
218
 
235
- /**
236
- * Storage utilities for file/image uploads
237
- */
238
219
  export type StorageUtils = {
239
- /**
240
- * Upload a file to storage
241
- * @param providerName - Name of storage provider from config
242
- * @param file - File object
243
- * @param buffer - File contents as Buffer
244
- * @param options - Upload options (validation, metadata)
245
- */
246
220
  uploadFile: (
247
221
  providerName: string,
248
222
  file: File,
@@ -250,13 +224,6 @@ export type StorageUtils = {
250
224
  options?: unknown,
251
225
  ) => Promise<unknown>
252
226
 
253
- /**
254
- * Upload an image with transformations
255
- * @param providerName - Name of storage provider from config
256
- * @param file - File object
257
- * @param buffer - File contents as Buffer
258
- * @param options - Upload options (validation, transformations, metadata)
259
- */
260
227
  uploadImage: (
261
228
  providerName: string,
262
229
  file: File,
@@ -264,24 +231,12 @@ export type StorageUtils = {
264
231
  options?: unknown,
265
232
  ) => Promise<unknown>
266
233
 
267
- /**
268
- * Delete a file from storage
269
- * @param providerName - Name of storage provider from config
270
- * @param filename - Name of file to delete
271
- */
272
234
  deleteFile: (providerName: string, filename: string) => Promise<void>
273
235
 
274
- /**
275
- * Delete an image and all its transformations
276
- * @param metadata - Image metadata containing storage provider and filename
277
- */
278
236
  deleteImage: (metadata: unknown) => Promise<void>
279
237
  }
280
238
 
281
- /**
282
- * Context type (simplified for access control)
283
- * Using interface instead of type to allow module augmentation
284
- */
239
+ // Uses `interface` rather than `type` so consumers can extend it via module augmentation.
285
240
  export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
286
241
  session: Session | null
287
242
  prisma: TPrisma
@@ -303,16 +258,22 @@ export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLi
303
258
  * in CONTEXT.md.
304
259
  */
305
260
  _resolveOutputChain: readonly { listKey: string; fieldKey: string }[]
261
+ /**
262
+ * Present when this context is JOINED into an enclosing transaction it did
263
+ * not open (ADR-0028, #899) — set by `context.transaction()`, or by the
264
+ * Write Pipeline when it opens the transaction the current write's hooks
265
+ * are rebound into. A write reached through a context carrying this defers
266
+ * its `afterTransaction` bracket to the registry instead of firing it at
267
+ * write time; `undefined` for a top-level context with no owner. Threaded
268
+ * through the same context-rebind path as `plugins`/`_resolveOutputChain`.
269
+ * @internal
270
+ */
271
+ _transactionOwner?: TransactionRegistry
306
272
  }
307
273
 
308
- /**
309
- * Prisma filter type - represents a where clause
310
- * Uses Partial to allow filtering by any subset of fields
311
- */
312
274
  export type PrismaFilter<T = Record<string, unknown>> = Partial<Record<keyof T, unknown>>
313
275
 
314
276
  /**
315
- * Access control function type
316
277
  * Can return:
317
278
  * - boolean: true = allow, false = deny
318
279
  * - PrismaFilter: Prisma where clause to filter results
@@ -324,51 +285,87 @@ export type AccessControl<T = Record<string, unknown>> = (args: {
324
285
  }) => boolean | PrismaFilter<T> | Promise<boolean | PrismaFilter<T>>
325
286
 
326
287
  /**
327
- * Field-level access control function.
288
+ * The per-operation argument shapes a `FieldAccessControl` function is called
289
+ * with — the discriminated union `FieldAccessControl` wraps, and the source
290
+ * of truth `FieldAccess`'s individual `read`/`create`/`update` members are
291
+ * picked from (via `Extract`) below. Keeping this as its own named type is
292
+ * what lets both sides reference the exact same three call shapes instead of
293
+ * two independently-maintained descriptions drifting apart.
294
+ */
295
+ type FieldAccessControlArgs<TItem, TCreateInput, TUpdateInput> =
296
+ | {
297
+ session: Session | null
298
+ // Field Visibility (phase 2 of the two-phase read) always evaluates
299
+ // `read` rules against an already-fetched row — see
300
+ // `resolveReadableFieldValue` in `field-visibility.ts`, the sole
301
+ // caller of `checkFieldAccess` for this operation. Unlike `create`
302
+ // (where no row exists yet), there is no case where `item` is absent
303
+ // here, so it is required rather than optional.
304
+ item: TItem
305
+ context: AccessContext
306
+ inputData?: undefined
307
+ operation: 'read'
308
+ }
309
+ | {
310
+ session: Session | null
311
+ item?: undefined
312
+ context: AccessContext
313
+ inputData: TCreateInput
314
+ operation: 'create'
315
+ }
316
+ | {
317
+ session: Session | null
318
+ item: TItem
319
+ context: AccessContext
320
+ inputData: TUpdateInput
321
+ operation: 'update'
322
+ }
323
+
324
+ /**
328
325
  * For create/update operations, receives inputData to validate incoming values.
329
326
  *
330
- * Note: While this type accepts filters for backward compatibility with AccessControl,
331
- * filters are ignored in field-level access. Only boolean results are used.
332
- * If a filter is returned, it defaults to allowing access (true).
327
+ * Unlike operation-level `AccessControl`, this returns `boolean` only. Field
328
+ * access is a per-field visibility decision, not a row filter — a denied
329
+ * field is removed, never used to scope which rows are returned (see the
330
+ * "Field-level access" glossary entry in `CONTEXT.md`, ADR-0001, and
331
+ * ADR-0030). A rule that needs to depend on the row or the write payload
332
+ * should evaluate the condition itself and return a boolean, e.g.
333
+ * `({ item, session }) => item?.ownerId === session?.userId`. The runtime
334
+ * evaluator (`checkFieldAccess`) enforces this: a rule that somehow returns
335
+ * anything other than `true`/`false` (bypassing this type) throws rather than
336
+ * defaulting to allow.
337
+ *
338
+ * This is the general, all-operations union — useful for a single function
339
+ * reused across more than one of `FieldAccess`'s `read`/`create`/`update`
340
+ * slots, narrowing on `operation` to tell the call shapes apart. A function
341
+ * written for exactly one slot doesn't need to: `FieldAccess` picks each
342
+ * slot's own single-operation shape out of this union (see below), so e.g. a
343
+ * `read`-only rule sees `item` as always present with no narrowing required.
333
344
  */
334
345
  export type FieldAccessControl<
335
346
  TItem = Record<string, unknown>,
336
347
  TCreateInput = Record<string, unknown>,
337
348
  TUpdateInput = Record<string, unknown>,
338
- > = (
339
- args:
340
- | {
341
- session: Session | null
342
- item?: undefined
343
- context: AccessContext
344
- inputData?: undefined
345
- operation: 'read'
346
- }
347
- | {
348
- session: Session | null
349
- item?: undefined
350
- context: AccessContext
351
- inputData: TCreateInput
352
- operation: 'create'
353
- }
354
- | {
355
- session: Session | null
356
- item: TItem
357
- context: AccessContext
358
- inputData: TUpdateInput
359
- operation: 'update'
360
- },
361
- ) => boolean | PrismaFilter<TItem> | Promise<boolean | PrismaFilter<TItem>>
349
+ > = (args: FieldAccessControlArgs<TItem, TCreateInput, TUpdateInput>) => boolean | Promise<boolean>
362
350
 
363
351
  /**
364
- * Field-level access control
352
+ * `read` is typed from the single `operation: 'read'` member of
353
+ * `FieldAccessControlArgs`, not the full `FieldAccessControl` union — so a
354
+ * rule written directly for this slot sees `item` as always present (never
355
+ * `TItem | undefined`) with no narrowing, cast, or `any` needed. A function
356
+ * typed as the broader `FieldAccessControl` union remains assignable here
357
+ * (and to `create`/`update`) — accepting more call shapes than the slot
358
+ * requires is a valid substitute, same as anywhere else function parameters
359
+ * are contravariant.
365
360
  */
366
361
  export type FieldAccess<
367
362
  TItem = Record<string, unknown>,
368
363
  TCreateInput = Record<string, unknown>,
369
364
  TUpdateInput = Record<string, unknown>,
370
365
  > = {
371
- read?: FieldAccessControl<TItem, TCreateInput, TUpdateInput>
366
+ read?: (
367
+ args: Extract<FieldAccessControlArgs<TItem, TCreateInput, TUpdateInput>, { operation: 'read' }>,
368
+ ) => boolean | Promise<boolean>
372
369
  create?: FieldAccessControl<TItem, TCreateInput, TUpdateInput>
373
370
  update?: FieldAccessControl<TItem, TCreateInput, TUpdateInput>
374
371
  }