Files
kvarta_project/apps/api/src/backup.ts
T
nunuchek 88b6e1395e refactor: simplify — мёртвые поля контента, общие хелперы api и тестов, липкая CTA без site в бандле
landing:
- site.ts: убраны неиспользуемые roast, brew, priceDisplay, hero.eyebrow, footer.phone (Footer берёт CONTACT_PHONE)
- form.ts: убраны FORM_TEXT.title/lead, CTAS, CtaPreset
- StickyCta получает тексты пропсами от layout: клиентский чанк больше не тащит все тексты страницы
- LeadForm/useLeadForm: без неиспользуемой подмены sendLead и className; leadPrefill без счётчика seq
- Stamp без мёртвого tone; убраны data-interest, data-hot, класс lead-form (ничем не читались)
- одно LEAD_FIELDS на sendLead и leadFormModel; регэксп typo вынесен из вызова; дубль фона body

api:
- changedFields и Version общие для leads и outbox; countLeads и leadStamps без копий SQL
- errorMessage в system.ts вместо четырёх копий; take() для ожидающих подтверждений
- backup: один Intl.DateTimeFormat на модуль, без невызываемых параметров timeZone/hour
- без мёртвых LOOPBACK '::ffff:127.0.0.1', client.call, Pick<BackupCommands> со всеми ключами
- testing.ts: validLead, addLead, count, leadIds вместо копий в тестах

deploy.sh: deployed_field и цикл по REPOS вместо повторов имён
2026-10-17 17:31:11 +03:00

377 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Бэкапы базы и восстановление из копии. Копии лежат рядом с базой (/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 { errorMessage, 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;
const DAILY_HOUR = 3;
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 };
const MINSK_PARTS = new Intl.DateTimeFormat('en-CA', {
timeZone: TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
/** Часы и дата по Минску. Через Intl, а не +3: правило могут поменять, tzdata — обновится. */
export function localParts(date: Date): LocalParts {
const parts = MINSK_PARTS.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): Date {
const guess = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
const shown = localParts(new Date(guess));
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): Date {
const today = localParts(now);
let run = fromLocal({ ...today, hour: DAILY_HOUR, minute: 0 });
if (run.getTime() <= now.getTime()) {
const tomorrow = localParts(new Date(run.getTime() + DAY_MS));
run = fromLocal({ ...tomorrow, hour: DAILY_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;
}
export 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('В копии нет таблицы заявок.');
return { path, userVersion, leads: leadStamps(copy) };
} 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}`));
return leadStamps(db).filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
}
function leadStamps(db: Db): InspectedBackup['leads'] {
return db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
}
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);
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 = `, но в личку не отправлен: ${errorMessage(err)}`;
}
}
await system.report('backup', `${summary} ✅${delivery}`);
return result;
}