refactor: simplify dtos and improve type saftey in json returns

This commit is contained in:
Nicolas Meienberger
2025-10-29 18:28:00 +01:00
parent d1c1adaba7
commit b188a84af3
26 changed files with 667 additions and 751 deletions

View File

@@ -6,7 +6,7 @@ import type {
RepositoryStatus,
} from "@ironmount/schemas/restic";
import { sql } from "drizzle-orm";
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }),
@@ -14,9 +14,9 @@ export const volumesTable = sqliteTable("volumes_table", {
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"),
lastHealthCheck: int("last_health_check", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
});
@@ -27,8 +27,8 @@ export const usersTable = sqliteTable("users_table", {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type User = typeof usersTable.$inferSelect;
@@ -38,8 +38,8 @@ export const sessionsTable = sqliteTable("sessions_table", {
userId: int("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "timestamp" }).notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
expiresAt: int("expires_at", { mode: "number" }).notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type Session = typeof sessionsTable.$inferSelect;
@@ -51,10 +51,10 @@ export const repositoriesTable = sqliteTable("repositories_table", {
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "timestamp" }),
lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type Repository = typeof repositoriesTable.$inferSelect;
@@ -81,12 +81,12 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
}>(),
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
lastBackupAt: int("last_backup_at", { mode: "timestamp" }),
lastBackupAt: int("last_backup_at", { mode: "number" }),
lastBackupStatus: text("last_backup_status").$type<"success" | "error">(),
lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "timestamp" }),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
nextBackupAt: int("next_backup_at", { mode: "number" }),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;

View File

@@ -0,0 +1,10 @@
import { Job } from "../core/scheduler";
import { authService } from "../modules/auth/auth.service";
export class CleanupSessionsJob extends Job {
async run() {
authService.cleanupExpiredSessions();
return { done: true, timestamp: new Date() };
}
}

View File

@@ -10,8 +10,14 @@ import {
logoutDto,
registerBodySchema,
registerDto,
type GetMeDto,
type GetStatusDto,
type LoginDto,
type LogoutDto,
type RegisterDto,
} from "./auth.dto";
import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
@@ -33,9 +39,12 @@ export const authController = new Hono()
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
return c.json({ message: "User registered successfully", user: { id: user.id, username: user.username } }, 201);
return c.json<RegisterDto>(
{ success: true, message: "User registered successfully", user: { id: user.id, username: user.username } },
201,
);
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Registration failed" }, 400);
return c.json<RegisterDto>({ success: false, message: toMessage(error) }, 400);
}
})
.post("/login", loginDto, validator("json", loginBodySchema), async (c) => {
@@ -46,15 +55,16 @@ export const authController = new Hono()
setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS,
expires: expiresAt,
expires: new Date(expiresAt),
});
return c.json({
return c.json<LoginDto>({
success: true,
message: "Login successful",
user: { id: user.id, username: user.username },
});
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Login failed" }, 401);
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
}
})
.post("/logout", logoutDto, async (c) => {
@@ -65,13 +75,13 @@ export const authController = new Hono()
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
return c.json({ message: "Logout successful" });
return c.json<LogoutDto>({ success: true });
})
.get("/me", getMeDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ message: "Not authenticated" }, 401);
return c.json<GetMeDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
@@ -81,11 +91,13 @@ export const authController = new Hono()
return c.json({ message: "Not authenticated" }, 401);
}
return c.json({
return c.json<GetMeDto>({
success: true,
user: session.user,
message: "Authenticated",
});
})
.get("/status", getStatusDto, async (c) => {
const hasUsers = await authService.hasUsers();
return c.json({ hasUsers });
return c.json<GetStatusDto>({ hasUsers });
});

View File

