Skip to content

feat(transactional-adapter-drizzle-orm): support better-sqlite3 via transactionMode option#572

Open
rodrigobnogueira wants to merge 4 commits into
Papooch:mainfrom
rodrigobnogueira:fix/drizzle-adapter-sync-mode
Open

feat(transactional-adapter-drizzle-orm): support better-sqlite3 via transactionMode option#572
rodrigobnogueira wants to merge 4 commits into
Papooch:mainfrom
rodrigobnogueira:fix/drizzle-adapter-sync-mode

Conversation

@rodrigobnogueira
Copy link
Copy Markdown

@rodrigobnogueira rodrigobnogueira commented May 25, 2026

Fixes #571.

Problem

@nestjs-cls/transactional-adapter-drizzle-orm wraps the inner Drizzle db.transaction(callback) callback in async. That works for async Drizzle drivers (libsql, node-postgres, postgres-js, mysql2) but breaks better-sqlite3 — its client.transaction(fn) is synchronous:

  • On older better-sqlite3 versions, the driver sees the async callback's immediately-returned Promise as a successful sync return and runs BEGIN → callback → COMMIT before any work happens. The transaction commits empty and writes happen outside the tx scope.
  • On better-sqlite3 v12+, the driver explicitly throws TypeError: Transaction function cannot return a promise.

See the linked issue for a self-contained in-memory repro.

Fix

New opt-in option on the adapter constructor:

transactionMode?: 'async' | 'sync' // default 'async'

In 'sync' mode, the adapter calls the driver's transaction(fn, options) with a synchronous callback and wraps the result in Promise.resolve(...) to satisfy the plugin's wrapWithTransaction: Promise<T> contract. The @Transactional()-decorated method must itself be sync (no async, no await inside) — the same constraint better-sqlite3 imposes on its own transaction callbacks. The decorator still returns Promise<T> to the caller.

Backward-compatible: every existing user passes no option, gets the default 'async', behaves exactly as before. The new code path is only entered when transactionMode: 'sync' is explicitly set.

Same shape applied to wrapWithNestedTransaction so savepoints work in sync mode too.

Tests

New file test/transactional-adapter-drizzle-orm-sync.spec.ts using better-sqlite3 in-memory (no docker required):

  • T1 — insert + read inside @Transactional persists
  • T2 — throw inside @Transactional rolls back (no rows)
  • T3a — nested @Transactional commits both savepoints when outer succeeds
  • T3b — outer throw rolls back nested savepoints
  • T4 — outside @Transactional, falls back to raw client
  • Two unit assertions on the transactionMode default vs explicit override

Existing Postgres async tests are unchanged. The 'async' code path is byte-identical to before; the new 'sync' branch is only entered when explicitly opted into.

Docs

Added a "better-sqlite3 (synchronous mode)" section to the Drizzle adapter docs page with the transactionMode: 'sync' example, the sync-method constraint, and the rationale. Existing pg/libsql examples are unchanged.

Discovered via

Building https://github.com/nest-native/reference-app — a reference app that uses @nestjs-cls/transactional with better-sqlite3 for local dev. The reference app currently ships an inlined version of this same adapter as a workaround; with this PR merged, it will drop the local file in favor of the upstream adapter with transactionMode: 'sync'.

…ransactionMode option

Adds an opt-in `transactionMode?: 'async' | 'sync'` option to the
adapter constructor. Defaults to `'async'` (no behavior change for
existing libsql/node-postgres/postgres-js/mysql2 users). In `'sync'`
mode the inner transaction callback runs synchronously and the result
is wrapped in `Promise.resolve(...)` to satisfy the plugin's
`wrapWithTransaction: Promise<T>` contract — required by
`better-sqlite3`, which rejects async callbacks.

Fixes Papooch#571
@rodrigobnogueira rodrigobnogueira force-pushed the fix/drizzle-adapter-sync-mode branch from 00f5522 to 4e633af Compare May 25, 2026 14:24
@rodrigobnogueira rodrigobnogueira marked this pull request as ready for review May 25, 2026 14:33
@Papooch
Copy link
Copy Markdown
Owner

