@@ -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" && (
-
+
GFS Rotation Configuration
@@ -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.
)}
-
-
- {isLoading ? "Saving Policy..." : "Save Retention Policy"}
+
+
+ {updateRetentionPolicy.isPending ? "Saving Policy..." : "Save Retention Policy"}
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";