@@ -14,10 +14,11 @@ export const registerBodySchema = type({
const loginResponseSchema = type({
message: "string",
success: "boolean",
user: type({
id: "string",
id: "number",
username: "string",
}),
}).optional(),
});
export const loginDto = describeRoute({
@@ -39,6 +40,8 @@ export const loginDto = describeRoute({
},
});
export type LoginDto = typeof loginResponseSchema.infer;
export const registerDto = describeRoute({
description: "Register a new user",
operationId: "register",
@@ -58,6 +61,12 @@ export const registerDto = describeRoute({
},
});
export type RegisterDto = typeof loginResponseSchema.infer;
const logoutResponseSchema = type({
success: "boolean",
});
export const logoutDto = describeRoute({
description: "Logout current user",
operationId: "logout",
@@ -67,13 +76,15 @@ export const logoutDto = describeRoute({
description: "Logout successful",
content: {
"application/json": {
schema: resolver(type({ message: "string" })),
schema: resolver(logoutResponseSchema),
},
},
},
},
});
export type LogoutDto = typeof logoutResponseSchema.infer;
export const getMeDto = describeRoute({
description: "Get current authenticated user",
operationId: "getMe",
@@ -87,12 +98,11 @@ export const getMeDto = describeRoute({
},
},
},
401: {
description: "Not authenticated",
},
},
});
export type GetMeDto = typeof loginResponseSchema.infer;
const statusResponseSchema = type({
hasUsers: "boolean",
});
@@ -113,5 +123,7 @@ export const getStatusDto = describeRoute({
},
});
export type GetStatusDto = typeof statusResponseSchema.infer;
export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer;

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { eq, lt } from "drizzle-orm";
import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
@@ -30,7 +30,7 @@ export class AuthService {
logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
await db.insert(sessionsTable).values({
id: sessionId,
@@ -58,7 +58,7 @@ export class AuthService {
}
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
await db.insert(sessionsTable).values({
id: sessionId,
@@ -100,7 +100,7 @@ export class AuthService {
return null;
}
if (session.session.expiresAt < new Date()) {
if (session.session.expiresAt < Date.now()) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null;
}
@@ -121,7 +121,7 @@ export class AuthService {
* Clean up expired sessions
*/
async cleanupExpiredSessions() {
const result = await db.delete(sessionsTable).where(eq(sessionsTable.expiresAt, new Date())).returning();
const result = await db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, Date.now())).returning();
if (result.length > 0) {
logger.info(`Cleaned up ${result.length} expired sessions`);
}

View File

@@ -10,6 +10,13 @@ import {
runBackupNowDto,
updateBackupScheduleBody,
updateBackupScheduleDto,
type CreateBackupScheduleDto,
type DeleteBackupScheduleDto,
type GetBackupScheduleDto,
type GetBackupScheduleForVolumeResponseDto,
type ListBackupSchedulesResponseDto,
type RunBackupNowDto,
type UpdateBackupScheduleDto,
} from "./backups.dto";
import { backupsService } from "./backups.service";
@@ -17,27 +24,27 @@ export const backupScheduleController = new Hono()
.get("/", listBackupSchedulesDto, async (c) => {
const schedules = await backupsService.listSchedules();
return c.json({ schedules }, 200);
return c.json<ListBackupSchedulesResponseDto>(schedules, 200);
})
.get("/:scheduleId", getBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
const schedule = await backupsService.getSchedule(Number(scheduleId));
return c.json({ schedule }, 200);
return c.json<GetBackupScheduleDto>(schedule, 200);
})
.get("/volume/:volumeId", getBackupScheduleForVolumeDto, async (c) => {
const volumeId = c.req.param("volumeId");
const schedule = await backupsService.getScheduleForVolume(Number(volumeId));
return c.json(schedule, 200);
return c.json<GetBackupScheduleForVolumeResponseDto>(schedule, 200);
})
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
const body = c.req.valid("json");
const schedule = await backupsService.createSchedule(body);
return c.json({ message: "Backup schedule created successfully", schedule }, 201);
return c.json<CreateBackupScheduleDto>(schedule, 201);
})
.patch("/:scheduleId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
const scheduleId = c.req.param("scheduleId");
@@ -45,14 +52,14 @@ export const backupScheduleController = new Hono()
const schedule = await backupsService.updateSchedule(Number(scheduleId), body);
return c.json({ message: "Backup schedule updated successfully", schedule }, 200);
return c.json<UpdateBackupScheduleDto>(schedule, 200);
})
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
await backupsService.deleteSchedule(Number(scheduleId));
return c.json({ message: "Backup schedule deleted successfully" }, 200);
return c.json<DeleteBackupScheduleDto>({ success: true }, 200);
})
.post("/:scheduleId/run", runBackupNowDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
@@ -61,11 +68,5 @@ export const backupScheduleController = new Hono()
console.error("Backup execution failed:", error);
});
return c.json(
{
message: "Backup started",
backupStarted: true,
},
200,
);
return c.json<RunBackupNowDto>({ success: true }, 200);
});

