@opensaas/stack-core 0.25.0 → 0.26.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.
@@ -0,0 +1,444 @@
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
+
6
+ /**
7
+ * #614: interactive, hook-firing transaction on the stack `Context`.
8
+ *
9
+ * `context.transaction(fn, options)` runs `fn` against a `txContext` whose
10
+ * `db.*` operations are access-checked and hook-firing (identical to the normal
11
+ * context) but persist against ONE underlying interactive transaction, so every
12
+ * write in the callback is atomic. The transaction `options` (notably
13
+ * `isolationLevel`) are passed through to the underlying Prisma transaction, and
14
+ * serialization failures propagate to the caller (rather than being swallowed)
15
+ * so a caller-owned retry loop can react to them.
16
+ *
17
+ * These tests use an in-memory Prisma mock. The `tx` client handed to the
18
+ * `$transaction` callback intentionally has NO `$transaction` of its own —
19
+ * mirroring a real Prisma interactive-transaction client — so nested
20
+ * `context.db` writes join the outer transaction instead of opening their own.
21
+ */
22
+
23
+ /** A Prisma-style serialization failure (write conflict / deadlock). */
24
+ function makeSerializationError(): Error & { code: string } {
25
+ const err = new Error('could not serialize access due to concurrent update') as Error & {
26
+ code: string
27
+ }
28
+ err.code = 'P2034'
29
+ return err
30
+ }
31
+
32
+ function isSerializationError(err: unknown): boolean {
33
+ return !!err && typeof err === 'object' && 'code' in err && err.code === 'P2034'
34
+ }
35
+
36
+ /**
37
+ * Basic transaction-aware mock. Writes apply to shared `tables` immediately and
38
+ * are rolled back by restoring a snapshot when the callback throws. The `tx`
39
+ * handed to the callback exposes the model delegates but NOT `$transaction`.
40
+ */
41
+ function createTxPrisma() {
42
+ const tables: Record<string, Map<string, Record<string, unknown>>> = {
43
+ user: new Map(),
44
+ post: new Map(),
45
+ }
46
+ let idCounter = 0
47
+ const nextId = () => `id-${++idCounter}`
48
+
49
+ function makeModel(table: string) {
50
+ return {
51
+ findUnique: vi.fn(
52
+ async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
53
+ ),
54
+ findFirst: vi.fn(async () => tables[table].values().next().value ?? null),
55
+ findMany: vi.fn(async () => Array.from(tables[table].values())),
56
+ count: vi.fn(async () => tables[table].size),
57
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
58
+ const id = (data.id as string) ?? nextId()
59
+ const record = { ...data, id }
60
+ tables[table].set(id, record)
61
+ return record
62
+ }),
63
+ update: vi.fn(
64
+ async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
65
+ const existing = tables[table].get(where.id) ?? { id: where.id }
66
+ const updated = { ...existing, ...data }
67
+ tables[table].set(where.id, updated)
68
+ return updated
69
+ },
70
+ ),
71
+ delete: vi.fn(async ({ where }: { where: { id: string } }) => {
72
+ const existing = tables[table].get(where.id) ?? { id: where.id }
73
+ tables[table].delete(where.id)
74
+ return existing
75
+ }),
76
+ }
77
+ }
78
+
79
+ const client: Record<string, unknown> = {
80
+ user: makeModel('user'),
81
+ post: makeModel('post'),
82
+ }
83
+
84
+ const capturedOptions: unknown[] = []
85
+
86
+ client.$transaction = async (fn: (tx: unknown) => Promise<unknown>, options?: unknown) => {
87
+ capturedOptions.push(options)
88
+ const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
89
+ for (const [name, map] of Object.entries(tables)) {
90
+ snapshot[name] = new Map(map)
91
+ }
92
+ // The interactive-transaction client mirrors real Prisma: model delegates
93
+ // are present but `$transaction` is NOT, so nested writes join this tx.
94
+ const { $transaction: _omit, ...models } = client
95
+ void _omit
96
+ try {
97
+ return await fn(models)
98
+ } catch (err) {
99
+ for (const [name, map] of Object.entries(snapshot)) {
100
+ tables[name] = map
101
+ }
102
+ throw err
103
+ }
104
+ }
105
+
106
+ return { client, tables, capturedOptions }
107
+ }
108
+
109
+ const baseConfig = () =>
110
+ config({
111
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
112
+ lists: {
113
+ User: list({
114
+ fields: { name: text() },
115
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
116
+ }),
117
+ Post: list({
118
+ fields: { title: text() },
119
+ access: { operation: { query: () => true, create: () => true } },
120
+ }),
121
+ },
122
+ })
123
+
124
+ describe('#614 context.transaction (interactive transaction)', () => {
125
+ let mock: ReturnType<typeof createTxPrisma>
126
+
127
+ beforeEach(() => {
128
+ mock = createTxPrisma()
129
+ vi.clearAllMocks()
130
+ })
131
+
132
+ it('exposes a transaction() method on the context', async () => {
133
+ const context = getContext(await baseConfig(), mock.client, { userId: '1' })
134
+ expect(typeof context.transaction).toBe('function')
135
+ })
136
+
137
+ it('runs db writes inside the callback and returns the callback result', async () => {
138
+ const context = getContext(await baseConfig(), mock.client, { userId: '1' })
139
+
140
+ const result = await context.transaction(async (tx) => {
141
+ const user = await tx.db.user.create({ data: { name: 'jane' } })
142
+ const post = await tx.db.post.create({ data: { title: 'hello' } })
143
+ return { user, post }
144
+ })
145
+
146
+ expect(result.user).toEqual(expect.objectContaining({ name: 'jane' }))
147
+ expect(result.post).toEqual(expect.objectContaining({ title: 'hello' }))
148
+ expect(mock.tables.user.size).toBe(1)
149
+ expect(mock.tables.post.size).toBe(1)
150
+ })
151
+
152
+ it('is atomic: a throw inside the callback rolls back every write', async () => {
153
+ const context = getContext(await baseConfig(), mock.client, { userId: '1' })
154
+
155
+ await expect(
156
+ context.transaction(async (tx) => {
157
+ await tx.db.user.create({ data: { name: 'jane' } })
158
+ await tx.db.post.create({ data: { title: 'hello' } })
159
+ throw new Error('boom')
160
+ }),
161
+ ).rejects.toThrow('boom')
162
+
163
+ expect(mock.tables.user.size).toBe(0)
164
+ expect(mock.tables.post.size).toBe(0)
165
+ })
166
+
167
+ it('passes transaction options (isolationLevel) through to the underlying client', async () => {
168
+ const context = getContext(await baseConfig(), mock.client, { userId: '1' })
169
+
170
+ await context.transaction(
171
+ async (tx) => {
172
+ await tx.db.user.create({ data: { name: 'jane' } })
173
+ },
174
+ { isolationLevel: 'Serializable' },
175
+ )
176
+
177
+ expect(mock.capturedOptions).toContainEqual(
178
+ expect.objectContaining({ isolationLevel: 'Serializable' }),
179
+ )
180
+ })
181
+
182
+ it('enforces access control inside the transaction (denied create returns null)', async () => {
183
+ const denyConfig = await config({
184
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
185
+ lists: {
186
+ User: list({
187
+ fields: { name: text() },
188
+ access: { operation: { query: () => true, create: () => false } },
189
+ }),
190
+ },
191
+ })
192
+ const context = getContext(denyConfig, mock.client, { userId: '1' })
193
+
194
+ const created = await context.transaction((tx) => tx.db.user.create({ data: { name: 'jane' } }))
195
+
196
+ expect(created).toBeNull()
197
+ expect(mock.tables.user.size).toBe(0)
198
+ })
199
+
200
+ it('fires list hooks inside the transaction', async () => {
201
+ const resolveInput = vi.fn(({ resolvedData }) => ({ ...resolvedData, name: 'transformed' }))
202
+ const hookConfig = await config({
203
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
204
+ lists: {
205
+ User: list({
206
+ fields: { name: text() },
207
+ access: { operation: { query: () => true, create: () => true } },
208
+ hooks: { resolveInput },
209
+ }),
210
+ },
211
+ })
212
+ const context = getContext(hookConfig, mock.client, { userId: '1' })
213
+
214
+ const created = await context.transaction((tx) => tx.db.user.create({ data: { name: 'jane' } }))
215
+
216
+ expect(resolveInput).toHaveBeenCalledTimes(1)
217
+ expect(created).toEqual(expect.objectContaining({ name: 'transformed' }))
218
+ })
219
+
220
+ it('propagates serialization failures to the caller (not swallowed to null)', async () => {
221
+ const context = getContext(await baseConfig(), mock.client, { userId: '1' })
222
+
223
+ await expect(
224
+ context.transaction(async () => {
225
+ throw makeSerializationError()
226
+ }),
227
+ ).rejects.toMatchObject({ code: 'P2034' })
228
+ })
229
+
230
+ it('the tx context carries the same session and a working sudo()', async () => {
231
+ const context = getContext(await baseConfig(), mock.client, { userId: '42' })
232
+
233
+ const seen = await context.transaction(async (tx) => {
234
+ expect(tx.session).toEqual({ userId: '42' })
235
+ expect(typeof tx.sudo).toBe('function')
236
+ expect(tx.sudo()._isSudo).toBe(true)
237
+ return tx.session?.userId
238
+ })
239
+
240
+ expect(seen).toBe('42')
241
+ })
242
+
243
+ it('sudo() writes inside the transaction are atomic and roll back with it', async () => {
244
+ const context = getContext(await baseConfig(), mock.client, { userId: '1' })
245
+
246
+ // A sudo write persists when the transaction commits.
247
+ const created = await context.transaction((tx) =>
248
+ tx.sudo().db.user.create({ data: { name: 'jane' } }),
249
+ )
250
+ expect(created).toEqual(expect.objectContaining({ name: 'jane' }))
251
+ expect(mock.tables.user.size).toBe(1)
252
+
253
+ // A sudo write rolls back when the transaction throws (it is bound to `tx`,
254
+ // not the original client).
255
+ await expect(
256
+ context.transaction(async (tx) => {
257
+ await tx.sudo().db.user.create({ data: { name: 'rollback-me' } })
258
+ throw new Error('boom')
259
+ }),
260
+ ).rejects.toThrow('boom')
261
+ expect(mock.tables.user.size).toBe(1)
262
+ })
263
+
264
+ it('falls back to running directly when the client has no $transaction', async () => {
265
+ // A client without `$transaction` (e.g. a plain mock or an already-open tx).
266
+ const tables = new Map<string, Record<string, unknown>>()
267
+ const plainClient: Record<string, unknown> = {
268
+ user: {
269
+ findUnique: vi.fn(
270
+ async ({ where }: { where: { id: string } }) => tables.get(where.id) ?? null,
271
+ ),
272
+ findMany: vi.fn(async () => Array.from(tables.values())),
273
+ count: vi.fn(async () => tables.size),
274
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
275
+ const record = { ...data, id: 'u1' }
276
+ tables.set('u1', record)
277
+ return record
278
+ }),
279
+ },
280
+ }
281
+ const context = getContext(await baseConfig(), plainClient, { userId: '1' })
282
+
283
+ const created = await context.transaction(async (tx) => {
284
+ expect(tx.db).toBeDefined()
285
+ return tx.db.user.create({ data: { name: 'jane' } })
286
+ })
287
+
288
+ expect(created).toEqual(expect.objectContaining({ name: 'jane' }))
289
+ expect(tables.size).toBe(1)
290
+ })
291
+ })
292
+
293
+ /**
294
+ * Serializable-isolation emulation for the capacity-gate use case.
295
+ *
296
+ * Each transaction reads a snapshot taken at begin and buffers its writes. At
297
+ * commit, if a transaction that committed during this tx's lifetime wrote to a
298
+ * table this tx READ (a predicate/phantom conflict) or WROTE, this tx fails with
299
+ * a P2034 serialization error — exactly the conflict a `Serializable` capacity
300
+ * gate must surface. With a caller-owned retry loop this yields exactly-N.
301
+ */
302
+ function createSerializablePrisma() {
303
+ const committed: Record<string, Map<string, Record<string, unknown>>> = { booking: new Map() }
304
+ let version = 0
305
+ let idCounter = 0
306
+ const log: { writeTables: Set<string>; version: number }[] = []
307
+
308
+ function snapshotTables() {
309
+ const snap: Record<string, Map<string, Record<string, unknown>>> = {}
310
+ for (const [name, map] of Object.entries(committed)) snap[name] = new Map(map)
311
+ return snap
312
+ }
313
+
314
+ const client: Record<string, unknown> = {
315
+ // Direct (non-tx) access is not used by these tests, but keep a booking model
316
+ // so getContext can build a delegate.
317
+ booking: {
318
+ findUnique: vi.fn(async () => null),
319
+ findMany: vi.fn(async () => Array.from(committed.booking.values())),
320
+ count: vi.fn(async () => committed.booking.size),
321
+ create: vi.fn(),
322
+ },
323
+ }
324
+
325
+ client.$transaction = async (fn: (tx: unknown) => Promise<unknown>, _options?: unknown) => {
326
+ const beganAt = version
327
+ const view = snapshotTables()
328
+ const buffer = new Map<string, Record<string, unknown>>()
329
+ const readTables = new Set<string>()
330
+ const writeTables = new Set<string>()
331
+
332
+ const tx: Record<string, unknown> = {
333
+ booking: {
334
+ findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
335
+ readTables.add('booking')
336
+ return buffer.get(where.id) ?? view.booking.get(where.id) ?? null
337
+ }),
338
+ findMany: vi.fn(async () => {
339
+ readTables.add('booking')
340
+ return [...view.booking.values(), ...buffer.values()]
341
+ }),
342
+ count: vi.fn(async ({ where }: { where?: { slotId?: string } } = {}) => {
343
+ readTables.add('booking')
344
+ const all = [...view.booking.values(), ...buffer.values()]
345
+ if (where?.slotId) return all.filter((r) => r.slotId === where.slotId).length
346
+ return all.length
347
+ }),
348
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
349
+ writeTables.add('booking')
350
+ const id = `b-${++idCounter}`
351
+ const record = { ...data, id }
352
+ buffer.set(id, record)
353
+ return record
354
+ }),
355
+ },
356
+ }
357
+
358
+ const result = await fn(tx)
359
+
360
+ // Commit: detect serialization conflicts against tx that committed after we began.
361
+ for (const entry of log) {
362
+ if (entry.version <= beganAt) continue
363
+ const conflictsRead = [...entry.writeTables].some((t) => readTables.has(t))
364
+ const conflictsWrite = [...entry.writeTables].some((t) => writeTables.has(t))
365
+ if (conflictsRead || conflictsWrite) {
366
+ const err = new Error('serialization failure') as Error & { code: string }
367
+ err.code = 'P2034'
368
+ throw err
369
+ }
370
+ }
371
+
372
+ // No conflict: apply buffered writes and advance the version.
373
+ for (const [id, record] of buffer) committed.booking.set(id, record)
374
+ version += 1
375
+ log.push({ writeTables, version })
376
+ return result
377
+ }
378
+
379
+ return { client, committed }
380
+ }
381
+
382
+ function makeBarrier(n: number) {
383
+ let count = 0
384
+ let release: () => void = () => {}
385
+ const gate = new Promise<void>((resolve) => {
386
+ release = resolve
387
+ })
388
+ return async () => {
389
+ count += 1
390
+ if (count >= n) release()
391
+ await gate
392
+ }
393
+ }
394
+
395
+ describe('#614 capacity gate under Serializable contention', () => {
396
+ it('N concurrent transactions against a capacity-N slot commit exactly N', async () => {
397
+ const capacity = 2
398
+ const racers = 4
399
+ const slotId = 'slot-1'
400
+
401
+ const mock = createSerializablePrisma()
402
+ const cfg = await config({
403
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
404
+ lists: {
405
+ Booking: list({
406
+ fields: { slotId: text() },
407
+ access: { operation: { query: () => true, create: () => true } },
408
+ }),
409
+ },
410
+ })
411
+ const context = getContext(cfg, mock.client, { userId: '1' })
412
+
413
+ // All racers must READ the count before any of them COMMITS, to create the
414
+ // contention. Only the first attempt waits at the barrier; retries proceed.
415
+ const barrier = makeBarrier(racers)
416
+
417
+ async function book(): Promise<boolean> {
418
+ for (let attempt = 0; attempt < 50; attempt++) {
419
+ try {
420
+ return await context.transaction(
421
+ async (tx) => {
422
+ const count = await tx.db.booking.count({ where: { slotId } })
423
+ if (attempt === 0) await barrier()
424
+ if (count >= capacity) return false
425
+ await tx.db.booking.create({ data: { slotId } })
426
+ return true
427
+ },
428
+ { isolationLevel: 'Serializable' },
429
+ )
430
+ } catch (err) {
431
+ if (isSerializationError(err)) continue
432
+ throw err
433
+ }
434
+ }
435
+ throw new Error('exceeded retry budget')
436
+ }
437
+
438
+ const results = await Promise.all(Array.from({ length: racers }, () => book()))
439
+
440
+ const booked = results.filter(Boolean).length
441
+ expect(booked).toBe(capacity)
442
+ expect(mock.committed.booking.size).toBe(capacity)
443
+ })
444
+ })
@@ -138,8 +138,10 @@ describe('Sudo Context', () => {
138
138
  data: { title: 'New Post' },
139
139
  })
140
140
  expect(sudoResult).toMatchObject({ title: 'New Post' })
141
+ // `views` declares `defaultValue: 0`, so the omitted value is resolved to
142
+ // its default before persistence (#615 resolve-then-validate).
141
143
  expect(mockPrisma.post.create).toHaveBeenCalledWith({
142
- data: { title: 'New Post' },
144
+ data: { title: 'New Post', views: 0 },
143
145
  })
144
146
  })
145
147
 
@@ -155,9 +157,11 @@ describe('Sudo Context', () => {
155
157
  data: { title: 'New Post', secretField: 'secret' },
156
158
  })
157
159
 
158
- // Verify that secretField was passed to Prisma
160
+ // Verify that secretField was passed to Prisma. `views` declares
161
+ // `defaultValue: 0`, so the omitted value is resolved to its default
162
+ // before persistence (#615 resolve-then-validate).
159
163
  expect(mockPrisma.post.create).toHaveBeenCalledWith({
160
- data: { title: 'New Post', secretField: 'secret' },
164
+ data: { title: 'New Post', secretField: 'secret', views: 0 },
161
165
  })
162
166
  })
163
167