Papooch commented May 25, 2026

Thanks for the find and PR. I tried investigating if we can somehow auto-detect the driver and found out that Drizzle internally uses resultKind: sync|async`` to branch the transaction behavior. Curently it is used for better-sqlite3 and `bun-sqlite`.

It is a private field on the drizzleInstance, but I'm inclined to use it for auto-detection ((drizzleInstance as any).resultKind). I am not opposed to an explicit option, though, to force the behavior should the auto-detection fail for any reason.

Comment on lines +63 to +114
const wrapSync = (
transactionFn: (cb: any, options?: any) => any,
options: DrizzleTransactionOptions<TClient>,
fn: (...args: any[]) => Promise<any>,
fn: (...args: any[]) => any,
setClient: (client?: TClient) => void,
) => {
return drizzleInstance.transaction(async (tx) => {
setClient(tx as TClient);
return fn();
}, options);
},
wrapWithNestedTransaction: async (
options: DrizzleTransactionOptions<TClient>,
fn: (...args: any[]) => Promise<any>,
setClient: (client?: TClient) => void,
client: TClient,
) => {
return client.transaction(async (tx) => {
setClient(tx as TClient);
return fn();
}, options);
},
getFallbackInstance: () => drizzleInstance,
});
) =>
Promise.resolve(
transactionFn((tx: TClient) => {
setClient(tx);
return fn();
}, options),
);

