refactor(sync-ui): split runtime into focused modules

- Extract sync-ui-runtime.ts into sync-ui-hosts-state, sync-ui-ipc-handlers, sync-ui-run-lifecycle, sync-ui-snapshots
- Reject sync --ui combined with --remote-cmd or --db in cli-parser-builder
This commit is contained in:
2026-07-12 23:04:59 -07:00
parent 247549fcfe
commit 89e5ac60f6
7 changed files with 416 additions and 285 deletions
+17 -1
View File
@@ -87,7 +87,10 @@ test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => {
const makeTemp = parseCliPrograms(['sync', '--make-temp'], 'subminer');
assert.deepEqual(makeTemp.invocations.syncCliTokens, ['--make-temp']);
const removeTemp = parseCliPrograms(['sync', '--remove-temp', '/tmp/subminer-sync-x'], 'subminer');
const removeTemp = parseCliPrograms(
['sync', '--remove-temp', '/tmp/subminer-sync-x'],
'subminer',
);
assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']);
});
@@ -112,3 +115,16 @@ test('parseCliPrograms captures sync --ui', () => {
/--ui cannot be combined/,
);
});
test('parseCliPrograms rejects sync --ui with --remote-cmd', () => {
assert.throws(
() => parseCliPrograms(['sync', '--ui', '--remote-cmd', '/opt/SubMiner.AppImage'], 'subminer'),
{ message: 'Sync --ui cannot be combined with other sync options.' },
);
});
test('parseCliPrograms rejects sync --ui with --db', () => {
assert.throws(() => parseCliPrograms(['sync', '--ui', '--db', '/tmp/db.sqlite'], 'subminer'), {
message: 'Sync --ui cannot be combined with other sync options.',
});
});
+2
View File
@@ -340,6 +340,8 @@ export function parseCliPrograms(
check ||
makeTemp ||
removeTemp ||
options.remoteCmd !== undefined ||
options.db !== undefined ||
options.json === true ||
options.force === true
) {
+23
View File
@@ -0,0 +1,23 @@
import {
readSyncHostsState,
writeSyncHostsState,
type SyncHostsState,
} from '../../shared/sync/sync-hosts-store';
interface SyncUiHostsStateDeps {
hostsFilePath: string;
broadcastStateChanged: () => void;
}
export function createSyncUiHostsState(deps: SyncUiHostsStateDeps) {
function readState(): SyncHostsState {
return readSyncHostsState(deps.hostsFilePath);
}
function writeState(state: SyncHostsState): void {
writeSyncHostsState(deps.hostsFilePath, state);
deps.broadcastStateChanged();
}
return { readState, writeState };
}
+63
View File
@@ -0,0 +1,63 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { removeSyncHost, upsertSyncHost } from '../../shared/sync/sync-hosts-store';
import type {
SyncUiHostUpdateRequest,
SyncUiRunRequest,
SyncUiSnapshot,
SyncUiStartResult,
} from '../../types/sync-ui';
interface SyncUiIpcHandlersDeps {
ipcMain: {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
nowMs: () => number;
revealPath: (targetPath: string) => void;
pickSnapshotFile: () => Promise<string | null>;
getSnapshot: () => SyncUiSnapshot;
readState: () => SyncUiSnapshot['hosts'];
writeState: (state: SyncUiSnapshot['hosts']) => void;
runHostSync: (request: SyncUiRunRequest) => SyncUiStartResult;
cancelRun: () => boolean;
checkHost: (host: string) => unknown;
createSnapshot: () => SyncUiStartResult;
mergeSnapshotFile: (filePath: string, force?: boolean) => SyncUiStartResult;
deleteSnapshot: (filePath: string) => void;
}
export function registerSyncUiIpcHandlers(deps: SyncUiIpcHandlersDeps): void {
const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => deps.getSnapshot());
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
deps.writeState(
upsertSyncHost(deps.readState(), update as SyncUiHostUpdateRequest, deps.nowMs()),
);
});
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
deps.writeState(removeSyncHost(deps.readState(), String(host)));
});
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
const value = Number(minutes);
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
}
deps.writeState({ ...deps.readState(), autoSyncIntervalMinutes: Math.floor(value) });
});
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
deps.runHostSync(request as SyncUiRunRequest),
);
deps.ipcMain.handle(channels.syncUiCancelRun, () => deps.cancelRun());
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => deps.checkHost(String(host)));
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => deps.createSnapshot());
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
deps.mergeSnapshotFile(String(filePath), force === true),
);
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
deps.deleteSnapshot(String(filePath));
});
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
deps.revealPath(String(filePath));
return true;
});
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
}
+183
View File
@@ -0,0 +1,183 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { isValidSyncHost } from '../../shared/sync/sync-hosts-store';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import type {
SyncUiCheckResult,
SyncUiRunKind,
SyncUiRunState,
SyncUiStartResult,
} from '../../types/sync-ui';
import {
runSyncLauncher,
type SyncLauncherRunHandle,
type SyncLauncherRunResult,
} from './sync-launcher-client';
interface ActiveRun {
id: number;
kind: SyncUiRunKind;
host: string | null;
handle: SyncLauncherRunHandle;
resultSeen: boolean;
completion?: Promise<void>;
}
interface SyncUiRunLifecycleDeps {
runLauncher: typeof runSyncLauncher;
resolveLauncherCommand: () => string[];
log?: (message: string) => void;
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
sendToWindow: (channel: string, payload?: unknown) => void;
broadcastStateChanged: () => void;
}
export function createSyncUiRunLifecycle(deps: SyncUiRunLifecycleDeps) {
let runCounter = 0;
let currentRun: ActiveRun | null = null;
function launchRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: {
notify?: boolean;
timeoutMs?: number;
emitProgress?: boolean;
onEvent?: (event: SyncProgressEvent) => void;
} = {},
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
if (currentRun) {
return {
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
done: null,
};
}
const emitProgress = options.emitProgress ?? true;
runCounter += 1;
const runId = runCounter;
const run: ActiveRun = {
id: runId,
kind,
host,
resultSeen: false,
handle: deps.runLauncher({
command: deps.resolveLauncherCommand(),
args,
timeoutMs: options.timeoutMs,
onEvent: (event: SyncProgressEvent) => {
if (event.type === 'result') run.resultSeen = true;
options.onEvent?.(event);
if (emitProgress) {
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
}
},
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
}),
};
currentRun = run;
run.completion = run.handle.done
.then((result: SyncLauncherRunResult) => {
if (currentRun?.id === runId) currentRun = null;
if (emitProgress && !run.resultSeen) {
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
runId,
kind,
host,
event: { type: 'result', ok: result.ok, error: result.error },
});
}
deps.broadcastStateChanged();
if (options.notify && deps.notify) {
const target = host ?? 'local database';
deps.notify(
result.ok
? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' }
: {
title: 'Sync failed',
body: `${target}: ${result.error ?? 'unknown error'}`,
variant: 'error',
},
);
}
})
.catch((error) => {
deps.log?.(
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
);
});
return { start: { started: true, runId, reason: null }, done: run.handle.done };
}
function startRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: { notify?: boolean } = {},
): SyncUiStartResult {
return launchRun(kind, host, args, options).start;
}
function checkHost(host: string): Promise<SyncUiCheckResult> {
const trimmed = host.trim();
const failed = (error: string): SyncUiCheckResult => ({
host: trimmed,
sshOk: false,
remoteCommand: null,
remoteVersion: null,
ok: false,
error,
});
if (!isValidSyncHost(trimmed)) {
return Promise.resolve(failed(`Invalid sync host: ${host}`));
}
let checkResult: SyncUiCheckResult | null = null;
const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], {
timeoutMs: 30_000,
emitProgress: false,
onEvent: (event) => {
if (event.type === 'check-result') {
checkResult = {
host: event.host,
sshOk: event.sshOk,
remoteCommand: event.remoteCommand,
remoteVersion: event.remoteVersion,
ok: event.ok,
error: event.error,
};
}
},
});
if (!start.started || !done) {
return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.'));
}
return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.'));
}
function cancelRun(): boolean {
if (!currentRun) return false;
currentRun.handle.cancel();
return true;
}
async function shutdown(): Promise<void> {
const run = currentRun;
if (!run) return;
run.handle.cancel();
await (run.completion ?? run.handle.done.then(() => undefined));
}
function runState(): SyncUiRunState {
return currentRun
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
: { running: false, runId: null, kind: null, host: null };
}
return {
startRun,
checkHost,
cancelRun,
shutdown,
runState,
isRunning: () => currentRun !== null,
};
}
+54 -284
View File
@@ -1,28 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
isValidSyncHost,
readSyncHostsState,
removeSyncHost,
upsertSyncHost,
writeSyncHostsState,
type SyncDirection,
type SyncHostsState,
} from '../../shared/sync/sync-hosts-store';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import type {
SyncUiCheckResult,
SyncUiHostUpdateRequest,
SyncUiRunKind,
SyncUiRunRequest,
SyncUiRunState,
SyncUiSnapshot,
SyncUiSnapshotFile,
SyncUiStartResult,
} from '../../types/sync-ui';
import type { SyncLauncherRunHandle, SyncLauncherRunResult } from './sync-launcher-client';
import type { SyncUiRunRequest, SyncUiSnapshot } from '../../types/sync-ui';
import { createSyncUiHostsState } from './sync-ui-hosts-state';
import { registerSyncUiIpcHandlers } from './sync-ui-ipc-handlers';
import { createSyncUiRunLifecycle } from './sync-ui-run-lifecycle';
import { runSyncLauncher } from './sync-launcher-client';
import { createSyncUiSnapshots } from './sync-ui-snapshots';
interface SyncUiWindowLike {
isDestroyed(): boolean;
@@ -46,27 +33,7 @@ export interface SyncUiRuntimeDeps {
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
}
interface ActiveRun {
id: number;
kind: SyncUiRunKind;
host: string | null;
handle: SyncLauncherRunHandle;
resultSeen: boolean;
completion?: Promise<void>;
}
// Milliseconds are part of the stamp so two snapshots taken in the same second
// do not land on the same path and silently overwrite each other.
function formatSnapshotName(nowMs: number): string {
const iso = new Date(nowMs).toISOString();
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`;
}
export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
let runCounter = 0;
let currentRun: ActiveRun | null = null;
function sendToWindow(channel: string, payload?: unknown): void {
const window = deps.getWindow();
if (!window || window.isDestroyed()) return;
@@ -77,146 +44,35 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
sendToWindow(IPC_CHANNELS.event.syncUiStateChanged);
}
function readState(): SyncHostsState {
return readSyncHostsState(deps.hostsFilePath);
}
function writeState(state: SyncHostsState): void {
writeSyncHostsState(deps.hostsFilePath, state);
broadcastStateChanged();
}
function listSnapshots(): SyncUiSnapshotFile[] {
let names: string[];
try {
names = fs.readdirSync(deps.snapshotsDir);
} catch {
return [];
}
const files: SyncUiSnapshotFile[] = [];
for (const name of names) {
if (!name.endsWith('.sqlite')) continue;
const filePath = path.join(deps.snapshotsDir, name);
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) continue;
files.push({
path: filePath,
name,
sizeBytes: stat.size,
modifiedAtMs: stat.mtimeMs,
});
} catch {
// file disappeared between readdir and stat
}
}
files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
return files;
}
function runState(): SyncUiRunState {
return currentRun
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
: { running: false, runId: null, kind: null, host: null };
}
const hostsState = createSyncUiHostsState({
hostsFilePath: deps.hostsFilePath,
broadcastStateChanged,
});
const runLifecycle = createSyncUiRunLifecycle({
runLauncher: deps.runLauncher,
resolveLauncherCommand: deps.resolveLauncherCommand,
log: deps.log,
notify: deps.notify,
sendToWindow,
broadcastStateChanged,
});
const snapshots = createSyncUiSnapshots({
snapshotsDir: deps.snapshotsDir,
nowMs: deps.nowMs,
startRun: runLifecycle.startRun,
broadcastStateChanged,
});
function getSnapshot(): SyncUiSnapshot {
return {
dbPath: deps.getDbPath(),
hosts: readState(),
hosts: hostsState.readState(),
snapshotsDir: deps.snapshotsDir,
snapshots: listSnapshots(),
run: runState(),
snapshots: snapshots.listSnapshots(),
run: runLifecycle.runState(),
};
}
// Shared run lifecycle: single-run mutex, launcher spawn, cleanup + state
// broadcast on completion. `emitProgress: false` runs silently (no renderer
// progress events); `onEvent` taps every NDJSON event either way.
function launchRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: {
notify?: boolean;
timeoutMs?: number;
emitProgress?: boolean;
onEvent?: (event: SyncProgressEvent) => void;
} = {},
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
if (currentRun) {
return {
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
done: null,
};
}
const emitProgress = options.emitProgress ?? true;
runCounter += 1;
const runId = runCounter;
const run: ActiveRun = {
id: runId,
kind,
host,
resultSeen: false,
handle: deps.runLauncher({
command: deps.resolveLauncherCommand(),
args,
timeoutMs: options.timeoutMs,
onEvent: (event: SyncProgressEvent) => {
if (event.type === 'result') run.resultSeen = true;
options.onEvent?.(event);
if (emitProgress) {
sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
}
},
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
}),
};
currentRun = run;
run.completion = run.handle.done
.then((result: SyncLauncherRunResult) => {
if (currentRun?.id === runId) currentRun = null;
// If the launcher died without emitting a result event (spawn failure,
// kill), synthesize one so the renderer can settle its progress view.
if (emitProgress && !run.resultSeen) {
sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
runId,
kind,
host,
event: { type: 'result', ok: result.ok, error: result.error },
});
}
broadcastStateChanged();
if (options.notify && deps.notify) {
const target = host ?? 'local database';
deps.notify(
result.ok
? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' }
: {
title: 'Sync failed',
body: `${target}: ${result.error ?? 'unknown error'}`,
variant: 'error',
},
);
}
})
.catch((error) => {
deps.log?.(
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
);
});
return { start: { started: true, runId, reason: null }, done: run.handle.done };
}
function startRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: { notify?: boolean } = {},
): SyncUiStartResult {
return launchRun(kind, host, args, options).start;
}
function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) {
const host = request.host.trim();
if (!isValidSyncHost(host)) {
@@ -228,132 +84,46 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
if (direction === 'pull') args.push('--pull');
if (request.force) args.push('--force');
args.push('--json');
const result = startRun('host-sync', host, args, options);
const result = runLifecycle.startRun('host-sync', host, args, options);
if (result.started) {
// Remember the host (and the direction used) before the launcher's own
// bookkeeping lands, so it shows up in the UI immediately.
writeState(upsertSyncHost(readState(), { host, direction: request.direction }, deps.nowMs()));
// Persist before launcher bookkeeping lands so the host appears immediately.
hostsState.writeState(
upsertSyncHost(
hostsState.readState(),
{ host, direction: request.direction },
deps.nowMs(),
),
);
}
return result;
}
function checkHost(host: string): Promise<SyncUiCheckResult> {
const trimmed = host.trim();
const failed = (error: string): SyncUiCheckResult => ({
host: trimmed,
sshOk: false,
remoteCommand: null,
remoteVersion: null,
ok: false,
error,
});
if (!isValidSyncHost(trimmed)) {
return Promise.resolve(failed(`Invalid sync host: ${host}`));
}
let checkResult: SyncUiCheckResult | null = null;
const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], {
timeoutMs: 30_000,
emitProgress: false,
onEvent: (event) => {
if (event.type === 'check-result') {
checkResult = {
host: event.host,
sshOk: event.sshOk,
remoteCommand: event.remoteCommand,
remoteVersion: event.remoteVersion,
ok: event.ok,
error: event.error,
};
}
},
});
if (!start.started || !done) {
return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.'));
}
return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.'));
}
function createSnapshot(): SyncUiStartResult {
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
return startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']);
}
function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult {
if (!fs.existsSync(filePath)) {
return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` };
}
const args = ['sync', '--merge', filePath];
if (force) args.push('--force');
args.push('--json');
return startRun('merge', null, args);
}
function deleteSnapshot(filePath: string): void {
const resolved = path.resolve(filePath);
const dir = path.resolve(deps.snapshotsDir);
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
throw new Error('Refusing to delete a file outside the snapshots directory.');
}
fs.rmSync(resolved, { force: true });
broadcastStateChanged();
}
function cancelRun(): boolean {
if (!currentRun) return false;
currentRun.handle.cancel();
return true;
}
async function shutdown(): Promise<void> {
const run = currentRun;
if (!run) return;
run.handle.cancel();
await (run.completion ?? run.handle.done.then(() => undefined));
}
function registerHandlers(): void {
const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs()));
registerSyncUiIpcHandlers({
ipcMain: deps.ipcMain,
nowMs: deps.nowMs,
revealPath: deps.revealPath,
pickSnapshotFile: deps.pickSnapshotFile,
getSnapshot,
readState: hostsState.readState,
writeState: hostsState.writeState,
runHostSync,
cancelRun: runLifecycle.cancelRun,
checkHost: runLifecycle.checkHost,
createSnapshot: snapshots.createSnapshot,
mergeSnapshotFile: snapshots.mergeSnapshotFile,
deleteSnapshot: snapshots.deleteSnapshot,
});
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
writeState(removeSyncHost(readState(), String(host)));
});
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
const value = Number(minutes);
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
}
writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
});
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
runHostSync(request as SyncUiRunRequest),
);
deps.ipcMain.handle(channels.syncUiCancelRun, () => cancelRun());
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => checkHost(String(host)));
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => createSnapshot());
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
mergeSnapshotFile(String(filePath), force === true),
);
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
deleteSnapshot(String(filePath));
});
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
deps.revealPath(String(filePath));
return true;
});
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
}
return {
registerHandlers,
getSnapshot,
readState,
readState: hostsState.readState,
runHostSync,
checkHost,
cancelRun,
shutdown,
isRunning: () => currentRun !== null,
checkHost: runLifecycle.checkHost,
cancelRun: runLifecycle.cancelRun,
shutdown: runLifecycle.shutdown,
isRunning: runLifecycle.isRunning,
};
}
+74
View File
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import type { SyncUiSnapshotFile, SyncUiStartResult } from '../../types/sync-ui';
interface SyncUiSnapshotsDeps {
snapshotsDir: string;
nowMs: () => number;
startRun: (kind: 'snapshot' | 'merge', host: null, args: string[]) => SyncUiStartResult;
broadcastStateChanged: () => void;
}
function formatSnapshotName(nowMs: number): string {
// Milliseconds prevent snapshots taken in the same second from overwriting each other.
const iso = new Date(nowMs).toISOString();
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`;
}
export function createSyncUiSnapshots(deps: SyncUiSnapshotsDeps) {
function listSnapshots(): SyncUiSnapshotFile[] {
let names: string[];
try {
names = fs.readdirSync(deps.snapshotsDir);
} catch {
return [];
}
const files: SyncUiSnapshotFile[] = [];
for (const name of names) {
if (!name.endsWith('.sqlite')) continue;
const filePath = path.join(deps.snapshotsDir, name);
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) continue;
files.push({
path: filePath,
name,
sizeBytes: stat.size,
modifiedAtMs: stat.mtimeMs,
});
} catch {
// file disappeared between readdir and stat
}
}
files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
return files;
}
function createSnapshot(): SyncUiStartResult {
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
return deps.startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']);
}
function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult {
if (!fs.existsSync(filePath)) {
return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` };
}
const args = ['sync', '--merge', filePath];
if (force) args.push('--force');
args.push('--json');
return deps.startRun('merge', null, args);
}
function deleteSnapshot(filePath: string): void {
const resolved = path.resolve(filePath);
const dir = path.resolve(deps.snapshotsDir);
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
throw new Error('Refusing to delete a file outside the snapshots directory.');
}
fs.rmSync(resolved, { force: true });
deps.broadcastStateChanged();
}
return { listSnapshots, createSnapshot, mergeSnapshotFile, deleteSnapshot };
}