110 lines
4.9 KiB
JavaScript
110 lines
4.9 KiB
JavaScript
import { connect } from '@tursodatabase/database';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { join, resolve } from 'node:path';
|
|
import process from 'node:process';
|
|
|
|
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
|
|
if (!bundleArgument || !process.argv.includes('--confirm-empty-staging-replace')
|
|
|| process.env.ALLOW_STAGING_BOOTSTRAP !== 'true') {
|
|
console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:');
|
|
console.error('node scripts/stage-bootstrap-import.mjs <bundle.json> --confirm-empty-staging-replace');
|
|
process.exit(2);
|
|
}
|
|
|
|
const bundlePath = resolve(bundleArgument);
|
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
|
if (bundle?.format !== 'shd-build-stage-bootstrap' || bundle?.version !== 1
|
|
|| bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1
|
|
|| !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars)
|
|
|| !Array.isArray(bundle.files?.gameIcons)) {
|
|
throw new Error('The selected file is not a supported SHD.build staging bootstrap bundle.');
|
|
}
|
|
for (const file of bundle.files.avatars) {
|
|
if (typeof file.name !== 'string' || !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') {
|
|
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
|
}
|
|
}
|
|
for (const file of bundle.files.gameIcons) {
|
|
if (typeof file.name !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') {
|
|
throw new Error('The bootstrap bundle contains an invalid game-data icon file.');
|
|
}
|
|
}
|
|
|
|
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-staging.db');
|
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
|
cwd: process.cwd(),
|
|
stdio: 'inherit',
|
|
env: process.env
|
|
});
|
|
if (safetyBackup.error) throw safetyBackup.error;
|
|
if (safetyBackup.status !== 0) throw new Error('Staging safety backup failed; the target was not changed.');
|
|
|
|
const database = await connect(databasePath);
|
|
for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) {
|
|
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get();
|
|
if (Number(row?.count ?? 0) > 0) {
|
|
throw new Error(`Staging bootstrap refused: target table "${tableName}" is not empty.`);
|
|
}
|
|
}
|
|
|
|
const targetTables = await (await database.prepare(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
ORDER BY rowid
|
|
`)).all();
|
|
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
|
const sourceNames = new Set(bundle.database.tables.map((table) => String(table.name)));
|
|
for (const row of targetTables) {
|
|
if (!sourceNames.has(String(row.name))) {
|
|
throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`);
|
|
}
|
|
}
|
|
const sourceOnlyTables = bundle.database.tables
|
|
.map((table) => String(table.name))
|
|
.filter((name) => !targetNames.has(name));
|
|
if (sourceOnlyTables.length) {
|
|
console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`);
|
|
}
|
|
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
|
let restoredRows = 0;
|
|
await database.exec('PRAGMA foreign_keys=OFF');
|
|
await database.exec('BEGIN');
|
|
try {
|
|
for (const row of [...targetTables].reverse()) {
|
|
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
|
}
|
|
for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) {
|
|
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]));
|
|
restoredRows++;
|
|
}
|
|
}
|
|
await database.exec('COMMIT');
|
|
} catch (error) {
|
|
await database.exec('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
await database.exec('PRAGMA foreign_keys=ON');
|
|
}
|
|
|
|
const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars');
|
|
const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
|
await mkdir(avatarDirectory, { recursive: true });
|
|
await mkdir(gameIconDirectory, { recursive: true });
|
|
for (const file of bundle.files.avatars) {
|
|
await writeFile(join(avatarDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
|
|
}
|
|
for (const file of bundle.files.gameIcons) {
|
|
await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
|
|
}
|
|
|
|
console.log(`Bootstrapped staging with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`);
|
|
console.log(`Source bundle: ${bundlePath}`);
|