fix(restore): bulk restore preview + queue actions with latest-backup resolver

This commit is contained in:
Charles GTE
2026-07-05 13:50:33 +02:00
parent 60f57a734d
commit 97e2c21d52
@@ -0,0 +1,152 @@
"use server";
import {z} from "zod";
import {and, desc, eq, isNull} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {ServerActionResult} from "@/types/action-type";
import {userAction, ActionError} from "@/lib/safe-actions/actions";
import {getOrganization} from "@/lib/auth/auth";
// Throws if the project isn't in the active org, or any db isn't in the project.
export async function assertDatabasesInOrgProject(projectId: string, databaseIds: string[]) {
const organization = await getOrganization({});
if (!organization) throw new ActionError("No active organization.");
const project = await db.query.project.findFirst({
where: and(
eq(drizzleDb.schemas.project.id, projectId),
eq(drizzleDb.schemas.project.organizationId, organization.id),
),
with: {databases: true},
});
if (!project) throw new ActionError("Project not found.");
const allowed = new Set(project.databases.map((d) => d.id));
for (const id of databaseIds) {
if (!allowed.has(id)) throw new ActionError("Database not in project.");
}
}
export type RestorePreviewRow = {
databaseId: string;
name: string;
backupId?: string;
backupStorageId?: string;
backupDate?: string; // ISO
restorable: boolean;
};
// For each db, latest non-deleted backup whose latest non-deleted backupStorage has status "success".
async function resolveLatestRestorable(projectId: string, databaseIds: string[]): Promise<RestorePreviewRow[]> {
const project = await db.query.project.findFirst({
where: eq(drizzleDb.schemas.project.id, projectId),
with: {databases: true},
});
const nameById = new Map((project?.databases ?? []).map((d) => [d.id, d.name] as const));
const rows: RestorePreviewRow[] = [];
for (const databaseId of databaseIds) {
const backups = await db.query.backup.findMany({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
isNull(drizzleDb.schemas.backup.deletedAt),
),
orderBy: desc(drizzleDb.schemas.backup.createdAt),
});
let picked: RestorePreviewRow | undefined;
for (const backup of backups) {
const storage = await db.query.backupStorage.findFirst({
where: and(
eq(drizzleDb.schemas.backupStorage.backupId, backup.id),
eq(drizzleDb.schemas.backupStorage.status, "success"),
isNull(drizzleDb.schemas.backupStorage.deletedAt),
),
orderBy: desc(drizzleDb.schemas.backupStorage.createdAt),
});
if (storage) {
picked = {
databaseId,
name: nameById.get(databaseId) ?? databaseId,
backupId: backup.id,
backupStorageId: storage.id,
backupDate: backup.createdAt?.toISOString(),
restorable: true,
};
break;
}
}
rows.push(picked ?? {databaseId, name: nameById.get(databaseId) ?? databaseId, restorable: false});
}
return rows;
}
const bulkSchema = z.object({
projectId: z.string().uuid(),
databaseIds: z.array(z.string().uuid()).min(1),
});
export const bulkRestorePreviewAction = userAction
.schema(bulkSchema)
.action(async ({parsedInput}): Promise<ServerActionResult<RestorePreviewRow[]>> => {
try {
const {projectId, databaseIds} = parsedInput;
await assertDatabasesInOrgProject(projectId, databaseIds);
const rows = await resolveLatestRestorable(projectId, databaseIds);
return {success: true, value: rows};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to build restore preview.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const bulkRestoreLatestAction = userAction
.schema(bulkSchema)
.action(async ({parsedInput}): Promise<ServerActionResult<{queued: number; skipped: {databaseId: string; reason: string}[]}>> => {
try {
const {projectId, databaseIds} = parsedInput;
await assertDatabasesInOrgProject(projectId, databaseIds);
const rows = await resolveLatestRestorable(projectId, databaseIds);
const restorable = rows.filter((r) => r.restorable);
const skipped = rows
.filter((r) => !r.restorable)
.map((r) => ({databaseId: r.databaseId, reason: "no successful backup"}));
if (restorable.length > 0) {
await db.insert(drizzleDb.schemas.restoration).values(
restorable.map((r) => ({
databaseId: r.databaseId,
backupId: r.backupId!,
backupStorageId: r.backupStorageId!,
status: "waiting" as const,
})),
);
}
return {
success: true,
value: {queued: restorable.length, skipped},
actionSuccess: {
message: `Queued ${restorable.length} restore(s)${skipped.length ? `, ${skipped.length} skipped` : ""}.`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to queue restores.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});