feat: add a TypeORM database adapter - #18
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
@nestm/better-auth/typeorm—typeormAdapter(dataSource, config?)— backing Better Auth with a TypeORMDataSourceon PostgreSQL.No TypeORM adapter exists anywhere today (
@better-auth/typeorm-adapteris a 404), which makes this the last blocker to removing Drizzle fromconcepta-artifacts.Implements the full
CustomAdaptersurfaceThe eight required methods plus
consumeOneandincrementOne. Both optional ones carry a literalTODO(...-required): tighten to required in the next minorin@better-auth/core, so they are implemented now rather than left to the factory fallback.The adapter contract is byte-identical between
better-auth1.6.25 and 1.6.26 (diffon@better-auth/core/dist/db/adapter/index.d.mtsreturns nothing), so developing against 1.6.26 is safe for a 1.6.25 consumer.The measured win over the reference adapter
consumeOneandincrementOneare 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.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_Paulowith the instant05:06:07Z:Dateparam02:06:07✗08:06:07Z✗Date05:06:07✓08:06:07Z✗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 aDateand never reads a baretimestamp. That makes it correct withoutpg.defaults.parseInputDatesAsUTCor 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
supportsUUIDstrueon pgfalse—true+generateId:"uuid"makes Better Auth emit no id, whichid text PRIMARY KEYcannot defaultsupportsJSON/supportsArraystrueon pgfalse— inert for Better Auth's own schema (no json/array field exists) and safer overtextexperimental.joinssetvsincrementprecedence matches Drizzle (Kysely is the reverse).Traps found while building this
raw.rowsfor SELECT/INSERT but the tuple[rows, rowCount]for UPDATE/DELETE. Assuming a flat array silently breaksupdate,consumeOneandincrementOne.selectis forwarded totransformOutputforfindOnebut not forfindMany, so a selectedfindManyreturns all schema keys with unselected onesundefined. Both adapters behave identically; pinned inadapter-options.spec.ts.idis force-retyped andString()-ed beforecustomTransformOutputruns, so the number-coercion rule must exclude identifiers orgenerateId: "serial"gets its ids coerced back, undoing the factory's own normalisation.Conformance
pnpm run test:postgres— 80 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 —verificationempty in both, provingconsumeOneactually deleted. Timezone round-trips in 4 zones includingAsia/Kolkata(+05:30).New
postgresCI job with its own service container on 55437.Two things worth a reviewer's judgement
consumeOne/incrementOneundefined for non-RETURNINGdialects 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.docker compose up -d postgrespath 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+attwon./typeorm✓Changeset included (minor). Not versioned, not published.