Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/sdk-transaction-routines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@noormdev/sdk': minor
---

The handle `ctx.transaction(fn)` passes to `fn` now carries typed `proc`, `func`, and `tvf` that run on the transaction's connection, so a routine call runs under a `SET LOCAL statement_timeout` set earlier in the same transaction and rolls back with the transaction. `scope.transaction(fn)` on an impersonated scope passes a handle of the same type. The handle's type, `TransactionScope`, and the `RoutineCalls` interface are exported. On Postgres, `ImpersonatedScope.proc` now retries a `CALL` against a function as `SELECT * FROM`, as `ctx.proc` already did; before, it threw.
78 changes: 78 additions & 0 deletions docs/design/sdk-transaction-routines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# SDK routines inside a transaction


## Problem


`ctx.transaction(fn)` hands `fn` a plain Kysely `Transaction<DB>`. `proc`, `func`, and `tvf` live only on `Context` and `ImpersonatedScope`, so a caller who needs a routine call inside a transaction drops to raw `sql` and loses the typed, dialect-aware call path.

The case that surfaced it (noormdev/noorm#106): a per-call Postgres `statement_timeout`. A function-level `SET statement_timeout` clause does not fire, because Postgres starts the timer with the calling statement. The working pattern is:

BEGIN;
SET LOCAL statement_timeout = '8s';
SELECT * FROM get_graph_neighborhood_fn(...);
COMMIT;

That needs the routine call on the transaction's connection.


## Approaches


| Approach | Wins | Loses |
|----------|------|-------|
| A. `proc`/`func`/`tvf` on the transaction handle | Composes with any transaction-scoped setting on any dialect (`SET LOCAL`, isolation level, advisory locks, writes rolled back together); non-breaking | Caller writes the `SET LOCAL` line |
| B. Per-call option, e.g. `{ statementTimeout: '8s' }` | One-liner for the timeout case | Postgres-only semantics; widens every routine signature with an options bag that collides with positional params; solves one setting, not the class |

**Chosen: A.** The issue lists both; A covers B's case in two lines and every other transaction-scoped need. B can be layered on A later if the timeout case proves common.


## Shape


The callback's `trx` stays a Kysely `Transaction<DB>` and gains `proc`, `func`, and `tvf`. Existing callbacks typed `(trx: Transaction<DB>) => …` still type-check, because a function that accepts the narrower `Transaction<DB>` accepts the intersection.

await ctx.transaction(async (trx) => {

await sql`SET LOCAL statement_timeout = '8s'`.execute(trx);

return trx.tvf('get_graph_neighborhood_fn', { p_node_id: id });

});

The routine methods are attached to the transaction instance Kysely creates for the call. Kysely's `Kysely`/`Transaction` classes keep state in `#props`, so a prototype-delegating wrapper (`Object.create(trx)`) would throw on every builder call; assigning the three methods onto the instance avoids that.

`ImpersonatedScope.transaction(fn)` gets the same handle, so the two transaction entry points match.


## Postgres `proc` fallback inside a transaction


`ctx.proc` on Postgres tries `CALL name(...)` and, on SQLSTATE 42809/42883 ("is a function, not a procedure"), retries as `SELECT * FROM name(...)`. Inside a transaction the failed `CALL` aborts the transaction, so the retry fails with 25P02 (`current transaction is aborted`).

When the executor is a transaction (`db.isTransaction`), `proc` asks the catalog first instead of trying `CALL`:

SELECT EXISTS (
SELECT 1 FROM pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE p.prokind = 'p' AND p.proname = $name
AND n.nspname = $schema -- qualified name
-- or: AND n.nspname = ANY (current_schemas(false)) -- unqualified name
)

`true` runs `CALL`, `false` runs `SELECT * FROM`. A function is never sent to `CALL` unless the same name is also a visible procedure, so calling a function does not abort the transaction. Outside a transaction nothing changes.

| Approach | Wins | Loses |
|----------|------|-------|
| Savepoint around `CALL`, `ROLLBACK TO` before the fallback | No extra round trip on the `CALL` path | `RELEASE`/`ROLLBACK TO` destroy later savepoints, so concurrent `trx.proc` calls break each other; a queue fixes that but not a sibling statement (`trx.insertInto`) that lands inside the savepoint window and is silently rolled back |
| **Catalog lookup** (chosen) | No failing `CALL` against a function, no savepoint window, safe under `Promise.all` | One extra round trip per `trx.proc` on Postgres |

Edge: when one name is both a FUNCTION and a PROCEDURE (overloads in one schema, or different schemas on `search_path`), the lookup returns true and `trx.proc` runs `CALL`. If `CALL` resolves to the function (a function earlier on `search_path`, or arguments matching the function overload), it fails with 42809 and aborts the transaction, where `ctx.proc` outside a transaction would fall back. The lookup matches on name, not arguments or `search_path` order; `trx.tvf` reaches the function in every case.


## Shared routine helpers


`Context`, `ImpersonatedScope`, and the transaction handle each need proc/func/tvf over a different executor. One module, `src/sdk/routines.ts`, holds the three executor-parameterised calls (including the Postgres fallback) and the function that attaches them to a transaction. Schema qualification (`qualifyName`) lives there too; `Context` passes its schema in, the scope passes none, as today.

Moving the scope's `proc` onto the shared helper gives it the Postgres function fallback it currently lacks. That is a behavior change on `ImpersonatedScope.proc` against a Postgres FUNCTION: an error today, rows after.
33 changes: 32 additions & 1 deletion docs/dev/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,35 @@ const result = await ctx.transaction(async (trx) => {
})
```

The handle also carries `proc`, `func`, and `tvf`, typed off the context's `Procs`, `Funcs`, and `Tvfs` and running on the transaction's connection. Use them when a routine call needs a transaction-scoped setting, or has to roll back with the rest of the callback:

```typescript
import { sql } from 'kysely'

const deptUsers = await ctx.transaction(async (trx) => {
await sql`SET LOCAL statement_timeout = '8s'`.execute(trx)
return trx.tvf('get_department_users', { dept_id: 5 })
})
```

`SET LOCAL` is Postgres syntax and lasts until the transaction ends. Routine names on the `ctx.transaction` handle get the context's schema prefix, as `ctx.proc` does. The `scope.transaction` handle inside `impersonate()` passes names as given, like `scope.proc`. Kysely instances derived from the handle, such as `trx.withSchema(...)`, are plain Kysely and have no routine methods.

The handle's type is `TransactionScope<DB, Procs, Funcs, Tvfs>`, which is `Transaction<DB> & RoutineCalls<Procs, Funcs, Tvfs>`. A callback annotated `(trx: Transaction<DB>) => ...` still compiles.

On Postgres a failed `CALL` aborts the transaction, so `trx.proc` checks `pg_catalog.pg_proc` before running anything:

```mermaid
flowchart TD
P["trx.proc(name)"] --> L{"pg_proc has a<br/>procedure named name?"}
L -->|no| S["SELECT * FROM name(...)"]
L -->|yes| C["CALL name(...)"]
C --> F{"CALL resolves<br/>to a function?"}
F -->|no| R["Procedure runs"]
F -->|yes| A["42809, transaction<br/>aborted"]
```

`CALL` resolves to a function when one name is both kinds and the arguments match a function overload, or a function sits earlier on `search_path` than the procedure. The lookup matches on name only, so `trx.tvf` is the call that reaches the function in every case.


### Schema Scoping

Expand All @@ -255,7 +284,7 @@ await acct.proc('billing.close_period')
// -> already qualified: caller's schema wins, no prefix added
```

Scoping composes through `transaction()` and `impersonate()` — both stay qualified against the derived schema. Calling `withSchema` again replaces the schema rather than stacking (`ctx.withSchema('a').withSchema('b')` resolves against `b`). `connect()`/`disconnect()` on either instance affect both — one connection, N typed views.
Scoping composes through `transaction()` and `impersonate()`: the query builder on both, and the routine methods on the `ctx.transaction` handle, stay qualified against the derived schema. Routine methods on an impersonated scope pass names as given; see the `impersonate` row in the [reference page's schema scoping table](../reference/sdk.md#schema-scoping). Calling `withSchema` again replaces the schema rather than stacking (`ctx.withSchema('a').withSchema('b')` resolves against `b`). `connect()`/`disconnect()` on either instance affect both — one connection, N typed views.

Unqualified identifiers inside raw `` sql`…` `` fragments are **not** rewritten — they resolve against the connection default. Qualify raw SQL by hand or use the query builder. The qualifier is dialect pass-through: a schema on postgres/mssql, a database on mysql, an ATTACHed database name on sqlite.

Expand Down Expand Up @@ -311,6 +340,8 @@ Parameter types control the call signature:

`proc()` and `func()` throw on SQLite, which has no stored procedure or function call support. `tvf()` is only available on MSSQL and PostgreSQL.

On PostgreSQL, `ctx.proc` outside a transaction runs `CALL` and, when that fails with 42809 or 42883 because the name is a function, retries as `SELECT * FROM name(...)`. Inside a transaction `trx.proc` decides up front instead; see [Transactions](#transactions).


#### Parameter handling and NULL semantics

Expand Down
29 changes: 25 additions & 4 deletions docs/reference/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Only transient failures retry: messages containing `econnrefused`, `etimedout`,
### Transactions


`transaction(fn)` hands `fn` a Kysely `Transaction<DB>` and resolves with its return value. A throw inside `fn` rolls back, so do not swallow errors in the callback.
`transaction(fn)` hands `fn` a Kysely `Transaction<DB>` with `proc`, `func`, and `tvf` attached, and resolves with its return value. A throw inside `fn` rolls back, so do not swallow errors in the callback.

```typescript
const cancelled = await ctx.transaction(async (trx) => {
Expand All @@ -208,6 +208,27 @@ const cancelled = await ctx.transaction(async (trx) => {
});
```

Routine calls run on the transaction's connection, so they see transaction-scoped settings and roll back with the rest of `fn`.

```typescript
import { sql } from 'kysely';

const open = await ctx.transaction(async (trx) => {
await sql`SET LOCAL statement_timeout = '8s'`.execute(trx);

return trx.tvf('party_open_orders', { party_id: 2 });
});
// SELECT * FROM "party_open_orders"("party_id" => $1), cancelled if it runs past 8 seconds
```

| Rule | Behavior |
|---|---|
| Handle type | `TransactionScope` is `Transaction<DB> & RoutineCalls<Procs, Funcs, Tvfs>`. A callback annotated `(trx: Transaction<DB>) => …` still compiles |
| `SET LOCAL` | Postgres syntax. It lasts until the transaction ends |
| Routine names | Qualified with the context's schema, as on `ctx` |
| `trx.proc` on postgres | Looks the name up in `pg_catalog.pg_proc` first: a procedure runs `CALL`, anything else runs `SELECT * FROM name(...)`. No `CALL` is attempted against a name with no visible procedure, so calling a function does not abort the transaction. A name that is both a procedure and a function runs `CALL`, which fails with 42809 and aborts the transaction when it resolves to the function: arguments that match a function overload, or a function earlier on `search_path` than the procedure. `trx.tvf` reaches the function in every case |
| Derived handles | `trx.withSchema(...)`, `trx.withPlugin(...)`, and other Kysely instances derived from `trx` carry no `proc`, `func`, or `tvf` |


### Impersonation

Expand Down Expand Up @@ -288,7 +309,7 @@ await ctx.kysely.selectFrom('sales_order').selectAll().execute();
| Replace, not stack | `ctx.withSchema('a').withSchema('b')` resolves against `b` only |
| Routine names | `proc`, `func`, and `tvf` prefix a name that has no `.` with the schema. A name with a `.` passes through |
| `transaction` | Inherits the schema |
| `impersonate` | `scope.kysely` and `scope.transaction` stay schema-qualified. `scope.proc`, `scope.func`, and `scope.tvf` call the builders with the name as given, so qualify routine names yourself inside a scope |
| `impersonate` | `scope.kysely` and the Kysely builder on `scope.transaction` stay schema-qualified. Routine methods inside a scope (`scope.proc`/`func`/`tvf` and the same methods on the `scope.transaction` handle) call the builders with the name as given, so qualify routine names yourself |
| Raw `` sql`...` `` | Not rewritten. Unqualified identifiers resolve against the connection's default schema, so qualify them by hand |
| `ctx.noorm` | Not affected. Pass `schema` to `db.describe*` explicitly |

Expand Down Expand Up @@ -357,7 +378,7 @@ await ctx.proc('close_order', [2, 1]);
await ctx.proc('archive_closed_orders');
```

On postgres, `proc()` retries as `SELECT * FROM name(...)` when `CALL` fails with SQLSTATE `42809` or `42883`. One method therefore reaches both `CREATE PROCEDURE` and `CREATE FUNCTION` objects.
On postgres, `proc()` retries as `SELECT * FROM name(...)` when `CALL` fails with SQLSTATE `42809` or `42883`. One method therefore reaches both `CREATE PROCEDURE` and `CREATE FUNCTION` objects. Inside a transaction a failed `CALL` would abort it, so `trx.proc` decides up front instead; see [Transactions](#transactions).


### Parameter handling and NULL semantics
Expand Down Expand Up @@ -1067,7 +1088,7 @@ Every class below is exported from `@noormdev/sdk`:
| Templates | `TemplateResult` |
| Vault | `VaultSecret`, `VaultStatus`, `VaultCopyOptions`, `VaultCopyResult`, `VaultPropagationResult` |
| Transfer and DT | `TransferOptions`, `TransferPlan`, `TransferTablePlan`, `TransferResult`, `TransferTableResult`, `ConflictStrategy`, `ExportOptions`, `ImportOptions` |
| Routines | `tvp`, `TvpValue`, `ImpersonatedScope`, `ExtractArgs`, `ExtractReturn` |
| Routines | `tvp`, `TvpValue`, `ImpersonatedScope`, `RoutineCalls`, `TransactionScope`, `ExtractArgs`, `ExtractReturn` |
| Events | `noormObserver`, `NoormEvents`, `NoormEventNames` |
| Errors | `RequireTestError`, `ProtectedConfigError`, `NotConnectedError`, `VaultAccessError`, `ImpersonationError`, `LockAcquireError`, `LockExpiredError`, `ChangeValidationError`, `ChangeNotFoundError`, `ChangeAlreadyAppliedError`, `ChangeNotAppliedError`, `ChangeOrphanedError`, `ManifestReferenceError` |

Expand Down
Loading
Loading