feat(api): ежедневный бэкап, копия в личку, восстановление через бота

backup.ts: db.backup() → gzip в backups/ рядом с базой, имя по времени
Минска (Intl), 14 ежедневных и pre-restore на 30 дней; планировщик
в 03:00 по Минску и при старте, если сегодня копии нет; файл админу,
итог в «Систему». Восстановление: gunzip → integrity_check → версия
схемы → пауза приёма (503) → pre-restore → атомарная подмена файла →
миграции; при сбое возвращаем прежнюю базу. createApp и очередь берут
базу через геттер, чтобы увидеть подменённую.
Бот в личке: /backups с кнопками, /backup, присланный .sqlite.gz,
подтверждение со списком пропадающих заявок, живёт 10 минут.
This commit is contained in:
2026-10-17 16:07:26 +03:00
parent 667c6a6e6d
commit 249b4bb627
11 changed files with 1103 additions and 15 deletions
+371
View File
@@ -0,0 +1,371 @@
// Бэкапы базы и восстановление из копии. Копии лежат рядом с базой (/data/backups),
// поэтому временные файлы на той же файловой системе и rename при подмене атомарен.
import {
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
} from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { createGunzip, createGzip } from 'node:zlib';
import Database from 'better-sqlite3';
import { openDb, type Db, type DbHolder } from './db/index.js';
import { MIGRATIONS } from './db/migrations.js';
import type { TelegramClient } from './telegram/client.js';
import { shortError, type SystemReporter } from './telegram/system.js';
export const TIME_ZONE = 'Europe/Minsk';
export const KEEP_DAILY = 14;
export const KEEP_PRE_RESTORE_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
export type BackupKind = 'daily' | 'pre-restore';
export type BackupFile = { file: string; kind: BackupKind; at: Date; bytes: number };
const NAME_RE = /^(kvarta|pre-restore)-(\d{4})-(\d{2})-(\d{2})T(\d{2})(\d{2})\.sqlite\.gz$/;
export function backupDirFor(dbPath: string): string {
return join(dirname(dbPath), 'backups');
}
type LocalParts = { year: number; month: number; day: number; hour: number; minute: number };
/** Часы и дата по Минску. Через Intl, а не +3: правило могут поменять, tzdata — обновится. */
export function localParts(date: Date, timeZone = TIME_ZONE): LocalParts {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
const get = (type: string) => Number(parts.find((p) => p.type === type)!.value);
return { year: get('year'), month: get('month'), day: get('day'), hour: get('hour'), minute: get('minute') };
}
const pad = (n: number) => String(n).padStart(2, '0');
/** «18.10 03:00» — для кнопок и отчётов. */
export function shortLabel(date: Date): string {
const p = localParts(date);
return `${pad(p.day)}.${pad(p.month)} ${pad(p.hour)}:${pad(p.minute)}`;
}
export function backupFileName(date: Date, kind: BackupKind): string {
const p = localParts(date);
const prefix = kind === 'daily' ? 'kvarta' : 'pre-restore';
return `${prefix}-${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}${pad(p.minute)}.sqlite.gz`;
}
/** Минское локальное время → UTC-момент (смещение берём на этот самый момент). */
function fromLocal(p: LocalParts, timeZone = TIME_ZONE): Date {
const guess = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
const shown = localParts(new Date(guess), timeZone);
const offset = Date.UTC(shown.year, shown.month - 1, shown.day, shown.hour, shown.minute) - guess;
return new Date(guess - offset);
}
function parseName(file: string): { kind: BackupKind; at: Date } | null {
const m = NAME_RE.exec(file);
if (!m) return null;
const [, prefix, y, mo, d, h, mi] = m;
return {
kind: prefix === 'kvarta' ? 'daily' : 'pre-restore',
at: fromLocal({ year: +y!, month: +mo!, day: +d!, hour: +h!, minute: +mi! }),
};
}
/** Следующий запуск ежедневного бэкапа: ближайшие 03:00 по Минску строго после now. */
export function nextDailyRun(now: Date, hour = 3): Date {
const today = localParts(now);
let run = fromLocal({ ...today, hour, minute: 0 });
if (run.getTime() <= now.getTime()) {
const tomorrow = localParts(new Date(run.getTime() + DAY_MS));
run = fromLocal({ ...tomorrow, hour, minute: 0 });
}
return run;
}
export function listBackups(dir: string): BackupFile[] {
if (!existsSync(dir)) return [];
const out: BackupFile[] = [];
for (const file of readdirSync(dir)) {
const parsed = parseName(file);
if (parsed) out.push({ file, ...parsed, bytes: statSync(join(dir, file)).size });
}
return out.sort((a, b) => b.at.getTime() - a.at.getTime() || b.file.localeCompare(a.file));
}
export function hasDailyBackupToday(dir: string, now: Date): boolean {
const today = localParts(now);
return listBackups(dir).some((b) => {
const p = localParts(b.at);
return b.kind === 'daily' && p.year === today.year && p.month === today.month && p.day === today.day;
});
}
/** 14 последних ежедневных; pre-restore — 30 дней. Возвращает удалённые имена. */
export function rotateBackups(dir: string, now: Date): string[] {
const removed: string[] = [];
const all = listBackups(dir);
for (const b of all.filter((b) => b.kind === 'daily').slice(KEEP_DAILY)) removed.push(b.file);
for (const b of all.filter((b) => b.kind === 'pre-restore')) {
if (now.getTime() - b.at.getTime() > KEEP_PRE_RESTORE_DAYS * DAY_MS) removed.push(b.file);
}
for (const file of removed) rmSync(join(dir, file), { force: true });
return removed;
}
function countLeads(db: Db): number {
return (db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n;
}
export type BackupResult = BackupFile & { leads: number };
/** Снимок через db.backup() (консистентен при работающей записи) → gzip → атомарный rename. */
export async function createBackup(db: Db, dir: string, now: Date, kind: BackupKind = 'daily'): Promise<BackupResult> {
mkdirSync(dir, { recursive: true });
const file = backupFileName(now, kind);
const raw = join(dir, `.tmp-${process.pid}-${Date.now()}.sqlite`);
const gz = join(dir, `.tmp-${file}`);
try {
await db.backup(raw);
// Копия наследует WAL из заголовка: переводим в DELETE, иначе рядом остаются -wal/-shm,
// а в архив попадает база, которой нужны файлы-спутники.
const snapshot = new Database(raw);
snapshot.pragma('journal_mode = DELETE');
const leads = countLeads(snapshot);
snapshot.close();
await pipeline(createReadStream(raw), createGzip(), createWriteStream(gz));
renameSync(gz, join(dir, file));
return { file, kind, at: now, bytes: statSync(join(dir, file)).size, leads };
} finally {
rmSync(raw, { force: true });
removeSidecars(raw);
rmSync(gz, { force: true });
}
}
export class RestoreRejected extends Error {}
export type InspectedBackup = {
/** Распакованная копия во временном файле; удаляет вызывающий (discard) */
path: string;
userVersion: number;
leads: { id: number; created_at: string }[];
};
/** gunzip → integrity_check → версия схемы. Кидает RestoreRejected с понятным текстом. */
export async function inspectBackup(source: string | Uint8Array, dir: string): Promise<InspectedBackup> {
mkdirSync(dir, { recursive: true });
const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const path = join(dir, `.restore-${stamp}.sqlite`);
const gzPath = typeof source === 'string' ? source : join(dir, `.upload-${stamp}.sqlite.gz`);
try {
if (typeof source !== 'string') await writeFile(gzPath, source);
try {
await pipeline(createReadStream(gzPath), createGunzip(), createWriteStream(path));
} catch {
throw new RestoreRejected('Файл не распаковывается: это не .sqlite.gz или он повреждён.');
}
// Открываем на запись: это наша временная копия, а readonly на WAL-базе оставил бы -wal/-shm.
let copy: Database.Database;
let integrity: unknown;
try {
copy = new Database(path, { fileMustExist: true });
} catch {
throw new RestoreRejected('Внутри архива не база SQLite.');
}
try {
try {
copy.pragma('journal_mode = DELETE');
integrity = copy.pragma('integrity_check', { simple: true });
} catch {
throw new RestoreRejected('Внутри архива не база SQLite.');
}
if (integrity !== 'ok') throw new RestoreRejected('Копия повреждена: integrity_check не прошёл.');
const userVersion = copy.pragma('user_version', { simple: true }) as number;
if (userVersion > MIGRATIONS.length) {
throw new RestoreRejected(`Копия от более новой версии api (схема v${userVersion}, у нас v${MIGRATIONS.length}).`);
}
const hasLeads = copy.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'leads'").get();
if (!hasLeads) throw new RestoreRejected('В копии нет таблицы заявок.');
const leads = copy.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
return { path, userVersion, leads };
} finally {
copy.close();
}
} catch (err) {
rmSync(path, { force: true });
removeSidecars(path);
throw err;
} finally {
if (typeof source !== 'string') rmSync(gzPath, { force: true });
}
}
export function discardInspected(inspected: InspectedBackup): void {
rmSync(inspected.path, { force: true });
}
/** Заявки текущей базы, которых нет в копии (по паре id + created_at: id после отката переиспользуются). */
export function leadsMissingFrom(db: Db, backupLeads: InspectedBackup['leads']): number[] {
const inCopy = new Set(backupLeads.map((l) => `${l.id}|${l.created_at}`));
const current = db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
return current.filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
}
function removeSidecars(path: string) {
rmSync(`${path}-wal`, { force: true });
rmSync(`${path}-shm`, { force: true });
}
export type RestoreResult = { preRestore: BackupResult; leads: number };
/**
* Подменить базу копией. Приём заявок на паузе с самого начала: заявка, пришедшая
* между снимком pre-restore и подменой, иначе пропала бы без следа.
*/
export async function restoreFromInspected({
holder,
dbPath,
inspected,
now,
waitIdle,
}: {
holder: DbHolder;
dbPath: string;
inspected: InspectedBackup;
now: Date;
/** Дождаться фоновых записей в базу (очередь Telegram), прежде чем её закрыть. */
waitIdle?: () => Promise<void>;
}): Promise<RestoreResult> {
const dir = backupDirFor(dbPath);
const rollback = `${dbPath}.rollback`;
holder.paused = true;
try {
const preRestore = await createBackup(holder.db, dir, now, 'pre-restore');
await waitIdle?.();
// От close до open — без await: никакой другой код не увидит закрытую базу.
holder.db.close();
removeSidecars(dbPath);
renameSync(dbPath, rollback);
try {
renameSync(inspected.path, dbPath);
holder.db = openDb(dbPath);
} catch (err) {
rmSync(dbPath, { force: true });
removeSidecars(dbPath);
renameSync(rollback, dbPath);
holder.db = openDb(dbPath);
throw err;
}
rmSync(rollback, { force: true });
return { preRestore, leads: countLeads(holder.db) };
} finally {
holder.paused = false;
discardInspected(inspected);
}
}
export type Scheduler = { start(): void; stop(): void };
/** Ежедневно в 03:00 по Минску и сразу при старте, если сегодняшней копии ещё нет. */
export function createBackupScheduler({
run,
now,
hasToday,
}: {
run: () => Promise<void>;
now: () => Date;
hasToday: () => boolean;
}): Scheduler {
let timer: NodeJS.Timeout | undefined;
let stopped = false;
const safeRun = () => run().catch((err: unknown) => console.error('backup job failed', err));
function schedule() {
if (stopped) return;
const delay = Math.max(1000, nextDailyRun(now()).getTime() - now().getTime());
timer = setTimeout(() => void safeRun().then(schedule), delay);
timer.unref();
}
return {
start() {
stopped = false;
if (!hasToday()) void safeRun();
schedule();
},
stop() {
stopped = true;
if (timer) clearTimeout(timer);
},
};
}
export function pluralLeads(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
const word =
mod10 === 1 && mod100 !== 11
? 'заявка'
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)
? 'заявки'
: 'заявок';
return `${n} ${word}`;
}
const kb = (bytes: number) => `${Math.max(1, Math.round(bytes / 1024))} КБ`;
/** Копия → ротация → файл администратору → строка в «Систему». Не бросает: только отчёт. */
export async function runBackupAndReport({
holder,
dir,
now,
client,
adminId,
system,
}: {
holder: DbHolder;
dir: string;
now: Date;
client?: Pick<TelegramClient, 'sendDocument'>;
adminId?: number;
system: SystemReporter;
}): Promise<BackupResult | undefined> {
let result: BackupResult;
try {
result = await createBackup(holder.db, dir, now, 'daily');
rotateBackups(dir, now);
} catch (err) {
await system.report('backup failed', `Бэкап ${shortLabel(now)} не удался ❌\n${shortError(err)}`);
return undefined;
}
const summary = `Бэкап ${shortLabel(result.at)}: ${pluralLeads(result.leads)}, ${kb(result.bytes)}`;
let delivery = '';
if (client && adminId !== undefined) {
try {
await client.sendDocument({
chat_id: adminId,
file: { name: result.file, data: readFileSync(join(dir, result.file)) },
caption: summary,
});
} catch (err) {
delivery = `, но в личку не отправлен: ${err instanceof Error ? err.message : String(err)}`;
}
}
await system.report('backup', `${summary} ✅${delivery}`);
return result;
}