mirror of
https://github.com/khairul169/db-backup-tool.git
synced 2026-09-16 17:33:26 +07:00
feat: add recurring backup scheduler
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { STORAGE_DIR } from "../consts";
|
||||
import { STORAGE_DIR } from "./src/consts";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.2.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"drizzle-orm": "^0.30.10",
|
||||
"hono": "^4.3.4",
|
||||
"hono": "4.3.5",
|
||||
"nanoid": "^5.0.7",
|
||||
"node-schedule": "^2.1.1",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
+3
-1
@@ -30,7 +30,9 @@ CREATE TABLE `servers` (
|
||||
`connection` text,
|
||||
`ssh` text,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`backup` text,
|
||||
`next_backup` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "96dd8a39-5c64-4bb1-86de-7a81b83ed1db",
|
||||
"id": "242cd56d-c814-44c6-8a5b-4f0814248f31",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"backups": {
|
||||
@@ -233,6 +233,20 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"backup": {
|
||||
"name": "backup",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"next_backup": {
|
||||
"name": "next_backup",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1715367813285,
|
||||
"tag": "0000_square_agent_brand",
|
||||
"when": 1715513358120,
|
||||
"tag": "0000_clumsy_doorman",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { relations, sql, type InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
relations,
|
||||
sql,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
} from "drizzle-orm";
|
||||
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { ServerBackupSchema } from "../schemas/server.schema";
|
||||
|
||||
export const userModel = sqliteTable("users", {
|
||||
id: text("id")
|
||||
@@ -27,6 +33,8 @@ export const serverModel = sqliteTable("servers", {
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
backup: text("backup", { mode: "json" }).$type<ServerBackupSchema>(),
|
||||
nextBackup: text("next_backup"),
|
||||
});
|
||||
export type ServerModel = InferSelectModel<typeof serverModel>;
|
||||
|
||||
@@ -96,6 +104,9 @@ export const backupModel = sqliteTable("backups", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export type BackupModel = InferSelectModel<typeof backupModel>;
|
||||
export type InsertBackupModel = InferInsertModel<typeof backupModel>;
|
||||
|
||||
export const backupRelations = relations(backupModel, ({ one }) => ({
|
||||
server: one(serverModel, {
|
||||
fields: [backupModel.serverId],
|
||||
|
||||
@@ -5,6 +5,7 @@ import ServerService from "../services/server.service";
|
||||
import {
|
||||
checkServerSchema,
|
||||
createServerSchema,
|
||||
updateServerSchema,
|
||||
} from "../schemas/server.schema";
|
||||
import DatabaseUtil from "../lib/database-util";
|
||||
|
||||
@@ -54,6 +55,13 @@ const router = new Hono()
|
||||
const { id } = c.req.param();
|
||||
const server = await serverService.getById(id);
|
||||
return c.json(server);
|
||||
})
|
||||
|
||||
.patch("/:id", zValidator("json", updateServerSchema), async (c) => {
|
||||
const server = await serverService.getOrFail(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const result = await serverService.update(server, data);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { and, eq, ne, gte, sql } from "drizzle-orm";
|
||||
import db from "../db";
|
||||
import {
|
||||
backupModel,
|
||||
databaseModel,
|
||||
serverModel,
|
||||
type InsertBackupModel,
|
||||
} from "../db/models";
|
||||
import dayjs from "dayjs";
|
||||
import ServerService from "../services/server.service";
|
||||
import type { CreateBackupSchema } from "../schemas/backup.schema";
|
||||
|
||||
export const backupScheduler = async () => {
|
||||
const serverService = new ServerService();
|
||||
const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
const queue = await db.query.servers.findMany({
|
||||
where: and(
|
||||
eq(serverModel.isActive, true),
|
||||
gte(
|
||||
sql`strftime('%s', ${now})`,
|
||||
sql`strftime('%s', ${serverModel.nextBackup})`
|
||||
)
|
||||
),
|
||||
with: {
|
||||
databases: {
|
||||
columns: { id: true },
|
||||
where: eq(databaseModel.isActive, true),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tasks = queue.map(async (item) => {
|
||||
console.log("CREATING BACKUP SCHEDULE FOR " + item.name);
|
||||
|
||||
try {
|
||||
const backups: InsertBackupModel[] = item.databases.map((d) => ({
|
||||
serverId: item.id,
|
||||
databaseId: d.id,
|
||||
type: "backup",
|
||||
}));
|
||||
await db.insert(backupModel).values(backups).execute();
|
||||
|
||||
const nextBackup = serverService.calculateNextBackup(item);
|
||||
await db
|
||||
.update(serverModel)
|
||||
.set({ nextBackup })
|
||||
.where(eq(serverModel.id, item.id));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(tasks);
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import scheduler from "node-schedule";
|
||||
import { processBackup } from "./process-backup";
|
||||
import { backupScheduler } from "./backup-scheduler";
|
||||
|
||||
export const initScheduler = () => {
|
||||
scheduler.scheduleJob("*/10 * * * * *", processBackup);
|
||||
// scheduler.scheduleJob("* * * * * *", backupScheduler);
|
||||
backupScheduler();
|
||||
};
|
||||
|
||||
@@ -12,9 +12,14 @@ export const getAllBackupQuery = z
|
||||
|
||||
export type GetAllBackupQuery = z.infer<typeof getAllBackupQuery>;
|
||||
|
||||
export const createBackupSchema = z.object({
|
||||
databaseId: z.string().nanoid(),
|
||||
});
|
||||
export const createBackupSchema = z
|
||||
.object({
|
||||
serverId: z.string().nanoid().optional(),
|
||||
databaseId: z.string().nanoid().optional(),
|
||||
})
|
||||
.refine((i) => i.serverId || i.databaseId, {
|
||||
message: "Either serverId or databaseId is required.",
|
||||
});
|
||||
|
||||
export type CreateBackupSchema = z.infer<typeof createBackupSchema>;
|
||||
|
||||
|
||||
@@ -16,21 +16,49 @@ const postgresSchema = z.object({
|
||||
host: z.string(),
|
||||
port: z.coerce.number().int().optional(),
|
||||
user: z.string(),
|
||||
pass: z.string(),
|
||||
pass: z.string().optional(),
|
||||
});
|
||||
|
||||
export const connectionSchema = z.discriminatedUnion("type", [postgresSchema]);
|
||||
|
||||
export const serverBackupSchema = z.object({
|
||||
compress: z.boolean(),
|
||||
scheduled: z.boolean(),
|
||||
every: z.coerce.number().min(1),
|
||||
interval: z.enum([
|
||||
"second",
|
||||
"minute",
|
||||
"hour",
|
||||
"day",
|
||||
"week",
|
||||
"month",
|
||||
"year",
|
||||
]),
|
||||
time: z
|
||||
.string()
|
||||
.regex(/^\d{2}:\d{2}$/)
|
||||
.optional(),
|
||||
day: z.coerce.number().min(0).max(6).optional(),
|
||||
month: z.coerce.number().min(0).max(11).optional(),
|
||||
});
|
||||
|
||||
export type ServerBackupSchema = z.infer<typeof serverBackupSchema>;
|
||||
|
||||
export const createServerSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
ssh: sshSchema,
|
||||
connection: connectionSchema,
|
||||
isActive: z.boolean().optional(),
|
||||
databases: z.string().array().min(1),
|
||||
backup: serverBackupSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateServerSchema = z.infer<typeof createServerSchema>;
|
||||
|
||||
export const updateServerSchema = createServerSchema.partial();
|
||||
|
||||
export type UpdateServerSchema = z.infer<typeof updateServerSchema>;
|
||||
|
||||
export const checkServerSchema = z.object({
|
||||
ssh: sshSchema,
|
||||
connection: connectionSchema,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import db from "../db";
|
||||
import { backupModel, serverModel } from "../db/models";
|
||||
import { backupModel, databaseModel, serverModel } from "../db/models";
|
||||
import type {
|
||||
CreateBackupSchema,
|
||||
GetAllBackupQuery,
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { and, count, desc, eq, inArray } from "drizzle-orm";
|
||||
import DatabaseService from "./database.service";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import ServerService from "./server.service";
|
||||
|
||||
export default class BackupService {
|
||||
private databaseService = new DatabaseService();
|
||||
@@ -58,19 +59,48 @@ export default class BackupService {
|
||||
* Queue new backup
|
||||
*/
|
||||
async create(data: CreateBackupSchema) {
|
||||
const database = await this.databaseService.getOrFail(data.databaseId);
|
||||
await this.checkPendingBackup(database.id);
|
||||
if (data.databaseId) {
|
||||
const database = await this.databaseService.getOrFail(data.databaseId);
|
||||
await this.checkPendingBackup(database.id);
|
||||
|
||||
const [result] = await db
|
||||
.insert(backupModel)
|
||||
.values({
|
||||
const [result] = await db
|
||||
.insert(backupModel)
|
||||
.values({
|
||||
type: "backup",
|
||||
serverId: database.serverId,
|
||||
databaseId: database.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
} else if (data.serverId) {
|
||||
const databases = await db.query.database.findMany({
|
||||
where: and(
|
||||
eq(databaseModel.serverId, data.serverId),
|
||||
eq(databaseModel.isActive, true)
|
||||
),
|
||||
});
|
||||
if (!databases.length) {
|
||||
throw new HTTPException(400, {
|
||||
message: "No active databases found for this server.",
|
||||
});
|
||||
}
|
||||
|
||||
const values = databases.map((d) => ({
|
||||
type: "backup",
|
||||
serverId: database.serverId,
|
||||
databaseId: database.id,
|
||||
})
|
||||
.returning();
|
||||
serverId: d.serverId,
|
||||
databaseId: d.id,
|
||||
}));
|
||||
|
||||
return result;
|
||||
const result = await db
|
||||
.insert(backupModel)
|
||||
.values(values as never)
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async restore(data: RestoreBackupSchema) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import db from "../db";
|
||||
import { databaseModel, serverModel, type ServerModel } from "../db/models";
|
||||
import type { CreateServerSchema } from "../schemas/server.schema";
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import type {
|
||||
CreateServerSchema,
|
||||
UpdateServerSchema,
|
||||
} from "../schemas/server.schema";
|
||||
import { and, asc, desc, eq, ne } from "drizzle-orm";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default class ServerService {
|
||||
async getAll() {
|
||||
@@ -61,6 +65,7 @@ export default class ServerService {
|
||||
type: data.connection.type,
|
||||
connection: data.connection ? JSON.stringify(data.connection) : null,
|
||||
ssh: data.ssh ? JSON.stringify(data.ssh) : null,
|
||||
nextBackup: this.calculateNextBackup(data as never),
|
||||
};
|
||||
|
||||
// Create server
|
||||
@@ -81,6 +86,47 @@ export default class ServerService {
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
server: Awaited<ReturnType<typeof this.getOrFail>>,
|
||||
data: UpdateServerSchema
|
||||
) {
|
||||
if (data.name) {
|
||||
const isExist = await db.query.servers.findFirst({
|
||||
where: and(
|
||||
ne(serverModel.id, server.id),
|
||||
eq(serverModel.name, data.name)
|
||||
),
|
||||
});
|
||||
if (isExist) {
|
||||
throw new HTTPException(400, { message: "Server name already exists" });
|
||||
}
|
||||
}
|
||||
|
||||
const dataValue = {
|
||||
...data,
|
||||
type: data.connection?.type || server.type,
|
||||
connection: data.connection
|
||||
? JSON.stringify({
|
||||
...data.connection,
|
||||
pass: data.connection.pass || server.connection?.pass,
|
||||
})
|
||||
: undefined,
|
||||
ssh: data.ssh ? JSON.stringify(data.ssh) : undefined,
|
||||
nextBackup: data.backup
|
||||
? this.calculateNextBackup(data as never)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// Update server
|
||||
const [result] = await db
|
||||
.update(serverModel)
|
||||
.set(dataValue)
|
||||
.where(eq(serverModel.id, server.id))
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
parse<T extends Pick<ServerModel, "connection" | "ssh">>(data: T) {
|
||||
const result = {
|
||||
...data,
|
||||
@@ -90,4 +136,44 @@ export default class ServerService {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
calculateNextBackup(
|
||||
server: Pick<ServerModel, "backup">,
|
||||
from?: Date | string | null
|
||||
) {
|
||||
if (!server.backup?.scheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let date = dayjs(from);
|
||||
const {
|
||||
interval = "day",
|
||||
every = 1,
|
||||
time = "00:00",
|
||||
day = 0,
|
||||
month = 0,
|
||||
} = server.backup || {};
|
||||
const [hh, mm] = time.split(":").map(Number);
|
||||
|
||||
if (Number.isNaN(hh) || Number.isNaN(mm)) {
|
||||
throw new Error("Invalid time format");
|
||||
}
|
||||
|
||||
date = date.add(every || 1, interval);
|
||||
|
||||
if (interval !== "second") {
|
||||
date = date.set("second", 0).set("millisecond", 0);
|
||||
}
|
||||
if (["day", "week", "month", "year"].includes(interval)) {
|
||||
date = date.set("hour", hh).set("minute", mm);
|
||||
}
|
||||
if (["week", "month"].includes(interval)) {
|
||||
date = date.set("day", day);
|
||||
}
|
||||
if (interval === "year") {
|
||||
date = date.set("month", month).set("date", 1);
|
||||
}
|
||||
|
||||
return date.format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export type PostgresConfig = {
|
||||
type: "postgres";
|
||||
host: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
pass?: string;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user