1import * as ps from "@distilled.cloud/planetscale";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import type { Connection } from "mysql2/promise";
7import {
8 makeMySQLMigrationExecutor,
9 runMigrations,
10 type NormalizedMigrationsInput,
11 type StampedMigrationsState,
12} from "../../SQL/Migrations/index.ts";
13import { readSqlFile, splitSqlStatements } from "../../SQL/SqlFile.ts";
14
15const MIGRATION_PASSWORD_TTL_SECONDS = 600;
16
17// `mysql2` is an optional peer dependency — loaded lazily so importing the
18// Planetscale provider never requires the driver unless migrations run.
19const importMysql = () =>
20 import("mysql2/promise").catch((cause) => {
21 throw new Error(
22 "Failed to load the 'mysql2' driver. Install the optional peer dependency 'mysql2' to run Planetscale MySQL migrations.",
23 { cause },
24 );
25 });
26
27export class MySQLMigrationError extends Data.TaggedError(
28 "Planetscale::MySQLMigrationError",
29)<{
30 message: string;
31 cause?: unknown;
32}> {}
33
34export interface MySQLMigrationTarget {
35 organization: string;
36 database: string;
37 branch: string;
38}
39
40/**
41 * PlanetScale MySQL's migration adaptation is exactly this: the shared
42 * pipeline with a temp-password-scoped mysql2 connection as its executor.
43 */
44export const runMySQLMigrations = (
45 target: MySQLMigrationTarget,
46 input: NormalizedMigrationsInput,
47 stamped: StampedMigrationsState,
48) =>
49 runMigrations({
50 input,
51 stamped,
52 withExecutor: (apply) =>
53 withMySQLConnection(target, (connection) =>
54 apply(makeMySQLMigrationExecutor(connection)),
55 ),
56 });
57
58export const runMySQLImports = (
59 target: MySQLMigrationTarget,
60 importFiles: ReadonlyArray<string>,
61 rootDir: string,
62 previous: Record<string, string>,
63) =>
64 Effect.gen(function* () {
65 const hashes: Record<string, string> = { ...previous };
66 for (const filePath of importFiles) {
67 const file = yield* readSqlFile(rootDir, filePath);
68 if (previous[filePath] === file.hash) {
69 hashes[filePath] = file.hash;
70 continue;
71 }
72 yield* runMySQLSql(target, file.sql);
73 hashes[filePath] = file.hash;
74 }
75 const tracked = new Set(importFiles);
76 for (const key of Object.keys(hashes)) {
77 if (!tracked.has(key)) delete hashes[key];
78 }
79 return hashes;
80 });
81
82const runMySQLSql = (target: MySQLMigrationTarget, sql: string) =>
83 withMySQLConnection(target, (connection) =>
84 Effect.gen(function* () {
85 for (const statement of splitSqlStatements(sql)) {
86 yield* mysqlQuery(connection, statement);
87 }
88 }),
89 );
90
91const withMySQLConnection = <A, E, R>(
92 target: MySQLMigrationTarget,
93 use: (connection: Connection) => Effect.Effect<A, E, R>,
94) =>
95 withTemporaryMySQLPassword(target, (password) =>
96 Effect.acquireUseRelease(
97 Effect.tryPromise({
98 try: async () => {
99 const { createConnection } = await importMysql();
100 return createConnection({
101 host: password.host,
102 user: password.username,
103 password: Redacted.value(password.password),
104 database: target.database,
105 multipleStatements: true,
106 ssl: { rejectUnauthorized: true },
107 });
108 },
109 catch: toMigrationError,
110 }),
111 use,
112 (connection) =>
113 Effect.tryPromise({
114 try: () => connection.end(),
115 catch: toMigrationError,
116 }).pipe(Effect.catch(() => Effect.void)),
117 ),
118 );
119
120const withTemporaryMySQLPassword = <A, E, R>(
121 target: MySQLMigrationTarget,
122 use: (password: {
123 id: string;
124 host: string;
125 username: string;
126 password: Redacted.Redacted<string>;
127 }) => Effect.Effect<A, E, R>,
128) =>
129 Effect.acquireUseRelease(
130 Effect.gen(function* () {
131 const created = yield* ps.createPassword({
132 organization: target.organization,
133 database: target.database,
134 branch: target.branch,
135 role: "admin",
136 ttl: MIGRATION_PASSWORD_TTL_SECONDS,
137 });
138
139 return {
140 id: created.id,
141 host: created.access_host_url,
142 username: created.username,
143 password: created.plain_text,
144 };
145 }),
146 use,
147 (password) =>
148 ps
149 .deletePassword({
150 organization: target.organization,
151 database: target.database,
152 branch: target.branch,
153 id: password.id,
154 })
155 .pipe(
156 // Already-deleted passwords are a success: nothing to clean up.
157 Effect.catchTag("NotFound", () => Effect.void),
158 Effect.retry({
159 schedule: Schedule.max([
160 Schedule.exponential("500 millis"),
161 Schedule.recurs(5),
162 ]),
163 }),
164 // Migrations succeeded; don't fail the parent over a release-step
165 // hiccup. The password's TTL bounds the orphan window; log loudly
166 // so an operator can clean it up manually if needed.
167 Effect.catch((cause: unknown) =>
168 Effect.logWarning(
169 `Failed to delete temporary Planetscale migration password after retries. ` +
170 `It will expire via TTL (~${MIGRATION_PASSWORD_TTL_SECONDS}s). ` +
171 `organization=${target.organization} database=${target.database} ` +
172 `branch=${target.branch} id=${password.id}`,
173 cause,
174 ),
175 ),
176 ),
177 );
178
179const mysqlQuery = (connection: Connection, sql: string) =>
180 Effect.tryPromise({
181 try: () => connection.query(sql).then(() => undefined),
182 catch: toMigrationError,
183 });
184
185const toMigrationError = (cause: unknown) =>
186 new MySQLMigrationError({
187 message: cause instanceof Error ? cause.message : String(cause),
188 cause,
189 });