fix(backups): сохранять неизвестный результат восстановления

This commit is contained in:
Denozordec committed 2026-10-01 16:32:06 +07:00
1 parent 39570656c9
commit 6f152c8dfe
11 files changed
+159 -11

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { ApiClientError } from "@/shared/api/http-client"
import { backupRestoreStorageKey, readBackupRestoreCheckpoint, restoreWasNotApplied } from "./backup-restore-checkpoint"
test("restore checkpoint preserves target and rejects corrupt or incomplete state", () => {
const target = { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router", requestedAt: "2026-10-01T00:00:00Z" }
assert.deepEqual(readBackupRestoreCheckpoint(JSON.stringify(target)), target); assert.equal(readBackupRestoreCheckpoint(null), null)
for (const payload of ["invalid JSON", JSON.stringify({}), JSON.stringify({ ...target, serverId: "1" }), JSON.stringify({ ...target, requestedAt: "bad" })]) assert.throws(() => readBackupRestoreCheckpoint(payload))
assert.notEqual(backupRestoreStorageKey("first"), backupRestoreStorageKey("second"))
})
test("unknown response is not proof that import was not applied", () => {
for (const error of [new Error("timeout"), new ApiClientError("old backend error", 400), new ApiClientError("lost after apply", 502, { outcome: "unknown" }), new ApiClientError("server error", 500)]) assert.equal(restoreWasNotApplied(error), false)
assert.equal(restoreWasNotApplied(new ApiClientError("prepare failed", 400, { outcome: "not-applied" })), true)
assert.equal(restoreWasNotApplied(new ApiClientError("forbidden", 403)), true)
})
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod"
import { ApiClientError } from "@/shared/api/http-client"
export const backupRestoreCheckpointSchema = z.object({ backupId: z.string().min(1), serverId: z.number().int().positive().safe(), filename: z.string().min(1), serverName: z.string().min(1), requestedAt: z.string().datetime({ offset: true }) })
export type BackupRestoreCheckpoint = z.infer<typeof backupRestoreCheckpointSchema>
export function readBackupRestoreCheckpoint(value: string | null) { return value ? backupRestoreCheckpointSchema.parse(JSON.parse(value)) : null }
export function backupRestoreStorageKey(backendUrl: string) { return "mmapp:backup-restore:" + backendUrl }
export function restoreWasNotApplied(error: unknown): boolean {
if (!(error instanceof ApiClientError)) return false
if ([403, 404, 405].includes(error.status)) return true
return error.status === 400 && !!error.payload && typeof error.payload === "object" && "outcome" in error.payload && error.payload.outcome === "not-applied"
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { backupRestoreCheckpointSchema, backupRestoreStorageKey, readBackupRestoreCheckpoint, type BackupRestoreCheckpoint } from "./backup-restore-checkpoint"
export function useBackupRestoreGuard(backendUrl: string, live: boolean) {
const [checkpoint, setCheckpoint] = useState<BackupRestoreCheckpoint | null>(null), current = useRef<BackupRestoreCheckpoint | null>(null)
const [hydrated, setHydrated] = useState(false), [error, setError] = useState<string | null>(null), blocked = useRef(false), alive = useRef(true)
const key = backupRestoreStorageKey(backendUrl)
useEffect(() => {
alive.current = true
queueMicrotask(() => {
if (!alive.current) return
try { const saved = live ? readBackupRestoreCheckpoint(sessionStorage.getItem(key)) : null; current.current = saved; setCheckpoint(saved) }
catch { blocked.current = true; setError("Не удалось прочитать отметку импорта. Проверьте состояние роутера перед новым восстановлением") }
setHydrated(true)
})
return () => { alive.current = false }
}, [key, live])
function begin(target: Omit<BackupRestoreCheckpoint, "requestedAt">) {
if (!live || !hydrated || !alive.current || blocked.current || current.current) return false
const value = backupRestoreCheckpointSchema.parse({ ...target, requestedAt: new Date().toISOString() })
try { sessionStorage.setItem(key, JSON.stringify(value)) }
catch { blocked.current = true; setError("Не удалось сохранить отметку импорта. Запрос не отправлен"); return false }
current.current = value; setCheckpoint(value); return true
}
function clear() {
if (!live || !hydrated || !alive.current) return false
try { sessionStorage.removeItem(key) }
catch { blocked.current = true; setError("Не удалось очистить отметку импорта. Новый импорт заблокирован"); return false }
current.current = null; blocked.current = false; setCheckpoint(null); setError(null); return true
}
return { checkpoint, hydrated, error, begin, clear, canStart: hydrated && !checkpoint && !error }
}