@opensaas/stack-core 0.30.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 (127) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +410 -0
  3. package/dist/access/access-filter.d.ts.map +1 -1
  4. package/dist/access/access-filter.js +14 -1
  5. package/dist/access/access-filter.js.map +1 -1
  6. package/dist/access/access-filter.test.js +50 -0
  7. package/dist/access/access-filter.test.js.map +1 -1
  8. package/dist/access/field-access.js +6 -6
  9. package/dist/access/field-access.js.map +1 -1
  10. package/dist/access/multi-column-read-write.test.js +46 -12
  11. package/dist/access/multi-column-read-write.test.js.map +1 -1
  12. package/dist/access/relationship-count.d.ts +60 -0
  13. package/dist/access/relationship-count.d.ts.map +1 -0
  14. package/dist/access/relationship-count.js +233 -0
  15. package/dist/access/relationship-count.js.map +1 -0
  16. package/dist/access/relationship-count.test.d.ts +2 -0
  17. package/dist/access/relationship-count.test.d.ts.map +1 -0
  18. package/dist/access/relationship-count.test.js +185 -0
  19. package/dist/access/relationship-count.test.js.map +1 -0
  20. package/dist/access/relationship-label-filter.d.ts +45 -0
  21. package/dist/access/relationship-label-filter.d.ts.map +1 -0
  22. package/dist/access/relationship-label-filter.js +93 -0
  23. package/dist/access/relationship-label-filter.js.map +1 -0
  24. package/dist/access/relationship-label-filter.test.d.ts +2 -0
  25. package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
  26. package/dist/access/relationship-label-filter.test.js +126 -0
  27. package/dist/access/relationship-label-filter.test.js.map +1 -0
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/nav-count.d.ts +34 -0
  31. package/dist/config/nav-count.d.ts.map +1 -0
  32. package/dist/config/nav-count.js +71 -0
  33. package/dist/config/nav-count.js.map +1 -0
  34. package/dist/config/types.d.ts +305 -4
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/context/hook-pipeline.d.ts.map +1 -1
  37. package/dist/context/hook-pipeline.js +11 -1
  38. package/dist/context/hook-pipeline.js.map +1 -1
  39. package/dist/context/index.d.ts +28 -0
  40. package/dist/context/index.d.ts.map +1 -1
  41. package/dist/context/index.js +205 -0
  42. package/dist/context/index.js.map +1 -1
  43. package/dist/context/nested-operations.d.ts.map +1 -1
  44. package/dist/context/nested-operations.js +7 -1
  45. package/dist/context/nested-operations.js.map +1 -1
  46. package/dist/extend.d.ts +1 -0
  47. package/dist/extend.d.ts.map +1 -1
  48. package/dist/fields/index.d.ts +1 -1
  49. package/dist/fields/index.d.ts.map +1 -1
  50. package/dist/fields/index.js +166 -0
  51. package/dist/fields/index.js.map +1 -1
  52. package/dist/filter/collect.d.ts +32 -0
  53. package/dist/filter/collect.d.ts.map +1 -0
  54. package/dist/filter/collect.js +60 -0
  55. package/dist/filter/collect.js.map +1 -0
  56. package/dist/filter/filter.test.d.ts +2 -0
  57. package/dist/filter/filter.test.d.ts.map +1 -0
  58. package/dist/filter/filter.test.js +348 -0
  59. package/dist/filter/filter.test.js.map +1 -0
  60. package/dist/filter/index.d.ts +7 -0
  61. package/dist/filter/index.d.ts.map +1 -0
  62. package/dist/filter/index.js +16 -0
  63. package/dist/filter/index.js.map +1 -0
  64. package/dist/filter/map.d.ts +18 -0
  65. package/dist/filter/map.d.ts.map +1 -0
  66. package/dist/filter/map.js +60 -0
  67. package/dist/filter/map.js.map +1 -0
  68. package/dist/filter/parse.d.ts +33 -0
  69. package/dist/filter/parse.d.ts.map +1 -0
  70. package/dist/filter/parse.js +103 -0
  71. package/dist/filter/parse.js.map +1 -0
  72. package/dist/filter/serialize.d.ts +25 -0
  73. package/dist/filter/serialize.d.ts.map +1 -0
  74. package/dist/filter/serialize.js +68 -0
  75. package/dist/filter/serialize.js.map +1 -0
  76. package/dist/filter/serialize.test.d.ts +2 -0
  77. package/dist/filter/serialize.test.d.ts.map +1 -0
  78. package/dist/filter/serialize.test.js +83 -0
  79. package/dist/filter/serialize.test.js.map +1 -0
  80. package/dist/filter/types.d.ts +122 -0
  81. package/dist/filter/types.d.ts.map +1 -0
  82. package/dist/filter/types.js +24 -0
  83. package/dist/filter/types.js.map +1 -0
  84. package/dist/hooks/index.d.ts +30 -0
  85. package/dist/hooks/index.d.ts.map +1 -1
  86. package/dist/hooks/index.js +79 -59
  87. package/dist/hooks/index.js.map +1 -1
  88. package/dist/index.d.ts +6 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +19 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/internal.d.ts +1 -0
  93. package/dist/internal.d.ts.map +1 -1
  94. package/dist/internal.js +4 -0
  95. package/dist/internal.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/access/access-filter.test.ts +79 -0
  98. package/src/access/access-filter.ts +15 -4
  99. package/src/access/field-access.ts +6 -6
  100. package/src/access/multi-column-read-write.test.ts +68 -19
  101. package/src/access/relationship-count.test.ts +269 -0
  102. package/src/access/relationship-count.ts +311 -0
  103. package/src/access/relationship-label-filter.test.ts +177 -0
  104. package/src/access/relationship-label-filter.ts +140 -0
  105. package/src/config/index.ts +6 -0
  106. package/src/config/nav-count.ts +85 -0
  107. package/src/config/types.ts +319 -1
  108. package/src/context/hook-pipeline.ts +19 -0
  109. package/src/context/index.ts +285 -3
  110. package/src/context/nested-operations.ts +22 -0
  111. package/src/extend.ts +9 -0
  112. package/src/fields/index.ts +168 -0
  113. package/src/filter/collect.ts +72 -0
  114. package/src/filter/filter.test.ts +400 -0
  115. package/src/filter/index.ts +25 -0
  116. package/src/filter/map.ts +68 -0
  117. package/src/filter/parse.ts +106 -0
  118. package/src/filter/serialize.test.ts +115 -0
  119. package/src/filter/serialize.ts +75 -0
  120. package/src/filter/types.ts +120 -0
  121. package/src/hooks/index.ts +87 -57
  122. package/src/index.ts +54 -0
  123. package/src/internal.ts +5 -0
  124. package/tests/context.test.ts +690 -0
  125. package/tests/multi-column-validation-ordering.test.ts +305 -0
  126. package/tests/nav-count.test.ts +189 -0
  127. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ })
