@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.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +82 -0
  3. package/CLAUDE.md +48 -0
  4. package/dist/access/declared-dependencies.d.ts +68 -0
  5. package/dist/access/declared-dependencies.d.ts.map +1 -0
  6. package/dist/access/declared-dependencies.js +79 -0
  7. package/dist/access/declared-dependencies.js.map +1 -0
  8. package/dist/access/field-visibility.d.ts +2 -1
  9. package/dist/access/field-visibility.d.ts.map +1 -1
  10. package/dist/access/field-visibility.js +23 -3
  11. package/dist/access/field-visibility.js.map +1 -1
  12. package/dist/access/index.d.ts +2 -0
  13. package/dist/access/index.d.ts.map +1 -1
  14. package/dist/access/index.js +3 -0
  15. package/dist/access/index.js.map +1 -1
  16. package/dist/config/types.d.ts +65 -1
  17. package/dist/config/types.d.ts.map +1 -1
  18. package/dist/context/index.d.ts.map +1 -1
  19. package/dist/context/index.js +65 -72
  20. package/dist/context/index.js.map +1 -1
  21. package/dist/fields/index.d.ts.map +1 -1
  22. package/dist/fields/index.js +12 -12
  23. package/dist/fields/index.js.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/validation/needs-closure.d.ts +49 -0
  29. package/dist/validation/needs-closure.d.ts.map +1 -0
  30. package/dist/validation/needs-closure.js +139 -0
  31. package/dist/validation/needs-closure.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/access/declared-dependencies.ts +140 -0
  34. package/src/access/field-visibility.ts +24 -0
  35. package/src/access/index.ts +8 -0
  36. package/src/config/types.ts +65 -1
  37. package/src/context/index.ts +117 -90
  38. package/src/fields/index.ts +12 -9
  39. package/src/index.ts +8 -0
  40. package/src/validation/needs-closure.ts +188 -0
  41. package/tests/bare-read-scalars.test.ts +147 -0
  42. package/tests/field-types.test.ts +6 -2
  43. package/tests/needs-declared-dependencies.test.ts +500 -0
  44. package/tests/nested-access-and-hooks.test.ts +10 -0
  45. package/tests/resolve-chain.test.ts +40 -23
  46. package/tests/singleton.test.ts +83 -0
  47. 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('includes @index modifier', () => {
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
- expect(prismaType.modifiers).toContain('@index')
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
+ })