import { Database } from "bun:sqlite"; import { Data } from "effect"; import { existsSync } from "node:fs"; /* oxlint-disable executor/no-json-parse, executor/no-switch-statement, executor/no-try-catch-or-throw -- boundary: one-shot legacy SQLite importer normalizes unknown rows and wraps native sqlite failures */ import { type AnyColumn, type AnyTable, type FumaTables } from "@executor-js/sdk"; type SqliteRow = Record; type ImportFumaDb = Readonly<{ createMany: (table: string, rows: SqliteRow[]) => Promise; transaction: (run: (db: ImportFumaDb) => Promise) => Promise; }>; export class LocalSqliteImportError extends Data.TaggedError("LocalSqliteImportError")<{ readonly message: string; readonly sqlitePath: string; readonly table?: string; readonly cause: unknown; }> {} export interface LocalSqliteImportOptions { readonly sqlitePath: string; readonly target: ImportFumaDb; readonly tables: FumaTables; readonly scopeId: string; } export interface LocalSqliteImportResult { readonly imported: boolean; readonly importedRows: number; readonly importedTables: readonly string[]; readonly backupPath?: string; } const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; const sqliteStringLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`; const tableExists = (sqlite: Database, tableName: string): boolean => { const row = sqlite .query<{ name: string }, [string]>( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", ) .get(tableName); return row !== null; }; const sqliteColumnNames = (sqlite: Database, tableName: string): ReadonlySet => { const rows = sqlite .query<{ name: string }, []>(`PRAGMA table_info(${sqliteStringLiteral(tableName)})`) .all(); return new Set(rows.map((row) => row.name)); }; const readRows = (sqlite: Database, tableName: string): readonly SqliteRow[] => sqlite.query(`SELECT * FROM ${quoteIdent(tableName)}`).all(); const readScopeIds = (sqlite: Database, tableName: string): readonly string[] => sqlite .query<{ scope_id: unknown }, []>( `SELECT DISTINCT "scope_id" AS scope_id FROM ${quoteIdent(tableName)} WHERE "scope_id" IS NOT NULL`, ) .all() .flatMap((row) => (typeof row.scope_id === "string" ? [row.scope_id] : [])); const parseJson = (value: string): unknown => { try { return JSON.parse(value); } catch { return value; } }; const toBigInt = (value: unknown): unknown => { if (typeof value === "bigint") return value; if (typeof value === "number" && Number.isFinite(value)) return BigInt(value); if (typeof value === "string" && value.trim().length > 0) return BigInt(value); return value; }; const toDate = (value: unknown): unknown => { if (value instanceof Date) return value; if (typeof value === "number") return new Date(value); if (typeof value === "string") { const trimmed = value.trim(); if (/^-?\d+$/.test(trimmed)) return new Date(Number(trimmed)); return new Date(trimmed); } return value; }; const toBool = (value: unknown): unknown => { if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value === "string") return value === "1" || value.toLowerCase() === "true"; return value; }; const defaultColumnValue = (input: { readonly tableKey: string; readonly columnKey: string; readonly row: SqliteRow; readonly scopeId: string; }): unknown => { if (input.columnKey === "scope_id") return input.scopeId; if (input.tableKey === "blob" && input.columnKey === "id") { const namespace = input.row.namespace; const key = input.row.key; if (typeof namespace === "string" && typeof key === "string") { return JSON.stringify([namespace, key]); } } return undefined; }; const normalizeColumnValue = (value: unknown, column: AnyColumn): unknown => { if (value === undefined || value === null) return value; switch (column.type) { case "bool": return toBool(value); case "bigint": return toBigInt(value); case "date": case "timestamp": return toDate(value); case "json": return typeof value === "string" ? parseJson(value) : value; default: return value; } }; const toFumaRow = (input: { readonly tableKey: string; readonly table: AnyTable; readonly sqliteColumns: ReadonlySet; readonly row: SqliteRow; readonly scopeId: string; }): SqliteRow => { const out: SqliteRow = {}; for (const [columnKey, column] of Object.entries(input.table.columns)) { if (columnKey === "row_id") continue; const sqlName = column.names.sql; const rawValue = input.sqliteColumns.has(sqlName) ? input.row[sqlName] : defaultColumnValue({ tableKey: input.tableKey, columnKey, row: input.row, scopeId: input.scopeId, }); const value = normalizeColumnValue(rawValue, column); if (value !== undefined) out[columnKey] = value; } return out; }; export const readLegacySqliteScopeIds = (options: { readonly sqlitePath: string; readonly tables: FumaTables; readonly scopeId: string; }): ReadonlySet => { const scopeIds = new Set([options.scopeId]); if (!existsSync(options.sqlitePath)) return scopeIds; let sqlite: Database | null = null; try { sqlite = new Database(options.sqlitePath, { readonly: true }); for (const table of Object.values(options.tables)) { const tableName = table.names.sql; if (!tableExists(sqlite, tableName)) continue; const columns = sqliteColumnNames(sqlite, tableName); if (!columns.has("scope_id")) continue; for (const scopeId of readScopeIds(sqlite, tableName)) { scopeIds.add(scopeId); } } return scopeIds; } catch (cause) { throw new LocalSqliteImportError({ message: `Failed to inspect local SQLite scope ids from ${options.sqlitePath}`, sqlitePath: options.sqlitePath, cause, }); } finally { sqlite?.close(); } }; export const importSqliteDataToFuma = async ( options: LocalSqliteImportOptions, ): Promise => { if (!existsSync(options.sqlitePath)) { return { imported: false, importedRows: 0, importedTables: [] }; } let sqlite: Database | null = null; try { sqlite = new Database(options.sqlitePath, { readonly: true }); const importedTables: string[] = []; let importedRows = 0; await options.target.transaction(async (db) => { for (const [tableKey, table] of Object.entries(options.tables)) { const tableName = table.names.sql; if (!tableExists(sqlite!, tableName)) continue; const sqliteColumns = sqliteColumnNames(sqlite!, tableName); const rows = readRows(sqlite!, tableName).map((row) => toFumaRow({ tableKey, table, sqliteColumns, row, scopeId: options.scopeId, }), ); if (rows.length === 0) continue; await db.createMany(tableKey, rows); importedTables.push(tableKey); importedRows += rows.length; } }); sqlite.close(); sqlite = null; return { imported: true, importedRows, importedTables }; } catch (cause) { throw new LocalSqliteImportError({ message: `Failed to import local SQLite data from ${options.sqlitePath}`, sqlitePath: options.sqlitePath, cause, }); } finally { sqlite?.close(); } };