import { Kysely, MysqlDialect, PostgresDialect, SqliteDialect } from 'kysely'; import { createPool as createMysqlPool } from 'mysql2'; import { Pool as PostgresPool } from 'pg'; import type { IdentityDatabaseSchema } from '../types/database'; import { IdentityDBConfigurationError } from '../core/errors'; export interface SqliteConnectionConfig { client: 'sqlite'; filename: string; readonly?: boolean; } export interface PostgresConnectionConfig { client: 'postgres'; connectionString?: string; host?: string; port?: number; database?: string; user?: string; password?: string; ssl?: boolean; } export interface MysqlConnectionConfig { client: 'mysql' | 'mariadb'; uri?: string; host?: string; port?: number; database?: string; user?: string; password?: string; } export type IdentityDBConnectionConfig = | SqliteConnectionConfig | PostgresConnectionConfig | MysqlConnectionConfig; export interface DatabaseConnection { client: IdentityDBConnectionConfig['client']; db: Kysely; destroy: () => Promise; } interface BunSqliteStatement { columnNames: ReadonlyArray; all(parameters?: ReadonlyArray): unknown[]; run(parameters?: ReadonlyArray): { changes: number | bigint; lastInsertRowid: number | bigint; }; iterate(parameters?: ReadonlyArray): IterableIterator; } interface BunSqliteDatabase { close(): void; exec(sql: string): void; prepare(sql: string): BunSqliteStatement; } interface BunSqliteModule { Database: { open(filename: string, flags?: number): BunSqliteDatabase; }; constants: { SQLITE_OPEN_CREATE: number; SQLITE_OPEN_MEMORY: number; SQLITE_OPEN_READONLY: number; SQLITE_OPEN_READWRITE: number; }; } interface NodeSqliteStatement { all(...parameters: ReadonlyArray): unknown[]; columns(): ReadonlyArray; iterate(...parameters: ReadonlyArray): IterableIterator; run(...parameters: ReadonlyArray): { changes: number | bigint; lastInsertRowid: number | bigint; }; } interface NodeSqliteDatabase { close(): void; exec(sql: string): void; prepare(sql: string): NodeSqliteStatement; } interface NodeSqliteModule { DatabaseSync: new ( filename: string, options?: { readOnly?: boolean; }, ) => NodeSqliteDatabase; } interface KyselyCompatibleSqliteStatement { readonly reader: boolean; all(parameters: ReadonlyArray): unknown[]; run(parameters: ReadonlyArray): { changes: number | bigint; lastInsertRowid: number | bigint; }; iterate(parameters: ReadonlyArray): IterableIterator; } interface KyselyCompatibleSqliteDatabase { close(): void; prepare(sql: string): KyselyCompatibleSqliteStatement; } const BUN_SQLITE_MODULE = 'bun:sqlite'; const NODE_SQLITE_MODULE = 'node:sqlite'; function createUnsupportedSqliteRuntimeError(): IdentityDBConfigurationError { return new IdentityDBConfigurationError( 'SQLite connections now require a runtime with a built-in SQLite driver. Use Bun for bun:sqlite support, or Node 22+ for node:sqlite support.', ); } function isBunRuntime(): boolean { return typeof globalThis === 'object' && 'Bun' in globalThis; } async function createBunSqliteDatabase( config: SqliteConnectionConfig, bunSqliteModule?: BunSqliteModule, ): Promise { const bunSqlite = bunSqliteModule ?? ((await import(BUN_SQLITE_MODULE).catch(() => { throw createUnsupportedSqliteRuntimeError(); })) as BunSqliteModule); const flags = config.readonly ? bunSqlite.constants.SQLITE_OPEN_READONLY : bunSqlite.constants.SQLITE_OPEN_READWRITE | bunSqlite.constants.SQLITE_OPEN_CREATE | (config.filename === ':memory:' ? bunSqlite.constants.SQLITE_OPEN_MEMORY : 0); const database = bunSqlite.Database.open(config.filename, flags); database.exec('PRAGMA foreign_keys = ON'); return { close() { database.close(); }, prepare(sql: string): KyselyCompatibleSqliteStatement { const statement = database.prepare(sql); return { reader: statement.columnNames.length > 0, all(parameters) { return statement.all(Array.from(parameters)); }, run(parameters) { return statement.run(Array.from(parameters)); }, iterate(parameters) { return statement.iterate(Array.from(parameters)); }, }; }, }; } function createNodeSqliteDatabase( config: SqliteConnectionConfig, nodeSqlite: NodeSqliteModule, ): KyselyCompatibleSqliteDatabase { const database = new nodeSqlite.DatabaseSync(config.filename, { readOnly: config.readonly ?? false, }); database.exec('PRAGMA foreign_keys = ON'); return { close() { database.close(); }, prepare(sql: string): KyselyCompatibleSqliteStatement { const statement = database.prepare(sql); return { reader: statement.columns().length > 0, all(parameters) { return statement.all(...parameters); }, run(parameters) { return statement.run(...parameters); }, iterate(parameters) { return statement.iterate(...parameters); }, }; }, }; } async function createSqliteDatabase( config: SqliteConnectionConfig, ): Promise { if (isBunRuntime()) { return createBunSqliteDatabase(config); } const nodeSqlite = await import(NODE_SQLITE_MODULE).catch(() => null); if (nodeSqlite) { return createNodeSqliteDatabase(config, nodeSqlite as NodeSqliteModule); } throw createUnsupportedSqliteRuntimeError(); } export async function createDatabase( config: IdentityDBConnectionConfig, ): Promise { switch (config.client) { case 'sqlite': { const sqlite = await createSqliteDatabase(config); const db = new Kysely({ dialect: new SqliteDialect({ database: sqlite, }), }); return { client: config.client, db, destroy: async () => { await db.destroy(); }, }; } case 'postgres': { const pool = new PostgresPool({ connectionString: config.connectionString, host: config.host, port: config.port, database: config.database, user: config.user, password: config.password, ssl: config.ssl ? { rejectUnauthorized: false } : undefined, }); const db = new Kysely({ dialect: new PostgresDialect({ pool }), }); return { client: config.client, db, destroy: async () => { await db.destroy(); await pool.end(); }, }; } case 'mysql': case 'mariadb': { const mysqlOptions: { host?: string; port?: number; database?: string; user?: string; password?: string; } = {}; if (config.host !== undefined) { mysqlOptions.host = config.host; } if (config.port !== undefined) { mysqlOptions.port = config.port; } if (config.database !== undefined) { mysqlOptions.database = config.database; } if (config.user !== undefined) { mysqlOptions.user = config.user; } if (config.password !== undefined) { mysqlOptions.password = config.password; } const pool = config.uri ? createMysqlPool(config.uri) : createMysqlPool(mysqlOptions); const db = new Kysely({ dialect: new MysqlDialect({ pool }), }); return { client: config.client, db, destroy: async () => { await db.destroy(); await new Promise((resolve, reject) => { pool.end((error) => { if (error) { reject(error); return; } resolve(); }); }); }, }; } default: { const neverClient: never = config; throw new IdentityDBConfigurationError( `Unsupported database client: ${JSON.stringify(neverClient)}`, ); } } }