@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +443 -0
- package/CLAUDE.md +4 -4
- 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 +206 -1
- 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/dist/mcp/handler.d.ts +11 -3
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +96 -20
- package/dist/mcp/handler.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 +286 -4
- 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/src/mcp/handler.ts +118 -28
- 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
|
@@ -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
|
|
123
|
+
describe('multi-column resolveInput (executeFieldResolveInputHooks) does NOT split (#789)', () => {
|
|
117
124
|
const fields = { media: multiColumnField() }
|
|
118
125
|
|
|
119
|
-
it('
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
})
|