core(standalone): integrate backup management using provider

This commit is contained in:
Elian Doran
2026-04-12 17:52:43 +03:00
parent 24112a9b6f
commit 8c61cc88e9
12 changed files with 165 additions and 124 deletions

View File

@@ -3,6 +3,7 @@ import { readFileSync } from "fs";
import { join } from "path";
import { initializeCore } from "@triliumnext/core";
import { serverZipExportProviderFactory } from "../src/services/export/zip/factory.js";
import ServerBackupService from "../src/backup_provider.js";
import ClsHookedExecutionContext from "../src/cls_provider.js";
import NodejsCryptoProvider from "../src/crypto_provider.js";
import NodejsZipProvider from "../src/zip_provider.js";
@@ -42,6 +43,7 @@ beforeAll(async () => {
schema: readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"),
platform: new ServerPlatformProvider(),
translations: initializeTranslationsWithParams,
inAppHelp: new NodejsInAppHelpProvider()
inAppHelp: new NodejsInAppHelpProvider(),
backup: new ServerBackupService()
});
});

View File

@@ -122,8 +122,6 @@ export default async function buildApp() {
const { sync, consistency_checks, scheduler } = await import("@triliumnext/core");
sync.startSyncTimer();
await import("./services/backup.js");
consistency_checks.startConsistencyChecks();
scheduler.startScheduler();

View File

@@ -0,0 +1,91 @@
import type { DatabaseBackup, OptionNames } from "@triliumnext/commons";
import { BackupService, sync_mutex as syncMutexService } from "@triliumnext/core";
import fs from "fs";
import path from "path";
import cls from "./services/cls.js";
import dataDir from "./services/data_dir.js";
import dateUtils from "./services/date_utils.js";
import log from "./services/log.js";
import optionService from "./services/options.js";
import sql from "./services/sql.js";
type BackupType = "daily" | "weekly" | "monthly";
export default class ServerBackupService extends BackupService {
override getExistingBackups(): DatabaseBackup[] {
if (!fs.existsSync(dataDir.BACKUP_DIR)) {
return [];
}
return fs
.readdirSync(dataDir.BACKUP_DIR)
.filter((fileName) => fileName.includes("backup"))
.map((fileName) => {
const filePath = path.resolve(dataDir.BACKUP_DIR, fileName);
const stat = fs.statSync(filePath);
return { fileName, filePath, mtime: stat.mtime };
});
}
override regularBackup(): void {
cls.init(() => {
this.periodBackup("lastDailyBackupDate", "daily", 24 * 3600);
this.periodBackup("lastWeeklyBackupDate", "weekly", 7 * 24 * 3600);
this.periodBackup("lastMonthlyBackupDate", "monthly", 30 * 24 * 3600);
});
}
private isBackupEnabled(backupType: BackupType): boolean {
let optionName: OptionNames;
switch (backupType) {
case "daily":
optionName = "dailyBackupEnabled";
break;
case "weekly":
optionName = "weeklyBackupEnabled";
break;
case "monthly":
optionName = "monthlyBackupEnabled";
break;
}
return optionService.getOptionBool(optionName);
}
private periodBackup(
optionName: "lastDailyBackupDate" | "lastWeeklyBackupDate" | "lastMonthlyBackupDate",
backupType: BackupType,
periodInSeconds: number
): void {
if (!this.isBackupEnabled(backupType)) {
return;
}
const now = new Date();
const lastBackupDate = dateUtils.parseDateTime(optionService.getOption(optionName));
if (now.getTime() - lastBackupDate.getTime() > periodInSeconds * 1000) {
this.backupNow(backupType);
optionService.setOption(optionName, dateUtils.utcNowDateTime());
}
}
override async backupNow(name: string): Promise<string> {
// we don't want to back up DB in the middle of sync with potentially inconsistent DB state
return await syncMutexService.doExclusively(async () => {
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${name}.db`);
if (!fs.existsSync(dataDir.BACKUP_DIR)) {
fs.mkdirSync(dataDir.BACKUP_DIR, 0o700);
}
log.info("Creating backup...");
await sql.copyDatabase(backupFile);
log.info(`Created backup at ${backupFile}`);
return backupFile;
});
}
}

View File

@@ -1,11 +1,11 @@
import { getBackup } from "@triliumnext/core";
import type { Router } from "express";
import backupService from "../services/backup.js";
import eu from "./etapi_utils.js";
function register(router: Router) {
eu.route<{ backupName: string }>(router, "put", "/etapi/backup/:backupName", (req, res, next) => {
backupService.backupNow(req.params.backupName)
getBackup().backupNow(req.params.backupName)
.then(() => res.sendStatus(204))
.catch(() => res.sendStatus(500));
});

View File

@@ -8,6 +8,7 @@ import fs from "fs";
import { t } from "i18next";
import path from "path";
import ServerBackupService from "./backup_provider.js";
import ClsHookedExecutionContext from "./cls_provider.js";
import { getIntegrationTestDbPath, loadCoreSchema } from "./core_assets.js";
import NodejsCryptoProvider from "./crypto_provider.js";
@@ -80,6 +81,7 @@ async function startApplication() {
// both source and bundled-production modes.
getDemoArchive: async () => fs.readFileSync(path.join(RESOURCE_DIR, "db", "demo.zip")),
inAppHelp: new NodejsInAppHelpProvider(),
backup: new ServerBackupService(),
extraAppInfo: {
nodeVersion: process.version,
dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR)

View File

@@ -1,12 +1,11 @@
import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
import { becca_loader, ValidationError } from "@triliumnext/core";
import { becca_loader, getBackup, ValidationError } from "@triliumnext/core";
import type { Request, Response } from "express";
import fs, { readFileSync } from "fs";
import path from "path";
import { getIntegrationTestDbPath } from "../../core_assets.js";
import anonymizationService from "../../services/anonymization.js";
import backupService from "../../services/backup.js";
import consistencyChecksService from "../../services/consistency_checks.js";
import dataDir from "../../services/data_dir.js";
import log from "../../services/log.js";
@@ -14,12 +13,12 @@ import sql from "../../services/sql.js";
import sql_init from "../../services/sql_init.js";
function getExistingBackups() {
return backupService.getExistingBackups();
return getBackup().getExistingBackups();
}
async function backupDatabase() {
return {
backupFile: await backupService.backupNow("now")
backupFile: await getBackup().backupNow("now")
} satisfies BackupDatabaseNowResponse;
}

View File

@@ -1,93 +0,0 @@
import type { DatabaseBackup, OptionNames } from "@triliumnext/commons";
import { sync_mutex as syncMutexService } from "@triliumnext/core";
import fs from "fs";
import path from "path";
import cls from "./cls.js";
import dataDir from "./data_dir.js";
import dateUtils from "./date_utils.js";
import log from "./log.js";
import optionService from "./options.js";
import sql from "./sql.js";
type BackupType = "daily" | "weekly" | "monthly";
function getExistingBackups(): DatabaseBackup[] {
if (!fs.existsSync(dataDir.BACKUP_DIR)) {
return [];
}
return fs
.readdirSync(dataDir.BACKUP_DIR)
.filter((fileName) => fileName.includes("backup"))
.map((fileName) => {
const filePath = path.resolve(dataDir.BACKUP_DIR, fileName);
const stat = fs.statSync(filePath);
return { fileName, filePath, mtime: stat.mtime };
});
}
function regularBackup() {
cls.init(() => {
periodBackup("lastDailyBackupDate", "daily", 24 * 3600);
periodBackup("lastWeeklyBackupDate", "weekly", 7 * 24 * 3600);
periodBackup("lastMonthlyBackupDate", "monthly", 30 * 24 * 3600);
});
}
function isBackupEnabled(backupType: BackupType) {
let optionName: OptionNames;
switch (backupType) {
case "daily":
optionName = "dailyBackupEnabled";
break;
case "weekly":
optionName = "weeklyBackupEnabled";
break;
case "monthly":
optionName = "monthlyBackupEnabled";
break;
}
return optionService.getOptionBool(optionName);
}
function periodBackup(optionName: "lastDailyBackupDate" | "lastWeeklyBackupDate" | "lastMonthlyBackupDate", backupType: BackupType, periodInSeconds: number) {
if (!isBackupEnabled(backupType)) {
return;
}
const now = new Date();
const lastBackupDate = dateUtils.parseDateTime(optionService.getOption(optionName));
if (now.getTime() - lastBackupDate.getTime() > periodInSeconds * 1000) {
backupNow(backupType);
optionService.setOption(optionName, dateUtils.utcNowDateTime());
}
}
async function backupNow(name: string) {
// we don't want to back up DB in the middle of sync with potentially inconsistent DB state
return await syncMutexService.doExclusively(async () => {
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${name}.db`);
if (!fs.existsSync(dataDir.BACKUP_DIR)) {
fs.mkdirSync(dataDir.BACKUP_DIR, 0o700);
}
log.info("Creating backup...");
await sql.copyDatabase(backupFile);
log.info(`Created backup at ${backupFile}`);
return backupFile;
});
}
export default {
getExistingBackups,
backupNow,
regularBackup
};

View File

@@ -1,6 +1,7 @@
import { ExecutionContext, initContext } from "./services/context";
import { CryptoProvider, initCrypto } from "./services/encryption/crypto";
import LogService, { getLog, initLog } from "./services/log";
import BackupService, { initBackup } from "./services/backup";
import { initSql } from "./services/sql/index";
import { SqlService, SqlServiceParams } from "./services/sql/sql";
import { initMessaging, MessagingProvider } from "./services/messaging/index";
@@ -15,6 +16,7 @@ import { type InAppHelpProvider, initInAppHelp } from "./services/in_app_help";
export { default as LogService, getLog } from "./services/log";
export { default as FileBasedLogService, type LogFileInfo } from "./services/file_based_log";
export { default as BackupService, getBackup, initBackup } from "./services/backup";
export type * from "./services/sql/types";
export * from "./services/sql/index";
export { default as sql_init } from "./services/sql_init";
@@ -122,7 +124,7 @@ export { default as scriptService } from "./services/script";
export { default as BackendScriptApi, type Api as BackendScriptApiInterface } from "./services/backend_script_api";
export * as scheduler from "./services/scheduler";
export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive, inAppHelp, log }: {
export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive, inAppHelp, log, backup }: {
dbConfig: SqlServiceParams,
executionContext: ExecutionContext,
crypto: CryptoProvider,
@@ -140,9 +142,11 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip,
dataDirectory: string;
};
log?: LogService;
backup?: BackupService;
}) {
initPlatform(platform);
initLog(log);
initBackup(backup);
await initTranslations(translations);
initCrypto(crypto);
initZipProvider(zip);

View File

@@ -20,7 +20,7 @@ import type BRevision from "../becca/entities/brevision.js";
import appInfo from "./app_info.js";
import attributeService from "./attributes.js";
import type { ApiParams } from "./backend_script_api_interface.js";
import backupService from "./backup.js";
import { getBackup } from "./backup.js";
import cloningService from "./cloning.js";
import config from "./config.js";
import dateNoteService from "./date_notes.js";
@@ -717,7 +717,7 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) {
};
this.runOutsideOfSync = syncMutex.doExclusively;
this.backupNow = backupService.backupNow;
this.backupNow = (name: string) => getBackup().backupNow(name);
this.duplicateSubtree = noteService.duplicateSubtree;
this.__private = {

View File

@@ -1,6 +1,49 @@
export default {
async backupNow(name: string) {
console.warn("Backup not yet available.");
return "backup-" + name + "-" + new Date().toISOString() + ".zip";
import type { DatabaseBackup } from "@triliumnext/commons";
/**
* Base backup service class.
* Provides default (no-op) implementations.
* Platform-specific implementations extend this class.
*/
export default class BackupService {
/**
* Create a backup with the given name.
* Returns the backup file path/name.
*/
async backupNow(name: string): Promise<string> {
console.warn("Backup not available - no backup provider configured.");
return `backup-${name}-${new Date().toISOString()}.db`;
}
/**
* Perform regular scheduled backups (daily, weekly, monthly).
* Called periodically by the scheduler.
*/
regularBackup(): void {
// No-op in base implementation
}
/**
* Get list of existing backups.
* Returns empty array if not supported.
*/
getExistingBackups(): DatabaseBackup[] {
return [];
}
}
let backupService: BackupService = new BackupService();
/**
* Get the current backup service instance.
*/
export function getBackup(): BackupService {
return backupService;
}
/**
* Initialize the backup service with a platform-specific provider.
*/
export function initBackup(provider?: BackupService): void {
backupService = provider ?? new BackupService();
}

View File

@@ -1,4 +1,4 @@
import backupService from "./backup.js";
import { getBackup } from "./backup.js";
import { getSql } from "./sql/index.js";
import { getLog } from "./log.js";
import { getPlatform } from "./platform.js";
@@ -26,7 +26,7 @@ async function migrate() {
// backup before attempting migration
if (!getPlatform().getEnv("TRILIUM_INTEGRATION_TEST")) {
await backupService.backupNow(
await getBackup().backupNow(
// creating a special backup for version 0.60.4, the changes in 0.61 are major.
currentDbVersion === 214 ? `before-migration-v060` : "before-migration"
);

View File

@@ -1,6 +1,7 @@
import { deferred, OptionRow } from "@triliumnext/commons";
import { getSql } from "./sql";
import { getLog } from "./log";
import { getBackup } from "./backup";
import optionService from "./options";
import eventService from "./events";
import { getContext } from "./context";
@@ -105,22 +106,16 @@ function initializeDb() {
getContext().init(initDbConnection);
dbReady.then(() => {
// TODO: Re-enable backup.
// if (config.General && config.General.noBackup === true) {
// log.info("Disabling scheduled backups.");
// Run regular backups every 4 hours
setInterval(() => getBackup().regularBackup(), 4 * 60 * 60 * 1000);
// return;
// }
// Kickoff first backup soon after start up
setTimeout(() => getBackup().regularBackup(), 5 * 60 * 1000);
// setInterval(() => backup.regularBackup(), 4 * 60 * 60 * 1000);
// Optimize is usually inexpensive no-op, so running it semi-frequently is not a big deal
setTimeout(() => optimize(), 60 * 60 * 1000);
// // kickoff first backup soon after start up
// setTimeout(() => backup.regularBackup(), 5 * 60 * 1000);
// // optimize is usually inexpensive no-op, so running it semi-frequently is not a big deal
// setTimeout(() => optimize(), 60 * 60 * 1000);
// setInterval(() => optimize(), 10 * 60 * 60 * 1000);
setInterval(() => optimize(), 10 * 60 * 60 * 1000);
});
}