Something went wrong. Try again.
A simple DAV sync application / cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247import { mkdirSync } from "node:fs";import { dirname } from "node:path";import { DatabaseSync } from "node:sqlite";import type { ActionRecord, RunRecord } from "../shared.ts";import type { Action, LastSeen } from "./types.ts";
const SCHEMA = `CREATE TABLE IF NOT EXISTS entries ( folder TEXT NOT NULL, path TEXT NOT NULL, kind TEXT NOT NULL, size INTEGER NOT NULL, sha256 TEXT, head TEXT, local_mtime REAL NOT NULL, local_ctime REAL, local_url TEXT NOT NULL, local_id TEXT, remote_mtime REAL NOT NULL, remote_ctime REAL, remote_etag TEXT, remote_url TEXT NOT NULL, remote_id TEXT, synced_at REAL NOT NULL, PRIMARY KEY (folder, path)) WITHOUT ROWID;CREATE INDEX IF NOT EXISTS entries_sha256 ON entries (folder, sha256);
CREATE TABLE IF NOT EXISTS runs ( id INTEGER PRIMARY KEY, folder TEXT NOT NULL, started REAL NOT NULL, finished REAL, status TEXT NOT NULL, -- running | ok | partial | failed | planned summary TEXT, -- JSON: counts per action type error TEXT);CREATE INDEX IF NOT EXISTS runs_folder ON runs (folder, started DESC);
CREATE TABLE IF NOT EXISTS actions ( id INTEGER PRIMARY KEY, run INTEGER NOT NULL REFERENCES runs (id) ON DELETE CASCADE, seq INTEGER NOT NULL, type TEXT NOT NULL, side TEXT, path TEXT NOT NULL, target TEXT, reason TEXT NOT NULL, status TEXT NOT NULL, -- planned | done | skipped | failed detail TEXT, at REAL NOT NULL);CREATE INDEX IF NOT EXISTS actions_run ON actions (run, seq);`;
/** * The last-seen state of every folder, plus a log of runs and the actions they * took. One SQLite file, in WAL mode so the setup window can read while the * service writes. */export class StateStore { readonly #db: DatabaseSync;
constructor(file: string) { if (file !== ":memory:") mkdirSync(dirname(file), { recursive: true }); this.#db = new DatabaseSync(file); this.#db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); this.#db.exec(SCHEMA); }
close(): void { this.#db.close(); }
transaction<T>(fn: () => T): T { this.#db.exec("BEGIN IMMEDIATE"); try { const result = fn(); this.#db.exec("COMMIT"); return result; } catch (error) { this.#db.exec("ROLLBACK"); throw error; } }
// ---- Last-seen state -------------------------------------------------------
loadBase(folder: string): Map<string, LastSeen> { const rows = this.#db.prepare("SELECT * FROM entries WHERE folder = ?").all(folder) as unknown as EntryRow[]; return new Map(rows.map((row) => [row.path, fromRow(row)])); }
putBase(folder: string, entry: LastSeen): void { this.#db .prepare( `INSERT OR REPLACE INTO entries VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( folder, entry.path, entry.kind, entry.size, entry.sha256, entry.head, entry.local.mtime, entry.local.ctime, entry.local.url, entry.local.id, entry.remote.mtime, entry.remote.ctime, entry.remote.etag, entry.remote.url, entry.remote.id, entry.syncedAt, ); }
deleteBase(folder: string, path: string): void { this.#db.prepare("DELETE FROM entries WHERE folder = ? AND path = ?").run(folder, path); }
/** Deletes `dir` and everything below it ('/' sorts just before '0', so this is a range scan). */ deleteBaseTree(folder: string, dir: string): void { this.#db .prepare("DELETE FROM entries WHERE folder = ? AND (path = ? OR (path >= ? AND path < ?))") .run(folder, dir, `${dir}/`, `${dir}0`); }
/** Forgets the last-seen state only: the next sync compares both sides afresh. */ forgetEntries(folder: string): void { this.#db.prepare("DELETE FROM entries WHERE folder = ?").run(folder); }
forgetFolder(folder: string): void { this.#db.prepare("DELETE FROM entries WHERE folder = ?").run(folder); this.#db.prepare("DELETE FROM runs WHERE folder = ?").run(folder); }
/** A page of last-seen entries for display, optionally filtered by a path substring. */ entries(folder: string, filter = "", limit = 200, offset = 0): { total: number; entries: LastSeen[] } { const like = `%${filter.replace(/[\\%_]/g, (c) => `\\${c}`)}%`; const { total } = this.#db .prepare("SELECT count(*) AS total FROM entries WHERE folder = ? AND path LIKE ? ESCAPE '\\'") .get(folder, like) as { total: number }; const rows = this.#db .prepare("SELECT * FROM entries WHERE folder = ? AND path LIKE ? ESCAPE '\\' ORDER BY path LIMIT ? OFFSET ?") .all(folder, like, limit, offset) as unknown as EntryRow[]; return { total, entries: rows.map(fromRow) }; }
// ---- Runs and actions --------------------------------------------------------
/** Runs still marked running whose process is gone (the caller holds the folder's lock): mark them failed. */ interruptRuns(folder: string): void { this.#db .prepare("UPDATE runs SET finished = ?, status = 'failed', error = 'interrupted: the process ended during the sync' WHERE folder = ? AND status = 'running'") .run(Date.now(), folder); }
startRun(folder: string, status: "running" | "planned" = "running"): number { const { lastInsertRowid } = this.#db .prepare("INSERT INTO runs (folder, started, status) VALUES (?, ?, ?)") .run(folder, Date.now(), status); return Number(lastInsertRowid); }
finishRun(run: number, status: RunRecord["status"], summary: Record<string, number> | null, error: string | null): void { this.#db .prepare("UPDATE runs SET finished = ?, status = ?, summary = ?, error = ? WHERE id = ?") .run(Date.now(), status, summary ? JSON.stringify(summary) : null, error, run); }
logAction(run: number, seq: number, action: Action, status: ActionRecord["status"], detail: string | null = null): void { const [side, path, target] = describe(action); this.#db .prepare("INSERT INTO actions (run, seq, type, side, path, target, reason, status, detail, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") .run(run, seq, action.type, side, path, target, action.reason, status, detail, Date.now()); }
runs(folder: string | null, limit = 50): RunRecord[] { const rows = ( folder === null ? this.#db.prepare("SELECT * FROM runs ORDER BY started DESC LIMIT ?").all(limit) : this.#db.prepare("SELECT * FROM runs WHERE folder = ? ORDER BY started DESC LIMIT ?").all(folder, limit) ) as unknown as (Omit<RunRecord, "summary"> & { summary: string | null })[]; return rows.map((row) => ({ ...row, summary: row.summary ? JSON.parse(row.summary) : null })); }
actions(run: number): ActionRecord[] { return this.#db.prepare("SELECT * FROM actions WHERE run = ? ORDER BY seq").all(run) as unknown as ActionRecord[]; }
/** Keeps the newest `keep` runs per folder (their actions go with them). */ pruneRuns(folder: string, keep = 500): void { this.#db .prepare("DELETE FROM runs WHERE folder = ? AND id NOT IN (SELECT id FROM runs WHERE folder = ? ORDER BY started DESC LIMIT ?)") .run(folder, folder, keep); }}
function describe(action: Action): [side: string | null, path: string, target: string | null] { switch (action.type) { case "move": return [action.on, action.from, action.to]; case "mkdir": case "delete": return [action.on, action.path, null]; case "upload": return ["remote", action.path, null]; case "download": return ["local", action.path, null]; case "forget": return [null, action.base, null]; case "hold": return ["remote", action.path, null]; default: // Settling an entry that moved: shown as where it was → where it is. return action.base !== null && action.base !== action.path ? [null, action.base, action.path] : [null, action.path, null]; }}
interface EntryRow { folder: string; path: string; kind: "file" | "dir"; size: number; sha256: string | null; head: string | null; local_mtime: number; local_ctime: number | null; local_url: string; local_id: string | null; remote_mtime: number; remote_ctime: number | null; remote_etag: string | null; remote_url: string; remote_id: string | null; synced_at: number;}
function fromRow(row: EntryRow): LastSeen { return { path: row.path, kind: row.kind, size: row.size, sha256: row.sha256, head: row.head, local: { mtime: row.local_mtime, ctime: row.local_ctime, etag: null, url: row.local_url, id: row.local_id }, remote: { mtime: row.remote_mtime, ctime: row.remote_ctime, etag: row.remote_etag, url: row.remote_url, id: row.remote_id }, syncedAt: row.synced_at, };}