Working on retention policy and cron system.

This commit is contained in:
charlesgauthereau
2025-09-14 16:46:23 +02:00
parent 2d559e456d
commit c3e2978919
13 changed files with 393 additions and 72 deletions
@@ -90,12 +90,12 @@ export default async function RoutePage(props: PageParams<{
<PageTitle className="flex items-center"> <PageTitle className="flex items-center">
{capitalizeFirstLetter(dbItem.name)} {capitalizeFirstLetter(dbItem.name)}
<EditButton/> <EditButton/>
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/> <CronButton database={dbItem}/>
<RetentionPolicySheet/>
</PageTitle> </PageTitle>
<PageActions className="justify-between"> <PageActions className="justify-between">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/> <BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</PageActions> </PageActions>
</div> </div>
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription> <PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
@@ -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],
}
})
@@ -1,7 +1,6 @@
"use client" "use client"
import {useState} from "react" import {useState} from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {Label} from "@/components/ui/label" import {Label} from "@/components/ui/label"
import {Button} from "@/components/ui/button" import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge" import {Badge} from "@/components/ui/badge"
@@ -10,6 +9,12 @@ import { Separator } from "@/components/ui/separator"
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group" import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"
import {Clock, Database, Save, Settings, Calendar, RotateCcw} from "lucide-react" import {Clock, Database, Save, Settings, Calendar, RotateCcw} from "lucide-react"
import {toast} from "sonner"; 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" type RetentionPolicyType = "count" | "days" | "gfs"
@@ -20,14 +25,19 @@ interface GFSSettings {
yearly: number yearly: number
} }
interface RetentionSettings { export interface RetentionSettings {
type: RetentionPolicyType type: RetentionPolicyType
count: number count: number
days: number days: number
gfs: GFSSettings gfs: GFSSettings
} }
export function BackupRetentionSettings() { type BackupRetentionSettingsProps = {
database: DbSchema,
}
export function BackupRetentionSettings({database}: BackupRetentionSettingsProps) {
const [settings, setSettings] = useState<RetentionSettings>({ const [settings, setSettings] = useState<RetentionSettings>({
type: "gfs", type: "gfs",
count: 7, count: 7,
@@ -39,20 +49,25 @@ export function BackupRetentionSettings() {
yearly: 3, yearly: 3,
}, },
}) })
const [isLoading, setIsLoading] = useState(false) const router = useRouter()
const handleSave = async () => { const updateRetentionPolicy = useMutation({
setIsLoading(true) mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({
try { databaseId: database.id,
await new Promise((resolve) => setTimeout(resolve, 1000)) settings: payload
toast.success("Your backup retention settings have been saved successfully.") }),
} catch (error) { onSuccess: () => {
toast.error("Failed to save retention settings") toast.success("Retention policy updated successfully.")
} finally { router.refresh()
setIsLoading(false) },
} onError: () => {
} toast.error("An error occurred while updating retention policy.")
},
})
const handleSave = () => {
updateRetentionPolicy.mutate(settings)
}
const calculateTotalFiles = () => { const calculateTotalFiles = () => {
if (settings.type === "gfs") { if (settings.type === "gfs") {
return settings.gfs.daily + settings.gfs.weekly + settings.gfs.monthly + settings.gfs.yearly return settings.gfs.daily + settings.gfs.weekly + settings.gfs.monthly + settings.gfs.yearly
@@ -76,10 +91,14 @@ export function BackupRetentionSettings() {
<Label className="text-sm font-medium">Retention Policy Type</Label> <Label className="text-sm font-medium">Retention Policy Type</Label>
<RadioGroup <RadioGroup
value={settings.type} value={settings.type}
onValueChange={(value: RetentionPolicyType) => setSettings((prev) => ({ ...prev, type: value }))} onValueChange={(value: RetentionPolicyType) => setSettings((prev) => ({
...prev,
type: value
}))}
className="grid grid-cols-1 gap-4" className="grid grid-cols-1 gap-4"
> >
<div className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors"> <div
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="count" id="count"/> <RadioGroupItem value="count" id="count"/>
<div className="flex-1"> <div className="flex-1">
<Label htmlFor="count" className="font-medium cursor-pointer"> <Label htmlFor="count" className="font-medium cursor-pointer">
@@ -91,17 +110,20 @@ export function BackupRetentionSettings() {
</div> </div>
</div> </div>
<div className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors"> <div
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="days" id="days"/> <RadioGroupItem value="days" id="days"/>
<div className="flex-1"> <div className="flex-1">
<Label htmlFor="days" className="font-medium cursor-pointer"> <Label htmlFor="days" className="font-medium cursor-pointer">
Keep backups for X days Keep backups for X days
</Label> </Label>
<p className="text-sm text-muted-foreground">Time-based retention (e.g., keep backups for 30 days)</p> <p className="text-sm text-muted-foreground">Time-based retention (e.g., keep
backups for 30 days)</p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors bg-accent/5"> <div
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors bg-accent/5">
<RadioGroupItem value="gfs" id="gfs"/> <RadioGroupItem value="gfs" id="gfs"/>
<div className="flex-1"> <div className="flex-1">
<Label htmlFor="gfs" className="font-medium cursor-pointer flex items-center gap-2"> <Label htmlFor="gfs" className="font-medium cursor-pointer flex items-center gap-2">
@@ -134,7 +156,10 @@ export function BackupRetentionSettings() {
min="1" min="1"
max="100" max="100"
value={settings.count} 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" className="w-32"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -158,7 +183,10 @@ export function BackupRetentionSettings() {
min="1" min="1"
max="3650" max="3650"
value={settings.days} 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" className="w-32"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -294,16 +322,18 @@ export function BackupRetentionSettings() {
</div> </div>
{settings.type === "gfs" && ( {settings.type === "gfs" && (
<div className="text-xs text-muted-foreground bg-accent/10 p-2 rounded border border-accent/20"> <div
<strong>Note:</strong> With GFS rotation, you'll have approximately {calculateTotalFiles()} files per className="text-xs text-muted-foreground bg-accent/10 p-2 rounded border border-accent/20">
<strong>Note:</strong> With GFS rotation, you'll have
approximately {calculateTotalFiles()} files per
database per year, providing excellent long-term retention with optimized storage usage. database per year, providing excellent long-term retention with optimized storage usage.
</div> </div>
)} )}
</div> </div>
<Button onClick={handleSave} disabled={isLoading} className="w-full"> <Button onClick={handleSave} disabled={updateRetentionPolicy.isPending} className="w-full">
<Save className="h-4 w-4 mr-2"/> <Save className="h-4 w-4 mr-2"/>
{isLoading ? "Saving Policy..." : "Save Retention Policy"} {updateRetentionPolicy.isPending ? "Saving Policy..." : "Save Retention Policy"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -4,9 +4,13 @@ import {Database, DatabaseZap} from "lucide-react";
import { import {
BackupRetentionSettings BackupRetentionSettings
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings"; } 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 ( return (
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
@@ -23,11 +27,12 @@ export const RetentionPolicySheet = () => {
Backup Retention Policy Backup Retention Policy
</SheetTitle> </SheetTitle>
<SheetDescription className="text-pretty"> <SheetDescription className="text-pretty">
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. enterprise GFS rotation strategies.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<BackupRetentionSettings/> <BackupRetentionSettings database={props.database}/>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) )
@@ -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<typeof retentionSettingsSchema>
+12 -1
View File
@@ -69,7 +69,10 @@ export const restoration = pgTable("restorations", {
}); });
export const databaseRelations = relations(database, ({one, many}) => ({ 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]}), agent: one(agent, {fields: [database.agentId], references: [agent.id]}),
project: one(project, {fields: [database.projectId], references: [project.id]}), project: one(project, {fields: [database.projectId], references: [project.id]}),
backups: many(backup), backups: many(backup),
@@ -86,6 +89,14 @@ export const restorationRelations = relations(restoration, ({one}) => ({
database: one(database, {fields: [restoration.databaseId], references: [database.id]}), 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 const databaseSchema = createSelectSchema(database);
export type Database = z.infer<typeof databaseSchema>; export type Database = z.infer<typeof databaseSchema>;
@@ -37,6 +37,14 @@ export function backupColumns(isAlreadyRestore: boolean, settings: Setting, data
accessorKey: "id", accessorKey: "id",
header: "Reference", header: "Reference",
}, },
{
accessorKey: "deletedAt",
header: "Deleted At",
cell: ({row}) => {
return row.original.deletedAt ? formatFrenchDate(row.getValue("deletedAt")) : ""
},
},
{ {
accessorKey: "createdAt", accessorKey: "createdAt",
header: "Created At", header: "Created At",
+9 -2
View File
@@ -3,6 +3,8 @@ import {enforceRetentionCount} from "@/lib/tasks/database/retention-count";
import {enforceRetentionDays} from "@/lib/tasks/database/retention-days"; import {enforceRetentionDays} from "@/lib/tasks/database/retention-days";
import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf"; import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf";
import {retentionPolicy} from "@/db/schema/07_database"; import {retentionPolicy} from "@/db/schema/07_database";
import {eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
export const retentionCleanTask = async () => { export const retentionCleanTask = async () => {
@@ -10,13 +12,16 @@ export const retentionCleanTask = async () => {
const databases = await db.query.database.findMany({ const databases = await db.query.database.findMany({
with: { with: {
retentionPolicy: true, retentionPolicy: true,
backups: true, backups: {
where: isNull(drizzleDb.schemas.backup.deletedAt),
},
}, },
}); });
console.log(databases);
for (const db of databases) { for (const db of databases) {
if (!db.retentionPolicy) continue; // no policy = skip if (!db.retentionPolicy) continue; // no policy = skip
await enforceRetention(db.id, db.retentionPolicy); await enforceRetention(db.id, db.retentionPolicy);
} }
} catch (e: any) { } catch (e: any) {
@@ -29,6 +34,8 @@ export async function enforceRetention(
databaseId: string, databaseId: string,
policy: typeof retentionPolicy.$inferSelect policy: typeof retentionPolicy.$inferSelect
) { ) {
console.log(`EnforceRetention: ${policy}`);
switch (policy.type) { switch (policy.type) {
case "count": case "count":
await enforceRetentionCount(databaseId, policy.count ?? 7); await enforceRetentionCount(databaseId, policy.count ?? 7);
+28 -2
View File
@@ -1,17 +1,43 @@
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {desc, eq} from "drizzle-orm"; import {desc, eq} from "drizzle-orm";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
export async function enforceRetentionCount(databaseId: string, count: number) { export async function enforceRetentionCount(databaseId: string, count: number) {
const backups = await db.query.backup.findMany({ const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId), where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
orderBy: desc(drizzleDb.schemas.backup.createdAt), orderBy: desc(drizzleDb.schemas.backup.createdAt),
with:{
database: {
with: {
project: true
}
}
}
}); });
const toDelete = backups.slice(count); // keep first `count`, delete rest const toDelete = backups.slice(count); // keep first `count`, delete rest
for (const b of toDelete) { for (const b of toDelete) {
await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id)); // await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id));
// TODO: delete backup file from storage
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);
}
} }
} }
+38 -7
View File
@@ -1,14 +1,45 @@
import {db} from "@/db"; import {db} from "@/db";
import {eq, lt} from "drizzle-orm"; import {eq, lt, and, desc} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
export async function enforceRetentionDays(databaseId: string, days: number) { 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( const expiredBackups = await db.query.backup.findMany({
eq(drizzleDb.schemas.backup.databaseId, databaseId) && where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
lt(drizzleDb.schemas.backup.createdAt, cutoff) lt(drizzleDb.schemas.backup.createdAt, cutoff)
); ),
with: {
// Note: files should also be deleted from storage 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);
}
}
}
+28 -2
View File
@@ -2,6 +2,7 @@ import {db} from "@/db";
import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns"; import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns";
import {eq, desc} from "drizzle-orm"; import {eq, desc} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
export async function enforceRetentionGFS(databaseId: string, gfsSettings: { export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
daily: number; daily: number;
@@ -12,6 +13,13 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
const backups = await db.query.backup.findMany({ const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId), where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
orderBy: desc(drizzleDb.schemas.backup.createdAt), orderBy: desc(drizzleDb.schemas.backup.createdAt),
with: {
database: {
with: {
project: true
}
}
}
}); });
const now = new Date(); const now = new Date();
@@ -52,8 +60,26 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
// Delete backups not in `toKeep` // Delete backups not in `toKeep`
for (const b of backups) { for (const b of backups) {
if (!toKeep.has(b.id)) { if (!toKeep.has(b.id)) {
await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id)); // await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id));
// TODO: delete backup file from storage
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);
}
} }
} }
} }
+95
View File
@@ -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<ServerActionResult<Backup>> => {
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"},
},
};
}
});
-1
View File
@@ -2,7 +2,6 @@ import {env} from "@/env.mjs";
import {db, makeMigration} from "@/db"; import {db, makeMigration} from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import cron from "node-cron";
import {retentionJob} from "@/lib/tasks"; import {retentionJob} from "@/lib/tasks";