@@ -0,0 +1,311 @@
1
+ import type { Session, AccessContext, PrismaFilter } from './types.js'
2
+ import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
3
+ import type { FilterOperator, RelationshipCountFilterMarker } from '../filter/types.js'
4
+ import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
5
+ import { checkAccess, getRelatedListConfig } from './engine.js'
6
+ import { getDbKey } from '../lib/case-utils.js'
7
+
8
+ /**
9
+ * Access-scoped to-many relationship counts for the admin list view (issue
10
+ * #732).
11
+ *
12
+ * A to-many relationship column shows the count of the related rows the session
13
+ * may see — fetched in the SAME query as the row via Prisma's filtered
14
+ * `_count` (`_count: { select: { orders: { where: <access filter> } } }`), so no
15
+ * per-row query is issued and the count can never include rows the session
16
+ * 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
+ * `buildIncludeWithAccessControl` folds it into relation includes.
19
+ *
20
+ * It also resolves the count Filter spec's markers: Prisma cannot compare a
21
+ * relation count in a `where`, so a to-many relationship's Filter spec emits a
22
+ * {@link RELATIONSHIP_COUNT_FILTER_KEY} marker that
23
+ * {@link resolveRelationshipCountFilters} turns into an access-scoped
24
+ * `{ id: { in } }` before the query runs — never leaking counts of related rows
25
+ * the session cannot see.
26
+ */
27
+
28
+ type CountArgs = {
29
+ session: Session | null
30
+ context: AccessContext
31
+ }
32
+
33
+ /**
34
+ * Whether a field is a to-many relationship — the only field kind that carries a
35
+ * relationship count (a to-one relationship has at most one related row).
36
+ */
37
+ export function isToManyRelationshipField(field: FieldConfig | undefined): boolean {
38
+ return (
39
+ field?.type === 'relationship' &&
40
+ 'many' in field &&
41
+ field.many === true &&
42
+ 'ref' in field &&
43
+ typeof field.ref === 'string' &&
44
+ field.ref.length > 0
45
+ )
46
+ }
47
+
48
+ /** The per-relation entry the count `_count.select` uses for one relationship. */
49
+ type CountAccessEntry =
50
+ | { kind: 'all' } // related list fully readable → count every related row
51
+ | { kind: 'scoped'; where: PrismaFilter } // count only rows matching the access filter
52
+ | { kind: 'denied' } // related list not readable at all → count is always 0
53
+
54
+ /**
55
+ * Resolve the related list's operation-level `query` access for one to-many
56
+ * relationship into the entry its `_count` select needs. Returns `null` when the
57
+ * field is not a resolvable to-many relationship.
58
+ */
59
+ async function relationshipCountAccessEntry(
60
+ field: FieldConfig | undefined,
61
+ args: CountArgs,
62
+ config: OpenSaasConfig,
63
+ ): Promise<CountAccessEntry | null> {
64
+ if (!field || !isToManyRelationshipField(field) || !('ref' in field)) {
65
+ return null
66
+ }
67
+ const ref = field.ref
68
+ if (typeof ref !== 'string') return null
69
+ const related = getRelatedListConfig(ref, config)
70
+ if (!related) return null
71
+
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' }
78
+ }
79
+
80
+ /**
81
+ * Build the object to place at `include._count.select` for a list's to-many
82
+ * relationships, with each related list's `query` access folded into the
83
+ * per-relation `where` so the returned counts are access-scoped. A relationship
84
+ * whose related list is fully denied is omitted (its count renders as 0). Returns
85
+ * `undefined` when the list has no countable to-many relationships.
86
+ */
87
+ export async function buildRelationshipCountSelect(
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
89
+ listConfig: ListConfig<any>,
90
+ args: CountArgs,
91
+ config: OpenSaasConfig,
92
+ ): Promise<Record<string, unknown> | undefined> {
93
+ const select: Record<string, unknown> = {}
94
+ for (const [fieldName, field] of Object.entries(listConfig.fields)) {
95
+ const entry = await relationshipCountAccessEntry(field, args, config)
96
+ if (entry === null || entry.kind === 'denied') continue
97
+ select[fieldName] = entry.kind === 'scoped' ? { where: entry.where } : true
98
+ }
99
+ return Object.keys(select).length > 0 ? select : undefined
100
+ }
101
+
102
+ /** Read a to-many relationship's count off a fetched row's `_count` payload. */
103
+ function readRelationshipCount(row: Record<string, unknown>, fieldName: string): number {
104
+ const counts = row._count
105
+ if (counts && typeof counts === 'object') {
106
+ const value = (counts as Record<string, unknown>)[fieldName]
107
+ if (typeof value === 'number') return value
108
+ }
109
+ return 0
110
+ }
111
+
112
+ /** Whether a count satisfies a Filter operator/value comparison. */
113
+ function matchesCount(count: number, operator: FilterOperator, value: number): boolean {
114
+ switch (operator) {
115
+ case 'eq':
116
+ return count === value
117
+ case 'gt':
118
+ return count > value
119
+ case 'gte':
120
+ return count >= value
121
+ case 'lt':
122
+ return count < value
123
+ case 'lte':
124
+ return count <= value
125
+ }
126
+ }
127
+
128
+ /** Minimal shape the resolver needs off the secured `context.db` delegate. */
129
+ interface CountFindManyDelegate {
130
+ findMany: (args: { include: { _count: { select: Record<string, unknown> } } }) => Promise<unknown>
131
+ }
132
+
133
+ function asCountDelegate(value: unknown): CountFindManyDelegate | null {
134
+ if (value && typeof value === 'object' && 'findMany' in value) {
135
+ const candidate = value as { findMany?: unknown }
136
+ if (typeof candidate.findMany === 'function') {
137
+ return value as CountFindManyDelegate
138
+ }
139
+ }
140
+ return null
141
+ }
142
+
143
+ /** Extract the `RelationshipCountFilterMarker` from a condition value, if present. */
144
+ function readCountMarker(value: unknown): RelationshipCountFilterMarker | null {
145
+ if (!value || typeof value !== 'object') return null
146
+ const marker = (value as Record<string, unknown>)[RELATIONSHIP_COUNT_FILTER_KEY]
147
+ if (!marker || typeof marker !== 'object') return null
148
+ const { operator, value: n } = marker as { operator?: unknown; value?: unknown }
149
+ if (
150
+ (operator === 'eq' ||
151
+ operator === 'gt' ||
152
+ operator === 'gte' ||
153
+ operator === 'lt' ||
154
+ operator === 'lte') &&
155
+ typeof n === 'number'
156
+ ) {
157
+ return { operator, value: n }
158
+ }
159
+ return null
160
+ }
161
+
162
+ /**
163
+ * Resolve one to-many relationship count-filter marker into a Prisma `where`
164
+ * fragment constraining the parent by id. Runs a single access-scoped read
165
+ * through the SECURED context — never a raw/unscoped query and never a per-row
166
+ * query — computing the access-visible count per parent and keeping the ids
167
+ * whose count satisfies the comparison. A fully-denied related list makes every
168
+ * count 0, resolved without any query.
169
+ */
170
+ async function resolveOneCountFilter(
171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
172
+ listConfig: ListConfig<any>,
173
+ listKey: string,
174
+ fieldName: string,
175
+ marker: RelationshipCountFilterMarker,
176
+ args: CountArgs,
177
+ config: OpenSaasConfig,
178
+ ): Promise<PrismaFilter> {
179
+ const field = listConfig.fields[fieldName]
180
+ const entry = await relationshipCountAccessEntry(field, args, config)
181
+
182
+ // Not resolvable (shouldn't happen for a marker the spec emitted) → no-op.
183
+ if (entry === null) return {}
184
+
185
+ // Related list fully denied → every parent's access-visible count is 0.
186
+ if (entry.kind === 'denied') {
187
+ return matchesCount(0, marker.operator, marker.value) ? {} : { id: { in: [] } }
188
+ }
189
+
190
+ const delegate = asCountDelegate(args.context.db[getDbKey(listKey)])
191
+ if (!delegate) return {}
192
+
193
+ const countSelect = entry.kind === 'scoped' ? { where: entry.where } : true
194
+ // This over-fetches the parent's scalar columns — it reads only `id` + `_count`
195
+ // per row yet materialises every scalar. It is left un-narrowed on purpose: the
196
+ // secured `context.db` `findMany` does NOT honour Prisma `select` (it
197
+ // warns-and-ignores it and returns the full access-filtered record — see
198
+ // `warnIfSelectIgnored` in context/index.ts and the "Narrowing Reads" note in
199
+ // packages/core/CLAUDE.md). The only supported narrowing is `include`/fragment
200
+ // `query`, neither of which can drop scalar columns. So adding
201
+ // `select: { id: true, _count: {...} }` here would be a silent no-op that also
202
+ // trips the ignore-warning, not a real projection. The count stays access-scoped
203
+ // via the filtered `_count` include below regardless; trimming the projection
204
+ // would first require the read pipeline to honour `select`.
205
+ const rows = await delegate.findMany({
206
+ include: { _count: { select: { [fieldName]: countSelect } } },
207
+ })
208
+
209
+ const matchingIds: string[] = []
210
+ if (Array.isArray(rows)) {
211
+ for (const row of rows) {
212
+ if (!row || typeof row !== 'object') continue
213
+ const record = row as Record<string, unknown>
214
+ if (matchesCount(readRelationshipCount(record, fieldName), marker.operator, marker.value)) {
215
+ matchingIds.push(String(record.id))
216
+ }
217
+ }
218
+ }
219
+ return { id: { in: matchingIds } }
220
+ }
221
+
222
+ /**
223
+ * Merge a filter-member's non-resolved sibling conditions with a resolved
224
+ * access-scoped fragment (e.g. a count marker's `{ id: { in } }`, or a to-one
225
+ * label filter's access-scoped `is`). With no siblings (the guaranteed case
226
+ * today) this is just the resolved fragment. When a sibling shares a key with
227
+ * the resolved fragment — a contrived case that cannot arise under the current
228
+ * one-condition-per-member invariant — both are ANDed so neither condition is
229
+ * silently lost. Shared by both relationship resolvers in this module and in
230
+ * `relationship-label-filter.ts`.
231
+ */
232
+ export function mergeResolvedMember(
233
+ siblings: Record<string, unknown>,
234
+ resolved: Record<string, unknown>,
235
+ ): Record<string, unknown> {
236
+ if (Object.keys(siblings).length === 0) return resolved
237
+ const collides = Object.keys(resolved).some((key) => key in siblings)
238
+ return collides ? { AND: [siblings, resolved] } : { ...siblings, ...resolved }
239
+ }
240
+
241
+ /**
242
+ * Replace any to-many relationship count-filter markers in a filter `where` with
243
+ * access-scoped `{ id: { in } }` fragments. Markers only ever appear as
244
+ * top-level AND members (the pure filter engine pushes each field condition into
245
+ * the top-level AND, and never nests a marker inside the free-text OR), so this
246
+ * walks only the top level. Returns the `where` unchanged when it contains no
247
+ * markers, so lists without count filters pay nothing.
248
+ */
249
+ export async function resolveRelationshipCountFilters(
250
+ where: Record<string, unknown> | undefined,
251
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
252
+ listConfig: ListConfig<any>,
253
+ listKey: string,
254
+ args: CountArgs,
255
+ config: OpenSaasConfig,
256
+ ): Promise<Record<string, unknown> | undefined> {
257
+ if (!where) return where
258
+
259
+ const andValue = where.AND
260
+ const members: Array<Record<string, unknown>> = Array.isArray(andValue)
261
+ ? (andValue as Array<Record<string, unknown>>)
262
+ : [where]
263
+
264
+ const findMarker = (
265
+ member: Record<string, unknown>,
266
+ ): { field: string; marker: RelationshipCountFilterMarker } | null => {
267
+ for (const key of Object.keys(member)) {
268
+ if (!isToManyRelationshipField(listConfig.fields[key])) continue
269
+ const marker = readCountMarker(member[key])
270
+ if (marker) return { field: key, marker }
271
+ }
272
+ return null
273
+ }
274
+
275
+ if (!members.some((member) => findMarker(member) !== null)) {
276
+ return where
277
+ }
278
+
279
+ const resolvedMembers: Array<Record<string, unknown>> = []
280
+ for (const member of members) {
281
+ const found = findMarker(member)
282
+ if (!found) {
283
+ resolvedMembers.push(member)
284
+ continue
285
+ }
286
+ const resolved = await resolveOneCountFilter(
287
+ listConfig,
288
+ listKey,
289
+ found.field,
290
+ found.marker,
291
+ args,
292
+ config,
293
+ )
294
+ // Preserve any sibling conditions co-present on this member rather than
295
+ // replacing it wholesale with the resolved `{ id: { in } }`. The filter engine
296
+ // currently guarantees each AND-member (and the no-AND single object) carries
297
+ // exactly one field condition, so `siblings` is empty today and this equals the
298
+ // previous wholesale replacement — but if a future engine change ever merged
299
+ // multiple conditions into one member, spreading keeps the marker's siblings
300
+ // from being silently dropped.
301
+ const siblings: Record<string, unknown> = { ...member }
302
+ delete siblings[found.field]
303
+ resolvedMembers.push(mergeResolvedMember(siblings, resolved))
304
+ }
305
+
306
+ // Drop no-op members (a fully-denied related list where 0 satisfies the
307
+ // comparison resolves to `{}` — matches everything, so it need not be ANDed).
308
+ const effective = resolvedMembers.filter((member) => Object.keys(member).length > 0)
309
+ if (effective.length === 0) return undefined
310
+ return effective.length === 1 ? effective[0] : { AND: effective }
311
+ }