Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/3-extensions/postgres/src/runtime/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type OrmClient<TContract extends Contract<SqlStorage>> = ReturnType<typeof ormBu
export interface PostgresTransactionContext<TContract extends Contract<SqlStorage>>
extends TransactionContext {
readonly sql: Db<TContract>;
readonly orm: OrmClient<TContract>;
}

export interface PostgresClient<TContract extends Contract<SqlStorage>> {
Expand Down Expand Up @@ -251,9 +252,18 @@ export default function postgres<TContract extends Contract<SqlStorage>>(
transaction<R>(fn: (tx: PostgresTransactionContext<TContract>) => PromiseLike<R>): Promise<R> {
return withTransaction(getRuntime(), (txCtx) => {
const txSql: Db<TContract> = sqlBuilder<TContract>({ context });
const txOrm: OrmClient<TContract> = ormBuilder({
runtime: {
execute(plan) {
return txCtx.execute(plan);
},
},
context,
});
const tx: PostgresTransactionContext<TContract> = {
...txCtx,
sql: txSql,
orm: txOrm,
};
return fn(tx);
});
Expand Down
25 changes: 25 additions & 0 deletions packages/3-extensions/postgres/test/postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,31 @@ describe('postgres', () => {
expect(mocks.sqlBuilder).toHaveBeenCalledTimes(2);
});

it('transaction() provides orm on the transaction context', async () => {
const { orm: ormMock } = await import('@prisma-next/sql-orm-client');
const txOrmProxy = { lane: 'tx-orm' };
let ormCallCount = 0;
vi.mocked(ormMock).mockImplementation((() => {
ormCallCount++;
if (ormCallCount === 1) return { lane: 'orm' };
return txOrmProxy;
}) as typeof ormMock);

const db = postgres({
contract,
url: 'postgres://localhost:5432/db',
});

let receivedTx: { orm?: unknown } | undefined;
await db.transaction(async (tx) => {
receivedTx = tx;
});

expect(receivedTx).toBeDefined();
expect(receivedTx!.orm).toBe(txOrmProxy);
expect(ormCallCount).toBe(2);
});

it('transaction() lazily creates runtime before connect()', async () => {
const db = postgres({
contract,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ test('tx.sql has the same type as db.sql', () => {
type TxSql = PostgresTransactionContext<TestContract>['sql'];
expectTypeOf<TxSql>().toEqualTypeOf<DbSql>();
});

test('tx.orm has the same type as db.orm', () => {
type DbOrm = PostgresClient<TestContract>['orm'];
type TxOrm = PostgresTransactionContext<TestContract>['orm'];
expectTypeOf<TxOrm>().toEqualTypeOf<DbOrm>();
});
Original file line number Diff line number Diff line change
Expand Up @@ -656,4 +656,39 @@ describe('mutation-executor', () => {
}),
).rejects.toThrow(/requires parent field "id"/);
});

it('executeNestedCreateMutation() reuses scope directly when runtime lacks transaction and connection', async () => {
const runtime = createMockRuntime();
runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@test.com' }]]);

const executeSpy = vi.spyOn(runtime, 'execute');

const created = await executeNestedCreateMutation({
context: getTestContext(),
runtime,
modelName: 'User',
data: { id: 1, name: 'Alice', email: 'alice@test.com' } as never,
});

expect(created).toEqual({ id: 1, name: 'Alice', email: 'alice@test.com' });
expect(executeSpy).toHaveBeenCalledTimes(1);
});

it('withMutationScope reuses runtime directly when no transaction or connection method exists', async () => {
const runtime = createMockRuntime();
runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@test.com' }]]);

expect(runtime.transaction).toBeUndefined();
expect(runtime.connection).toBeUndefined();

const created = await executeNestedCreateMutation({
context: getTestContext(),
runtime,
modelName: 'User',
data: { id: 1, name: 'Alice', email: 'alice@test.com' } as never,
});

expect(created).toEqual({ id: 1, name: 'Alice', email: 'alice@test.com' });
expect(runtime.executions).toHaveLength(1);
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions projects/orm-client-transaction-api/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ Wire `tx.orm` so ORM collections inside the transaction use the transaction's co

**Tasks:**

- [ ] **2.1** Add `tx.orm` to `TransactionContext`. Create an ORM client (`orm()`) bound to the transaction's `RuntimeQueryable`. The key: the `RuntimeQueryable` passed to `orm()` must route `execute()` through the transaction scope and must NOT expose `connection()` or `transaction()` β€” when `withMutationScope` checks `typeof runtime.transaction === 'function'`, it must get `false` (or the method must be absent) so it uses the scope directly rather than starting a nested transaction.
- [ ] **2.2** Verify that `withMutationScope` in `mutation-executor.ts` correctly falls through to `acquireRuntimeScope` when the runtime has no `transaction` method β€” and that `acquireRuntimeScope` returns the transaction-scoped execute. Read the code paths and add a targeted unit test confirming nested creates within a transaction reuse the transaction scope.
- [ ] **2.3** Integration test: ORM `.create()` with nested relation mutations inside `db.transaction()` β€” verify all rows are created atomically and a throw rolls back everything including nested creates.
- [ ] **2.4** Integration test: ORM reads inside `transaction` see writes made earlier in the same transaction (read-your-own-writes within tx).
- [x] **2.1** Add `tx.orm` to `TransactionContext`. Create an ORM client (`orm()`) bound to the transaction's `RuntimeQueryable`. The key: the `RuntimeQueryable` passed to `orm()` must route `execute()` through the transaction scope and must NOT expose `connection()` or `transaction()` β€” when `withMutationScope` checks `typeof runtime.transaction === 'function'`, it must get `false` (or the method must be absent) so it uses the scope directly rather than starting a nested transaction.
- [x] **2.2** Verify that `withMutationScope` in `mutation-executor.ts` correctly falls through to `acquireRuntimeScope` when the runtime has no `transaction` method β€” and that `acquireRuntimeScope` returns the transaction-scoped execute. Read the code paths and add a targeted unit test confirming nested creates within a transaction reuse the transaction scope.
- [x] **2.3** Integration test: ORM `.create()` with nested relation mutations inside `db.transaction()` β€” verify all rows are created atomically and a throw rolls back everything including nested creates.
- [x] **2.4** Integration test: ORM reads inside `transaction` see writes made earlier in the same transaction (read-your-own-writes within tx).

### Milestone 3: Type safety and edge cases

Expand Down
8 changes: 4 additions & 4 deletions projects/orm-client-transaction-api/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ await db.transaction(async (tx) => {
## Core API

- [x] `db.transaction(callback)` is callable on `PostgresClient` and returns `Promise<T>` where `T` is the callback's return value.
- [ ] The callback receives a context object with `orm`, `sql`, and `execute` properties.
- [ ] `tx.orm` has the same collection types and API as `db.orm`.
- [x] The callback receives a context object with `orm`, `sql`, and `execute` properties.
- [x] `tx.sql` has the same table proxy types as `db.sql`.
- [x] `tx.execute(plan)` executes a query plan against the transaction's connection.
- [x] `tx.orm` has the same collection types and API as `db.orm`.

## Lifecycle

Expand All @@ -103,8 +103,8 @@ await db.transaction(async (tx) => {

## ORM integration

- [ ] ORM operations inside `transaction` (including nested mutation patterns like `create` with relation callbacks) execute on the transaction's connection, not a new one.
- [ ] ORM reads inside `transaction` see writes made earlier in the same transaction.
- [x] ORM operations inside `transaction` (including nested mutation patterns like `create` with relation callbacks) execute on the transaction's connection, not a new one.
- [x] ORM reads inside `transaction` see writes made earlier in the same transaction.

## Type safety

Expand Down
1 change: 1 addition & 0 deletions test/e2e/framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@prisma-next/framework-components": "workspace:*",
"@prisma-next/ids": "workspace:*",
"@prisma-next/integration-tests": "workspace:*",
"@prisma-next/postgres": "workspace:*",
"@prisma-next/sql-builder": "workspace:*",
"@prisma-next/sql-contract": "workspace:*",
"@prisma-next/sql-contract-ts": "workspace:*",
Expand Down
80 changes: 48 additions & 32 deletions test/e2e/framework/test/fixtures/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import pgvectorPack from '@prisma-next/extension-pgvector/pack';
import sqlFamily from '@prisma-next/family-sql/pack';
import { extractCodecLookup } from '@prisma-next/framework-components/control';
import { uuidv7 } from '@prisma-next/ids';
import { defineContract, field, model } from '@prisma-next/sql-contract-ts/contract-builder';
import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder';
import postgresPack from '@prisma-next/target-postgres/pack';

const postgresCodecLookup = extractCodecLookup([postgresAdapter, pgvectorPack]);
Expand All @@ -40,6 +40,50 @@ const profileSchema = {
expression: '{ name: string; age: number }',
};

const UserBase = model('User', {
fields: {
id: field.column(int4Column).defaultSql('autoincrement()').id(),
email: field.column(varcharColumn(255)).unique({ name: 'user_email_key' }),
createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'),
updatedAt: field.column(timestamptzColumn).optional().column('update_at'),
profile: field.column(jsonb()).optional(),
},
});

const PostBase = model('Post', {
fields: {
id: field.column(int4Column).defaultSql('autoincrement()').id(),
userId: field.column(int4Column),
title: field.column(textColumn),
createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'),
updatedAt: field.column(timestamptzColumn).optional().column('update_at'),
published: field.column(boolColumn),
meta: field.column(json()).optional(),
},
});

const Comment = model('Comment', {
fields: {
id: field.column(int4Column).defaultSql('autoincrement()').id(),
postId: field.column(int4Column),
content: field.column(textColumn),
createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'),
updatedAt: field.column(timestamptzColumn).optional().column('update_at'),
},
relations: {
post: rel.belongsTo(PostBase, { from: 'postId', to: 'id' }),
},
}).sql({ table: 'comment' });

const Post = PostBase.relations({
author: rel.belongsTo(UserBase, { from: 'userId', to: 'id' }),
comments: rel.hasMany(() => Comment, { by: 'postId' }),
}).sql({ table: 'post' });

const User = UserBase.relations({
posts: rel.hasMany(() => Post, { by: 'userId' }),
}).sql({ table: 'user' });

export const contract = defineContract({
family: sqlFamily,
target: postgresPack,
Expand All @@ -54,37 +98,9 @@ export const contract = defineContract({
},
},
models: {
User: model('User', {
fields: {
id: field.column(int4Column).defaultSql('autoincrement()').id(),
email: field.column(varcharColumn(255)).unique({ name: 'user_email_key' }),
createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'),
updatedAt: field.column(timestamptzColumn).optional().column('update_at'),
profile: field.column(jsonb()).optional(),
},
}).sql({ table: 'user' }),

Post: model('Post', {
fields: {
id: field.column(int4Column).defaultSql('autoincrement()').id(),
userId: field.column(int4Column),
title: field.column(textColumn),
createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'),
updatedAt: field.column(timestamptzColumn).optional().column('update_at'),
published: field.column(boolColumn),
meta: field.column(json()).optional(),
},
}).sql({ table: 'post' }),

Comment: model('Comment', {
fields: {
id: field.column(int4Column).defaultSql('autoincrement()').id(),
postId: field.column(int4Column),
content: field.column(textColumn),
createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'),
updatedAt: field.column(timestamptzColumn).optional().column('update_at'),
},
}).sql({ table: 'comment' }),
User,
Post,
Comment,

ParamTypes: model('ParamTypes', {
fields: {
Expand Down
41 changes: 38 additions & 3 deletions test/e2e/framework/test/fixtures/generated/contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,16 @@ type ContractBase = ContractType<
readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' };
};
};
readonly relations: Record<string, never>;
readonly relations: {
readonly post: {
readonly to: 'Post';
readonly cardinality: 'N:1';
readonly on: {
readonly localFields: readonly ['postId'];
readonly targetFields: readonly ['id'];
};
};
};
readonly storage: {
readonly table: 'comment';
readonly fields: {
Expand Down Expand Up @@ -798,7 +807,24 @@ type ContractBase = ContractType<
readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/json@1' };
};
};
readonly relations: Record<string, never>;
readonly relations: {
readonly author: {
readonly to: 'User';
readonly cardinality: 'N:1';
readonly on: {
readonly localFields: readonly ['userId'];
readonly targetFields: readonly ['id'];
};
};
readonly comments: {
readonly to: 'Comment';
readonly cardinality: '1:N';
readonly on: {
readonly localFields: readonly ['id'];
readonly targetFields: readonly ['postId'];
};
};
};
readonly storage: {
readonly table: 'post';
readonly fields: {
Expand Down Expand Up @@ -839,7 +865,16 @@ type ContractBase = ContractType<
readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/jsonb@1' };
};
};
readonly relations: Record<string, never>;
readonly relations: {
readonly posts: {
readonly to: 'Post';
readonly cardinality: '1:N';
readonly on: {
readonly localFields: readonly ['id'];
readonly targetFields: readonly ['userId'];
};
};
};
readonly storage: {
readonly table: 'user';
readonly fields: {
Expand Down
Loading
Loading