From 8c61cc88e930370ce6d5b5d35258576c7dcadeed Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 12 Apr 2026 17:52:43 +0300 Subject: [PATCH] core(standalone): integrate backup management using provider --- apps/server/spec/setup.ts | 4 +- apps/server/src/app.ts | 2 - apps/server/src/backup_provider.ts | 91 ++++++++++++++++++ apps/server/src/etapi/backup.ts | 4 +- apps/server/src/main.ts | 2 + apps/server/src/routes/api/database.ts | 7 +- apps/server/src/services/backup.ts | 93 ------------------- packages/trilium-core/src/index.ts | 6 +- .../src/services/backend_script_api.ts | 4 +- packages/trilium-core/src/services/backup.ts | 51 +++++++++- .../trilium-core/src/services/migration.ts | 4 +- .../trilium-core/src/services/sql_init.ts | 21 ++--- 12 files changed, 165 insertions(+), 124 deletions(-) create mode 100644 apps/server/src/backup_provider.ts delete mode 100644 apps/server/src/services/backup.ts diff --git a/apps/server/spec/setup.ts b/apps/server/spec/setup.ts index 10ee99569b..77dcf98871 100644 --- a/apps/server/spec/setup.ts +++ b/apps/server/spec/setup.ts @@ -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() }); }); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index b85402d1bd..3074e6ed01 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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(); diff --git a/apps/server/src/backup_provider.ts b/apps/server/src/backup_provider.ts new file mode 100644 index 0000000000..2dbbaa66a6 --- /dev/null +++ b/apps/server/src/backup_provider.ts @@ -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 { + // 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; + }); + } +} diff --git a/apps/server/src/etapi/backup.ts b/apps/server/src/etapi/backup.ts index 73ab3e5c48..fc9755e3b6 100644 --- a/apps/server/src/etapi/backup.ts +++ b/apps/server/src/etapi/backup.ts @@ -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)); }); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1d40fce243..8854218a1a 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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) diff --git a/apps/server/src/routes/api/database.ts b/apps/server/src/routes/api/database.ts index ef73983bde..58457a1ebf 100644 --- a/apps/server/src/routes/api/database.ts +++ b/apps/server/src/routes/api/database.ts @@ -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; } diff --git a/apps/server/src/services/backup.ts b/apps/server/src/services/backup.ts deleted file mode 100644 index 23599d291e..0000000000 --- a/apps/server/src/services/backup.ts +++ /dev/null @@ -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 -}; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index f2e380bd9c..0f3ecfee12 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -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); diff --git a/packages/trilium-core/src/services/backend_script_api.ts b/packages/trilium-core/src/services/backend_script_api.ts index e1c0a7a494..e233cb2c99 100644 --- a/packages/trilium-core/src/services/backend_script_api.ts +++ b/packages/trilium-core/src/services/backend_script_api.ts @@ -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 = { diff --git a/packages/trilium-core/src/services/backup.ts b/packages/trilium-core/src/services/backup.ts index 7726a9418c..dd25b467cf 100644 --- a/packages/trilium-core/src/services/backup.ts +++ b/packages/trilium-core/src/services/backup.ts @@ -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 { + 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(); +} diff --git a/packages/trilium-core/src/services/migration.ts b/packages/trilium-core/src/services/migration.ts index a241d6e8db..8084d834ac 100644 --- a/packages/trilium-core/src/services/migration.ts +++ b/packages/trilium-core/src/services/migration.ts @@ -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" ); diff --git a/packages/trilium-core/src/services/sql_init.ts b/packages/trilium-core/src/services/sql_init.ts index 29654cf97c..9157be2953 100644 --- a/packages/trilium-core/src/services/sql_init.ts +++ b/packages/trilium-core/src/services/sql_init.ts @@ -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); }); }