Step 6 migration: add JSON backup and restore flows
This commit is contained in:
267
api/server.js
267
api/server.js
@@ -113,6 +113,15 @@ async function runMigrations() {
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS backup_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reason TEXT NOT NULL,
|
||||
dump_payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
@@ -238,6 +247,241 @@ async function getCatalogFull() {
|
||||
return { brands, gamesByConsole };
|
||||
}
|
||||
|
||||
async function exportCatalogDumpWithClient(client) {
|
||||
const brandsResult = await client.query(`
|
||||
SELECT id::int AS id, name
|
||||
FROM brands
|
||||
ORDER BY name ASC;
|
||||
`);
|
||||
|
||||
const consolesResult = await client.query(`
|
||||
SELECT
|
||||
c.id::int AS id,
|
||||
b.name AS brand,
|
||||
c.name
|
||||
FROM consoles c
|
||||
JOIN brands b ON b.id = c.brand_id
|
||||
ORDER BY b.name ASC, c.name ASC;
|
||||
`);
|
||||
|
||||
const gamesResult = await client.query(`
|
||||
SELECT
|
||||
g.id::text AS id,
|
||||
b.name AS brand,
|
||||
c.name AS console_name,
|
||||
g.title,
|
||||
g.genre,
|
||||
g.publisher,
|
||||
g.release_year,
|
||||
g.estimated_value,
|
||||
g.loaned_to,
|
||||
g.created_at,
|
||||
g.updated_at
|
||||
FROM games g
|
||||
JOIN consoles c ON c.id = g.console_id
|
||||
JOIN brands b ON b.id = c.brand_id
|
||||
ORDER BY g.created_at DESC;
|
||||
`);
|
||||
|
||||
return {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
source: serviceName,
|
||||
summary: {
|
||||
brands: brandsResult.rows.length,
|
||||
consoles: consolesResult.rows.length,
|
||||
games: gamesResult.rows.length,
|
||||
},
|
||||
catalog: {
|
||||
brands: brandsResult.rows.map((row) => ({
|
||||
name: row.name,
|
||||
})),
|
||||
consoles: consolesResult.rows.map((row) => ({
|
||||
brand: row.brand,
|
||||
name: row.name,
|
||||
})),
|
||||
games: gamesResult.rows.map((row) => ({
|
||||
id: row.id,
|
||||
brand: row.brand,
|
||||
consoleName: row.console_name,
|
||||
title: row.title,
|
||||
genre: row.genre || "",
|
||||
publisher: row.publisher || "",
|
||||
year: row.release_year || null,
|
||||
value: row.estimated_value != null ? Number(row.estimated_value) : null,
|
||||
loanedTo: row.loaned_to || "",
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function exportCatalogDump() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
return await exportCatalogDumpWithClient(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function validateBackupDump(dump) {
|
||||
if (!dump || typeof dump !== "object") {
|
||||
throw new Error("Invalid dump payload");
|
||||
}
|
||||
|
||||
if (!dump.catalog || typeof dump.catalog !== "object") {
|
||||
throw new Error("Invalid dump catalog");
|
||||
}
|
||||
|
||||
const { brands, consoles, games } = dump.catalog;
|
||||
if (!Array.isArray(brands) || !Array.isArray(consoles) || !Array.isArray(games)) {
|
||||
throw new Error("Invalid dump arrays");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDateOrNull(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
async function restoreCatalogDump(mode, dump) {
|
||||
validateBackupDump(dump);
|
||||
const restoreMode = mode === "replace" ? "replace" : "merge";
|
||||
|
||||
const client = await pool.connect();
|
||||
let insertedConsoles = 0;
|
||||
let insertedGames = 0;
|
||||
let preRestoreSnapshotId = null;
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
if (restoreMode === "replace") {
|
||||
const snapshotDump = await exportCatalogDumpWithClient(client);
|
||||
const snapshotResult = await client.query(
|
||||
`
|
||||
INSERT INTO backup_snapshots(reason, dump_payload)
|
||||
VALUES ($1, $2::jsonb)
|
||||
RETURNING id::int AS id;
|
||||
`,
|
||||
["pre-restore-replace", JSON.stringify(snapshotDump)],
|
||||
);
|
||||
preRestoreSnapshotId = snapshotResult.rows[0].id;
|
||||
|
||||
await client.query("DELETE FROM games;");
|
||||
await client.query("DELETE FROM consoles;");
|
||||
await client.query("DELETE FROM brands;");
|
||||
}
|
||||
|
||||
for (const brandEntry of dump.catalog.brands) {
|
||||
const brand = normalizeText(brandEntry && brandEntry.name).toUpperCase();
|
||||
if (!brand) {
|
||||
continue;
|
||||
}
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO brands(name)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
`,
|
||||
[brand],
|
||||
);
|
||||
}
|
||||
|
||||
for (const consoleEntry of dump.catalog.consoles) {
|
||||
const brand = normalizeText(consoleEntry && consoleEntry.brand).toUpperCase();
|
||||
const consoleName = normalizeText(consoleEntry && consoleEntry.name);
|
||||
if (!brand || !consoleName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingConsole = await client.query(
|
||||
`
|
||||
SELECT c.id
|
||||
FROM consoles c
|
||||
JOIN brands b ON b.id = c.brand_id
|
||||
WHERE b.name = $1 AND c.name = $2
|
||||
LIMIT 1;
|
||||
`,
|
||||
[brand, consoleName],
|
||||
);
|
||||
|
||||
const ensured = await ensureConsole(client, brand, consoleName);
|
||||
if (ensured && ensured.consoleId && !existingConsole.rowCount) {
|
||||
insertedConsoles += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const gameEntry of dump.catalog.games) {
|
||||
const brand = normalizeText(gameEntry && gameEntry.brand).toUpperCase();
|
||||
const consoleName = normalizeText(gameEntry && gameEntry.consoleName);
|
||||
const title = normalizeText(gameEntry && gameEntry.title);
|
||||
if (!brand || !consoleName || !title) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ensured = await ensureConsole(client, brand, consoleName);
|
||||
const consoleId = ensured.consoleId;
|
||||
const year = gameEntry && gameEntry.year != null && gameEntry.year !== "" ? Number(gameEntry.year) : null;
|
||||
|
||||
const dedupeResult = await client.query(
|
||||
`
|
||||
SELECT 1
|
||||
FROM games
|
||||
WHERE console_id = $1
|
||||
AND LOWER(title) = LOWER($2)
|
||||
AND COALESCE(release_year, 0) = COALESCE($3, 0)
|
||||
LIMIT 1;
|
||||
`,
|
||||
[consoleId, title, year],
|
||||
);
|
||||
|
||||
if (dedupeResult.rowCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const genre = normalizeText(gameEntry && gameEntry.genre) || null;
|
||||
const publisher = normalizeText(gameEntry && gameEntry.publisher) || null;
|
||||
const loanedTo = normalizeText(gameEntry && gameEntry.loanedTo) || null;
|
||||
const value = gameEntry && gameEntry.value != null && gameEntry.value !== "" ? Number(gameEntry.value) : null;
|
||||
const createdAt = normalizeDateOrNull(gameEntry && gameEntry.createdAt);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO games(
|
||||
console_id, title, genre, publisher, release_year, estimated_value, loaned_to, created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8::timestamptz, NOW()));
|
||||
`,
|
||||
[consoleId, title, genre, publisher, year, value, loanedTo, createdAt],
|
||||
);
|
||||
|
||||
insertedGames += 1;
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return {
|
||||
mode: restoreMode,
|
||||
insertedConsoles,
|
||||
insertedGames,
|
||||
preRestoreSnapshotId,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function createConsole(brand, consoleName) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -564,6 +808,29 @@ async function handleRequest(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/backup/export") {
|
||||
try {
|
||||
const dump = await exportCatalogDump();
|
||||
sendJson(response, 200, dump);
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/backup/restore") {
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
const mode = body && body.mode ? body.mode : "merge";
|
||||
const dump = body && body.dump ? body.dump : body;
|
||||
const result = await restoreCatalogDump(mode, dump);
|
||||
sendJson(response, 200, { status: "ok", ...result });
|
||||
} catch (error) {
|
||||
sendJson(response, 400, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const gameIdMatch = url.pathname.match(/^\/api\/catalog\/games\/([0-9a-fA-F-]+)$/);
|
||||
if (request.method === "PUT" && gameIdMatch) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user