@opensaas/stack-core 0.40.0 → 0.42.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 +65 -0
- package/CLAUDE.md +52 -0
- package/dist/access/access-filter.d.ts +88 -18
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +343 -36
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +759 -6
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +11 -0
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +26 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +34 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +47 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -2
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +73 -23
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +7 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +9 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +70 -58
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +16 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +76 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +21 -3
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +23 -29
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1403 -159
- package/src/access/access-filter.ts +525 -47
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.ts +28 -0
- package/src/access/errors.ts +55 -0
- package/src/access/field-visibility.ts +105 -24
- package/src/access/index.ts +11 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +76 -50
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +120 -39
- package/src/context/nested-operations.ts +35 -11
- package/src/context/write-pipeline.ts +52 -32
- package/src/hooks/index.ts +22 -15
- package/tests/context.test.ts +193 -5
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/nested-access-and-hooks.test.ts +324 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { config, list } from '../src/config/index.js'
|
|
4
|
+
import { text } from '../src/fields/index.js'
|
|
5
|
+
import { ResolveOutputCycleError } from '../src/access/index.js'
|
|
6
|
+
import type { Plugin } from '../src/config/types.js'
|
|
7
|
+
import type { StackContext } from '../src/context/index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* #1176: a list/field `resolveInput`/`validate`/`beforeOperation`/
|
|
11
|
+
* `afterOperation` hook now receives the FULL secured {@link StackContext} —
|
|
12
|
+
* `sudo()`, `withSession()`, `transaction()`, `serverAction` — bound to the
|
|
13
|
+
* write's OWN transaction client, instead of the bare `AccessContext` object
|
|
14
|
+
* literal `bindContextToTransaction` used to hand-assemble. See ADR-0012's
|
|
15
|
+
* amendment and `packages/core/CLAUDE.md`.
|
|
16
|
+
*
|
|
17
|
+
* `beforeTransaction`/`afterTransaction` are OUT of scope (ADR-0028): they
|
|
18
|
+
* keep a base-client `AccessContext`, unchanged by this issue.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A transaction-aware mock whose `$transaction` hands the callback a `tx`
|
|
23
|
+
* object that is NOT `client` itself (mirroring real Prisma, and required to
|
|
24
|
+
* prove a hook's context is bound to the TRANSACTION client, not the base
|
|
25
|
+
* one) and — faithfully — has no `$transaction` of its own, so a nested
|
|
26
|
+
* `context.transaction()`/`context.db` write can only join, never nest.
|
|
27
|
+
*/
|
|
28
|
+
function createTxPrisma(tableNames: string[]) {
|
|
29
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {}
|
|
30
|
+
for (const name of tableNames) tables[name] = new Map()
|
|
31
|
+
let idCounter = 0
|
|
32
|
+
const nextId = () => `id-${++idCounter}`
|
|
33
|
+
|
|
34
|
+
function makeModel(table: string) {
|
|
35
|
+
return {
|
|
36
|
+
findUnique: vi.fn(
|
|
37
|
+
async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
|
|
38
|
+
),
|
|
39
|
+
findFirst: vi.fn(async () => tables[table].values().next().value ?? null),
|
|
40
|
+
findMany: vi.fn(async () => Array.from(tables[table].values())),
|
|
41
|
+
count: vi.fn(async () => tables[table].size),
|
|
42
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
43
|
+
const id = (data.id as string) ?? nextId()
|
|
44
|
+
const record = { ...data, id }
|
|
45
|
+
tables[table].set(id, record)
|
|
46
|
+
return record
|
|
47
|
+
}),
|
|
48
|
+
update: vi.fn(
|
|
49
|
+
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
50
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
51
|
+
const updated = { ...existing, ...data }
|
|
52
|
+
tables[table].set(where.id, updated)
|
|
53
|
+
return updated
|
|
54
|
+
},
|
|
55
|
+
),
|
|
56
|
+
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
57
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
58
|
+
tables[table].delete(where.id)
|
|
59
|
+
return existing
|
|
60
|
+
}),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const client: Record<string, unknown> = {}
|
|
65
|
+
for (const name of tableNames) client[name] = makeModel(name)
|
|
66
|
+
|
|
67
|
+
const transactionSpy = vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
68
|
+
const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
|
|
69
|
+
for (const [name, map] of Object.entries(tables)) snapshot[name] = new Map(map)
|
|
70
|
+
// Faithful to real Prisma: the tx client has the model delegates but NOT
|
|
71
|
+
// `$transaction` — a nested write detects this and joins rather than
|
|
72
|
+
// opening a second transaction. Spreading into a NEW object also makes
|
|
73
|
+
// `tx !== client`, which the "bound to tx, not the base client" tests key on.
|
|
74
|
+
const { $transaction: _omit, ...models } = client
|
|
75
|
+
void _omit
|
|
76
|
+
try {
|
|
77
|
+
return await fn(models)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
for (const [name, map] of Object.entries(snapshot)) tables[name] = map
|
|
80
|
+
throw err
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
client.$transaction = transactionSpy
|
|
84
|
+
|
|
85
|
+
return { client, tables, transactionSpy }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('#1176 hook context is a full secured StackContext bound to the transaction', () => {
|
|
89
|
+
let mock: ReturnType<typeof createTxPrisma>
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
mock = createTxPrisma(['user'])
|
|
93
|
+
vi.clearAllMocks()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('resolveInput/validate/beforeOperation/afterOperation (list AND field) all receive sudo/withSession/transaction/serverAction, bound to `tx`', async () => {
|
|
97
|
+
const captured: StackContext[] = []
|
|
98
|
+
|
|
99
|
+
const testConfig = await config({
|
|
100
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
101
|
+
lists: {
|
|
102
|
+
User: list({
|
|
103
|
+
fields: {
|
|
104
|
+
name: text({
|
|
105
|
+
hooks: {
|
|
106
|
+
resolveInput: async ({ context, resolvedData, fieldKey }) => {
|
|
107
|
+
captured.push(context)
|
|
108
|
+
return resolvedData[fieldKey]
|
|
109
|
+
},
|
|
110
|
+
validate: async ({ context }) => {
|
|
111
|
+
captured.push(context)
|
|
112
|
+
},
|
|
113
|
+
beforeOperation: async ({ context }) => {
|
|
114
|
+
captured.push(context)
|
|
115
|
+
},
|
|
116
|
+
afterOperation: async ({ context }) => {
|
|
117
|
+
captured.push(context)
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
123
|
+
hooks: {
|
|
124
|
+
resolveInput: async ({ context, resolvedData }) => {
|
|
125
|
+
captured.push(context)
|
|
126
|
+
return resolvedData
|
|
127
|
+
},
|
|
128
|
+
validate: async ({ context }) => {
|
|
129
|
+
captured.push(context)
|
|
130
|
+
},
|
|
131
|
+
beforeOperation: async ({ context }) => {
|
|
132
|
+
captured.push(context)
|
|
133
|
+
},
|
|
134
|
+
afterOperation: async ({ context }) => {
|
|
135
|
+
captured.push(context)
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
143
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
144
|
+
|
|
145
|
+
// list resolveInput/validate/beforeOperation/afterOperation + the same 4
|
|
146
|
+
// at field level = 8 hook invocations, one create.
|
|
147
|
+
expect(captured).toHaveLength(8)
|
|
148
|
+
|
|
149
|
+
for (const hookContext of captured) {
|
|
150
|
+
expect(typeof hookContext.sudo).toBe('function')
|
|
151
|
+
expect(typeof hookContext.withSession).toBe('function')
|
|
152
|
+
expect(typeof hookContext.transaction).toBe('function')
|
|
153
|
+
expect(typeof hookContext.serverAction).toBe('function')
|
|
154
|
+
// Bound to the transaction client, never the base one.
|
|
155
|
+
expect(hookContext.prisma).not.toBe(mock.client)
|
|
156
|
+
expect(hookContext.sudo().prisma).toBe(hookContext.prisma)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('a context.sudo()/.withSession() write from beforeOperation succeeds against an access-denied-for-the-session list, and rolls back with the outer write', async () => {
|
|
161
|
+
async function run(shouldThrow: boolean) {
|
|
162
|
+
const denyMock = createTxPrisma(['user', 'draft'])
|
|
163
|
+
const testConfig = await config({
|
|
164
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
165
|
+
lists: {
|
|
166
|
+
User: list({
|
|
167
|
+
fields: { name: text() },
|
|
168
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
169
|
+
hooks: {
|
|
170
|
+
beforeOperation: async ({ context, operation }) => {
|
|
171
|
+
if (operation !== 'create') return
|
|
172
|
+
// Denied for this session under normal access — only sudo can write it.
|
|
173
|
+
await context.sudo().db.draft.create({ data: { note: 'draft' } })
|
|
174
|
+
if (shouldThrow) {
|
|
175
|
+
throw new Error('boom')
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
Draft: list({
|
|
181
|
+
fields: { note: text() },
|
|
182
|
+
access: { operation: { query: () => true, create: () => false } },
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
const context = getContext(testConfig, denyMock.client, { userId: '1' })
|
|
187
|
+
|
|
188
|
+
if (shouldThrow) {
|
|
189
|
+
await expect(context.db.user.create({ data: { name: 'jane' } })).rejects.toThrow('boom')
|
|
190
|
+
} else {
|
|
191
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
192
|
+
}
|
|
193
|
+
return denyMock
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const committed = await run(false)
|
|
197
|
+
expect(committed.tables.draft.size).toBe(1)
|
|
198
|
+
|
|
199
|
+
const rolledBack = await run(true)
|
|
200
|
+
expect(rolledBack.tables.draft.size).toBe(0)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('context.withSession() from a hook stays bound to `tx` and preserves the receiver sudo state; sudo()/withSession() compose in either order', async () => {
|
|
204
|
+
const seen: { withSessionPrisma?: unknown; ownPrisma?: unknown; combos?: boolean[] } = {}
|
|
205
|
+
|
|
206
|
+
const testConfig = await config({
|
|
207
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
208
|
+
lists: {
|
|
209
|
+
User: list({
|
|
210
|
+
fields: { name: text() },
|
|
211
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
212
|
+
hooks: {
|
|
213
|
+
beforeOperation: async ({ context }) => {
|
|
214
|
+
seen.ownPrisma = context.prisma
|
|
215
|
+
seen.withSessionPrisma = context.withSession({ userId: '2' }).prisma
|
|
216
|
+
seen.combos = [
|
|
217
|
+
context.sudo().withSession({ userId: '2' })._isSudo,
|
|
218
|
+
context.withSession({ userId: '2' }).sudo()._isSudo,
|
|
219
|
+
context.withSession({ userId: '2' })._isSudo, // non-sudo receiver stays non-sudo
|
|
220
|
+
]
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
228
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
229
|
+
|
|
230
|
+
expect(seen.withSessionPrisma).toBe(seen.ownPrisma)
|
|
231
|
+
expect(seen.combos).toEqual([true, true, false])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('context.transaction() called from a hook JOINS the write transaction rather than opening a nested one', async () => {
|
|
235
|
+
const joinMock = createTxPrisma(['user', 'note'])
|
|
236
|
+
const testConfig = await config({
|
|
237
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
238
|
+
lists: {
|
|
239
|
+
User: list({
|
|
240
|
+
fields: { name: text() },
|
|
241
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
242
|
+
hooks: {
|
|
243
|
+
beforeOperation: async ({ context }) => {
|
|
244
|
+
await context.transaction(async (tx2) => {
|
|
245
|
+
await tx2.db.note.create({ data: { name: 'joined' } })
|
|
246
|
+
})
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
Note: list({
|
|
251
|
+
fields: { name: text() },
|
|
252
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const context = getContext(testConfig, joinMock.client, { userId: '1' })
|
|
258
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
259
|
+
|
|
260
|
+
// Exactly the one outer transaction — the hook's `context.transaction()`
|
|
261
|
+
// joined it instead of calling `$transaction` again.
|
|
262
|
+
expect(joinMock.transactionSpy).toHaveBeenCalledTimes(1)
|
|
263
|
+
expect(joinMock.tables.user.size).toBe(1)
|
|
264
|
+
expect(joinMock.tables.note.size).toBe(1)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('a write issued through a hook context.sudo() defers afterTransaction to the outer settle and reports the outer outcome', async () => {
|
|
268
|
+
async function run(commentThrows: boolean) {
|
|
269
|
+
const m = createTxPrisma(['user', 'comment', 'audit'])
|
|
270
|
+
const auditAfter = vi.fn()
|
|
271
|
+
const testConfig = await config({
|
|
272
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
273
|
+
lists: {
|
|
274
|
+
User: list({
|
|
275
|
+
fields: { name: text() },
|
|
276
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
277
|
+
hooks: {
|
|
278
|
+
afterOperation: async ({ context, operation }) => {
|
|
279
|
+
if (operation === 'create') {
|
|
280
|
+
await context.sudo().db.audit.create({ data: { note: 'audit' } })
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
Comment: list({
|
|
286
|
+
fields: { body: text() },
|
|
287
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
288
|
+
hooks: commentThrows
|
|
289
|
+
? {
|
|
290
|
+
afterOperation: () => {
|
|
291
|
+
throw new Error('comment boom')
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
: undefined,
|
|
295
|
+
}),
|
|
296
|
+
// Denied for the session — only reachable via sudo, proving the
|
|
297
|
+
// deferred afterTransaction reports the join even for a sudo write.
|
|
298
|
+
Audit: list({
|
|
299
|
+
fields: { note: text() },
|
|
300
|
+
access: { operation: { query: () => true, create: () => false } },
|
|
301
|
+
hooks: { afterTransaction: auditAfter },
|
|
302
|
+
}),
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
const context = getContext(testConfig, m.client, { userId: '1' })
|
|
306
|
+
|
|
307
|
+
if (commentThrows) {
|
|
308
|
+
await expect(
|
|
309
|
+
context.transaction(async (tx) => {
|
|
310
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
311
|
+
await tx.db.comment.create({ data: { body: 'trigger' } })
|
|
312
|
+
}),
|
|
313
|
+
).rejects.toThrow('comment boom')
|
|
314
|
+
} else {
|
|
315
|
+
await context.transaction(async (tx) => {
|
|
316
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { m, auditAfter }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const committed = await run(false)
|
|
324
|
+
expect(committed.m.tables.audit.size).toBe(1)
|
|
325
|
+
expect(committed.auditAfter).toHaveBeenCalledTimes(1)
|
|
326
|
+
expect(committed.auditAfter.mock.calls[0][0].status).toBe('committed')
|
|
327
|
+
|
|
328
|
+
const rolledBack = await run(true)
|
|
329
|
+
expect(rolledBack.m.tables.audit.size).toBe(0)
|
|
330
|
+
expect(rolledBack.auditAfter).toHaveBeenCalledTimes(1)
|
|
331
|
+
expect(rolledBack.auditAfter.mock.calls[0][0].status).toBe('rolled-back')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('plugin runtime() is invoked exactly once per request, even across a hook-firing write with a nested context.db write', async () => {
|
|
335
|
+
const runtimeSpy = vi.fn(() => ({}))
|
|
336
|
+
const testPlugin: Plugin = {
|
|
337
|
+
name: 'test-plugin',
|
|
338
|
+
init: () => {},
|
|
339
|
+
runtime: runtimeSpy,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const nestedMock = createTxPrisma(['user', 'audit'])
|
|
343
|
+
const testConfig = await config({
|
|
344
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
345
|
+
plugins: [testPlugin],
|
|
346
|
+
lists: {
|
|
347
|
+
User: list({
|
|
348
|
+
fields: { name: text() },
|
|
349
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
350
|
+
hooks: {
|
|
351
|
+
afterOperation: async ({ context, operation }) => {
|
|
352
|
+
if (operation === 'create') {
|
|
353
|
+
await context.db.audit.create({ data: { note: 'audit' } })
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
}),
|
|
358
|
+
Audit: list({
|
|
359
|
+
fields: { note: text() },
|
|
360
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
361
|
+
}),
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const context = getContext(testConfig, nestedMock.client, { userId: '1' })
|
|
366
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
367
|
+
|
|
368
|
+
expect(runtimeSpy).toHaveBeenCalledTimes(1)
|
|
369
|
+
expect(nestedMock.tables.audit.size).toBe(1)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it("a write issued from inside a resolveOutput hook carries that hook's resolve chain into the write's own Field Visibility pass (ADR-0023)", async () => {
|
|
373
|
+
// Ping.label's resolveOutput creates a Pong row, whose own Field
|
|
374
|
+
// Visibility pass (Phase 11 of THAT write) computes Pong.label — which
|
|
375
|
+
// creates a Ping row, whose Field Visibility pass computes Ping.label
|
|
376
|
+
// again. If (and only if) the write's hook context carries the resolve
|
|
377
|
+
// chain forward, that third hop re-enters `Ping.label`, already on the
|
|
378
|
+
// chain, and the cycle guard throws instead of recursing forever.
|
|
379
|
+
const { virtual } = await import('../src/fields/index.js')
|
|
380
|
+
|
|
381
|
+
const cycleMock = createTxPrisma(['ping', 'pong'])
|
|
382
|
+
const testConfig = await config({
|
|
383
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
384
|
+
lists: {
|
|
385
|
+
Ping: list({
|
|
386
|
+
fields: {
|
|
387
|
+
name: text(),
|
|
388
|
+
label: virtual({
|
|
389
|
+
type: 'string',
|
|
390
|
+
hooks: {
|
|
391
|
+
resolveOutput: async ({ context }) => {
|
|
392
|
+
await context.db.pong.create({ data: { name: 'q' } })
|
|
393
|
+
return 'ping'
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
}),
|
|
397
|
+
},
|
|
398
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
399
|
+
}),
|
|
400
|
+
Pong: list({
|
|
401
|
+
fields: {
|
|
402
|
+
name: text(),
|
|
403
|
+
label: virtual({
|
|
404
|
+
type: 'string',
|
|
405
|
+
hooks: {
|
|
406
|
+
resolveOutput: async ({ context }) => {
|
|
407
|
+
await context.db.ping.create({ data: { name: 'p' } })
|
|
408
|
+
return 'pong'
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
},
|
|
413
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
414
|
+
}),
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
const context = getContext(testConfig, cycleMock.client, { userId: '1' })
|
|
419
|
+
|
|
420
|
+
await expect(context.db.ping.create({ data: { name: 'p0' } })).rejects.toThrow(
|
|
421
|
+
ResolveOutputCycleError,
|
|
422
|
+
)
|
|
423
|
+
})
|
|
424
|
+
})
|