View File

@@ -35,9 +35,7 @@ export type BackupScheduleDto = typeof backupScheduleSchema.infer;
/**
* List all backup schedules
*/
export const listBackupSchedulesResponse = type({
schedules: backupScheduleSchema.array(),
});
export const listBackupSchedulesResponse = backupScheduleSchema.array();
export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer;
@@ -60,9 +58,7 @@ export const listBackupSchedulesDto = describeRoute({
/**
* Get a single backup schedule
*/
export const getBackupScheduleResponse = type({
schedule: backupScheduleSchema,
});
export const getBackupScheduleResponse = backupScheduleSchema;
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
@@ -118,10 +114,7 @@ export const createBackupScheduleBody = type({
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
export const createBackupScheduleResponse = type({
message: "string",
schedule: backupScheduleSchema,
});
export const createBackupScheduleResponse = backupScheduleSchema;
export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer;
@@ -156,10 +149,9 @@ export const updateBackupScheduleBody = type({
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
export const updateBackupScheduleResponse = type({
message: "string",
schedule: backupScheduleSchema,
});
export const updateBackupScheduleResponse = backupScheduleSchema;
export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer;
export const updateBackupScheduleDto = describeRoute({
description: "Update a backup schedule",
@@ -181,9 +173,11 @@ export const updateBackupScheduleDto = describeRoute({
* Delete a backup schedule
*/
export const deleteBackupScheduleResponse = type({
message: "string",
success: "boolean",
});
export type DeleteBackupScheduleDto = typeof deleteBackupScheduleResponse.infer;
export const deleteBackupScheduleDto = describeRoute({
description: "Delete a backup schedule",
operationId: "deleteBackupSchedule",
@@ -204,10 +198,11 @@ export const deleteBackupScheduleDto = describeRoute({
* Run a backup immediately
*/
export const runBackupNowResponse = type({
message: "string",
backupStarted: "boolean",
success: "boolean",
});
export type RunBackupNowDto = typeof runBackupNowResponse.infer;
export const runBackupNowDto = describeRoute({
description: "Trigger a backup immediately for a schedule",
operationId: "runBackupNow",

View File

@@ -10,19 +10,19 @@ import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors";
const calculateNextRun = (cronExpression: string): Date => {
const calculateNextRun = (cronExpression: string): number => {
try {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(),
tz: "UTC",
});
return interval.next().toDate();
return interval.next().getTime();
} catch (error) {
logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`);
const fallback = new Date();
fallback.setMinutes(fallback.getMinutes() + 1);
return fallback;
return fallback.getTime();
}
};
@@ -123,7 +123,7 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
const [updated] = await db
.update(backupSchedulesTable)
.set({ ...data, nextBackupAt, updatedAt: new Date() })
.set({ ...data, nextBackupAt, updatedAt: Date.now() })
.where(eq(backupSchedulesTable.id, scheduleId))
.returning();
@@ -204,11 +204,11 @@ const executeBackup = async (scheduleId: number) => {
await db
.update(backupSchedulesTable)
.set({
lastBackupAt: new Date(),
lastBackupAt: Date.now(),
lastBackupStatus: "success",
lastBackupError: null,
nextBackupAt: nextBackupAt,
updatedAt: new Date(),
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, scheduleId));
@@ -219,10 +219,10 @@ const executeBackup = async (scheduleId: number) => {
await db
.update(backupSchedulesTable)
.set({
lastBackupAt: new Date(),
lastBackupAt: Date.now(),
lastBackupStatus: "error",
lastBackupError: toMessage(error),
updatedAt: new Date(),
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, scheduleId));
@@ -231,7 +231,7 @@ const executeBackup = async (scheduleId: number) => {
};
const getSchedulesToExecute = async () => {
const now = new Date();
const now = Date.now();
const schedules = await db.query.backupSchedulesTable.findMany({
where: eq(backupSchedulesTable.enabled, true),
});

View File

@@ -8,6 +8,7 @@ import { volumeService } from "../volumes/volume.service";
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
import { BackupExecutionJob } from "../../jobs/backup-execution";
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
export const startup = async () => {
await Scheduler.start();
@@ -30,6 +31,7 @@ export const startup = async () => {
}
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
Scheduler.build(VolumeHealthCheckJob).schedule("* * * * *");
Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
};

View File

@@ -5,11 +5,12 @@ import {
createRepositoryDto,
deleteRepositoryDto,
getRepositoryDto,
type GetRepositoryResponseDto,
type ListRepositoriesResponseDto,
listRepositoriesDto,
listSnapshotsDto,
type ListSnapshotsResponseDto,
type DeleteRepositoryDto,
type GetRepositoryDto,
type ListRepositoriesDto,
type ListSnapshotsDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
@@ -17,16 +18,7 @@ export const repositoriesController = new Hono()
.get("/", listRepositoriesDto, async (c) => {
const repositories = await repositoriesService.listRepositories();
const response = {
repositories: repositories.map((repository) => ({
...repository,
updatedAt: repository.updatedAt.getTime(),
createdAt: repository.createdAt.getTime(),
lastChecked: repository.lastChecked?.getTime() ?? null,
})),
} satisfies ListRepositoriesResponseDto;
return c.json(response, 200);
return c.json<ListRepositoriesDto>(repositories, 200);
})
.post("/", createRepositoryDto, validator("json", createRepositoryBody), async (c) => {
const body = c.req.valid("json");
@@ -38,22 +30,13 @@ export const repositoriesController = new Hono()
const { name } = c.req.param();
const res = await repositoriesService.getRepository(name);
const response = {
repository: {
...res.repository,
createdAt: res.repository.createdAt.getTime(),
updatedAt: res.repository.updatedAt.getTime(),
lastChecked: res.repository.lastChecked?.getTime() ?? null,
},
} satisfies GetRepositoryResponseDto;
return c.json(response, 200);
return c.json<GetRepositoryDto>(res.repository, 200);
})
.delete("/:name", deleteRepositoryDto, async (c) => {
const { name } = c.req.param();
await repositoriesService.deleteRepository(name);
return c.json({ message: "Repository deleted" }, 200);
return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
})
.get("/:name/snapshots", listSnapshotsDto, async (c) => {
const { name } = c.req.param();
@@ -77,9 +60,9 @@ export const repositoriesController = new Hono()
};
});
const response = { snapshots } satisfies ListSnapshotsResponseDto;
const response = { snapshots };
c.header("Cache-Control", "max-age=30, stale-while-revalidate=300");
return c.json(response, 200);
return c.json<ListSnapshotsDto>(response, 200);
});

View File

@@ -25,10 +25,8 @@ export type RepositoryDto = typeof repositorySchema.infer;
/**
* List all repositories
*/
export const listRepositoriesResponse = type({
repositories: repositorySchema.array(),
});
export type ListRepositoriesResponseDto = typeof listRepositoriesResponse.infer;
export const listRepositoriesResponse = repositorySchema.array();
export type ListRepositoriesDto = typeof listRepositoriesResponse.infer;
export const listRepositoriesDto = describeRoute({
description: "List all repositories",
@@ -65,6 +63,8 @@ export const createRepositoryResponse = type({
}),
});
export type CreateRepositoryDto = typeof createRepositoryResponse.infer;
export const createRepositoryDto = describeRoute({
description: "Create a new restic repository",
operationId: "createRepository",
@@ -84,10 +84,8 @@ export const createRepositoryDto = describeRoute({
/**
* Get a single repository
*/
export const getRepositoryResponse = type({
repository: repositorySchema,
});
export type GetRepositoryResponseDto = typeof getRepositoryResponse.infer;
export const getRepositoryResponse = repositorySchema;
export type GetRepositoryDto = typeof getRepositoryResponse.infer;
export const getRepositoryDto = describeRoute({
description: "Get a single repository by name",
@@ -112,6 +110,8 @@ export const deleteRepositoryResponse = type({
message: "string",
});
export type DeleteRepositoryDto = typeof deleteRepositoryResponse.infer;
export const deleteRepositoryDto = describeRoute({
description: "Delete a repository",
tags: ["Repositories"],
@@ -143,7 +143,7 @@ const listSnapshotsResponse = type({
snapshots: snapshotSchema.array(),
});
export type ListSnapshotsResponseDto = typeof listSnapshotsResponse.infer;
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
export const listSnapshotsDto = describeRoute({
description: "List all snapshots in a repository",

View File

@@ -65,7 +65,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
.update(repositoriesTable)
.set({
status: "healthy",
lastChecked: new Date(),
lastChecked: Date.now(),
lastError: null,
})
.where(eq(repositoriesTable.id, id));

View File

@@ -4,22 +4,23 @@ import {
createVolumeBody,
createVolumeDto,
deleteVolumeDto,
type GetVolumeResponseDto,
getContainersDto,
getVolumeDto,
healthCheckDto,
type ListContainersResponseDto,
type ListFilesResponseDto,
type ListVolumesResponseDto,
type ListVolumesDto,
listFilesDto,
listVolumesDto,
mountVolumeDto,
testConnectionBody,
testConnectionDto,
type UpdateVolumeResponseDto,
unmountVolumeDto,
updateVolumeBody,
updateVolumeDto,
type CreateVolumeDto,
type GetVolumeDto,
type ListContainersDto,
type UpdateVolumeDto,
type ListFilesDto,
} from "./volume.dto";
import { volumeService } from "./volume.service";
import { getVolumePath } from "./helpers";
@@ -32,19 +33,21 @@ export const volumeController = new Hono()
volumes: volumes.map((volume) => ({
path: getVolumePath(volume.name),
...volume,
updatedAt: volume.updatedAt.getTime(),
createdAt: volume.createdAt.getTime(),
lastHealthCheck: volume.lastHealthCheck.getTime(),
})),
} satisfies ListVolumesResponseDto;
};
return c.json(response, 200);
return c.json<ListVolumesDto>(response, 200);
})
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
const body = c.req.valid("json");
const res = await volumeService.createVolume(body.name, body.config);
return c.json({ message: "Volume created", volume: res.volume }, 201);
const response = {
...res.volume,
path: getVolumePath(res.volume.name),
};
return c.json<CreateVolumeDto>(response, 201);
})
.post("/test-connection", testConnectionDto, validator("json", testConnectionBody), async (c) => {
const body = c.req.valid("json");
@@ -66,28 +69,21 @@ export const volumeController = new Hono()
volume: {
...res.volume,
path: getVolumePath(res.volume.name),
createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
},
statfs: {
total: res.statfs.total ?? 0,
used: res.statfs.used ?? 0,
free: res.statfs.free ?? 0,
},
} satisfies GetVolumeResponseDto;
};
return c.json(response, 200);
return c.json<GetVolumeDto>(response, 200);
})
.get("/:name/containers", getContainersDto, async (c) => {
const { name } = c.req.param();
const { containers } = await volumeService.getContainersUsingVolume(name);
const response = {
containers,
} satisfies ListContainersResponseDto;
return c.json(response, 200);
return c.json<ListContainersDto>(containers, 200);
})
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param();
@@ -95,17 +91,11 @@ export const volumeController = new Hono()
const res = await volumeService.updateVolume(name, body);
const response = {
message: "Volume updated",
volume: {
...res.volume,
path: getVolumePath(res.volume.name),
createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
},
} satisfies UpdateVolumeResponseDto;
...res.volume,
path: getVolumePath(res.volume.name),
};
return c.json(response, 200);
return c.json<UpdateVolumeDto>(response, 200);
})
.post("/:name/mount", mountVolumeDto, async (c) => {
const { name } = c.req.param();
@@ -133,9 +123,9 @@ export const volumeController = new Hono()
const response = {
files: result.files,
path: result.path,
} satisfies ListFilesResponseDto;
};
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
return c.json(response, 200);
return c.json<ListFilesDto>(response, 200);
});

View File

@@ -24,7 +24,7 @@ export type VolumeDto = typeof volumeSchema.infer;
export const listVolumesResponse = type({
volumes: volumeSchema.array(),
});
export type ListVolumesResponseDto = typeof listVolumesResponse.infer;
export type ListVolumesDto = typeof listVolumesResponse.infer;
export const listVolumesDto = describeRoute({
description: "List all volumes",
@@ -50,13 +50,8 @@ export const createVolumeBody = type({
config: volumeConfigSchema,
});
export const createVolumeResponse = type({
message: "string",
volume: type({
name: "string",
path: "string",
}),
});
export const createVolumeResponse = volumeSchema;
export type CreateVolumeDto = typeof createVolumeResponse.infer;
export const createVolumeDto = describeRoute({
description: "Create a new volume",
@@ -80,6 +75,7 @@ export const createVolumeDto = describeRoute({
export const deleteVolumeResponse = type({
message: "string",
});
export type DeleteVolumeDto = typeof deleteVolumeResponse.infer;
export const deleteVolumeDto = describeRoute({
description: "Delete a volume",
@@ -108,7 +104,7 @@ const getVolumeResponse = type({
statfs: statfsSchema,
});
export type GetVolumeResponseDto = typeof getVolumeResponse.infer;
export type GetVolumeDto = typeof getVolumeResponse.infer;
/**
* Get a volume
*/
@@ -141,10 +137,8 @@ export const updateVolumeBody = type({
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
export const updateVolumeResponse = type({
message: "string",
volume: volumeSchema,
});
export const updateVolumeResponse = volumeSchema;
export type UpdateVolumeDto = typeof updateVolumeResponse.infer;
export const updateVolumeDto = describeRoute({
description: "Update a volume's configuration",
@@ -165,8 +159,6 @@ export const updateVolumeDto = describeRoute({
},
});
export type UpdateVolumeResponseDto = typeof updateVolumeResponse.infer;
/**
* Test connection
*/
@@ -178,6 +170,7 @@ export const testConnectionResponse = type({
success: "boolean",
message: "string",
});
export type TestConnectionDto = typeof testConnectionResponse.infer;
export const testConnectionDto = describeRoute({
description: "Test connection to backend",
@@ -202,6 +195,7 @@ export const mountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type MountVolumeDto = typeof mountVolumeResponse.infer;
export const mountVolumeDto = describeRoute({
description: "Mount a volume",
@@ -216,9 +210,6 @@ export const mountVolumeDto = describeRoute({
},
},
},
404: {
description: "Volume not found",
},
},
});
@@ -229,6 +220,7 @@ export const unmountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type UnmountVolumeDto = typeof unmountVolumeResponse.infer;
export const unmountVolumeDto = describeRoute({
description: "Unmount a volume",
@@ -243,9 +235,6 @@ export const unmountVolumeDto = describeRoute({
},
},
},
404: {
description: "Volume not found",
},
},
});
@@ -253,6 +242,7 @@ export const healthCheckResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type HealthCheckDto = typeof healthCheckResponse.infer;
export const healthCheckDto = describeRoute({
description: "Perform a health check on a volume",
@@ -283,10 +273,8 @@ const containerSchema = type({
image: "string",
});
export const listContainersResponse = type({
containers: containerSchema.array(),
});
export type ListContainersResponseDto = typeof listContainersResponse.infer;
export const listContainersResponse = containerSchema.array();
export type ListContainersDto = typeof listContainersResponse.infer;
export const getContainersDto = describeRoute({
description: "Get containers using a volume by name",
@@ -322,7 +310,7 @@ export const listFilesResponse = type({
files: fileEntrySchema.array(),
path: "string",
});
export type ListFilesResponseDto = typeof listFilesResponse.infer;
export type ListFilesDto = typeof listFilesResponse.infer;
export const listFilesDto = describeRoute({
description: "List files in a volume directory",
@@ -348,8 +336,5 @@ export const listFilesDto = describeRoute({
},
},
},
404: {
description: "Volume not found",
},
},
});

View File

@@ -6,7 +6,6 @@ import Docker from "dockerode";
import { eq } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
@@ -51,7 +50,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() })
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, slug));
return { volume: created, status: 201 };
@@ -85,7 +84,7 @@ const mountVolume = async (name: string) => {
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() })
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
return { error, status };
@@ -149,7 +148,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
config: volumeData.config,
type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount,
updatedAt: new Date(),
updatedAt: Date.now(),
})
.where(eq(volumesTable.name, name))
.returning();
@@ -163,7 +162,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const { error, status } = await backend.mount();
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() })
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
}
@@ -178,9 +177,9 @@ const testConnection = async (backendConfig: BackendConfig) => {
name: "test-connection",
path: tempDir,
config: backendConfig,
createdAt: new Date(),
updatedAt: new Date(),
lastHealthCheck: new Date(),
createdAt: Date.now(),
updatedAt: Date.now(),
lastHealthCheck: Date.now(),
type: backendConfig.backend,
status: "unmounted" as const,
lastError: null,
@@ -215,7 +214,7 @@ const checkHealth = async (name: string) => {
await db
.update(volumesTable)
.set({ lastHealthCheck: new Date(), status, lastError: error ?? null })
.set({ lastHealthCheck: Date.now(), status, lastError: error ?? null })
.where(eq(volumesTable.name, volume.name));
return { status, error };