180 lines
6.6 KiB
TypeScript
180 lines
6.6 KiB
TypeScript
// src/db/backup_user.ts
|
|
/*
|
|
|
|
Implement this at some point
|
|
|
|
|
|
Solution 1: Use a Custom Backup/Restore Script (Recommended)
|
|
The most reliable way to handle this is to create dedicated scripts for backing up and restoring a user's data. These scripts can use SQL to intelligently query the database, trace all the foreign key relationships, and generate a complete, ordered SQL file for restoration.
|
|
|
|
How it works:
|
|
|
|
Create a Backup Script (backup_user.js):
|
|
|
|
This Node.js script would connect to the database.
|
|
It would take a user's email as an argument.
|
|
It would then systematically query all tables related to that user, starting with the users table and following the foreign keys to shopping_lists, shopping_list_items, watched_items, etc.
|
|
It would then generate a single user_backup.sql file containing all the INSERT statements needed to recreate that user's data, carefully ordered to respect foreign key constraints (e.g., insert shopping_lists before shopping_list_items).
|
|
Create a Restore Script (restore_user.js):
|
|
|
|
This script would connect to the database.
|
|
It would read the user_backup.sql file and execute its contents against the database, restoring the user's data into the newly created schema.
|
|
Update the Workflow:
|
|
|
|
The manual-db-reset.yml workflow would be simplified to call these scripts. The "Backup" step would run node backup_user.js tsx@gmail.com, and the "Restore" step would run node restore_user.js.
|
|
|
|
*/
|
|
/**
|
|
* @file This script creates a data-only backup for a single user.
|
|
* It traces relationships across tables to export all relevant data as SQL INSERT statements.
|
|
*
|
|
* Usage (from project root, after setting environment variables):
|
|
* export DB_USER=... DB_PASSWORD=... && npx tsx src/db/backup_user.ts --email user@example.com --output user_backup.sql
|
|
*/
|
|
|
|
import { Pool, PoolClient } from 'pg';
|
|
import fs from 'node:fs/promises';
|
|
import { logger } from '../services/logger.server';
|
|
|
|
const pool = new Pool({
|
|
user: process.env.DB_USER,
|
|
host: process.env.DB_HOST,
|
|
database: process.env.DB_NAME_PROD, // IMPORTANT: This script targets the production DB
|
|
password: process.env.DB_PASSWORD,
|
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
|
});
|
|
|
|
/**
|
|
* A map defining the tables to back up and the column that links them to the user.
|
|
* The order is critical to respect foreign key constraints on restore.
|
|
* Tables with direct user_id foreign keys come first.
|
|
*/
|
|
const USER_DATA_TABLES: Record<string, string> = {
|
|
users: 'user_id',
|
|
profiles: 'user_id',
|
|
pantry_locations: 'user_id',
|
|
shopping_lists: 'user_id',
|
|
recipes: 'user_id',
|
|
menu_plans: 'user_id',
|
|
recipe_collections: 'user_id',
|
|
user_item_aliases: 'user_id',
|
|
user_appliances: 'user_id',
|
|
user_dietary_restrictions: 'user_id',
|
|
favorite_stores: 'user_id',
|
|
favorite_recipes: 'user_id',
|
|
user_watched_items: 'user_id',
|
|
receipts: 'user_id',
|
|
shopping_trips: 'user_id',
|
|
};
|
|
|
|
type DBValue = string | number | boolean | null | Date | object;
|
|
|
|
/**
|
|
* Generates a SQL INSERT statement for a given row of data.
|
|
* @param table The name of the table.
|
|
* @param row The data object for the row.
|
|
* @returns A formatted SQL INSERT statement string.
|
|
*/
|
|
function generateInsertStatement(table: string, row: Record<string, DBValue>): string {
|
|
const columns = Object.keys(row)
|
|
.map((col) => `"${col}"`)
|
|
.join(', ');
|
|
const values = Object.values(row)
|
|
.map((val) => {
|
|
if (val === null) return 'NULL';
|
|
if (val instanceof Date) return `'${val.toISOString()}'`; // Handle Date objects
|
|
if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`; // Escape single quotes
|
|
if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'`; // Handle JSONB
|
|
return val;
|
|
})
|
|
.join(', ');
|
|
|
|
return `INSERT INTO public.${table} (${columns}) VALUES (${values});\n`;
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
const emailIndex = args.indexOf('--email');
|
|
const outputIndex = args.indexOf('--output');
|
|
|
|
if (emailIndex === -1 || outputIndex === -1) {
|
|
console.error(
|
|
'Usage: tsx src/db/backup_user.ts --email <user_email> --output <output_file.sql>',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const userEmail = args[emailIndex + 1];
|
|
const outputFile = args[outputIndex + 1];
|
|
let client: PoolClient | null = null;
|
|
|
|
try {
|
|
client = await pool.connect();
|
|
logger.info(`Connected to database. Backing up user: ${userEmail}`);
|
|
|
|
// 1. Find the user ID
|
|
const userRes = await client.query('SELECT user_id FROM public.users WHERE email = $1', [
|
|
userEmail,
|
|
]);
|
|
if (userRes.rows.length === 0) {
|
|
logger.warn(`User with email ${userEmail} not found. No backup will be created.`);
|
|
return;
|
|
}
|
|
const userId = userRes.rows[0].user_id;
|
|
logger.info(`Found user ID: ${userId}`);
|
|
|
|
let backupSql = '-- User Data Backup\n';
|
|
backupSql += `-- Generated at: ${new Date().toISOString()}\n`;
|
|
backupSql += `-- User Email: ${userEmail}\n\n`;
|
|
|
|
// 2. Backup data from tables with a direct user_id link
|
|
for (const [table, column] of Object.entries(USER_DATA_TABLES)) {
|
|
const res = await client.query(`SELECT * FROM public.${table} WHERE ${column} = $1`, [
|
|
userId,
|
|
]);
|
|
if (res.rows.length > 0) {
|
|
backupSql += `-- Data for table: ${table}\n`;
|
|
for (const row of res.rows) {
|
|
backupSql += generateInsertStatement(table, row);
|
|
}
|
|
backupSql += '\n';
|
|
}
|
|
}
|
|
|
|
// 3. Backup data from indirectly related tables (e.g., shopping_list_items)
|
|
const shoppingListsRes = await client.query(
|
|
'SELECT shopping_list_id FROM public.shopping_lists WHERE user_id = $1',
|
|
[userId],
|
|
);
|
|
const shoppingListIds = shoppingListsRes.rows.map((r) => r.shopping_list_id);
|
|
|
|
if (shoppingListIds.length > 0) {
|
|
const itemsRes = await client.query(
|
|
'SELECT * FROM public.shopping_list_items WHERE shopping_list_id = ANY($1)',
|
|
[shoppingListIds],
|
|
);
|
|
if (itemsRes.rows.length > 0) {
|
|
backupSql += '-- Data for table: shopping_list_items\n';
|
|
for (const row of itemsRes.rows) {
|
|
backupSql += generateInsertStatement('shopping_list_items', row);
|
|
}
|
|
backupSql += '\n';
|
|
}
|
|
}
|
|
|
|
// (Add similar logic for other indirectly related tables like recipe_ingredients, planned_meals, etc.)
|
|
|
|
// 4. Write the final SQL to the output file
|
|
await fs.writeFile(outputFile, backupSql);
|
|
logger.info(`✅ Successfully created backup for user ${userEmail} at ${outputFile}`);
|
|
} catch (error) {
|
|
logger.error({ error }, 'Failed to create user backup.');
|
|
process.exit(1);
|
|
} finally {
|
|
client?.release();
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
main();
|