@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +40 -0
- package/CLAUDE.md +50 -0
- package/dist/context/apply-defaults.d.ts +36 -0
- package/dist/context/apply-defaults.d.ts.map +1 -0
- package/dist/context/apply-defaults.js +70 -0
- package/dist/context/apply-defaults.js.map +1 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +10 -0
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +60 -17
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +41 -13
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +6 -0
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/context/apply-defaults.ts +79 -0
- package/src/context/hook-pipeline.ts +11 -0
- package/src/context/index.ts +133 -31
- package/src/context/nested-operations.ts +7 -0
- package/src/index.ts +5 -0
- package/tests/apply-defaults.test.ts +119 -0
- package/tests/default-value-create.test.ts +299 -0
- package/tests/interactive-transaction.test.ts +444 -0
- package/tests/sudo.test.ts +7 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
})
|
package/tests/sudo.test.ts
CHANGED
|
@@ -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
|
|