@opensaas/stack-core 0.32.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +61 -0
- package/dist/access/access-filter.d.ts +76 -20
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +102 -70
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +175 -10
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/depth-limits.d.ts +26 -0
- package/dist/access/depth-limits.d.ts.map +1 -0
- package/dist/access/depth-limits.js +26 -0
- package/dist/access/depth-limits.js.map +1 -0
- package/dist/access/errors.d.ts +43 -0
- package/dist/access/errors.d.ts.map +1 -0
- package/dist/access/errors.js +55 -0
- package/dist/access/errors.js.map +1 -0
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +82 -20
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +5 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.test.js +1 -1
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +1 -1
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/types.d.ts +15 -7
- package/dist/access/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +15 -9
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +1 -5
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +43 -6
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +5 -4
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +258 -7
- package/src/access/access-filter.ts +146 -72
- package/src/access/depth-limits.ts +26 -0
- package/src/access/errors.ts +62 -0
- package/src/access/field-visibility.ts +97 -21
- package/src/access/index.ts +6 -0
- package/src/access/multi-column-read-write.test.ts +1 -1
- package/src/access/relationship-count.test.ts +1 -1
- package/src/access/relationship-label-filter.test.ts +1 -1
- package/src/access/types.ts +12 -5
- package/src/context/index.ts +15 -6
- package/src/context/nested-operations.ts +0 -7
- package/src/context/transaction-boundary.ts +48 -7
- package/src/context/write-pipeline.ts +5 -4
- package/src/index.ts +11 -0
- package/tests/access-relationships.test.ts +78 -64
- package/tests/context.test.ts +106 -24
- package/tests/default-value-create.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +1 -1
- package/tests/nav-count.test.ts +2 -2
- package/tests/resolve-chain.test.ts +394 -0
- package/tests/transaction-boundary-hooks.test.ts +246 -1
- package/tests/write-pipeline.test.ts +1 -1
- 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
|
+
})
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
2
2
|
import { getContext } from '../src/context/index.js'
|
|
3
3
|
import { config, list } from '../src/config/index.js'
|
|
4
4
|
import { text, relationship } from '../src/fields/index.js'
|
|
5
|
+
import { enumerateInvolvedLists } from '../src/context/transaction-boundary.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* #590 / ADR-0010: transaction-boundary hooks (`beforeTransaction` /
|
|
@@ -17,12 +18,13 @@ import { text, relationship } from '../src/fields/index.js'
|
|
|
17
18
|
* that sudo does not affect these hooks.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
|
-
function createTxPrisma() {
|
|
21
|
+
function createTxPrisma(extraTables: string[] = []) {
|
|
21
22
|
const tables: Record<string, Map<string, Record<string, unknown>>> = {
|
|
22
23
|
post: new Map(),
|
|
23
24
|
user: new Map(),
|
|
24
25
|
comment: new Map(),
|
|
25
26
|
}
|
|
27
|
+
for (const table of extraTables) tables[table] = new Map()
|
|
26
28
|
let idCounter = 0
|
|
27
29
|
const nextId = () => `id-${++idCounter}`
|
|
28
30
|
|
|
@@ -111,6 +113,7 @@ function createTxPrisma() {
|
|
|
111
113
|
user: makeModel('user'),
|
|
112
114
|
comment: makeModel('comment'),
|
|
113
115
|
}
|
|
116
|
+
for (const table of extraTables) client[table] = makeModel(table)
|
|
114
117
|
|
|
115
118
|
client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
116
119
|
const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
|
|
@@ -463,3 +466,245 @@ describe('#590 transaction-boundary hooks', () => {
|
|
|
463
466
|
expect(after).not.toHaveBeenCalled()
|
|
464
467
|
})
|
|
465
468
|
})
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* #835: `enumerateInvolvedLists`'s walk used to stop at a fixed depth cap
|
|
472
|
+
* (`MAX_DEPTH = 5`), so lists reachable only past it never entered the
|
|
473
|
+
* involved-list set and their transaction-boundary hooks silently never fired.
|
|
474
|
+
* The fix replaces the depth cap with a saturation bound computed from the
|
|
475
|
+
* CONFIG's relationship graph reachable from the top-level list: the walk
|
|
476
|
+
* stops once every (list, operation) pair it could ever find has been
|
|
477
|
+
* recorded, regardless of how deep the payload nests.
|
|
478
|
+
*/
|
|
479
|
+
describe('#835 enumerateInvolvedLists — saturation-bound enumeration walk', () => {
|
|
480
|
+
function chainConfigLists(length: number) {
|
|
481
|
+
const lists: Record<string, ReturnType<typeof list>> = {}
|
|
482
|
+
for (let i = 1; i <= length; i++) {
|
|
483
|
+
const name = `L${i}`
|
|
484
|
+
const fields: Record<string, ReturnType<typeof text> | ReturnType<typeof relationship>> = {
|
|
485
|
+
name: text(),
|
|
486
|
+
}
|
|
487
|
+
if (i < length) {
|
|
488
|
+
fields[`l${i + 1}`] = relationship({ ref: `L${i + 1}` })
|
|
489
|
+
}
|
|
490
|
+
lists[name] = list({ fields })
|
|
491
|
+
}
|
|
492
|
+
return lists
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function chainInputData(length: number): Record<string, unknown> {
|
|
496
|
+
let payload: Record<string, unknown> = { name: `r${length}` }
|
|
497
|
+
for (let i = length - 1; i >= 1; i--) {
|
|
498
|
+
payload = { name: `r${i}`, [`l${i + 1}`]: { create: payload } }
|
|
499
|
+
}
|
|
500
|
+
return payload
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
it('enumerates every list in an 8-list chain, deeper than the old fixed depth cap of 5', async () => {
|
|
504
|
+
const resolvedConfig = await config({
|
|
505
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
506
|
+
lists: chainConfigLists(8),
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
const involved = enumerateInvolvedLists({
|
|
510
|
+
listName: 'L1',
|
|
511
|
+
listConfig: resolvedConfig.lists.L1,
|
|
512
|
+
operation: 'create',
|
|
513
|
+
inputData: chainInputData(8),
|
|
514
|
+
topLevelOriginalItem: undefined,
|
|
515
|
+
config: resolvedConfig,
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
expect(involved.map((i) => i.listKey)).toEqual(['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7', 'L8'])
|
|
519
|
+
expect(involved.map((i) => i.operation)).toEqual(Array(8).fill('create'))
|
|
520
|
+
// The top-level list is first and is the only one marked isTopLevel.
|
|
521
|
+
expect(involved[0].isTopLevel).toBe(true)
|
|
522
|
+
expect(involved.slice(1).every((i) => !i.isTopLevel)).toBe(true)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('dedupes by (list, operation) when a list is nested many times in one payload', async () => {
|
|
526
|
+
const resolvedConfig = await config({
|
|
527
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
528
|
+
lists: {
|
|
529
|
+
Parent: list({
|
|
530
|
+
fields: { name: text(), children: relationship({ ref: 'Child', many: true }) },
|
|
531
|
+
}),
|
|
532
|
+
Child: list({ fields: { name: text() } }),
|
|
533
|
+
},
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
const involved = enumerateInvolvedLists({
|
|
537
|
+
listName: 'Parent',
|
|
538
|
+
listConfig: resolvedConfig.lists.Parent,
|
|
539
|
+
operation: 'create',
|
|
540
|
+
inputData: {
|
|
541
|
+
name: 'p',
|
|
542
|
+
children: { create: [{ name: 'c1' }, { name: 'c2' }, { name: 'c3' }] },
|
|
543
|
+
},
|
|
544
|
+
topLevelOriginalItem: undefined,
|
|
545
|
+
config: resolvedConfig,
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// Three nested Child creates collapse into a single involvement — the
|
|
549
|
+
// hooks are a per-LIST compensation bracket, not per-record.
|
|
550
|
+
expect(involved.map((i) => `${i.listKey}:${i.operation}`)).toEqual([
|
|
551
|
+
'Parent:create',
|
|
552
|
+
'Child:create',
|
|
553
|
+
])
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('stops descending once every reachable (list, operation) pair is recorded, without inspecting payload past that point', async () => {
|
|
557
|
+
// Node self-references, so the reachable closure from Node is just
|
|
558
|
+
// {Node} — the saturation bound is 1 list * 3 operations = 3 pairs.
|
|
559
|
+
// Extra1/Extra2 are unrelated lists in the same config: if the bound were
|
|
560
|
+
// ever computed from the WHOLE config instead of the graph reachable
|
|
561
|
+
// from the write's own top-level list, the bound would be inflated to 9
|
|
562
|
+
// and this test's trap (below) would fire.
|
|
563
|
+
const resolvedConfig = await config({
|
|
564
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
565
|
+
lists: {
|
|
566
|
+
Node: list({
|
|
567
|
+
fields: {
|
|
568
|
+
name: text(),
|
|
569
|
+
childrenA: relationship({ ref: 'Node', many: true }),
|
|
570
|
+
childrenB: relationship({ ref: 'Node', many: true }),
|
|
571
|
+
},
|
|
572
|
+
}),
|
|
573
|
+
Extra1: list({ fields: { name: text() } }),
|
|
574
|
+
Extra2: list({ fields: { name: text() } }),
|
|
575
|
+
},
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
// A payload entry that must NEVER be walked once the walk has saturated —
|
|
579
|
+
// reading its `childrenA` property throws, so any attempt to descend
|
|
580
|
+
// into it fails the test with a thrown error instead of relying on timing.
|
|
581
|
+
const trap: Record<string, unknown> = { name: 'trap' }
|
|
582
|
+
Object.defineProperty(trap, 'childrenA', {
|
|
583
|
+
enumerable: true,
|
|
584
|
+
get(): never {
|
|
585
|
+
throw new Error('walkNested must not descend past the saturation bound')
|
|
586
|
+
},
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
const involved = enumerateInvolvedLists({
|
|
590
|
+
listName: 'Node',
|
|
591
|
+
listConfig: resolvedConfig.lists.Node,
|
|
592
|
+
operation: 'create',
|
|
593
|
+
inputData: {
|
|
594
|
+
name: 'root',
|
|
595
|
+
// Completes the saturation bound: seed (Node:create) + Node:update +
|
|
596
|
+
// Node:delete = 3 pairs = the full reachable closure for Node.
|
|
597
|
+
childrenA: {
|
|
598
|
+
update: [{ where: { id: 'u1' }, data: { name: 'updated' } }],
|
|
599
|
+
delete: [{ id: 'd1' }],
|
|
600
|
+
},
|
|
601
|
+
// Processed after childrenA (insertion order) — by the time the walk
|
|
602
|
+
// reaches it, the bound is already saturated, so `trap` must never
|
|
603
|
+
// be descended into.
|
|
604
|
+
childrenB: { create: [trap] },
|
|
605
|
+
},
|
|
606
|
+
topLevelOriginalItem: undefined,
|
|
607
|
+
config: resolvedConfig,
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
expect(involved.map((i) => `${i.listKey}:${i.operation}`)).toEqual([
|
|
611
|
+
'Node:create',
|
|
612
|
+
'Node:update',
|
|
613
|
+
'Node:delete',
|
|
614
|
+
])
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* #835 integration: the enumeration fix must not change the (separately
|
|
620
|
+
* verified, and unrelated) fact that nested writes stay access-checked at
|
|
621
|
+
* every depth — `processNestedOperations`'s own depth guard was already dead
|
|
622
|
+
* code before this fix (neither recursive call site nor the Write Pipeline
|
|
623
|
+
* ever passed a depth), so nested access control was never gated by depth and
|
|
624
|
+
* remains that way.
|
|
625
|
+
*/
|
|
626
|
+
describe('#835 deep nested writes remain access-checked at every depth', () => {
|
|
627
|
+
function chainLists(length: number, denyCreateAt?: number) {
|
|
628
|
+
const lists: Record<string, ReturnType<typeof list>> = {}
|
|
629
|
+
for (let i = 1; i <= length; i++) {
|
|
630
|
+
const name = `L${i}`
|
|
631
|
+
const fields: Record<string, ReturnType<typeof text> | ReturnType<typeof relationship>> = {
|
|
632
|
+
name: text(),
|
|
633
|
+
}
|
|
634
|
+
if (i < length) {
|
|
635
|
+
fields[`l${i + 1}`] = relationship({ ref: `L${i + 1}` })
|
|
636
|
+
}
|
|
637
|
+
lists[name] = list({
|
|
638
|
+
fields,
|
|
639
|
+
access: {
|
|
640
|
+
operation: {
|
|
641
|
+
query: () => true,
|
|
642
|
+
create: () => denyCreateAt !== i,
|
|
643
|
+
update: () => true,
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
return lists
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function chainInputData(length: number): Record<string, unknown> {
|
|
652
|
+
let payload: Record<string, unknown> = { name: `r${length}` }
|
|
653
|
+
for (let i = length - 1; i >= 1; i--) {
|
|
654
|
+
payload = { name: `r${i}`, [`l${i + 1}`]: { create: payload } }
|
|
655
|
+
}
|
|
656
|
+
return payload
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
it('throws when a nested create 6 levels deep is denied, even though 6 is past the old enumeration depth cap', async () => {
|
|
660
|
+
const tables = Array.from({ length: 8 }, (_, i) => `l${i + 1}`)
|
|
661
|
+
const mock = createTxPrisma(tables)
|
|
662
|
+
|
|
663
|
+
const testConfig = config({
|
|
664
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
665
|
+
lists: chainLists(8, 6),
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
669
|
+
|
|
670
|
+
await expect(context.db.l1.create({ data: chainInputData(8) })).rejects.toThrow(
|
|
671
|
+
/access denied/i,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
// Nothing was persisted — the whole write was aborted by the denial.
|
|
675
|
+
for (const table of tables) {
|
|
676
|
+
expect(mock.tables[table].size).toBe(0)
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('fires beforeTransaction/afterTransaction for every list in an 8-list chain, including the deepest', async () => {
|
|
681
|
+
const tables = Array.from({ length: 8 }, (_, i) => `l${i + 1}`)
|
|
682
|
+
const mock = createTxPrisma(tables)
|
|
683
|
+
|
|
684
|
+
const fired: string[] = []
|
|
685
|
+
const lists = chainLists(8)
|
|
686
|
+
for (const [name, listConfig] of Object.entries(lists)) {
|
|
687
|
+
listConfig.hooks = {
|
|
688
|
+
beforeTransaction: () => {
|
|
689
|
+
fired.push(`before:${name}`)
|
|
690
|
+
},
|
|
691
|
+
afterTransaction: () => {
|
|
692
|
+
fired.push(`after:${name}`)
|
|
693
|
+
},
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const testConfig = config({
|
|
698
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
699
|
+
lists,
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
703
|
+
await context.db.l1.create({ data: chainInputData(8) })
|
|
704
|
+
|
|
705
|
+
for (let i = 1; i <= 8; i++) {
|
|
706
|
+
expect(fired).toContain(`before:L${i}`)
|
|
707
|
+
expect(fired).toContain(`after:L${i}`)
|
|
708
|
+
}
|
|
709
|
+
})
|
|
710
|
+
})
|