feat(launcher): add sync command to merge stats and history between machines over SSH

subminer sync <host> exchanges VACUUM INTO snapshots over ssh/scp and each
side merges the other's data as an insert-only union keyed on session UUIDs,
video keys, series title keys, and word/kanji identity. Lifetime totals and
daily/monthly rollups are applied incrementally so pre-retention history
survives, remote-only historical rollups are copied, and re-syncing is
idempotent. sync --snapshot/--merge expose the underlying steps for manual
transfers; a pid-file/mpv-socket guard refuses to run while SubMiner may be
writing the database and schema-version mismatches abort the merge.
This commit is contained in:
2026-07-08 23:28:10 -07:00
parent cdb1475a54
commit be31f96f02
21 changed files with 2762 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fail, log } from '../log.js';
import { resolveImmersionDbPath } from '../history-db.js';
import {
createDbSnapshot,
findLiveStatsDaemonPid,
formatMergeSummary,
mergeSnapshotIntoDb,
} from '../sync/sync-db.js';
import { resolveRemoteSubminerCommand, runScp, runSsh, shellQuote } from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js';
import type { LauncherCommandContext } from './context.js';
function resolveDbPath(context: LauncherCommandContext): string {
const override = context.args.syncDbPath.trim();
return override ? resolvePathMaybe(override) : resolveImmersionDbPath();
}
function isTrackerDb(dbPath: string): boolean {
const trackerDbPath = resolveImmersionDbPath();
try {
return fs.realpathSync(dbPath) === fs.realpathSync(trackerDbPath);
} catch {
return dbPath === trackerDbPath;
}
}
function ensureTrackerQuiescent(context: LauncherCommandContext, dbPath: string): void {
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
// elsewhere needs no guard.
if (!isTrackerDb(dbPath)) return;
const daemonPid = findLiveStatsDaemonPid(dbPath);
if (daemonPid !== null) {
fail(
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
);
}
if (context.mpvSocketPath && fs.existsSync(context.mpvSocketPath)) {
fail(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
}
}
function runSnapshotMode(context: LauncherCommandContext, dbPath: string): void {
const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
createDbSnapshot(dbPath, outPath);
console.log(outPath);
}
function runMergeMode(context: LauncherCommandContext, dbPath: string): void {
ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
const summary = mergeSnapshotIntoDb(dbPath, snapshotPath);
console.log(formatMergeSummary(summary));
}
function cleanupRemote(host: string, remoteTmpDir: string): void {
if (!remoteTmpDir.startsWith('/tmp/')) return;
runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
}
function runHostSync(context: LauncherCommandContext, dbPath: string): void {
const { args } = context;
const host = args.syncHost;
ensureTrackerQuiescent(context, dbPath);
const remoteCmd = resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
const localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
let remoteTmpDir = '';
try {
const mktemp = runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
remoteTmpDir = mktemp.stdout.trim();
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
fail(`Could not create a temporary directory on ${host}.`);
}
const forceFlag = args.syncForce ? ' --force' : '';
console.log(`Snapshotting local database (${dbPath})...`);
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
createDbSnapshot(dbPath, localSnapshot);
console.log(`Snapshotting ${host}...`);
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
const snapshotRun = runSsh(
host,
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
);
if (snapshotRun.status !== 0) {
fail(`Remote snapshot failed on ${host}.`);
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
runScp(localSnapshot, `${host}:${incomingSnapshot}`);
console.log(`\nMerging ${host} -> local:`);
const summary = mergeSnapshotIntoDb(dbPath, pulledSnapshot);
console.log(formatMergeSummary(summary));
console.log(`\nMerging local -> ${host}:`);
const mergeRun = runSsh(
host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
);
process.stdout.write(mergeRun.stdout);
if (mergeRun.status !== 0) {
fail(
`Remote merge failed on ${host}. The local database was updated; re-run "subminer sync ${host}" once the remote issue is fixed.`,
);
}
console.log('\nSync complete.');
} finally {
fs.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
try {
cleanupRemote(host, remoteTmpDir);
} catch {
// best effort
}
}
}
}
export function runSyncCommand(context: LauncherCommandContext): boolean {
const { args } = context;
if (!args.sync) return false;
const dbPath = resolveDbPath(context);
if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath);
} else if (args.syncMergePath) {
runMergeMode(context, dbPath);
} else if (args.syncHost) {
runHostSync(context, dbPath);
} else {
fail('sync requires a host, --snapshot <file>, or --merge <file>.');
}
return true;
}