type QueryResult = { rows: T[]; rowCount: number | null; }; export type QueryExecutor = { query = Record>(text: string, params?: unknown[]): Promise>; }; export const databaseExportTables = [ "users", "auth_sessions", "app_settings", "user_preferences", "organizations", "tickets", "projects", "project_teamspace_entries", "hidden_user_projects", "ticket_todos", "recurring_billings", "recurring_billing_slots", "recurring_billing_exceptions", "sessions", "active_timers", "ticket_month_closures", "month_closures", "ticket_day_closures", "day_closures", "ticket_day_billings", "ticket_day_billing_acknowledgements" ] as const; type ColumnInfo = { columnName: string; dataType: string; udtName: string; ordinalPosition: number; isNullable: "YES" | "NO"; }; const jsonDataTypes = new Set(["json", "jsonb"]); const insertBatchSize = 500; export function quoteIdentifier(value: string) { return `"${value.replace(/"/g, '""')}"`; } function quoteSqlString(value: string) { return `'${value.replace(/'/g, "''")}'`; } function isJsonColumn(column?: Pick) { return Boolean(column && (jsonDataTypes.has(column.dataType) || jsonDataTypes.has(column.udtName))); } function jsonValueForSql(value: unknown) { if (value instanceof Date) { return JSON.stringify(value.toISOString()); } const json = JSON.stringify(value); return json === undefined ? "null" : json; } export function sqlLiteral(value: unknown, column?: Pick): string { if (value === null || value === undefined) { return "NULL"; } if (Buffer.isBuffer(value)) { return `decode('${value.toString("hex")}', 'hex')`; } if (isJsonColumn(column)) { return quoteSqlString(jsonValueForSql(value)); } if (value instanceof Date) { return quoteSqlString(value.toISOString()); } if (typeof value === "boolean") { return value ? "TRUE" : "FALSE"; } if (typeof value === "number") { if (!Number.isFinite(value)) { throw new Error("Cannot export non-finite number"); } return String(value); } if (typeof value === "bigint") { return String(value); } if (typeof value === "object") { return quoteSqlString(jsonValueForSql(value)); } return quoteSqlString(String(value)); } function chunk(items: T[], size: number) { const chunks: T[][] = []; for (let index = 0; index < items.length; index += size) { chunks.push(items.slice(index, index + size)); } return chunks; } async function tableColumns(client: QueryExecutor, table: string) { const result = await client.query( ` SELECT column_name AS "columnName", data_type AS "dataType", udt_name AS "udtName", ordinal_position::int AS "ordinalPosition", is_nullable AS "isNullable" FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position ASC; `, [table] ); return result.rows; } function orderByClause(columns: ColumnInfo[]) { if (columns.some((column) => column.columnName === "id")) { return ` ORDER BY ${quoteIdentifier("id")}`; } const orderableColumns = columns.filter((column) => !isJsonColumn(column)).map((column) => quoteIdentifier(column.columnName)); return orderableColumns.length > 0 ? ` ORDER BY ${orderableColumns.join(", ")}` : ""; } async function sequenceNameForIdColumn(client: QueryExecutor, table: string, columns: ColumnInfo[]) { if (!columns.some((column) => column.columnName === "id")) { return null; } const result = await client.query<{ sequenceName: string | null }>( "SELECT pg_get_serial_sequence($1, 'id') AS \"sequenceName\";", [`public.${table}`] ); return result.rows[0]?.sequenceName ?? null; } export function sequenceSetvalSql(table: string, sequenceName: string) { const quotedTable = quoteIdentifier(table); return `SELECT setval(${quoteSqlString(sequenceName)}, COALESCE((SELECT MAX("id") FROM ${quotedTable}), 1), COALESCE((SELECT MAX("id") FROM ${quotedTable}), 0) > 0);`; } export function nextSequenceIdAfterRestore(maxId: number | null | undefined) { return maxId && maxId > 0 ? maxId + 1 : 1; } export async function exportTableSql(client: QueryExecutor, table: string) { const columns = await tableColumns(client, table); if (columns.length === 0) { return [`-- Tabelle ${table} existiert nicht im aktuellen Schema.`]; } const columnNames = columns.map((column) => column.columnName); const quotedColumns = columnNames.map(quoteIdentifier); const rowsResult = await client.query>( ` SELECT ${quotedColumns.join(", ")} FROM ${quoteIdentifier(table)}${orderByClause(columns)}; ` ); const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`, `-- columns ${table}: ${columnNames.join(",")}`]; for (const rows of chunk(rowsResult.rows, insertBatchSize)) { if (rows.length === 0) { continue; } lines.push( [ `INSERT INTO ${quoteIdentifier(table)} (${quotedColumns.join(", ")}) VALUES`, rows .map((row) => ` (${columns.map((column) => sqlLiteral(row[column.columnName], column)).join(", ")})`) .join(",\n") + ";" ].join("\n") ); } const sequenceName = await sequenceNameForIdColumn(client, table, columns); if (sequenceName) { lines.push(sequenceSetvalSql(table, sequenceName)); } return lines; } export async function currentTableColumns(client: QueryExecutor) { const result = await client.query<{ table_name: string; column_name: string }>( ` SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ANY($1::text[]) ORDER BY table_name, ordinal_position ASC; `, [[...databaseExportTables]] ); const columns = new Map(); for (const row of result.rows) { columns.set(row.table_name, [...(columns.get(row.table_name) ?? []), row.column_name]); } return columns; } export function restoreSqlText(sql: string) { const trimmed = sql.replace(/^\uFEFF/, "").trim(); if (!trimmed.includes("TicketTracker Datenbankexport")) { throw new Error("Nur TicketTracker-SQL-Backups können eingespielt werden"); } return trimmed .replace(/^\s*BEGIN\s*;\s*$/gim, "") .replace(/^\s*SET\s+LOCAL\s+(?:lock_timeout|statement_timeout)\s*=\s*'[^']*'\s*;\s*$/gim, "") .replace(/^\s*COMMIT\s*;\s*$/gim, "") .replace(/TRUNCATE\s+TABLE[\s\S]*?;\s*/i, "") .trim(); } function parseDumpColumnList(value: string) { const quoted = [...value.matchAll(/"((?:[^"]|"")+)"/g)].map((match) => match[1].replace(/""/g, '"')); if (quoted.length > 0) { return quoted; } return value .split(",") .map((column) => column.trim().replace(/^"|"$/g, "")) .filter(Boolean); } export type RestoreValidationResult = { valid: boolean; checkedTables: number; issues: string[]; }; export async function validateTicketTrackerBackup(client: QueryExecutor, sql: string): Promise { const issues: string[] = []; let body = ""; try { body = restoreSqlText(sql); } catch (error) { return { valid: false, checkedTables: 0, issues: [error instanceof Error ? error.message : "Backup-Datei konnte nicht gelesen werden"] }; } const dumpColumns = new Map>(); for (const match of sql.matchAll(/^--\s*columns\s+([a-z_][a-z0-9_]*):\s*(.+)$/gim)) { dumpColumns.set(match[1], new Set(parseDumpColumnList(match[2]))); } for (const match of body.matchAll(/INSERT\s+INTO\s+"?([a-z_][a-z0-9_]*)"?\s*\(([^)]+)\)\s*VALUES/gi)) { const table = match[1]; const columns = dumpColumns.get(table) ?? new Set(); for (const column of parseDumpColumnList(match[2])) { columns.add(column); } dumpColumns.set(table, columns); } for (const table of dumpColumns.keys()) { if (!(databaseExportTables as readonly string[]).includes(table)) { issues.push(`Dump enthält unbekannte Tabelle "${table}".`); } } const dbColumns = await currentTableColumns(client); for (const table of databaseExportTables) { const currentColumns = dbColumns.get(table) ?? []; const backupColumns = dumpColumns.get(table); if (currentColumns.length === 0) { issues.push(`Aktuelle Datenbank enthält Tabelle "${table}" nicht.`); continue; } if (!backupColumns) { issues.push(`Dump enthält keine Strukturinformationen für Tabelle "${table}". Bitte ein aktuelles TicketTracker-Backup verwenden.`); continue; } const missingInDump = currentColumns.filter((column) => !backupColumns.has(column)); const extraInDump = [...backupColumns].filter((column) => !currentColumns.includes(column)); if (missingInDump.length > 0) { issues.push(`Dump passt nicht zu Tabelle "${table}": Feld(er) fehlen: ${missingInDump.join(", ")}.`); } if (extraInDump.length > 0) { issues.push(`Dump passt nicht zu Tabelle "${table}": unbekannte Feld(er): ${extraInDump.join(", ")}.`); } } return { valid: issues.length === 0, checkedTables: databaseExportTables.length, issues }; } export async function exportDatabaseSql(client: QueryExecutor) { const lines = [ "-- TicketTracker Datenbankexport", `-- Erstellt: ${new Date().toISOString()}`, "--", "-- Restore:", "-- 1. Frische PostgreSQL-Datenbank erstellen.", "-- 2. TicketTracker-Migrationen ausführen.", "-- 3. TicketTracker-Backend stoppen.", "-- 4. Diese Datei einspielen:", "-- psql -v ON_ERROR_STOP=1 -d DATABASE -f backup.sql", "-- 5. TicketTracker-Backend wieder starten.", "--", "-- Der Restore läuft atomar in einer Transaktion.", "-- ACHTUNG: TRUNCATE benötigt exklusive PostgreSQL-Locks auf den betroffenen Tabellen.", "-- Für den Restore sollte das TicketTracker-Backend gestoppt werden.", "", "BEGIN;", "SET LOCAL lock_timeout = '10s';", "SET LOCAL statement_timeout = '15min';", `TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;` ]; for (const table of databaseExportTables) { lines.push("", ...(await exportTableSql(client, table))); } lines.push("", "COMMIT;", ""); return lines.join("\n"); }