@opensaas/stack-core 0.29.0 → 0.31.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 (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +443 -0
  3. package/CLAUDE.md +4 -4
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +14 -1
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +50 -0
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/field-access.js +6 -6
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/multi-column-read-write.test.js +46 -12
  12. package/dist/access/multi-column-read-write.test.js.map +1 -1
  13. package/dist/access/relationship-count.d.ts +60 -0
  14. package/dist/access/relationship-count.d.ts.map +1 -0
  15. package/dist/access/relationship-count.js +233 -0
  16. package/dist/access/relationship-count.js.map +1 -0
  17. package/dist/access/relationship-count.test.d.ts +2 -0
  18. package/dist/access/relationship-count.test.d.ts.map +1 -0
  19. package/dist/access/relationship-count.test.js +185 -0
  20. package/dist/access/relationship-count.test.js.map +1 -0
  21. package/dist/access/relationship-label-filter.d.ts +45 -0
  22. package/dist/access/relationship-label-filter.d.ts.map +1 -0
  23. package/dist/access/relationship-label-filter.js +93 -0
  24. package/dist/access/relationship-label-filter.js.map +1 -0
  25. package/dist/access/relationship-label-filter.test.d.ts +2 -0
  26. package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
  27. package/dist/access/relationship-label-filter.test.js +126 -0
  28. package/dist/access/relationship-label-filter.test.js.map +1 -0
  29. package/dist/config/index.d.ts +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/nav-count.d.ts +34 -0
  32. package/dist/config/nav-count.d.ts.map +1 -0
  33. package/dist/config/nav-count.js +71 -0
  34. package/dist/config/nav-count.js.map +1 -0
  35. package/dist/config/types.d.ts +305 -4
  36. package/dist/config/types.d.ts.map +1 -1
  37. package/dist/context/hook-pipeline.d.ts.map +1 -1
  38. package/dist/context/hook-pipeline.js +11 -1
  39. package/dist/context/hook-pipeline.js.map +1 -1
  40. package/dist/context/index.d.ts +28 -0
  41. package/dist/context/index.d.ts.map +1 -1
  42. package/dist/context/index.js +206 -1
  43. package/dist/context/index.js.map +1 -1
  44. package/dist/context/nested-operations.d.ts.map +1 -1
  45. package/dist/context/nested-operations.js +7 -1
  46. package/dist/context/nested-operations.js.map +1 -1
  47. package/dist/extend.d.ts +1 -0
  48. package/dist/extend.d.ts.map +1 -1
  49. package/dist/fields/index.d.ts +1 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +166 -0
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/filter/collect.d.ts +32 -0
  54. package/dist/filter/collect.d.ts.map +1 -0
  55. package/dist/filter/collect.js +60 -0
  56. package/dist/filter/collect.js.map +1 -0
  57. package/dist/filter/filter.test.d.ts +2 -0
  58. package/dist/filter/filter.test.d.ts.map +1 -0
  59. package/dist/filter/filter.test.js +348 -0
  60. package/dist/filter/filter.test.js.map +1 -0
  61. package/dist/filter/index.d.ts +7 -0
  62. package/dist/filter/index.d.ts.map +1 -0
  63. package/dist/filter/index.js +16 -0
  64. package/dist/filter/index.js.map +1 -0
  65. package/dist/filter/map.d.ts +18 -0
  66. package/dist/filter/map.d.ts.map +1 -0
  67. package/dist/filter/map.js +60 -0
  68. package/dist/filter/map.js.map +1 -0
  69. package/dist/filter/parse.d.ts +33 -0
  70. package/dist/filter/parse.d.ts.map +1 -0
  71. package/dist/filter/parse.js +103 -0
  72. package/dist/filter/parse.js.map +1 -0
  73. package/dist/filter/serialize.d.ts +25 -0
  74. package/dist/filter/serialize.d.ts.map +1 -0
  75. package/dist/filter/serialize.js +68 -0
  76. package/dist/filter/serialize.js.map +1 -0
  77. package/dist/filter/serialize.test.d.ts +2 -0
  78. package/dist/filter/serialize.test.d.ts.map +1 -0
  79. package/dist/filter/serialize.test.js +83 -0
  80. package/dist/filter/serialize.test.js.map +1 -0
  81. package/dist/filter/types.d.ts +122 -0
  82. package/dist/filter/types.d.ts.map +1 -0
  83. package/dist/filter/types.js +24 -0
  84. package/dist/filter/types.js.map +1 -0
  85. package/dist/hooks/index.d.ts +30 -0
  86. package/dist/hooks/index.d.ts.map +1 -1
  87. package/dist/hooks/index.js +79 -59
  88. package/dist/hooks/index.js.map +1 -1
  89. package/dist/index.d.ts +6 -1
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +19 -0
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal.d.ts +1 -0
  94. package/dist/internal.d.ts.map +1 -1
  95. package/dist/internal.js +4 -0
  96. package/dist/internal.js.map +1 -1
  97. package/dist/mcp/handler.d.ts +11 -3
  98. package/dist/mcp/handler.d.ts.map +1 -1
  99. package/dist/mcp/handler.js +96 -20
  100. package/dist/mcp/handler.js.map +1 -1
  101. package/package.json +1 -1
  102. package/src/access/access-filter.test.ts +79 -0
  103. package/src/access/access-filter.ts +15 -4
  104. package/src/access/field-access.ts +6 -6
  105. package/src/access/multi-column-read-write.test.ts +68 -19
  106. package/src/access/relationship-count.test.ts +269 -0
  107. package/src/access/relationship-count.ts +311 -0
  108. package/src/access/relationship-label-filter.test.ts +177 -0
  109. package/src/access/relationship-label-filter.ts +140 -0
  110. package/src/config/index.ts +6 -0
  111. package/src/config/nav-count.ts +85 -0
  112. package/src/config/types.ts +319 -1
  113. package/src/context/hook-pipeline.ts +19 -0
  114. package/src/context/index.ts +286 -4
  115. package/src/context/nested-operations.ts +22 -0
  116. package/src/extend.ts +9 -0
  117. package/src/fields/index.ts +168 -0
  118. package/src/filter/collect.ts +72 -0
  119. package/src/filter/filter.test.ts +400 -0
  120. package/src/filter/index.ts +25 -0
  121. package/src/filter/map.ts +68 -0
  122. package/src/filter/parse.ts +106 -0
  123. package/src/filter/serialize.test.ts +115 -0
  124. package/src/filter/serialize.ts +75 -0
  125. package/src/filter/types.ts +120 -0
  126. package/src/hooks/index.ts +87 -57
  127. package/src/index.ts +54 -0
  128. package/src/internal.ts +5 -0
  129. package/src/mcp/handler.ts +118 -28
  130. package/tests/context.test.ts +690 -0
  131. package/tests/multi-column-validation-ordering.test.ts +305 -0
  132. package/tests/nav-count.test.ts +189 -0
  133. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { filterReadableFields } from './field-visibility.js'
3
- import { executeFieldResolveInputHooks } from '../hooks/index.js'
3
+ import { executeFieldResolveInputHooks, splitMultiColumnFields } from '../hooks/index.js'
4
4
  import type { FieldConfig } from '../config/types.js'
5
5
  import type { AccessContext, FieldAccess } from './types.js'
6
6
 
@@ -10,6 +10,13 @@ import type { AccessContext, FieldAccess } from './types.js'
10
10
  * field-agnostic: they assert that ANY field implementing
11
11
  * getColumnNames/assembleColumns/splitColumns is assembled on read (raw columns
12
12
  * stripped) and split on write.
13
+ *
14
+ * The write side is split across two phases (#789): `executeFieldResolveInputHooks`
15
+ * (Phase 1.5) resolves the field's value under its LOGICAL key only — no split —
16
+ * so that validation (Phase 2-3, not exercised by these field-agnostic unit
17
+ * tests; see the Hook Pipeline / Write Pipeline test suites) runs against the
18
+ * logical value first. `splitMultiColumnFields` (Phase 4) then replaces the
19
+ * logical key with its physical per-part columns, AFTER validation has passed.
13
20
  */
14
21
 
15
22
  // A minimal multi-column field: two physical columns `m_url` and `m_size`
@@ -113,10 +120,10 @@ describe('multi-column read assembly (filterReadableFields)', () => {
113
120
  })
114
121
  })
