server/backup/prisma-repository.ts
Rama main · réplica de lectura del repositorio original.
import 'server-only'
import { Prisma } from '@prisma/client'
import { db } from '~/server/lib/db'
import type {
BackupAlbumRecord,
BackupConfigRecord,
BackupImageAlbumRelationRecord,
BackupImageRecord,
BackupJsonValue,
BackupPayloadV1,
BackupSource,
} from '~/types/backup'
import type { BackupRepository, BackupRepositoryImportResult } from '~/server/backup/repository'
const IMPORT_TRANSACTION_TIMEOUT_MS = 120_000
function toIsoString(value: Date | null | undefined) {
return value ? value.toISOString() : null
}
function toBackupJson(value: Prisma.JsonValue | null): BackupJsonValue | null {
return value as BackupJsonValue | null
}
function toPrismaJson(value: BackupJsonValue | null): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
if (value === null) {
return Prisma.DbNull
}
return value as Prisma.InputJsonValue
}
function countCreatedAndUpdated(existingKeys: Set<string>, incomingKeys: string[]) {
let createdCount = 0
let updatedCount = 0
for (const key of incomingKeys) {
if (existingKeys.has(key)) {
updatedCount += 1
} else {
createdCount += 1
}
}
return {
totalCount: incomingKeys.length,
createdCount,
updatedCount,
}
}
function createRelationSet(relations: BackupImageAlbumRelationRecord[]) {
return new Set(relations.map((item) => `${item.imageId}::${item.album_value}`))
}
function groupRelationAlbumValues(relations: Array<{ imageId: string; album_value: string }>) {
const map = new Map<string, Set<string>>()
for (const relation of relations) {
const relationSet = map.get(relation.imageId) ?? new Set<string>()
relationSet.add(relation.album_value)
map.set(relation.imageId, relationSet)
}
return map
}
function areRelationSetsEqual(left: Set<string>, right: Set<string>) {
if (left.size !== right.size) {
return false
}
for (const value of left) {
if (!right.has(value)) {
return false
}
}
return true
}
function mapConfigRecord(config: {
config_key: string;
config_value: string | null;
detail: string | null;
createdAt: Date;
updatedAt: Date | null;
}): BackupConfigRecord {
return {
config_key: config.config_key,
config_value: config.config_value,
detail: config.detail,
createdAt: config.createdAt.toISOString(),
updatedAt: toIsoString(config.updatedAt),
}
}
function mapAlbumRecord(album: {
album_value: string;
name: string;
detail: string | null;
theme: string;
show: number;
sort: number;
random_show: number;
license: string | null;
image_sorting: number;
daily_weight: number;
del: number;
createdAt: Date;
updatedAt: Date | null;
}): BackupAlbumRecord {
return {
album_value: album.album_value,
name: album.name,
detail: album.detail,
theme: album.theme,
show: album.show,
sort: album.sort,
random_show: album.random_show,
license: album.license,
image_sorting: album.image_sorting,
daily_weight: album.daily_weight,
del: album.del,
createdAt: album.createdAt.toISOString(),
updatedAt: toIsoString(album.updatedAt),
}
}
function mapImageRecord(image: {
id: string;
image_name: string | null;
url: string | null;
preview_url: string | null;
video_url: string | null;
blurhash: string | null;
exif: Prisma.JsonValue | null;
labels: Prisma.JsonValue | null;
width: number;
height: number;
lon: string | null;
lat: string | null;
title: string | null;
detail: string | null;
type: number;
show: number;
show_on_mainpage: number;
sort: number;
del: number;
createdAt: Date;
updatedAt: Date | null;
}): BackupImageRecord {
return {
id: image.id,
image_name: image.image_name,
url: image.url,
preview_url: image.preview_url,
video_url: image.video_url,
blurhash: image.blurhash,
exif: toBackupJson(image.exif),
labels: toBackupJson(image.labels),
width: image.width,
height: image.height,
lon: image.lon,
lat: image.lat,
title: image.title,
detail: image.detail,
type: image.type,
show: image.show,
show_on_mainpage: image.show_on_mainpage,
sort: image.sort,
del: image.del,
createdAt: image.createdAt.toISOString(),
updatedAt: toIsoString(image.updatedAt),
}
}
export class PrismaBackupRepository implements BackupRepository {
getSource(): BackupSource {
return {
orm: 'prisma',
database: 'postgresql',
}
}
async exportSnapshot(): Promise<BackupPayloadV1> {
const [configs, albums, images, imageAlbumRelations] = await Promise.all([
db.configs.findMany({
orderBy: [{ config_key: 'asc' }],
select: {
config_key: true,
config_value: true,
detail: true,
createdAt: true,
updatedAt: true,
},
}),
db.albums.findMany({
orderBy: [{ sort: 'desc' }, { createdAt: 'asc' }, { album_value: 'asc' }],
select: {
album_value: true,
name: true,
detail: true,
theme: true,
show: true,
sort: true,
random_show: true,
license: true,
image_sorting: true,
daily_weight: true,
del: true,
createdAt: true,
updatedAt: true,
},
}),
db.images.findMany({
orderBy: [{ sort: 'desc' }, { createdAt: 'asc' }, { id: 'asc' }],
select: {
id: true,
image_name: true,
url: true,
preview_url: true,
video_url: true,
blurhash: true,
exif: true,
labels: true,
width: true,
height: true,
lon: true,
lat: true,
title: true,
detail: true,
type: true,
show: true,
show_on_mainpage: true,
sort: true,
del: true,
createdAt: true,
updatedAt: true,
},
}),
db.imagesAlbumsRelation.findMany({
orderBy: [{ imageId: 'asc' }, { album_value: 'asc' }],
select: {
imageId: true,
album_value: true,
},
}),
])
return {
configs: configs.map(mapConfigRecord),
albums: albums.map(mapAlbumRecord),
images: images.map(mapImageRecord),
imageAlbumRelations,
}
}
async importSnapshot(snapshot: BackupPayloadV1): Promise<BackupRepositoryImportResult> {
const configKeys = snapshot.configs.map((item) => item.config_key)
const albumValues = snapshot.albums.map((item) => item.album_value)
const imageIds = snapshot.images.map((item) => item.id)
const importedRelationSet = createRelationSet(snapshot.imageAlbumRelations)
const [existingConfigs, existingAlbums, existingImages, existingRelations] = await Promise.all([
configKeys.length > 0
? db.configs.findMany({
where: { config_key: { in: configKeys } },
select: { config_key: true },
})
: Promise.resolve([]),
albumValues.length > 0
? db.albums.findMany({
where: { album_value: { in: albumValues } },
select: { album_value: true },
})
: Promise.resolve([]),
imageIds.length > 0
? db.images.findMany({
where: { id: { in: imageIds } },
select: { id: true },
})
: Promise.resolve([]),
imageIds.length > 0
? db.imagesAlbumsRelation.findMany({
where: { imageId: { in: imageIds } },
select: { imageId: true, album_value: true },
})
: Promise.resolve([]),
])
const configStats = countCreatedAndUpdated(
new Set(existingConfigs.map((item) => item.config_key)),
configKeys,
)
const albumStats = countCreatedAndUpdated(
new Set(existingAlbums.map((item) => item.album_value)),
albumValues,
)
const imageStats = countCreatedAndUpdated(
new Set(existingImages.map((item) => item.id)),
imageIds,
)
const existingRelationSet = new Set(existingRelations.map((item) => `${item.imageId}::${item.album_value}`))
const existingRelationsByImage = groupRelationAlbumValues(existingRelations)
const importedRelationsByImage = groupRelationAlbumValues(snapshot.imageAlbumRelations)
let addedCount = 0
let unchangedCount = 0
let removedCount = 0
let replacedImageCount = 0
for (const relationKey of importedRelationSet) {
if (existingRelationSet.has(relationKey)) {
unchangedCount += 1
} else {
addedCount += 1
}
}
for (const relationKey of existingRelationSet) {
if (!importedRelationSet.has(relationKey)) {
removedCount += 1
}
}
for (const [imageId, importedAlbumValues] of importedRelationsByImage) {
const existingAlbumValues = existingRelationsByImage.get(imageId)
if (existingAlbumValues && !areRelationSetsEqual(existingAlbumValues, importedAlbumValues)) {
replacedImageCount += 1
}
}
const importedAt = new Date()
await db.$transaction(async (tx) => {
for (const config of snapshot.configs) {
await tx.configs.upsert({
where: {
config_key: config.config_key,
},
update: {
config_value: config.config_value,
detail: config.detail,
createdAt: new Date(config.createdAt),
},
create: {
config_key: config.config_key,
config_value: config.config_value,
detail: config.detail,
createdAt: new Date(config.createdAt),
updatedAt: config.updatedAt ? new Date(config.updatedAt) : undefined,
},
})
}
for (const album of snapshot.albums) {
await tx.albums.upsert({
where: {
album_value: album.album_value,
},
update: {
name: album.name,
detail: album.detail,
theme: album.theme,
show: album.show,
sort: album.sort,
random_show: album.random_show,
license: album.license,
image_sorting: album.image_sorting,
daily_weight: album.daily_weight,
del: album.del,
createdAt: new Date(album.createdAt),
},
create: {
album_value: album.album_value,
name: album.name,
detail: album.detail,
theme: album.theme,
show: album.show,
sort: album.sort,
random_show: album.random_show,
license: album.license,
image_sorting: album.image_sorting,
daily_weight: album.daily_weight,
del: album.del,
createdAt: new Date(album.createdAt),
updatedAt: album.updatedAt ? new Date(album.updatedAt) : undefined,
},
})
}
for (const image of snapshot.images) {
await tx.images.upsert({
where: {
id: image.id,
},
update: {
image_name: image.image_name,
url: image.url,
preview_url: image.preview_url,
video_url: image.video_url,
blurhash: image.blurhash,
exif: toPrismaJson(image.exif),
labels: toPrismaJson(image.labels),
width: image.width,
height: image.height,
lon: image.lon,
lat: image.lat,
title: image.title,
detail: image.detail,
type: image.type,
show: image.show,
show_on_mainpage: image.show_on_mainpage,
sort: image.sort,
del: image.del,
createdAt: new Date(image.createdAt),
},
create: {
id: image.id,
image_name: image.image_name,
url: image.url,
preview_url: image.preview_url,
video_url: image.video_url,
blurhash: image.blurhash,
exif: toPrismaJson(image.exif),
labels: toPrismaJson(image.labels),
width: image.width,
height: image.height,
lon: image.lon,
lat: image.lat,
title: image.title,
detail: image.detail,
type: image.type,
show: image.show,
show_on_mainpage: image.show_on_mainpage,
sort: image.sort,
del: image.del,
createdAt: new Date(image.createdAt),
updatedAt: image.updatedAt ? new Date(image.updatedAt) : undefined,
},
})
}
if (imageIds.length > 0) {
await tx.imagesAlbumsRelation.deleteMany({
where: {
imageId: {
in: imageIds,
},
},
})
}
if (snapshot.imageAlbumRelations.length > 0) {
await tx.imagesAlbumsRelation.createMany({
data: snapshot.imageAlbumRelations,
skipDuplicates: true,
})
}
await tx.$executeRaw`REFRESH MATERIALIZED VIEW "daily_images"`
await tx.configs.upsert({
where: {
config_key: 'daily_last_refresh',
},
update: {
config_value: importedAt.toISOString(),
detail: 'Daily homepage last refresh time',
createdAt: importedAt,
},
create: {
config_key: 'daily_last_refresh',
config_value: importedAt.toISOString(),
detail: 'Daily homepage last refresh time',
createdAt: importedAt,
updatedAt: importedAt,
},
})
}, {
maxWait: 10_000,
timeout: IMPORT_TRANSACTION_TIMEOUT_MS,
})
return {
entities: {
configs: configStats,
albums: albumStats,
images: imageStats,
imageAlbumRelations: {
totalCount: snapshot.imageAlbumRelations.length,
addedCount,
unchangedCount,
replacedImageCount,
removedCount,
},
},
dailyRefreshAt: importedAt.toISOString(),
}
}
}