@opensaas/stack-core 0.33.0 → 0.35.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 +82 -0
- package/CLAUDE.md +4 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +12 -7
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +5 -1
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/depth-limits.d.ts +14 -0
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +14 -0
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/errors.d.ts +24 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +26 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +72 -17
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -0
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.test.js +1 -1
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +1 -1
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/types.d.ts +15 -7
- package/dist/access/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +73 -36
- package/dist/context/index.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +5 -4
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +5 -1
- package/src/access/access-filter.ts +12 -7
- package/src/access/depth-limits.ts +15 -0
- package/src/access/errors.ts +30 -0
- package/src/access/field-visibility.ts +87 -18
- package/src/access/index.ts +2 -0
- package/src/access/multi-column-read-write.test.ts +1 -1
- package/src/access/relationship-count.test.ts +1 -1
- package/src/access/relationship-label-filter.test.ts +1 -1
- package/src/access/types.ts +12 -5
- package/src/context/index.ts +99 -53
- package/src/context/write-pipeline.ts +5 -4
- package/src/index.ts +5 -0
- package/tests/access-relationships.test.ts +1 -1
- package/tests/bare-read-scalars.test.ts +147 -0
- package/tests/context.test.ts +2 -2
- package/tests/default-value-create.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +1 -1
- package/tests/nav-count.test.ts +2 -2
- package/tests/nested-access-and-hooks.test.ts +10 -0
- package/tests/resolve-chain.test.ts +411 -0
- package/tests/singleton.test.ts +83 -0
- package/tests/write-pipeline.test.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { virtual, text, relationship } from '../src/fields/index.js'
|
|
4
|
+
import { ResolveOutputCycleError } from '../src/access/index.js'
|
|
5
|
+
import { RESOLVE_CHAIN_MAX_LENGTH } from '../src/access/depth-limits.js'
|
|
6
|
+
import type { OpenSaasConfig } from '../src/config/types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression coverage for issue #844 / ADR-0023: a `resolveOutput` hook that
|
|
10
|
+
* issues its own read used to be able to recurse without bound, because the
|
|
11
|
+
* only guard was a boolean read of a mutable counter shared by the whole
|
|
12
|
+
* request. The fix is a resolve chain — an ordered list of `(list, field)`
|
|
13
|
+
* pairs, extended by deriving a NEW context per hook invocation rather than
|
|
14
|
+
* mutating one shared value — with a cycle guard that refuses to re-enter a
|
|
15
|
+
* pair already on the chain, and a separate, non-fatal cost cap.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
function makeModel(): any {
|
|
20
|
+
return { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), count: vi.fn() }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('resolve chain — cycle guard terminates hook-issued reads (#844)', () => {
|
|
24
|
+
it('two lists with no relationship fields, whose virtual hooks read each other, throw the cycle error instead of recursing', async () => {
|
|
25
|
+
const config: OpenSaasConfig = {
|
|
26
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
27
|
+
lists: {
|
|
28
|
+
Ping: {
|
|
29
|
+
fields: {
|
|
30
|
+
label: virtual({
|
|
31
|
+
type: 'string',
|
|
32
|
+
hooks: {
|
|
33
|
+
resolveOutput: async ({ context }) => {
|
|
34
|
+
await context.db.pong.findMany({})
|
|
35
|
+
return 'ping'
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
access: { operation: { query: () => true } },
|
|
41
|
+
},
|
|
42
|
+
Pong: {
|
|
43
|
+
fields: {
|
|
44
|
+
label: virtual({
|
|
45
|
+
type: 'string',
|
|
46
|
+
hooks: {
|
|
47
|
+
resolveOutput: async ({ context }) => {
|
|
48
|
+
await context.db.ping.findMany({})
|
|
49
|
+
return 'pong'
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
access: { operation: { query: () => true } },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
const prisma: any = { ping: makeModel(), pong: makeModel() }
|
|
61
|
+
prisma.ping.findMany.mockResolvedValue([{ id: 'p1' }])
|
|
62
|
+
prisma.pong.findMany.mockResolvedValue([{ id: 'q1' }])
|
|
63
|
+
|
|
64
|
+
const context = await getContext(config, prisma, null)
|
|
65
|
+
|
|
66
|
+
await expect(context.db.ping.findMany({})).rejects.toThrow(ResolveOutputCycleError)
|
|
67
|
+
|
|
68
|
+
// The cycle must be caught within a handful of hops, never left to grow
|
|
69
|
+
// toward the hundreds of queries the un-bounded chain produced (#844).
|
|
70
|
+
const totalCalls =
|
|
71
|
+
prisma.ping.findMany.mock.calls.length + prisma.pong.findMany.mock.calls.length
|
|
72
|
+
expect(totalCalls).toBeLessThan(10)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('names every (list, field) pair on the chain in order, including the repeat', async () => {
|
|
76
|
+
const config: OpenSaasConfig = {
|
|
77
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
78
|
+
lists: {
|
|
79
|
+
Ping: {
|
|
80
|
+
fields: {
|
|
81
|
+
label: virtual({
|
|
82
|
+
type: 'string',
|
|
83
|
+
hooks: {
|
|
84
|
+
resolveOutput: async ({ context }) => {
|
|
85
|
+
await context.db.pong.findMany({})
|
|
86
|
+
return 'ping'
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
access: { operation: { query: () => true } },
|
|
92
|
+
},
|
|
93
|
+
Pong: {
|
|
94
|
+
fields: {
|
|
95
|
+
label: virtual({
|
|
96
|
+
type: 'string',
|
|
97
|
+
hooks: {
|
|
98
|
+
resolveOutput: async ({ context }) => {
|
|
99
|
+
await context.db.ping.findMany({})
|
|
100
|
+
return 'pong'
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
access: { operation: { query: () => true } },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
111
|
+
const prisma: any = { ping: makeModel(), pong: makeModel() }
|
|
112
|
+
prisma.ping.findMany.mockResolvedValue([{ id: 'p1' }])
|
|
113
|
+
prisma.pong.findMany.mockResolvedValue([{ id: 'q1' }])
|
|
114
|
+
|
|
115
|
+
const context = await getContext(config, prisma, null)
|
|
116
|
+
|
|
117
|
+
let caught: unknown
|
|
118
|
+
try {
|
|
119
|
+
await context.db.ping.findMany({})
|
|
120
|
+
} catch (err) {
|
|
121
|
+
caught = err
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
expect(caught).toBeInstanceOf(ResolveOutputCycleError)
|
|
125
|
+
const err = caught as ResolveOutputCycleError
|
|
126
|
+
expect(err.chain).toEqual([
|
|
127
|
+
{ listKey: 'Ping', fieldKey: 'label' },
|
|
128
|
+
{ listKey: 'Pong', fieldKey: 'label' },
|
|
129
|
+
{ listKey: 'Ping', fieldKey: 'label' },
|
|
130
|
+
])
|
|
131
|
+
expect(err.message).toBe(
|
|
132
|
+
'resolveOutput cycle detected: Ping.label → Pong.label → Ping.label. A hook that ' +
|
|
133
|
+
're-enters a (list, field) pair already on its own resolve chain cannot terminate, so ' +
|
|
134
|
+
'the read is refused rather than left to recurse until the process runs out of memory. ' +
|
|
135
|
+
'Restructure the hooks so the read does not loop back into itself.',
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("no longer reproduces the reporter's 3-list cyclic schema (User → Account → Student) on a bare hook-issued read (#848, ADR-0024)", async () => {
|
|
140
|
+
// Sketch matches the issue's minimal reproduction: User.name reads
|
|
141
|
+
// Account, whose Student rows' own virtual field reads Account again. The
|
|
142
|
+
// hook's read is BARE (no `include`), so under ADR-0024 it fetches
|
|
143
|
+
// Account's own columns only — `students` is never fetched, the walk into
|
|
144
|
+
// `Student.label` never happens, and the cycle cannot form.
|
|
145
|
+
const config: OpenSaasConfig = {
|
|
146
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
147
|
+
lists: {
|
|
148
|
+
User: {
|
|
149
|
+
fields: {
|
|
150
|
+
accounts: relationship({ ref: 'Account.user', many: true }),
|
|
151
|
+
name: virtual({
|
|
152
|
+
type: 'string',
|
|
153
|
+
hooks: {
|
|
154
|
+
resolveOutput: async ({ item, context }) => {
|
|
155
|
+
const [a] = await context.db.account.findMany({
|
|
156
|
+
where: { userId: item.id },
|
|
157
|
+
take: 1,
|
|
158
|
+
})
|
|
159
|
+
return `${(a as { firstName?: string } | undefined)?.firstName}`
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
access: { operation: { query: () => true } },
|
|
165
|
+
},
|
|
166
|
+
Account: {
|
|
167
|
+
fields: {
|
|
168
|
+
firstName: text(),
|
|
169
|
+
user: relationship({ ref: 'User.accounts' }),
|
|
170
|
+
students: relationship({ ref: 'Student.account', many: true }),
|
|
171
|
+
},
|
|
172
|
+
access: { operation: { query: () => true } },
|
|
173
|
+
},
|
|
174
|
+
Student: {
|
|
175
|
+
fields: {
|
|
176
|
+
account: relationship({ ref: 'Account.students' }),
|
|
177
|
+
label: virtual({
|
|
178
|
+
type: 'string',
|
|
179
|
+
hooks: {
|
|
180
|
+
resolveOutput: async ({ item, context }) => {
|
|
181
|
+
const a = await context.db.account.findUnique({
|
|
182
|
+
where: { id: item.accountId },
|
|
183
|
+
})
|
|
184
|
+
return `${(a as { firstName?: string } | undefined)?.firstName}`
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
access: { operation: { query: () => true } },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
195
|
+
const prisma: any = { user: makeModel(), account: makeModel(), student: makeModel() }
|
|
196
|
+
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }])
|
|
197
|
+
// Mirrors real Prisma semantics: a relation key is only present on the
|
|
198
|
+
// returned row when the caller actually asked for it via `include`. The
|
|
199
|
+
// hook's read never does, so `students` (and `user`) never appear here —
|
|
200
|
+
// that is the mechanism that breaks the cycle under ADR-0024.
|
|
201
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
202
|
+
prisma.account.findMany.mockImplementation((args: any = {}) => {
|
|
203
|
+
const row: Record<string, unknown> = { id: 'a1', firstName: 'Ann' }
|
|
204
|
+
if (args.include?.students) row.students = [{ id: 's1', accountId: 'a1' }]
|
|
205
|
+
if (args.include?.user) row.user = { id: 'u1' }
|
|
206
|
+
return Promise.resolve([row])
|
|
207
|
+
})
|
|
208
|
+
// `context.db.<list>.findUnique` is implemented via the Prisma model's
|
|
209
|
+
// `findFirst` (see `createFindUnique` in `context/index.ts`), not its
|
|
210
|
+
// `findUnique` — mock the method actually called.
|
|
211
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
212
|
+
prisma.account.findFirst.mockImplementation((args: any = {}) => {
|
|
213
|
+
const row: Record<string, unknown> = { id: 'a1', firstName: 'Ann' }
|
|
214
|
+
if (args.include?.students) row.students = [{ id: 's1', accountId: 'a1' }]
|
|
215
|
+
if (args.include?.user) row.user = { id: 'u1' }
|
|
216
|
+
return Promise.resolve(row)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const context = await getContext(config, prisma, null)
|
|
220
|
+
|
|
221
|
+
// Must settle (not hang or throw) — the bare hook-issued read never
|
|
222
|
+
// fetches `students`, so `Student.label` never fires and there is no
|
|
223
|
+
// cycle to detect.
|
|
224
|
+
const result = await context.db.user.findMany({})
|
|
225
|
+
expect(result).toEqual([{ id: 'u1', name: 'Ann' }])
|
|
226
|
+
|
|
227
|
+
// Only the two reads the hook actually issues — no unbounded recursion.
|
|
228
|
+
const totalCalls =
|
|
229
|
+
prisma.user.findMany.mock.calls.length +
|
|
230
|
+
prisma.account.findMany.mock.calls.length +
|
|
231
|
+
prisma.account.findFirst.mock.calls.length
|
|
232
|
+
expect(totalCalls).toBe(2)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('resolve chain — cost cap is a warning, not a denial (#844)', () => {
|
|
237
|
+
it('an acyclic chain longer than RESOLVE_CHAIN_MAX_LENGTH omits the field and warns once, without throwing', async () => {
|
|
238
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
239
|
+
|
|
240
|
+
// A straight-line chain of distinct lists L0 → L1 → … so no (list, field)
|
|
241
|
+
// pair ever repeats — this chain is acyclic and would terminate on its
|
|
242
|
+
// own; it only needs to be capped as a cost limit.
|
|
243
|
+
const listCount = RESOLVE_CHAIN_MAX_LENGTH + 3
|
|
244
|
+
const lists: OpenSaasConfig['lists'] = {}
|
|
245
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
246
|
+
const prisma: any = {}
|
|
247
|
+
for (let i = 0; i < listCount; i++) {
|
|
248
|
+
const listKey = `L${i}`
|
|
249
|
+
const dbKey = `l${i}`
|
|
250
|
+
const nextDbKey = `l${i + 1}`
|
|
251
|
+
lists[listKey] = {
|
|
252
|
+
fields: {
|
|
253
|
+
next: virtual({
|
|
254
|
+
type: 'string',
|
|
255
|
+
hooks: {
|
|
256
|
+
resolveOutput:
|
|
257
|
+
i < listCount - 1
|
|
258
|
+
? async ({ context }) => {
|
|
259
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
260
|
+
await (context.db as any)[nextDbKey].findMany({})
|
|
261
|
+
return 'ok'
|
|
262
|
+
}
|
|
263
|
+
: () => 'leaf',
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
},
|
|
267
|
+
access: { operation: { query: () => true } },
|
|
268
|
+
}
|
|
269
|
+
prisma[dbKey] = makeModel()
|
|
270
|
+
prisma[dbKey].findMany.mockResolvedValue([{ id: `${dbKey}-row` }])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const config: OpenSaasConfig = {
|
|
274
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
275
|
+
lists,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const context = await getContext(config, prisma, null)
|
|
279
|
+
|
|
280
|
+
// Does NOT throw — a cap hit is a cost limit, never a correctness denial.
|
|
281
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
282
|
+
const result = await (context.db as any).l0.findMany({})
|
|
283
|
+
expect(result).toBeTruthy()
|
|
284
|
+
|
|
285
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
286
|
+
expect(warnSpy.mock.calls[0][0]).toContain('RESOLVE_CHAIN_MAX_LENGTH')
|
|
287
|
+
|
|
288
|
+
// Nothing past the cap is ever queried — the chain simply stops growing.
|
|
289
|
+
for (let i = RESOLVE_CHAIN_MAX_LENGTH + 1; i < listCount; i++) {
|
|
290
|
+
expect(prisma[`l${i}`].findMany).not.toHaveBeenCalled()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
warnSpy.mockRestore()
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('resolve chain — concurrent hook invocations are isolated (#844)', () => {
|
|
298
|
+
it('sibling rows in a to-many read each observe their own chain of length 1, not a racing shared counter', async () => {
|
|
299
|
+
const observedLengths: number[] = []
|
|
300
|
+
|
|
301
|
+
const config: OpenSaasConfig = {
|
|
302
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
303
|
+
lists: {
|
|
304
|
+
Widget: {
|
|
305
|
+
fields: {
|
|
306
|
+
tag: virtual({
|
|
307
|
+
type: 'string',
|
|
308
|
+
hooks: {
|
|
309
|
+
resolveOutput: async ({ context }) => {
|
|
310
|
+
// Stagger completion so the three hook invocations
|
|
311
|
+
// genuinely interleave rather than running back-to-back.
|
|
312
|
+
await new Promise((resolve) => setTimeout(resolve, Math.random() * 5))
|
|
313
|
+
observedLengths.push(context._resolveOutputChain.length)
|
|
314
|
+
return 'tag'
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
},
|
|
319
|
+
access: { operation: { query: () => true } },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
325
|
+
const prisma: any = { widget: makeModel() }
|
|
326
|
+
prisma.widget.findMany.mockResolvedValue([{ id: 'w1' }, { id: 'w2' }, { id: 'w3' }])
|
|
327
|
+
|
|
328
|
+
const context = await getContext(config, prisma, null)
|
|
329
|
+
await context.db.widget.findMany({})
|
|
330
|
+
|
|
331
|
+
expect(observedLengths.sort()).toEqual([1, 1, 1])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('an unrelated top-level read in flight alongside a hook still gets its full explicit include scoped', async () => {
|
|
335
|
+
let releaseSlowHook: () => void = () => {}
|
|
336
|
+
|
|
337
|
+
const config: OpenSaasConfig = {
|
|
338
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
339
|
+
lists: {
|
|
340
|
+
Slow: {
|
|
341
|
+
fields: {
|
|
342
|
+
tag: virtual({
|
|
343
|
+
type: 'string',
|
|
344
|
+
hooks: {
|
|
345
|
+
resolveOutput: async () => {
|
|
346
|
+
await new Promise<void>((resolve) => {
|
|
347
|
+
releaseSlowHook = resolve
|
|
348
|
+
})
|
|
349
|
+
return 'tag'
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
},
|
|
354
|
+
access: { operation: { query: () => true } },
|
|
355
|
+
},
|
|
356
|
+
Fast: {
|
|
357
|
+
fields: {
|
|
358
|
+
name: text(),
|
|
359
|
+
child: relationship({ ref: 'FastChild' }),
|
|
360
|
+
},
|
|
361
|
+
access: { operation: { query: () => true } },
|
|
362
|
+
},
|
|
363
|
+
FastChild: {
|
|
364
|
+
fields: {
|
|
365
|
+
label: text(),
|
|
366
|
+
grandchild: relationship({ ref: 'FastGrandchild' }),
|
|
367
|
+
},
|
|
368
|
+
access: { operation: { query: () => true } },
|
|
369
|
+
},
|
|
370
|
+
FastGrandchild: {
|
|
371
|
+
fields: { value: text() },
|
|
372
|
+
access: { operation: { query: () => true } },
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
378
|
+
const prisma: any = { slow: makeModel(), fast: makeModel() }
|
|
379
|
+
prisma.slow.findMany.mockResolvedValue([{ id: 'sl1' }])
|
|
380
|
+
prisma.fast.findMany.mockResolvedValue([{ id: 'f1' }])
|
|
381
|
+
|
|
382
|
+
const context = await getContext(config, prisma, null)
|
|
383
|
+
|
|
384
|
+
// Start the slow read — it blocks inside Slow.tag's hook until released.
|
|
385
|
+
const slowPromise = context.db.slow.findMany({})
|
|
386
|
+
|
|
387
|
+
// Let the slow hook actually start (and derive its context) before racing
|
|
388
|
+
// the unrelated read against it.
|
|
389
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
390
|
+
|
|
391
|
+
// While the slow hook is still in flight, issue a plain top-level read
|
|
392
|
+
// that has nothing to do with it. A bare read fetches scalars only
|
|
393
|
+
// (ADR-0024), so name `child` explicitly — a caller include naming a
|
|
394
|
+
// relation BARE (`true`, no nested include of its own) still picks up the
|
|
395
|
+
// access-controlled include for whatever lies beneath it.
|
|
396
|
+
const fastPromise = context.db.fast.findMany({ include: { child: true } })
|
|
397
|
+
|
|
398
|
+
await fastPromise
|
|
399
|
+
releaseSlowHook()
|
|
400
|
+
await slowPromise
|
|
401
|
+
|
|
402
|
+
// The unrelated read's access-controlled scoping must still descend two
|
|
403
|
+
// levels deep (child → grandchild), not collapse to a bare `{ child: true }`
|
|
404
|
+
// because it happened to run while a totally different read's hook was
|
|
405
|
+
// active — `insideResolveOutput` must stay scoped to the hook's OWN
|
|
406
|
+
// derived context, never leak into a concurrently-running, unrelated one.
|
|
407
|
+
expect(prisma.fast.findMany.mock.calls[0][0].include).toEqual({
|
|
408
|
+
child: { include: { grandchild: true } },
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
})
|
package/tests/singleton.test.ts
CHANGED
|
@@ -205,6 +205,89 @@ describe('Singleton Lists', () => {
|
|
|
205
205
|
})
|
|
206
206
|
})
|
|
207
207
|
|
|
208
|
+
describe('get() with a caller include (#848, ADR-0024)', () => {
|
|
209
|
+
// A singleton read gains the same caller-`include` handling as findUnique
|
|
210
|
+
// and findMany: bare fetches scalars only, and a caller include is merged
|
|
211
|
+
// with the access-controlled include (row-scoped per relation).
|
|
212
|
+
it("a bare get() sends no include and does not evaluate the related list's query access", async () => {
|
|
213
|
+
const homeAuthorQuerySpy = vi.fn(() => true)
|
|
214
|
+
const relConfig: OpenSaasConfig = {
|
|
215
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
216
|
+
lists: {
|
|
217
|
+
HomePage: {
|
|
218
|
+
fields: {
|
|
219
|
+
title: { type: 'text' },
|
|
220
|
+
featuredAuthor: { type: 'relationship', ref: 'Author' },
|
|
221
|
+
},
|
|
222
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
223
|
+
isSingleton: true,
|
|
224
|
+
},
|
|
225
|
+
Author: {
|
|
226
|
+
fields: { name: { type: 'text' } },
|
|
227
|
+
access: { operation: { query: homeAuthorQuerySpy } },
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
232
|
+
const relPrisma: any = {
|
|
233
|
+
homePage: { findFirst: vi.fn(), create: vi.fn(), count: vi.fn() },
|
|
234
|
+
author: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
235
|
+
}
|
|
236
|
+
relPrisma.homePage.findFirst.mockResolvedValue({
|
|
237
|
+
id: 1,
|
|
238
|
+
title: 'Home',
|
|
239
|
+
featuredAuthorId: 'a1',
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const context = getContext(relConfig, relPrisma, null)
|
|
243
|
+
const result = await context.db.homePage.get()
|
|
244
|
+
|
|
245
|
+
expect(relPrisma.homePage.findFirst).toHaveBeenCalledWith({ where: {}, include: undefined })
|
|
246
|
+
expect(homeAuthorQuerySpy).not.toHaveBeenCalled()
|
|
247
|
+
expect(result).toEqual({ id: 1, title: 'Home', featuredAuthorId: 'a1' })
|
|
248
|
+
expect(result).not.toHaveProperty('featuredAuthor')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('a caller include on get() is merged with the access-controlled include, row-scoped like any other read', async () => {
|
|
252
|
+
const homeAuthorQuerySpy = vi.fn(() => ({ published: { equals: true } }))
|
|
253
|
+
const relConfig: OpenSaasConfig = {
|
|
254
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
255
|
+
lists: {
|
|
256
|
+
HomePage: {
|
|
257
|
+
fields: {
|
|
258
|
+
title: { type: 'text' },
|
|
259
|
+
featuredAuthor: { type: 'relationship', ref: 'Author' },
|
|
260
|
+
},
|
|
261
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
262
|
+
isSingleton: true,
|
|
263
|
+
},
|
|
264
|
+
Author: {
|
|
265
|
+
fields: { name: { type: 'text' } },
|
|
266
|
+
access: { operation: { query: homeAuthorQuerySpy } },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
271
|
+
const relPrisma: any = {
|
|
272
|
+
homePage: { findFirst: vi.fn(), create: vi.fn(), count: vi.fn() },
|
|
273
|
+
author: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
274
|
+
}
|
|
275
|
+
relPrisma.homePage.findFirst.mockResolvedValue({
|
|
276
|
+
id: 1,
|
|
277
|
+
title: 'Home',
|
|
278
|
+
featuredAuthorId: 'a1',
|
|
279
|
+
featuredAuthor: { id: 'a1', name: 'Ann' },
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const context = getContext(relConfig, relPrisma, null)
|
|
283
|
+
const result = await context.db.homePage.get({ include: { featuredAuthor: true } })
|
|
284
|
+
|
|
285
|
+
const call = relPrisma.homePage.findFirst.mock.calls[0][0]
|
|
286
|
+
expect(call.include.featuredAuthor).toEqual({ where: { published: { equals: true } } })
|
|
287
|
+
expect(result?.featuredAuthor).toEqual({ id: 'a1', name: 'Ann' })
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
208
291
|
describe('delete operation', () => {
|
|
209
292
|
it('should block delete on singleton lists', async () => {
|
|
210
293
|
mockPrisma.settings.findUnique.mockResolvedValue({
|