feat(better-auth): add DrizzlePostgres layer#281
Conversation
96dcaef to
caf35e3
Compare
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.
caf35e3 to
ebdc97e
Compare
| * ) | ||
| * ``` | ||
| */ | ||
| export const DrizzlePostgres = { |
There was a problem hiding this comment.
Don't export objects like this. Just èxport const fromHyperdrive = from the module. Better for tree-shaking
There was a problem hiding this comment.
flattened into top-level drizzlePostgresFromHyperdrive and drizzlePostgresFromUrl
| 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>; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
done — Drizzle.postgresRaw lives in its own module in #280
ebdc97e to
9662f25
Compare
| * | ||
| * @binding | ||
| */ | ||
| export const postgresRaw = < |
There was a problem hiding this comment.
Add example usage to the docs
There was a problem hiding this comment.
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!, |
There was a problem hiding this comment.
We should not be using environment variables here. Use Random resource to generate this instead of expecting the user to manually juggle.
There was a problem hiding this comment.
Done in 120a61c — removed the secret option, the layer now provisions an Alchemy.Random("BetterAuthSecret") resource internally (matching the CloudflareD1 layer).
| 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); | ||
| }; |
There was a problem hiding this comment.
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.postgresDrizzleThere was a problem hiding this comment.
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>
|
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>
Closes #274. Stacked on #280.
Adds
drizzlePostgresFromHyperdrive(...)anddrizzlePostgresFromUrl(...)— Layer factories that wirebetter-authto a Postgres database viadrizzle-orm. MirrorsCloudflareD1.Then in routes:
For non-Hyperdrive Postgres (Neon direct, Supabase direct, env URL):
Options
Accepts the full
BetterAuthOptions(minusdatabase, supplied internally viaDrizzle.postgresRaw(...)from #280) plus a flat pass-through ofdrizzleAdapterconfig —schema,usePlural,debugLogs,camelCase,transaction. All plugins, hooks, advanced cookie config, rate limit, etc. work as you'd expect.Per-request init (no
Effect.cachedon auth)Unlike
CloudflareD1, auth is rebuilt per request — Cloudflare Worker TCP sockets can't be reused across requests. Thepgpool is cached per-ExecutionContextviaDrizzle.postgresRawand the auth wrapper matches that lifecycle.