SPB Git

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%
4.2 KB · 113 lines javascript
Raw Blame History
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