1import { Action, Stack, type Output } from "alchemy";
2import { sha256Object } from "alchemy/Util/sha256";
3import { CurrentRuntimeContext, sanitizeKey } from "alchemy/RuntimeContext";
4import type { BetterAuthOptions } from "better-auth";
5import { getSchema } from "better-auth/db";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import type { DatabaseService } from "./Database.ts";
9import { BetterAuthMigrationError } from "./Errors.ts";
10
11/**
12 * Deploy-time migration machinery.
13 *
14 * This module is ONLY imported dynamically from inside
15 * `if (!globalThis.__ALCHEMY_RUNTIME__)` in `BetterAuth.ts`, so none of it
16 * (including `better-auth/db` and the Kysely-based migration engine pulled
17 * in by `better-auth/db/migration`) ever reaches a deployed runtime bundle.
18 *
19 * @internal
20 */
21
22interface MigrateInput {
23 /** Non-secret identity of the target database (from the platform layer). */
24 identity: Record<string, unknown>;
25 /** Fingerprint of the Better Auth schema (tables + fields). */
26 schema: string;
27 /** Dialect family — part of the input so a dialect swap re-runs. */
28 provider: string;
29}
30
31export interface MigrateOutput {
32 tablesCreated: number;
33 tablesAltered: number;
34 indexesCreated: number;
35}
36
37export interface RegisterMigrationOptions {
38 readonly id: string;
39 /** The user's Better Auth options (without `database`). */
40 readonly options: BetterAuthOptions;
41 readonly db: DatabaseService;
42 /** The user's `migrate` prop — `undefined` means default-on. */
43 readonly migrate: boolean | undefined;
44}
45
46/**
47 * Register the schema-migration Action for a `BetterAuth` instance.
48 *
49 * Actions run only during `alchemy deploy` (apply), re-run only when their
50 * resolved input changes — here: the schema fingerprint, the database
51 * identity, or the dialect — and their body gets the ambient stack
52 * services, so `migrate.connect` can resolve resource Outputs and reach
53 * the cloud.
54 */
55export const registerMigration = ({
56 id,
57 options,
58 db,
59 migrate,
60}: RegisterMigrationOptions): Effect.Effect<void> =>
61 Effect.gen(function* () {
62 const support = db.migrate;
63 if (support === undefined) {
64 if (migrate === true) {
65 return yield* Effect.die(
66 new Error(
67 `BetterAuth(${id}): \`migrate: true\` was requested, but the ` +
68 `"${db.provider}" Database layer does not support automatic ` +
69 "migrations. Remove `migrate: true` (and manage the schema " +
70 "yourself, e.g. via `npx auth@1.7.5 generate`) or use a " +
71 "layer with migration support.",
72 ),
73 );
74 }
75 return;
76 }
77 if (migrate === false) {
78 return;
79 }
80 // Outside a stack evaluation (unit tests driving BetterAuth directly)
81 // there is nothing to register on — skip silently.
82 const stack = yield* Effect.serviceOption(Stack);
83 if (Option.isNone(stack)) {
84 return;
85 }
86
87 // Init-effect form: `support.connect`'s outer half runs here under the
88 // Action's capture context, so resource Outputs it yields (databaseId,
89 // connection-string attrs) become dependency captures resolved at
90 // apply. The returned runner does the actual connect + migrate.
91 const Migrate = Action(
92 "BetterAuth.Migrate",
93 Effect.gen(function* () {
94 const acquire = yield* support.connect;
95 return (_input: MigrateInput) => runMigrationWith(acquire, options);
96 }),
97 );
98 const result = yield* Migrate(`${id}Migrate`, {
99 identity: support.identity,
100 schema: yield* schemaFingerprint(options),
101 provider: db.provider,
102 }).pipe(
103 // The Action call's requirements (Stack, the runner's Req) are
104 // ambient during stack evaluation — erase them; presence of Stack
105 // was checked above.
106 (effect) => effect as unknown as Effect.Effect<Output<MigrateOutput>>,
107 );
108
109 // Bind the migration result into the host environment (when there is a
110 // host): the host's env then depends on the Action's output, giving the
111 // engine a Worker/Function → Migration dependency edge so first-deploy
112 // traffic cannot race the schema.
113 const rc = yield* CurrentRuntimeContext;
114 if (rc !== undefined) {
115 yield* rc.set(sanitizeKey(`${id}Migration`), result as never);
116 }
117 }) as Effect.Effect<void>;
118
119/**
120 * Connect via the platform layer's `migrate.connect` and apply Better
121 * Auth's schema migrations. Additive and idempotent — re-running against
122 * an up-to-date database is a no-op.
123 */
124export const applyMigrations = (
125 support: NonNullable<DatabaseService["migrate"]>,
126 options: BetterAuthOptions,
127) =>
128 Effect.flatMap(support.connect, (acquire) =>
129 runMigrationWith(acquire, options),
130 );
131
132const runMigrationWith = (
133 acquire: Effect.Effect<
134 import("./Database.ts").DirectDatabase,
135 BetterAuthMigrationError,
136 import("effect/Scope").Scope
137 >,
138 options: BetterAuthOptions,
139) =>
140 Effect.scoped(
141 Effect.gen(function* () {
142 const database = yield* acquire;
143 if (typeof database === "function") {
144 return yield* Effect.die(
145 new Error(
146 "BetterAuth migration: `connect` yielded an adapter factory — " +
147 "migrations require a direct (Kysely-resolvable) database.",
148 ),
149 );
150 }
151 const { getMigrations, UnsafeMigrationError } = yield* Effect.promise(
152 () => import("better-auth/db/migration"),
153 );
154 const migrations = yield* Effect.tryPromise({
155 try: () =>
156 getMigrations({
157 ...options,
158 database,
159 // getMigrations only introspects the schema; the secret is
160 // irrelevant but required by the options type.
161 secret: "alchemy-migrate",
162 telemetry: { enabled: false },
163 }),
164 catch: (cause) =>
165 new BetterAuthMigrationError({
166 message:
167 cause instanceof UnsafeMigrationError
168 ? cause.message
169 : "Failed to compute Better Auth schema migrations",
170 cause,
171 }),
172 });
173 yield* Effect.tryPromise({
174 try: () => migrations.runMigrations(),
175 catch: (cause) =>
176 new BetterAuthMigrationError({
177 message: "Failed to apply Better Auth schema migrations",
178 cause,
179 }),
180 });
181 return {
182 tablesCreated: migrations.toBeCreated.length,
183 tablesAltered: migrations.toBeAdded.length,
184 indexesCreated: migrations.toBeAddedIndexes.length,
185 } satisfies MigrateOutput;
186 }),
187 );
188
189/**
190 * Stable fingerprint of the Better Auth schema derived from the user's
191 * options (plugins, additionalFields, model renames, table indexes).
192 * Changing the schema changes the fingerprint, which changes the
193 * migration Action's input and re-runs it on the next deploy.
194 */
195export const schemaFingerprint = (
196 options: BetterAuthOptions,
197): Effect.Effect<string> =>
198 Effect.suspend(() => {
199 const schema = getSchema(options);
200 // Reduce to the migration-relevant field and index attributes;
201 // sha256Object's stable serialization handles key ordering.
202 const reduced = Object.fromEntries(
203 Object.entries(schema).map(([table, def]) => [
204 table,
205 {
206 fields: Object.fromEntries(
207 Object.entries(def.fields).map(([name, field]) => [
208 name,
209 {
210 type: String(field.type),
211 required: field.required ?? false,
212 unique: field.unique ?? false,
213 index: field.index ?? false,
214 fieldName: field.fieldName,
215 references: field.references
216 ? {
217 model: field.references.model,
218 field: field.references.field,
219 }
220 : undefined,
221 },
222 ]),
223 ),
224 indexes: (def.indexes ?? []).map((index) => ({
225 name: index.name,
226 unique: index.unique ?? false,
227 columns: [...index.columns],
228 })),
229 },
230 ]),
231 );
232 return sha256Object(reduced);
233 });