import { connect } from '@tursodatabase/database'; import { spawnSync } from 'node:child_process'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; import process from 'node:process'; import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs'; const snapshotArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--')); if (!snapshotArgument || !process.argv.includes('--confirm-replace-isac-data')) { console.error('Usage: node scripts/isac-data-import.mjs --confirm-replace-isac-data'); process.exit(2); } const snapshotPath = resolve(snapshotArgument); const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8')); if (snapshot?.format !== 'shd-build-isac-data' || snapshot?.version !== 1 || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons)) { throw new Error('The selected file is not a supported SHD.build ISAC data snapshot.'); } const receivedNames = snapshot.tables.map((table) => table.name); if (receivedNames.length !== ISAC_DATA_TABLES.length || ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name)) { throw new Error('The ISAC snapshot table manifest is incomplete or out of order.'); } for (const icon of snapshot.icons) { if (typeof icon.storageKey !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey) || typeof icon.contentBase64 !== 'string') { throw new Error('The ISAC snapshot contains an invalid uploaded icon.'); } } const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); const backupPath = resolve(`./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`); const isacBackupPath = resolve(`./data/backups/isac-data-before-import-${timestamp}.json`); for (const [script, output] of [ ['scripts/db-backup.mjs', backupPath], ['scripts/isac-data-export.mjs', isacBackupPath] ]) { const result = spawnSync(process.execPath, [script, output], { cwd: process.cwd(), stdio: 'inherit', env: process.env }); if (result.error) throw result.error; if (result.status !== 0) throw new Error(`Safety backup failed in ${script}; no ISAC data was changed.`); } const database = await connect(databasePath); let importedRows = 0; await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { for (const name of [...ISAC_DATA_TABLES].reverse()) { await database.exec(`DELETE FROM ${quoteIdentifier(name)}`); } for (const table of snapshot.tables) { for (const row of table.rows) { const columns = Object.keys(row); if (!columns.length) continue; const insert = await database.prepare( `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${columns.map(() => '?').join(', ')})` ); await insert.run(...columns.map((column) => row[column])); importedRows++; } } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); await mkdir(iconDirectory, { recursive: true }); for (const icon of snapshot.icons) { await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 }); } console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded icons from ${snapshotPath}.`); console.log(`Full pre-import backup: ${backupPath}`); console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);