/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/storage/versions.mjs * Purpose : File version history — keep superseded blobs on replace/edit, * list, restore, prune (each version row holds one blob ref) * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { getDb, logActivity } from '../db/db.mjs'; import { refBlob, unrefBlob } from './blobs.mjs'; /** Versions kept per node; older ones are pruned (blob unref'd). */ export const MAX_VERSIONS = 20; function fileNode(id) { const node = getDb().prepare("SELECT * FROM nodes WHERE id = ? AND type = 'file'").get(id); if (!node) throw Object.assign(new Error('No such file'), { statusCode: 404 }); return node; } /** Version history for a node, newest first. */ export function listVersions(nodeId) { return getDb() .prepare('SELECT * FROM node_versions WHERE node_id = ? ORDER BY replaced_at DESC, id DESC') .all(nodeId); } export function getVersion(nodeId, versionId) { return getDb() .prepare('SELECT * FROM node_versions WHERE id = ? AND node_id = ?') .get(versionId, nodeId) ?? null; } /** * Snapshot a node's CURRENT blob into its history (called just before the * blob is swapped). Takes its own ref on the blob and prunes old versions. */ function pushVersion(node, origin) { if (!node.blob_sha) return; const db = getDb(); db.prepare( `INSERT INTO node_versions (node_id, blob_sha, size, mime, created, replaced_at, origin) VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run(node.id, node.blob_sha, node.size, node.mime, node.modified, Date.now(), origin); refBlob(node.blob_sha); // Prune beyond the cap, oldest first. const excess = db .prepare( `SELECT id, blob_sha FROM node_versions WHERE node_id = ? ORDER BY replaced_at DESC, id DESC LIMIT -1 OFFSET ?`, ) .all(node.id, MAX_VERSIONS); for (const row of excess) deleteVersionRow(row); } function deleteVersionRow(row) { unrefBlob(row.blob_sha); getDb().prepare('DELETE FROM node_versions WHERE id = ?').run(row.id); } /** * Swap a file node's content to a new stored blob, archiving the current one. * The caller must have already put the blob in the CAS (no ref taken yet). * @param {'replace'|'edit'|'restore'} origin * @returns {object} the updated node row */ export function setNodeBlob(nodeId, { sha, size, mime, origin = 'replace', ip = '' }) { const node = fileNode(nodeId); const now = Date.now(); const db = getDb(); if (node.blob_sha === sha) { db.prepare('UPDATE nodes SET modified = ? WHERE id = ?').run(now, nodeId); return fileNode(nodeId); } pushVersion(node, origin); refBlob(sha); unrefBlob(node.blob_sha); db.prepare('UPDATE nodes SET blob_sha = ?, size = ?, mime = ?, modified = ? WHERE id = ?') .run(sha, size, mime ?? node.mime, now, nodeId); logActivity(`file.${origin}`, { nodeId, detail: node.name, ip }); return fileNode(nodeId); } /** * Make an old version current again. The current content is archived * (origin 'restore') and the version row stays in the history. */ export function restoreVersion(nodeId, versionId, { ip = '' } = {}) { const version = getVersion(nodeId, versionId); if (!version) throw Object.assign(new Error('No such version'), { statusCode: 404 }); return setNodeBlob(nodeId, { sha: version.blob_sha, size: version.size, mime: version.mime, origin: 'restore', ip, }); } /** Delete one version from history (releases its blob ref). */ export function deleteVersion(nodeId, versionId) { const version = getVersion(nodeId, versionId); if (!version) return false; deleteVersionRow(version); return true; } /** Release every version blob for a node (used by hard delete). */ export function dropVersionsForNode(nodeId) { for (const row of listVersions(nodeId)) deleteVersionRow(row); }