import mysql, { type Pool, type PoolConnection, type ResultSetHeader, type RowDataPacket, } from "mysql2/promise"; import { getDatabaseUrl } from "./runtime-env"; export type DatabaseResult> = { results: T[]; success: boolean; meta: { changes: number; last_row_id: number; }; }; type Executor = Pool | PoolConnection; type MysqlBindValue = | string | number | bigint | boolean | Date | null | Blob | Buffer | Uint8Array | MysqlBindValue[] | { [key: string]: MysqlBindValue }; export function normalizeSqlForMysql(input: string) { let sql = input.trim(); sql = sql.replace(/^INSERT\s+OR\s+IGNORE\s+INTO\b/i, "INSERT IGNORE INTO"); sql = sql.replace(/\s+ESCAPE\s+'\\\\'/gi, ""); sql = sql.replace( /datetime\(\s*'now'\s*,\s*'-([0-9]+)\s+minutes?'\s*\)/gi, "DATE_SUB(UTC_TIMESTAMP(), INTERVAL $1 MINUTE)", ); sql = sql.replace( /datetime\(\s*([^,]+?)\s*,\s*'\+([0-9]+)\s+days?'\s*\)/gi, "DATE_ADD($1, INTERVAL $2 DAY)", ); const conflict = sql.match( /\s+ON\s+CONFLICT\s*\(([^)]+)\)\s+DO\s+UPDATE\s+SET\s+([\s\S]+)$/i, ); if (conflict) { const assignments = conflict[2].replace( /\bexcluded\.([a-zA-Z_][a-zA-Z0-9_]*)\b/g, "VALUES($1)", ); sql = `${sql.slice(0, conflict.index)} ON DUPLICATE KEY UPDATE ${assignments}`; } return sql; } function normalizeBindValue(value: unknown): MysqlBindValue { if (value === undefined) return null; if ( typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value) ) { return value.replace("T", " ").replace(/Z$/, ""); } return value as MysqlBindValue; } export class DatabaseStatement { private params: unknown[] = []; readonly database: DatabaseClient; readonly sql: string; constructor(database: DatabaseClient, sql: string) { this.database = database; this.sql = sql; } bind(...params: unknown[]) { const statement = new DatabaseStatement(this.database, this.sql); statement.params = params; return statement; } async all>() { return this.database.execute(this.sql, this.params); } async first>(column?: string) { const result = await this.all(); const row = result.results[0]; if (!row) return null; return column ? ((row as Record)[column] as T) : row; } async run() { return this.database.execute(this.sql, this.params); } values() { return [...this.params]; } } export class DatabaseClient { private readonly executor: Executor; constructor(executor: Executor) { this.executor = executor; } prepare(sql: string) { return new DatabaseStatement(this, sql); } async execute>(sql: string, params: unknown[] = []) { const [result] = await this.executor.execute( normalizeSqlForMysql(sql), params.map(normalizeBindValue), ); if (Array.isArray(result)) { return { results: result as T[], success: true, meta: { changes: 0, last_row_id: 0 }, } satisfies DatabaseResult; } const header = result as ResultSetHeader; return { results: [], success: true, meta: { changes: header.affectedRows ?? 0, last_row_id: header.insertId ?? 0, }, } satisfies DatabaseResult; } async batch(statements: DatabaseStatement[]) { const pool = getPool(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); const tx = new DatabaseClient(connection); const results = []; for (const statement of statements) { results.push(await tx.execute(statement.sql, statement.values())); } await connection.commit(); return results; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } async transaction(operation: (database: DatabaseClient) => Promise) { const pool = getPool(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); const transaction = new DatabaseClient(connection); const result = await operation(transaction); await connection.commit(); return result; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } } declare global { var __kocLoopMysqlPool: Pool | undefined; } export function getPool() { if (!globalThis.__kocLoopMysqlPool) { globalThis.__kocLoopMysqlPool = mysql.createPool({ uri: getDatabaseUrl(), connectionLimit: 10, waitForConnections: true, queueLimit: 0, charset: "utf8mb4", timezone: "Z", dateStrings: true, decimalNumbers: true, enableKeepAlive: true, }); } return globalThis.__kocLoopMysqlPool; } export function getDatabase() { return new DatabaseClient(getPool()); } export async function checkDatabaseConnection() { const [rows] = await getPool().query("SELECT 1 AS healthy"); return rows[0]?.healthy === 1; } export async function withDatabaseLock( name: string, timeoutSeconds: number, operation: () => Promise, ) { const connection = await getPool().getConnection(); try { const [rows] = await connection.query( "SELECT GET_LOCK(?, ?) AS acquired", [name, timeoutSeconds], ); if (Number(rows[0]?.acquired ?? 0) !== 1) return null; try { return await operation(); } finally { await connection.query("SELECT RELEASE_LOCK(?)", [name]); } } finally { connection.release(); } }