| 1 | import type { RuntimeContext } from "alchemy"; |
| 2 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import type * as Scope from "effect/Scope"; |
| 6 | import { |
| 7 | Database, |
| 8 | type DatabaseService, |
| 9 | type DirectDatabase, |
| 10 | } from "./Database.ts"; |
| 11 | import type { BetterAuthMigrationError } from "./Errors.ts"; |
| 12 | |
| 13 | /** |
| 14 | * Cloudflare D1 database layer for Better Auth. |
| 15 | * |
| 16 | * At runtime the Worker reaches the database through its native D1 binding |
| 17 | * (the layer bakes in `QueryDatabaseBinding`). At deploy time, schema |
| 18 | * migrations run over the D1 HTTP query API — which also transparently |
| 19 | * targets the local D1 simulator under `alchemy dev`. |
| 20 | * |
| 21 | * |
| 22 | * ### Using D1 as the auth database |
| 23 | * Provide the layer on the Worker impl effect that yields `BetterAuth`. |
| 24 | * The database resource can be referenced from module scope — the layer |
| 25 | * accepts the resource or its Effect. |
| 26 | * **Example:** Worker with a D1-backed BetterAuth |
| 27 | * ```typescript |
| 28 | * import { BetterAuth } from "@alchemy.run/better-auth"; |
| 29 | * import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 30 | * import * as Cloudflare from "alchemy/Cloudflare"; |
| 31 | * |
| 32 | * export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 33 | * |
| 34 | * export default class Api extends Cloudflare.Worker<Api>()( |
| 35 | * "Api", |
| 36 | * { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } }, |
| 37 | * Effect.gen(function* () { |
| 38 | * const auth = yield* BetterAuth({ |
| 39 | * basePath: "/auth", |
| 40 | * emailAndPassword: { enabled: true }, |
| 41 | * }); |
| 42 | * return { |
| 43 | * fetch: Effect.gen(function* () { |
| 44 | * const request = yield* HttpServerRequest; |
| 45 | * if (request.url.startsWith("/auth")) { |
| 46 | * return yield* auth.fetch; |
| 47 | * } |
| 48 | * const session = yield* auth.getSession(); |
| 49 | * return yield* HttpServerResponse.json({ user: session?.user ?? null }); |
| 50 | * }), |
| 51 | * }; |
| 52 | * }).pipe(Effect.provide(CloudflareD1(AuthDb))), |
| 53 | * ) {} |
| 54 | * ``` |
| 55 | * |
| 56 | * ### Migrations |
| 57 | * The schema migration Action connects over the D1 HTTP API at deploy |
| 58 | * time — the Worker's native binding is never used outside the deployed |
| 59 | * runtime, and no migration code ships in the Worker bundle. |
| 60 | * **Example:** Opting out of automatic migrations |
| 61 | * ```typescript |
| 62 | * const auth = yield* BetterAuth({ |
| 63 | * migrate: false, // manage the schema yourself |
| 64 | * emailAndPassword: { enabled: true }, |
| 65 | * }); |
| 66 | * ``` |
| 67 | * |
| 68 | * @layer |
| 69 | * @provides BetterAuth.Database |
| 70 | * @product D1 |
| 71 | */ |
| 72 | export const CloudflareD1 = ( |
| 73 | database: |
| 74 | | Cloudflare.D1.Database |
| 75 | | Effect.Effect<Cloudflare.D1.Database, never, any>, |
| 76 | ) => |
| 77 | Layer.effect( |
| 78 | Database, |
| 79 | Effect.gen(function* () { |
| 80 | const db = Effect.isEffect(database) |
| 81 | ? yield* database as Effect.Effect<Cloudflare.D1.Database> |
| 82 | : database; |
| 83 | const client = yield* Cloudflare.D1.QueryDatabase(db); |
| 84 | const service: DatabaseService = { |
| 85 | provider: "sqlite", |
| 86 | runtime: client.raw, |
| 87 | }; |
| 88 | // Migration support references the deploy-time HTTP/local D1 client |
| 89 | // (`QueryDatabaseLocal`), which must never reach a Worker bundle — |
| 90 | // this branch is dead-code-eliminated from deployed runtimes, which |
| 91 | // also tree-shakes the local-runtime machinery out of the bundle. |
| 92 | if (!globalThis.__ALCHEMY_RUNTIME__) { |
| 93 | return { |
| 94 | ...service, |
| 95 | migrate: { |
| 96 | identity: { databaseId: db.databaseId } as Record<string, unknown>, |
| 97 | connect: Effect.gen(function* () { |
| 98 | // Init half — runs under the migration Action's capture |
| 99 | // context: `QueryDatabase(database)` yields the databaseId |
| 100 | // Output, recording the dependency; the client's `raw` is a |
| 101 | // deferred accessor that resolves at apply. |
| 102 | const local = yield* Cloudflare.D1.QueryDatabase(db); |
| 103 | // Apply half — materialize the HTTP D1 facade. |
| 104 | return Effect.map( |
| 105 | local.raw, |
| 106 | (database) => database as DirectDatabase, |
| 107 | ); |
| 108 | }).pipe( |
| 109 | Effect.provide(Cloudflare.D1.QueryDatabaseLocal), |
| 110 | (effect) => |
| 111 | // The HTTP client's build requirements (Cloudflare |
| 112 | // credentials, HttpClient) are ambient during stack-eval |
| 113 | // where the init half runs. |
| 114 | effect as unknown as Effect.Effect< |
| 115 | Effect.Effect< |
| 116 | DirectDatabase, |
| 117 | BetterAuthMigrationError, |
| 118 | Scope.Scope |
| 119 | >, |
| 120 | never, |
| 121 | RuntimeContext |
| 122 | >, |
| 123 | ), |
| 124 | }, |
| 125 | } satisfies DatabaseService; |
| 126 | } |
| 127 | return service; |
| 128 | }), |
| 129 | ).pipe(Layer.provide(Cloudflare.D1.QueryDatabaseBinding)); |