Add automatic backups and an admin restore page

Scheduler ticks every 5 minutes and snapshots data/avtoambor.db (via
better-sqlite3's online backup API) when the DB file's mtime has advanced.
After each new backup, prune older snapshots: keep everything from the last
7 days, then one per calendar day. New /admin page lists backups with
Download and Restore actions, plus a Back-up-now button. Restore takes a
safety snapshot first, closes the live connection, swaps the .db file, and
lets the next request reopen.

Also: TZ=Asia/Dushanbe in the container so backup filenames use local time,
and tzdata added to the image so TZ takes effect.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
David Beccue
2026-05-16 07:25:18 +05:00
parent 2bb51940f0
commit f65fca09e4
12 changed files with 401 additions and 1 deletions

View File

@ -1,8 +1,10 @@
import { getDb } from '$lib/server/db.js';
import { startBackupScheduler } from '$lib/server/backup.js';
// Open (and warm) the database on server startup so the first request
// doesn't pay the cost.
getDb();
startBackupScheduler();
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {

View File

@ -24,6 +24,7 @@
<a href="/parts" class:active={isActive('/parts')}>{$t('nav.parts')}</a>
<a href="/movements/new" class:active={isActive('/movements')}>{$t('nav.movements')}</a>
<a href="/suppliers" class:active={isActive('/suppliers')}>{$t('nav.suppliers')}</a>
<a href="/admin" class:active={isActive('/admin')}>{$t('nav.admin')}</a>
</nav>
<button class="lang" type="button" on:click={toggleLocale} aria-label="Switch language">

View File

@ -8,6 +8,7 @@
"parts": "Parts",
"movements": "Movements",
"suppliers": "Suppliers",
"admin": "Backups",
"new_part": "New part",
"new_movement": "Record movement"
},
@ -97,6 +98,27 @@
"not_enough_stock": "Not enough stock on hand."
}
},
"admin": {
"title": "Backups & Restore",
"warning_title": "Important: copy backups to a USB stick regularly!",
"warning_body": "Backups are kept on this computer only. If the hard drive fails, all of your data and all backups will be lost. At least once a week, plug in a USB stick and click the Download button next to a recent backup, then save the file onto the stick.",
"backup_now": "Back up now",
"auto_note": "A backup is taken automatically every 5 minutes when there are changes.",
"list_title": "Available backups",
"no_backups": "No backups yet.",
"created_at": "When",
"size": "Size",
"download": "Download",
"restore": "Restore",
"restore_confirm": "Restore the database from this backup? The current data will be replaced (a safety backup of the current data will be made first).",
"prune_note": "Backups from the last 7 days are kept in full. Older backups are thinned to one per day automatically.",
"flash": {
"backup_taken": "Backup created.",
"backup_failed": "Backup failed. See the server logs.",
"restored": "Database restored.",
"restore_failed": "Restore failed. See the server logs."
}
},
"suppliers": {
"title": "Suppliers",
"name": "Name",

View File

@ -8,6 +8,7 @@
"parts": "Қисмҳо",
"movements": "Ҳаракатҳо",
"suppliers": "Таъминкунандагон",
"admin": "Нусхаҳо",
"new_part": "Қисми нав",
"new_movement": "Сабти ҳаракат"
},
@ -97,6 +98,27 @@
"not_enough_stock": "Дар анбор миқдори кофӣ нест."
}
},
"admin": {
"title": "Нусхабардорӣ ва барқарорсозӣ",
"warning_title": "Муҳим: нусхаҳоро мунтазам ба USB-флешка нусхабардорӣ кунед!",
"warning_body": "Нусхаҳо танҳо дар ин компютер нигоҳ дошта мешаванд. Агар диски сахт вайрон шавад, ҳамаи маълумот ва ҳамаи нусхаҳо нест мешаванд. Ҳафтае як маротиба USB-флешкаро пайваст кунед, тугмаи «Зеркашӣ»-ро дар сатри як нусхаи нав пахш кунед ва файлро ба флешка захира кунед.",
"backup_now": "Ҳозир нусха гирифтан",
"auto_note": "Ҳар 5 дақиқа агар тағйирот бошад, нусха худкор гирифта мешавад.",
"list_title": "Нусхаҳои мавҷуда",
"no_backups": "Ҳоло нусхае нест.",
"created_at": "Сана",
"size": "Андоза",
"download": "Зеркашӣ",
"restore": "Барқарор кардан",
"restore_confirm": "Маълумотро аз ин нусха барқарор мекунед? Маълумоти ҷорӣ иваз карда мешавад (аввал нусхаи эҳтиётии маълумоти ҷорӣ гирифта мешавад).",
"prune_note": "Нусхаҳои 7 рӯзи охир пурра нигоҳ дошта мешаванд. Нусхаҳои кӯҳна то як адад дар як рӯз худкор кам карда мешаванд.",
"flash": {
"backup_taken": "Нусха сохта шуд.",
"backup_failed": "Нусхабардорӣ ноком шуд. Логи серверро бинед.",
"restored": "Маълумот барқарор карда шуд.",
"restore_failed": "Барқарорсозӣ ноком шуд. Логи серверро бинед."
}
},
"suppliers": {
"title": "Таъминкунандагон",
"name": "Ном",

165
src/lib/server/backup.js Normal file
View File

@ -0,0 +1,165 @@
// Backup / restore for data/avtoambor.db.
//
// - Scheduler ticks every 5 minutes; takes a backup only if the DB file's
// mtime has advanced since the previous backup (so an idle shop doesn't
// accumulate identical snapshots).
// - Backups land in ./backups/ at the repo root, named
// avtoambor-YYYY-MM-DD_HH-MM-SS.db (sortable, human-readable).
// - After each new backup, prune older snapshots: keep ALL backups from the
// last 7 days; for anything older, keep only the most recent backup of
// each calendar day.
// - Restore closes the live DB, removes WAL/SHM, copies the chosen backup
// over the .db file, and lets the next getDb() reopen.
import { getDb, closeDb, DB_FILE } from './db.js';
import {
mkdirSync, existsSync, readdirSync, statSync, unlinkSync, copyFileSync
} from 'node:fs';
import { resolve, dirname, join } from 'node:path';
const DATA_DIR = dirname(DB_FILE);
export const BACKUP_DIR = resolve(DATA_DIR, '..', 'backups');
const PREFIX = 'avtoambor-';
const EXT = '.db';
const FILE_RE = /^avtoambor-(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.db$/;
const RETAIN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
const TICK_MS = 5 * 60 * 1000;
function ensureDir() {
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
}
function pad(n) { return String(n).padStart(2, '0'); }
function stamp(d = new Date()) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
}
function parseStamp(name) {
const m = name.match(FILE_RE);
if (!m) return null;
const [, Y, Mo, D, H, Mi, S] = m;
return new Date(+Y, +Mo - 1, +D, +H, +Mi, +S);
}
function dayKey(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function safeName(name) {
return typeof name === 'string'
&& !name.includes('/')
&& !name.includes('\\')
&& FILE_RE.test(name);
}
export function listBackups() {
ensureDir();
return readdirSync(BACKUP_DIR)
.filter((n) => n.startsWith(PREFIX) && n.endsWith(EXT) && FILE_RE.test(n))
.map((name) => {
const p = join(BACKUP_DIR, name);
const st = statSync(p);
return {
name,
path: p,
size: st.size,
createdAt: parseStamp(name) ?? st.mtime
};
})
.sort((a, b) => b.createdAt - a.createdAt);
}
// Latest mtime across the SQLite triplet (.db, -wal, -shm).
function dbSourceMtime() {
let max = 0;
for (const suffix of ['', '-wal', '-shm']) {
const p = DB_FILE + suffix;
if (existsSync(p)) max = Math.max(max, statSync(p).mtimeMs);
}
return max;
}
export async function takeBackup() {
ensureDir();
const name = `${PREFIX}${stamp()}${EXT}`;
const dest = join(BACKUP_DIR, name);
await getDb().backup(dest);
const st = statSync(dest);
pruneOldBackups();
return { name, path: dest, createdAt: new Date(), size: st.size };
}
// Keep everything in the last RETAIN_DAYS; for older snapshots, keep only
// the newest one per calendar day. Deletions are silent on error.
export function pruneOldBackups(now = new Date()) {
const cutoff = now.getTime() - RETAIN_DAYS * DAY_MS;
const all = listBackups(); // newest first
const seenDays = new Set();
for (const b of all) {
const t = b.createdAt.getTime();
if (t >= cutoff) continue; // inside retention window — keep
const key = dayKey(b.createdAt);
if (seenDays.has(key)) {
try { unlinkSync(b.path); } catch { /* ignore */ }
} else {
seenDays.add(key); // first (newest) of this day — keep
}
}
}
export async function restoreBackup(name) {
if (!safeName(name)) throw new Error('Invalid backup name');
const src = join(BACKUP_DIR, name);
if (!existsSync(src)) throw new Error('Backup not found');
// Snapshot the current DB first so a misclick is recoverable.
try { await takeBackup(); } catch (e) { console.error('[backup] pre-restore snapshot failed', e); }
// From here on: no awaits. Node is single-threaded so other requests
// cannot interleave and reopen the DB on partial state.
closeDb();
for (const suffix of ['-wal', '-shm']) {
const p = DB_FILE + suffix;
if (existsSync(p)) {
try { unlinkSync(p); } catch { /* ignore */ }
}
}
copyFileSync(src, DB_FILE);
// Next getDb() reopens against the restored file.
}
// Module-level scheduler state. globalThis-guarded so dev HMR doesn't stack
// multiple intervals on top of each other.
const STARTED_FLAG = '__avtoambor_backup_started';
export function startBackupScheduler() {
if (globalThis[STARTED_FLAG]) return;
globalThis[STARTED_FLAG] = true;
ensureDir();
// Baseline: pretend the most recent backup's timestamp is "what we've
// already captured", so we don't immediately take a duplicate at boot.
let lastSeenMtime = 0;
const existing = listBackups();
if (existing.length > 0) lastSeenMtime = existing[0].createdAt.getTime();
const tick = async () => {
try {
const mtime = dbSourceMtime();
if (mtime > lastSeenMtime) {
await takeBackup();
lastSeenMtime = mtime;
}
} catch (e) {
console.error('[backup] scheduled tick failed', e);
}
};
// Run once shortly after boot, then every TICK_MS.
setTimeout(tick, 5_000);
setInterval(tick, TICK_MS);
}

View File

@ -20,4 +20,13 @@ export function getDb() {
return _db;
}
// Close the live connection. Used by the restore flow before
// overwriting the .db file; next getDb() call will reopen.
export function closeDb() {
if (_db) {
try { _db.close(); } catch { /* ignore */ }
_db = null;
}
}
export const DB_FILE = DB_PATH;

View File

@ -0,0 +1,35 @@
import { fail } from '@sveltejs/kit';
import { listBackups, takeBackup, restoreBackup } from '$lib/server/backup.js';
export function load() {
return {
backups: listBackups().map((b) => ({
name: b.name,
size: b.size,
createdAt: b.createdAt.toISOString()
}))
};
}
export const actions = {
backup: async () => {
try {
const b = await takeBackup();
return { ok: true, message: 'admin.flash.backup_taken', name: b.name };
} catch (e) {
console.error('[admin] manual backup failed', e);
return fail(500, { message: 'admin.flash.backup_failed' });
}
},
restore: async ({ request }) => {
const form = await request.formData();
const name = String(form.get('name') ?? '');
try {
await restoreBackup(name);
return { ok: true, message: 'admin.flash.restored' };
} catch (e) {
console.error('[admin] restore failed', e);
return fail(500, { message: 'admin.flash.restore_failed' });
}
}
};

View File

@ -0,0 +1,118 @@
<script>
import { t, locale } from '$lib/i18n/store.js';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
export let data;
export let form;
$: ({ backups } = data);
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatWhen(iso, lang) {
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
return `${date} ${time}`;
}
</script>
<h1>{$t('admin.title')}</h1>
<div class="warning">
<strong>{$t('admin.warning_title')}</strong>
<p>{$t('admin.warning_body')}</p>
</div>
{#if form?.message}
<div class={form.ok ? 'flash ok' : 'flash err'}>
{$t(form.message)}
</div>
{/if}
<div class="toolbar">
<form method="POST" action="?/backup" use:enhance={() => async ({ update }) => { await update(); }}>
<button type="submit">{$t('admin.backup_now')}</button>
</form>
<p class="muted">{$t('admin.auto_note')}</p>
</div>
<h2>{$t('admin.list_title')}</h2>
{#if backups.length === 0}
<p class="muted">{$t('admin.no_backups')}</p>
{:else}
<table>
<thead>
<tr>
<th>{$t('admin.created_at')}</th>
<th class="num">{$t('admin.size')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each backups as b}
<tr>
<td>{formatWhen(b.createdAt, $locale)}</td>
<td class="num">{formatSize(b.size)}</td>
<td class="actions">
<a class="btn-link" href="/admin/download/{b.name}" download={b.name}>
{$t('admin.download')}
</a>
<form method="POST" action="?/restore" use:enhance={() => async ({ update }) => { await update(); await invalidateAll(); }}
on:submit={(e) => { if (!confirm($t('admin.restore_confirm'))) e.preventDefault(); }}>
<input type="hidden" name="name" value={b.name} />
<button type="submit" class="secondary">{$t('admin.restore')}</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
<p class="muted small">{$t('admin.prune_note')}</p>
{/if}
<style>
.warning {
background: #fff7e6;
border: 2px solid #d9821a;
border-radius: 6px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.warning strong { font-size: 1.1rem; color: #8a4a00; display: block; margin-bottom: 0.4rem; }
.warning p { margin: 0.4rem 0; }
.toolbar {
display: flex;
align-items: center;
gap: 1rem;
margin: 1rem 0 1.5rem;
}
.toolbar form { margin: 0; }
.flash {
padding: 0.6rem 0.85rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.flash.ok { background: #e6f4ec; border: 1px solid #9bd1b1; color: #154d2a; }
.flash.err { background: #fdecea; border: 1px solid #f5c2c0; color: #8a1f1b; }
.small { font-size: 0.85rem; margin-top: 0.75rem; }
td.actions { display: flex; gap: 0.5rem; align-items: center; }
td.actions form { margin: 0; }
.btn-link {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 4px;
border: 1px solid #006a4e;
background: #006a4e;
color: #fff;
text-decoration: none;
font-size: 0.92rem;
}
.btn-link:hover { background: #00553e; color: #fff; }
</style>

View File

@ -0,0 +1,24 @@
import { error } from '@sveltejs/kit';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { BACKUP_DIR, safeName } from '$lib/server/backup.js';
export function GET({ params }) {
const { name } = params;
if (!safeName(name)) throw error(400, 'Invalid backup name');
const path = join(BACKUP_DIR, name);
if (!existsSync(path)) throw error(404, 'Backup not found');
const buf = readFileSync(path);
const size = statSync(path).size;
return new Response(buf, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${name}"`,
'Cache-Control': 'no-store'
}
});
}