import { app } from 'electron' import { join } from 'path' import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync } from 'fs' /** * One shared persistence layer for the hand-rolled JSON array stores (history, * error log, templates, sources, media-items). Replaces the per-module * read/parse/write copies (SIMP1) and fixes three data-safety blockers: * * - R1 -- atomic writes: write a temp file then `rename` over the target, so a * crash / power-cut mid-write can never truncate the real file. (`electron-store`, * used for settings, is already atomic; these stores were not.) * - R2 -- corruption is no longer silent data loss: on a parse error the bad file * is copied aside (`.corrupt-`) before we fall back to empty, so a * single bad byte can't wipe the user's history/sources from their perspective. * - R3 -- an in-memory cache + debounced batched writes: the previous code * re-read + re-parsed the entire (multi-MB) file and rewrote it synchronously * on *every* download completion (≈O(n²) over a large channel, blocking the * main thread). Reads now hit the cache; writes coalesce into one atomic flush. */ // Debounce window for batched writes. Long enough to coalesce a burst (e.g. many // items enqueued/marked at once), short enough that data lands on disk promptly. const FLUSH_DELAY_MS = 400 /** * Read + validate a JSON array from `path`. Invalid rows are dropped. On a parse * error the file is backed up to `.corrupt-` before returning [] * so corruption is recoverable rather than a silent wipe (R2). A missing file is * simply [] (first run), with no backup. */ export function readJsonArraySafe(path: string, isValid: (o: unknown) => o is T): T[] { let raw: string try { if (!existsSync(path)) return [] raw = readFileSync(path, 'utf8') } catch { return [] } try { const data = JSON.parse(raw) return Array.isArray(data) ? data.filter(isValid) : [] } catch { try { copyFileSync(path, `${path}.corrupt-${Date.now()}`) } catch { /* best-effort -- if we can't back it up, still don't crash the read */ } return [] } } /** * Atomically write `value` as JSON to `path`: serialise to `.tmp`, then * `rename` it over the target (an atomic operation on the same volume), so a * crash mid-write leaves either the old file or the new one -- never a truncated * one (R1). `pretty` indents for human-readable files; pass false for large * machine-only stores to avoid inflating size/write time (R8). Best-effort: a * read-only data dir just means nothing is persisted. */ export function writeJsonAtomic(path: string, value: unknown, pretty = true): void { const tmp = `${path}.tmp` try { writeFileSync(tmp, pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)) renameSync(tmp, path) } catch { /* best-effort; e.g. a read-only data dir */ } } export interface JsonStore { /** All rows, validated; cached after the first read. */ read(): T[] /** Replace all rows (capped), update the cache, and schedule an atomic flush. Returns the stored rows. */ write(items: T[]): T[] /** Force any pending debounced write to disk synchronously (e.g. on quit). */ flush(): void } // Every store registers itself so app-quit can flush pending writes in one call. const registry: JsonStore[] = [] /** * Create a cached, atomic JSON array store backed by `/`. * `cap` bounds the row count (pass Infinity for none). The path is resolved * lazily so importing this module never touches `app` before it's ready. */ export function createJsonStore( filename: string, isValid: (o: unknown) => o is T, cap: number, // Pretty-print by default (small, hand-inspectable files); pass false for a // large machine-only store to keep writes compact (R8). pretty = true ): JsonStore { let cache: T[] | null = null let timer: ReturnType | null = null let dirty = false const filePath = (): string => join(app.getPath('userData'), filename) function read(): T[] { if (cache === null) cache = readJsonArraySafe(filePath(), isValid) return cache } function flush(): void { if (timer) { clearTimeout(timer) timer = null } if (!dirty) return dirty = false writeJsonAtomic(filePath(), cache ?? [], pretty) } function write(items: T[]): T[] { // Avoid an extra copy in the common under-cap case so a per-completion write // stays O(1) rather than re-slicing the whole list each time (R3). cache = items.length > cap ? items.slice(0, cap) : items dirty = true if (timer) clearTimeout(timer) timer = setTimeout(flush, FLUSH_DELAY_MS) return cache } const store: JsonStore = { read, write, flush } registry.push(store as JsonStore) return store } /** Synchronously flush every store's pending write -- call on app quit. */ export function flushAllStores(): void { for (const s of registry) s.flush() }