1import type { RuntimeContext } from "alchemy";
2import {
3 connectionSourceDigest,
4 resolveConnectionSource,
5 staticConnectionSource,
6 type ConnectionSource,
7 type StaticConnectionSource,
8} from "alchemy/SQL/ConnectionSource";
9import { openPostgresPool } from "alchemy/SQL/PostgresDriver";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import type * as Redacted from "effect/Redacted";
13import type * as Scope from "effect/Scope";
14import type { PoolConfig } from "pg";
15import {
16 Database,
17 type DatabaseService,
18 type DirectDatabase,
19} from "./Database.ts";
20import { BetterAuthMigrationError } from "./Errors.ts";
21
22export type { ConnectionSource };
23/** Deploy-resolvable connection source usable for migrations. */
24export type MigrateSource = StaticConnectionSource;
25
26export interface SqlLayerOptions {
27 /**
28 * Deploy-resolvable connection source for schema migrations. Defaults to
29 * the layer's own `url` when that is deploy-resolvable (a literal or a
30 * resource Output); a runtime-only `url` (Hyperdrive) has no default —
31 * pass the origin URL here, or `false` to disable migration support.
32 */
33 readonly migrate?: MigrateSource | false;
34 /** Extra driver pool options (`max` defaults to 1 per execution). */
35 readonly pool?: Record<string, unknown>;
36}
37
38export interface PostgresOptions extends SqlLayerOptions {
39 readonly pool?: Omit<PoolConfig, "connectionString">;
40}
41
42/**
43 * Build a `migrate` config over a scoped open-connection effect: the init
44 * half captures the connection-string Output, the apply half opens the
45 * driver against the resolved value.
46 *
47 * @internal
48 */
49export const makeMigrateSupport = (
50 migrateSource: MigrateSource,
51 open: (
52 url: Effect.Effect<Redacted.Redacted<string>>,
53 ) => Effect.Effect<DirectDatabase, never, Scope.Scope>,
54 failure: string,
55): NonNullable<DatabaseService["migrate"]> => ({
56 identity: { urlDigest: connectionSourceDigest(migrateSource) } as Record<
57 string,
58 unknown
59 >,
60 connect: Effect.gen(function* () {
61 // Init half — capture the connection-string Output.
62 const urlAccessor = yield* resolveConnectionSource(migrateSource);
63 // Apply half — load the driver and open the pool.
64 return open(urlAccessor).pipe(
65 Effect.catchDefect((cause: unknown) =>
66 Effect.fail(new BetterAuthMigrationError({ message: failure, cause })),
67 ),
68 );
69 }) as Effect.Effect<
70 Effect.Effect<DirectDatabase, BetterAuthMigrationError, Scope.Scope>,
71 never,
72 RuntimeContext
73 >,
74});
75
76/**
77 * Build the Postgres {@link DatabaseService}.
78 *
79 * @internal
80 */
81export const makePostgresService = (
82 source: ConnectionSource,
83 options: PostgresOptions | undefined,
84): Effect.Effect<DatabaseService> =>
85 Effect.gen(function* () {
86 const urlEffect = yield* resolveConnectionSource(source);
87 const migrateSource = staticConnectionSource(source, options?.migrate);
88 const open = (url: Effect.Effect<Redacted.Redacted<string>>) =>
89 openPostgresPool(url, options?.pool) as Effect.Effect<
90 DirectDatabase,
91 never,
92 Scope.Scope
93 >;
94
95 return {
96 provider: "postgres",
97 runtime: open(urlEffect) as DatabaseService["runtime"],
98 ...(migrateSource === undefined
99 ? {}
100 : {
101 migrate: makeMigrateSupport(
102 migrateSource,
103 open,
104 "Failed to load `pg` — install it to migrate a Postgres-backed BetterAuth",
105 ),
106 }),
107 } satisfies DatabaseService;
108 }) as Effect.Effect<DatabaseService>;
109
110/**
111 * Generic TCP Postgres database layer for Better Auth, from a connection
112 * string.
113 *
114 * Works with every Postgres alchemy exposes a connection string for:
115 * PlanetScale Postgres (`role.connectionUrl`), Prisma Postgres
116 * (`connection.databaseUrl`), AWS RDS inside a VPC, or any literal URL.
117 * One `pg.Pool` is opened per execution and closed when the event settles
118 * — the only legal pooling shape on workerd. Prefer `Neon`,
119 * `AuroraDataApi`, or `CloudflareHyperdrive` when they match your
120 * environment → database pair.
121 *
122 * On AWS Lambda, add `build: { install: ["pg"] }` to the Function props so
123 * the dynamically-imported driver ships in the artifact with an npm layout
124 * (its CJS require chain does not survive store-style node_modules).
125 *
126 *
127 * ### Connecting with a resource Output
128 * Resource Outputs are bound into the host environment at deploy and read
129 * back at runtime; the same source drives deploy-time migrations.
130 * **Example:** PlanetScale Postgres
131 * ```typescript
132 * import { BetterAuth } from "@alchemy.run/better-auth";
133 * import { Postgres } from "@alchemy.run/better-auth/Postgres";
134 *
135 * const role = yield* Planetscale.PostgresRole("auth-role", { database, branch });
136 *
137 * Effect.gen(function* () {
138 * const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } });
139 * return { fetch: ... };
140 * }).pipe(Effect.provide(Postgres(role.connectionUrl)))
141 * ```
142 *
143 * ### Separate migration source
144 * When the runtime URL is not deploy-resolvable (or points at a pooler),
145 * pass a direct deploy-time URL as `migrate`.
146 * **Example:** Pooled runtime, direct migrations
147 * ```typescript
148 * Postgres(role.connectionUrlPooled, { migrate: role.connectionUrl })
149 * ```
150 *
151 * @layer
152 * @provides BetterAuth.Database
153 * @peer pg
154 * @product Postgres
155 */
156export const Postgres = (
157 url: ConnectionSource,
158 options?: PostgresOptions,
159): Layer.Layer<Database> =>
160 Layer.effect(
161 Database,
162 makePostgresService(url, options),
163 ) as Layer.Layer<Database>;