@prisma-next/sql-runtime 0.4.0-dev.9 → 0.4.2

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.
@@ -9,19 +9,28 @@ import {
9
9
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
10
10
  import type {
11
11
  CodecRegistry,
12
- SelectAst,
13
12
  SqlDriver,
14
13
  SqlExecuteRequest,
15
14
  } from '@prisma-next/sql-relational-core/ast';
16
- import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast';
15
+ import {
16
+ BinaryExpr,
17
+ ColumnRef,
18
+ codec,
19
+ createCodecRegistry,
20
+ LiteralExpr,
21
+ SelectAst,
22
+ TableSource,
23
+ } from '@prisma-next/sql-relational-core/ast';
24
+ import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
17
25
  import { describe, expect, it, vi } from 'vitest';
26
+ import type { SqlMiddleware } from '../src/middleware/sql-middleware';
18
27
  import type {
19
28
  SqlRuntimeAdapterDescriptor,
20
29
  SqlRuntimeAdapterInstance,
21
30
  SqlRuntimeTargetDescriptor,
22
31
  } from '../src/sql-context';
23
32
  import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context';
24
- import { createRuntime } from '../src/sql-runtime';
33
+ import { createRuntime, withTransaction } from '../src/sql-runtime';
25
34
 
26
35
  const testContract: Contract<SqlStorage> = {
27
36
  targetFamily: 'sql',
@@ -35,13 +44,18 @@ const testContract: Contract<SqlStorage> = {
35
44
  meta: {},
36
45
  };
37
46
 
38
- interface DriverExecuteSpies {
47
+ interface DriverMockSpies {
39
48
  rootExecute: ReturnType<typeof vi.fn>;
40
49
  connectionExecute: ReturnType<typeof vi.fn>;
41
50
  transactionExecute: ReturnType<typeof vi.fn>;
51
+ connectionRelease: ReturnType<typeof vi.fn>;
52
+ connectionDestroy: ReturnType<typeof vi.fn>;
53
+ transactionCommit: ReturnType<typeof vi.fn>;
54
+ transactionRollback: ReturnType<typeof vi.fn>;
55
+ driverClose: ReturnType<typeof vi.fn>;
42
56
  }
43
57
 
44
- type MockSqlDriver = SqlDriver & { __spies: DriverExecuteSpies };
58
+ type MockSqlDriver = SqlDriver & { __spies: DriverMockSpies };
45
59
 
46
60
  function createStubCodecs(): CodecRegistry {
47
61
  const registry = createCodecRegistry();
@@ -76,10 +90,7 @@ function createStubAdapter() {
76
90
  },
77
91
  },
78
92
  lower(ast: SelectAst) {
79
- return {
80
- profileId: 'test-profile',
81
- body: Object.freeze({ sql: JSON.stringify(ast), params: [] }),
82
- };
93
+ return Object.freeze({ sql: JSON.stringify(ast), params: [] });
83
94
  },
84
95
  };
85
96
  }
@@ -112,15 +123,18 @@ function createMockDriver(): MockSqlDriver {
112
123
  execute: connectionExecute,
113
124
  query,
114
125
  release: vi.fn().mockResolvedValue(undefined),
126
+ destroy: vi.fn().mockResolvedValue(undefined),
115
127
  beginTransaction: vi.fn().mockResolvedValue(transaction),
116
128
  };
117
129
 
130
+ const driverClose = vi.fn().mockResolvedValue(undefined);
131
+
118
132
  const driver: SqlDriver = {
119
133
  execute: rootExecute,
120
134
  query,
121
135
  connect: vi.fn().mockImplementation(async (_binding?: undefined) => undefined),
122
136
  acquireConnection: vi.fn().mockResolvedValue(connection),
123
- close: vi.fn().mockResolvedValue(undefined),
137
+ close: driverClose,
124
138
  };
125
139
 
126
140
  return Object.assign(driver, {
@@ -128,6 +142,11 @@ function createMockDriver(): MockSqlDriver {
128
142
  rootExecute,
129
143
  connectionExecute,
130
144
  transactionExecute,
145
+ connectionRelease: connection.release,
146
+ connectionDestroy: connection.destroy,
147
+ transactionCommit: transaction.commit,
148
+ transactionRollback: transaction.rollback,
149
+ driverClose,
131
150
  },
132
151
  });
133
152
  }
@@ -287,6 +306,24 @@ describe('createRuntime', () => {
287
306
  await connection.release();
288
307
  });
289
308
 
