@opensaas/stack-core 0.23.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 (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +256 -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/index.d.ts +1 -1
  20. package/dist/config/index.d.ts.map +1 -1
  21. package/dist/config/types.d.ts +378 -0
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +19 -1
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +153 -26
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts +59 -3
  28. package/dist/context/nested-operations.d.ts.map +1 -1
  29. package/dist/context/nested-operations.js +552 -129
  30. package/dist/context/nested-operations.js.map +1 -1
  31. package/dist/context/transaction-boundary.d.ts +91 -0
  32. package/dist/context/transaction-boundary.d.ts.map +1 -0
  33. package/dist/context/transaction-boundary.js +329 -0
  34. package/dist/context/transaction-boundary.js.map +1 -0
  35. package/dist/context/write-pipeline.d.ts +15 -1
  36. package/dist/context/write-pipeline.d.ts.map +1 -1
  37. package/dist/context/write-pipeline.js +173 -10
  38. package/dist/context/write-pipeline.js.map +1 -1
  39. package/dist/fields/calendar-day.test.d.ts +2 -0
  40. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  41. package/dist/fields/calendar-day.test.js +120 -0
  42. package/dist/fields/calendar-day.test.js.map +1 -0
  43. package/dist/fields/index.d.ts +18 -2
  44. package/dist/fields/index.d.ts.map +1 -1
  45. package/dist/fields/index.js +93 -17
  46. package/dist/fields/index.js.map +1 -1
  47. package/dist/hooks/index.d.ts +116 -0
  48. package/dist/hooks/index.d.ts.map +1 -1
  49. package/dist/hooks/index.js +154 -0
  50. package/dist/hooks/index.js.map +1 -1
  51. package/dist/validation/schema.test.js +222 -1
  52. package/dist/validation/schema.test.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/access/access-filter.ts +156 -0
  55. package/src/access/field-access.test.ts +255 -0
  56. package/src/access/field-access.ts +91 -5
  57. package/src/access/index.ts +1 -1
  58. package/src/access/types.ts +45 -0
  59. package/src/config/index.ts +2 -0
  60. package/src/config/types.ts +426 -0
  61. package/src/context/index.ts +207 -37
  62. package/src/context/nested-operations.ts +969 -143
  63. package/src/context/transaction-boundary.ts +440 -0
  64. package/src/context/write-pipeline.ts +234 -13
  65. package/src/fields/calendar-day.test.ts +140 -0
  66. package/src/fields/index.ts +96 -16
  67. package/src/hooks/index.ts +265 -0
  68. package/src/validation/schema.test.ts +266 -1
  69. package/tests/access.test.ts +24 -16
  70. package/tests/config.test.ts +30 -0
  71. package/tests/context.test.ts +481 -0
  72. package/tests/field-types.test.ts +17 -3
  73. package/tests/nested-access-and-hooks.test.ts +1130 -54
  74. package/tests/nested-operation-registry.test.ts +28 -3
  75. package/tests/nested-write-hooks.test.ts +864 -0
  76. package/tests/transaction-boundary-hooks.test.ts +465 -0
  77. 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
+ })