366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
type QueryResult<T> = {
|
|
rows: T[];
|
|
rowCount: number | null;
|
|
};
|
|
|
|
export type QueryExecutor = {
|
|
query<T extends Record<string, unknown> = Record<string, unknown>>(text: string, params?: unknown[]): Promise<QueryResult<T>>;
|
|
};
|
|
|
|
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<ColumnInfo, "dataType" | "udtName">) {
|
|
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<ColumnInfo, "dataType" | "udtName">): 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<T>(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<ColumnInfo>(
|
|
`
|
|
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<Record<string, unknown>>(
|
|
`
|
|
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<string, string[]>();
|
|
|
|
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<RestoreValidationResult> {
|
|
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<string, Set<string>>();
|
|
|
|
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<string>();
|
|
|
|
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");
|
|
}
|