309
+ it('delegates connection.destroy() to the driver connection', async () => {
310
+ const { stackInstance, context, driver } = createTestSetup();
311
+ const runtime = createRuntime({
312
+ stackInstance,
313
+ context,
314
+ driver,
315
+ verify: { mode: 'onFirstUse', requireMarker: false },
316
+ });
317
+
318
+ const connection = await runtime.connection();
319
+ const reason = new Error('bad state');
320
+ await connection.destroy(reason);
321
+
322
+ expect(driver.__spies.connectionDestroy).toHaveBeenCalledOnce();
323
+ expect(driver.__spies.connectionDestroy).toHaveBeenCalledWith(reason);
324
+ expect(driver.__spies.connectionRelease).not.toHaveBeenCalled();
325
+ });
326
+
290
327
  it('uses transaction queryable for transaction.execute', async () => {
291
328
  const { stackInstance, context, driver } = createTestSetup();
292
329
  const runtime = createRuntime({
@@ -352,16 +389,404 @@ describe('createRuntime', () => {
352
389
 
353
390
  it('rejects a Mongo middleware with a clear error', () => {
354
391
  const { stackInstance, context, driver } = createTestSetup();
392
+ // Simulate a caller bypassing the SqlMiddleware type constraint (e.g. dynamically-loaded
393
+ // middleware). Static typing already rejects familyId: 'mongo'; this tests the runtime guard.
394
+ const mongoMiddleware = { name: 'mongo-mw', familyId: 'mongo' } as unknown as SqlMiddleware;
355
395
  expect(() =>
356
396
  createRuntime({
357
397
  stackInstance,
358
398
  context,
359
399
  driver,
360
400
  verify: { mode: 'onFirstUse', requireMarker: false },
361
- middleware: [{ name: 'mongo-mw', familyId: 'mongo' }],
401
+ middleware: [mongoMiddleware],
362
402
  }),
363
403
  ).toThrow(
364
404
  "Middleware 'mongo-mw' requires family 'mongo' but the runtime is configured for family 'sql'",
365
405
  );
366
406
  });
407
+
408
+ it('invokes beforeCompile and lowers the rewritten AST', async () => {
409
+ const { stackInstance, context, driver } = createTestSetup();
410
+ const debug = vi.fn();
411
+ const softDeletePredicate = BinaryExpr.eq(
412
+ ColumnRef.of('users', 'deleted_at'),
413
+ LiteralExpr.of(null),
414
+ );
415
+ const softDelete: SqlMiddleware = {
416
+ name: 'softDelete',
417
+ familyId: 'sql',
418
+ async beforeCompile(draft) {
419
+ if (draft.ast.kind !== 'select') return;
420
+ return { ...draft, ast: draft.ast.withWhere(softDeletePredicate) };
421
+ },
422
+ };
423
+
424
+ const runtime = createRuntime({
425
+ stackInstance,
426
+ context,
427
+ driver,
428
+ verify: { mode: 'onFirstUse', requireMarker: false },
429
+ middleware: [softDelete],
430
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug },
431
+ });
432
+
433
+ const queryPlan: SqlQueryPlan = {
434
+ ast: SelectAst.from(TableSource.named('users')).withProjection([]),
435
+ params: [],
436
+ meta: {
437
+ target: 'postgres',
438
+ storageHash: testContract.storage.storageHash,
439
+ lane: 'dsl',
440
+ paramDescriptors: [],
441
+ },
442
+ };
443
+
444
+ await runtime.execute(queryPlan).toArray();
445
+
446
+ expect(driver.__spies.rootExecute).toHaveBeenCalledTimes(1);
447
+ const request = driver.__spies.rootExecute.mock.calls[0]?.[0] as SqlExecuteRequest;
448
+ expect(request.sql).toContain('deleted_at');
449
+ expect(debug).toHaveBeenCalledWith({
450
+ event: 'middleware.rewrite',
451
+ middleware: 'softDelete',
452
+ lane: 'dsl',
453
+ });
454
+ });
455
+
456
+ it('invokes adapter.lower exactly once per execute regardless of chain length', async () => {
457
+ const adapter = createStubAdapter();
458
+ const lowerSpy = vi.spyOn(adapter, 'lower');
459
+ const driver = createMockDriver();
460
+
461
+ const targetDescriptor = createTestTargetDescriptor();
462
+ const adapterDescriptor = createTestAdapterDescriptor(adapter);
463
+ const stack = createSqlExecutionStack({
464
+ target: targetDescriptor,
465
+ adapter: adapterDescriptor,
466
+ extensionPacks: [],
467
+ });
468
+ const stackInstance = instantiateExecutionStack(stack) as ExecutionStackInstance<
469
+ 'sql',
470
+ 'postgres',
471
+ SqlRuntimeAdapterInstance<'postgres'>,
472
+ RuntimeDriverInstance<'sql', 'postgres'>,
473
+ RuntimeExtensionInstance<'sql', 'postgres'>
474
+ >;
475
+ const context = createExecutionContext({
476
+ contract: testContract,
477
+ stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] },
478
+ });
479
+
480
+ const rewriteA: SqlMiddleware = {
481
+ name: 'rewriteA',
482
+ familyId: 'sql',
483
+ async beforeCompile(draft) {
484
+ if (draft.ast.kind !== 'select') return undefined;
485
+ return {
486
+ ...draft,
487
+ ast: draft.ast.withWhere(BinaryExpr.eq(ColumnRef.of('users', 'a'), LiteralExpr.of(1))),
488
+ };
489
+ },
490
+ };
491
+ const rewriteB: SqlMiddleware = {
492
+ name: 'rewriteB',
493
+ familyId: 'sql',
494
+ async beforeCompile(draft) {
495
+ if (draft.ast.kind !== 'select') return undefined;
496
+ return {
497
+ ...draft,
498
+ ast: draft.ast.withWhere(BinaryExpr.eq(ColumnRef.of('users', 'b'), LiteralExpr.of(2))),
499
+ };
500
+ },
501
+ };
502
+
503
+ const runtime = createRuntime({
504
+ stackInstance,
505
+ context,
506
+ driver,
507
+ verify: { mode: 'onFirstUse', requireMarker: false },
508
+ middleware: [rewriteA, rewriteB],
509
+ });
510
+
511
+ const queryPlan: SqlQueryPlan = {
512
+ ast: SelectAst.from(TableSource.named('users')).withProjection([]),
513
+ params: [],
514
+ meta: {
515
+ target: 'postgres',
516
+ storageHash: testContract.storage.storageHash,
517
+ lane: 'dsl',
518
+ paramDescriptors: [],
519
+ },
520
+ };
521
+
522
+ await runtime.execute(queryPlan).toArray();
523
+
524
+ expect(lowerSpy).toHaveBeenCalledTimes(1);
525
+ const loweredAst = lowerSpy.mock.calls[0]?.[0] as SelectAst;
526
+ expect(loweredAst.where?.kind).toBe('binary');
527
+ });
528
+
529
+ it('skips beforeCompile for raw execution plans with no AST', async () => {
530
+ const { stackInstance, context, driver } = createTestSetup();
531
+ const debug = vi.fn();
532
+ const beforeCompile = vi.fn();
533
+ const observer: SqlMiddleware = {
534
+ name: 'observer',
535
+ familyId: 'sql',
536
+ beforeCompile,
537
+ };
538
+
539
+ const runtime = createRuntime({
540
+ stackInstance,
541
+ context,
542
+ driver,
543
+ verify: { mode: 'onFirstUse', requireMarker: false },
544
+ middleware: [observer],
545
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug },
546
+ });
547
+
548
+ await runtime.execute(createRawExecutionPlan()).toArray();
549
+
550
+ expect(beforeCompile).not.toHaveBeenCalled();
551
+ expect(debug).not.toHaveBeenCalled();
552
+ });
553
+ });
554
+
555
+ describe('withTransaction', () => {
556
+ function createRuntimeForTransaction() {
557
+ const { stackInstance, context, driver } = createTestSetup();
558
+ const runtime = createRuntime({
559
+ stackInstance,
560
+ context,
561
+ driver,
562
+ verify: { mode: 'onFirstUse', requireMarker: false },
563
+ });
564
+ return { runtime, driver };
565
+ }
566
+
567
+ it('commits on successful callback and returns the result', async () => {
568
+ const { runtime, driver } = createRuntimeForTransaction();
569
+
570
+ const result = await withTransaction(runtime, async (tx) => {
571
+ await tx.execute(createRawExecutionPlan()).toArray();
572
+ return 42;
573
+ });
574
+
575
+ expect(result).toBe(42);
576
+ expect(driver.__spies.transactionCommit).toHaveBeenCalledOnce();
577
+ expect(driver.__spies.transactionRollback).not.toHaveBeenCalled();
578
+ expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
579
+ });
580
+
581
+ it('rolls back on callback error and re-throws', async () => {
582
+ const { runtime, driver } = createRuntimeForTransaction();
583
+ const error = new Error('test error');
584
+
585
+ await expect(
586
+ withTransaction(runtime, async () => {
587
+ throw error;
588
+ }),
589
+ ).rejects.toBe(error);
590
+
591
+ expect(driver.__spies.transactionRollback).toHaveBeenCalledOnce();
592
+ expect(driver.__spies.transactionCommit).not.toHaveBeenCalled();
593
+ expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
594
+ });
595
+
596
+ it('releases connection after commit', async () => {
597
+ const { runtime, driver } = createRuntimeForTransaction();
598
+
599
+ await withTransaction(runtime, async () => 'ok');
600
+
601
+ expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
602
+ });
603
+
604
+ it('releases connection after rollback', async () => {
605
+ const { runtime, driver } = createRuntimeForTransaction();
606
+
607
+ await withTransaction(runtime, async () => {
608
+ throw new Error('fail');
609
+ }).catch(() => {});
610
+
611
+ expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
612
+ });
613
+
614
+ it('wraps commit failure and exposes the original error as cause', async () => {
615
+ const { runtime, driver } = createRuntimeForTransaction();
616
+ const commitError = new Error('commit failed');
617
+ driver.__spies.transactionCommit.mockRejectedValueOnce(commitError);
618
+
619
+ const result = withTransaction(runtime, async () => 'value');
620
+
621
+ await expect(result).rejects.toMatchObject({
622
+ code: 'RUNTIME.TRANSACTION_COMMIT_FAILED',
623
+ cause: commitError,
624
+ });
625
+ });
626
+
627
+ it('attempts best-effort rollback after commit fails and releases when it succeeds', async () => {
628
+ const { runtime, driver } = createRuntimeForTransaction();
629
+ const commitError = new Error('commit failed');
630
+ driver.__spies.transactionCommit.mockRejectedValueOnce(commitError);
631
+
632
+ await withTransaction(runtime, async () => 'value').catch(() => {});
633
+
634
+ expect(driver.__spies.transactionCommit).toHaveBeenCalledOnce();
635
+ expect(driver.__spies.transactionRollback).toHaveBeenCalledOnce();
636
+ // A successful rollback after a failed commit means the server is no
637
+ // longer in a transaction and the connection round-tripped cleanly, so
638
+ // it is safe to return to the pool rather than evict it.
639
+ expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce();
640
+ expect(driver.__spies.connectionDestroy).not.toHaveBeenCalled();
641
+ });
642
+
643
+ it('forwards the callback return value', async () => {
644
+ const { runtime } = createRuntimeForTransaction();
645
+
646
+ const result = await withTransaction(runtime, async () => ({
647
+ name: 'test',
648
+ count: 3,
649
+ }));
650
+
651
+ expect(result).toEqual({ name: 'test', count: 3 });
652
+ });
653
+
654
+ it('executes queries against the transaction', async () => {
655
+ const { runtime, driver } = createRuntimeForTransaction();
656
+
657
+ await withTransaction(runtime, async (tx) => {
658
+ await tx.execute(createRawExecutionPlan()).toArray();
659
+ });
660
+
661
+ expect(driver.__spies.transactionExecute).toHaveBeenCalledOnce();
662
+ expect(driver.__spies.rootExecute).not.toHaveBeenCalled();
663
+ expect(driver.__spies.connectionExecute).not.toHaveBeenCalled();
664
+ });
665
+
666
+ it('throws on execute after commit (invalidation)', async () => {
667
+ const { runtime } = createRuntimeForTransaction();
668
+ let savedTx: { execute: (plan: ExecutionPlan) => unknown } | undefined;
669
+
670
+ await withTransaction(runtime, async (tx) => {
671
+ savedTx = tx;
672
+ });
673
+
674
+ expect(() => savedTx!.execute(createRawExecutionPlan())).toThrow(
675
+ 'Cannot read from a query result after the transaction has ended',
676
+ );
677
+ });
678
+
679
+ it('throws on iteration of escaped AsyncIterableResult after commit', async () => {
680
+ const { runtime } = createRuntimeForTransaction();
681
+
682
+ const escaped = await withTransaction(runtime, async (tx) => {
683
+ return { result: tx.execute(createRawExecutionPlan()) };
684
+ });
685
+
686
+ await expect(escaped.result.toArray()).rejects.toThrow(
687
+ 'Cannot read from a query result after the transaction has ended',
688
+ );
689
+ });
690
+
691
+ it('sets invalidated flag after commit', async () => {
692
+ const { runtime } = createRuntimeForTransaction();
693
+ let txRef: { invalidated: boolean } | undefined;
694
+
695
+ await withTransaction(runtime, async (tx) => {
696
+ expect(tx.invalidated).toBe(false);
697
+ txRef = tx;
698
+ });
699
+
700
+ expect(txRef!.invalidated).toBe(true);
701
+ });
702
+
703
+ it('wraps original error when rollback fails', async () => {
704
+ const { runtime, driver } = createRuntimeForTransaction();
705
+ const callbackError = new Error('callback failed');
706
+ const rollbackError = new Error('rollback failed');
707
+ driver.__spies.transactionRollback.mockRejectedValueOnce(rollbackError);
708
+
709
+ const rejection = withTransaction(runtime, async () => {
710
+ throw callbackError;
711
+ });
712
+
713
+ await expect(rejection).rejects.toThrow('Transaction rollback failed after callback error');
714
+ await expect(rejection).rejects.toMatchObject({
715
+ code: 'RUNTIME.TRANSACTION_ROLLBACK_FAILED',
716
+ cause: callbackError,
717
+ details: { rollbackError },
718
+ });
719
+ expect(driver.__spies.connectionDestroy).toHaveBeenCalledOnce();
720
+ expect(driver.__spies.connectionRelease).not.toHaveBeenCalled();
721
+ });
722
+
723
+ it('destroys connection when rollback fails even if destroy also fails', async () => {
724
+ const { runtime, driver } = createRuntimeForTransaction();
725
+ const callbackError = new Error('callback failed');
726
+ const rollbackError = new Error('rollback failed');
727
+ const destroyError = new Error('destroy failed');
728
+ driver.__spies.transactionRollback.mockRejectedValueOnce(rollbackError);
729
+ driver.__spies.connectionDestroy.mockRejectedValueOnce(destroyError);
730
+
731
+ const rejection = withTransaction(runtime, async () => {
732
+ throw callbackError;
733
+ });
734
+
735
+ await expect(rejection).rejects.toMatchObject({
736
+ code: 'RUNTIME.TRANSACTION_ROLLBACK_FAILED',
737
+ cause: callbackError,
738
+ details: { rollbackError },
739
+ });
740
+ expect(driver.__spies.connectionDestroy).toHaveBeenCalledOnce();
741
+ expect(driver.__spies.connectionRelease).not.toHaveBeenCalled();
742
+ });
743
+
744
+ it('destroys connection when commit fails and best-effort rollback also fails', async () => {
745
+ const { runtime, driver } = createRuntimeForTransaction();
746
+ const commitError = new Error('commit failed');
747
+ const rollbackError = new Error('rollback also failed');
748
+ driver.__spies.transactionCommit.mockRejectedValueOnce(commitError);
749
+ driver.__spies.transactionRollback.mockRejectedValueOnce(rollbackError);
750
+
751
+ const rejection = withTransaction(runtime, async () => 'value');
752
+
753
+ await expect(rejection).rejects.toMatchObject({
754
+ code: 'RUNTIME.TRANSACTION_COMMIT_FAILED',
755
+ cause: commitError,
756
+ });
757
+ expect(driver.__spies.connectionDestroy).toHaveBeenCalledOnce();
758
+ expect(driver.__spies.connectionRelease).not.toHaveBeenCalled();
759
+ });
760
+
761
+ it('sets invalidated flag after rollback', async () => {
762
+ const { runtime } = createRuntimeForTransaction();
763
+ let txRef: { invalidated: boolean } | undefined;
764
+
765
+ await withTransaction(runtime, async (tx) => {
766
+ txRef = tx;
767
+ throw new Error('fail');
768
+ }).catch(() => {});
769
+
770
+ expect(txRef!.invalidated).toBe(true);
771
+ });
772
+
773
+ it('releases connection independently across sequential transactions', async () => {
774
+ const { runtime, driver } = createRuntimeForTransaction();
775
+
776
+ await withTransaction(runtime, async (tx) => {
777
+ await tx.execute(createRawExecutionPlan()).toArray();
778
+ });
779
+
780
+ await withTransaction(runtime, async (tx) => {
781
+ await tx.execute(createRawExecutionPlan()).toArray();
782
+ });
783
+
784
+ await withTransaction(runtime, async () => {
785
+ throw new Error('fail');
786
+ }).catch(() => {});
787
+
788
+ expect(driver.__spies.connectionRelease).toHaveBeenCalledTimes(3);
789
+ expect(driver.__spies.transactionCommit).toHaveBeenCalledTimes(2);
790
+ expect(driver.__spies.transactionRollback).toHaveBeenCalledTimes(1);
791
+ });
367
792
  });
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
  }