Skip to content

feat: add a TypeORM database adapter - #18

Merged
kauandotnet merged 1 commit into
mainfrom
feat/typeorm-adapter
Aug 10, 2026
Merged

feat: add a TypeORM database adapter#18
kauandotnet merged 1 commit into
mainfrom
feat/typeorm-adapter

Conversation

@kauandotnet

Copy link
Copy Markdown
Contributor

Adds @nestm/better-auth/typeormtypeormAdapter(dataSource, config?) — backing Better Auth with a TypeORM DataSource on PostgreSQL.

No TypeORM adapter exists anywhere today (@better-auth/typeorm-adapter is a 404), which makes this the last blocker to removing Drizzle from concepta-artifacts.

Implements the full CustomAdapter surface

The eight required methods plus consumeOne and incrementOne. Both optional ones carry a literal TODO(...-required): tighten to required in the next minor in @better-auth/core, so they are implemented now rather than left to the factory fallback.

The adapter contract is byte-identical between better-auth 1.6.25 and 1.6.26 (diff on @better-auth/core/dist/db/adapter/index.d.mts returns nothing), so developing against 1.6.26 is safe for a 1.6.25 consumer.

The measured win over the reference adapter

consumeOne and incrementOne are single-statement compare-and-swaps with the guard repeated outside the subquery, not only inside it. The reason is Postgres EvalPlanQual: when a blocked racer unblocks, re-checking the OUTER qualification against the newest row version is what makes it a true CAS.

32 concurrent incrementOne calls against a count < 10 guard: @better-auth/drizzle-adapter admits 32/32. This adapter admits exactly 10.

A sequential control proves both compile the guard identically, isolating the difference to the race.

Timezones — the adapter owns this end to end

Measured under TZ=America/Sao_Paulo with the instant 05:06:07Z:

path stored read back
node-pg + Date param 02:06:07 08:06:07Z
postgres.js + Date 05:06:07 08:06:07Z
this adapter (ISO in, AT TIME ZONE 'UTC' out) 05:06:07 05:06:07Z

It binds ISO-8601 UTC strings and reads through AT TIME ZONE 'UTC', so it never hands the driver a Date and never reads a bare timestamp. That makes it correct without pg.defaults.parseInputDatesAsUTC or a custom OID 1114 parser, and without mutating process-global driver state the adapter does not own — a consumer applying those settings, or not, cannot change its behaviour.

Session and OTP expiry are wall-clock comparisons, so this is a correctness concern, not a cosmetic one. timestamp DEFAULT now() renders in the server's zone and is documented as outside the adapter's reach.

Deliberate divergences from the Drizzle adapter

Drizzle here
supportsUUIDs true on pg falsetrue + generateId:"uuid" makes Better Auth emit no id, which id text PRIMARY KEY cannot default
supportsJSON / supportsArrays true on pg false — inert for Better Auth's own schema (no json/array field exists) and safer over text
guarded CAS subquery only guard repeated outside
native joins supported under experimental.joins throws rather than silently dropping a relation

set vs increment precedence matches Drizzle (Kysely is the reverse).

Traps found while building this

  • TypeORM's Postgres runner returns raw.rows for SELECT/INSERT but the tuple [rows, rowCount] for UPDATE/DELETE. Assuming a flat array silently breaks update, consumeOne and incrementOne.
  • select is forwarded to transformOutput for findOne but not for findMany, so a selected findMany returns all schema keys with unselected ones undefined. Both adapters behave identically; pinned in adapter-options.spec.ts.
  • id is force-retyped and String()-ed before customTransformOutput runs, so the number-coercion rule must exclude identifiers or generateId: "serial" gets its ids coerced back, undoing the factory's own normalisation.

Conformance

pnpm run test:postgres80 tests across 7 files, every flow run twice: once on @better-auth/drizzle-adapter, once on this adapter, against one committed DDL in per-arm Postgres schemas, with the Node process in a non-UTC zone (on UTC the timezone class of bug is invisible).

Sign-up/sign-in, refresh past updateAge, email-OTP (consumeOne), org create/invite/accept/list (count, sortBy/limit/offset), MCP OAuth register/authorize/token, rate-limit hammering (incrementOne + bigint-as-number). All 11 tables identical column-for-column after the full sequence, including each value's JS type — verification empty in both, proving consumeOne actually deleted. Timezone round-trips in 4 zones including Asia/Kolkata (+05:30).

New postgres CI job with its own service container on 55437.

Two things worth a reviewer's judgement

  1. Scoped to Postgres-family drivers, rejecting others at construction, rather than leaving consumeOne/incrementOne undefined for non-RETURNING dialects and letting the factory fall back. The reasoning is no untested surface in a published package; the counter-argument is that it narrows support without needing to. Easy to relax later.
  2. The local docker compose up -d postgres path on 55437 is unexercised — the daemon was wedged throughout the session, so the suite was run against a Postgres on 5432 instead. The CI job uses its own service container with matching config, so that path is self-exercising on this PR; only the developer convenience path wants one confirmation run.

Verification

pnpm run check ✓ · pnpm run test (155) ✓ · pnpm run test:postgres (80, both arms) ✓ · pnpm run verify:pack + attw on ./typeorm

Changeset included (minor). Not versioned, not published.

Ships as the `./typeorm` subpath of @nestm/better-auth. No TypeORM adapter
exists anywhere — `@better-auth/typeorm-adapter` is a 404 — so a TypeORM
application had to keep a second ORM alive purely for auth.

`typeormAdapter(dataSource, config?)` resolves Better Auth's camelCase model
and field names onto `EntityMetadata` (rateLimit -> class RateLimit -> table
rate_limit, userId -> column user_id), with an `entities` override for the
ambiguous cases and errors that name the candidates they saw.

It emits raw parameterised SQL rather than using QueryBuilder. The deciding
reason is `consumeOne`/`incrementOne`: both must be one statement whose
predicate is simultaneously selector and guard, with the guard repeated
OUTSIDE the `IN (SELECT ... LIMIT 1)` subquery so Postgres re-checks it
against the newest row version during EvalPlanQual. tests/postgres/atomicity
shows the cost of the weaker form: the query-builder-based Drizzle adapter
admits all 32 racers past a `count < 10` guard where this one admits 10.

The adapter also owns the timestamp timezone contract end to end, binding
dates as ISO-8601 UTC and reading naive columns back through
`AT TIME ZONE 'UTC'`. That makes it correct on any machine without
`pg.defaults.parseInputDatesAsUTC` or an OID 1114 parser, and without
mutating process-global driver state that belongs to the host application.

Conformance is differential: every flow runs through both
`@better-auth/drizzle-adapter` and this adapter against one committed DDL in
per-arm schemas, then all 11 tables are compared column by column, including
each value's JavaScript type. The suite runs in a non-UTC process zone,
because on UTC the timezone class of bug is invisible.

`typeorm` is an optional peer (^1.1.0), which raises engines.node to >=22.13.
@kauandotnet
kauandotnet merged commit 31bb0f6 into main Aug 10, 2026
9 checks passed
@kauandotnet
kauandotnet deleted the feat/typeorm-adapter branch August 10, 2026 13:42
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.

1 participant