@opensaas/stack-core 0.24.0 → 0.25.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 +223 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,465 @@
|
|
|
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, relationship } from '../src/fields/index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #590 / ADR-0010: transaction-boundary hooks (`beforeTransaction` /
|
|
8
|
+
* `afterTransaction`) run OUTSIDE the write's transaction — `beforeTransaction`
|
|
9
|
+
* before it opens, `afterTransaction` after it settles (always, with the
|
|
10
|
+
* commit/rollback outcome). They form a per-list compensation bracket around the
|
|
11
|
+
* atomic write.
|
|
12
|
+
*
|
|
13
|
+
* These tests reuse a transaction-aware in-memory Prisma mock (mirroring
|
|
14
|
+
* nested-write-hooks.test.ts) so they can assert commit/rollback outcomes, the
|
|
15
|
+
* symmetric-bracket always-run rule, per-list firing across nested writes,
|
|
16
|
+
* compensation when an afterTransaction itself throws, field-level variants, and
|
|
17
|
+
* that sudo does not affect these hooks.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function createTxPrisma() {
|
|
21
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {
|
|
22
|
+
post: new Map(),
|
|
23
|
+
user: new Map(),
|
|
24
|
+
comment: new Map(),
|
|
25
|
+
}
|
|
26
|
+
let idCounter = 0
|
|
27
|
+
const nextId = () => `id-${++idCounter}`
|
|
28
|
+
|
|
29
|
+
function applyNested(
|
|
30
|
+
table: string,
|
|
31
|
+
record: Record<string, unknown>,
|
|
32
|
+
data: Record<string, unknown>,
|
|
33
|
+
): Record<string, unknown> {
|
|
34
|
+
const result = { ...record }
|
|
35
|
+
for (const [key, value] of Object.entries(data)) {
|
|
36
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
37
|
+
const nested = value as Record<string, unknown>
|
|
38
|
+
if (nested.create || nested.update || nested.delete || nested.connect) {
|
|
39
|
+
const relTable = key === 'author' ? 'user' : key === 'comments' ? 'comment' : key
|
|
40
|
+
const linkField = `${key}Link`
|
|
41
|
+
if (nested.create) {
|
|
42
|
+
const created = doCreate(relTable, nested.create as Record<string, unknown>)
|
|
43
|
+
result[linkField] = created.id
|
|
44
|
+
result[key] = created
|
|
45
|
+
}
|
|
46
|
+
if (nested.update) {
|
|
47
|
+
const upd = nested.update as { where: { id: string }; data: Record<string, unknown> }
|
|
48
|
+
const updated = doUpdate(relTable, upd.where, upd.data)
|
|
49
|
+
result[key] = updated
|
|
50
|
+
}
|
|
51
|
+
if (nested.delete) {
|
|
52
|
+
const del = nested.delete as { id: string }
|
|
53
|
+
doDelete(relTable, del)
|
|
54
|
+
result[key] = null
|
|
55
|
+
}
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
result[key] = value
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function doCreate(table: string, data: Record<string, unknown>): Record<string, unknown> {
|
|
65
|
+
const id = (data.id as string) ?? nextId()
|
|
66
|
+
let record: Record<string, unknown> = { id }
|
|
67
|
+
record = applyNested(table, record, data)
|
|
68
|
+
tables[table].set(id, record)
|
|
69
|
+
return record
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function doUpdate(
|
|
73
|
+
table: string,
|
|
74
|
+
where: { id: string },
|
|
75
|
+
data: Record<string, unknown>,
|
|
76
|
+
): Record<string, unknown> {
|
|
77
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
78
|
+
const updated = applyNested(table, existing, data)
|
|
79
|
+
tables[table].set(where.id, updated)
|
|
80
|
+
return updated
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function doDelete(table: string, where: { id: string }): Record<string, unknown> {
|
|
84
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
85
|
+
tables[table].delete(where.id)
|
|
86
|
+
return existing
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeModel(table: string) {
|
|
90
|
+
return {
|
|
91
|
+
findUnique: vi.fn(
|
|
92
|
+
async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
|
|
93
|
+
),
|
|
94
|
+
findFirst: vi.fn(async ({ where }: { where?: { id?: string } }) => {
|
|
95
|
+
if (where?.id) return tables[table].get(where.id) ?? null
|
|
96
|
+
return tables[table].values().next().value ?? null
|
|
97
|
+
}),
|
|
98
|
+
findMany: vi.fn(async () => Array.from(tables[table].values())),
|
|
99
|
+
count: vi.fn(async () => tables[table].size),
|
|
100
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => doCreate(table, data)),
|
|
101
|
+
update: vi.fn(
|
|
102
|
+
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) =>
|
|
103
|
+
doUpdate(table, where, data),
|
|
104
|
+
),
|
|
105
|
+
delete: vi.fn(async ({ where }: { where: { id: string } }) => doDelete(table, where)),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const client: Record<string, unknown> = {
|
|
110
|
+
post: makeModel('post'),
|
|
111
|
+
user: makeModel('user'),
|
|
112
|
+
comment: makeModel('comment'),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
116
|
+
const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
|
|
117
|
+
for (const [name, map] of Object.entries(tables)) {
|
|
118
|
+
snapshot[name] = new Map(map)
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return await fn(client)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
for (const [name, map] of Object.entries(snapshot)) {
|
|
124
|
+
tables[name] = map
|
|
125
|
+
}
|
|
126
|
+
throw err
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { client, tables }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe('#590 transaction-boundary hooks', () => {
|
|
134
|
+
let mock: ReturnType<typeof createTxPrisma>
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
mock = createTxPrisma()
|
|
138
|
+
vi.clearAllMocks()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('commit path: afterTransaction fires with status committed + the persisted item', async () => {
|
|
142
|
+
const before = vi.fn()
|
|
143
|
+
const after = vi.fn()
|
|
144
|
+
|
|
145
|
+
const testConfig = config({
|
|
146
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
147
|
+
lists: {
|
|
148
|
+
User: list({
|
|
149
|
+
fields: { name: text() },
|
|
150
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
151
|
+
hooks: { beforeTransaction: before, afterTransaction: after },
|
|
152
|
+
}),
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
157
|
+
const created = await context.db.user.create({ data: { name: 'jane' } })
|
|
158
|
+
|
|
159
|
+
expect(created).toBeTruthy()
|
|
160
|
+
expect(before).toHaveBeenCalledWith(
|
|
161
|
+
expect.objectContaining({ operation: 'create', listKey: 'User' }),
|
|
162
|
+
)
|
|
163
|
+
expect(after).toHaveBeenCalledTimes(1)
|
|
164
|
+
const arg = after.mock.calls[0][0]
|
|
165
|
+
expect(arg.status).toBe('committed')
|
|
166
|
+
expect(arg.item).toEqual(expect.objectContaining({ name: 'jane' }))
|
|
167
|
+
expect(arg.error).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('beforeTransaction runs before the transaction opens (no writes yet)', async () => {
|
|
171
|
+
const order: string[] = []
|
|
172
|
+
const testConfig = config({
|
|
173
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
174
|
+
lists: {
|
|
175
|
+
User: list({
|
|
176
|
+
fields: { name: text() },
|
|
177
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
178
|
+
hooks: {
|
|
179
|
+
beforeTransaction: () => {
|
|
180
|
+
// No rows persisted at this point.
|
|
181
|
+
order.push(`before:size=${mock.tables.user.size}`)
|
|
182
|
+
},
|
|
183
|
+
afterTransaction: () => {
|
|
184
|
+
order.push(`after:size=${mock.tables.user.size}`)
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
192
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
193
|
+
|
|
194
|
+
expect(order).toEqual(['before:size=0', 'after:size=1'])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('rollback path (thrown afterOperation): afterTransaction fires rolled-back + error + no item', async () => {
|
|
198
|
+
const after = vi.fn()
|
|
199
|
+
|
|
200
|
+
const testConfig = config({
|
|
201
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
202
|
+
lists: {
|
|
203
|
+
User: list({
|
|
204
|
+
fields: { name: text() },
|
|
205
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
206
|
+
hooks: {
|
|
207
|
+
// In-transaction afterOperation throws → the transaction rolls back.
|
|
208
|
+
afterOperation: async () => {
|
|
209
|
+
throw new Error('in-tx boom')
|
|
210
|
+
},
|
|
211
|
+
afterTransaction: after,
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
218
|
+
|
|
219
|
+
await expect(context.db.user.create({ data: { name: 'jane' } })).rejects.toThrow('in-tx boom')
|
|
220
|
+
|
|
221
|
+
expect(after).toHaveBeenCalledTimes(1)
|
|
222
|
+
const arg = after.mock.calls[0][0]
|
|
223
|
+
expect(arg.status).toBe('rolled-back')
|
|
224
|
+
expect(arg.error).toBeInstanceOf(Error)
|
|
225
|
+
expect((arg.error as Error).message).toBe('in-tx boom')
|
|
226
|
+
expect(arg.item).toBeUndefined()
|
|
227
|
+
expect(arg.inputData).toEqual(expect.objectContaining({ name: 'jane' }))
|
|
228
|
+
// Nothing persisted.
|
|
229
|
+
expect(mock.tables.user.size).toBe(0)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('a thrown beforeTransaction aborts the write and fires afterTransaction only for already-run lists', async () => {
|
|
233
|
+
const events: string[] = []
|
|
234
|
+
|
|
235
|
+
const testConfig = config({
|
|
236
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
237
|
+
lists: {
|
|
238
|
+
User: list({
|
|
239
|
+
fields: { name: text() },
|
|
240
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
241
|
+
hooks: {
|
|
242
|
+
// The nested User's beforeTransaction throws.
|
|
243
|
+
beforeTransaction: () => {
|
|
244
|
+
events.push('user:before')
|
|
245
|
+
throw new Error('user before boom')
|
|
246
|
+
},
|
|
247
|
+
afterTransaction: ({ status }) => {
|
|
248
|
+
events.push(`user:after:${status}`)
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
// A list that is NOT involved in the write — must get neither hook.
|
|
253
|
+
Comment: list({
|
|
254
|
+
fields: { body: text() },
|
|
255
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
256
|
+
hooks: {
|
|
257
|
+
beforeTransaction: () => events.push('comment:before'),
|
|
258
|
+
afterTransaction: () => events.push('comment:after'),
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
Post: list({
|
|
262
|
+
fields: {
|
|
263
|
+
title: text(),
|
|
264
|
+
author: relationship({ ref: 'User.posts' }),
|
|
265
|
+
},
|
|
266
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
267
|
+
hooks: {
|
|
268
|
+
beforeTransaction: () => events.push('post:before'),
|
|
269
|
+
afterTransaction: ({ status }) => events.push(`post:after:${status}`),
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
276
|
+
|
|
277
|
+
await expect(
|
|
278
|
+
context.db.post.create({ data: { title: 'T', author: { create: { name: 'x' } } } }),
|
|
279
|
+
).rejects.toThrow('user before boom')
|
|
280
|
+
|
|
281
|
+
// Post (top-level) beforeTransaction ran, then User's threw. Both ran-lists
|
|
282
|
+
// get a rolled-back afterTransaction; the unrelated Comment list gets nothing;
|
|
283
|
+
// the transaction was never opened (no persistence).
|
|
284
|
+
expect(events).toContain('post:before')
|
|
285
|
+
expect(events).toContain('user:before')
|
|
286
|
+
expect(events).toContain('post:after:rolled-back')
|
|
287
|
+
expect(events).toContain('user:after:rolled-back')
|
|
288
|
+
expect(events).not.toContain('comment:before')
|
|
289
|
+
expect(events).not.toContain('comment:after')
|
|
290
|
+
expect(mock.tables.post.size).toBe(0)
|
|
291
|
+
expect(mock.tables.user.size).toBe(0)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('fires per list across a nested write (parent + nested list both bracketed)', async () => {
|
|
295
|
+
const userAfter = vi.fn()
|
|
296
|
+
const postAfter = vi.fn()
|
|
297
|
+
|
|
298
|
+
const testConfig = config({
|
|
299
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
300
|
+
lists: {
|
|
301
|
+
User: list({
|
|
302
|
+
fields: { name: text() },
|
|
303
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
304
|
+
hooks: { beforeTransaction: vi.fn(), afterTransaction: userAfter },
|
|
305
|
+
}),
|
|
306
|
+
Post: list({
|
|
307
|
+
fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
|
|
308
|
+
access: { operation: { query: () => true, update: () => true } },
|
|
309
|
+
hooks: { beforeTransaction: vi.fn(), afterTransaction: postAfter },
|
|
310
|
+
}),
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
mock.tables.post.set('p1', { id: 'p1', title: 'Original' })
|
|
315
|
+
|
|
316
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
317
|
+
await context.db.post.update({
|
|
318
|
+
where: { id: 'p1' },
|
|
319
|
+
data: { title: 'Updated', author: { create: { name: 'john' } } },
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
expect(postAfter).toHaveBeenCalledWith(
|
|
323
|
+
expect.objectContaining({ status: 'committed', operation: 'update', listKey: 'Post' }),
|
|
324
|
+
)
|
|
325
|
+
expect(userAfter).toHaveBeenCalledWith(
|
|
326
|
+
expect.objectContaining({ status: 'committed', operation: 'create', listKey: 'User' }),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
// The committed item/originalItem are surfaced ONLY for the top-level record.
|
|
330
|
+
// The top-level Post (update) gets the persisted item + its originalItem; the
|
|
331
|
+
// nested User (create) gets `item: undefined` — NOT the top-level Post row,
|
|
332
|
+
// which would be the wrong record for the nested list's own item type.
|
|
333
|
+
const postArg = postAfter.mock.calls[0][0]
|
|
334
|
+
expect(postArg.item).toEqual(expect.objectContaining({ id: 'p1', title: 'Updated' }))
|
|
335
|
+
expect(postArg.originalItem).toEqual(expect.objectContaining({ id: 'p1', title: 'Original' }))
|
|
336
|
+
|
|
337
|
+
const userArg = userAfter.mock.calls[0][0]
|
|
338
|
+
expect(userArg.item).toBeUndefined()
|
|
339
|
+
// Nested compensation keys off inputData, not the (unavailable) persisted row.
|
|
340
|
+
expect(userArg.inputData).toEqual(expect.objectContaining({ name: 'john' }))
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('a throwing afterTransaction does not prevent the other afterTransaction hooks running', async () => {
|
|
344
|
+
const fired: string[] = []
|
|
345
|
+
|
|
346
|
+
const testConfig = config({
|
|
347
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
348
|
+
lists: {
|
|
349
|
+
User: list({
|
|
350
|
+
fields: { name: text() },
|
|
351
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
352
|
+
hooks: {
|
|
353
|
+
afterTransaction: () => {
|
|
354
|
+
fired.push('user')
|
|
355
|
+
throw new Error('user after boom')
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
}),
|
|
359
|
+
Post: list({
|
|
360
|
+
fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
|
|
361
|
+
access: { operation: { query: () => true, update: () => true } },
|
|
362
|
+
hooks: {
|
|
363
|
+
afterTransaction: () => {
|
|
364
|
+
fired.push('post')
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
}),
|
|
368
|
+
},
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
mock.tables.post.set('p1', { id: 'p1', title: 'Original' })
|
|
372
|
+
|
|
373
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
374
|
+
|
|
375
|
+
// The write itself committed; surfaced error is the afterTransaction failure.
|
|
376
|
+
await expect(
|
|
377
|
+
context.db.post.update({
|
|
378
|
+
where: { id: 'p1' },
|
|
379
|
+
data: { title: 'Updated', author: { create: { name: 'john' } } },
|
|
380
|
+
}),
|
|
381
|
+
).rejects.toThrow(/afterTransaction hook\(s\) failed/)
|
|
382
|
+
|
|
383
|
+
// Both compensators fired even though one threw.
|
|
384
|
+
expect(fired).toEqual(expect.arrayContaining(['post', 'user']))
|
|
385
|
+
// DB state is final (the write committed).
|
|
386
|
+
expect((mock.tables.post.get('p1') as Record<string, unknown>).title).toBe('Updated')
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('field-level beforeTransaction / afterTransaction variants fire', async () => {
|
|
390
|
+
const fieldBefore = vi.fn()
|
|
391
|
+
const fieldAfter = vi.fn()
|
|
392
|
+
|
|
393
|
+
const testConfig = config({
|
|
394
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
395
|
+
lists: {
|
|
396
|
+
User: list({
|
|
397
|
+
fields: {
|
|
398
|
+
name: text({
|
|
399
|
+
hooks: { beforeTransaction: fieldBefore, afterTransaction: fieldAfter },
|
|
400
|
+
}),
|
|
401
|
+
},
|
|
402
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
403
|
+
}),
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
408
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
409
|
+
|
|
410
|
+
expect(fieldBefore).toHaveBeenCalledWith(
|
|
411
|
+
expect.objectContaining({ operation: 'create', fieldKey: 'name' }),
|
|
412
|
+
)
|
|
413
|
+
expect(fieldAfter).toHaveBeenCalledWith(
|
|
414
|
+
expect.objectContaining({ status: 'committed', operation: 'create', fieldKey: 'name' }),
|
|
415
|
+
)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('sudo path still runs transaction-boundary hooks', async () => {
|
|
419
|
+
const before = vi.fn()
|
|
420
|
+
const after = vi.fn()
|
|
421
|
+
|
|
422
|
+
const testConfig = config({
|
|
423
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
424
|
+
lists: {
|
|
425
|
+
User: list({
|
|
426
|
+
fields: { name: text() },
|
|
427
|
+
// create access denies normally; sudo bypasses access only.
|
|
428
|
+
access: { operation: { query: () => true, create: () => false } },
|
|
429
|
+
hooks: { beforeTransaction: before, afterTransaction: after },
|
|
430
|
+
}),
|
|
431
|
+
},
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
435
|
+
const created = await context.db.user.create({ data: { name: 'sudo-made' } })
|
|
436
|
+
|
|
437
|
+
expect(created).toBeTruthy()
|
|
438
|
+
expect(before).toHaveBeenCalledTimes(1)
|
|
439
|
+
expect(after).toHaveBeenCalledWith(expect.objectContaining({ status: 'committed' }))
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('access-denied (non-sudo) write fires NEITHER transaction-boundary hook', async () => {
|
|
443
|
+
const before = vi.fn()
|
|
444
|
+
const after = vi.fn()
|
|
445
|
+
|
|
446
|
+
const testConfig = config({
|
|
447
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
448
|
+
lists: {
|
|
449
|
+
User: list({
|
|
450
|
+
fields: { name: text() },
|
|
451
|
+
access: { operation: { query: () => true, create: () => false } },
|
|
452
|
+
hooks: { beforeTransaction: before, afterTransaction: after },
|
|
453
|
+
}),
|
|
454
|
+
},
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
458
|
+
const created = await context.db.user.create({ data: { name: 'jane' } })
|
|
459
|
+
|
|
460
|
+
// Silent failure: a denied create returns null and takes no external action.
|
|
461
|
+
expect(created).toBeNull()
|
|
462
|
+
expect(before).not.toHaveBeenCalled()
|
|
463
|
+
expect(after).not.toHaveBeenCalled()
|
|
464
|
+
})
|
|
465
|
+
})
|