feat: MVP-5 Sprint 1 - Backup/Restore system
- Add backup types and RecallBackup format - Create backup snapshot engine (createBackupSnapshot) - Add IndexedDB storage for local backups - Implement retention policy (max 10, 30-day cleanup) - Add backup validation and restore logic (merge/replace modes) - Add backup restore UI dialog with preview and confirmation - Add unsaved changes guard hook - Integrate backups section in Settings - Add backup endpoint to export-import API
This commit is contained in:
56
src/lib/backup-policy.ts
Normal file
56
src/lib/backup-policy.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { getBackups } from '@/lib/backup-storage'
|
||||
import { deleteBackup } from '@/lib/backup-storage'
|
||||
import { BackupSource } from '@/types/backup'
|
||||
|
||||
const MAX_AUTOMATIC_BACKUPS = 10
|
||||
const MAX_BACKUP_AGE_DAYS = 30
|
||||
|
||||
function daysAgo(date: Date): number {
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
return diffMs / (1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
export async function shouldCleanup(): Promise<boolean> {
|
||||
const backups = await getBackups()
|
||||
|
||||
const automaticBackups = backups.filter((b) => b.source === 'automatic')
|
||||
if (automaticBackups.length > MAX_AUTOMATIC_BACKUPS) {
|
||||
return true
|
||||
}
|
||||
|
||||
const oldBackups = backups.filter(
|
||||
(b) => daysAgo(new Date(b.createdAt)) > MAX_BACKUP_AGE_DAYS
|
||||
)
|
||||
if (oldBackups.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function cleanupOldBackups(): Promise<number> {
|
||||
const backups = await getBackups()
|
||||
let deletedCount = 0
|
||||
|
||||
// Remove automatic backups exceeding the limit
|
||||
const automaticBackups = backups.filter((b) => b.source === 'automatic')
|
||||
if (automaticBackups.length > MAX_AUTOMATIC_BACKUPS) {
|
||||
const toRemove = automaticBackups.slice(MAX_AUTOMATIC_BACKUPS)
|
||||
for (const backup of toRemove) {
|
||||
await deleteBackup(backup.id)
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Remove backups older than 30 days
|
||||
const recentBackups = await getBackups()
|
||||
for (const backup of recentBackups) {
|
||||
if (daysAgo(new Date(backup.createdAt)) > MAX_BACKUP_AGE_DAYS) {
|
||||
await deleteBackup(backup.id)
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount
|
||||
}
|
||||
78
src/lib/backup-storage.ts
Normal file
78
src/lib/backup-storage.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
|
||||
const DB_NAME = 'recall_backups'
|
||||
const STORE_NAME = 'backups'
|
||||
const DB_VERSION = 1
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveBackup(backup: RecallBackup): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.put(backup)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
tx.oncomplete = () => db.close()
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBackups(): Promise<RecallBackup[]> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.getAll()
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
const backups = (request.result as RecallBackup[]).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
resolve(backups)
|
||||
}
|
||||
tx.oncomplete = () => db.close()
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBackup(id: string): Promise<RecallBackup | null> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.get(id)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result || null)
|
||||
tx.oncomplete = () => db.close()
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteBackup(id: string): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.delete(id)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
tx.oncomplete = () => db.close()
|
||||
})
|
||||
}
|
||||
75
src/lib/backup-validator.ts
Normal file
75
src/lib/backup-validator.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
info?: {
|
||||
noteCount: number
|
||||
tagCount: number
|
||||
createdAt: string
|
||||
source: string
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBackup(data: unknown): ValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return { valid: false, errors: ['Backup must be an object'] }
|
||||
}
|
||||
|
||||
const backup = data as Record<string, unknown>
|
||||
|
||||
if (!validateSchemaVersion(backup as unknown as RecallBackup)) {
|
||||
errors.push('Missing or invalid schemaVersion (expected "1.0")')
|
||||
}
|
||||
|
||||
if (!backup.createdAt || typeof backup.createdAt !== 'string') {
|
||||
errors.push('Missing or invalid createdAt field')
|
||||
}
|
||||
|
||||
if (!backup.source || typeof backup.source !== 'string') {
|
||||
errors.push('Missing or invalid source field')
|
||||
}
|
||||
|
||||
if (!backup.metadata || typeof backup.metadata !== 'object') {
|
||||
errors.push('Missing or invalid metadata field')
|
||||
} else {
|
||||
const metadata = backup.metadata as Record<string, unknown>
|
||||
if (typeof metadata.noteCount !== 'number') {
|
||||
errors.push('Missing or invalid metadata.noteCount')
|
||||
}
|
||||
if (typeof metadata.tagCount !== 'number') {
|
||||
errors.push('Missing or invalid metadata.tagCount')
|
||||
}
|
||||
}
|
||||
|
||||
if (!backup.data || typeof backup.data !== 'object') {
|
||||
errors.push('Missing or invalid data field')
|
||||
} else {
|
||||
const data = backup.data as Record<string, unknown>
|
||||
if (!Array.isArray(data.notes)) {
|
||||
errors.push('Missing or invalid data.notes (expected array)')
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
const metadata = backup.metadata as { noteCount: number; tagCount: number }
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
info: {
|
||||
noteCount: metadata.noteCount,
|
||||
tagCount: metadata.tagCount,
|
||||
createdAt: backup.createdAt as string,
|
||||
source: backup.source as string,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSchemaVersion(backup: RecallBackup): boolean {
|
||||
return backup.schemaVersion === '1.0'
|
||||
}
|
||||
62
src/lib/backup.ts
Normal file
62
src/lib/backup.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { BackupSource, RecallBackup } from '@/types/backup'
|
||||
|
||||
const SCHEMA_VERSION = '1.0'
|
||||
|
||||
export async function createBackupSnapshot(source: BackupSource): Promise<RecallBackup> {
|
||||
const [notes, tags, backlinks, noteVersions] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
include: { tags: { include: { tag: true } } },
|
||||
}),
|
||||
prisma.tag.findMany(),
|
||||
prisma.backlink.findMany(),
|
||||
prisma.noteVersion.findMany(),
|
||||
])
|
||||
|
||||
const exportNotes = notes.map((note) => ({
|
||||
...note,
|
||||
tags: note.tags.map((nt) => nt.tag.name),
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt.toISOString(),
|
||||
}))
|
||||
|
||||
const exportTags = tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
}))
|
||||
|
||||
const exportBacklinks = backlinks.map((bl) => ({
|
||||
id: bl.id,
|
||||
sourceNoteId: bl.sourceNoteId,
|
||||
targetNoteId: bl.targetNoteId,
|
||||
createdAt: bl.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
const exportVersions = noteVersions.map((v) => ({
|
||||
id: v.id,
|
||||
noteId: v.noteId,
|
||||
title: v.title,
|
||||
content: v.content,
|
||||
createdAt: v.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
const backup: RecallBackup = {
|
||||
id: crypto.randomUUID(),
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
source,
|
||||
metadata: {
|
||||
noteCount: notes.length,
|
||||
tagCount: tags.length,
|
||||
versionCount: noteVersions.length,
|
||||
},
|
||||
data: {
|
||||
notes: exportNotes,
|
||||
tags: exportTags,
|
||||
backlinks: exportBacklinks,
|
||||
noteVersions: exportVersions,
|
||||
},
|
||||
}
|
||||
|
||||
return backup
|
||||
}
|
||||
168
src/lib/restore.ts
Normal file
168
src/lib/restore.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createBackupSnapshot } from '@/lib/backup'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
|
||||
interface RestoreResult {
|
||||
success: boolean
|
||||
restored: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export async function restoreBackup(
|
||||
backup: RecallBackup,
|
||||
mode: 'merge' | 'replace'
|
||||
): Promise<RestoreResult> {
|
||||
const errors: string[] = []
|
||||
|
||||
try {
|
||||
if (mode === 'replace') {
|
||||
await createBackupSnapshot('pre-destructive')
|
||||
}
|
||||
|
||||
const notes = backup.data.notes as Array<{
|
||||
id?: string
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
isFavorite?: boolean
|
||||
isPinned?: boolean
|
||||
tags?: string[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}>
|
||||
|
||||
let restored = 0
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const note of notes) {
|
||||
const parseDate = (dateStr: string | undefined): Date => {
|
||||
if (!dateStr) return new Date()
|
||||
const parsed = new Date(dateStr)
|
||||
return isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
}
|
||||
|
||||
const createdAt = parseDate(note.createdAt)
|
||||
const updatedAt = parseDate(note.updatedAt)
|
||||
|
||||
if (mode === 'replace') {
|
||||
if (note.id) {
|
||||
await tx.note.upsert({
|
||||
where: { id: note.id },
|
||||
create: {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
update: {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
}
|
||||
restored++
|
||||
} else {
|
||||
if (note.id) {
|
||||
const existing = await tx.note.findUnique({ where: { id: note.id } })
|
||||
if (existing) {
|
||||
await tx.note.update({
|
||||
where: { id: note.id },
|
||||
data: { title: note.title, content: note.content, updatedAt },
|
||||
})
|
||||
await tx.noteTag.deleteMany({ where: { noteId: note.id } })
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const existingByTitle = await tx.note.findFirst({ where: { title: note.title } })
|
||||
if (existingByTitle) {
|
||||
await tx.note.update({
|
||||
where: { id: existingByTitle.id },
|
||||
data: { content: note.content, updatedAt },
|
||||
})
|
||||
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
restored++
|
||||
}
|
||||
|
||||
const noteId = note.id
|
||||
? (await tx.note.findUnique({ where: { id: note.id } }))?.id
|
||||
: (await tx.note.findFirst({ where: { title: note.title } }))?.id
|
||||
|
||||
if (noteId && note.tags && note.tags.length > 0) {
|
||||
for (const tagName of note.tags) {
|
||||
const tag = await tx.tag.upsert({
|
||||
where: { name: tagName },
|
||||
create: { name: tagName },
|
||||
update: {},
|
||||
})
|
||||
await tx.noteTag.create({
|
||||
data: { noteId, tagId: tag.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (noteId) {
|
||||
const noteRecord = await tx.note.findUnique({ where: { id: noteId } })
|
||||
if (noteRecord) {
|
||||
await syncBacklinks(noteId, noteRecord.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, restored, errors: [] }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
return { success: false, restored: 0, errors: [message] }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user