When the master changes an account type with "New accounts only", it now serves the type's old rules as kept rule sets (firm.keptAccountRules), each for accounts dated inside [openedAt, closedAt). This build applies them: - getAccountConfig, the one rules lookup trading reads, puts a kept set's rule fields over the live type when the account's rules date falls in its window. Id, label, prefix, size and the projection fields stay the live type's, so every report still names the live type. - account_assignments gains rules_date and rules_date_manual. A new row is dated when the machine first sees the account, on the master's clock when this one runs behind and never before the catalog's last change. Auto dates come down to the broker's evidence: the end of the first fund day in Chicago, or a DXtrade registration instant. Evidence is applied before an account's first trade. Rows from before the migration get its time; unpinning keeps the row and its date. Backups carry both columns. - An account on a kept set stays on its type while its prefix and cached size still match, so a tie-break flip can't move it off its old rules. - Kept entries parse through the same normalizer as live types (now normalizeAccountType in lib/kept-rules.ts); a malformed one rejects the sync like a malformed type. The cached catalog records the parser that stored it, and a cache from an older parser is fetched in full rather than trusted with ?since= (this also brings in maxConcurrentAccounts). - Reports add keptRuleIds per firm and live counts per kept set per type. - The dashboard row and the account page show the rules date next to the account name, with an "old rules" chip saying what differs. Clicking the date edits it inline (PUT /api/accounts/:id/rules-date), previewing which rules the date lands on. Also: the master's per-firm cap on accounts traded at once (maxConcurrentAccounts), applied on top of the machine's own max concurrent when a batch is drawn and in copy trading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
9.9 KiB
JavaScript
234 lines
9.9 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,
|
|
rules_date TEXT,
|
|
rules_date_manual INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
`);
|
|
try {
|
|
db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''");
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
// The rules-date migration, as lib/db.ts runs it — including its backfill:
|
|
// adding the column here means lib/db.ts's own ALTER never runs on this file,
|
|
// and rows the restore doesn't overwrite must not stay undated. Dated as
|
|
// lib/db.ts undatedRowsDate dates them: now, never before the catalog's last
|
|
// change (a clock running behind must not date a row into an old rule set).
|
|
function undatedRowsDate() {
|
|
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get();
|
|
const floor = row && typeof row.value === 'string' ? Date.parse(row.value) : NaN;
|
|
return new Date(Math.max(Date.now(), Number.isFinite(floor) ? floor : 0)).toISOString();
|
|
}
|
|
// lib/kept-rules.ts sortableIso: the only rules dates stored are plain
|
|
// four-digit-year ISO strings from 2000 on, so string order is time order.
|
|
function sortableIso(ms) {
|
|
if (!Number.isFinite(ms) || ms < Date.UTC(2000, 0, 1) || Math.abs(ms) > 8.64e15) return null;
|
|
const iso = new Date(ms).toISOString();
|
|
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(iso) ? iso : null;
|
|
}
|
|
try {
|
|
db.transaction(() => {
|
|
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
|
|
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
|
}).immediate();
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
try {
|
|
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
|
|
} 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'
|
|
);
|
|
// lib/db.ts restoreAssignment, by hand: the backup's rules date (and how
|
|
// it was set) wins; a backup from an older build has none, so an existing
|
|
// row keeps its own and a new one is dated at the restore.
|
|
const setAssignDated = db.prepare(`
|
|
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
|
|
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,
|
|
rules_date = excluded.rules_date,
|
|
rules_date_manual = excluded.rules_date_manual
|
|
`);
|
|
const setAssignUndated = db.prepare(`
|
|
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
|
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,
|
|
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
|
`);
|
|
|
|
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 ?? []) {
|
|
const now = new Date().toISOString();
|
|
const resolvedAt = a.resolved_at ?? now;
|
|
const rulesDate = typeof a.rules_date === 'string' ? sortableIso(Date.parse(a.rules_date)) : null;
|
|
if (rulesDate) {
|
|
setAssignDated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt,
|
|
rulesDate, a.rules_date_manual === 1 ? 1 : 0);
|
|
} else {
|
|
setAssignUndated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, now);
|
|
}
|
|
}
|
|
})();
|
|
|
|
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());
|