Skip to content

Commit ebdc97e

Browse files
committed
feat(better-auth): add DrizzlePostgres layer
1 parent f8b5929 commit ebdc97e

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

packages/better-auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"dependencies": {
2626
"alchemy": "workspace:*",
2727
"better-auth": "catalog:",
28+
"drizzle-orm": "catalog:",
2829
"effect": "catalog:"
2930
},
3031
"publishConfig": {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import * as Cloudflare from "alchemy/Cloudflare";
2+
import type { Hyperdrive } from "alchemy/Cloudflare/Hyperdrive";
3+
import * as Drizzle from "alchemy/Drizzle";
4+
import {
5+
type Auth,
6+
type BetterAuthOptions,
7+
betterAuth as makeBetterAuth,
8+
} from "better-auth";
9+
import {
10+
drizzleAdapter,
11+
type DrizzleAdapterConfig,
12+
} from "better-auth/adapters/drizzle";
13+
import type { AnyRelations, EmptyRelations } from "drizzle-orm";
14+
import * as Effect from "effect/Effect";
15+
import * as Layer from "effect/Layer";
16+
import * as Redacted from "effect/Redacted";
17+
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
18+
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
19+
import { BetterAuth } from "./BetterAuth.ts";
20+
21+
/**
22+
* Common options accepted by every `DrizzlePostgres.from*` factory: the full
23+
* `BetterAuthOptions` (minus `database`, supplied internally) plus a flat
24+
* pass-through of `drizzleAdapter` config (minus `provider`, fixed to `"pg"`).
25+
*/
26+
export type DrizzlePostgresOptions<TRelations extends AnyRelations> = Omit<
27+
BetterAuthOptions,
28+
"database"
29+
> &
30+
Omit<DrizzleAdapterConfig, "provider">;
31+
32+
/**
33+
* Builds the `BetterAuth` service from a connection-string Effect that has
34+
* no further requirements (i.e. already-resolved against the runtime env).
35+
*/
36+
const buildLayer = <TRelations extends AnyRelations>(
37+
connectionString: Effect.Effect<Redacted.Redacted<string>>,
38+
options: DrizzlePostgresOptions<TRelations>,
39+
) => {
40+
const {
41+
schema,
42+
usePlural,
43+
debugLogs,
44+
camelCase,
45+
transaction,
46+
...betterAuthOptions
47+
} = options;
48+
return Layer.effect(
49+
BetterAuth,
50+
Effect.gen(function* () {
51+
const db = yield* Drizzle.postgres(connectionString);
52+
53+
const auth = Effect.gen(function* () {
54+
const raw = yield* db.raw;
55+
return makeBetterAuth({
56+
...betterAuthOptions,
57+
database: drizzleAdapter(raw, {
58+
provider: "pg",
59+
schema,
60+
usePlural,
61+
debugLogs,
62+
camelCase,
63+
transaction,
64+
}),
65+
});
66+
}) as unknown as Effect.Effect<Auth<any>>;
67+
68+
return {
69+
auth,
70+
fetch: Effect.gen(function* () {
71+
const request = yield* HttpServerRequest;
72+
const a = yield* auth;
73+
const response = yield* Effect.promise(() =>
74+
a.handler(request.source as Request),
75+
);
76+
return HttpServerResponse.fromWeb(response);
77+
}),
78+
};
79+
}),
80+
);
81+
};
82+
83+
/**
84+
* Wires `better-auth` to a Postgres database via `drizzle-orm`. Pass any
85+
* `BetterAuthOptions` you'd normally pass to `betterAuth(...)` plus the
86+
* `drizzleAdapter` schema/config. The `database` field is supplied
87+
* internally using `Drizzle.postgres(...).raw` (a vanilla
88+
* `drizzle-orm/node-postgres` client). The drizzle pool is cached
89+
* per-`ExecutionContext`; the better-auth instance is cached so it's
90+
* constructed at most once per JS realm.
91+
*
92+
* Two factories — pick by where your connection string comes from.
93+
*
94+
* @example Cloudflare Hyperdrive
95+
* ```typescript
96+
* import { BetterAuth, DrizzlePostgres } from "@alchemy.run/better-auth";
97+
*
98+
* .pipe(
99+
* Effect.provide(
100+
* DrizzlePostgres.fromHyperdrive({
101+
* hyperdrive: MyHyperdrive,
102+
* secret: process.env.BETTER_AUTH_SECRET!,
103+
* baseURL: "https://app.example.com",
104+
* basePath: "/api/auth",
105+
* schema: { user, session, account, verification },
106+
* plugins: [emailOTP({ ... })],
107+
* }),
108+
* ),
109+
* )
110+
*
111+
* // in routes:
112+
* const auth = yield* BetterAuth;
113+
* if (url.pathname.startsWith("/api/auth/")) return yield* auth.fetch;
114+
* ```
115+
*
116+
* @example Plain URL (Neon, Supabase direct, env var, …)
117+
* ```typescript
118+
* .pipe(
119+
* Effect.provide(
120+
* DrizzlePostgres.fromUrl({
121+
* url: process.env.DATABASE_URL!,
122+
* secret: process.env.BETTER_AUTH_SECRET!,
123+
* schema: { user, session, account, verification },
124+
* }),
125+
* ),
126+
* )
127+
* ```
128+
*/
129+
export const DrizzlePostgres = {
130+
/**
131+
* Build the `BetterAuth` layer from a Cloudflare Hyperdrive resource.
132+
* Provides `Cloudflare.HyperdriveBindingLive` internally so consumers
133+
* don't need to wire it up.
134+
*/
135+
fromHyperdrive: <TRelations extends AnyRelations = EmptyRelations>(
136+
options: DrizzlePostgresOptions<TRelations> & { hyperdrive: Hyperdrive },
137+
) => {
138+
const { hyperdrive, ...rest } = options;
139+
return Layer.unwrap(
140+
Effect.gen(function* () {
141+
const hd = yield* Cloudflare.Hyperdrive.bind(hyperdrive);
142+
return buildLayer<TRelations>(hd.connectionString, rest);
143+
}),
144+
).pipe(Layer.provide(Cloudflare.HyperdriveBindingLive));
145+
},
146+
147+
/**
148+
* Build the `BetterAuth` layer from a plain Postgres connection URL —
149+
* a literal string, a `Redacted<string>`, or an Effect resolving to one.
150+
* Use for Neon, Supabase direct connections, or any non-Hyperdrive
151+
* Postgres source.
152+
*/
153+
fromUrl: <TRelations extends AnyRelations = EmptyRelations>(
154+
options: DrizzlePostgresOptions<TRelations> & {
155+
url:
156+
| string
157+
| Redacted.Redacted<string>
158+
| Effect.Effect<Redacted.Redacted<string>>;
159+
},
160+
) => {
161+
const { url, ...rest } = options;
162+
const connectionString: Effect.Effect<Redacted.Redacted<string>> =
163+
typeof url === "string"
164+
? Effect.succeed(Redacted.make(url))
165+
: Redacted.isRedacted(url)
166+
? Effect.succeed(url)
167+
: url;
168+
return buildLayer<TRelations>(connectionString, rest);
169+
},
170+
};

packages/better-auth/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./BetterAuth.ts";
22
export * from "./CloudflareD1.ts";
3+
export * from "./DrizzlePostgres.ts";

0 commit comments

Comments
 (0)