fix(settings): сохранять результат операций базы при вторичных сбоях

This commit is contained in:
Denozordec committed 2026-10-03 16:43:52 +07:00
1 parent 738c98b9d3
commit 92b65a30ce
15 files changed
+159 -32

No files matched your search

+3 -2
View File
@@ -105,14 +105,15 @@ export default function SettingsPage() {
if (!systemDbAvailable) return
setDbBackupBusy(true)
try {
const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl)
const { blob, filename, warnings } = await downloadSystemDatabaseBackup(backendUrl)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success("Бэкап базы приложения скачан")
toast.success("Бэкап базы приложения подготовлен к скачиванию")
for (const warning of Object.values(warnings)) if (warning) toast.warning(warning)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап")
} finally {
+1 -1
View File
@@ -35,7 +35,7 @@
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts",
"test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts",
"test:certificates": "tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
"test:system-database": "tsx src/services/system-database-restore-execution.test.ts"
"test:system-database": "tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
+4 -2
View File
@@ -3,13 +3,13 @@ type Client = {
release: (destroy?: boolean) => void
}
/** Nonblocking session lock: a second destructive operation is rejected rather than queued. */
export async function withTrafficFlowPurgeLock<T>(connect: () => Promise<Client>, key: number, run: () => Promise<T>): Promise<T> {
export async function withOperationLock<T>(connect: () => Promise<Client>, key: number, run: () => Promise<T>, busyMessage = "Сброс NetFlow уже выполняется"): Promise<T> {
const client = await connect()
let owned = false, destroy = false
try {
destroy = true
const result = await client.query("SELECT pg_try_advisory_lock($1) AS acquired", [key])
if (result.rows[0]?.acquired === false) { destroy = false; throw new Error("Сброс NetFlow уже выполняется") }
if (result.rows[0]?.acquired === false) { destroy = false; throw new Error(busyMessage) }
if (result.rows[0]?.acquired !== true) throw new Error("Блокировка сброса NetFlow не подтверждена")
owned = true; destroy = false
return await run()
@@ -24,3 +24,5 @@ export async function withTrafficFlowPurgeLock<T>(connect: () => Promise<Client>
client.release(destroy)
}
}
export const withTrafficFlowPurgeLock = withOperationLock
+1
View File
@@ -86,6 +86,7 @@ export async function buildApp(opts?: {
await app.register(cors, {
origin: env.CORS_ORIGIN,
exposedHeaders: ["Content-Disposition", "X-System-Database-Outcome"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
@@ -0,0 +1,16 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {SignJWT} from "jose"
process.env.AUTH_REQUIRED="true";process.env.AUTH_JWT_SECRET="evolution-fixture-secret";process.env.AUTH_ISSUER="https://auth.test.local";process.env.NODE_ENV="test"
const {default:authPlugin,requireAuth}=await import("./auth.js")
const {createSystemDatabaseRoutes}=await import("../routes/system-database.js")
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
let exports=0,restores=0
await app.register(createSystemDatabaseRoutes({exportBackup:async()=>{exports++;return {filename:"fixture.dump",buffer:Buffer.from("PGDMPfixture")}},restoreBackup:async()=>{restores++;return {}},event:async()=>undefined as never}),{prefix:"/api"})
for(const permission of ["mm:settings:admin","mm:settings:write","mm:traffic:admin","mm:dashboard:read"]){
const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret"))
for(const [method,url] of [["GET","/api/system/database/backup"],["POST","/api/system/database/restore"]] as const){const response=await app.inject({method,url,headers:{authorization:"Bearer "+token,"content-type":"application/octet-stream"},...(method==="POST"?{payload:Buffer.from("PGDMPfixture")}:{})});assert.equal(response.statusCode,permission==="mm:settings:admin"?200:403)}
}
assert.equal(exports,1);assert.equal(restores,1)
for(const [method,url] of [["GET","/api/system/database/backup"],["POST","/api/system/database/restore"]] as const)assert.equal((await app.inject({method,url})).statusCode,401)
assert.equal(exports,1);assert.equal(restores,1);await app.close();console.log("System DB JWT: actual backup/restore handlers require settings admin before operations")
@@ -0,0 +1,17 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {createSystemDatabaseRoutes} from "./system-database.js"
import {withOperationLock} from "../db/operation-lock.js"
const app=Fastify(),dump=Buffer.from("PGDMPfixture")
let exports=0,restores=0,events=0,eventFails=true,coreFails=false,held=false
let gate:Promise<void>|undefined
let entered:(()=>void)|undefined
const lock=async()=>({query:async(sql:string)=>{if(sql.includes("pg_try")){const acquired=!held;if(acquired)held=true;return {rows:[{acquired}]}}held=false;return {rows:[{released:true}]}},release:()=>{}})
await app.register(createSystemDatabaseRoutes({exportBackup:async()=>{exports++;if(coreFails)throw Error("dump failed");return {filename:"fixture.dump",buffer:dump,listenerError:"listener warning"}},restoreBackup:async()=>withOperationLock(lock,1,async()=>{restores++;entered?.();if(gate)await gate;if(coreFails)throw Error("restore failed");return {schedulerError:"scheduler warning",cleanupError:"cleanup warning"}},"Операция с базой приложения уже выполняется"),event:async()=>{events++;if(eventFails)throw Error("event fixture secret");return undefined as never}}),{prefix:"/api"})
const restore=()=>app.inject({method:"POST",url:"/api/system/database/restore",headers:{"content-type":"application/octet-stream"},payload:dump})
let response=await app.inject({method:"GET",url:"/api/system/database/backup"});assert.equal(response.statusCode,200);assert.deepEqual(response.rawPayload,dump);let warnings=JSON.parse(decodeURIComponent(response.headers["x-system-database-outcome"] as string));assert.equal(warnings.listenerError,"listener warning");assert.match(warnings.eventError,/событие/);assert.equal(response.body.includes("secret"),false)
response=await restore();assert.equal(response.statusCode,200);assert.equal(response.json().ok,true);assert.equal(response.json().cleanupError,"cleanup warning");assert.match(response.json().eventError,/событие/)
coreFails=true;const before=events;response=await restore();assert.equal(response.statusCode,400);assert.equal(response.json().ok,undefined);assert.equal(events,before);response=await app.inject({method:"GET",url:"/api/system/database/backup"});assert.equal(response.statusCode,500);assert.equal(events,before)
coreFails=false;eventFails=false;let release!:()=>void;gate=new Promise<void>(resolve=>release=resolve);const started=new Promise<void>(resolve=>entered=resolve);const first=restore();await started;const runs=restores;response=await restore();assert.equal(response.statusCode,409);assert.equal(restores,runs);release();assert.equal((await first).statusCode,200);gate=undefined
response=await app.inject({method:"POST",url:"/api/system/database/restore",headers:{"content-type":"application/octet-stream"},payload:Buffer.alloc(0)});assert.equal(response.statusCode,400);assert.equal(restores,runs)
assert.equal(exports,2);await app.close();console.log("System DB HTTP: confirmed binary/restore, separate audit warnings, failures, nonqueued conflict and no retry")
+23 -8
View File
@@ -5,7 +5,12 @@ import {
restoreSystemDatabaseBackup,
} from "../services/system-database-backup.js"
const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
interface Dependencies {
exportBackup: typeof exportSystemDatabaseBackup
restoreBackup: typeof restoreSystemDatabaseBackup
event: typeof appendEvent
}
export function createSystemDatabaseRoutes(deps: Dependencies): FastifyPluginAsync { return async (app) => {
app.addContentTypeParser(
"application/octet-stream",
{ parseAs: "buffer", bodyLimit: 512 * 1024 * 1024 },
@@ -16,8 +21,10 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
app.get("/system/database/backup", async (_req, reply) => {
try {
const { filename, buffer } = await exportSystemDatabaseBackup()
await appendEvent({
const { filename, buffer, ...warnings } = await deps.exportBackup()
let eventError: string | undefined
try {
await deps.event({
level: "info",
eventType: "system.database.backup",
sourceModule: "system",
@@ -30,12 +37,15 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
sizeBytes: buffer.length,
},
})
} catch { eventError = "Не удалось записать событие резервного копирования" }
const outcome = { ...warnings, ...(eventError ? { eventError } : {}) }
reply.header("X-System-Database-Outcome", encodeURIComponent(JSON.stringify(outcome)))
reply.header("Content-Type", "application/octet-stream")
reply.header("Content-Disposition", `attachment; filename="${filename}"`)
return reply.send(buffer)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return reply.status(500).send({ error: message })
return reply.status(message.includes("уже выполняется") ? 409 : 500).send({ error: message })
}
})
@@ -45,8 +55,10 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
return reply.status(400).send({ error: "Ожидается тело запроса с файлом pg_dump (custom, PGDMP)" })
}
try {
const outcome = await restoreSystemDatabaseBackup(body)
await appendEvent({
const outcome = await deps.restoreBackup(body)
let eventError: string | undefined
try {
await deps.event({
level: "warning",
eventType: "system.database.restore",
sourceModule: "system",
@@ -58,7 +70,8 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
sizeBytes: body.length,
},
})
return reply.send({ ok: true, ...outcome })
} catch { eventError = "Не удалось записать событие восстановления" }
return reply.send({ ok: true, ...outcome, ...(eventError ? { eventError } : {}) })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const status = message.includes("уже выполняется") ? 409 : 400
@@ -67,4 +80,6 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
})
}
export default systemDatabaseRoutes
}
export default createSystemDatabaseRoutes({ exportBackup: exportSystemDatabaseBackup, restoreBackup: restoreSystemDatabaseBackup, event: appendEvent })
+13 -15
View File
@@ -5,7 +5,9 @@ import os from "node:os"
import path from "node:path"
import { executeSystemDatabaseRestore, validateSystemDatabaseRestoreArchive, type SystemDatabaseRestoreOutcome } from "./system-database-restore-execution.js"
import { env } from "../config.js"
import { withAdvisoryLock } from "../db/index.js"
import { pool } from "../db/index.js"
import { withOperationLock } from "../db/operation-lock.js"
import { executeDatabaseOperation, type DatabaseOperationWarnings } from "./system-database-operation.js"
import { refreshScheduler, stopScheduler } from "./scheduler.js"
import {
startTrafficFlowListener,
@@ -34,7 +36,7 @@ function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer
child.on("error", reject)
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(err.trim() || `${command} завершился с кодом ${code}`))
reject(new Error(err.trim().replaceAll(env.DATABASE_URL, "[DATABASE_URL]") || `${command} завершился с кодом ${code}`))
return
}
resolve(Buffer.concat(chunks))
@@ -44,20 +46,16 @@ function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer
})
}
async function withDatabaseOperation<T>(fn: () => Promise<T>): Promise<T> {
return await withAdvisoryLock(BACKUP_LOCK_KEY, async () => {
await stopTrafficFlowListener()
stopScheduler()
try {
return await fn()
} finally {
await startTrafficFlowListener()
await refreshScheduler()
}
})
async function withDatabaseOperation<T extends object>(fn: () => Promise<T>): Promise<T & DatabaseOperationWarnings> {
return withOperationLock(() => pool.connect(), BACKUP_LOCK_KEY, () => executeDatabaseOperation(fn, {
stopListener: stopTrafficFlowListener,
stopScheduler,
startListener: startTrafficFlowListener,
startScheduler: refreshScheduler,
}), "Операция с базой приложения уже выполняется")
}
export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer }> {
export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer } & DatabaseOperationWarnings> {
return await withDatabaseOperation(async () => {
const buffer = await spawnCollected("pg_dump", ["-Fc", env.DATABASE_URL])
return {
@@ -67,7 +65,7 @@ export async function exportSystemDatabaseBackup(): Promise<{ filename: string;
})
}
export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<SystemDatabaseRestoreOutcome> {
export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<SystemDatabaseRestoreOutcome & DatabaseOperationWarnings> {
validateSystemDatabaseRestoreArchive(buffer)
return await withDatabaseOperation(() => executeSystemDatabaseRestore(buffer, env.DATABASE_URL, {
allocate: async () => {
@@ -0,0 +1,23 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {executeDatabaseOperation} from "./system-database-operation.js"
function fixture(fail: string[] = []) {
const calls:string[]=[]
const step=(name:string)=>async()=>{calls.push(name);if(fail.includes(name))throw Error(name+" failed")}
return {calls,deps:{stopListener:step("stop listener"),stopScheduler:step("stop scheduler"),startListener:step("start listener"),startScheduler:step("start scheduler")},run:async()=>{await step("command")();return {cleanupError:"cleanup warning"}}}
}
test("confirmed command survives independent restart failures",async()=>{
for(const fail of [[],["start listener"],["start scheduler"],["start listener","start scheduler"]]){
const f=fixture(fail);const result=await executeDatabaseOperation(f.run,f.deps)
assert.equal(result.cleanupError,"cleanup warning");assert.equal(!!result.listenerError,fail.includes("start listener"));assert.equal(!!result.schedulerError,fail.includes("start scheduler"))
assert.deepEqual(f.calls,["stop listener","stop scheduler","command","start listener","start scheduler"])
}
})
test("core failure survives restart failures without command retry",async()=>{
const f=fixture(["command","start listener","start scheduler"])
await assert.rejects(executeDatabaseOperation(f.run,f.deps),/command failed/)
assert.equal(f.calls.filter(s=>s==="command").length,1);assert.deepEqual(f.calls.slice(-2),["start listener","start scheduler"])
})
test("stop failure prevents command and still attempts both restarts",async()=>{
for(const step of ["stop listener","stop scheduler"]){const f=fixture([step,"start listener"]);await assert.rejects(executeDatabaseOperation(f.run,f.deps),new RegExp(step+" failed"));assert.equal(f.calls.includes("command"),false);assert.deepEqual(f.calls.slice(-2),["start listener","start scheduler"])}
})
@@ -0,0 +1,23 @@
export type DatabaseOperationWarnings = { listenerError?: string; schedulerError?: string }
interface Dependencies {
stopListener: () => Promise<void> | void
stopScheduler: () => Promise<void> | void
startListener: () => Promise<void> | void
startScheduler: () => Promise<void> | void
}
/** Restart both consumers independently; secondary errors cannot erase a completed command. */
export async function executeDatabaseOperation<T extends object>(run: () => Promise<T>, deps: Dependencies): Promise<T & DatabaseOperationWarnings> {
let result: T | undefined, failed = false, failure: unknown
const warnings: DatabaseOperationWarnings = {}
try {
await deps.stopListener()
await deps.stopScheduler()
result = await run()
} catch (error) { failed = true; failure = error }
finally {
try { await deps.startListener() } catch { warnings.listenerError = "Не удалось перезапустить приём NetFlow" }
try { await deps.startScheduler() } catch { warnings.schedulerError = "Не удалось перезапустить планировщик" }
}
if (failed) throw failure
return { ...result!, ...warnings }
}
+3 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03
Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.2.1 (af5dbb0). Следующий блок 11Б.3.6.2.2.2: отделить подтверждённый результат backup/restore от ошибок перезапуска listener/scheduler и appendEvent; проверить HTTP/JWT/concurrency через DI. Далее 11Б.3.6.2.3: отдельная форма DB с source/active guards, синхронным lock и review неизвестного результата. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.2.2. Следующий блок 11Б.3.6.2.3: отдельная форма DB с source/active guards, синхронным lock и review неизвестного результата. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
@@ -298,3 +298,5 @@ REST запрос → ожидание → подтверждённый output /
Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма.
Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI.
2026-10-03: завершён 11Б.3.6.2.2.2, подтверждённый backup/restore отделён от restart/audit warnings; nonqueued concurrency, HTTP/JWT/API/UI проверки прошли локально. Далее DB форма 11Б.3.6.2.3.
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.2.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.2.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.3.6.2.2.2 — подтверждённая операция и вторичные сбои (2026-10-03)
- Core backup/restore отделён от restart listener/scheduler: оба restart выполняются независимо, их errors — warnings после подтверждённой команды; первичная ошибка не подменяется вторичной. Ошибка stop предотвращает команду и запускает попытки восстановления потребителей. Cleanup warning сохранён.
- appendEvent failure не отменяет готовый бинарный backup или ok:true restore. Restore возвращает eventError, backup — percent-encoded strict metadata header; CORS exposes filename/outcome. Client читает warnings отдельно, malformed metadata не уничтожает подтверждённый PGDMP и даёт предупреждение. UI сообщает подготовку скачивания, не подтверждает сохранение файла на диск.
- System DB теперь использует nonblocking pg_try_advisory_lock вместо общей blocking функции: конкурентный запрос получает 409 и не ставится в очередь. Общий helper получил нейтральное имя withOperationLock, NetFlow alias/default сохранены. DATABASE_URL редактируется из точного совпадения stderr команды; прежние event rows не изменялись.
- 4 restore DI + 3 lifecycle DI, HTTP binary/audit/core failure/concurrency, реальные signed JWT gates, 4 API unit группы и 2 browser receipt сценария прошли; backend build, typecheck и профильный lint прошли. Реальный PG dump/restore/lock overlap и UDP stop acknowledgment не проверены; тесты не обращались к реальной БД.
- Следующий блок 11Б.3.6.2.3: отдельная форма system DB, admin/source/active guards, общий синхронный lock, review неизвестного результата, адаптивность. Затем Alerts/Releases/probe scheduling и приёмка 11Б/12.
## Блок 11Б.3.6.2.2.1 — выполнение восстановления PostgreSQL (2026-10-03)
- Restore executor вынесен в pure DI helper. Production pg_restore получает --single-transaction вместе с --clean/--if-exists; validate size/PGDMP prefix выполняется до lock/остановки сборщиков и повторно в executor. Команда не повторяется, временный файл удаляется при write/run error. Cleanup failure после успешной команды — отдельный cleanupError, не отменяет её подтверждённый результат; основная ошибка не подменяется cleanup error.
+2
View File
@@ -224,3 +224,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма.
Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI.
Завершён 11Б.3.6.2.2.2: core/restart/event outcomes, nonqueued lock/409, DI HTTP/JWT/concurrency/API и browser checks прошли; реальная БД не запускалась. Далее 11Б.3.6.2.3 — отдельная DB форма с source/active/admin/sync lock/unknown review.
+12
View File
@@ -26,3 +26,15 @@ test("restore needs literal receipt; malformed/false/204/HTTP error never retry"
calls=0;globalThis.fetch=async()=>{calls++;return Response.json({error:"restore unknown"},{status:503})};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/restore unknown/);assert.equal(calls,1)
}finally{globalThis.fetch=original}
})
test("backup warning header is strict and malformed metadata preserves valid archive with warning",async()=>{
const original=globalThis.fetch;try{
const warnings={listenerError:"listener warning",schedulerError:"scheduler warning",eventError:"event warning"}
globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"X-System-Database-Outcome":encodeURIComponent(JSON.stringify(warnings))}})
assert.deepEqual((await downloadSystemDatabaseBackup("https://backend.test")).warnings,warnings)
for(const header of ["%","false",encodeURIComponent(JSON.stringify({listenerError:4})),encodeURIComponent(JSON.stringify({error:"failed"}))]){
globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"X-System-Database-Outcome":header}})
const result=await downloadSystemDatabaseBackup("https://backend.test");assert.equal(await result.blob.text(),"PGDMPfixture");assert.match(result.warnings.eventError!,/не подтверждены/)
}
}finally{globalThis.fetch=original}
})
+9 -2
View File
@@ -4,6 +4,8 @@ const MAX_RESTORE_BYTES=512*1024*1024
const magic=[80,71,68,77,80]
const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).optional(),listenerError:z.string().min(1).optional(),schedulerError:z.string().min(1).optional(),cleanupError:z.string().min(1).optional()}).strict()
export type SystemDatabaseRestoreReceipt=z.output<typeof receiptSchema>
const warningSchema=receiptSchema.omit({ok:true})
export type SystemDatabaseWarnings=z.output<typeof warningSchema>
type Options={signal?:AbortSignal}
const signal=(options?:Options)=>options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(600_000)]):AbortSignal.timeout(600_000)
function parseFilename(value:string|null):string{
@@ -21,12 +23,17 @@ async function confirmDump(blob:Blob):Promise<void>{
const head=new Uint8Array(await blob.slice(0,magic.length).arrayBuffer())
if(!magic.every((value,index)=>head[index]===value))throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)")
}
export async function downloadSystemDatabaseBackup(baseUrl:string,options?:Options):Promise<{blob:Blob;filename:string}>{
export async function downloadSystemDatabaseBackup(baseUrl:string,options?:Options):Promise<{blob:Blob;filename:string;warnings:SystemDatabaseWarnings}>{
const activeSignal=signal(options)
const response=await requestBlob(baseUrl,"/api/system/database/backup",{signal:activeSignal})
const blob=await response.blob()
await confirmDump(blob);activeSignal.throwIfAborted()
return {blob,filename:parseFilename(response.headers.get("Content-Disposition"))}
const rawWarnings=response.headers.get("X-System-Database-Outcome")
let warnings:SystemDatabaseWarnings={}
if(rawWarnings){
try{warnings=warningSchema.parse(JSON.parse(decodeURIComponent(rawWarnings)))}catch{warnings={eventError:"Бэкап подготовлен, но дополнительные результаты не подтверждены"}}
}
return {blob,filename:parseFilename(response.headers.get("Content-Disposition")),warnings}
}
export async function restoreSystemDatabaseBackup(baseUrl:string,file:File,options?:Options):Promise<SystemDatabaseRestoreReceipt>{
if(file.size>MAX_RESTORE_BYTES)throw new ApiClientError("Файл больше 512 МБ — уменьшите бэкап или обратитесь к администратору",413)