import { connect } from '@tursodatabase/database'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import process from 'node:process'; const backupArgument = process.argv[2]; if (!backupArgument) { console.error('Usage: npm run db:restore -- '); process.exit(2); } const backupPath = resolve(backupArgument); const databasePath = resolve( process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', ); const backup = JSON.parse(await readFile(backupPath, 'utf8')); if ( backup?.format !== 'shd-build-database-backup' || backup?.version !== 1 || !Array.isArray(backup.tables) ) { throw new Error( 'The selected file is not a supported SHD.build database backup.', ); } const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; const database = await connect(databasePath); // Restores are deliberately limited to empty databases. This prevents an // accidental merge from overwriting contributor-entered records. for (const table of backup.tables) { const existing = await ( await database.prepare( `SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`, ) ).get(); if (Number(existing?.count) > 0) { throw new Error( `Restore refused: table "${table.name}" is not empty. Use a new migrated database.`, ); } } let restoredRows = 0; const attributeTable = backup.tables.find( (table) => table.name === 'stat_attributes', ); const legacySkillChipProfiles = attributeTable?.rows.some( (row) => 'skill_chip_min' in row || 'skill_chip_max' in row, ); const legacySkillChipRanges = new Map( (attributeTable?.rows ?? []).map((row) => [ String(row.id), { min: row.skill_chip_min, max: row.skill_chip_max }, ]), ); const restoredLegacySecondaries = new Set(); await database.exec('BEGIN'); try { for (const table of backup.tables) { for (const row of table.rows) { if ( legacySkillChipProfiles && table.name === 'skill_chip_primary_options' ) continue; const restoredRow = { ...row }; if (legacySkillChipProfiles && table.name === 'stat_attributes') { delete restoredRow.skill_chip_min; delete restoredRow.skill_chip_max; } if ( legacySkillChipProfiles && table.name === 'skill_chip_secondary_options' ) { delete restoredRow.skill_chip_set_id; const statId = String(restoredRow.stat_attribute_id ?? ''); if (restoredLegacySecondaries.has(statId)) continue; restoredLegacySecondaries.add(statId); } const columns = Object.keys(restoredRow); if (!columns.length) continue; const columnSql = columns.map(quoteIdentifier).join(', '); const placeholders = columns.map(() => '?').join(', '); const insert = await database.prepare( `INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`, ); await insert.run(...columns.map((column) => restoredRow[column])); restoredRows++; } } if (legacySkillChipProfiles) { const primaryAttributes = await ( await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN ( 'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity' )`) ).all(); if ( primaryAttributes.length !== 4 || primaryAttributes.some((attribute) => Number(attribute.active) !== 1) ) throw new Error( 'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.', ); for (const attribute of primaryAttributes) { const statId = String(attribute.id); await ( await database.prepare( 'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)', ) ).run(statId); const range = legacySkillChipRanges.get(statId); if (!range || range.min === null || range.max === null) continue; for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end']) await ( await database.prepare(`INSERT INTO skill_chip_primary_quality_values (stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`) ).run(statId, quality, range.min, range.max); } for (const statId of restoredLegacySecondaries) { const range = legacySkillChipRanges.get(statId); if (!range || range.min === null || range.max === null) continue; for (const quality of ['Specialized', 'Superior', 'High-end']) await ( await database.prepare(`INSERT INTO skill_chip_secondary_quality_values (stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`) ).run(statId, quality, range.min, range.max); } } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } console.log(`Restored ${restoredRows} rows into ${databasePath}.`); console.log(`Source backup: ${backupPath}`);