@opensaas/stack-core 0.33.0 → 0.34.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 (61) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +35 -0
  3. package/dist/access/access-filter.d.ts.map +1 -1
  4. package/dist/access/access-filter.js +12 -7
  5. package/dist/access/access-filter.js.map +1 -1
  6. package/dist/access/access-filter.test.js +5 -1
  7. package/dist/access/access-filter.test.js.map +1 -1
  8. package/dist/access/depth-limits.d.ts +14 -0
  9. package/dist/access/depth-limits.d.ts.map +1 -1
  10. package/dist/access/depth-limits.js +14 -0
  11. package/dist/access/depth-limits.js.map +1 -1
  12. package/dist/access/errors.d.ts +24 -0
  13. package/dist/access/errors.d.ts.map +1 -1
  14. package/dist/access/errors.js +26 -0
  15. package/dist/access/errors.js.map +1 -1
  16. package/dist/access/field-visibility.d.ts.map +1 -1
  17. package/dist/access/field-visibility.js +72 -17
  18. package/dist/access/field-visibility.js.map +1 -1
  19. package/dist/access/index.d.ts +1 -0
  20. package/dist/access/index.d.ts.map +1 -1
  21. package/dist/access/index.js +2 -0
  22. package/dist/access/index.js.map +1 -1
  23. package/dist/access/multi-column-read-write.test.js +1 -1
  24. package/dist/access/multi-column-read-write.test.js.map +1 -1
  25. package/dist/access/relationship-count.test.js +1 -1
  26. package/dist/access/relationship-count.test.js.map +1 -1
  27. package/dist/access/relationship-label-filter.test.js +1 -1
  28. package/dist/access/relationship-label-filter.test.js.map +1 -1
  29. package/dist/access/types.d.ts +15 -7
  30. package/dist/access/types.d.ts.map +1 -1
  31. package/dist/context/index.js +1 -1
  32. package/dist/context/index.js.map +1 -1
  33. package/dist/context/write-pipeline.d.ts.map +1 -1
  34. package/dist/context/write-pipeline.js +5 -4
  35. package/dist/context/write-pipeline.js.map +1 -1
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/access/access-filter.test.ts +5 -1
  42. package/src/access/access-filter.ts +12 -7
  43. package/src/access/depth-limits.ts +15 -0
  44. package/src/access/errors.ts +30 -0
  45. package/src/access/field-visibility.ts +87 -18
  46. package/src/access/index.ts +2 -0
  47. package/src/access/multi-column-read-write.test.ts +1 -1
  48. package/src/access/relationship-count.test.ts +1 -1
  49. package/src/access/relationship-label-filter.test.ts +1 -1
  50. package/src/access/types.ts +12 -5
  51. package/src/context/index.ts +1 -1
  52. package/src/context/write-pipeline.ts +5 -4
  53. package/src/index.ts +5 -0
  54. package/tests/access-relationships.test.ts +1 -1
  55. package/tests/context.test.ts +2 -2
  56. package/tests/default-value-create.test.ts +1 -1
  57. package/tests/hook-pipeline.test.ts +1 -1
  58. package/tests/nav-count.test.ts +2 -2
  59. package/tests/resolve-chain.test.ts +394 -0
  60. package/tests/write-pipeline.test.ts +1 -1
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,394 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { getContext } from '../src/context/index.js'
3
+ import { virtual, text, relationship } from '../src/fields/index.js'
4
+ import { ResolveOutputCycleError } from '../src/access/index.js'
5
+ import { RESOLVE_CHAIN_MAX_LENGTH } from '../src/access/depth-limits.js'
6
+ import type { OpenSaasConfig } from '../src/config/types.js'
7
+
8
+ /**
9
+ * Regression coverage for issue #844 / ADR-0023: a `resolveOutput` hook that
10
+ * issues its own read used to be able to recurse without bound, because the
11
+ * only guard was a boolean read of a mutable counter shared by the whole
12
+ * request. The fix is a resolve chain — an ordered list of `(list, field)`
13
+ * pairs, extended by deriving a NEW context per hook invocation rather than
14
+ * mutating one shared value — with a cycle guard that refuses to re-enter a
15
+ * pair already on the chain, and a separate, non-fatal cost cap.
16
+ */
17
+
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ function makeModel(): any {
20
+ return { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), count: vi.fn() }
21
+ }
22
+
23
+ describe('resolve chain — cycle guard terminates hook-issued reads (#844)', () => {
24
+ it('two lists with no relationship fields, whose virtual hooks read each other, throw the cycle error instead of recursing', async () => {
25
+ const config: OpenSaasConfig = {
26
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
27
+ lists: {
28
+ Ping: {
29
+ fields: {
30
+ label: virtual({
31
+ type: 'string',
32
+ hooks: {
33
+ resolveOutput: async ({ context }) => {
34
+ await context.db.pong.findMany({})
35
+ return 'ping'
36
+ },
37
+ },
38
+ }),
39
+ },
40
+ access: { operation: { query: () => true } },
41
+ },
42
+ Pong: {
43
+ fields: {
44
+ label: virtual({
45
+ type: 'string',
46
+ hooks: {
47
+ resolveOutput: async ({ context }) => {
48
+ await context.db.ping.findMany({})
49
+ return 'pong'
50
+ },
51
+ },
52
+ }),
53
+ },
54
+ access: { operation: { query: () => true } },
55
+ },
56
+ },
57
+ }
58
+
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ const prisma: any = { ping: makeModel(), pong: makeModel() }
61
+ prisma.ping.findMany.mockResolvedValue([{ id: 'p1' }])
62
+ prisma.pong.findMany.mockResolvedValue([{ id: 'q1' }])
63
+
64
+ const context = await getContext(config, prisma, null)
65
+
66
+ await expect(context.db.ping.findMany({})).rejects.toThrow(ResolveOutputCycleError)
67
+
68
+ // The cycle must be caught within a handful of hops, never left to grow
69
+ // toward the hundreds of queries the un-bounded chain produced (#844).
70
+ const totalCalls =
71
+ prisma.ping.findMany.mock.calls.length + prisma.pong.findMany.mock.calls.length
72
+ expect(totalCalls).toBeLessThan(10)
73
+ })
74
+
75
+ it('names every (list, field) pair on the chain in order, including the repeat', async () => {
76
+ const config: OpenSaasConfig = {
77
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
78
+ lists: {
79
+ Ping: {
80
+ fields: {
81
+ label: virtual({
82
+ type: 'string',
83
+ hooks: {
84
+ resolveOutput: async ({ context }) => {
85
+ await context.db.pong.findMany({})
86
+ return 'ping'
87
+ },
88
+ },
89
+ }),
90
+ },
91
+ access: { operation: { query: () => true } },
92
+ },
93
+ Pong: {
94
+ fields: {
95
+ label: virtual({
96
+ type: 'string',
97
+ hooks: {
98
+ resolveOutput: async ({ context }) => {
99
+ await context.db.ping.findMany({})
100
+ return 'pong'
101
+ },
102
+ },
103
+ }),
104
+ },
105
+ access: { operation: { query: () => true } },
106
+ },
107
+ },
108
+ }
109
+
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ const prisma: any = { ping: makeModel(), pong: makeModel() }
112
+ prisma.ping.findMany.mockResolvedValue([{ id: 'p1' }])
113
+ prisma.pong.findMany.mockResolvedValue([{ id: 'q1' }])
114
+
115
+ const context = await getContext(config, prisma, null)
116
+
117
+ let caught: unknown
118
+ try {
119
+ await context.db.ping.findMany({})
120
+ } catch (err) {
121
+ caught = err
122
+ }
123
+
124
+ expect(caught).toBeInstanceOf(ResolveOutputCycleError)
125
+ const err = caught as ResolveOutputCycleError
126
+ expect(err.chain).toEqual([
127
+ { listKey: 'Ping', fieldKey: 'label' },
128
+ { listKey: 'Pong', fieldKey: 'label' },
129
+ { listKey: 'Ping', fieldKey: 'label' },
130
+ ])
131
+ expect(err.message).toBe(
132
+ 'resolveOutput cycle detected: Ping.label → Pong.label → Ping.label. A hook that ' +
133
+ 're-enters a (list, field) pair already on its own resolve chain cannot terminate, so ' +
134
+ 'the read is refused rather than left to recurse until the process runs out of memory. ' +
135
+ 'Restructure the hooks so the read does not loop back into itself.',
136
+ )
137
+ })
138
+
139
+ it("reproduces the reporter's 3-list cyclic schema (User → Account → Student) within a bounded query budget", async () => {
140
+ // Sketch matches the issue's minimal reproduction: User.name reads
141
+ // Account, whose Student rows' own virtual field reads Account again.
142
+ const config: OpenSaasConfig = {
143
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
144
+ lists: {
145
+ User: {
146
+ fields: {
147
+ accounts: relationship({ ref: 'Account.user', many: true }),
148
+ name: virtual({
149
+ type: 'string',
150
+ hooks: {
151
+ resolveOutput: async ({ item, context }) => {
152
+ const [a] = await context.db.account.findMany({
153
+ where: { userId: item.id },
154
+ take: 1,
155
+ })
156
+ return `${(a as { firstName?: string } | undefined)?.firstName}`
157
+ },
158
+ },
159
+ }),
160
+ },
161
+ access: { operation: { query: () => true } },
162
+ },
163
+ Account: {
164
+ fields: {
165
+ firstName: text(),
166
+ user: relationship({ ref: 'User.accounts' }),
167
+ students: relationship({ ref: 'Student.account', many: true }),
168
+ },
169
+ access: { operation: { query: () => true } },
170
+ },
171
+ Student: {
172
+ fields: {
173
+ account: relationship({ ref: 'Account.students' }),
174
+ label: virtual({
175
+ type: 'string',
176
+ hooks: {
177
+ resolveOutput: async ({ item, context }) => {
178
+ const a = await context.db.account.findUnique({
179
+ where: { id: item.accountId },
180
+ })
181
+ return `${(a as { firstName?: string } | undefined)?.firstName}`
182
+ },
183
+ },
184
+ }),
185
+ },
186
+ access: { operation: { query: () => true } },
187
+ },
188
+ },
189
+ }
190
+
191
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
192
+ const prisma: any = { user: makeModel(), account: makeModel(), student: makeModel() }
193
+ prisma.user.findMany.mockResolvedValue([{ id: 'u1' }])
194
+ // Account rows returned to a hook-issued read embed their (row-scoped,
195
+ // one-level) relations directly — deliberately WITHOUT `user`, so the
196
+ // walk goes through `students` → `Student.label`, matching the trace in
197
+ // the issue rather than short-circuiting through the `user` back-edge.
198
+ prisma.account.findMany.mockResolvedValue([
199
+ { id: 'a1', firstName: 'Ann', students: [{ id: 's1', accountId: 'a1' }] },
200
+ ])
201
+ // `context.db.<list>.findUnique` is implemented via the Prisma model's
202
+ // `findFirst` (see `createFindUnique` in `context/index.ts`), not its
203
+ // `findUnique` — mock the method actually called.
204
+ prisma.account.findFirst.mockResolvedValue({
205
+ id: 'a1',
206
+ firstName: 'Ann',
207
+ students: [{ id: 's1', accountId: 'a1' }],
208
+ })
209
+
210
+ const context = await getContext(config, prisma, null)
211
+
212
+ // Must settle (not hang), and must not have driven an unbounded number of
213
+ // queries before doing so — this is the actual OOM mechanism from #844.
214
+ await expect(context.db.user.findMany({})).rejects.toThrow(ResolveOutputCycleError)
215
+
216
+ const totalCalls =
217
+ prisma.user.findMany.mock.calls.length +
218
+ prisma.account.findMany.mock.calls.length +
219
+ prisma.account.findFirst.mock.calls.length
220
+ expect(totalCalls).toBeLessThan(20)
221
+ })
222
+ })
223
+
224
+ describe('resolve chain — cost cap is a warning, not a denial (#844)', () => {
225
+ it('an acyclic chain longer than RESOLVE_CHAIN_MAX_LENGTH omits the field and warns once, without throwing', async () => {
226
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
227
+
228
+ // A straight-line chain of distinct lists L0 → L1 → … so no (list, field)
229
+ // pair ever repeats — this chain is acyclic and would terminate on its
230
+ // own; it only needs to be capped as a cost limit.
231
+ const listCount = RESOLVE_CHAIN_MAX_LENGTH + 3
232
+ const lists: OpenSaasConfig['lists'] = {}
233
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
234
+ const prisma: any = {}
235
+ for (let i = 0; i < listCount; i++) {
236
+ const listKey = `L${i}`
237
+ const dbKey = `l${i}`
238
+ const nextDbKey = `l${i + 1}`
239
+ lists[listKey] = {
240
+ fields: {
241
+ next: virtual({
242
+ type: 'string',
243
+ hooks: {
244
+ resolveOutput:
245
+ i < listCount - 1
246
+ ? async ({ context }) => {
247
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
248
+ await (context.db as any)[nextDbKey].findMany({})
249
+ return 'ok'
250
+ }
251
+ : () => 'leaf',
252
+ },
253
+ }),
254
+ },
255
+ access: { operation: { query: () => true } },
256
+ }
257
+ prisma[dbKey] = makeModel()
258
+ prisma[dbKey].findMany.mockResolvedValue([{ id: `${dbKey}-row` }])
259
+ }
260
+
261
+ const config: OpenSaasConfig = {
262
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
263
+ lists,
264
+ }
265
+
266
+ const context = await getContext(config, prisma, null)
267
+
268
+ // Does NOT throw — a cap hit is a cost limit, never a correctness denial.
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
+ const result = await (context.db as any).l0.findMany({})
271
+ expect(result).toBeTruthy()
272
+
273
+ expect(warnSpy).toHaveBeenCalledTimes(1)
274
+ expect(warnSpy.mock.calls[0][0]).toContain('RESOLVE_CHAIN_MAX_LENGTH')
275
+
276
+ // Nothing past the cap is ever queried — the chain simply stops growing.
277
+ for (let i = RESOLVE_CHAIN_MAX_LENGTH + 1; i < listCount; i++) {
278
+ expect(prisma[`l${i}`].findMany).not.toHaveBeenCalled()
279
+ }
280
+
281
+ warnSpy.mockRestore()
282
+ })
283
+ })
284
+
285
+ describe('resolve chain — concurrent hook invocations are isolated (#844)', () => {
286
+ it('sibling rows in a to-many read each observe their own chain of length 1, not a racing shared counter', async () => {
287
+ const observedLengths: number[] = []
288
+
289
+ const config: OpenSaasConfig = {
290
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
291
+ lists: {
292
+ Widget: {
293
+ fields: {
294
+ tag: virtual({
295
+ type: 'string',
296
+ hooks: {
297
+ resolveOutput: async ({ context }) => {
298
+ // Stagger completion so the three hook invocations
299
+ // genuinely interleave rather than running back-to-back.
300
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 5))
301
+ observedLengths.push(context._resolveOutputChain.length)
302
+ return 'tag'
303
+ },
304
+ },
305
+ }),
306
+ },
307
+ access: { operation: { query: () => true } },
308
+ },
309
+ },
310
+ }
311
+
312
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
313
+ const prisma: any = { widget: makeModel() }
314
+ prisma.widget.findMany.mockResolvedValue([{ id: 'w1' }, { id: 'w2' }, { id: 'w3' }])
315
+
316
+ const context = await getContext(config, prisma, null)
317
+ await context.db.widget.findMany({})
318
+
319
+ expect(observedLengths.sort()).toEqual([1, 1, 1])
320
+ })
321
+
322
+ it('an unrelated top-level read in flight alongside a hook still gets its full nested auto-include', async () => {
323
+ let releaseSlowHook: () => void = () => {}
324
+
325
+ const config: OpenSaasConfig = {
326
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
327
+ lists: {
328
+ Slow: {
329
+ fields: {
330
+ tag: virtual({
331
+ type: 'string',
332
+ hooks: {
333
+ resolveOutput: async () => {
334
+ await new Promise<void>((resolve) => {
335
+ releaseSlowHook = resolve
336
+ })
337
+ return 'tag'
338
+ },
339
+ },
340
+ }),
341
+ },
342
+ access: { operation: { query: () => true } },
343
+ },
344
+ Fast: {
345
+ fields: {
346
+ name: text(),
347
+ child: relationship({ ref: 'FastChild' }),
348
+ },
349
+ access: { operation: { query: () => true } },
350
+ },
351
+ FastChild: {
352
+ fields: {
353
+ label: text(),
354
+ grandchild: relationship({ ref: 'FastGrandchild' }),
355
+ },
356
+ access: { operation: { query: () => true } },
357
+ },
358
+ FastGrandchild: {
359
+ fields: { value: text() },
360
+ access: { operation: { query: () => true } },
361
+ },
362
+ },
363
+ }
364
+
365
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
366
+ const prisma: any = { slow: makeModel(), fast: makeModel() }
367
+ prisma.slow.findMany.mockResolvedValue([{ id: 'sl1' }])
368
+ prisma.fast.findMany.mockResolvedValue([{ id: 'f1' }])
369
+
370
+ const context = await getContext(config, prisma, null)
371
+
372
+ // Start the slow read — it blocks inside Slow.tag's hook until released.
373
+ const slowPromise = context.db.slow.findMany({})
374
+
375
+ // Let the slow hook actually start (and derive its context) before racing
376
+ // the unrelated read against it.
377
+ await new Promise((resolve) => setTimeout(resolve, 0))
378
+
379
+ // While the slow hook is still in flight, issue a plain top-level read
380
+ // that has nothing to do with it.
381
+ const fastPromise = context.db.fast.findMany({})
382
+
383
+ await fastPromise
384
+ releaseSlowHook()
385
+ await slowPromise
386
+
387
+ // The unrelated read's auto-include must still descend two levels deep
388
+ // (child → grandchild), not collapse to a bare `{ child: true }` because
389
+ // it happened to run while a totally different read's hook was active.
390
+ expect(prisma.fast.findMany.mock.calls[0][0].include).toEqual({
391
+ child: { include: { grandchild: true } },
392
+ })
393
+ })
394
+ })
@@ -88,7 +88,7 @@ function makeContext(opts?: { isSudo?: boolean }): AccessContext {
88
88
  storage: {} as any,
89
89
  plugins: {},
90
90
  _isSudo: opts?.isSudo ?? false,
91
- _resolveOutputCounter: { depth: 0 },
91
+ _resolveOutputChain: [],
92
92
  }
93
93
  }
94
94