spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/storage/versions.mjs8 * Purpose : File version history — keep superseded blobs on replace/edit,9 * list, restore, prune (each version row holds one blob ref)10 * License : MIT © Simon-Pierre Boucher11 * ─────────────────────────────────────────────12 */1314import { getDb, logActivity } from '../db/db.mjs';15import { refBlob, unrefBlob } from './blobs.mjs';1617/** Versions kept per node; older ones are pruned (blob unref'd). */18export const MAX_VERSIONS = 20;1920function fileNode(id) {21 const node = getDb().prepare("SELECT * FROM nodes WHERE id = ? AND type = 'file'").get(id);22 if (!node) throw Object.assign(new Error('No such file'), { statusCode: 404 });23 return node;24}2526/** Version history for a node, newest first. */27export function listVersions(nodeId) {28 return getDb()29 .prepare('SELECT * FROM node_versions WHERE node_id = ? ORDER BY replaced_at DESC, id DESC')30 .all(nodeId);31}3233export function getVersion(nodeId, versionId) {34 return getDb()35 .prepare('SELECT * FROM node_versions WHERE id = ? AND node_id = ?')36 .get(versionId, nodeId) ?? null;37}3839/**40 * Snapshot a node's CURRENT blob into its history (called just before the41 * blob is swapped). Takes its own ref on the blob and prunes old versions.42 */43function pushVersion(node, origin) {44 if (!node.blob_sha) return;45 const db = getDb();46 db.prepare(47 `INSERT INTO node_versions (node_id, blob_sha, size, mime, created, replaced_at, origin)48 VALUES (?, ?, ?, ?, ?, ?, ?)`,49 ).run(node.id, node.blob_sha, node.size, node.mime, node.modified, Date.now(), origin);50 refBlob(node.blob_sha);51 // Prune beyond the cap, oldest first.52 const excess = db53 .prepare(54 `SELECT id, blob_sha FROM node_versions WHERE node_id = ?55 ORDER BY replaced_at DESC, id DESC LIMIT -1 OFFSET ?`,56 )57 .all(node.id, MAX_VERSIONS);58 for (const row of excess) deleteVersionRow(row);59}6061function deleteVersionRow(row) {62 unrefBlob(row.blob_sha);63 getDb().prepare('DELETE FROM node_versions WHERE id = ?').run(row.id);64}6566/**67 * Swap a file node's content to a new stored blob, archiving the current one.68 * The caller must have already put the blob in the CAS (no ref taken yet).69 * @param {'replace'|'edit'|'restore'} origin70 * @returns {object} the updated node row71 */72export function setNodeBlob(nodeId, { sha, size, mime, origin = 'replace', ip = '' }) {73 const node = fileNode(nodeId);74 const now = Date.now();75 const db = getDb();76 if (node.blob_sha === sha) {77 db.prepare('UPDATE nodes SET modified = ? WHERE id = ?').run(now, nodeId);78 return fileNode(nodeId);79 }80 pushVersion(node, origin);81 refBlob(sha);82 unrefBlob(node.blob_sha);83 db.prepare('UPDATE nodes SET blob_sha = ?, size = ?, mime = ?, modified = ? WHERE id = ?')84 .run(sha, size, mime ?? node.mime, now, nodeId);85 logActivity(`file.${origin}`, { nodeId, detail: node.name, ip });86 return fileNode(nodeId);87}8889/**90 * Make an old version current again. The current content is archived91 * (origin 'restore') and the version row stays in the history.92 */93export function restoreVersion(nodeId, versionId, { ip = '' } = {}) {94 const version = getVersion(nodeId, versionId);95 if (!version) throw Object.assign(new Error('No such version'), { statusCode: 404 });96 return setNodeBlob(nodeId, {97 sha: version.blob_sha, size: version.size, mime: version.mime, origin: 'restore', ip,98 });99}100101/** Delete one version from history (releases its blob ref). */102export function deleteVersion(nodeId, versionId) {103 const version = getVersion(nodeId, versionId);104 if (!version) return false;105 deleteVersionRow(version);106 return true;107}108109/** Release every version blob for a node (used by hard delete). */110export function dropVersionsForNode(nodeId) {111 for (const row of listVersions(nodeId)) deleteVersionRow(row);112}113