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>
129 lines
4.9 KiB
TypeScript
129 lines
4.9 KiB
TypeScript
/**
|
|
* Restores a backup pulled from the master onto THIS machine — the other half
|
|
* of lib/backup-sync.ts, for replacing a dead VPS.
|
|
*
|
|
* Applying a backup overwrites this machine's settings, broker credentials,
|
|
* instruments and assignments, and — the point of the exercise — adopts the
|
|
* backed-up instance's uuid and name, so the master's roster, payouts and card
|
|
* links carry straight on. The master already handles adoption: a hello
|
|
* presenting a known uuid lands on that person's roster row.
|
|
*
|
|
* Run it before first boot via scripts/seed-settings.js --restore-from (which
|
|
* duplicates this apply logic — keep them in sync), or from the settings UI
|
|
* via POST /api/backup/restore. Either way the old machine must be stopped
|
|
* first: two live instances presenting one uuid fight over the socket.
|
|
*/
|
|
|
|
import {
|
|
setSetting,
|
|
getSetting,
|
|
setCredentials,
|
|
setInstrumentEnabled,
|
|
restoreAssignment,
|
|
runInTransaction,
|
|
} from './db';
|
|
import type { BackupPayload } from './backup-sync';
|
|
|
|
/** Mirror of the master's listing rows (lib/backups.ts BackupMeta). */
|
|
export interface BackupMeta {
|
|
id: string;
|
|
name: string;
|
|
savedAt: string;
|
|
sizeBytes: number;
|
|
}
|
|
|
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
|
|
// Runtime bookkeeping describing the machine the backup was taken on, not
|
|
// this one. Kept in sync with the skip-set in scripts/seed-settings.js.
|
|
const RESTORE_SKIP_KEYS = new Set([
|
|
'runner_heartbeat',
|
|
'catalog_last_sync_at',
|
|
'catalog_last_error',
|
|
'backup_last_push_at',
|
|
'backup_last_error',
|
|
]);
|
|
|
|
function authedFetch(masterUrl: string, token: string, path: string) {
|
|
const url = new URL(path, masterUrl);
|
|
return fetch(url, {
|
|
headers: { authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
});
|
|
}
|
|
|
|
export async function fetchBackupList(masterUrl: string, token: string): Promise<BackupMeta[]> {
|
|
const res = await authedFetch(masterUrl, token, '/api/instances/backup');
|
|
if (!res.ok) {
|
|
const detail = await res.json().then((b) => b?.error).catch(() => '');
|
|
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
const body = await res.json();
|
|
if (!Array.isArray(body?.backups)) throw new Error('unrecognized backup list');
|
|
return body.backups as BackupMeta[];
|
|
}
|
|
|
|
export async function fetchBackup(masterUrl: string, token: string, id: string): Promise<BackupPayload> {
|
|
const res = await authedFetch(masterUrl, token, `/api/instances/backup/${encodeURIComponent(id)}`);
|
|
if (!res.ok) {
|
|
const detail = await res.json().then((b) => b?.error).catch(() => '');
|
|
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
const payload = await res.json() as BackupPayload;
|
|
if (payload?.v !== 1 || typeof payload.settings !== 'object' || payload.settings === null) {
|
|
throw new Error('unrecognized backup payload');
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export interface RestoreCounts {
|
|
settings: number;
|
|
credentials: number;
|
|
instruments: number;
|
|
assignments: number;
|
|
}
|
|
|
|
/**
|
|
* Overwrites this machine's configuration with the backup, including
|
|
* instance_id and instance_name — the identity adoption. Callers restart the
|
|
* process afterwards: live platform clients, the master link's hello and any
|
|
* cached settings all predate the restore.
|
|
*/
|
|
export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: boolean } = {}): RestoreCounts {
|
|
// Defaults to keeping this machine's master url: the machine is talking to
|
|
// that master right now, while an old backup may point at a dead host.
|
|
const keepMasterUrl = opts.keepMasterUrl ?? true;
|
|
const ownMasterUrl = getSetting('master_dashboard_url');
|
|
const ownToken = getSetting('master_backup_token');
|
|
|
|
const counts: RestoreCounts = { settings: 0, credentials: 0, instruments: 0, assignments: 0 };
|
|
|
|
runInTransaction(() => {
|
|
for (const [key, value] of Object.entries(payload.settings)) {
|
|
if (RESTORE_SKIP_KEYS.has(key)) continue;
|
|
if (typeof value !== 'string') continue;
|
|
setSetting(key, value);
|
|
counts.settings++;
|
|
}
|
|
if (keepMasterUrl && ownMasterUrl) setSetting('master_dashboard_url', ownMasterUrl);
|
|
if (ownToken) setSetting('master_backup_token', ownToken);
|
|
|
|
for (const c of payload.credentials ?? []) {
|
|
setCredentials(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? '');
|
|
counts.credentials++;
|
|
}
|
|
for (const i of payload.instruments ?? []) {
|
|
setInstrumentEnabled(i.symbol, i.enabled);
|
|
counts.instruments++;
|
|
}
|
|
// Rules dates travel with them: a restored machine must not date
|
|
// its accounts afresh, or old ones would leave their kept rules.
|
|
for (const a of payload.assignments ?? []) {
|
|
restoreAssignment(a);
|
|
counts.assignments++;
|
|
}
|
|
});
|
|
|
|
return counts;
|
|
}
|