return {
wrapWithTransaction: async (
options: DrizzleTransactionOptions<TClient>,
fn: (...args: any[]) => Promise<any>,
setClient: (client?: TClient) => void,
) => {
if (this.transactionMode === 'sync') {
return wrapSync(
drizzleInstance.transaction.bind(drizzleInstance),
options,
fn,
setClient,
);
}
return drizzleInstance.transaction(async (tx) => {
setClient(tx as TClient);
return fn();
}, options);
},
wrapWithNestedTransaction: async (
options: DrizzleTransactionOptions<TClient>,
fn: (...args: any[]) => Promise<any>,
setClient: (client?: TClient) => void,
client: TClient,
) => {
if (this.transactionMode === 'sync') {
return wrapSync(
client.transaction.bind(client),
options,
fn,
setClient,
);
}
return client.transaction(async (tx) => {
setClient(tx as TClient);
return fn();
}, options);
},
getFallbackInstance: () => drizzleInstance,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I don't like that we have to go through an if every time at runtime. We can select the correct implementation at setup-time and assign a lambda directly:

const runTx = transactionMode == 'sync'
        ? (client: TClient, options: DrizzleTransactionOptions<TClient>, fn: () => any, setClient: (client?: TClient) => void) =>
            Promise.resolve(
                client.transaction((tx) => {
                    setClient(tx as TClient);
                    return fn();
                }, options),
            )
        : (client: TClient, options: DrizzleTransactionOptions<TClient>, fn: () => any, setClient: (client?: TClient) => void) =>
            client.transaction(async (tx) => {
                setClient(tx as TClient);
                return fn();
            }, options);

    return {
        wrapWithTransaction: (options, fn, setClient) =>
            runTx(drizzleInstance, options, fn, setClient),
        wrapWithNestedTransaction: (options, fn, setClient, client) =>
            runTx(client, options, fn, setClient),
        getFallbackInstance: () => drizzleInstance,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 225f384: applied your pattern almost verbatim. runTx selected once inside optionsFactory; both wrap*Transaction are plain delegators now

fn: (...args: any[]) => any,
setClient: (client?: TClient) => void,
) => {
return drizzleInstance.transaction(async (tx) => {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: on a second though, I guess we can just remove the async modifier here - it's redundant anyway. The behavior will be identical and support both sync and async callbacks.

Correct me if I'm wrong, but the suggested implementation only works around the typing of AnyDrizzleClient (with async transaction method), but does not add any runtime guarantees. I think we can instead relax that interface, knowing that Sqlite3 instance requires a sync method.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rodrigobnogueira I am confused, you put thumbs up on both of my comments, but commited something else

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry. I was working and you are fast :D

Done in 225f384

…from Drizzle's resultKind

Per @Papooch's review on Papooch#572: Drizzle sets an internal `resultKind`
field on sqlite-core databases — `'sync'` for `better-sqlite3`,
`bun-sqlite`, `expo-sqlite`, `sql-js`, and `durable-sqlite`; `'async'`
or absent for everything else.

Inspect that field by default (`transactionMode: 'auto'`) so users
don't need to opt in for any first-party Drizzle driver. The explicit
`'sync'` / `'async'` values remain as an override for the case where
auto-detection ever picks the wrong mode.
@rodrigobnogueira
Copy link
Copy Markdown
Author

Thanks @Papooch for checking this PR.

Pushed in 92a465d. Auto-detection via (drizzleInstance as { resultKind?: unknown }).resultKind === 'sync'; explicit transactionMode: 'sync' | 'async' kept as an override.

One note: the pattern covers more than better-sqlite3 + bun-sqlite — resultKind: 'sync' is set on all five synchronous sqlite drivers (better-sqlite3, bun-sqlite, expo-sqlite, sql-js, durable-sqlite). Async sqlite drivers (libsql, op-sqlite, d1) carry resultKind: 'async'; non-sqlite drivers (node-postgres, postgres-js, mysql2) don't have the field. The === 'sync' check discriminates the first group from everything else.

Sync behavioral suite now omits transactionMode: 'sync' to prove auto-detect works; the existing Postgres suite is unchanged and still passes.

@rodrigobnogueira rodrigobnogueira requested a review from Papooch May 25, 2026 21:02
Papooch#572

Per @Papooch's review:

1. Select sync vs async implementation once at setup time via a single
   `runTx` lambda assigned inside `optionsFactory`, instead of branching
   on `effectiveMode` on every `wrapWithTransaction` /
   `wrapWithNestedTransaction` call.

2. Relax `AnyDrizzleClient` to allow non-`Promise` return types on both
   `transaction` and its callback. The sync sqlite drivers genuinely
   accept (and require) sync callbacks; the previous `Promise<any>`
   typing was a fiction that the adapter then had to work around.

3. Drop the `async` modifier on `wrapWithTransaction` and
   `wrapWithNestedTransaction` — they now delegate directly to
   `runTx`.

Sync `runTx` keeps an explicit try/catch + Promise.reject so the
synchronous rollback throw from better-sqlite3 surfaces as a rejected
Promise (required by the plugin's wrapWithTransaction: Promise<T>
contract).

17/17 tests still passing locally (auto-detect + sync sqlite + async pg).
@rodrigobnogueira
Copy link
Copy Markdown
Author

Refactored in 225f384: runTx selected once at setup time, AnyDrizzleClient relaxed (sync drivers' callbacks no longer have to pretend to return Promise), async dropped from both wrap*Transaction.

One small deviation: sync runTx keeps a try/catch -> Promise.reject(err) because better-sqlite3 throws synchronously on rollback, and the plugin contract needs that surfaced as a rejection.
Happy to drop it if you have a cleaner pattern in mind.

Copy link
Copy Markdown
Owner

@Papooch Papooch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I still have one minor suggestion - if you have time, please address it.

setClient(tx as TClient);
return fn();
}, options);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: the implementation here is still selected at run time (each time when runTx is called), not at setup time. I'd like it better if the correct runTx (perhaps effectiveRunTx) implementation was selected before being assigned passed to wrapWithTransaction and wrapWithNestedTransaction.

}
```

The decorator itself still returns a `Promise<T>` to the caller, so consumers can `await runTransaction()` as usual. The synchronous constraint only applies _inside_ the method body, where the sync driver is driving the transaction.
Copy link
Copy Markdown
Owner

@Papooch Papooch Jun 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cocnern: this is annoying, the linter is going to complain that we're awaiting a non-promise, but I can't think of a better solution in these circumstances

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@nestjs-cls/transactional-adapter-drizzle-orm silently breaks transactions with better-sqlite3

2 participants