Skip to content

feat(better-auth): add DrizzlePostgres layer#281

Open
anaydot wants to merge 6 commits into
alchemy-run:mainfrom
anaydot:feat/better-auth-drizzle-postgres
Open

feat(better-auth): add DrizzlePostgres layer#281
anaydot wants to merge 6 commits into
alchemy-run:mainfrom
anaydot:feat/better-auth-drizzle-postgres

Conversation

@anaydot
Copy link
Copy Markdown

@anaydot anaydot commented May 8, 2026

Closes #274. Stacked on #280.

Adds drizzlePostgresFromHyperdrive(...) and drizzlePostgresFromUrl(...) — Layer factories that wire better-auth to a Postgres database via drizzle-orm. Mirrors CloudflareD1.

import { BetterAuth, drizzlePostgresFromHyperdrive } from "@alchemy.run/better-auth";

.pipe(
  Effect.provide(
    drizzlePostgresFromHyperdrive({
      hyperdrive: MyHyperdrive,
      secret: process.env.BETTER_AUTH_SECRET!,
      baseURL: "https://app.example.com",
      basePath: "/api/auth",
      schema: { user, session, account, verification },
      plugins: [emailOTP({ ... })],
    }),
  ),
)

Then in routes:

const auth = yield* BetterAuth;
if (url.pathname.startsWith("/api/auth/")) return yield* auth.fetch;

For non-Hyperdrive Postgres (Neon direct, Supabase direct, env URL):

drizzlePostgresFromUrl({
  url: process.env.DATABASE_URL!,
  secret: process.env.BETTER_AUTH_SECRET!,
  schema: { user, session, account, verification },
})

Options

