Skip to content
Snippets Groups Projects
backups.ts 6.61 KiB
import * as dateFns from 'date-fns';
import { v4 as uuidv4 } from 'uuid';

import * as connection from '../platform/server/connection';
import * as fs from '../platform/server/fs';
import * as sqlite from '../platform/server/sqlite';
import * as monthUtils from '../shared/months';

import * as cloudStorage from './cloud-storage';
import * as prefs from './prefs';

// A special backup that represents the latest version of the db that
// can be reverted to after loading a backup
const LATEST_BACKUP_FILENAME = 'db.latest.sqlite';
let serviceInterval = null;

export type Backup = { id: string; date: string } | LatestBackup;
type LatestBackup = { id: string; date: null; isLatest: true };
type BackupWithDate = { id: string; date: Date };

async function getBackups(id: string): Promise<BackupWithDate[]> {
  const budgetDir = fs.getBudgetDir(id);
  const backupDir = fs.join(budgetDir, 'backups');

  let paths = [];
  if (await fs.exists(backupDir)) {
    paths = await fs.listDir(backupDir);
    paths = paths.filter(file => file.match(/\.sqlite$/));
  }

  const backups = await Promise.all(
    paths.map(async path => {
      const mtime = await fs.getModifiedTime(fs.join(backupDir, path));
      return {
        id: path,
        date: new Date(mtime),
      };
    }),
  );

  backups.sort((b1, b2) => {
    if (b1.date < b2.date) {
      return 1;
    } else if (b1.date > b2.date) {
      return -1;
    }
    return 0;
  });

  return backups;
}

async function getLatestBackup(id: string): Promise<LatestBackup | null> {
  const budgetDir = fs.getBudgetDir(id);
  if (await fs.exists(fs.join(budgetDir, LATEST_BACKUP_FILENAME))) {
    return {
      id: LATEST_BACKUP_FILENAME,
      date: null,
      isLatest: true,
    };
  }
  return null;
}

export async function getAvailableBackups(id: string): Promise<Backup[]> {
  let backups = await getBackups(id);

  let latestBackup = await getLatestBackup(id);
  if (latestBackup) {
    backups.unshift(latestBackup);
  }

  return backups.map(backup => ({
    ...backup,
    date: backup.date ? dateFns.format(backup.date, 'yyyy-MM-dd h:mm') : null,
  }));
}

export async function updateBackups(backups) {
  const byDay = backups.reduce((groups, backup) => {
    const day = dateFns.format(backup.date, 'yyyy-MM-dd');
    groups[day] = groups[day] || [];
    groups[day].push(backup);
    return groups;
  }, {});

  const removed = [];
  for (let day of Object.keys(byDay)) {
    const dayBackups = byDay[day];
    const isToday = day === monthUtils.currentDay();
    // Allow 3 backups of the current day (so fine-grained edits are
    // kept around). Otherwise only keep around one backup per day.
    // And only keep a total of 10 backups.
    for (let backup of dayBackups.slice(isToday ? 3 : 1)) {
      removed.push(backup.id);
    }
  }

  // Get the list of remaining backups and only keep the latest 10
  const currentBackups = backups.filter(backup => !removed.includes(backup.id));
  return removed.concat(currentBackups.slice(10).map(backup => backup.id));
}

export async function makeBackup(id: string) {
  const budgetDir = fs.getBudgetDir(id);

  // When making a backup, we no longer consider the user to be
  // viewing any backups. If there exists a "latest backup" we should
  // delete it and consider whatever is current as the latest
  if (await fs.exists(fs.join(budgetDir, LATEST_BACKUP_FILENAME))) {
    await fs.removeFile(fs.join(fs.getBudgetDir(id), LATEST_BACKUP_FILENAME));
  }

  let backupId = `${uuidv4()}.sqlite`;
  let backupPath = fs.join(budgetDir, 'backups', backupId);

  if (!(await fs.exists(fs.join(budgetDir, 'backups')))) {
    await fs.mkdir(fs.join(budgetDir, 'backups'));
  }

  await fs.copyFile(fs.join(budgetDir, 'db.sqlite'), backupPath);

  // Remove all the messages from the backup
  const db = sqlite.openDatabase(backupPath);
  await sqlite.runQuery(db, 'DELETE FROM messages_crdt');
  await sqlite.runQuery(db, 'DELETE FROM messages_clock');
  sqlite.closeDatabase(db);

  const toRemove = await updateBackups(await getBackups(id));
  for (let id of toRemove) {
    await fs.removeFile(fs.join(budgetDir, 'backups', id));
  }

  connection.send('backups-updated', await getAvailableBackups(id));
}

export async function loadBackup(id: string, backupId: string) {
  const budgetDir = fs.getBudgetDir(id);

  if (!(await fs.exists(fs.join(budgetDir, LATEST_BACKUP_FILENAME)))) {
    // If this is the first time we're loading a backup, save the
    // current version so the user can easily revert back to it
    await fs.copyFile(
      fs.join(budgetDir, 'db.sqlite'),
      fs.join(budgetDir, LATEST_BACKUP_FILENAME),
    );

    await fs.copyFile(
      fs.join(budgetDir, 'metadata.json'),
      fs.join(budgetDir, 'metadata.latest.json'),
    );

    // Restart the backup service to make sure the user has the full
    // amount of time to figure out which one they want
    stopBackupService();
    startBackupService(id);

    await prefs.loadPrefs(id);
  }

  if (backupId === LATEST_BACKUP_FILENAME) {
    console.log('Reverting backup');

    // If reverting back to the latest, copy and delete the latest
    // backup
    await fs.copyFile(
      fs.join(budgetDir, LATEST_BACKUP_FILENAME),
      fs.join(budgetDir, 'db.sqlite'),
    );
    await fs.copyFile(
      fs.join(budgetDir, 'metadata.latest.json'),
      fs.join(budgetDir, 'metadata.json'),
    );
    await fs.removeFile(fs.join(budgetDir, LATEST_BACKUP_FILENAME));
    await fs.removeFile(fs.join(budgetDir, 'metadata.latest.json'));

    // Re-upload the new file
    try {
      await cloudStorage.upload();
    } catch (e) {}
    prefs.unloadPrefs();
  } else {
    console.log('Loading backup', backupId);

    // This function is only ever called when a budget isn't loaded,
    // so it's safe to load our prefs in. We need to forget about any
    // syncing data if we are loading a backup (the current sync data
    // will be restored if the user reverts to the original version)
    await prefs.loadPrefs(id);
    await prefs.savePrefs({
      groupId: null,
      lastSyncedTimestamp: null,
      lastUploaded: null,
    });

    // Re-upload the new file
    try {
      await cloudStorage.upload();
    } catch (e) {}

    prefs.unloadPrefs();

    await fs.copyFile(
      fs.join(budgetDir, 'backups', backupId),
      fs.join(budgetDir, 'db.sqlite'),
    );
  }
}

export function startBackupService(id: string) {
  if (serviceInterval) {
    clearInterval(serviceInterval);
  }

  // Make a backup every 15 minutes
  serviceInterval = setInterval(async () => {
    console.log('Making backup');
    await makeBackup(id);
  }, 1000 * 60 * 15);
}

export function stopBackupService() {
  clearInterval(serviceInterval);
  serviceInterval = null;
}