@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.
Files changed (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +223 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/types.d.ts +318 -0
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/context/index.d.ts +19 -1
  22. package/dist/context/index.d.ts.map +1 -1
  23. package/dist/context/index.js +153 -26
  24. package/dist/context/index.js.map +1 -1
  25. package/dist/context/nested-operations.d.ts +59 -3
  26. package/dist/context/nested-operations.d.ts.map +1 -1
  27. package/dist/context/nested-operations.js +552 -129
  28. package/dist/context/nested-operations.js.map +1 -1
  29. package/dist/context/transaction-boundary.d.ts +91 -0
  30. package/dist/context/transaction-boundary.d.ts.map +1 -0
  31. package/dist/context/transaction-boundary.js +329 -0
  32. package/dist/context/transaction-boundary.js.map +1 -0
  33. package/dist/context/write-pipeline.d.ts +15 -1
  34. package/dist/context/write-pipeline.d.ts.map +1 -1
  35. package/dist/context/write-pipeline.js +173 -10
  36. package/dist/context/write-pipeline.js.map +1 -1
  37. package/dist/fields/calendar-day.test.d.ts +2 -0
  38. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  39. package/dist/fields/calendar-day.test.js +120 -0
  40. package/dist/fields/calendar-day.test.js.map +1 -0
  41. package/dist/fields/index.d.ts +18 -2
  42. package/dist/fields/index.d.ts.map +1 -1
  43. package/dist/fields/index.js +93 -17
  44. package/dist/fields/index.js.map +1 -1
  45. package/dist/hooks/index.d.ts +116 -0
  46. package/dist/hooks/index.d.ts.map +1 -1
  47. package/dist/hooks/index.js +154 -0
  48. package/dist/hooks/index.js.map +1 -1
  49. package/dist/validation/schema.test.js +222 -1
  50. package/dist/validation/schema.test.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/access/access-filter.ts +156 -0
  53. package/src/access/field-access.test.ts +255 -0
  54. package/src/access/field-access.ts +91 -5
  55. package/src/access/index.ts +1 -1
  56. package/src/access/types.ts +45 -0
  57. package/src/config/types.ts +364 -0
  58. package/src/context/index.ts +207 -37
  59. package/src/context/nested-operations.ts +969 -143
  60. package/src/context/transaction-boundary.ts +440 -0
  61. package/src/context/write-pipeline.ts +234 -13
  62. package/src/fields/calendar-day.test.ts +140 -0
  63. package/src/fields/index.ts +96 -16
  64. package/src/hooks/index.ts +265 -0
  65. package/src/validation/schema.test.ts +266 -1
  66. package/tests/access.test.ts +24 -16
  67. package/tests/context.test.ts +481 -0
  68. package/tests/field-types.test.ts +17 -3
  69. package/tests/nested-access-and-hooks.test.ts +1130 -54
  70. package/tests/nested-operation-registry.test.ts +28 -3
  71. package/tests/nested-write-hooks.test.ts +864 -0
  72. package/tests/transaction-boundary-hooks.test.ts +465 -0
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,864 @@
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
+ * #569 / ADR-0010: nested create/update/delete must run the SAME full hook
8
+ * pipeline (list + field `beforeOperation`/`afterOperation`) as the equivalent
9
+ * top-level write, with the documented arguments, inside ONE transaction that
10
+ * rolls back if any hook throws.
11
+ *
12
+ * These tests use a transaction-aware in-memory Prisma mock so they can assert:
13
+ * - nested before/afterOperation fire with correct args;
14
+ * - side effects are identical nested vs top-level;
15
+ * - a throwing nested afterOperation rolls back the parent write (atomicity);
16
+ * - sudo still bypasses access while running hooks.
17
+ */
18
+
19
+ /**
20
+ * A tiny in-memory Prisma mock supporting interactive transactions.
21
+ *
22
+ * `$transaction(fn)` snapshots every table, runs `fn` against a tx client whose
23
+ * writes mutate the live tables, and on throw restores the snapshot (rollback).
24
+ * Nested writes are supported for to-one/to-many `create`/`update`/`delete`.
25
+ */
26
+ function createTxPrisma() {
27
+ const tables: Record<string, Map<string, Record<string, unknown>>> = {
28
+ post: new Map(),
29
+ user: new Map(),
30
+ comment: new Map(),
31
+ }
32
+ let idCounter = 0
33
+ const nextId = () => `id-${++idCounter}`
34
+
35
+ function applyNested(
36
+ table: string,
37
+ record: Record<string, unknown>,
38
+ data: Record<string, unknown>,
39
+ ): Record<string, unknown> {
40
+ const result = { ...record }
41
+ for (const [key, value] of Object.entries(data)) {
42
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
43
+ const nested = value as Record<string, unknown>
44
+ // Heuristic: a relationship op object has create/update/delete keys.
45
+ if (nested.create || nested.update || nested.delete || nested.connect) {
46
+ const relTable = key === 'author' ? 'user' : key === 'comments' ? 'comment' : key
47
+ const linkField = `${key}Link`
48
+ if (nested.create) {
49
+ const created = doCreate(relTable, nested.create as Record<string, unknown>)
50
+ result[linkField] = created.id
51
+ result[key] = created
52
+ }
53
+ if (nested.update) {
54
+ const upd = nested.update as { where: { id: string }; data: Record<string, unknown> }
55
+ const updated = doUpdate(relTable, upd.where, upd.data)
56
+ result[key] = updated
57
+ }
58
+ if (nested.delete) {
59
+ const del = nested.delete as { id: string }
60
+ doDelete(relTable, del)
61
+ result[key] = null
62
+ }
63
+ continue
64
+ }
65
+ }
66
+ result[key] = value
67
+ }
68
+ return result
69
+ }
70
+
71
+ function doCreate(table: string, data: Record<string, unknown>): Record<string, unknown> {
72
+ const id = (data.id as string) ?? nextId()
73
+ let record: Record<string, unknown> = { id }
74
+ record = applyNested(table, record, data)
75
+ tables[table].set(id, record)
76
+ return record
77
+ }
78
+
79
+ function doUpdate(
80
+ table: string,
81
+ where: { id: string },
82
+ data: Record<string, unknown>,
83
+ ): Record<string, unknown> {
84
+ const existing = tables[table].get(where.id) ?? { id: where.id }
85
+ const updated = applyNested(table, existing, data)
86
+ tables[table].set(where.id, updated)
87
+ return updated
88
+ }
89
+
90
+ function doDelete(table: string, where: { id: string }): Record<string, unknown> {
91
+ const existing = tables[table].get(where.id) ?? { id: where.id }
92
+ tables[table].delete(where.id)
93
+ return existing
94
+ }
95
+
96
+ function makeModel(table: string) {
97
+ return {
98
+ findUnique: vi.fn(
99
+ async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
100
+ ),
101
+ findFirst: vi.fn(async ({ where }: { where?: { id?: string } }) => {
102
+ if (where?.id) return tables[table].get(where.id) ?? null
103
+ return tables[table].values().next().value ?? null
104
+ }),
105
+ findMany: vi.fn(async () => Array.from(tables[table].values())),
106
+ count: vi.fn(async () => tables[table].size),
107
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => doCreate(table, data)),
108
+ update: vi.fn(
109
+ async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) =>
110
+ doUpdate(table, where, data),
111
+ ),
112
+ delete: vi.fn(async ({ where }: { where: { id: string } }) => doDelete(table, where)),
113
+ }
114
+ }
115
+
116
+ const client: Record<string, unknown> = {
117
+ post: makeModel('post'),
118
+ user: makeModel('user'),
119
+ comment: makeModel('comment'),
120
+ }
121
+
122
+ client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
123
+ const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
124
+ for (const [name, map] of Object.entries(tables)) {
125
+ snapshot[name] = new Map(map)
126
+ }
127
+ try {
128
+ return await fn(client)
129
+ } catch (err) {
130
+ // Roll back: restore every table from the snapshot.
131
+ for (const [name, map] of Object.entries(snapshot)) {
132
+ tables[name] = map
133
+ }
134
+ throw err
135
+ }
136
+ }
137
+
138
+ return { client, tables }
139
+ }
140
+
141
+ describe('#569 nested writes — full hook pipeline + transaction', () => {
142
+ let mock: ReturnType<typeof createTxPrisma>
143
+
144
+ beforeEach(() => {
145
+ mock = createTxPrisma()
146
+ vi.clearAllMocks()
147
+ })
148
+
149
+ it('nested create fires list + field before/afterOperation with correct args', async () => {
150
+ const events: string[] = []
151
+ const listBefore = vi.fn(({ operation }) => {
152
+ events.push(`list:before:${operation}`)
153
+ })
154
+ const listAfter = vi.fn(({ operation, item }) => {
155
+ events.push(`list:after:${operation}`)
156
+ expect(item).toBeDefined()
157
+ expect(item.id).toBeDefined()
158
+ })
159
+ const fieldBefore = vi.fn(({ operation }) => {
160
+ events.push(`field:before:${operation}`)
161
+ })
162
+ const fieldAfter = vi.fn(({ operation, item }) => {
163
+ events.push(`field:after:${operation}`)
164
+ expect(item.id).toBeDefined()
165
+ })
166
+
167
+ const testConfig = config({
168
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
169
+ lists: {
170
+ User: list({
171
+ fields: {
172
+ name: text({ hooks: { beforeOperation: fieldBefore, afterOperation: fieldAfter } }),
173
+ },
174
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
175
+ hooks: { beforeOperation: listBefore, afterOperation: listAfter },
176
+ }),
177
+ Post: list({
178
+ fields: {
179
+ title: text(),
180
+ author: relationship({ ref: 'User.posts' }),
181
+ },
182
+ access: { operation: { query: () => true, update: () => true } },
183
+ }),
184
+ },
185
+ })
186
+
187
+ mock.tables.post.set('p1', { id: 'p1', title: 'Original' })
188
+
189
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
190
+
191
+ await context.db.post.update({
192
+ where: { id: 'p1' },
193
+ data: {
194
+ title: 'Updated',
195
+ author: { create: { name: 'john' } },
196
+ },
197
+ })
198
+
199
+ // All four hooks fired, create operation, before strictly precedes after.
200
+ expect(listBefore).toHaveBeenCalledWith(
201
+ expect.objectContaining({ operation: 'create', listKey: 'User' }),
202
+ )
203
+ expect(fieldBefore).toHaveBeenCalledWith(
204
+ expect.objectContaining({ operation: 'create', fieldKey: 'name' }),
205
+ )
206
+ expect(listAfter).toHaveBeenCalledWith(
207
+ expect.objectContaining({ operation: 'create', listKey: 'User' }),
208
+ )
209
+ expect(fieldAfter).toHaveBeenCalledWith(
210
+ expect.objectContaining({ operation: 'create', fieldKey: 'name' }),
211
+ )
212
+ expect(events.indexOf('list:before:create')).toBeLessThan(events.indexOf('list:after:create'))
213
+ })
214
+
215
+ it('nested update fires afterOperation with originalItem + updated item', async () => {
216
+ const listAfter = vi.fn()
217
+
218
+ const testConfig = config({
219
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
220
+ lists: {
221
+ User: list({
222
+ fields: { name: text() },
223
+ access: { operation: { query: () => true, update: () => true } },
224
+ hooks: { afterOperation: listAfter },
225
+ }),
226
+ Post: list({
227
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
228
+ access: { operation: { query: () => true, update: () => true } },
229
+ }),
230
+ },
231
+ })
232
+
233
+ mock.tables.post.set('p1', { id: 'p1', title: 'P', authorLink: 'u1' })
234
+ mock.tables.user.set('u1', { id: 'u1', name: 'old name' })
235
+
236
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
237
+
238
+ await context.db.post.update({
239
+ where: { id: 'p1' },
240
+ data: {
241
+ author: { update: { where: { id: 'u1' }, data: { name: 'new name' } } },
242
+ },
243
+ })
244
+
245
+ expect(listAfter).toHaveBeenCalledWith(
246
+ expect.objectContaining({
247
+ operation: 'update',
248
+ originalItem: expect.objectContaining({ id: 'u1', name: 'old name' }),
249
+ item: expect.objectContaining({ id: 'u1', name: 'new name' }),
250
+ }),
251
+ )
252
+ })
253
+
254
+ it('nested delete fires before/afterOperation with originalItem', async () => {
255
+ const listBefore = vi.fn()
256
+ const listAfter = vi.fn()
257
+
258
+ const testConfig = config({
259
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
260
+ lists: {
261
+ Comment: list({
262
+ fields: { body: text() },
263
+ access: {
264
+ operation: { query: () => true, update: () => true, delete: () => true },
265
+ },
266
+ hooks: { beforeOperation: listBefore, afterOperation: listAfter },
267
+ }),
268
+ Post: list({
269
+ fields: {
270
+ title: text(),
271
+ comments: relationship({ ref: 'Comment', many: true }),
272
+ },
273
+ access: { operation: { query: () => true, update: () => true } },
274
+ }),
275
+ },
276
+ })
277
+
278
+ mock.tables.post.set('p1', { id: 'p1', title: 'P' })
279
+ mock.tables.comment.set('c1', { id: 'c1', body: 'doomed' })
280
+
281
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
282
+
283
+ await context.db.post.update({
284
+ where: { id: 'p1' },
285
+ data: { comments: { delete: { id: 'c1' } } },
286
+ })
287
+
288
+ expect(listBefore).toHaveBeenCalledWith(
289
+ expect.objectContaining({
290
+ operation: 'delete',
291
+ item: expect.objectContaining({ id: 'c1', body: 'doomed' }),
292
+ }),
293
+ )
294
+ expect(listAfter).toHaveBeenCalledWith(
295
+ expect.objectContaining({
296
+ operation: 'delete',
297
+ originalItem: expect.objectContaining({ id: 'c1', body: 'doomed' }),
298
+ }),
299
+ )
300
+ })
301
+
302
+ it('side effect fires identically for nested vs top-level create', async () => {
303
+ const sideEffects: string[] = []
304
+ const afterOp = vi.fn(({ operation, item }) => {
305
+ if (operation === 'create') sideEffects.push(`created:${item.name}`)
306
+ })
307
+
308
+ const makeConfig = () =>
309
+ config({
310
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
311
+ lists: {
312
+ User: list({
313
+ fields: { name: text() },
314
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
315
+ hooks: { afterOperation: afterOp },
316
+ }),
317
+ Post: list({
318
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
319
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
320
+ }),
321
+ },
322
+ })
323
+
324
+ // Top-level create.
325
+ const ctx1 = getContext(await makeConfig(), mock.client, { userId: '1' })
326
+ await ctx1.db.user.create({ data: { name: 'top-level' } })
327
+
328
+ // Nested create (same logical operation).
329
+ mock.tables.post.set('p1', { id: 'p1', title: 'P' })
330
+ const ctx2 = getContext(await makeConfig(), mock.client, { userId: '1' })
331
+ await ctx2.db.post.update({
332
+ where: { id: 'p1' },
333
+ data: { author: { create: { name: 'nested' } } },
334
+ })
335
+
336
+ expect(sideEffects).toContain('created:top-level')
337
+ expect(sideEffects).toContain('created:nested')
338
+ })
339
+
340
+ it('a throwing nested afterOperation rolls back the parent write (atomicity)', async () => {
341
+ const testConfig = config({
342
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
343
+ lists: {
344
+ User: list({
345
+ fields: { name: text() },
346
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
347
+ hooks: {
348
+ afterOperation: ({ operation }) => {
349
+ if (operation === 'create') {
350
+ throw new Error('nested afterOperation boom')
351
+ }
352
+ },
353
+ },
354
+ }),
355
+ Post: list({
356
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
357
+ access: { operation: { query: () => true, update: () => true } },
358
+ }),
359
+ },
360
+ })
361
+
362
+ mock.tables.post.set('p1', { id: 'p1', title: 'Original' })
363
+
364
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
365
+
366
+ await expect(
367
+ context.db.post.update({
368
+ where: { id: 'p1' },
369
+ data: {
370
+ title: 'Should NOT persist',
371
+ author: { create: { name: 'doomed' } },
372
+ },
373
+ }),
374
+ ).rejects.toThrow('nested afterOperation boom')
375
+
376
+ // Rollback: the parent title is unchanged and the nested user is gone.
377
+ expect(mock.tables.post.get('p1')?.title).toBe('Original')
378
+ expect(mock.tables.user.size).toBe(0)
379
+ })
380
+
381
+ it('a throwing nested beforeOperation rolls back and never persists', async () => {
382
+ const testConfig = config({
383
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
384
+ lists: {
385
+ User: list({
386
+ fields: { name: text() },
387
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
388
+ hooks: {
389
+ beforeOperation: ({ operation }) => {
390
+ if (operation === 'create') throw new Error('nested beforeOperation boom')
391
+ },
392
+ },
393
+ }),
394
+ Post: list({
395
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
396
+ access: { operation: { query: () => true, update: () => true } },
397
+ }),
398
+ },
399
+ })
400
+
401
+ mock.tables.post.set('p1', { id: 'p1', title: 'Original' })
402
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
403
+
404
+ await expect(
405
+ context.db.post.update({
406
+ where: { id: 'p1' },
407
+ data: { title: 'nope', author: { create: { name: 'doomed' } } },
408
+ }),
409
+ ).rejects.toThrow('nested beforeOperation boom')
410
+
411
+ expect(mock.tables.post.get('p1')?.title).toBe('Original')
412
+ expect(mock.tables.user.size).toBe(0)
413
+ })
414
+
415
+ it('sudo bypasses access on nested create but still runs hooks', async () => {
416
+ const createAccess = vi.fn(() => false as const)
417
+ const afterOp = vi.fn()
418
+
419
+ const testConfig = config({
420
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
421
+ lists: {
422
+ User: list({
423
+ fields: { name: text() },
424
+ access: { operation: { query: () => true, create: createAccess, update: () => true } },
425
+ hooks: { afterOperation: afterOp },
426
+ }),
427
+ Post: list({
428
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
429
+ access: { operation: { query: () => true, update: () => true } },
430
+ }),
431
+ },
432
+ })
433
+
434
+ mock.tables.post.set('p1', { id: 'p1', title: 'P' })
435
+ const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
436
+
437
+ await context.db.post.update({
438
+ where: { id: 'p1' },
439
+ data: { author: { create: { name: 'sudo-made' } } },
440
+ })
441
+
442
+ // Access denied normally; sudo bypasses the access fn, but hooks still run.
443
+ expect(createAccess).not.toHaveBeenCalled()
444
+ expect(afterOp).toHaveBeenCalledWith(expect.objectContaining({ operation: 'create' }))
445
+ expect(mock.tables.user.size).toBe(1)
446
+ })
447
+
448
+ it('non-sudo nested create denied by access throws and rolls back', async () => {
449
+ const testConfig = config({
450
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
451
+ lists: {
452
+ User: list({
453
+ fields: { name: text() },
454
+ access: { operation: { query: () => true, create: () => false, update: () => true } },
455
+ }),
456
+ Post: list({
457
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
458
+ access: { operation: { query: () => true, update: () => true } },
459
+ }),
460
+ },
461
+ })
462
+
463
+ mock.tables.post.set('p1', { id: 'p1', title: 'Original' })
464
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
465
+
466
+ await expect(
467
+ context.db.post.update({
468
+ where: { id: 'p1' },
469
+ data: { title: 'nope', author: { create: { name: 'denied' } } },
470
+ }),
471
+ ).rejects.toThrow('Access denied: Cannot create related item')
472
+
473
+ // The whole write rolled back — parent title unchanged.
474
+ expect(mock.tables.post.get('p1')?.title).toBe('Original')
475
+ })
476
+
477
+ it('every write is transactional — top-level create uses $transaction', async () => {
478
+ const txSpy = vi.spyOn(
479
+ mock.client as { $transaction: unknown } & Record<string, unknown>,
480
+ '$transaction',
481
+ )
482
+
483
+ const testConfig = config({
484
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
485
+ lists: {
486
+ User: list({
487
+ fields: { name: text() },
488
+ access: { operation: { query: () => true, create: () => true } },
489
+ }),
490
+ },
491
+ })
492
+
493
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
494
+ await context.db.user.create({ data: { name: 'solo' } })
495
+
496
+ expect(txSpy).toHaveBeenCalledTimes(1)
497
+ })
498
+ })
499
+
500
+ /**
501
+ * A richer transaction-aware mock that models a to-many relation
502
+ * (`Post.comments`) with real link tracking and `include`-echoing, so the
503
+ * created-row recovery (#569 finding 1+2) can be exercised against the actual
504
+ * id-diff: each created comment gets a fresh id, the parent `update`/`create`
505
+ * result echoes `include: { comments: true }` with the linked rows, and
506
+ * pre-existing comments are preserved.
507
+ */
508
+ function createToManyPrisma() {
509
+ const tables: Record<string, Map<string, Record<string, unknown>>> = {
510
+ post: new Map(),
511
+ comment: new Map(),
512
+ }
513
+ // commentId -> postId link.
514
+ const commentToPost = new Map<string, string>()
515
+ let idCounter = 0
516
+ const nextId = () => `c-${++idCounter}`
517
+
518
+ function linkedComments(postId: string): Array<Record<string, unknown>> {
519
+ const rows: Array<Record<string, unknown>> = []
520
+ for (const [commentId, owner] of commentToPost.entries()) {
521
+ if (owner === postId) {
522
+ const row = tables.comment.get(commentId)
523
+ if (row) rows.push(row)
524
+ }
525
+ }
526
+ return rows
527
+ }
528
+
529
+ function applyCommentOps(postId: string, ops: Record<string, unknown>) {
530
+ if (ops.create) {
531
+ const creates = Array.isArray(ops.create) ? ops.create : [ops.create]
532
+ for (const data of creates as Array<Record<string, unknown>>) {
533
+ const id = (data.id as string) ?? nextId()
534
+ const row: Record<string, unknown> = { id, ...data }
535
+ tables.comment.set(id, row)
536
+ commentToPost.set(id, postId)
537
+ }
538
+ }
539
+ if (ops.update) {
540
+ const updates = Array.isArray(ops.update) ? ops.update : [ops.update]
541
+ for (const u of updates as Array<{ where: { id: string }; data: Record<string, unknown> }>) {
542
+ const existing = tables.comment.get(u.where.id) ?? { id: u.where.id }
543
+ tables.comment.set(u.where.id, { ...existing, ...u.data })
544
+ }
545
+ }
546
+ if (ops.delete) {
547
+ const deletes = Array.isArray(ops.delete) ? ops.delete : [ops.delete]
548
+ for (const d of deletes as Array<{ id: string }>) {
549
+ tables.comment.delete(d.id)
550
+ commentToPost.delete(d.id)
551
+ }
552
+ }
553
+ }
554
+
555
+ function buildPostResult(
556
+ postId: string,
557
+ include: Record<string, unknown> | undefined,
558
+ ): Record<string, unknown> {
559
+ const base = tables.post.get(postId) ?? { id: postId }
560
+ if (include?.comments) {
561
+ return { ...base, comments: linkedComments(postId) }
562
+ }
563
+ return { ...base }
564
+ }
565
+
566
+ const postModel = {
567
+ findUnique: vi.fn(
568
+ async ({ where, include }: { where: { id: string }; include?: Record<string, unknown> }) => {
569
+ if (!tables.post.has(where.id)) return null
570
+ return buildPostResult(where.id, include)
571
+ },
572
+ ),
573
+ findFirst: vi.fn(async ({ where }: { where?: { id?: string } }) => {
574
+ if (where?.id) return tables.post.get(where.id) ?? null
575
+ return tables.post.values().next().value ?? null
576
+ }),
577
+ findMany: vi.fn(async () => Array.from(tables.post.values())),
578
+ count: vi.fn(async () => tables.post.size),
579
+ create: vi.fn(
580
+ async ({
581
+ data,
582
+ include,
583
+ }: {
584
+ data: Record<string, unknown>
585
+ include?: Record<string, unknown>
586
+ }) => {
587
+ const id = (data.id as string) ?? `p-${++idCounter}`
588
+ const { comments, ...scalars } = data
589
+ tables.post.set(id, { id, ...scalars })
590
+ if (comments) applyCommentOps(id, comments as Record<string, unknown>)
591
+ return buildPostResult(id, include)
592
+ },
593
+ ),
594
+ update: vi.fn(
595
+ async ({
596
+ where,
597
+ data,
598
+ include,
599
+ }: {
600
+ where: { id: string }
601
+ data: Record<string, unknown>
602
+ include?: Record<string, unknown>
603
+ }) => {
604
+ const existing = tables.post.get(where.id) ?? { id: where.id }
605
+ const { comments, ...scalars } = data
606
+ tables.post.set(where.id, { ...existing, ...scalars })
607
+ if (comments) applyCommentOps(where.id, comments as Record<string, unknown>)
608
+ return buildPostResult(where.id, include)
609
+ },
610
+ ),
611
+ delete: vi.fn(async ({ where }: { where: { id: string } }) => {
612
+ const existing = tables.post.get(where.id) ?? { id: where.id }
613
+ tables.post.delete(where.id)
614
+ return existing
615
+ }),
616
+ }
617
+
618
+ function makeCommentModel() {
619
+ return {
620
+ findUnique: vi.fn(
621
+ async ({ where }: { where: { id: string } }) => tables.comment.get(where.id) ?? null,
622
+ ),
623
+ findFirst: vi.fn(async () => tables.comment.values().next().value ?? null),
624
+ findMany: vi.fn(async () => Array.from(tables.comment.values())),
625
+ count: vi.fn(async () => tables.comment.size),
626
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
627
+ const id = (data.id as string) ?? nextId()
628
+ const row = { id, ...data }
629
+ tables.comment.set(id, row)
630
+ return row
631
+ }),
632
+ update: vi.fn(
633
+ async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
634
+ const existing = tables.comment.get(where.id) ?? { id: where.id }
635
+ const updated = { ...existing, ...data }
636
+ tables.comment.set(where.id, updated)
637
+ return updated
638
+ },
639
+ ),
640
+ delete: vi.fn(async ({ where }: { where: { id: string } }) => {
641
+ const existing = tables.comment.get(where.id) ?? { id: where.id }
642
+ tables.comment.delete(where.id)
643
+ commentToPost.delete(where.id)
644
+ return existing
645
+ }),
646
+ }
647
+ }
648
+
649
+ const client: Record<string, unknown> = {
650
+ post: postModel,
651
+ comment: makeCommentModel(),
652
+ }
653
+
654
+ client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
655
+ const snapshot = {
656
+ post: new Map(tables.post),
657
+ comment: new Map(tables.comment),
658
+ links: new Map(commentToPost),
659
+ }
660
+ try {
661
+ return await fn(client)
662
+ } catch (err) {
663
+ tables.post = snapshot.post
664
+ tables.comment = snapshot.comment
665
+ commentToPost.clear()
666
+ for (const [k, v] of snapshot.links) commentToPost.set(k, v)
667
+ throw err
668
+ }
669
+ }
670
+
671
+ /** Link a pre-existing comment to a post (test setup helper). */
672
+ function seedComment(postId: string, comment: Record<string, unknown>) {
673
+ tables.comment.set(comment.id as string, comment)
674
+ commentToPost.set(comment.id as string, postId)
675
+ }
676
+
677
+ return { client, tables, seedComment, linkedComments }
678
+ }
679
+
680
+ describe('#569 nested writes — created-row recovery by id-diff (findings 1 + 2)', () => {
681
+ function makeConfig(afterOp: ReturnType<typeof vi.fn>) {
682
+ return config({
683
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
684
+ lists: {
685
+ Comment: list({
686
+ fields: { body: text() },
687
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
688
+ hooks: { afterOperation: afterOp },
689
+ }),
690
+ Post: list({
691
+ fields: {
692
+ title: text(),
693
+ comments: relationship({ ref: 'Comment', many: true }),
694
+ },
695
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
696
+ }),
697
+ },
698
+ })
699
+ }
700
+
701
+ it('multiple nested creates on a to-many relation each fire afterOperation once with their OWN distinct row', async () => {
702
+ const mock = createToManyPrisma()
703
+ const created: Array<{ id: unknown; body: unknown }> = []
704
+ const afterOp = vi.fn(({ operation, item }) => {
705
+ if (operation === 'create') created.push({ id: item.id, body: item.body })
706
+ })
707
+
708
+ mock.tables.post.set('p1', { id: 'p1', title: 'P' })
709
+ const context = getContext(await makeConfig(afterOp), mock.client, { userId: '1' })
710
+
711
+ await context.db.post.update({
712
+ where: { id: 'p1' },
713
+ data: {
714
+ comments: { create: [{ body: 'A' }, { body: 'B' }] },
715
+ },
716
+ })
717
+
718
+ // afterOperation fired exactly once per created comment.
719
+ const createEvents = afterOp.mock.calls.filter((c) => c[0].operation === 'create')
720
+ expect(createEvents).toHaveLength(2)
721
+
722
+ // Each fired against its OWN distinct, persisted row — distinct ids, the two
723
+ // bodies, never the same row twice (the original bug recovered fresh[last]
724
+ // for every create).
725
+ expect(created).toHaveLength(2)
726
+ const ids = created.map((c) => c.id)
727
+ expect(new Set(ids).size).toBe(2)
728
+ for (const id of ids) expect(typeof id).toBe('string')
729
+ expect(created.map((c) => c.body).sort()).toEqual(['A', 'B'])
730
+ })
731
+
732
+ it('nested create on a to-many relation with pre-existing rows fires afterOperation ONLY for the newly-created row', async () => {
733
+ const mock = createToManyPrisma()
734
+ const createdItems: Array<Record<string, unknown>> = []
735
+ const afterOp = vi.fn(({ operation, item }) => {
736
+ if (operation === 'create') createdItems.push(item)
737
+ })
738
+
739
+ // A post that already has a pre-existing comment.
740
+ mock.tables.post.set('p1', { id: 'p1', title: 'P' })
741
+ mock.seedComment('p1', { id: 'pre-1', body: 'pre-existing' })
742
+
743
+ const context = getContext(await makeConfig(afterOp), mock.client, { userId: '1' })
744
+
745
+ await context.db.post.update({
746
+ where: { id: 'p1' },
747
+ data: { comments: { create: { body: 'brand-new' } } },
748
+ })
749
+
750
+ // Exactly one create afterOperation — for the new row, NOT the pre-existing one.
751
+ const createEvents = afterOp.mock.calls.filter((c) => c[0].operation === 'create')
752
+ expect(createEvents).toHaveLength(1)
753
+ expect(createdItems).toHaveLength(1)
754
+ expect(createdItems[0].id).not.toBe('pre-1')
755
+ expect(createdItems[0].body).toBe('brand-new')
756
+
757
+ // Both comments now exist in the DB (pre-existing preserved).
758
+ expect(
759
+ mock
760
+ .linkedComments('p1')
761
+ .map((c) => c.body)
762
+ .sort(),
763
+ ).toEqual(['brand-new', 'pre-existing'])
764
+ })
765
+ })
766
+
767
+ describe('#569 nested writes — context.db inside hooks is transactional (finding 3)', () => {
768
+ it('a hook writing via context.db rolls back when the transaction later throws', async () => {
769
+ const mock = createToManyPrisma()
770
+
771
+ // The Comment afterOperation writes a SECOND comment via context.db, then a
772
+ // later (post) afterOperation throws — the whole transaction must roll back,
773
+ // including the context.db write, leaving NO comments persisted.
774
+ const testConfig = config({
775
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
776
+ lists: {
777
+ Comment: list({
778
+ fields: { body: text() },
779
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
780
+ hooks: {
781
+ afterOperation: async ({ operation, item, context }) => {
782
+ // Only the originally-created comment triggers the side-write, and
783
+ // guard against recursion (the side-write is body 'audit').
784
+ if (operation === 'create' && item.body === 'main') {
785
+ await context.db.comment.create({ data: { body: 'audit' } })
786
+ }
787
+ },
788
+ },
789
+ }),
790
+ Post: list({
791
+ fields: {
792
+ title: text(),
793
+ comments: relationship({ ref: 'Comment', many: true }),
794
+ },
795
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
796
+ hooks: {
797
+ afterOperation: ({ operation }) => {
798
+ if (operation === 'update') throw new Error('post afterOperation boom')
799
+ },
800
+ },
801
+ }),
802
+ },
803
+ })
804
+
805
+ mock.tables.post.set('p1', { id: 'p1', title: 'P' })
806
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
807
+
808
+ await expect(
809
+ context.db.post.update({
810
+ where: { id: 'p1' },
811
+ data: { comments: { create: { body: 'main' } } },
812
+ }),
813
+ ).rejects.toThrow('post afterOperation boom')
814
+
815
+ // Rollback: neither the nested 'main' comment nor the hook's 'audit'
816
+ // context.db write survived — proving the hook's context.db participated in
817
+ // the same transaction.
818
+ expect(mock.tables.comment.size).toBe(0)
819
+ })
820
+
821
+ it('a hook writing via context.db persists when the transaction commits', async () => {
822
+ const mock = createToManyPrisma()
823
+
824
+ const testConfig = config({
825
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
826
+ lists: {
827
+ Comment: list({
828
+ fields: { body: text() },
829
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
830
+ hooks: {
831
+ afterOperation: async ({ operation, item, context }) => {
832
+ if (operation === 'create' && item.body === 'main') {
833
+ await context.db.comment.create({ data: { body: 'audit' } })
834
+ }
835
+ },
836
+ },
837
+ }),
838
+ Post: list({
839
+ fields: {
840
+ title: text(),
841
+ comments: relationship({ ref: 'Comment', many: true }),
842
+ },
843
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
844
+ }),
845
+ },
846
+ })
847
+
848
+ mock.tables.post.set('p1', { id: 'p1', title: 'P' })
849
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
850
+
851
+ await context.db.post.update({
852
+ where: { id: 'p1' },
853
+ data: { comments: { create: { body: 'main' } } },
854
+ })
855
+
856
+ // Commit: both the nested 'main' comment and the hook's 'audit' write
857
+ // persisted.
858
+ expect(
859
+ Array.from(mock.tables.comment.values())
860
+ .map((c) => c.body)
861
+ .sort(),
862
+ ).toEqual(['audit', 'main'])
863
+ })
864
+ })