@opensaas/stack-core 0.34.0 → 0.36.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 +48 -0
- package/dist/access/declared-dependencies.d.ts +68 -0
- package/dist/access/declared-dependencies.d.ts.map +1 -0
- package/dist/access/declared-dependencies.js +79 -0
- package/dist/access/declared-dependencies.js.map +1 -0
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +23 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +2 -0
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +3 -0
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +65 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +65 -72
- package/dist/context/index.js.map +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +12 -12
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/validation/needs-closure.d.ts +49 -0
- package/dist/validation/needs-closure.d.ts.map +1 -0
- package/dist/validation/needs-closure.js +139 -0
- package/dist/validation/needs-closure.js.map +1 -0
- package/package.json +1 -1
- package/src/access/declared-dependencies.ts +140 -0
- package/src/access/field-visibility.ts +24 -0
- package/src/access/index.ts +8 -0
- package/src/config/types.ts +65 -1
- package/src/context/index.ts +117 -90
- package/src/fields/index.ts +12 -9
- package/src/index.ts +8 -0
- package/src/validation/needs-closure.ts +188 -0
- package/tests/bare-read-scalars.test.ts +147 -0
- package/tests/field-types.test.ts +6 -2
- package/tests/needs-declared-dependencies.test.ts +500 -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
|
+
})
|
|
@@ -103,12 +103,16 @@ describe('Field Types', () => {
|
|
|
103
103
|
expect(prismaType.modifiers).toContain('@unique')
|
|
104
104
|
})
|
|
105
105
|
|
|
106
|
-
test('
|
|
106
|
+
test('requests a block-level index rather than an inline modifier', () => {
|
|
107
107
|
const field = text({ isIndexed: true })
|
|
108
108
|
const prismaType = field.getPrismaType('slug')
|
|
109
109
|
|
|
110
110
|
expect(prismaType.type).toBe('String')
|
|
111
|
-
|
|
111
|
+
// Prisma has no field-level `@index` attribute — emitting one produces a
|
|
112
|
+
// schema Prisma refuses to parse. A non-unique index is requested
|
|
113
|
+
// out-of-line and lands as `@@index([slug])` on the model.
|
|
114
|
+
expect(prismaType.index).toBe(true)
|
|
115
|
+
expect(prismaType.modifiers ?? '').not.toContain('@index')
|
|
112
116
|
})
|
|
113
117
|
|
|
114
118
|
test('db.isNullable: true makes optional field explicitly nullable', () => {
|
|
@@ -0,0 +1,500 @@
|
|
|
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, integer, relationship, virtual } from '../src/fields/index.js'
|
|
5
|
+
import { defineFragment } from '../src/query/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Coverage for ADR-0025 / issue #850: a computed field may declare (via
|
|
9
|
+
* `needs`) the immediate relations its `resolveOutput` hook cannot compute
|
|
10
|
+
* without. The read fetches exactly those, scoped through the Access Filter
|
|
11
|
+
* like any other relation a read asks for, and strips them from the result
|
|
12
|
+
* unless the caller named them too — a declared dependency is private
|
|
13
|
+
* plumbing, not an implicit `include` (see the "Declared dependency" and
|
|
14
|
+
* "Session-relative value" glossary entries in CONTEXT.md).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
function createMockPrisma(): any {
|
|
19
|
+
const model = () => ({
|
|
20
|
+
findFirst: vi.fn(),
|
|
21
|
+
findMany: vi.fn(),
|
|
22
|
+
create: vi.fn(),
|
|
23
|
+
update: vi.fn(),
|
|
24
|
+
delete: vi.fn(),
|
|
25
|
+
count: vi.fn(),
|
|
26
|
+
})
|
|
27
|
+
return {
|
|
28
|
+
order: model(),
|
|
29
|
+
lineItem: model(),
|
|
30
|
+
product: model(),
|
|
31
|
+
tag: model(),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildTestConfig(options?: {
|
|
36
|
+
lineItemQuery?: () => boolean | Record<string, unknown>
|
|
37
|
+
lineItemsFieldAccess?: () => boolean
|
|
38
|
+
// Adds a second declaring field on LineItem, pointed back at Order, so
|
|
39
|
+
// Order.total needs lineItems AND LineItem.orderTitle needs order form a
|
|
40
|
+
// genuine two-list declaration cycle. Opt-in — most tests want a shallow,
|
|
41
|
+
// acyclic closure.
|
|
42
|
+
withDeclarationCycle?: boolean
|
|
43
|
+
}) {
|
|
44
|
+
return config({
|
|
45
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
46
|
+
lists: {
|
|
47
|
+
Tag: list({
|
|
48
|
+
fields: { name: text() },
|
|
49
|
+
access: { operation: { query: () => true } },
|
|
50
|
+
}),
|
|
51
|
+
Product: list({
|
|
52
|
+
fields: { name: text() },
|
|
53
|
+
access: { operation: { query: () => true } },
|
|
54
|
+
}),
|
|
55
|
+
LineItem: list({
|
|
56
|
+
fields: {
|
|
57
|
+
price: integer(),
|
|
58
|
+
order: relationship({ ref: 'Order.lineItems' }),
|
|
59
|
+
product: relationship({ ref: 'Product' }),
|
|
60
|
+
tag: relationship({ ref: 'Tag' }),
|
|
61
|
+
// Declares a dependency on a SIBLING relation (`product`) — the
|
|
62
|
+
// shape every acceptance criterion below exercises.
|
|
63
|
+
summary: virtual({
|
|
64
|
+
type: 'string',
|
|
65
|
+
needs: ['product'],
|
|
66
|
+
hooks: {
|
|
67
|
+
resolveOutput: ({ item }) => {
|
|
68
|
+
const typedItem = item as { product?: { name?: string } | null }
|
|
69
|
+
return typedItem.product ? `${typedItem.product.name} x1` : 'unknown product x1'
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
...(options?.withDeclarationCycle
|
|
74
|
+
? {
|
|
75
|
+
orderTitle: virtual({
|
|
76
|
+
type: 'string',
|
|
77
|
+
needs: ['order'],
|
|
78
|
+
hooks: {
|
|
79
|
+
resolveOutput: ({ item }) => {
|
|
80
|
+
const typedItem = item as { order?: { title?: string } | null }
|
|
81
|
+
return typedItem.order?.title ?? 'no-order'
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
}
|
|
86
|
+
: {}),
|
|
87
|
+
},
|
|
88
|
+
access: {
|
|
89
|
+
operation: { query: options?.lineItemQuery ?? (() => true) },
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
Order: list({
|
|
93
|
+
fields: {
|
|
94
|
+
title: text(),
|
|
95
|
+
lineItems: relationship({
|
|
96
|
+
ref: 'LineItem.order',
|
|
97
|
+
many: true,
|
|
98
|
+
...(options?.lineItemsFieldAccess
|
|
99
|
+
? { access: { read: options.lineItemsFieldAccess } }
|
|
100
|
+
: {}),
|
|
101
|
+
}),
|
|
102
|
+
total: virtual({
|
|
103
|
+
type: 'number',
|
|
104
|
+
needs: ['lineItems'],
|
|
105
|
+
hooks: {
|
|
106
|
+
resolveOutput: ({ item }) => {
|
|
107
|
+
const typedItem = item as { lineItems?: Array<{ price?: number }> }
|
|
108
|
+
return (typedItem.lineItems ?? []).reduce((sum, li) => sum + (li.price ?? 0), 0)
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
access: { operation: { query: () => true } },
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('a computed field declares the relations it needs (#850, ADR-0025)', () => {
|
|
120
|
+
it('is available to resolveOutput on a bare read (no caller include), and absent from the result', async () => {
|
|
121
|
+
const testConfig = await buildTestConfig()
|
|
122
|
+
const mockPrisma = createMockPrisma()
|
|
123
|
+
mockPrisma.order.findMany.mockResolvedValue([
|
|
124
|
+
{
|
|
125
|
+
id: 'o1',
|
|
126
|
+
title: 'Order 1',
|
|
127
|
+
lineItems: [
|
|
128
|
+
{ id: 'li1', price: 10, orderId: 'o1' },
|
|
129
|
+
{ id: 'li2', price: 5, orderId: 'o1' },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
135
|
+
const result = await context.db.order.findMany({})
|
|
136
|
+
|
|
137
|
+
// The declared relation WAS fetched (folded into the include even though
|
|
138
|
+
// the caller asked for nothing).
|
|
139
|
+
expect(mockPrisma.order.findMany).toHaveBeenCalledWith(
|
|
140
|
+
expect.objectContaining({
|
|
141
|
+
include: expect.objectContaining({ lineItems: expect.anything() }),
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
// ...but it never widens the result: the field computes from it, and the
|
|
146
|
+
// relation itself is stripped because the caller never named it.
|
|
147
|
+
expect(result[0].total).toBe(15)
|
|
148
|
+
expect(result[0]).not.toHaveProperty('lineItems')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('a caller include naming the same relation still receives it, unchanged', async () => {
|
|
152
|
+
const testConfig = await buildTestConfig()
|
|
153
|
+
const mockPrisma = createMockPrisma()
|
|
154
|
+
mockPrisma.order.findFirst.mockResolvedValue({
|
|
155
|
+
id: 'o1',
|
|
156
|
+
title: 'Order 1',
|
|
157
|
+
lineItems: [{ id: 'li1', price: 10, orderId: 'o1' }],
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
161
|
+
const result = await context.db.order.findUnique({
|
|
162
|
+
where: { id: 'o1' },
|
|
163
|
+
include: { lineItems: true },
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
expect(result?.total).toBe(10)
|
|
167
|
+
// Caller-named, so present and unchanged (its own `summary` field also
|
|
168
|
+
// computes as normal — LineItem's fields are unaffected by this feature).
|
|
169
|
+
expect(result?.lineItems?.[0]).toMatchObject({ id: 'li1', price: 10, orderId: 'o1' })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('folds a declared dependency into an EXPLICIT nested caller include, and strips only the added key', async () => {
|
|
173
|
+
const testConfig = await buildTestConfig()
|
|
174
|
+
const mockPrisma = createMockPrisma()
|
|
175
|
+
// Simulates what Prisma would actually return given the fold: `tag`
|
|
176
|
+
// (caller-named) AND `product` (declaration-added) both present on the
|
|
177
|
+
// fetched row.
|
|
178
|
+
mockPrisma.order.findFirst.mockResolvedValue({
|
|
179
|
+
id: 'o1',
|
|
180
|
+
title: 'Order 1',
|
|
181
|
+
lineItems: [
|
|
182
|
+
{
|
|
183
|
+
id: 'li1',
|
|
184
|
+
price: 10,
|
|
185
|
+
orderId: 'o1',
|
|
186
|
+
tag: { id: 't1', name: 'Sale' },
|
|
187
|
+
product: { id: 'p1', name: 'Widget' },
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
193
|
+
const result = await context.db.order.findUnique({
|
|
194
|
+
where: { id: 'o1' },
|
|
195
|
+
include: { lineItems: { include: { tag: true } } },
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// The Prisma call's nested include for `lineItems` folds `product` in
|
|
199
|
+
// alongside the caller's own `tag` selection.
|
|
200
|
+
expect(mockPrisma.order.findFirst).toHaveBeenCalledWith(
|
|
201
|
+
expect.objectContaining({
|
|
202
|
+
include: expect.objectContaining({
|
|
203
|
+
lineItems: { include: { tag: true, product: true } },
|
|
204
|
+
}),
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
// The hook saw the declared relation and computed from it...
|
|
209
|
+
expect(result?.lineItems?.[0].summary).toBe('Widget x1')
|
|
210
|
+
// ...`tag` (caller-named) survives unchanged...
|
|
211
|
+
expect(result?.lineItems?.[0].tag).toEqual({ id: 't1', name: 'Sale' })
|
|
212
|
+
// ...but `product` (declaration-only at this level) is stripped.
|
|
213
|
+
expect(result?.lineItems?.[0]).not.toHaveProperty('product')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('scopes a declared dependency through the Access Filter: a filtered relation yields a session-relative value', async () => {
|
|
217
|
+
// Only line items priced >= 10 are visible to this session.
|
|
218
|
+
const testConfig = await buildTestConfig({ lineItemQuery: () => ({ price: { gte: 10 } }) })
|
|
219
|
+
const mockPrisma = createMockPrisma()
|
|
220
|
+
mockPrisma.order.findMany.mockResolvedValue([
|
|
221
|
+
{
|
|
222
|
+
id: 'o1',
|
|
223
|
+
title: 'Order 1',
|
|
224
|
+
// Simulates the DB honouring the access `where` folded into the
|
|
225
|
+
// declared relation — only the visible row comes back.
|
|
226
|
+
lineItems: [{ id: 'li1', price: 10, orderId: 'o1' }],
|
|
227
|
+
},
|
|
228
|
+
])
|
|
229
|
+
|
|
230
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
231
|
+
const result = await context.db.order.findMany({})
|
|
232
|
+
|
|
233
|
+
// The access filter's `where` rode along on the declaration-added relation.
|
|
234
|
+
expect(mockPrisma.order.findMany).toHaveBeenCalledWith(
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
include: expect.objectContaining({
|
|
237
|
+
lineItems: expect.objectContaining({ where: { price: { gte: 10 } } }),
|
|
238
|
+
}),
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
// total reflects only the visible row (5 would be the "true" total if the
|
|
242
|
+
// denied row leaked in) — a projection of what the session can see.
|
|
243
|
+
expect(result[0].total).toBe(10)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('a field whose declared dependency is entirely denied still computes, and is not withheld', async () => {
|
|
247
|
+
const testConfig = await buildTestConfig({ lineItemQuery: () => false })
|
|
248
|
+
const mockPrisma = createMockPrisma()
|
|
249
|
+
mockPrisma.order.findMany.mockResolvedValue([{ id: 'o1', title: 'Order 1' }])
|
|
250
|
+
|
|
251
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
252
|
+
const result = await context.db.order.findMany({})
|
|
253
|
+
|
|
254
|
+
// The field is present with a value computed over nothing, not withheld.
|
|
255
|
+
expect(result[0]).toHaveProperty('total')
|
|
256
|
+
expect(result[0].total).toBe(0)
|
|
257
|
+
expect(result[0]).not.toHaveProperty('lineItems')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('a field-level read denial on the declared relation also still lets the field compute', async () => {
|
|
261
|
+
const testConfig = await buildTestConfig({ lineItemsFieldAccess: () => false })
|
|
262
|
+
const mockPrisma = createMockPrisma()
|
|
263
|
+
mockPrisma.order.findMany.mockResolvedValue([
|
|
264
|
+
{ id: 'o1', title: 'Order 1', lineItems: [{ id: 'li1', price: 10, orderId: 'o1' }] },
|
|
265
|
+
])
|
|
266
|
+
|
|
267
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
268
|
+
const result = await context.db.order.findMany({})
|
|
269
|
+
|
|
270
|
+
expect(result[0].total).toBe(0)
|
|
271
|
+
expect(result[0]).not.toHaveProperty('lineItems')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('holds for fragment (query) reads too: the fold feeds the hook, the fragment projection still governs what returns', async () => {
|
|
275
|
+
const testConfig = await buildTestConfig()
|
|
276
|
+
const mockPrisma = createMockPrisma()
|
|
277
|
+
mockPrisma.order.findFirst.mockResolvedValue({
|
|
278
|
+
id: 'o1',
|
|
279
|
+
title: 'Order 1',
|
|
280
|
+
lineItems: [{ id: 'li1', price: 10, orderId: 'o1' }],
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
284
|
+
const orderFragment = defineFragment<any>()({ title: true, total: true } as const)
|
|
285
|
+
|
|
286
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
287
|
+
const result = await context.db.order.findUnique({
|
|
288
|
+
where: { id: 'o1' },
|
|
289
|
+
query: orderFragment,
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
expect(result?.total).toBe(10)
|
|
293
|
+
expect(result).not.toHaveProperty('lineItems')
|
|
294
|
+
expect(result).toHaveProperty('title')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('a declaration cycle across two lists terminates via the existing relationship-graph cycle guard (ADR-0026 note)', async () => {
|
|
298
|
+
// Order.total needs lineItems; LineItem.orderTitle needs order — a
|
|
299
|
+
// two-list declaration cycle. This must not hang or crash: it rides the
|
|
300
|
+
// SAME `visitedLists` cycle guard `buildIncludeWithAccessControl` already
|
|
301
|
+
// uses for the relationship graph, not a separate mechanism.
|
|
302
|
+
const testConfig = await buildTestConfig({ withDeclarationCycle: true })
|
|
303
|
+
const mockPrisma = createMockPrisma()
|
|
304
|
+
mockPrisma.order.findMany.mockResolvedValue([
|
|
305
|
+
{
|
|
306
|
+
id: 'o1',
|
|
307
|
+
title: 'Order 1',
|
|
308
|
+
lineItems: [
|
|
309
|
+
{
|
|
310
|
+
id: 'li1',
|
|
311
|
+
price: 10,
|
|
312
|
+
orderId: 'o1',
|
|
313
|
+
// The cycle-pruned back-edge: LineItem's own `order` is a flat
|
|
314
|
+
// fetch (no further nested `lineItems` beneath IT).
|
|
315
|
+
order: { id: 'o1', title: 'Order 1' },
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
])
|
|
320
|
+
|
|
321
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
322
|
+
const result = await context.db.order.findMany({})
|
|
323
|
+
|
|
324
|
+
expect(result[0].total).toBe(10)
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
describe('needs — generate-time validation (ADR-0025)', () => {
|
|
329
|
+
it('rejects a `needs` entry that does not name a relationship field on the same list', async () => {
|
|
330
|
+
const { validateNeedsDeclarations } = await import('../src/validation/needs-closure.js')
|
|
331
|
+
const testConfig = await buildTestConfig()
|
|
332
|
+
// Reach in and corrupt a `needs` array the way an un-typed (plain JS)
|
|
333
|
+
// config author might — the type constraint only helps when the list is
|
|
334
|
+
// annotated with its generated `Lists.X.TypeInfo`.
|
|
335
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
336
|
+
;(testConfig.lists.LineItem.fields.summary as any).needs = ['price']
|
|
337
|
+
|
|
338
|
+
const errors = validateNeedsDeclarations(testConfig)
|
|
339
|
+
expect(errors).toHaveLength(1)
|
|
340
|
+
expect(errors[0]).toMatchObject({
|
|
341
|
+
listKey: 'LineItem',
|
|
342
|
+
fieldKey: 'summary',
|
|
343
|
+
reason: 'invalid-relation',
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('rejects a needs closure that cannot fit within the read-include depth cap from any starting point', async () => {
|
|
348
|
+
const { validateNeedsClosureDepth } = await import('../src/validation/needs-closure.js')
|
|
349
|
+
|
|
350
|
+
// A straight-line chain of 6 lists, each needing the next — deeper than
|
|
351
|
+
// READ_INCLUDE_MAX_DEPTH (5) even starting at List0 itself.
|
|
352
|
+
const listNames = ['List0', 'List1', 'List2', 'List3', 'List4', 'List5', 'List6']
|
|
353
|
+
const lists: Record<string, ReturnType<typeof list>> = {}
|
|
354
|
+
for (let i = 0; i < listNames.length; i++) {
|
|
355
|
+
const name = listNames[i]
|
|
356
|
+
const nextName = listNames[i + 1]
|
|
357
|
+
lists[name] = list({
|
|
358
|
+
fields: {
|
|
359
|
+
...(nextName
|
|
360
|
+
? {
|
|
361
|
+
next: relationship({ ref: `${nextName}.prev`, many: false }),
|
|
362
|
+
computed: virtual({
|
|
363
|
+
type: 'string',
|
|
364
|
+
needs: ['next'],
|
|
365
|
+
hooks: { resolveOutput: () => 'x' },
|
|
366
|
+
}),
|
|
367
|
+
}
|
|
368
|
+
: {}),
|
|
369
|
+
...(i > 0 ? { prev: relationship({ ref: `${listNames[i - 1]}.next`, many: true }) } : {}),
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const deepConfig = await config({
|
|
375
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
376
|
+
lists,
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
const errors = validateNeedsClosureDepth(deepConfig)
|
|
380
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
381
|
+
expect(errors[0].reason).toBe('depth')
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('rejects a cyclic needs declaration closure', async () => {
|
|
385
|
+
const { validateNeedsClosureDepth } = await import('../src/validation/needs-closure.js')
|
|
386
|
+
|
|
387
|
+
const cyclicConfig = await config({
|
|
388
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
389
|
+
lists: {
|
|
390
|
+
A: list({
|
|
391
|
+
fields: {
|
|
392
|
+
b: relationship({ ref: 'B.a', many: false }),
|
|
393
|
+
computed: virtual({
|
|
394
|
+
type: 'string',
|
|
395
|
+
needs: ['b'],
|
|
396
|
+
hooks: { resolveOutput: () => 'x' },
|
|
397
|
+
}),
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
B: list({
|
|
401
|
+
fields: {
|
|
402
|
+
a: relationship({ ref: 'A.b', many: false }),
|
|
403
|
+
computed: virtual({
|
|
404
|
+
type: 'string',
|
|
405
|
+
needs: ['a'],
|
|
406
|
+
hooks: { resolveOutput: () => 'x' },
|
|
407
|
+
}),
|
|
408
|
+
},
|
|
409
|
+
}),
|
|
410
|
+
},
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const errors = validateNeedsClosureDepth(cyclicConfig)
|
|
414
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
415
|
+
expect(errors.some((e) => e.reason === 'cycle')).toBe(true)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('accepts a shallow, acyclic needs closure', async () => {
|
|
419
|
+
const { validateNeedsDeclarations, validateNeedsClosureDepth } =
|
|
420
|
+
await import('../src/validation/needs-closure.js')
|
|
421
|
+
const testConfig = await buildTestConfig()
|
|
422
|
+
|
|
423
|
+
expect(validateNeedsDeclarations(testConfig)).toEqual([])
|
|
424
|
+
expect(validateNeedsClosureDepth(testConfig)).toEqual([])
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('handles the edges of closure resolution without crashing: a fieldless list, an unresolvable ref, a needs entry naming a non-relationship field, one naming a field that does not exist at all, and two needs entries with different depths', async () => {
|
|
428
|
+
const { validateNeedsDeclarations, validateNeedsClosureDepth } =
|
|
429
|
+
await import('../src/validation/needs-closure.js')
|
|
430
|
+
|
|
431
|
+
// Raw config objects (not the `list()` builder) so a list can legitimately
|
|
432
|
+
// have no `fields` key at all — both validators must skip it rather than
|
|
433
|
+
// crash on `listConfig.fields`.
|
|
434
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
435
|
+
const edgeConfig: any = {
|
|
436
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
437
|
+
lists: {
|
|
438
|
+
// No `fields` at all.
|
|
439
|
+
Empty: {},
|
|
440
|
+
Tag: { fields: { name: { type: 'text' } } },
|
|
441
|
+
LineItem: {
|
|
442
|
+
fields: {
|
|
443
|
+
order: { type: 'relationship', ref: 'Order.lineItems' },
|
|
444
|
+
price: { type: 'text' },
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
Dangling: {
|
|
448
|
+
fields: {
|
|
449
|
+
// Resolves to a real list that has no fields (closure bottoms out at 0).
|
|
450
|
+
target: { type: 'relationship', ref: 'Empty.field' },
|
|
451
|
+
// Does not resolve to any list at all.
|
|
452
|
+
dangling: { type: 'relationship', ref: 'DoesNotExist.field' },
|
|
453
|
+
computed: {
|
|
454
|
+
type: 'virtual',
|
|
455
|
+
needs: ['target', 'dangling'],
|
|
456
|
+
hooks: { resolveOutput: () => 'x' },
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
Order: {
|
|
461
|
+
fields: {
|
|
462
|
+
lineItems: { type: 'relationship', ref: 'LineItem.order' },
|
|
463
|
+
tag: { type: 'relationship', ref: 'Tag' },
|
|
464
|
+
price: { type: 'text' },
|
|
465
|
+
// Both dependencies resolve to a 0-deep closure — the second
|
|
466
|
+
// does not exceed the first's recorded depth.
|
|
467
|
+
multi: {
|
|
468
|
+
type: 'virtual',
|
|
469
|
+
needs: ['tag', 'lineItems'],
|
|
470
|
+
hooks: { resolveOutput: () => 'x' },
|
|
471
|
+
},
|
|
472
|
+
// Names a real, non-relationship field.
|
|
473
|
+
usesNonRelation: {
|
|
474
|
+
type: 'virtual',
|
|
475
|
+
needs: ['price'],
|
|
476
|
+
hooks: { resolveOutput: () => 'x' },
|
|
477
|
+
},
|
|
478
|
+
// Names a field that does not exist on this list at all.
|
|
479
|
+
typo: {
|
|
480
|
+
type: 'virtual',
|
|
481
|
+
needs: ['nonexistentField'],
|
|
482
|
+
hooks: { resolveOutput: () => 'x' },
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
expect(() => validateNeedsDeclarations(edgeConfig)).not.toThrow()
|
|
490
|
+
expect(() => validateNeedsClosureDepth(edgeConfig)).not.toThrow()
|
|
491
|
+
|
|
492
|
+
const declErrors = validateNeedsDeclarations(edgeConfig)
|
|
493
|
+
expect(declErrors.some((e) => e.fieldKey === 'usesNonRelation')).toBe(true)
|
|
494
|
+
const typoError = declErrors.find((e) => e.fieldKey === 'typo')
|
|
495
|
+
expect(typoError?.message).toContain('has no field named')
|
|
496
|
+
|
|
497
|
+
// None of this forms a cycle or an over-deep closure.
|
|
498
|
+
expect(validateNeedsClosureDepth(edgeConfig)).toEqual([])
|
|
499
|
+
})
|
|
500
|
+
})
|