diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index ca8b2586..770d1542 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -89,13 +89,13 @@ export default async function RoutePage(props: PageParams<{
{capitalizeFirstLetter(dbItem.name)} - - - + + + + -
{dbItem.description} diff --git a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action.tsx b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action.tsx new file mode 100644 index 00000000..e0212d5f --- /dev/null +++ b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action.tsx @@ -0,0 +1,65 @@ +"use server" + +import {userAction} from "@/safe-actions" +import {z} from "zod" +import {db} from "@/db" +import {eq} from "drizzle-orm" +import {retentionSettingsSchema} from "@/components/wrappers/dashboard/database/retention-policy/schema"; +import * as drizzleDb from "@/db"; + + +export const updateOrCreateBackupRetentionPolicyAction = userAction + .schema( + z.object({ + databaseId: z.string(), + settings: retentionSettingsSchema, + }) + ) + .action(async ({parsedInput}) => { + const {databaseId, settings} = parsedInput + + // Check if a retention policy already exists + const existing = await db + .select() + .from(drizzleDb.schemas.retentionPolicy) + .where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId)) + .limit(1) + + let updated + + if (existing.length > 0) { + // Update existing policy + updated = await db + .update(drizzleDb.schemas.retentionPolicy) + .set({ + type: settings.type, + count: settings.count, + days: settings.days, + gfsDaily: settings.gfs.daily, + gfsWeekly: settings.gfs.weekly, + gfsMonthly: settings.gfs.monthly, + gfsYearly: settings.gfs.yearly, + }) + .where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId)) + .returning() + } else { + // Insert new policy + updated = await db + .insert(drizzleDb.schemas.retentionPolicy) + .values({ + databaseId, + type: settings.type, + count: settings.count, + days: settings.days, + gfsDaily: settings.gfs.daily, + gfsWeekly: settings.gfs.weekly, + gfsMonthly: settings.gfs.monthly, + gfsYearly: settings.gfs.yearly, + }) + .returning() + } + + return { + data: updated[0], + } + }) diff --git a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.tsx b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.tsx index 695fc121..5f41c41c 100644 --- a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.tsx +++ b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.tsx @@ -1,15 +1,20 @@ "use client" -import { useState } from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Label } from "@/components/ui/label" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -import { Clock, Database, Save, Settings, Calendar, RotateCcw } from "lucide-react" +import {useState} from "react" +import {Label} from "@/components/ui/label" +import {Button} from "@/components/ui/button" +import {Badge} from "@/components/ui/badge" +import {Input} from "@/components/ui/input" +import {Separator} from "@/components/ui/separator" +import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group" +import {Clock, Database, Save, Settings, Calendar, RotateCcw} from "lucide-react" import {toast} from "sonner"; +import {useMutation} from "@tanstack/react-query"; +import {useRouter} from "next/navigation"; +import { + updateOrCreateBackupRetentionPolicyAction +} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action"; +import {Database as DbSchema} from "@/db/schema/07_database"; type RetentionPolicyType = "count" | "days" | "gfs" @@ -20,14 +25,19 @@ interface GFSSettings { yearly: number } -interface RetentionSettings { +export interface RetentionSettings { type: RetentionPolicyType count: number days: number gfs: GFSSettings } -export function BackupRetentionSettings() { +type BackupRetentionSettingsProps = { + database: DbSchema, +} + + +export function BackupRetentionSettings({database}: BackupRetentionSettingsProps) { const [settings, setSettings] = useState({ type: "gfs", count: 7, @@ -39,20 +49,25 @@ export function BackupRetentionSettings() { yearly: 3, }, }) - const [isLoading, setIsLoading] = useState(false) + const router = useRouter() - const handleSave = async () => { - setIsLoading(true) - try { - await new Promise((resolve) => setTimeout(resolve, 1000)) - toast.success("Your backup retention settings have been saved successfully.") - } catch (error) { - toast.error("Failed to save retention settings") - } finally { - setIsLoading(false) - } + const updateRetentionPolicy = useMutation({ + mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({ + databaseId: database.id, + settings: payload + }), + onSuccess: () => { + toast.success("Retention policy updated successfully.") + router.refresh() + }, + onError: () => { + toast.error("An error occurred while updating retention policy.") + }, + }) + + const handleSave = () => { + updateRetentionPolicy.mutate(settings) } - const calculateTotalFiles = () => { if (settings.type === "gfs") { return settings.gfs.daily + settings.gfs.weekly + settings.gfs.monthly + settings.gfs.yearly @@ -76,11 +91,15 @@ export function BackupRetentionSettings() { setSettings((prev) => ({ ...prev, type: value }))} + onValueChange={(value: RetentionPolicyType) => setSettings((prev) => ({ + ...prev, + type: value + }))} className="grid grid-cols-1 gap-4" > -
- +
+
-
- +
+
-

Time-based retention (e.g., keep backups for 30 days)

+

Time-based retention (e.g., keep + backups for 30 days)

-
- +
+
- + {settings.type === "count" && (
- +
@@ -134,7 +156,10 @@ export function BackupRetentionSettings() { min="1" max="100" value={settings.count} - onChange={(e) => setSettings((prev) => ({ ...prev, count: Number.parseInt(e.target.value) || 1 }))} + onChange={(e) => setSettings((prev) => ({ + ...prev, + count: Number.parseInt(e.target.value) || 1 + }))} className="w-32" />

@@ -147,7 +172,7 @@ export function BackupRetentionSettings() { {settings.type === "days" && (

- +
@@ -158,7 +183,10 @@ export function BackupRetentionSettings() { min="1" max="3650" value={settings.days} - onChange={(e) => setSettings((prev) => ({ ...prev, days: Number.parseInt(e.target.value) || 1 }))} + onChange={(e) => setSettings((prev) => ({ + ...prev, + days: Number.parseInt(e.target.value) || 1 + }))} className="w-32" />

@@ -171,7 +199,7 @@ export function BackupRetentionSettings() { {settings.type === "gfs" && (

- +
@@ -186,7 +214,7 @@ export function BackupRetentionSettings() { onChange={(e) => setSettings((prev) => ({ ...prev, - gfs: { ...prev.gfs, daily: Number.parseInt(e.target.value) || 1 }, + gfs: {...prev.gfs, daily: Number.parseInt(e.target.value) || 1}, })) } /> @@ -204,7 +232,7 @@ export function BackupRetentionSettings() { onChange={(e) => setSettings((prev) => ({ ...prev, - gfs: { ...prev.gfs, weekly: Number.parseInt(e.target.value) || 0 }, + gfs: {...prev.gfs, weekly: Number.parseInt(e.target.value) || 0}, })) } /> @@ -222,7 +250,7 @@ export function BackupRetentionSettings() { onChange={(e) => setSettings((prev) => ({ ...prev, - gfs: { ...prev.gfs, monthly: Number.parseInt(e.target.value) || 0 }, + gfs: {...prev.gfs, monthly: Number.parseInt(e.target.value) || 0}, })) } /> @@ -240,7 +268,7 @@ export function BackupRetentionSettings() { onChange={(e) => setSettings((prev) => ({ ...prev, - gfs: { ...prev.gfs, yearly: Number.parseInt(e.target.value) || 0 }, + gfs: {...prev.gfs, yearly: Number.parseInt(e.target.value) || 0}, })) } /> @@ -259,12 +287,12 @@ export function BackupRetentionSettings() {
)} - +
- + Storage Impact Summary
{settings.type === "gfs" && ( -
- Note: With GFS rotation, you'll have approximately {calculateTotalFiles()} files per +
+ Note: With GFS rotation, you'll have + approximately {calculateTotalFiles()} files per database per year, providing excellent long-term retention with optimized storage usage.
)}
-
diff --git a/src/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet.tsx b/src/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet.tsx index cd03430c..f85dab20 100644 --- a/src/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet.tsx +++ b/src/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet.tsx @@ -4,9 +4,13 @@ import {Database, DatabaseZap} from "lucide-react"; import { BackupRetentionSettings } from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings"; +import {Database as DbSchema} from "@/db/schema/07_database"; +type RetentionPolicySheetProps = { + database: DbSchema +} -export const RetentionPolicySheet = () => { +export const RetentionPolicySheet = (props: RetentionPolicySheetProps) => { return ( @@ -19,15 +23,16 @@ export const RetentionPolicySheet = () => { > - + Backup Retention Policy - Configure how long to keep your .dump backup files. Choose from simple count-based, time-based, or + Configure how long to keep your .dump backup files. Choose from simple count-based, time-based, + or enterprise GFS rotation strategies. - + ) diff --git a/src/components/wrappers/dashboard/database/retention-policy/schema.ts b/src/components/wrappers/dashboard/database/retention-policy/schema.ts new file mode 100644 index 00000000..032bf973 --- /dev/null +++ b/src/components/wrappers/dashboard/database/retention-policy/schema.ts @@ -0,0 +1,18 @@ +// --- Zod Schema --- +import {z} from "zod"; + +export const gfsSettingsSchema = z.object({ + daily: z.number().min(1).max(31), + weekly: z.number().min(0).max(52), + monthly: z.number().min(0).max(120), + yearly: z.number().min(0).max(50), +}) + +export const retentionSettingsSchema = z.object({ + type: z.enum(["count", "days", "gfs"]), + count: z.number().min(1).max(100), + days: z.number().min(1).max(3650), + gfs: gfsSettingsSchema, +}) + +export type RetentionSettings = z.infer diff --git a/src/db/schema/07_database.ts b/src/db/schema/07_database.ts index aaeceb42..c23addbe 100644 --- a/src/db/schema/07_database.ts +++ b/src/db/schema/07_database.ts @@ -69,7 +69,10 @@ export const restoration = pgTable("restorations", { }); export const databaseRelations = relations(database, ({one, many}) => ({ - retentionPolicy: one(retentionPolicy), + retentionPolicy: one(retentionPolicy, { + fields: [database.id], + references: [retentionPolicy.databaseId], + }), agent: one(agent, {fields: [database.agentId], references: [agent.id]}), project: one(project, {fields: [database.projectId], references: [project.id]}), backups: many(backup), @@ -86,6 +89,14 @@ export const restorationRelations = relations(restoration, ({one}) => ({ database: one(database, {fields: [restoration.databaseId], references: [database.id]}), })); + +export const retentionPolicyRelations = relations(retentionPolicy, ({one}) => ({ + database: one(database, { + fields: [retentionPolicy.databaseId], + references: [database.id], + }), +})); + export const databaseSchema = createSelectSchema(database); export type Database = z.infer; diff --git a/src/features/dashboard/backup/columns.tsx b/src/features/dashboard/backup/columns.tsx index 48c0b446..427c4cfa 100644 --- a/src/features/dashboard/backup/columns.tsx +++ b/src/features/dashboard/backup/columns.tsx @@ -37,6 +37,14 @@ export function backupColumns(isAlreadyRestore: boolean, settings: Setting, data accessorKey: "id", header: "Reference", }, + { + accessorKey: "deletedAt", + header: "Deleted At", + cell: ({row}) => { + + return row.original.deletedAt ? formatFrenchDate(row.getValue("deletedAt")) : "" + }, + }, { accessorKey: "createdAt", header: "Created At", diff --git a/src/lib/tasks/database/index.ts b/src/lib/tasks/database/index.ts index f0b0881d..84fa5392 100644 --- a/src/lib/tasks/database/index.ts +++ b/src/lib/tasks/database/index.ts @@ -3,6 +3,8 @@ import {enforceRetentionCount} from "@/lib/tasks/database/retention-count"; import {enforceRetentionDays} from "@/lib/tasks/database/retention-days"; import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf"; import {retentionPolicy} from "@/db/schema/07_database"; +import {eq, isNull} from "drizzle-orm"; +import * as drizzleDb from "@/db"; export const retentionCleanTask = async () => { @@ -10,13 +12,16 @@ export const retentionCleanTask = async () => { const databases = await db.query.database.findMany({ with: { retentionPolicy: true, - backups: true, + backups: { + where: isNull(drizzleDb.schemas.backup.deletedAt), + + }, }, }); + console.log(databases); for (const db of databases) { if (!db.retentionPolicy) continue; // no policy = skip - await enforceRetention(db.id, db.retentionPolicy); } } catch (e: any) { @@ -29,6 +34,8 @@ export async function enforceRetention( databaseId: string, policy: typeof retentionPolicy.$inferSelect ) { + console.log(`EnforceRetention: ${policy}`); + switch (policy.type) { case "count": await enforceRetentionCount(databaseId, policy.count ?? 7); diff --git a/src/lib/tasks/database/retention-count.ts b/src/lib/tasks/database/retention-count.ts index d3c47d7b..752a6ee8 100644 --- a/src/lib/tasks/database/retention-count.ts +++ b/src/lib/tasks/database/retention-count.ts @@ -1,17 +1,43 @@ import * as drizzleDb from "@/db"; import {db} from "@/db"; import {desc, eq} from "drizzle-orm"; +import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; export async function enforceRetentionCount(databaseId: string, count: number) { const backups = await db.query.backup.findMany({ where: eq(drizzleDb.schemas.backup.databaseId, databaseId), orderBy: desc(drizzleDb.schemas.backup.createdAt), + with:{ + database: { + with: { + project: true + } + } + } }); const toDelete = backups.slice(count); // keep first `count`, delete rest for (const b of toDelete) { - await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id)); - // TODO: delete backup file from storage + // await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id)); + + const deletion = await deleteBackupCronAction({ + backupId: b.id, + databaseId: b.databaseId, + file: b.file!, + projectSlug: b.database.project?.slug! + }); + + // @ts-ignore + if (deletion.data.success) { + // @ts-ignore + console.log(deletion.data.actionSuccess.message); + } else { + // @ts-ignore + console.log(deletion.data.actionError.message); + } + + + } } \ No newline at end of file diff --git a/src/lib/tasks/database/retention-days.ts b/src/lib/tasks/database/retention-days.ts index c233e8bc..78fee581 100644 --- a/src/lib/tasks/database/retention-days.ts +++ b/src/lib/tasks/database/retention-days.ts @@ -1,14 +1,45 @@ import {db} from "@/db"; -import {eq, lt} from "drizzle-orm"; +import {eq, lt, and, desc} from "drizzle-orm"; import * as drizzleDb from "@/db"; +import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; export async function enforceRetentionDays(databaseId: string, days: number) { - const cutoff = new Date(Date.now() - days * 86400000); // days → ms + const cutoff = new Date(Date.now() - days * 86400000); - await db.delete(drizzleDb.schemas.backup).where( - eq(drizzleDb.schemas.backup.databaseId, databaseId) && - lt(drizzleDb.schemas.backup.createdAt, cutoff) - ); + const expiredBackups = await db.query.backup.findMany({ + where: and( + eq(drizzleDb.schemas.backup.databaseId, databaseId), + lt(drizzleDb.schemas.backup.createdAt, cutoff) + ), + with: { + database: { + with: { + project: true + } + } + } + }); + + + for (const backup of expiredBackups) { + + const deletion = await deleteBackupCronAction({ + backupId: backup.id, + databaseId: backup.databaseId, + file: backup.file!, + projectSlug: backup.database.project?.slug! + }); + + // @ts-ignore + if (deletion.data.success) { + // @ts-ignore + console.log(deletion.data.actionSuccess.message); + } else { + // @ts-ignore + console.log(deletion.data.actionError.message); + } + + } + +} - // Note: files should also be deleted from storage -} \ No newline at end of file diff --git a/src/lib/tasks/database/retention-gsf.ts b/src/lib/tasks/database/retention-gsf.ts index a668e651..e7ce5450 100644 --- a/src/lib/tasks/database/retention-gsf.ts +++ b/src/lib/tasks/database/retention-gsf.ts @@ -2,6 +2,7 @@ import {db} from "@/db"; import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns"; import {eq, desc} from "drizzle-orm"; import * as drizzleDb from "@/db"; +import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; export async function enforceRetentionGFS(databaseId: string, gfsSettings: { daily: number; @@ -12,6 +13,13 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: { const backups = await db.query.backup.findMany({ where: eq(drizzleDb.schemas.backup.databaseId, databaseId), orderBy: desc(drizzleDb.schemas.backup.createdAt), + with: { + database: { + with: { + project: true + } + } + } }); const now = new Date(); @@ -52,8 +60,26 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: { // Delete backups not in `toKeep` for (const b of backups) { if (!toKeep.has(b.id)) { - await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id)); - // TODO: delete backup file from storage + // await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id)); + + + const deletion = await deleteBackupCronAction({ + backupId: b.id, + databaseId: b.databaseId, + file: b.file!, + projectSlug: b.database.project?.slug! + }); + + // @ts-ignore + if (deletion.data.success) { + // @ts-ignore + console.log(deletion.data.actionSuccess.message); + } else { + // @ts-ignore + console.log(deletion.data.actionError.message); + } + + } } } diff --git a/src/lib/tasks/database/utils/delete.ts b/src/lib/tasks/database/utils/delete.ts new file mode 100644 index 00000000..9f8c7630 --- /dev/null +++ b/src/lib/tasks/database/utils/delete.ts @@ -0,0 +1,95 @@ +"use server" +import {action, userAction} from "@/safe-actions"; +import {z} from "zod"; +import {ServerActionResult} from "@/types/action-type"; +import {Backup} from "@/db/schema/07_database"; +import {db} from "@/db"; +import * as drizzleDb from "@/db"; +import {and, eq} from "drizzle-orm"; +import {deleteFileS3Private, deleteLocalPrivate} from "@/features/upload/private/upload.action"; +import {env} from "@/env.mjs"; + + +export const deleteBackupCronAction = action + .schema( + z.object({ + backupId: z.string(), + databaseId: z.string(), + projectSlug: z.string(), + file: z.string(), + }) + ) + .action(async ({parsedInput}): Promise> => { + try { + + const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); + if (!settings) { + return { + success: false, + actionError: { + message: "No settings found.", + status: 404, + cause: "No settings found.", + messageParams: {message: "Error deleting the backup"}, + }, + }; + } + + + await db + .update(drizzleDb.schemas.backup) + .set({ + deletedAt: new Date(), + }) + .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) + + + + let success: boolean, message: string; + + const result = + settings.storage === "local" + ? await deleteLocalPrivate(parsedInput.file) + : await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!); + + ({success, message} = result); + + if (!success) { + return { + success: false, + actionError: { + message: message, + status: 404, + cause: "Unable to delete backup from storage", + messageParams: {message: "Error deleting the backup"}, + }, + }; + } + + // await db + // .delete(drizzleDb.schemas.backup) + // .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) + // .execute(); + + + + return { + success: true, + actionSuccess: { + message: "Backup deleted successfully.", + }, + }; + + } catch (error) { + console.error(error); + return { + success: false, + actionError: { + message: "Failed to delete backup.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {message: "Error deleting the backup"}, + }, + }; + } + }); \ No newline at end of file diff --git a/src/utils/init.ts b/src/utils/init.ts index 18ab244e..59173a6b 100644 --- a/src/utils/init.ts +++ b/src/utils/init.ts @@ -2,7 +2,6 @@ import {env} from "@/env.mjs"; import {db, makeMigration} from "@/db"; import {eq} from "drizzle-orm"; import * as drizzleDb from "@/db"; -import cron from "node-cron"; import {retentionJob} from "@/lib/tasks";