115
122
 
116
- describe('multi-column write split (executeFieldResolveInputHooks)', () => {
123
+ describe('multi-column resolveInput (executeFieldResolveInputHooks) does NOT split (#789)', () => {
117
124
  const fields = { media: multiColumnField() }
118
125
 
119
- it('splits the logical value into per-part columns and removes the logical key', async () => {
126
+ it('resolves the field under its LOGICAL key and leaves it unsplit', async () => {
120
127
  const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
121
128
  const result = await executeFieldResolveInputHooks(
122
129
  inputData,
@@ -126,33 +133,81 @@ describe('multi-column write split (executeFieldResolveInputHooks)', () => {
126
133
  makeContext(),
127
134
  'Post',
128
135
  )
136
+ // Still under the logical key — no split, no per-part columns yet.
137
+ expect(result).toEqual({ media: { url: 'https://x/y.jpg', size: 99 } })
138
+ expect('m_url' in result).toBe(false)
139
+ expect('m_size' in result).toBe(false)
140
+ })
141
+
142
+ it('passes an unrecognised value through unsplit, under the logical key', async () => {
143
+ // The field's own resolveInput fallback (see fixture: "unknown → return as
144
+ // is and let validation catch it") must be able to hand this value to
145
+ // validation BEFORE any split happens.
146
+ const inputData = { media: 'not-a-valid-shape' }
147
+ const result = await executeFieldResolveInputHooks(
148
+ inputData,
149
+ { ...inputData },
150
+ fields,
151
+ 'update',
152
+ makeContext(),
153
+ 'Post',
154
+ )
155
+ expect(result).toEqual({ media: 'not-a-valid-shape' })
156
+ })
157
+
158
+ it('does not touch the field when it is absent from the write', async () => {
159
+ const inputData = { title: 'no media in payload' }
160
+ const result = await executeFieldResolveInputHooks(
161
+ inputData,
162
+ { ...inputData },
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs
164
+ { ...fields, title: { type: 'text' } as any },
165
+ 'update',
166
+ makeContext(),
167
+ 'Post',
168
+ )
169
+ expect(result).toEqual({ title: 'no media in payload' })
170
+ expect('media' in result).toBe(false)
171
+ })
172
+ })
173
+
174
+ describe('multi-column write split (splitMultiColumnFields, AFTER validation — #789)', () => {
175
+ const fields = { media: multiColumnField() }
176
+
177
+ it('splits the logical value into per-part columns and removes the logical key', async () => {
178
+ const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
179
+ const result = await splitMultiColumnFields(
180
+ inputData,
181
+ { ...inputData },
182
+ fields,
183
+ 'create',
184
+ makeContext(),
185
+ )
129
186
  expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
130
187
  expect('media' in result).toBe(false)
131
188
  })
132
189
 
133
190
  it('splitting null clears all per-part columns', async () => {
134
191
  const inputData = { media: null }
135
- const result = await executeFieldResolveInputHooks(
192
+ const result = await splitMultiColumnFields(
136
193
  inputData,
137
194
  { ...inputData },
138
195
  fields,
139
196
  'update',
140
197
  makeContext(),
141
- 'Post',
142
198
  )
143
199
  expect(result).toEqual({ m_url: null, m_size: null })
144
200
  })
145
201
 
146
202
  it('does not touch the columns when the logical field is absent from the write', async () => {
147
203
  const inputData = { title: 'no media in payload' }
148
- const result = await executeFieldResolveInputHooks(
204
+ const result = await splitMultiColumnFields(
149
205
  inputData,
150
206
  { ...inputData },
151
207
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs
152
208
  { ...fields, title: { type: 'text' } as any },
153
209
  'update',
154
210
  makeContext(),
155
- 'Post',
156
211
  )
157
212
  expect(result).toEqual({ title: 'no media in payload' })
158
213
  expect('m_url' in result).toBe(false)
@@ -163,13 +218,12 @@ describe('multi-column write split respects field-level write access', () => {
163
218
  it('does NOT write any per-part columns when update access is denied', async () => {
164
219
  const fields = { media: multiColumnField({ update: () => false }) }
165
220
  const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
166
- const result = await executeFieldResolveInputHooks(
221
+ const result = await splitMultiColumnFields(
167
222
  inputData,
168
223
  { ...inputData },
169
224
  fields,
170
225
  'update',
171
226
  makeContext(),
172
- 'Post',
173
227
  )
174
228
  // The logical key is dropped (it is not a real column) AND none of its
175
229
  // per-part columns are written — identical to how filterWritableFields
@@ -183,13 +237,12 @@ describe('multi-column write split respects field-level write access', () => {
183
237
  it('does NOT write any per-part columns when create access is denied', async () => {
184
238
  const fields = { media: multiColumnField({ create: () => false }) }
185
239
  const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
186
- const result = await executeFieldResolveInputHooks(
240
+ const result = await splitMultiColumnFields(
187
241
  inputData,
188
242
  { ...inputData },
189
243
  fields,
190
244
  'create',
191
245
  makeContext(),
192
- 'Post',
193
246
  )
194
247
  expect(result).toEqual({})
195
248
  expect('m_url' in result).toBe(false)
@@ -198,13 +251,12 @@ describe('multi-column write split respects field-level write access', () => {
198
251
  it('still splits/writes the columns when write access is granted', async () => {
199
252
  const fields = { media: multiColumnField({ update: () => true, create: () => true }) }
200
253
  const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
201
- const result = await executeFieldResolveInputHooks(
254
+ const result = await splitMultiColumnFields(
202
255
  inputData,
203
256
  { ...inputData },
204
257
  fields,
205
258
  'update',
206
259
  makeContext(),
207
- 'Post',
208
260
  )
209
261
  expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
210
262
  expect('media' in result).toBe(false)
@@ -214,13 +266,12 @@ describe('multi-column write split respects field-level write access', () => {
214
266
  // A field that denies `update` must still be writable on `create`.
215
267
  const fields = { media: multiColumnField({ update: () => false }) }
216
268
  const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
217
- const result = await executeFieldResolveInputHooks(
269
+ const result = await splitMultiColumnFields(
218
270
  inputData,
219
271
  { ...inputData },
220
272
  fields,
221
273
  'create',
222
274
  makeContext(),
223
- 'Post',
224
275
  )
225
276
  expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
226
277
  })
@@ -228,13 +279,12 @@ describe('multi-column write split respects field-level write access', () => {
228
279
  it('sudo bypasses the field-access gate and still splits', async () => {
229
280
  const fields = { media: multiColumnField({ update: () => false }) }
230
281
  const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
231
- const result = await executeFieldResolveInputHooks(
282
+ const result = await splitMultiColumnFields(
232
283
  inputData,
233
284
  { ...inputData },
234
285
  fields,
235
286
  'update',
236
287
  makeContext({ isSudo: true }),
237
- 'Post',
238
288
  )
239
289
  expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
240
290
  })
@@ -242,13 +292,12 @@ describe('multi-column write split respects field-level write access', () => {
242
292
  it('a multi-column field WITHOUT field-level access splits exactly as before', async () => {
243
293
  const fields = { media: multiColumnField() }
244
294
  const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
245
- const result = await executeFieldResolveInputHooks(
295
+ const result = await splitMultiColumnFields(
246
296
  inputData,
247
297
  { ...inputData },
248
298
  fields,
249
299
  'update',
250
300
  makeContext(),
251
- 'Post',
252
301
  )
253
302
  expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
254
303
  })
@@ -0,0 +1,269 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { list } from '../config/index.js'
3
+ import { text, relationship, integer } from '../fields/index.js'
4
+ import type { OpenSaasConfig } from '../config/types.js'
5
+ import type { AccessContext } from './types.js'
6
+ import {
7
+ buildRelationshipCountSelect,
8
+ resolveRelationshipCountFilters,
9
+ isToManyRelationshipField,
10
+ } from './relationship-count.js'
11
+ import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
12
+
13
+ /**
14
+ * Access-scoped to-many relationship counts (issue #732). Verifies:
15
+ * • the filtered `_count` select folds in the related list's query access,
16
+ * • a denied related list is omitted (its count renders as 0, never a leak),
17
+ * • count-filter markers resolve to access-scoped `{ id: { in } }` via a single
18
+ * secured read (never a per-row query), including the fully-denied case.
19
+ */
20
+
21
+ // User (published-only for anon via a filter), plus a Widget list that is fully
22
+ // closed by default (no query access) to prove the denied path.
23
+ function makeConfig(): OpenSaasConfig {
24
+ return {
25
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
26
+ lists: {
27
+ User: list({
28
+ fields: {
29
+ name: text(),
30
+ posts: relationship({ ref: 'Post.author', many: true }),
31
+ widgets: relationship({ ref: 'Widget', many: true }),
32
+ manager: relationship({ ref: 'User' }), // to-one — never counted
33
+ },
34
+ access: { operation: { query: () => true } },
35
+ }),
36
+ Post: list({
37
+ fields: { title: text(), views: integer(), author: relationship({ ref: 'User.posts' }) },
38
+ // Only published posts are visible → the count must be access-scoped.
39
+ access: { operation: { query: () => ({ status: { equals: 'published' } }) } },
40
+ }),
41
+ // Widget ships closed (no access block) → query denied by default.
42
+ Widget: list({ fields: { name: text() } }),
43
+ },
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
45
+ } as any
46
+ }
47
+
48
+ function makeContext(
49
+ findMany?: (args: unknown) => Promise<Array<Record<string, unknown>>>,
50
+ ): AccessContext {
51
+ return {
52
+ session: null,
53
+ _isSudo: false,
54
+ _resolveOutputCounter: { depth: 0 },
55
+ db: findMany ? { user: { findMany } } : {},
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
57
+ } as any
58
+ }
59
+
60
+ describe('isToManyRelationshipField', () => {
61
+ const config = makeConfig()
62
+ const userFields = config.lists.User.fields
63
+ it('is true only for a to-many relationship', () => {
64
+ expect(isToManyRelationshipField(userFields.posts)).toBe(true)
65
+ expect(isToManyRelationshipField(userFields.widgets)).toBe(true)
66
+ expect(isToManyRelationshipField(userFields.manager)).toBe(false)
67
+ expect(isToManyRelationshipField(userFields.name)).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe('buildRelationshipCountSelect', () => {
72
+ it("folds the related list's query filter into each to-many count and omits denied/to-one", async () => {
73
+ const config = makeConfig()
74
+ const select = await buildRelationshipCountSelect(
75
+ config.lists.User,
76
+ { session: null, context: makeContext() },
77
+ config,
78
+ )
79
+ // `posts` → access-scoped where; `widgets` → denied (omitted); `manager` →
80
+ // to-one (never counted).
81
+ expect(select).toEqual({ posts: { where: { status: { equals: 'published' } } } })
82
+ })
83
+
84
+ it('uses a bare `true` when the related list is fully readable', async () => {
85
+ const config: OpenSaasConfig = {
86
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
87
+ lists: {
88
+ Tag: list({ fields: { name: text() }, access: { operation: { query: () => true } } }),
89
+ Post: list({ fields: { title: text(), tags: relationship({ ref: 'Tag', many: true }) } }),
90
+ },
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
92
+ } as any
93
+ const select = await buildRelationshipCountSelect(
94
+ config.lists.Post,
95
+ { session: null, context: makeContext() },
96
+ config,
97
+ )
98
+ expect(select).toEqual({ tags: true })
99
+ })
100
+
101
+ it('returns undefined when there are no countable to-many relationships', async () => {
102
+ const config: OpenSaasConfig = {
103
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
104
+ lists: { Post: list({ fields: { title: text() } }) },
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
106
+ } as any
107
+ const select = await buildRelationshipCountSelect(
108
+ config.lists.Post,
109
+ { session: null, context: makeContext() },
110
+ config,
111
+ )
112
+ expect(select).toBeUndefined()
113
+ })
114
+ })
115
+
116
+ describe('resolveRelationshipCountFilters', () => {
117
+ const marker = (operator: string, value: number) => ({
118
+ posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator, value } },
119
+ })
120
+
121
+ it('returns the where unchanged when there are no count markers', async () => {
122
+ const config = makeConfig()
123
+ const where = { name: { contains: 'ada' } }
124
+ const resolved = await resolveRelationshipCountFilters(
125
+ where,
126
+ config.lists.User,
127
+ 'User',
128
+ { session: null, context: makeContext() },
129
+ config,
130
+ )
131
+ expect(resolved).toBe(where)
132
+ })
133
+
134
+ it('resolves a count marker to an access-scoped { id: { in } } via a single secured read', async () => {
135
+ const config = makeConfig()
136
+ // Two users; the secured read returns each with its access-scoped `_count`.
137
+ const findMany = vi.fn(async () => [
138
+ { id: 'u1', _count: { posts: 3 } },
139
+ { id: 'u2', _count: { posts: 7 } },
140
+ ])
141
+ const resolved = await resolveRelationshipCountFilters(
142
+ marker('gt', 5),
143
+ config.lists.User,
144
+ 'User',
145
+ { session: null, context: makeContext(findMany) },
146
+ config,
147
+ )
148
+ // Only u2 (7 > 5) matches; the read was access-scoped (published-only count).
149
+ expect(resolved).toEqual({ id: { in: ['u2'] } })
150
+ expect(findMany).toHaveBeenCalledTimes(1)
151
+ expect(findMany).toHaveBeenCalledWith({
152
+ include: { _count: { select: { posts: { where: { status: { equals: 'published' } } } } } },
153
+ })
154
+ })
155
+
156
+ it('includes zero-count rows for comparisons that admit zero (no complement bug)', async () => {
157
+ const config = makeConfig()
158
+ const findMany = vi.fn(async () => [
159
+ { id: 'u1', _count: { posts: 0 } },
160
+ { id: 'u2', _count: { posts: 4 } },
161
+ ])
162
+ const resolved = await resolveRelationshipCountFilters(
163
+ marker('lt', 1), // count < 1 → only the zero-count user
164
+ config.lists.User,
165
+ 'User',
166
+ { session: null, context: makeContext(findMany) },
167
+ config,
168
+ )
169
+ expect(resolved).toEqual({ id: { in: ['u1'] } })
170
+ })
171
+
172
+ it('resolves a denied related list without any query: count is always 0', async () => {
173
+ const config = makeConfig()
174
+ const findMany = vi.fn(async () => [])
175
+ // `widgets`' related list (Widget) is fully denied → every count is 0.
176
+ const where = { widgets: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } } }
177
+ const resolved = await resolveRelationshipCountFilters(
178
+ where,
179
+ config.lists.User,
180
+ 'User',
181
+ { session: null, context: makeContext(findMany) },
182
+ config,
183
+ )
184
+ // 0 is not > 5 → nothing matches, and no read was issued.
185
+ expect(resolved).toEqual({ id: { in: [] } })
186
+ expect(findMany).not.toHaveBeenCalled()
187
+ })
188
+
189
+ it('preserves sibling conditions, ANDing the resolved id constraint', async () => {
190
+ const config = makeConfig()
191
+ const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }])
192
+ const where = { AND: [{ name: { contains: 'ada' } }, marker('gte', 1)] }
193
+ const resolved = await resolveRelationshipCountFilters(
194
+ where,
195
+ config.lists.User,
196
+ 'User',
197
+ { session: null, context: makeContext(findMany) },
198
+ config,
199
+ )
200
+ expect(resolved).toEqual({ AND: [{ name: { contains: 'ada' } }, { id: { in: ['u2'] } }] })
201
+ })
202
+
203
+ it('reads with only the filtered `_count` include — no over-fetching `select` projection', async () => {
204
+ const config = makeConfig()
205
+ const findMany = vi.fn((_args: unknown) =>
206
+ Promise.resolve([{ id: 'u2', _count: { posts: 7 } }]),
207
+ )
208
+ await resolveRelationshipCountFilters(
209
+ marker('gt', 5),
210
+ config.lists.User,
211
+ 'User',
212
+ { session: null, context: makeContext(findMany) },
213
+ config,
214
+ )
215
+ // The secured read is issued with the access-scoped `_count` include and NO
216
+ // `select` — the secured findMany does not honour `select`, so forcing one
217
+ // would be an ignored no-op. Access-scoping lives entirely in `_count.where`.
218
+ expect(findMany).toHaveBeenCalledTimes(1)
219
+ const callArg = findMany.mock.calls[0][0]
220
+ expect(callArg).not.toHaveProperty('select')
221
+ expect(callArg).toEqual({
222
+ include: { _count: { select: { posts: { where: { status: { equals: 'published' } } } } } },
223
+ })
224
+ })
225
+
226
+ it('preserves a sibling condition co-present in the same member as the marker', async () => {
227
+ const config = makeConfig()
228
+ const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }])
229
+ // A single AND-member carrying BOTH a scalar condition and the count marker.
230
+ // The marker resolution must keep the co-present sibling, not replace the
231
+ // member wholesale (guards against a future filter-engine change that merges
232
+ // conditions into one member).
233
+ const where = {
234
+ AND: [
235
+ {
236
+ name: { contains: 'ada' },
237
+ posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } },
238
+ },
239
+ ],
240
+ }
241
+ const resolved = await resolveRelationshipCountFilters(
242
+ where,
243
+ config.lists.User,
244
+ 'User',
245
+ { session: null, context: makeContext(findMany) },
246
+ config,
247
+ )
248
+ expect(resolved).toEqual({ name: { contains: 'ada' }, id: { in: ['u2'] } })
249
+ })
250
+
251
+ it('ANDs the resolved id constraint with a co-present sibling id condition (no silent drop)', async () => {
252
+ const config = makeConfig()
253
+ const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }])
254
+ // Contrived: a member carrying its own `id` condition alongside the marker.
255
+ // Spreading would let one `id` overwrite the other; instead both are ANDed.
256
+ const where = {
257
+ id: { in: ['u1', 'u2'] },
258
+ posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } },
259
+ }
260
+ const resolved = await resolveRelationshipCountFilters(
261
+ where,
262
+ config.lists.User,
263
+ 'User',
264
+ { session: null, context: makeContext(findMany) },
265
+ config,
266
+ )
267
+ expect(resolved).toEqual({ AND: [{ id: { in: ['u1', 'u2'] } }, { id: { in: ['u2'] } }] })
268
+ })
269
+ })