server/backup/format-adapter.ts
Rama main · réplica de lectura del repositorio original.
import 'server-only'
import { z } from 'zod'
import {
BACKUP_FORMAT,
BACKUP_VERSION_V1,
type BackupEnvelopeV1,
type BackupJsonValue,
type BackupPreviewData,
type BackupPreviewCounts,
type BackupSource,
type BackupValidationIssue,
} from '~/types/backup'
const INCLUDED_SCOPE = ['configs', 'albums', 'images', 'imageAlbumRelations'] as const
const EXCLUDED_SCOPE = [
'user',
'session',
'account',
'two_factor',
'passkey',
'verification',
'admin_task_runs',
'daily_images',
] as const
const emptyCounts: BackupPreviewCounts = {
configs: 0,
albums: 0,
images: 0,
imageAlbumRelations: 0,
}
const isoDateStringSchema = z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Expected a valid ISO datetime string',
})
const backupJsonSchema: z.ZodType<BackupJsonValue> = z.lazy(() => z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(backupJsonSchema),
z.record(z.string(), backupJsonSchema),
]))
const backupSourceSchema: z.ZodType<BackupSource> = z.object({
orm: z.enum(['prisma', 'drizzle', 'unknown']),
database: z.literal('postgresql'),
}).strict()
const backupConfigRecordSchema = z.object({
config_key: z.string().min(1),
config_value: z.string().nullable(),
detail: z.string().nullable(),
createdAt: isoDateStringSchema,
updatedAt: isoDateStringSchema.nullable(),
}).strict()
const backupAlbumRecordSchema = z.object({
album_value: z.string().min(1),
name: z.string().min(1),
detail: z.string().nullable(),
theme: z.string(),
show: z.number().int(),
sort: z.number().int(),
random_show: z.number().int(),
license: z.string().nullable(),
image_sorting: z.number().int(),
daily_weight: z.number().int(),
del: z.number().int(),
createdAt: isoDateStringSchema,
updatedAt: isoDateStringSchema.nullable(),
}).strict()
const backupImageRecordSchema = z.object({
id: z.string().min(1),
image_name: z.string().nullable(),
url: z.string().nullable(),
preview_url: z.string().nullable(),
video_url: z.string().nullable(),
blurhash: z.string().nullable(),
exif: backupJsonSchema.nullable(),
labels: backupJsonSchema.nullable(),
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
lon: z.string().nullable(),
lat: z.string().nullable(),
title: z.string().nullable(),
detail: z.string().nullable(),
type: z.number().int(),
show: z.number().int(),
show_on_mainpage: z.number().int(),
sort: z.number().int(),
del: z.number().int(),
createdAt: isoDateStringSchema,
updatedAt: isoDateStringSchema.nullable(),
}).strict()
const backupImageAlbumRelationRecordSchema = z.object({
imageId: z.string().min(1),
album_value: z.string().min(1),
}).strict()
const backupEnvelopeV1Schema: z.ZodType<BackupEnvelopeV1> = z.object({
format: z.literal(BACKUP_FORMAT),
version: z.literal(BACKUP_VERSION_V1),
exportedAt: isoDateStringSchema,
source: backupSourceSchema,
payload: z.object({
configs: z.array(backupConfigRecordSchema),
albums: z.array(backupAlbumRecordSchema),
images: z.array(backupImageRecordSchema),
imageAlbumRelations: z.array(backupImageAlbumRelationRecordSchema),
}).strict(),
}).strict()
function createPreviewData(partial?: Partial<BackupPreviewData>): BackupPreviewData {
return {
valid: false,
format: null,
version: null,
exportedAt: null,
source: null,
scope: {
included: [...INCLUDED_SCOPE],
excluded: [...EXCLUDED_SCOPE],
},
counts: emptyCounts,
warnings: [],
issues: [],
...partial,
}
}
function zodPathToString(path: readonly PropertyKey[]): string {
if (path.length === 0) {
return '$'
}
return path.reduce<string>((result, segment) => {
if (typeof segment === 'number') {
return `${result}[${segment}]`
}
const printableSegment = typeof segment === 'string' ? segment : String(segment)
return `${result}.${printableSegment}`
}, '$')
}
function zodIssuesToValidationIssues(error: z.ZodError): BackupValidationIssue[] {
return error.issues.map((issue) => ({
path: zodPathToString(issue.path),
message: issue.message,
}))
}
// eslint-disable-next-line no-unused-vars
type DuplicateKeySelector<T> = (value: T) => string
// eslint-disable-next-line no-unused-vars
type DuplicatePathSelector = (position: number) => string
function appendDuplicateIssues<T>(
issues: BackupValidationIssue[],
items: T[],
getKey: DuplicateKeySelector<T>,
getPath: DuplicatePathSelector,
label: string,
) {
const seen = new Map<string, number>()
for (let index = 0; index < items.length; index += 1) {
const key = getKey(items[index])
const existingIndex = seen.get(key)
if (existingIndex !== undefined) {
issues.push({
path: getPath(index),
message: `Duplicate ${label}: "${key}" also appears at index ${existingIndex}`,
})
continue
}
seen.set(key, index)
}
}
function getPreviewCounts(envelope: BackupEnvelopeV1): BackupPreviewCounts {
return {
configs: envelope.payload.configs.length,
albums: envelope.payload.albums.length,
images: envelope.payload.images.length,
imageAlbumRelations: envelope.payload.imageAlbumRelations.length,
}
}
function buildWarnings(envelope: BackupEnvelopeV1) {
const warnings = [
'This backup includes sensitive configuration values such as storage credentials and secret keys.',
'Authentication data, sessions, passkeys, and other Better Auth tables are excluded.',
'Object storage files are not included; only database records and URLs are backed up.',
]
if (envelope.source.orm === 'unknown') {
warnings.push('The backup source ORM is marked as unknown; import still uses the versioned PicImpact contract.')
}
return warnings
}
function appendRelationReferenceIssues(envelope: BackupEnvelopeV1, issues: BackupValidationIssue[]) {
const imageIds = new Set(envelope.payload.images.map((item) => item.id))
const albumValues = new Set(envelope.payload.albums.map((item) => item.album_value))
envelope.payload.imageAlbumRelations.forEach((relation, index) => {
if (!imageIds.has(relation.imageId)) {
issues.push({
path: `$.payload.imageAlbumRelations[${index}].imageId`,
message: `Referenced image "${relation.imageId}" does not exist in payload.images`,
})
}
if (!albumValues.has(relation.album_value)) {
issues.push({
path: `$.payload.imageAlbumRelations[${index}].album_value`,
message: `Referenced album "${relation.album_value}" does not exist in payload.albums`,
})
}
})
}
function createValidationError(message: string, preview: BackupPreviewData) {
return new BackupValidationError(message, preview)
}
export class BackupValidationError extends Error {
preview: BackupPreviewData
constructor(message: string, preview: BackupPreviewData) {
super(message)
this.name = 'BackupValidationError'
this.preview = preview
}
}
export function parseBackupEnvelope(input: unknown) {
const baseResult = z.object({
format: z.string().nullable().optional(),
version: z.number().int().nullable().optional(),
exportedAt: z.string().nullable().optional(),
source: backupSourceSchema.nullable().optional(),
}).safeParse(input)
if (!baseResult.success) {
throw createValidationError('Invalid backup package', createPreviewData({
issues: zodIssuesToValidationIssues(baseResult.error),
}))
}
const base = baseResult.data
if (base.format !== BACKUP_FORMAT) {
throw createValidationError('Unsupported backup format', createPreviewData({
format: base.format ?? null,
version: base.version ?? null,
exportedAt: base.exportedAt ?? null,
source: base.source ?? null,
issues: [{
path: '$.format',
message: `Expected "${BACKUP_FORMAT}"`,
}],
}))
}
if (base.version !== BACKUP_VERSION_V1) {
throw createValidationError('Unsupported backup version', createPreviewData({
format: base.format,
version: base.version ?? null,
exportedAt: base.exportedAt ?? null,
source: base.source ?? null,
issues: [{
path: '$.version',
message: `Only backup version ${BACKUP_VERSION_V1} is supported`,
}],
}))
}
const envelopeResult = backupEnvelopeV1Schema.safeParse(input)
if (!envelopeResult.success) {
throw createValidationError('Invalid backup package', createPreviewData({
format: base.format,
version: base.version,
exportedAt: base.exportedAt ?? null,
source: base.source ?? null,
issues: zodIssuesToValidationIssues(envelopeResult.error),
}))
}
const envelope = envelopeResult.data
const semanticIssues: BackupValidationIssue[] = []
appendDuplicateIssues(
semanticIssues,
envelope.payload.configs,
(item) => item.config_key,
(index) => `$.payload.configs[${index}].config_key`,
'config_key',
)
appendDuplicateIssues(
semanticIssues,
envelope.payload.albums,
(item) => item.album_value,
(index) => `$.payload.albums[${index}].album_value`,
'album_value',
)
appendDuplicateIssues(
semanticIssues,
envelope.payload.images,
(item) => item.id,
(index) => `$.payload.images[${index}].id`,
'image.id',
)
appendDuplicateIssues(
semanticIssues,
envelope.payload.imageAlbumRelations,
(item) => `${item.imageId}::${item.album_value}`,
(index) => `$.payload.imageAlbumRelations[${index}]`,
'image-album relation',
)
appendRelationReferenceIssues(envelope, semanticIssues)
const preview = createPreviewData({
valid: semanticIssues.length === 0,
format: envelope.format,
version: envelope.version,
exportedAt: envelope.exportedAt,
source: envelope.source,
counts: getPreviewCounts(envelope),
warnings: buildWarnings(envelope),
issues: semanticIssues,
})
if (semanticIssues.length > 0) {
throw createValidationError('Invalid backup package', preview)
}
return {
envelope,
preview,
}
}