@opensaas/stack-core 0.35.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 +35 -0
- package/CLAUDE.md +44 -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 +51 -95
- 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 +96 -115
- package/src/fields/index.ts +12 -9
- package/src/index.ts +8 -0
- package/src/validation/needs-closure.ts +188 -0
- package/tests/field-types.test.ts +6 -2
- package/tests/needs-declared-dependencies.test.ts +500 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
})
|