lib/db.ts switched to WAL at module load before setting busy_timeout.
next build imports it from several workers while the live server holds
the same file, and a journal-mode change needs the file to itself — so
the pragma failed instantly with SQLITE_BUSY and took the whole build
down ("Failed to collect page data"), blocking updates on any box whose
database was still in rollback mode. The timeout is now set first and
the WAL switch is wrapped: a held file keeps its current mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
7.0 KiB
JavaScript
179 lines
7.0 KiB
JavaScript
/**
|
|
* Writes the reporter settings into the instance database before first launch,
|
|
* and optionally restores a full backup from the master.
|
|
*
|
|
* lib/db.ts seeds its keys with INSERT OR IGNORE, so values written here
|
|
* survive the app's own startup seeding. Must be run from the project root
|
|
* unless AUTOFIRMER_DB points elsewhere — lib/db.ts opens the database at
|
|
* process.cwd() by default, and this script must hit the same file.
|
|
*
|
|
* node scripts/seed-settings.js <master_dashboard_url> <instance_name> \
|
|
* [--token <backup_token>] [--restore-from <instance_uuid>]
|
|
*
|
|
* --restore-from pulls that instance's encrypted backup from the master
|
|
* (authenticated by --token) and applies it — settings, firm credentials,
|
|
* instruments, account assignments — before the app has ever booted, so the
|
|
* very first hello to the master already presents the adopted uuid. The
|
|
* backup's instance_name wins over the CLI argument.
|
|
*/
|
|
const path = require('path');
|
|
const { mkdirSync } = require('fs');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const args = process.argv.slice(2);
|
|
const positional = [];
|
|
let token = '';
|
|
let restoreFrom = '';
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--token') token = args[++i] ?? '';
|
|
else if (args[i] === '--restore-from') restoreFrom = args[++i] ?? '';
|
|
else positional.push(args[i]);
|
|
}
|
|
const [url, name] = positional;
|
|
|
|
const DB_PATH = process.env.AUTOFIRMER_DB
|
|
? path.resolve(process.env.AUTOFIRMER_DB)
|
|
: path.join(process.cwd(), 'autotrader.sqlite');
|
|
|
|
mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
const db = new Database(DB_PATH);
|
|
db.pragma('busy_timeout = 5000');
|
|
try {
|
|
db.pragma('journal_mode = WAL');
|
|
} catch {
|
|
// Another connection holds the file; keep its journal mode.
|
|
}
|
|
|
|
// Mirrors the schema in lib/db.ts for every table a restore touches. The app
|
|
// has usually never run at this point, so the tables may not exist yet; the
|
|
// shapes must be kept in sync with lib/db.ts by hand.
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS firm_credentials (
|
|
firm_id TEXT PRIMARY KEY,
|
|
firm_name TEXT NOT NULL,
|
|
username TEXT NOT NULL,
|
|
password TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS instruments (
|
|
symbol TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 1
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS account_assignments (
|
|
account_id INTEGER PRIMARY KEY,
|
|
firm_id TEXT NOT NULL,
|
|
account_type_id TEXT,
|
|
manual INTEGER NOT NULL DEFAULT 0,
|
|
resolved_at TEXT NOT NULL
|
|
);
|
|
`);
|
|
try {
|
|
db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''");
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
|
|
const upsert = db.prepare(
|
|
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
|
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
|
);
|
|
|
|
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
|
|
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
|
|
if (token) { upsert.run('master_backup_token', token); console.log(' master_backup_token = (set)'); }
|
|
|
|
// Runtime bookkeeping keys that describe the machine the backup was taken on,
|
|
// not the one being provisioned. Kept in sync with lib/backup-restore.ts.
|
|
const RESTORE_SKIP_KEYS = new Set([
|
|
'runner_heartbeat',
|
|
'catalog_last_sync_at',
|
|
'catalog_last_error',
|
|
'backup_last_push_at',
|
|
'backup_last_error',
|
|
]);
|
|
|
|
async function restore() {
|
|
if (!restoreFrom) return;
|
|
if (!url) { console.error('--restore-from needs the master url argument'); process.exit(1); }
|
|
if (!token) { console.error('--restore-from needs --token'); process.exit(1); }
|
|
|
|
const endpoint = new URL(`/api/instances/backup/${restoreFrom}`, url);
|
|
console.log(` restoring from ${endpoint}`);
|
|
const res = await fetch(endpoint, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!res.ok) {
|
|
console.error(` restore failed: master answered ${res.status} ${await res.text().catch(() => '')}`);
|
|
process.exit(1);
|
|
}
|
|
const payload = await res.json();
|
|
if (payload?.v !== 1 || typeof payload.settings !== 'object') {
|
|
console.error(' restore failed: unrecognized backup payload');
|
|
process.exit(1);
|
|
}
|
|
|
|
const setCred = db.prepare(`
|
|
INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(firm_id) DO UPDATE SET
|
|
firm_name = excluded.firm_name,
|
|
username = excluded.username,
|
|
password = excluded.password,
|
|
api_key = excluded.api_key,
|
|
updated_at = excluded.updated_at
|
|
`);
|
|
const setInstr = db.prepare(
|
|
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
|
|
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
|
|
);
|
|
const setAssign = db.prepare(`
|
|
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(account_id) DO UPDATE SET
|
|
firm_id = excluded.firm_id,
|
|
account_type_id = excluded.account_type_id,
|
|
manual = excluded.manual,
|
|
resolved_at = excluded.resolved_at
|
|
`);
|
|
|
|
db.transaction(() => {
|
|
for (const [key, value] of Object.entries(payload.settings)) {
|
|
if (RESTORE_SKIP_KEYS.has(key)) continue;
|
|
if (typeof value !== 'string') continue;
|
|
upsert.run(key, value);
|
|
}
|
|
// The freshly seeded master url wins over whatever the backup recorded:
|
|
// the operator just typed it, and an old backup may point at a dead host.
|
|
if (url) upsert.run('master_dashboard_url', url);
|
|
if (token) upsert.run('master_backup_token', token);
|
|
|
|
for (const c of payload.credentials ?? []) {
|
|
setCred.run(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? '', c.updated_at ?? new Date().toISOString());
|
|
}
|
|
for (const i of payload.instruments ?? []) {
|
|
setInstr.run(i.symbol, i.enabled ? 1 : 0);
|
|
}
|
|
for (const a of payload.assignments ?? []) {
|
|
setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString());
|
|
}
|
|
})();
|
|
|
|
console.log(` restored: ${Object.keys(payload.settings).length} settings, ` +
|
|
`${(payload.credentials ?? []).length} credentials, ` +
|
|
`${(payload.instruments ?? []).length} instruments, ` +
|
|
`${(payload.assignments ?? []).length} assignments`);
|
|
console.log(` adopted identity: "${payload.settings.instance_name ?? ''}" (${payload.settings.instance_id ?? 'no uuid'})`);
|
|
}
|
|
|
|
restore()
|
|
.catch((err) => { console.error(` restore failed: ${err.message}`); process.exit(1); })
|
|
.finally(() => db.close());
|