feat(sync): add sync-hosts store, NDJSON --json mode, and --check to subminer sync

This commit is contained in:
2026-07-11 17:45:27 -07:00
parent 8797719a09
commit 0a3f76c0a8
16 changed files with 979 additions and 27 deletions
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSyncProgressLine } from './sync-events';
test('parseSyncProgressLine parses known event types', () => {
assert.deepEqual(
parseSyncProgressLine('{"type":"stage","stage":"snapshot-local","message":"Snapshotting"}'),
{ type: 'stage', stage: 'snapshot-local', message: 'Snapshotting' },
);
const summaryLine = JSON.stringify({
type: 'merge-summary',
target: 'local',
summary: { sessionsMerged: 2 },
});
const parsed = parseSyncProgressLine(summaryLine);
assert.equal(parsed?.type, 'merge-summary');
assert.deepEqual(
parseSyncProgressLine('{"type":"result","ok":true,"error":null}'),
{ type: 'result', ok: true, error: null },
);
});
test('parseSyncProgressLine rejects non-events and garbage', () => {
assert.equal(parseSyncProgressLine('plain text output'), null);
assert.equal(parseSyncProgressLine('{"no":"type"}'), null);
assert.equal(parseSyncProgressLine('{"type":"unknown-event"}'), null);
assert.equal(parseSyncProgressLine(''), null);
assert.equal(parseSyncProgressLine('42'), null);
});
+68
View File
@@ -0,0 +1,68 @@
// NDJSON progress protocol emitted by `subminer sync --json` and consumed by
// the sync UI's launcher client. Keep this file free of Electron/bun imports —
// it is shared between the launcher (bun) and the app main process (node).
export interface SyncMergeSummary {
sessionsMerged: number;
sessionsAlreadyPresent: number;
activeSessionsSkipped: number;
animeAdded: number;
videosAdded: number;
wordsAdded: number;
kanjiAdded: number;
subtitleLinesAdded: number;
telemetryRowsAdded: number;
eventsAdded: number;
excludedWordsAdded: number;
dailyRollupsCopied: number;
monthlyRollupsCopied: number;
rollupGroupsRecomputed: number;
}
export type SyncStage =
| 'snapshot-local'
| 'snapshot-remote'
| 'download'
| 'upload'
| 'merge-local'
| 'merge-remote';
export type SyncProgressEvent =
| { type: 'stage'; stage: SyncStage; message: string }
| { type: 'merge-summary'; target: 'local' | 'remote'; summary: SyncMergeSummary }
| { type: 'remote-output'; text: string }
| { type: 'snapshot-created'; path: string }
| {
type: 'check-result';
host: string;
sshOk: boolean;
remoteCommand: string | null;
remoteVersion: string | null;
ok: boolean;
error: string | null;
}
| { type: 'result'; ok: boolean; error: string | null };
const EVENT_TYPES = new Set([
'stage',
'merge-summary',
'remote-output',
'snapshot-created',
'check-result',
'result',
]);
export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
const trimmed = line.trim();
if (!trimmed.startsWith('{')) return null;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const type = (parsed as { type?: unknown }).type;
if (typeof type !== 'string' || !EVENT_TYPES.has(type)) return null;
return parsed as SyncProgressEvent;
}
+182
View File
@@ -0,0 +1,182 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
createDefaultSyncHostsState,
getSyncHostsPath,
isValidSyncHost,
normalizeSyncHostsState,
readSyncHostsState,
recordSyncResult,
removeSyncHost,
upsertSyncHost,
writeSyncHostsState,
} from './sync-hosts-store';
function withTempDir(fn: (dir: string) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-hosts-test-'));
try {
fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('createDefaultSyncHostsState returns empty v1 state', () => {
assert.deepEqual(createDefaultSyncHostsState(), {
version: 1,
autoSyncIntervalMinutes: 30,
hosts: [],
});
});
test('isValidSyncHost accepts ssh destinations and rejects unsafe values', () => {
assert.equal(isValidSyncHost('desktop'), true);
assert.equal(isValidSyncHost('user@10.0.0.5'), true);
assert.equal(isValidSyncHost('my-alias.local'), true);
assert.equal(isValidSyncHost(''), false);
assert.equal(isValidSyncHost(' '), false);
assert.equal(isValidSyncHost('-oProxyCommand=evil'), false);
assert.equal(isValidSyncHost('host with spaces'), false);
});
test('upsertSyncHost adds a new host with defaults and preserves fields on update', () => {
const state = createDefaultSyncHostsState();
const added = upsertSyncHost(state, { host: 'user@laptop' }, 1000);
assert.deepEqual(added.hosts, [
{
host: 'user@laptop',
label: null,
direction: 'both',
autoSync: false,
addedAtMs: 1000,
lastSyncAtMs: null,
lastSyncStatus: null,
lastSyncDetail: null,
},
]);
// original state untouched
assert.equal(state.hosts.length, 0);
const synced = recordSyncResult(added, 'user@laptop', {
atMs: 2000,
status: 'success',
detail: 'Sessions merged: 3',
});
const updated = upsertSyncHost(synced, { host: 'user@laptop', direction: 'push' }, 3000);
assert.equal(updated.hosts.length, 1);
const entry = updated.hosts[0]!;
assert.equal(entry.direction, 'push');
assert.equal(entry.addedAtMs, 1000);
assert.equal(entry.lastSyncAtMs, 2000);
assert.equal(entry.lastSyncStatus, 'success');
assert.equal(entry.lastSyncDetail, 'Sessions merged: 3');
});
test('upsertSyncHost trims host and throws on invalid host', () => {
const state = upsertSyncHost(createDefaultSyncHostsState(), { host: ' desktop ' }, 1);
assert.equal(state.hosts[0]!.host, 'desktop');
assert.throws(() => upsertSyncHost(state, { host: '-bad' }, 2));
});
test('removeSyncHost removes only the matching host', () => {
let state = createDefaultSyncHostsState();
state = upsertSyncHost(state, { host: 'a' }, 1);
state = upsertSyncHost(state, { host: 'b' }, 2);
const removed = removeSyncHost(state, 'a');
assert.deepEqual(
removed.hosts.map((entry) => entry.host),
['b'],
);
assert.equal(removeSyncHost(removed, 'missing').hosts.length, 1);
});
test('recordSyncResult upserts unknown hosts so CLI syncs are remembered', () => {
const state = recordSyncResult(createDefaultSyncHostsState(), 'user@server', {
atMs: 5000,
status: 'error',
detail: 'Remote merge failed',
});
assert.equal(state.hosts.length, 1);
const entry = state.hosts[0]!;
assert.equal(entry.host, 'user@server');
assert.equal(entry.addedAtMs, 5000);
assert.equal(entry.lastSyncAtMs, 5000);
assert.equal(entry.lastSyncStatus, 'error');
assert.equal(entry.lastSyncDetail, 'Remote merge failed');
});
test('normalizeSyncHostsState drops invalid entries and falls back to defaults', () => {
assert.deepEqual(normalizeSyncHostsState(null), createDefaultSyncHostsState());
assert.deepEqual(normalizeSyncHostsState('junk'), createDefaultSyncHostsState());
assert.deepEqual(normalizeSyncHostsState({ version: 99 }), createDefaultSyncHostsState());
const normalized = normalizeSyncHostsState({
version: 1,
autoSyncIntervalMinutes: 15,
hosts: [
{
host: 'desktop',
label: 'Desktop PC',
direction: 'pull',
autoSync: true,
addedAtMs: 10,
lastSyncAtMs: 20,
lastSyncStatus: 'success',
lastSyncDetail: 'ok',
},
{ host: '-bad', direction: 'both', autoSync: false, addedAtMs: 1 },
{ host: 'dupe', direction: 'sideways', autoSync: false, addedAtMs: 1 },
'not-an-object',
],
});
assert.equal(normalized.autoSyncIntervalMinutes, 15);
assert.deepEqual(normalized.hosts, [
{
host: 'desktop',
label: 'Desktop PC',
direction: 'pull',
autoSync: true,
addedAtMs: 10,
lastSyncAtMs: 20,
lastSyncStatus: 'success',
lastSyncDetail: 'ok',
},
{
host: 'dupe',
label: null,
direction: 'both',
autoSync: false,
addedAtMs: 1,
lastSyncAtMs: null,
lastSyncStatus: null,
lastSyncDetail: null,
},
]);
});
test('read/write round-trips state and tolerates missing or corrupt files', () => {
withTempDir((root) => {
const filePath = getSyncHostsPath(root);
assert.equal(filePath, path.join(root, 'sync-hosts.json'));
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
fs.writeFileSync(filePath, '{corrupt');
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
let state = createDefaultSyncHostsState();
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
writeSyncHostsState(filePath, state);
assert.deepEqual(readSyncHostsState(filePath), state);
});
});
test('writeSyncHostsState creates parent directories', () => {
withTempDir((root) => {
const filePath = path.join(root, 'nested', 'dir', 'sync-hosts.json');
writeSyncHostsState(filePath, createDefaultSyncHostsState());
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
});
});
+195
View File
@@ -0,0 +1,195 @@
import fs from 'node:fs';
import path from 'node:path';
export type SyncDirection = 'both' | 'push' | 'pull';
export type SyncResultStatus = 'success' | 'error';
export interface SyncHostEntry {
host: string;
label: string | null;
direction: SyncDirection;
autoSync: boolean;
addedAtMs: number;
lastSyncAtMs: number | null;
lastSyncStatus: SyncResultStatus | null;
lastSyncDetail: string | null;
}
export interface SyncHostsState {
version: 1;
autoSyncIntervalMinutes: number;
hosts: SyncHostEntry[];
}
export interface SyncHostUpdate {
host: string;
label?: string | null;
direction?: SyncDirection;
autoSync?: boolean;
}
export interface SyncResultUpdate {
atMs: number;
status: SyncResultStatus;
detail: string | null;
}
const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES = 30;
export function createDefaultSyncHostsState(): SyncHostsState {
return {
version: 1,
autoSyncIntervalMinutes: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES,
hosts: [],
};
}
// Hosts are passed as a single spawn argument to ssh, so the only dangerous
// shapes are option injection (leading "-") and multi-token values.
export function isValidSyncHost(host: string): boolean {
const trimmed = host.trim();
return trimmed.length > 0 && !trimmed.startsWith('-') && !/\s/.test(trimmed);
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isDirection(value: unknown): value is SyncDirection {
return value === 'both' || value === 'push' || value === 'pull';
}
function normalizeHostEntry(value: unknown): SyncHostEntry | null {
const record = asObject(value);
if (!record) return null;
const host = typeof record.host === 'string' ? record.host.trim() : '';
if (!isValidSyncHost(host)) return null;
return {
host,
label: typeof record.label === 'string' && record.label.trim() ? record.label.trim() : null,
direction: isDirection(record.direction) ? record.direction : 'both',
autoSync: record.autoSync === true,
addedAtMs:
typeof record.addedAtMs === 'number' && Number.isFinite(record.addedAtMs)
? record.addedAtMs
: 0,
lastSyncAtMs:
typeof record.lastSyncAtMs === 'number' && Number.isFinite(record.lastSyncAtMs)
? record.lastSyncAtMs
: null,
lastSyncStatus:
record.lastSyncStatus === 'success' || record.lastSyncStatus === 'error'
? record.lastSyncStatus
: null,
lastSyncDetail: typeof record.lastSyncDetail === 'string' ? record.lastSyncDetail : null,
};
}
export function normalizeSyncHostsState(value: unknown): SyncHostsState {
const record = asObject(value);
if (!record || record.version !== 1) return createDefaultSyncHostsState();
const hosts = Array.isArray(record.hosts)
? record.hosts.map(normalizeHostEntry).filter((entry): entry is SyncHostEntry => entry !== null)
: [];
const interval = record.autoSyncIntervalMinutes;
return {
version: 1,
autoSyncIntervalMinutes:
typeof interval === 'number' && Number.isFinite(interval) && interval >= 1
? Math.floor(interval)
: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES,
hosts,
};
}
export function upsertSyncHost(
state: SyncHostsState,
update: SyncHostUpdate,
nowMs: number,
): SyncHostsState {
const host = update.host.trim();
if (!isValidSyncHost(host)) {
throw new Error(`Invalid sync host: ${JSON.stringify(update.host)}`);
}
const existing = state.hosts.find((entry) => entry.host === host);
const base: SyncHostEntry = existing ?? {
host,
label: null,
direction: 'both',
autoSync: false,
addedAtMs: nowMs,
lastSyncAtMs: null,
lastSyncStatus: null,
lastSyncDetail: null,
};
const next: SyncHostEntry = {
...base,
label: update.label !== undefined ? update.label : base.label,
direction: update.direction ?? base.direction,
autoSync: update.autoSync ?? base.autoSync,
};
const hosts = existing
? state.hosts.map((entry) => (entry.host === host ? next : entry))
: [...state.hosts, next];
return { ...state, hosts };
}
export function removeSyncHost(state: SyncHostsState, host: string): SyncHostsState {
return { ...state, hosts: state.hosts.filter((entry) => entry.host !== host.trim()) };
}
export function recordSyncResult(
state: SyncHostsState,
host: string,
result: SyncResultUpdate,
): SyncHostsState {
const upserted = upsertSyncHost(state, { host }, result.atMs);
const hosts = upserted.hosts.map((entry) =>
entry.host === host.trim()
? {
...entry,
lastSyncAtMs: result.atMs,
lastSyncStatus: result.status,
lastSyncDetail: result.detail,
}
: entry,
);
return { ...upserted, hosts };
}
export function getSyncHostsPath(configDir: string): string {
return path.join(configDir, 'sync-hosts.json');
}
export function readSyncHostsState(
filePath: string,
deps?: {
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
},
): SyncHostsState {
const existsSync = deps?.existsSync ?? fs.existsSync;
const readFileSync = deps?.readFileSync ?? fs.readFileSync;
if (!existsSync(filePath)) return createDefaultSyncHostsState();
try {
return normalizeSyncHostsState(JSON.parse(readFileSync(filePath, 'utf8')));
} catch {
return createDefaultSyncHostsState();
}
}
export function writeSyncHostsState(
filePath: string,
state: SyncHostsState,
deps?: {
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
},
): void {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}