@opensaas/stack-core 0.35.0 → 0.37.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 (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +88 -0
  3. package/CLAUDE.md +44 -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/index.d.ts +1 -1
  17. package/dist/config/index.d.ts.map +1 -1
  18. package/dist/config/types.d.ts +191 -1
  19. package/dist/config/types.d.ts.map +1 -1
  20. package/dist/context/index.d.ts.map +1 -1
  21. package/dist/context/index.js +51 -95
  22. package/dist/context/index.js.map +1 -1
  23. package/dist/fields/index.d.ts.map +1 -1
  24. package/dist/fields/index.js +40 -17
  25. package/dist/fields/index.js.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +6 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/validation/needs-closure.d.ts +49 -0
  31. package/dist/validation/needs-closure.d.ts.map +1 -0
  32. package/dist/validation/needs-closure.js +139 -0
  33. package/dist/validation/needs-closure.js.map +1 -0
  34. package/package.json +1 -1
  35. package/src/access/declared-dependencies.ts +140 -0
  36. package/src/access/field-visibility.ts +24 -0
  37. package/src/access/index.ts +8 -0
  38. package/src/config/index.ts +2 -0
  39. package/src/config/types.ts +195 -1
  40. package/src/context/index.ts +96 -115
  41. package/src/fields/index.ts +47 -14
  42. package/src/index.ts +10 -0
  43. package/src/validation/needs-closure.ts +188 -0
  44. package/tests/field-types.test.ts +18 -2
  45. package/tests/needs-declared-dependencies.test.ts +500 -0
  46. 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
+ })