@opensaas/stack-core 0.34.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 +47 -0
- package/CLAUDE.md +4 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +72 -35
- package/dist/context/index.js.map +1 -1
- package/package.json +1 -1
- package/src/context/index.ts +98 -52
- package/tests/bare-read-scalars.test.ts +147 -0
- package/tests/nested-access-and-hooks.test.ts +10 -0
- package/tests/resolve-chain.test.ts +40 -23
- package/tests/singleton.test.ts +83 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { config, list } from '../src/config/index.js'
|
|
4
|
+
import { text, relationship, virtual } from '../src/fields/index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression coverage for issue #848 / ADR-0024: a `context.db` read with no
|
|
8
|
+
* `include` (and no fragment `query`) used to auto-include every readable
|
|
9
|
+
* relationship of the list, recursing to `READ_INCLUDE_MAX_DEPTH` and
|
|
10
|
+
* evaluating every related list's operation-level `query` access along the
|
|
11
|
+
* way. It now returns the row's own columns plus its virtual fields — nothing
|
|
12
|
+
* more — matching Prisma's own semantics for a bare read. Relations are
|
|
13
|
+
* fetched only when a caller names them via `include` (covered, unmodified,
|
|
14
|
+
* by the #566/#830 regression suites in `context.test.ts`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
function createMockPrisma(): any {
|
|
19
|
+
return {
|
|
20
|
+
author: {
|
|
21
|
+
findFirst: vi.fn(),
|
|
22
|
+
findMany: vi.fn(),
|
|
23
|
+
create: vi.fn(),
|
|
24
|
+
update: vi.fn(),
|
|
25
|
+
delete: vi.fn(),
|
|
26
|
+
count: vi.fn(),
|
|
27
|
+
},
|
|
28
|
+
post: {
|
|
29
|
+
findFirst: vi.fn(),
|
|
30
|
+
findMany: vi.fn(),
|
|
31
|
+
create: vi.fn(),
|
|
32
|
+
update: vi.fn(),
|
|
33
|
+
delete: vi.fn(),
|
|
34
|
+
count: vi.fn(),
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildTestConfig(authorQuerySpy: ReturnType<typeof vi.fn>) {
|
|
40
|
+
return config({
|
|
41
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
42
|
+
lists: {
|
|
43
|
+
Author: list({
|
|
44
|
+
fields: {
|
|
45
|
+
name: text(),
|
|
46
|
+
},
|
|
47
|
+
access: { operation: { query: authorQuerySpy } },
|
|
48
|
+
}),
|
|
49
|
+
Post: list({
|
|
50
|
+
fields: {
|
|
51
|
+
title: text(),
|
|
52
|
+
// List-only ref keeps the schema minimal — the relation still owns
|
|
53
|
+
// an `authorId` foreign-key column, which is what matters here.
|
|
54
|
+
author: relationship({ ref: 'Author' }),
|
|
55
|
+
shout: virtual({
|
|
56
|
+
type: 'string',
|
|
57
|
+
hooks: {
|
|
58
|
+
resolveOutput: ({ item }) => `${(item as { title?: string }).title}!`,
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
access: { operation: { query: () => true } },
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('a bare read fetches scalars, not relations (#848, ADR-0024)', () => {
|
|
69
|
+
it('findUnique with no include sends no include to the ORM and evaluates no related query access', async () => {
|
|
70
|
+
const authorQuerySpy = vi.fn(() => true)
|
|
71
|
+
const testConfig = await buildTestConfig(authorQuerySpy)
|
|
72
|
+
const mockPrisma = createMockPrisma()
|
|
73
|
+
mockPrisma.post.findFirst.mockResolvedValue({ id: '1', title: 'Hello', authorId: 'a1' })
|
|
74
|
+
|
|
75
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
76
|
+
const result = await context.db.post.findUnique({ where: { id: '1' } })
|
|
77
|
+
|
|
78
|
+
expect(mockPrisma.post.findFirst).toHaveBeenCalledWith({
|
|
79
|
+
where: { id: '1' },
|
|
80
|
+
include: undefined,
|
|
81
|
+
})
|
|
82
|
+
// Related list's operation-level `query` access is never invoked — a bare
|
|
83
|
+
// read never even considers whether the relation is fetchable.
|
|
84
|
+
expect(authorQuerySpy).not.toHaveBeenCalled()
|
|
85
|
+
|
|
86
|
+
// Own columns (including the FK column) and the computed virtual field
|
|
87
|
+
// are present; the relation key is absent entirely.
|
|
88
|
+
expect(result).toEqual({ id: '1', title: 'Hello', authorId: 'a1', shout: 'Hello!' })
|
|
89
|
+
expect(result).not.toHaveProperty('author')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('findMany with no include sends no include to the ORM and evaluates no related query access', async () => {
|
|
93
|
+
const authorQuerySpy = vi.fn(() => true)
|
|
94
|
+
const testConfig = await buildTestConfig(authorQuerySpy)
|
|
95
|
+
const mockPrisma = createMockPrisma()
|
|
96
|
+
mockPrisma.post.findMany.mockResolvedValue([{ id: '1', title: 'Hello', authorId: 'a1' }])
|
|
97
|
+
|
|
98
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
99
|
+
const result = await context.db.post.findMany({})
|
|
100
|
+
|
|
101
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
102
|
+
expect.objectContaining({ include: undefined }),
|
|
103
|
+
)
|
|
104
|
+
expect(authorQuerySpy).not.toHaveBeenCalled()
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual([{ id: '1', title: 'Hello', authorId: 'a1', shout: 'Hello!' }])
|
|
107
|
+
expect(result[0]).not.toHaveProperty('author')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('a caller-supplied include still evaluates the related query access (unchanged)', async () => {
|
|
111
|
+
const authorQuerySpy = vi.fn(() => true)
|
|
112
|
+
const testConfig = await buildTestConfig(authorQuerySpy)
|
|
113
|
+
const mockPrisma = createMockPrisma()
|
|
114
|
+
mockPrisma.post.findFirst.mockResolvedValue({
|
|
115
|
+
id: '1',
|
|
116
|
+
title: 'Hello',
|
|
117
|
+
authorId: 'a1',
|
|
118
|
+
author: { id: 'a1', name: 'Ann' },
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
122
|
+
const result = await context.db.post.findUnique({
|
|
123
|
+
where: { id: '1' },
|
|
124
|
+
include: { author: true },
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(authorQuerySpy).toHaveBeenCalled()
|
|
128
|
+
expect(result?.author).toEqual({ id: 'a1', name: 'Ann' })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('sudo bare read also sends no include (behaviour already matched Prisma; unaffected by this change)', async () => {
|
|
132
|
+
const authorQuerySpy = vi.fn(() => true)
|
|
133
|
+
const testConfig = await buildTestConfig(authorQuerySpy)
|
|
134
|
+
const mockPrisma = createMockPrisma()
|
|
135
|
+
mockPrisma.post.findFirst.mockResolvedValue({ id: '1', title: 'Hello', authorId: 'a1' })
|
|
136
|
+
|
|
137
|
+
const context = getContext(testConfig, mockPrisma, null).sudo()
|
|
138
|
+
const result = await context.db.post.findUnique({ where: { id: '1' } })
|
|
139
|
+
|
|
140
|
+
expect(mockPrisma.post.findFirst).toHaveBeenCalledWith({
|
|
141
|
+
where: { id: '1' },
|
|
142
|
+
include: undefined,
|
|
143
|
+
})
|
|
144
|
+
expect(authorQuerySpy).not.toHaveBeenCalled()
|
|
145
|
+
expect(result).toEqual({ id: '1', title: 'Hello', authorId: 'a1', shout: 'Hello!' })
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -397,8 +397,12 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
397
397
|
|
|
398
398
|
const context = getContext(await testConfig, mockPrisma, null)
|
|
399
399
|
|
|
400
|
+
// A caller-supplied `include` is required to fetch `posts` at all — a
|
|
401
|
+
// bare read returns scalars only (ADR-0024) — so name it explicitly to
|
|
402
|
+
// exercise the per-relation access `where` merge.
|
|
400
403
|
await context.db.user.findUnique({
|
|
401
404
|
where: { id: '1' },
|
|
405
|
+
include: { posts: true },
|
|
402
406
|
})
|
|
403
407
|
|
|
404
408
|
// Verify findFirst was called with access filter
|
|
@@ -469,8 +473,11 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
469
473
|
|
|
470
474
|
const context = getContext(await testConfig, mockPrisma, null)
|
|
471
475
|
|
|
476
|
+
// `author` must be named explicitly — a bare read returns scalars only
|
|
477
|
+
// (ADR-0024).
|
|
472
478
|
const result = await context.db.post.findUnique({
|
|
473
479
|
where: { id: '1' },
|
|
480
|
+
include: { author: true },
|
|
474
481
|
})
|
|
475
482
|
|
|
476
483
|
// Email should be filtered out
|
|
@@ -522,8 +529,11 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
522
529
|
|
|
523
530
|
const context = getContext(await testConfig, mockPrisma, null)
|
|
524
531
|
|
|
532
|
+
// `author` must be named explicitly to exercise the access-denied drop —
|
|
533
|
+
// a bare read never requests it at all (ADR-0024).
|
|
525
534
|
await context.db.post.findUnique({
|
|
526
535
|
where: { id: '1' },
|
|
536
|
+
include: { author: true },
|
|
527
537
|
})
|
|
528
538
|
|
|
529
539
|
// Verify include does NOT include author (access denied)
|
|
@@ -136,9 +136,12 @@ describe('resolve chain — cycle guard terminates hook-issued reads (#844)', ()
|
|
|
136
136
|
)
|
|
137
137
|
})
|
|
138
138
|
|
|
139
|
-
it("reproduces the reporter's 3-list cyclic schema (User → Account → Student)
|
|
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
140
|
// Sketch matches the issue's minimal reproduction: User.name reads
|
|
141
|
-
// Account, whose Student rows' own virtual field reads Account again.
|
|
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.
|
|
142
145
|
const config: OpenSaasConfig = {
|
|
143
146
|
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
144
147
|
lists: {
|
|
@@ -191,33 +194,42 @@ describe('resolve chain — cycle guard terminates hook-issued reads (#844)', ()
|
|
|
191
194
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
192
195
|
const prisma: any = { user: makeModel(), account: makeModel(), student: makeModel() }
|
|
193
196
|
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }])
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
})
|
|
201
208
|
// `context.db.<list>.findUnique` is implemented via the Prisma model's
|
|
202
209
|
// `findFirst` (see `createFindUnique` in `context/index.ts`), not its
|
|
203
210
|
// `findUnique` — mock the method actually called.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
firstName: 'Ann'
|
|
207
|
-
students
|
|
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)
|
|
208
217
|
})
|
|
209
218
|
|
|
210
219
|
const context = await getContext(config, prisma, null)
|
|
211
220
|
|
|
212
|
-
// Must settle (not hang
|
|
213
|
-
//
|
|
214
|
-
|
|
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' }])
|
|
215
226
|
|
|
227
|
+
// Only the two reads the hook actually issues — no unbounded recursion.
|
|
216
228
|
const totalCalls =
|
|
217
229
|
prisma.user.findMany.mock.calls.length +
|
|
218
230
|
prisma.account.findMany.mock.calls.length +
|
|
219
231
|
prisma.account.findFirst.mock.calls.length
|
|
220
|
-
expect(totalCalls).
|
|
232
|
+
expect(totalCalls).toBe(2)
|
|
221
233
|
})
|
|
222
234
|
})
|
|
223
235
|
|
|
@@ -319,7 +331,7 @@ describe('resolve chain — concurrent hook invocations are isolated (#844)', ()
|
|
|
319
331
|
expect(observedLengths.sort()).toEqual([1, 1, 1])
|
|
320
332
|
})
|
|
321
333
|
|
|
322
|
-
it('an unrelated top-level read in flight alongside a hook still gets its full
|
|
334
|
+
it('an unrelated top-level read in flight alongside a hook still gets its full explicit include scoped', async () => {
|
|
323
335
|
let releaseSlowHook: () => void = () => {}
|
|
324
336
|
|
|
325
337
|
const config: OpenSaasConfig = {
|
|
@@ -377,16 +389,21 @@ describe('resolve chain — concurrent hook invocations are isolated (#844)', ()
|
|
|
377
389
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
378
390
|
|
|
379
391
|
// While the slow hook is still in flight, issue a plain top-level read
|
|
380
|
-
// that has nothing to do with it.
|
|
381
|
-
|
|
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 } })
|
|
382
397
|
|
|
383
398
|
await fastPromise
|
|
384
399
|
releaseSlowHook()
|
|
385
400
|
await slowPromise
|
|
386
401
|
|
|
387
|
-
// The unrelated read's
|
|
388
|
-
// (child → grandchild), not collapse to a bare `{ child: true }`
|
|
389
|
-
// it happened to run while a totally different read's hook was
|
|
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.
|
|
390
407
|
expect(prisma.fast.findMany.mock.calls[0][0].include).toEqual({
|
|
391
408
|
child: { include: { grandchild: true } },
|
|
392
409
|
})
|
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({
|