140 lines
4.9 KiB
JavaScript
140 lines
4.9 KiB
JavaScript
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 -- <backup-file>');
|
|
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}`);
|