Accepts the full BetterAuthOptions (minus database, supplied internally via Drizzle.postgresRaw(...) from #280) plus a flat pass-through of drizzleAdapter config — schema, usePlural, debugLogs, camelCase, transaction. All plugins, hooks, advanced cookie config, rate limit, etc. work as you'd expect.

Per-request init (no Effect.cached on auth)

Unlike CloudflareD1, auth is rebuilt per request — Cloudflare Worker TCP sockets can't be reused across requests. The pg pool is cached per-ExecutionContext via Drizzle.postgresRaw and the auth wrapper matches that lifecycle.

@anaydot anaydot force-pushed the feat/better-auth-drizzle-postgres branch from 96dcaef to caf35e3 Compare May 8, 2026 07:38
Adds `db.raw` to the object returned by `Drizzle.postgres(...)`. It
yields a vanilla `drizzle-orm/node-postgres` instance backed by the
same connection string. Use it for libraries that need a regular
promise-shaped drizzle (e.g. better-auth's `drizzleAdapter`):

```ts
const db = yield* Drizzle.postgres(hd.connectionString);
const raw = yield* db.raw;

const auth = betterAuth({
  database: drizzleAdapter(raw, { provider: "pg", schema }),
});
```

The drizzle client is cached per-`ExecutionContext` so the pool is
built at most once per request.
@anaydot anaydot force-pushed the feat/better-auth-drizzle-postgres branch from caf35e3 to ebdc97e Compare May 8, 2026 07:56
@anaydot anaydot marked this pull request as ready for review May 10, 2026 03:07
* )
* ```
*/
export const DrizzlePostgres = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Don't export objects like this. Just èxport const fromHyperdrive = from the module. Better for tree-shaking

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.

flattened into top-level drizzlePostgresFromHyperdrive and drizzlePostgresFromUrl

Comment on lines +99 to +105
const raw = Effect.gen(function* () {
const ctx = yield* ExecutionContext;
return yield* (ctx.cache[rawSymbol] ??= yield* Effect.gen(function* () {
const url = Redacted.value(yield* connectionString);
return nodePgDrizzle<TRelations>(url);
}).pipe(Effect.cached));
}) as Effect.Effect<NodePgDatabase<TRelations>, E, R | ExecutionContext>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think for tree-shaking reasons, we should probably make this a different binding.

Drizzle.postgresRaw.

Effect's PgClient.layer doesn't go through this import, so we don't want to bloat the bundle by having them all in the same binding

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 — Drizzle.postgresRaw lives in its own module in #280

@anaydot anaydot force-pushed the feat/better-auth-drizzle-postgres branch from ebdc97e to 9662f25 Compare May 11, 2026 22:05
@anaydot anaydot requested a review from sam-goodwin May 11, 2026 22:18
*
* @binding
*/
export const postgresRaw = <
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Add example usage to the docs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done in 120a61c — added @section/@example JSDoc on postgresRaw (Hyperdrive binding, plain URL, plugging into better-auth) so they land in the generated API reference.

* Effect.provide(
* drizzlePostgresFromHyperdrive({
* hyperdrive: MyHyperdrive,
* secret: process.env.BETTER_AUTH_SECRET!,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should not be using environment variables here. Use Random resource to generate this instead of expecting the user to manually juggle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done in 120a61c — removed the secret option, the layer now provisions an Alchemy.Random("BetterAuthSecret") resource internally (matching the CloudflareD1 layer).

Comment on lines +115 to +168
export const drizzlePostgresFromHyperdrive = <
TRelations extends AnyRelations = EmptyRelations,
>(
options: DrizzlePostgresOptions<TRelations> & { hyperdrive: Hyperdrive },
) => {
const { hyperdrive, ...rest } = options;
return Layer.unwrap(
Effect.gen(function* () {
const hd = yield* Cloudflare.Hyperdrive.bind(hyperdrive);
return buildLayer<TRelations>(hd.connectionString, rest);
}),
).pipe(Layer.provide(Cloudflare.HyperdriveBindingLive));
};

/**
* Wires `better-auth` to a Postgres database via `drizzle-orm` from a
* plain connection URL — a literal string, a `Redacted<string>`, or an
* Effect resolving to one. Use for Neon, Supabase direct connections,
* or any non-Hyperdrive Postgres source.
*
* @example
* ```typescript
* import { BetterAuth, drizzlePostgresFromUrl } from "@alchemy.run/better-auth";
*
* .pipe(
* Effect.provide(
* drizzlePostgresFromUrl({
* url: process.env.DATABASE_URL!,
* secret: process.env.BETTER_AUTH_SECRET!,
* schema: { user, session, account, verification },
* }),
* ),
* )
* ```
*/
export const drizzlePostgresFromUrl = <
TRelations extends AnyRelations = EmptyRelations,
>(
options: DrizzlePostgresOptions<TRelations> & {
url:
| string
| Redacted.Redacted<string>
| Effect.Effect<Redacted.Redacted<string>>;
},
) => {
const { url, ...rest } = options;
const connectionString: Effect.Effect<Redacted.Redacted<string>> =
typeof url === "string"
? Effect.succeed(Redacted.make(url))
: Redacted.isRedacted(url)
? Effect.succeed(url)
: url;
return buildLayer<TRelations>(connectionString, rest);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can these be combined into one function with a more idiomatic name like postgresDrizzle (consistent prefix)

import * as BetterAuth from "@alchemy.run/better-auth"

BetterAuth.postgresDrizzle

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done in 120a61c — collapsed into a single postgresDrizzle({ db }) that accepts a Hyperdrive resource, URL string, Redacted<string>, or Effect<Redacted<string>>. Old file renamed to PostgresDrizzle.ts for consistency.

…ecret

Combines drizzlePostgresFromHyperdrive and drizzlePostgresFromUrl into a
single postgresDrizzle factory that takes either a Hyperdrive resource
or a connection URL via a single db option.

Removes the secret option in favor of an internally-provisioned
Alchemy.Random resource, matching the CloudflareD1 layer pattern — no
manual BETTER_AUTH_SECRET juggling.

Adds @section/@example JSDoc on Drizzle.postgresRaw so the binding
shows up in the generated API reference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@anaydot
Copy link
Copy Markdown
Author

anaydot commented May 13, 2026

thanks, also noticed we should update CloudflareD1 to accept better-auth's options but that can be done separately

Adds a deploy-driven fixture test that wires up a Neon Project, generates
better-auth's table schema via Drizzle.Schema, fronts the project with a
Cloudflare Hyperdrive, and deploys a Worker that mounts
BetterAuth.postgresDrizzle({ db: Hyperdrive, schema, emailAndPassword }).
The test drives /api/auth/sign-up/email and /api/auth/sign-in/email over
HTTP, proving the layer composes end-to-end through Hyperdrive + pg +
drizzle and that the auto-provisioned Random secret round-trips.

Widens PostgresSource to accept Effect<Hyperdrive> so the deferred
resource form (Cloudflare.Hyperdrive(...) inside a fixture module) flows
through without manual unwrapping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

Better-auth + Drizzle.postgres integration

2 participants