@prisma-next/sql-runtime 0.4.0-dev.9 → 0.5.0-dev.1
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/dist/{exports-BO6Fl7yn.mjs → exports-TJ70Qw3r.mjs} +71 -7
- package/dist/exports-TJ70Qw3r.mjs.map +1 -0
- package/dist/{index-n6z6trta.d.mts → index-DyDQ4fyK.d.mts} +27 -2
- package/dist/{index-n6z6trta.d.mts.map → index-DyDQ4fyK.d.mts.map} +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/test/utils.d.mts +1 -1
- package/dist/test/utils.d.mts.map +1 -1
- package/dist/test/utils.mjs +6 -9
- package/dist/test/utils.mjs.map +1 -1
- package/package.json +11 -11
- package/src/exports/index.ts +5 -1
- package/src/lower-sql-plan.ts +2 -4
- package/src/sql-runtime.ts +139 -1
- package/test/execution-stack.test.ts +1 -4
- package/test/sql-runtime.test.ts +275 -8
- package/test/utils.ts +2 -5
- package/dist/exports-BO6Fl7yn.mjs.map +0 -1
package/src/lower-sql-plan.ts
CHANGED
|
@@ -21,11 +21,9 @@ export function lowerSqlPlan<Row>(
|
|
|
21
21
|
params: queryPlan.params,
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
const body = lowered.body;
|
|
25
|
-
|
|
26
24
|
return Object.freeze({
|
|
27
|
-
sql:
|
|
28
|
-
params:
|
|
25
|
+
sql: lowered.sql,
|
|
26
|
+
params: lowered.params ?? queryPlan.params,
|
|
29
27
|
ast: queryPlan.ast,
|
|
30
28
|
meta: queryPlan.meta,
|
|
31
29
|
});
|
package/src/sql-runtime.ts
CHANGED
|
@@ -13,7 +13,11 @@ import type {
|
|
|
13
13
|
RuntimeVerifyOptions,
|
|
14
14
|
TelemetryOutcome,
|
|
15
15
|
} from '@prisma-next/runtime-executor';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
AsyncIterableResult,
|
|
18
|
+
createRuntimeCore,
|
|
19
|
+
runtimeError,
|
|
20
|
+
} from '@prisma-next/runtime-executor';
|
|
17
21
|
import type { SqlStorage } from '@prisma-next/sql-contract/types';
|
|
18
22
|
import type {
|
|
19
23
|
Adapter,
|
|
@@ -73,7 +77,28 @@ export interface Runtime extends RuntimeQueryable {
|
|
|
73
77
|
|
|
74
78
|
export interface RuntimeConnection extends RuntimeQueryable {
|
|
75
79
|
transaction(): Promise<RuntimeTransaction>;
|
|
80
|
+
/**
|
|
81
|
+
* Returns the connection to the pool for reuse. Only call this when the
|
|
82
|
+
* connection is known to be in a clean state. If a transaction
|
|
83
|
+
* commit/rollback failed or the connection is otherwise suspect, call
|
|
84
|
+
* `destroy(reason)` instead.
|
|
85
|
+
*/
|
|
76
86
|
release(): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Evicts the connection so it is never reused. Call this when the
|
|
89
|
+
* connection may be in an indeterminate state (e.g. a failed rollback
|
|
90
|
+
* leaving an open transaction, or a broken socket).
|
|
91
|
+
*
|
|
92
|
+
* If teardown fails the error is propagated and the connection remains
|
|
93
|
+
* retryable, so the caller can decide whether to swallow the failure or
|
|
94
|
+
* retry cleanup. Calling destroy() or release() more than once after a
|
|
95
|
+
* successful teardown is caller error.
|
|
96
|
+
*
|
|
97
|
+
* `reason` is advisory context only. It may be surfaced to driver-level
|
|
98
|
+
* observability hooks (e.g. pg-pool's `'release'` event) but does not
|
|
99
|
+
* influence eviction behavior and is not rethrown.
|
|
100
|
+
*/
|
|
101
|
+
destroy(reason?: unknown): Promise<void>;
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
export interface RuntimeTransaction extends RuntimeQueryable {
|
|
@@ -87,6 +112,10 @@ export interface RuntimeQueryable {
|
|
|
87
112
|
): AsyncIterableResult<Row>;
|
|
88
113
|
}
|
|
89
114
|
|
|
115
|
+
export interface TransactionContext extends RuntimeQueryable {
|
|
116
|
+
readonly invalidated: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
interface CoreQueryable {
|
|
91
120
|
execute<Row = Record<string, unknown>>(plan: ExecutionPlan<Row>): AsyncIterableResult<Row>;
|
|
92
121
|
}
|
|
@@ -206,6 +235,7 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
|
|
|
206
235
|
};
|
|
207
236
|
},
|
|
208
237
|
release: coreConn.release.bind(coreConn),
|
|
238
|
+
destroy: coreConn.destroy.bind(coreConn),
|
|
209
239
|
execute<Row = Record<string, unknown>>(
|
|
210
240
|
plan: ExecutionPlan<Row> | SqlQueryPlan<Row>,
|
|
211
241
|
): AsyncIterableResult<Row> {
|
|
@@ -224,6 +254,114 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
|
|
|
224
254
|
}
|
|
225
255
|
}
|
|
226
256
|
|
|
257
|
+
function transactionClosedError(): Error {
|
|
258
|
+
return runtimeError(
|
|
259
|
+
'RUNTIME.TRANSACTION_CLOSED',
|
|
260
|
+
'Cannot read from a query result after the transaction has ended. Await the result or call .toArray() inside the transaction callback.',
|
|
261
|
+
{},
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function withTransaction<R>(
|
|
266
|
+
runtime: Runtime,
|
|
267
|
+
fn: (tx: TransactionContext) => PromiseLike<R>,
|
|
268
|
+
): Promise<R> {
|
|
269
|
+
const connection = await runtime.connection();
|
|
270
|
+
const transaction = await connection.transaction();
|
|
271
|
+
|
|
272
|
+
let invalidated = false;
|
|
273
|
+
const txContext: TransactionContext = {
|
|
274
|
+
get invalidated() {
|
|
275
|
+
return invalidated;
|
|
276
|
+
},
|
|
277
|
+
execute<Row = Record<string, unknown>>(
|
|
278
|
+
plan: ExecutionPlan<Row> | SqlQueryPlan<Row>,
|
|
279
|
+
): AsyncIterableResult<Row> {
|
|
280
|
+
if (invalidated) {
|
|
281
|
+
throw transactionClosedError();
|
|
282
|
+
}
|
|
283
|
+
const inner = transaction.execute(plan);
|
|
284
|
+
const guarded = async function* (): AsyncGenerator<Row, void, unknown> {
|
|
285
|
+
for await (const row of inner) {
|
|
286
|
+
if (invalidated) {
|
|
287
|
+
throw transactionClosedError();
|
|
288
|
+
}
|
|
289
|
+
yield row;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
return new AsyncIterableResult(guarded());
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
let connectionDisposed = false;
|
|
297
|
+
const destroyConnection = async (reason: unknown): Promise<void> => {
|
|
298
|
+
if (connectionDisposed) return;
|
|
299
|
+
connectionDisposed = true;
|
|
300
|
+
// SqlConnection.destroy() propagates teardown errors so callers can
|
|
301
|
+
// decide what to do with them. Here, we're already about to throw a
|
|
302
|
+
// more informative error describing why we're evicting the connection
|
|
303
|
+
// (rollback/commit failure), so swallowing the teardown error is the
|
|
304
|
+
// right call — surfacing it would mask the original cause.
|
|
305
|
+
await connection.destroy(reason).catch(() => undefined);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
let result: R;
|
|
310
|
+
try {
|
|
311
|
+
result = await fn(txContext);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
try {
|
|
314
|
+
await transaction.rollback();
|
|
315
|
+
} catch (rollbackError) {
|
|
316
|
+
await destroyConnection(rollbackError);
|
|
317
|
+
const wrapped = runtimeError(
|
|
318
|
+
'RUNTIME.TRANSACTION_ROLLBACK_FAILED',
|
|
319
|
+
'Transaction rollback failed after callback error',
|
|
320
|
+
{ rollbackError },
|
|
321
|
+
);
|
|
322
|
+
wrapped.cause = error;
|
|
323
|
+
throw wrapped;
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
} finally {
|
|
327
|
+
invalidated = true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
await transaction.commit();
|
|
332
|
+
} catch (commitError) {
|
|
333
|
+
// After a failed COMMIT the server-side transaction may be: (a) already
|
|
334
|
+
// committed (error on response path), (b) already rolled back (deferred
|
|
335
|
+
// constraint / serialization failure), or (c) still open (COMMIT never
|
|
336
|
+
// reached the server). Attempt a best-effort rollback to cover (c) and
|
|
337
|
+
// confirm the protocol is healthy.
|
|
338
|
+
//
|
|
339
|
+
// If rollback succeeds, the server is definitely no longer in a
|
|
340
|
+
// transaction (no-op in (a)/(b), real cleanup in (c)) and we've just
|
|
341
|
+
// proved the connection round-trips correctly — it's safe to return
|
|
342
|
+
// to the pool. If rollback fails, the connection state is ambiguous
|
|
343
|
+
// (broken socket, protocol desync, etc.) and we must destroy it.
|
|
344
|
+
try {
|
|
345
|
+
await transaction.rollback();
|
|
346
|
+
} catch {
|
|
347
|
+
await destroyConnection(commitError);
|
|
348
|
+
}
|
|
349
|
+
const wrapped = runtimeError(
|
|
350
|
+
'RUNTIME.TRANSACTION_COMMIT_FAILED',
|
|
351
|
+
'Transaction commit failed',
|
|
352
|
+
{ commitError },
|
|
353
|
+
);
|
|
354
|
+
wrapped.cause = commitError;
|
|
355
|
+
throw wrapped;
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
} finally {
|
|
359
|
+
if (!connectionDisposed) {
|
|
360
|
+
await connection.release();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
227
365
|
export function createRuntime<TContract extends Contract<SqlStorage>, TTargetId extends string>(
|
|
228
366
|
options: CreateRuntimeOptions<TContract, TTargetId>,
|
|
229
367
|
): Runtime {
|
|
@@ -41,10 +41,7 @@ function createStubAdapterDescriptor(): SqlRuntimeAdapterDescriptor<'postgres'>
|
|
|
41
41
|
readMarkerStatement: () => ({ sql: '', params: [] }),
|
|
42
42
|
},
|
|
43
43
|
lower() {
|
|
44
|
-
return {
|
|
45
|
-
profileId: 'test-profile',
|
|
46
|
-
body: Object.freeze({ sql: '', params: [] }),
|
|
47
|
-
};
|
|
44
|
+
return Object.freeze({ sql: '', params: [] });
|
|
48
45
|
},
|
|
49
46
|
},
|
|
50
47
|
);
|
package/test/sql-runtime.test.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
SqlRuntimeTargetDescriptor,
|
|
22
22
|
} from '../src/sql-context';
|
|
23
23
|
import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context';
|
|
24
|
-
import { createRuntime } from '../src/sql-runtime';
|
|
24
|
+
import { createRuntime, withTransaction } from '../src/sql-runtime';
|
|
25
25
|
|
|
26
26
|
const testContract: Contract<SqlStorage> = {
|
|
27
27
|
targetFamily: 'sql',
|
|
@@ -35,13 +35,18 @@ const testContract: Contract<SqlStorage> = {
|
|
|
35
35
|
meta: {},
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
interface
|
|
38
|
+
interface DriverMockSpies {
|
|
39
39
|
rootExecute: ReturnType<typeof vi.fn>;
|
|
40
40
|
connectionExecute: ReturnType<typeof vi.fn>;
|
|
41
41
|
transactionExecute: ReturnType<typeof vi.fn>;
|
|
42
|
+
connectionRelease: ReturnType<typeof vi.fn>;
|
|
43
|
+
connectionDestroy: ReturnType<typeof vi.fn>;
|
|
44
|
+
transactionCommit: ReturnType<typeof vi.fn>;
|
|
45
|
+
transactionRollback: ReturnType<typeof vi.fn>;
|
|
46
|
+
driverClose: ReturnType<typeof vi.fn>;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
type MockSqlDriver = SqlDriver & { __spies:
|
|
49
|
+
type MockSqlDriver = SqlDriver & { __spies: DriverMockSpies };
|
|
45
50
|
|
|
46
51
|
function createStubCodecs(): CodecRegistry {
|
|
47
52
|
const registry = createCodecRegistry();
|
|
@@ -76,10 +81,7 @@ function createStubAdapter() {
|
|
|
76
81
|
},
|
|
77
82
|
},
|
|
78
83
|
lower(ast: SelectAst) {
|
|
79
|
-
return {
|
|
80
|
-
profileId: 'test-profile',
|
|
81
|
-
body: Object.freeze({ sql: JSON.stringify(ast), params: [] }),
|
|
82
|
-
};
|
|
84
|
+
return Object.freeze({ sql: JSON.stringify(ast), params: [] });
|
|
83
85
|
},
|
|
84
86
|
};
|
|
85
87
|
}
|
|
@@ -112,15 +114,18 @@ function createMockDriver(): MockSqlDriver {
|
|
|
112
114
|
execute: connectionExecute,
|
|
113
115
|
query,
|
|
114
116
|
release: vi.fn().mockResolvedValue(undefined),
|
|
117
|
+
destroy: vi.fn().mockResolvedValue(undefined),
|
|
115
118
|
beginTransaction: vi.fn().mockResolvedValue(transaction),
|
|
116
119
|
};
|
|
117
120
|
|
|
121
|
+
const driverClose = vi.fn().mockResolvedValue(undefined);
|
|
122
|
+
|
|
118
123
|
const driver: SqlDriver = {
|
|
119
124
|
execute: rootExecute,
|
|
120
125
|
query,
|
|
121
126
|
connect: vi.fn().mockImplementation(async (_binding?: undefined) => undefined),
|
|
122
127
|
acquireConnection: vi.fn().mockResolvedValue(connection),
|
|
123
|
-
close:
|
|
128
|
+
close: driverClose,
|
|
124
129
|
};
|
|
125
130
|
|
|
126
131
|
return Object.assign(driver, {
|
|
@@ -128,6 +133,11 @@ function createMockDriver(): MockSqlDriver {
|
|
|
128
133
|
rootExecute,
|
|
129
134
|
connectionExecute,
|
|
130
135
|
transactionExecute,
|
|
136
|
+
connectionRelease: connection.release,
|
|
137
|
+
connectionDestroy: connection.destroy,
|
|
138
|
+
transactionCommit: transaction.commit,
|
|
139
|
+
transactionRollback: transaction.rollback,
|
|
140
|
+
driverClose,
|
|
131
141
|
},
|
|
132
142
|
});
|
|
133
143
|
}
|
|
@@ -287,6 +297,24 @@ describe('createRuntime', () => {
|
|
|
287
297
|
await connection.release();
|
|
288
298
|
});
|
|
289
299
|
|
|
300
|
+
it('delegates connection.destroy() to the driver connection', async () => {
|
|
301
|
+
const { stackInstance, context, driver } = createTestSetup();
|
|
302
|
+
const runtime = createRuntime({
|
|
303
|
+
stackInstance,
|
|
304
|
+
context,
|
|
305
|
+
driver,
|
|
306
|
+
verify: { mode: 'onFirstUse', requireMarker: false },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const connection = await runtime.connection();
|
|
310
|
+
const reason = new Error('bad state');
|
|
311
|
+
await connection.destroy(reason);
|
|
312
|
+
|
|
313
|
+
expect(driver.__spies.connectionDestroy).toHaveBeenCalledOnce();
|
|
314
|
+
expect(driver.__spies.connectionDestroy).toHaveBeenCalledWith(reason);
|
|
315
|
+
expect(driver.__spies.connectionRelease).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
|
|
290
318
|
it('uses transaction queryable for transaction.execute', async () => {
|
|
291
319
|
const { stackInstance, context, driver } = createTestSetup();
|
|
292
320
|
const runtime = createRuntime({
|
|
@@ -365,3 +393,242 @@ describe('createRuntime', () => {
|
|
|
365
393
|
);
|
|
366
394
|
});
|
|
367
395
|
});
|
|
396
|
+
|
|
397
|
+
describe('withTransaction', () => {
|
|
398
|
+
function createRuntimeForTransaction() {
|
|
399
|
+
const { stackInstance, context, driver } = createTestSetup();
|
|
400
|
+
const runtime = createRuntime({
|
|
401
|
+
stackInstance,
|
|
402
|
+
context,
|
|
403
|
+
driver,
|
|
404
|
+
verify: { mode: 'onFirstUse', requireMarker: false },
|
|
405
|
+
});
|
|
406
|
+
return { runtime, driver };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
it('commits on successful callback and returns the result', async () => {
|
|
410
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
411
|
+
|
|
412
|
+
const result = await withTransaction(runtime, async (tx) => {
|
|
413
|
+
await tx.execute(createRawExecutionPlan()).toArray();
|
|
414
|
+
return 42;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
expect(result).toBe(42);
|
|
418
|
+
expect(driver.__spies.transactionCommit).toHaveBeenCalledOnce();
|
|
419
|
+
expect(driver.__spies.transactionRollback).not.toHaveBeenCalled();
|
|
420
|
+
expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('rolls back on callback error and re-throws', async () => {
|
|
424
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
425
|
+
const error = new Error('test error');
|
|
426
|
+
|
|
427
|
+
await expect(
|
|
428
|
+
withTransaction(runtime, async () => {
|
|
429
|
+
throw error;
|
|
430
|
+
}),
|
|
431
|
+
).rejects.toBe(error);
|
|
432
|
+
|
|
433
|
+
expect(driver.__spies.transactionRollback).toHaveBeenCalledOnce();
|
|
434
|
+
expect(driver.__spies.transactionCommit).not.toHaveBeenCalled();
|
|
435
|
+
expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('releases connection after commit', async () => {
|
|
439
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
440
|
+
|
|
441
|
+
await withTransaction(runtime, async () => 'ok');
|
|
442
|
+
|
|
443
|
+
expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('releases connection after rollback', async () => {
|
|
447
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
448
|
+
|
|
449
|
+
await withTransaction(runtime, async () => {
|
|
450
|
+
throw new Error('fail');
|
|
451
|
+
}).catch(() => {});
|
|
452
|
+
|
|
453
|
+
expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('wraps commit failure and exposes the original error as cause', async () => {
|
|
457
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
458
|
+
const commitError = new Error('commit failed');
|
|
459
|
+
driver.__spies.transactionCommit.mockRejectedValueOnce(commitError);
|
|
460
|
+
|
|
461
|
+
const result = withTransaction(runtime, async () => 'value');
|
|
462
|
+
|
|
463
|
+
await expect(result).rejects.toMatchObject({
|
|
464
|
+
code: 'RUNTIME.TRANSACTION_COMMIT_FAILED',
|
|
465
|
+
cause: commitError,
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('attempts best-effort rollback after commit fails and releases when it succeeds', async () => {
|
|
470
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
471
|
+
const commitError = new Error('commit failed');
|
|
472
|
+
driver.__spies.transactionCommit.mockRejectedValueOnce(commitError);
|
|
473
|
+
|
|
474
|
+
await withTransaction(runtime, async () => 'value').catch(() => {});
|
|
475
|
+
|
|
476
|
+
expect(driver.__spies.transactionCommit).toHaveBeenCalledOnce();
|
|
477
|
+
expect(driver.__spies.transactionRollback).toHaveBeenCalledOnce();
|
|
478
|
+
// A successful rollback after a failed commit means the server is no
|
|
479
|
+
// longer in a transaction and the connection round-tripped cleanly, so
|
|
480
|
+
// it is safe to return to the pool rather than evict it.
|
|
481
|
+
expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
|
|
482
|
+
expect(driver.__spies.connectionDestroy).not.toHaveBeenCalled();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('forwards the callback return value', async () => {
|
|
486
|
+
const { runtime } = createRuntimeForTransaction();
|
|
487
|
+
|
|
488
|
+
const result = await withTransaction(runtime, async () => ({
|
|
489
|
+
name: 'test',
|
|
490
|
+
count: 3,
|
|
491
|
+
}));
|
|
492
|
+
|
|
493
|
+
expect(result).toEqual({ name: 'test', count: 3 });
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('executes queries against the transaction', async () => {
|
|
497
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
498
|
+
|
|
499
|
+
await withTransaction(runtime, async (tx) => {
|
|
500
|
+
await tx.execute(createRawExecutionPlan()).toArray();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(driver.__spies.transactionExecute).toHaveBeenCalledOnce();
|
|
504
|
+
expect(driver.__spies.rootExecute).not.toHaveBeenCalled();
|
|
505
|
+
expect(driver.__spies.connectionExecute).not.toHaveBeenCalled();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('throws on execute after commit (invalidation)', async () => {
|
|
509
|
+
const { runtime } = createRuntimeForTransaction();
|
|
510
|
+
let savedTx: { execute: (plan: ExecutionPlan) => unknown } | undefined;
|
|
511
|
+
|
|
512
|
+
await withTransaction(runtime, async (tx) => {
|
|
513
|
+
savedTx = tx;
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
expect(() => savedTx!.execute(createRawExecutionPlan())).toThrow(
|
|
517
|
+
'Cannot read from a query result after the transaction has ended',
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('throws on iteration of escaped AsyncIterableResult after commit', async () => {
|
|
522
|
+
const { runtime } = createRuntimeForTransaction();
|
|
523
|
+
|
|
524
|
+
const escaped = await withTransaction(runtime, async (tx) => {
|
|
525
|
+
return { result: tx.execute(createRawExecutionPlan()) };
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
await expect(escaped.result.toArray()).rejects.toThrow(
|
|
529
|
+
'Cannot read from a query result after the transaction has ended',
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('sets invalidated flag after commit', async () => {
|
|
534
|
+
const { runtime } = createRuntimeForTransaction();
|
|
535
|
+
let txRef: { invalidated: boolean } | undefined;
|
|
536
|
+
|
|
537
|
+
await withTransaction(runtime, async (tx) => {
|
|
538
|
+
expect(tx.invalidated).toBe(false);
|
|
539
|
+
txRef = tx;
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(txRef!.invalidated).toBe(true);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('wraps original error when rollback fails', async () => {
|
|
546
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
547
|
+
const callbackError = new Error('callback failed');
|
|
548
|
+
const rollbackError = new Error('rollback failed');
|
|
549
|
+
driver.__spies.transactionRollback.mockRejectedValueOnce(rollbackError);
|
|
550
|
+
|
|
551
|
+
const rejection = withTransaction(runtime, async () => {
|
|
552
|
+
throw callbackError;
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
await expect(rejection).rejects.toThrow('Transaction rollback failed after callback error');
|
|
556
|
+
await expect(rejection).rejects.toMatchObject({
|
|
557
|
+
code: 'RUNTIME.TRANSACTION_ROLLBACK_FAILED',
|
|
558
|
+
cause: callbackError,
|
|
559
|
+
details: { rollbackError },
|
|
560
|
+
});
|
|
561
|
+
expect(driver.__spies.connectionDestroy).toHaveBeenCalledOnce();
|
|
562
|
+
expect(driver.__spies.connectionRelease).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('destroys connection when rollback fails even if destroy also fails', async () => {
|
|
566
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
567
|
+
const callbackError = new Error('callback failed');
|
|
568
|
+
const rollbackError = new Error('rollback failed');
|
|
569
|
+
const destroyError = new Error('destroy failed');
|
|
570
|
+
driver.__spies.transactionRollback.mockRejectedValueOnce(rollbackError);
|
|
571
|
+
driver.__spies.connectionDestroy.mockRejectedValueOnce(destroyError);
|
|
572
|
+
|
|
573
|
+
const rejection = withTransaction(runtime, async () => {
|
|
574
|
+
throw callbackError;
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
await expect(rejection).rejects.toMatchObject({
|
|
578
|
+
code: 'RUNTIME.TRANSACTION_ROLLBACK_FAILED',
|
|
579
|
+
cause: callbackError,
|
|
580
|
+
details: { rollbackError },
|
|
581
|
+
});
|
|
582
|
+
expect(driver.__spies.connectionDestroy).toHaveBeenCalledOnce();
|
|
583
|
+
expect(driver.__spies.connectionRelease).not.toHaveBeenCalled();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('destroys connection when commit fails and best-effort rollback also fails', async () => {
|
|
587
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
588
|
+
const commitError = new Error('commit failed');
|
|
589
|
+
const rollbackError = new Error('rollback also failed');
|
|
590
|
+
driver.__spies.transactionCommit.mockRejectedValueOnce(commitError);
|
|
591
|
+
driver.__spies.transactionRollback.mockRejectedValueOnce(rollbackError);
|
|
592
|
+
|
|
593
|
+
const rejection = withTransaction(runtime, async () => 'value');
|
|
594
|
+
|
|
595
|
+
await expect(rejection).rejects.toMatchObject({
|
|
596
|
+
code: 'RUNTIME.TRANSACTION_COMMIT_FAILED',
|
|
597
|
+
cause: commitError,
|
|
598
|
+
});
|
|
599
|
+
expect(driver.__spies.connectionDestroy).toHaveBeenCalledOnce();
|
|
600
|
+
expect(driver.__spies.connectionRelease).not.toHaveBeenCalled();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('sets invalidated flag after rollback', async () => {
|
|
604
|
+
const { runtime } = createRuntimeForTransaction();
|
|
605
|
+
let txRef: { invalidated: boolean } | undefined;
|
|
606
|
+
|
|
607
|
+
await withTransaction(runtime, async (tx) => {
|
|
608
|
+
txRef = tx;
|
|
609
|
+
throw new Error('fail');
|
|
610
|
+
}).catch(() => {});
|
|
611
|
+
|
|
612
|
+
expect(txRef!.invalidated).toBe(true);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('releases connection independently across sequential transactions', async () => {
|
|
616
|
+
const { runtime, driver } = createRuntimeForTransaction();
|
|
617
|
+
|
|
618
|
+
await withTransaction(runtime, async (tx) => {
|
|
619
|
+
await tx.execute(createRawExecutionPlan()).toArray();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
await withTransaction(runtime, async (tx) => {
|
|
623
|
+
await tx.execute(createRawExecutionPlan()).toArray();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
await withTransaction(runtime, async () => {
|
|
627
|
+
throw new Error('fail');
|
|
628
|
+
}).catch(() => {});
|
|
629
|
+
|
|
630
|
+
expect(driver.__spies.connectionRelease).toHaveBeenCalledTimes(3);
|
|
631
|
+
expect(driver.__spies.transactionCommit).toHaveBeenCalledTimes(2);
|
|
632
|
+
expect(driver.__spies.transactionRollback).toHaveBeenCalledTimes(1);
|
|
633
|
+
});
|
|
634
|
+
});
|
package/test/utils.ts
CHANGED
|
@@ -132,7 +132,7 @@ export function createTestAdapterDescriptor(
|
|
|
132
132
|
codecs: () => codecRegistry,
|
|
133
133
|
parameterizedCodecs: () => [],
|
|
134
134
|
mutationDefaultGenerators: createTestMutationDefaultGenerators,
|
|
135
|
-
create(): SqlRuntimeAdapterInstance<'postgres'> {
|
|
135
|
+
create(_stack): SqlRuntimeAdapterInstance<'postgres'> {
|
|
136
136
|
return Object.assign({ familyId: 'sql' as const, targetId: 'postgres' as const }, adapter);
|
|
137
137
|
},
|
|
138
138
|
};
|
|
@@ -256,10 +256,7 @@ export function createStubAdapter(): Adapter<SelectAst, Contract<SqlStorage>, Lo
|
|
|
256
256
|
},
|
|
257
257
|
lower(ast: SelectAst, ctx: { contract: Contract<SqlStorage>; params?: readonly unknown[] }) {
|
|
258
258
|
const sqlText = JSON.stringify(ast);
|
|
259
|
-
return {
|
|
260
|
-
profileId: this.profile.id,
|
|
261
|
-
body: Object.freeze({ sql: sqlText, params: ctx.params ? [...ctx.params] : [] }),
|
|
262
|
-
};
|
|
259
|
+
return Object.freeze({ sql: sqlText, params: ctx.params ? [...ctx.params] : [] });
|
|
263
260
|
},
|
|
264
261
|
};
|
|
265
262
|
}
|