@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +410 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +14 -1
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +50 -0
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/field-access.js +6 -6
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +46 -12
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.d.ts +60 -0
- package/dist/access/relationship-count.d.ts.map +1 -0
- package/dist/access/relationship-count.js +233 -0
- package/dist/access/relationship-count.js.map +1 -0
- package/dist/access/relationship-count.test.d.ts +2 -0
- package/dist/access/relationship-count.test.d.ts.map +1 -0
- package/dist/access/relationship-count.test.js +185 -0
- package/dist/access/relationship-count.test.js.map +1 -0
- package/dist/access/relationship-label-filter.d.ts +45 -0
- package/dist/access/relationship-label-filter.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.js +93 -0
- package/dist/access/relationship-label-filter.js.map +1 -0
- package/dist/access/relationship-label-filter.test.d.ts +2 -0
- package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.test.js +126 -0
- package/dist/access/relationship-label-filter.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/nav-count.d.ts +34 -0
- package/dist/config/nav-count.d.ts.map +1 -0
- package/dist/config/nav-count.js +71 -0
- package/dist/config/nav-count.js.map +1 -0
- package/dist/config/types.d.ts +305 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +11 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +28 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +205 -0
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +7 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/extend.d.ts +1 -0
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +166 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +32 -0
- package/dist/filter/collect.d.ts.map +1 -0
- package/dist/filter/collect.js +60 -0
- package/dist/filter/collect.js.map +1 -0
- package/dist/filter/filter.test.d.ts +2 -0
- package/dist/filter/filter.test.d.ts.map +1 -0
- package/dist/filter/filter.test.js +348 -0
- package/dist/filter/filter.test.js.map +1 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +16 -0
- package/dist/filter/index.js.map +1 -0
- package/dist/filter/map.d.ts +18 -0
- package/dist/filter/map.d.ts.map +1 -0
- package/dist/filter/map.js +60 -0
- package/dist/filter/map.js.map +1 -0
- package/dist/filter/parse.d.ts +33 -0
- package/dist/filter/parse.d.ts.map +1 -0
- package/dist/filter/parse.js +103 -0
- package/dist/filter/parse.js.map +1 -0
- package/dist/filter/serialize.d.ts +25 -0
- package/dist/filter/serialize.d.ts.map +1 -0
- package/dist/filter/serialize.js +68 -0
- package/dist/filter/serialize.js.map +1 -0
- package/dist/filter/serialize.test.d.ts +2 -0
- package/dist/filter/serialize.test.d.ts.map +1 -0
- package/dist/filter/serialize.test.js +83 -0
- package/dist/filter/serialize.test.js.map +1 -0
- package/dist/filter/types.d.ts +122 -0
- package/dist/filter/types.d.ts.map +1 -0
- package/dist/filter/types.js +24 -0
- package/dist/filter/types.js.map +1 -0
- package/dist/hooks/index.d.ts +30 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +79 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +79 -0
- package/src/access/access-filter.ts +15 -4
- package/src/access/field-access.ts +6 -6
- package/src/access/multi-column-read-write.test.ts +68 -19
- package/src/access/relationship-count.test.ts +269 -0
- package/src/access/relationship-count.ts +311 -0
- package/src/access/relationship-label-filter.test.ts +177 -0
- package/src/access/relationship-label-filter.ts +140 -0
- package/src/config/index.ts +6 -0
- package/src/config/nav-count.ts +85 -0
- package/src/config/types.ts +319 -1
- package/src/context/hook-pipeline.ts +19 -0
- package/src/context/index.ts +285 -3
- package/src/context/nested-operations.ts +22 -0
- package/src/extend.ts +9 -0
- package/src/fields/index.ts +168 -0
- package/src/filter/collect.ts +72 -0
- package/src/filter/filter.test.ts +400 -0
- package/src/filter/index.ts +25 -0
- package/src/filter/map.ts +68 -0
- package/src/filter/parse.ts +106 -0
- package/src/filter/serialize.test.ts +115 -0
- package/src/filter/serialize.ts +75 -0
- package/src/filter/types.ts +120 -0
- package/src/hooks/index.ts +87 -57
- package/src/index.ts +54 -0
- package/src/internal.ts +5 -0
- package/tests/context.test.ts +690 -0
- package/tests/multi-column-validation-ordering.test.ts +305 -0
- package/tests/nav-count.test.ts +189 -0
- 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
|
+
}
|