feat(db): перевести хранилище с SQLite на PostgreSQL
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s

При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-08 01:36:48 +07:00
co-authored by Cursor
parent 0e5fb065e2
commit ec43591a99
128 changed files with 4304 additions and 3639 deletions
+54 -50
View File
@@ -1,68 +1,78 @@
import { randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import Database from "better-sqlite3"
import { env } from "../config.js"
import { beginSqliteExclusiveOp, endSqliteExclusiveOp, reopenSqlite, sqliteDatabase } from "../db/index.js"
import { withAdvisoryLock } from "../db/index.js"
import { refreshScheduler, stopScheduler } from "./scheduler.js"
import {
reattachFlowSqlite,
startTrafficFlowListener,
stopTrafficFlowListener,
} from "./traffic-flow-ingest.js"
const SQLITE_MAGIC = Buffer.from("SQLite format 3\0")
const PG_DUMP_MAGIC = Buffer.from("PGDMP")
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
type SqliteHandle = InstanceType<typeof Database>
const BACKUP_LOCK_KEY = 8_723_102
function fmtTimestamp(date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
}
function resolveDatabasePath(): string {
return path.resolve(process.cwd(), env.DATABASE_PATH)
}
function assertSqliteFile(buffer: Buffer): void {
if (buffer.length < SQLITE_MAGIC.length) {
throw new Error("Файл слишком маленький для SQLite")
function assertPgDumpFile(buffer: Buffer): void {
if (buffer.length < PG_DUMP_MAGIC.length) {
throw new Error("Файл слишком маленький для дампа PostgreSQL")
}
if (!buffer.subarray(0, SQLITE_MAGIC.length).equals(SQLITE_MAGIC)) {
throw new Error("Файл не похож на резервную копию SQLite")
if (!buffer.subarray(0, PG_DUMP_MAGIC.length).equals(PG_DUMP_MAGIC)) {
throw new Error("Файл не похож на резервную копию PostgreSQL (ожидается формат custom, PGDMP)")
}
}
async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
beginSqliteExclusiveOp()
stopTrafficFlowListener()
stopScheduler()
try {
return await fn()
} finally {
startTrafficFlowListener()
refreshScheduler()
endSqliteExclusiveOp()
}
function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer }): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
env: { ...process.env, DATABASE_URL: env.DATABASE_URL },
stdio: ["pipe", "pipe", "pipe"],
})
const chunks: Buffer[] = []
let err = ""
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk))
child.stderr.on("data", (chunk: Buffer) => {
err += chunk.toString("utf8")
})
child.on("error", reject)
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(err.trim() || `${command} завершился с кодом ${code}`))
return
}
resolve(Buffer.concat(chunks))
})
if (opts?.stdin) child.stdin.end(opts.stdin)
else child.stdin.end()
})
}
async function withDatabaseOperation<T>(fn: () => Promise<T>): Promise<T> {
return await withAdvisoryLock(BACKUP_LOCK_KEY, async () => {
stopTrafficFlowListener()
stopScheduler()
try {
return await fn()
} finally {
await startTrafficFlowListener()
await refreshScheduler()
}
})
}
export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer }> {
return withDatabaseOperation(async () => {
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
const tempDir = path.join(os.tmpdir(), "mmapp-db-backup")
await mkdir(tempDir, { recursive: true })
const tempPath = path.join(tempDir, `manager-${randomUUID()}.db`)
try {
await sqliteDatabase.backup(tempPath)
const buffer = await readFile(tempPath)
return {
filename: `mikrotik-manager_${fmtTimestamp()}.db`,
buffer,
}
} finally {
await rm(tempPath, { force: true })
return await withDatabaseOperation(async () => {
const buffer = await spawnCollected("pg_dump", ["-Fc", env.DATABASE_URL])
return {
filename: `mikrotik-manager_${fmtTimestamp()}.dump`,
buffer,
}
})
}
@@ -71,27 +81,21 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<void>
if (buffer.length > MAX_RESTORE_BYTES) {
throw new Error("Файл бэкапа слишком большой")
}
assertSqliteFile(buffer)
assertPgDumpFile(buffer)
await withDatabaseOperation(async () => {
const tempDir = path.join(os.tmpdir(), "mmapp-db-restore")
await mkdir(tempDir, { recursive: true })
const tempPath = path.join(tempDir, `restore-${randomUUID()}.db`)
let source: SqliteHandle | null = null
const tempPath = path.join(tempDir, `restore-${randomUUID()}.dump`)
try {
await writeFile(tempPath, buffer)
source = new Database(tempPath, { readonly: true, fileMustExist: true })
await source.backup(resolveDatabasePath())
reopenSqlite()
reattachFlowSqlite()
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
await spawnCollected("pg_restore", ["--clean", "--if-exists", "-d", env.DATABASE_URL, tempPath])
} finally {
source?.close()
await rm(tempPath, { force: true })
}
})
}
export function getSystemDatabasePath(): string {
return resolveDatabasePath()
return env.DATABASE_URL
}