Phase4
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:32:27 +02:00
parent 9b061c3ee7
commit 4262464cd5
101 changed files with 4024 additions and 74 deletions

View File

@@ -19,6 +19,7 @@
"@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*",
"@hexahost/storage": "workspace:*",
"@nestjs/common": "^11.0.7",
"@nestjs/core": "^11.0.7",
"@nestjs/platform-fastify": "^11.0.7",

View File

@@ -320,7 +320,14 @@ export class NodesService {
normalized === 'files.list' ||
normalized === 'files.read' ||
normalized === 'files.write' ||
normalized === 'command'
normalized === 'command' ||
normalized === 'backup.prepare' ||
normalized === 'backup.release' ||
normalized === 'world.validate' ||
normalized === 'world.archive' ||
normalized === 'world.replace' ||
normalized === 'storage.upload' ||
normalized === 'storage.download'
);
}

View File

@@ -0,0 +1,102 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
createBackupRequestSchema,
restoreBackupRequestSchema,
updateBackupScheduleSchema,
} from '@hexahost/contracts';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { BackupsService } from './backups.service';
@ApiTags('servers')
@Controller('servers/:serverId/backups')
@UseGuards(SessionGuard)
export class BackupsController {
constructor(private readonly backupsService: BackupsService) {}
@Get()
@ApiOperation({ summary: 'List server backups' })
list(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
) {
return this.backupsService.listBackups(user.id, serverId);
}
@Post()
@ApiOperation({ summary: 'Create a manual backup' })
create(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body() body: unknown,
) {
const input = createBackupRequestSchema.parse(body ?? {});
return this.backupsService.createBackup(user.id, serverId, input);
}
@Get('schedule')
@ApiOperation({ summary: 'Get backup schedule' })
getSchedule(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
) {
return this.backupsService.getSchedule(user.id, serverId);
}
@Patch('schedule')
@ApiOperation({ summary: 'Update backup schedule' })
updateSchedule(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body() body: unknown,
) {
const input = updateBackupScheduleSchema.parse(body);
return this.backupsService.updateSchedule(user.id, serverId, input);
}
@Get(':backupId/download-url')
@ApiOperation({ summary: 'Get a presigned download URL for a backup' })
downloadUrl(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('backupId', ParseUUIDPipe) backupId: string,
) {
return this.backupsService.getDownloadUrl(user.id, serverId, backupId);
}
@Post(':backupId/restore')
@ApiOperation({ summary: 'Restore a backup' })
restore(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('backupId', ParseUUIDPipe) backupId: string,
@Body() body: unknown,
) {
restoreBackupRequestSchema.parse(body);
return this.backupsService.restoreBackup(user.id, serverId, backupId);
}
@Delete(':backupId')
@ApiOperation({ summary: 'Delete a backup' })
deleteBackup(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('backupId', ParseUUIDPipe) backupId: string,
) {
return this.backupsService.deleteBackup(user.id, serverId, backupId);
}
}

View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { Queue } from 'bullmq';
import { getConfig } from '@hexahost/config';
import { AuthModule } from '../../auth/auth.module';
import { PrismaModule } from '../../prisma/prisma.module';
import { BackupsController } from './backups.controller';
import { BackupsService, SERVER_BACKUPS_QUEUE } from './backups.service';
import { ServerStateService } from '../server-state.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [BackupsController],
providers: [
BackupsService,
ServerStateService,
{
provide: SERVER_BACKUPS_QUEUE,
useFactory: () => {
const config = getConfig();
return new Queue('server-backups', {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
},
},
],
exports: [BackupsService, SERVER_BACKUPS_QUEUE],
})
export class BackupsModule {}

View File

@@ -0,0 +1,341 @@
import {
BadRequestException,
Inject,
Injectable,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import type { ServerBackup } from '@hexahost/database';
import type {
BackupActionResponse,
BackupDownloadUrlResponse,
BackupListResponse,
BackupResponse,
BackupRestoreResponse,
BackupSchedule,
CreateBackupRequest,
UpdateBackupSchedule,
} from '@hexahost/contracts';
import {
createPresignedGetUrl,
createS3Client,
deleteStoredObject,
loadStorageConfig,
} from '@hexahost/storage';
import { AuditService } from '../../auth/audit.service';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
import { ServerStateService } from '../server-state.service';
export const SERVER_BACKUPS_QUEUE = Symbol('SERVER_BACKUPS_QUEUE');
@Injectable()
export class BackupsService {
constructor(
private readonly prisma: PrismaService,
private readonly serverState: ServerStateService,
private readonly auditService: AuditService,
@Inject(SERVER_BACKUPS_QUEUE) private readonly backupsQueue: Queue,
) {}
async listBackups(userId: string, serverId: string): Promise<BackupListResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const backups = await this.prisma.serverBackup.findMany({
where: {
serverId,
status: { not: 'DELETED' },
},
orderBy: { createdAt: 'desc' },
});
return { backups: backups.map((backup) => this.toResponse(backup)) };
}
async createBackup(
userId: string,
serverId: string,
input: CreateBackupRequest,
): Promise<BackupActionResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
if (!server.nodeId) {
throw new BadRequestException('Server is not assigned to a node');
}
if (server.status !== 'RUNNING' && server.status !== 'STOPPED') {
throw new BadRequestException(
`Cannot create backup while server is ${server.status}`,
);
}
const previousStatus = server.status;
const backup = await this.prisma.serverBackup.create({
data: {
serverId,
type: 'MANUAL',
status: 'PENDING',
label: input.label ?? 'Manual backup',
},
});
await this.prisma.gameServer.update({
where: { id: serverId },
data: { status: 'BACKING_UP' },
});
await this.prisma.gameServerStateTransition.create({
data: {
gameServerId: serverId,
fromStatus: previousStatus,
toStatus: 'BACKING_UP',
reason: 'Manual backup requested',
actorId: userId,
},
});
const job = await this.backupsQueue.add('create-backup', {
backupId: backup.id,
serverId,
previousStatus,
});
await this.auditService.record({
action: 'server.backup.create',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { backupId: backup.id },
});
return {
backup: this.toResponse(backup),
jobId: job.id?.toString(),
};
}
async getDownloadUrl(
userId: string,
serverId: string,
backupId: string,
): Promise<BackupDownloadUrlResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const backup = await this.prisma.serverBackup.findFirstOrThrow({
where: { id: backupId, serverId, status: 'AVAILABLE' },
});
if (!backup.s3Key) {
throw new BadRequestException('Backup is not stored yet');
}
const config = loadStorageConfig();
const client = createS3Client(config);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const downloadUrl = await createPresignedGetUrl(
client,
config.S3_BUCKET,
backup.s3Key,
15 * 60,
);
return {
downloadUrl,
expiresAt: expiresAt.toISOString(),
};
}
async deleteBackup(
userId: string,
serverId: string,
backupId: string,
): Promise<BackupResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const backup = await this.prisma.serverBackup.findFirstOrThrow({
where: { id: backupId, serverId },
});
if (backup.s3Key) {
const config = loadStorageConfig();
const client = createS3Client(config);
await deleteStoredObject(client, config.S3_BUCKET, backup.s3Key);
}
const updated = await this.prisma.serverBackup.update({
where: { id: backupId },
data: { status: 'DELETED' },
});
await this.auditService.record({
action: 'server.backup.delete',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { backupId },
});
return this.toResponse(updated);
}
async restoreBackup(
userId: string,
serverId: string,
backupId: string,
): Promise<BackupRestoreResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
if (!server.nodeId) {
throw new BadRequestException('Server is not assigned to a node');
}
if (server.status === 'RUNNING') {
throw new BadRequestException('Server must be stopped before restore');
}
if (server.status !== 'STOPPED') {
throw new BadRequestException(
`Cannot restore while server is ${server.status}`,
);
}
const backup = await this.prisma.serverBackup.findFirstOrThrow({
where: { id: backupId, serverId, status: 'AVAILABLE' },
});
const restore = await this.prisma.backupRestore.create({
data: {
serverId,
backupId: backup.id,
status: 'PENDING',
},
});
await this.prisma.gameServer.update({
where: { id: serverId },
data: { status: 'RESTORING' },
});
await this.prisma.gameServerStateTransition.create({
data: {
gameServerId: serverId,
fromStatus: 'STOPPED',
toStatus: 'RESTORING',
reason: 'Restore requested',
actorId: userId,
},
});
const job = await this.backupsQueue.add('restore-backup', {
restoreId: restore.id,
serverId,
backupId: backup.id,
});
await this.auditService.record({
action: 'server.backup.restore',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { backupId, restoreId: restore.id },
});
return {
restore: {
id: restore.id,
serverId,
backupId: backup.id,
safetyBackupId: restore.safetyBackupId,
status: restore.status,
failureReason: restore.failureReason,
createdAt: restore.createdAt.toISOString(),
completedAt: restore.completedAt?.toISOString() ?? null,
},
jobId: job.id?.toString(),
};
}
async getSchedule(userId: string, serverId: string): Promise<BackupSchedule> {
await findOwnedServer(this.prisma, userId, serverId);
const schedule = await this.prisma.backupSchedule.upsert({
where: { serverId },
create: {
serverId,
enabled: false,
intervalHours: 24,
retentionCount: 5,
nextRunAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
update: {},
});
return this.toScheduleResponse(schedule);
}
async updateSchedule(
userId: string,
serverId: string,
input: UpdateBackupSchedule,
): Promise<BackupSchedule> {
await findOwnedServer(this.prisma, userId, serverId);
const schedule = await this.prisma.backupSchedule.upsert({
where: { serverId },
create: {
serverId,
enabled: input.enabled ?? false,
intervalHours: input.intervalHours ?? 24,
retentionCount: input.retentionCount ?? 5,
nextRunAt: new Date(Date.now() + (input.intervalHours ?? 24) * 60 * 60 * 1000),
},
update: {
enabled: input.enabled,
intervalHours: input.intervalHours,
retentionCount: input.retentionCount,
nextRunAt:
input.enabled === true
? new Date(Date.now() + (input.intervalHours ?? 24) * 60 * 60 * 1000)
: undefined,
},
});
return this.toScheduleResponse(schedule);
}
private toResponse(backup: ServerBackup): BackupResponse {
return {
id: backup.id,
serverId: backup.serverId,
type: backup.type,
status: backup.status,
s3Key: backup.s3Key,
sizeBytes: backup.sizeBytes?.toString() ?? null,
sha256: backup.sha256,
label: backup.label,
failureReason: backup.failureReason,
createdAt: backup.createdAt.toISOString(),
completedAt: backup.completedAt?.toISOString() ?? null,
expiresAt: backup.expiresAt?.toISOString() ?? null,
};
}
private toScheduleResponse(schedule: {
enabled: boolean;
intervalHours: number;
retentionCount: number;
nextRunAt: Date | null;
lastRunAt: Date | null;
}): BackupSchedule {
return {
enabled: schedule.enabled,
intervalHours: schedule.intervalHours,
retentionCount: schedule.retentionCount,
nextRunAt: schedule.nextRunAt?.toISOString() ?? null,
lastRunAt: schedule.lastRunAt?.toISOString() ?? null,
};
}
}

View File

@@ -7,10 +7,12 @@ const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
DRAFT: ['PROVISIONING', 'DELETING'],
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
STOPPED: ['STARTING', 'PROVISIONING', 'DELETING'],
STOPPED: ['STARTING', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'],
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'DELETING'],
RUNNING: ['STOPPING', 'ERROR', 'DELETING'],
RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'DELETING'],
STOPPING: ['STOPPED', 'ERROR', 'DELETING'],
BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'],
RESTORING: ['STOPPED', 'ERROR'],
ERROR: ['STOPPED', 'STARTING', 'PROVISIONING', 'DELETING'],
DELETING: ['DELETED'],
DELETED: [],
@@ -21,6 +23,8 @@ const IN_PROGRESS_STATUSES: GameServerStatus[] = [
'INSTALLING',
'STARTING',
'STOPPING',
'BACKING_UP',
'RESTORING',
'DELETING',
];

View File

@@ -9,7 +9,9 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
import { AppConfigModule } from '../config/app-config.module';
import { ConsoleModule } from './console/console.module';
import { BackupsModule } from './backups/backups.module';
import { FilesModule } from './files/files.module';
import { WorldsModule } from './worlds/worlds.module';
import { PlayersModule } from './players/players.module';
import { PropertiesModule } from './properties/properties.module';
import { ServerStateService } from './server-state.service';
@@ -28,6 +30,8 @@ import { PlansService } from './plans.service';
AppConfigModule,
AuthModule,
forwardRef(() => ConsoleModule),
forwardRef(() => BackupsModule),
forwardRef(() => WorldsModule),
forwardRef(() => FilesModule),
forwardRef(() => PropertiesModule),
forwardRef(() => PlayersModule),

View File

@@ -314,6 +314,7 @@ export class ServersService {
hostPort: server.hostPort,
containerId: server.containerId,
dataPath: server.dataPath,
activeWorldName: server.activeWorldName,
ramMb: server.ramMb,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),

View File

@@ -0,0 +1,63 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { initiateWorldUploadSchema } from '@hexahost/contracts';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { WorldsService } from './worlds.service';
@ApiTags('servers')
@Controller('servers/:serverId/worlds')
@UseGuards(SessionGuard)
export class WorldsController {
constructor(private readonly worldsService: WorldsService) {}
@Get()
@ApiOperation({ summary: 'Get world overview' })
info(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
) {
return this.worldsService.getWorldInfo(user.id, serverId);
}
@Post('uploads')
@ApiOperation({ summary: 'Initiate a world upload' })
initiateUpload(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body() body: unknown,
) {
const input = initiateWorldUploadSchema.parse(body);
return this.worldsService.initiateUpload(user.id, serverId, input);
}
@Post('uploads/:uploadId/complete')
@ApiOperation({ summary: 'Complete a world upload and apply it' })
completeUpload(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('uploadId', ParseUUIDPipe) uploadId: string,
) {
return this.worldsService.completeUpload(user.id, serverId, uploadId);
}
@Post('download')
@ApiOperation({ summary: 'Create a backup for world download' })
download(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
) {
return this.worldsService.requestWorldDownload(user.id, serverId);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
import { PrismaModule } from '../../prisma/prisma.module';
import { BackupsModule } from '../backups/backups.module';
import { ServerStateService } from '../server-state.service';
import { WorldsController } from './worlds.controller';
import { WorldsService } from './worlds.service';
@Module({
imports: [AuthModule, PrismaModule, NodeBridgeModule, BackupsModule],
controllers: [WorldsController],
providers: [WorldsService, ServerStateService],
})
export class WorldsModule {}

View File

@@ -0,0 +1,250 @@
import {
BadRequestException,
Inject,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import { randomUUID } from 'node:crypto';
import type {
InitiateWorldUpload,
WorldDownloadUrlResponse,
WorldInfoResponse,
WorldUploadInitResponse,
WorldUploadResponse,
} from '@hexahost/contracts';
import type { GameServer, WorldUpload } from '@hexahost/database';
import {
createPresignedPutUrl,
createS3Client,
headStoredObject,
loadStorageConfig,
worldUploadObjectKey,
} from '@hexahost/storage';
import { AuditService } from '../../auth/audit.service';
import { NodeBridgeService } from '../../nodes/node-bridge.service';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
import { ServerStateService } from '../server-state.service';
import { SERVER_BACKUPS_QUEUE } from '../backups/backups.service';
const WORLD_MANAGE_STATUSES = new Set<GameServer['status']>(['STOPPED']);
@Injectable()
export class WorldsService {
constructor(
private readonly prisma: PrismaService,
private readonly nodeBridge: NodeBridgeService,
private readonly serverState: ServerStateService,
private readonly auditService: AuditService,
@Inject(SERVER_BACKUPS_QUEUE) private readonly backupsQueue: Queue,
) {}
async getWorldInfo(userId: string, serverId: string): Promise<WorldInfoResponse> {
const server = await this.requireNode(serverId, userId);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.world.validate',
{
serverId,
generation: server.version,
worldName: server.activeWorldName,
},
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to load world info',
);
}
const result = response.result as {
worlds?: Array<{
name: string;
path: string;
sizeBytes: number;
hasLevelDat: boolean;
}>;
};
return {
activeWorldName: server.activeWorldName,
worlds: (result.worlds ?? []).map((world) => ({
name: world.name,
path: world.path,
sizeBytes: String(world.sizeBytes),
hasLevelDat: world.hasLevelDat,
})),
};
}
async initiateUpload(
userId: string,
serverId: string,
input: InitiateWorldUpload,
): Promise<WorldUploadInitResponse> {
const server = await this.requireStoppedServer(userId, serverId);
const uploadId = randomUUID();
const config = loadStorageConfig();
const client = createS3Client(config);
const s3Key = worldUploadObjectKey(serverId, uploadId);
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
const uploadUrl = await createPresignedPutUrl(
client,
config.S3_BUCKET,
s3Key,
60 * 60,
'application/gzip',
);
await this.prisma.worldUpload.create({
data: {
id: uploadId,
serverId,
status: 'UPLOADING',
s3Key,
worldName: input.worldName,
},
});
return {
uploadId,
uploadUrl,
expiresAt: expiresAt.toISOString(),
s3Key,
};
}
async completeUpload(
userId: string,
serverId: string,
uploadId: string,
): Promise<WorldUploadResponse> {
const server = await this.requireStoppedServer(userId, serverId);
const upload = await this.prisma.worldUpload.findFirstOrThrow({
where: { id: uploadId, serverId },
});
const config = loadStorageConfig();
const client = createS3Client(config);
const head = await headStoredObject(client, config.S3_BUCKET, upload.s3Key);
if (head.sizeBytes <= 0) {
throw new BadRequestException('Uploaded world archive is empty');
}
const updated = await this.prisma.worldUpload.update({
where: { id: uploadId },
data: {
status: 'READY',
sizeBytes: BigInt(head.sizeBytes),
},
});
await this.backupsQueue.add('apply-world-upload', {
uploadId,
serverId,
});
await this.auditService.record({
action: 'server.world.upload',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { uploadId, worldName: upload.worldName },
});
return this.toUploadResponse(updated);
}
async requestWorldDownload(
userId: string,
serverId: string,
): Promise<WorldDownloadUrlResponse> {
const server = await this.requireNode(serverId, userId);
if (server.status !== 'RUNNING' && server.status !== 'STOPPED') {
throw new BadRequestException(
`Cannot download world while server is ${server.status}`,
);
}
const backup = await this.prisma.serverBackup.create({
data: {
serverId,
type: 'MANUAL',
status: 'PENDING',
label: 'World download',
},
});
const previousStatus = server.status as 'RUNNING' | 'STOPPED';
await this.prisma.gameServer.update({
where: { id: serverId },
data: { status: 'BACKING_UP' },
});
await this.prisma.gameServerStateTransition.create({
data: {
gameServerId: serverId,
fromStatus: server.status,
toStatus: 'BACKING_UP',
reason: 'World download backup started',
actorId: userId,
},
});
await this.backupsQueue.add('create-backup', {
backupId: backup.id,
serverId,
previousStatus,
});
return {
backupId: backup.id,
downloadUrl: '',
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
};
}
private async requireNode(serverId: string, userId: string): Promise<GameServer> {
const server = await findOwnedServer(this.prisma, userId, serverId);
if (!server.nodeId) {
throw new BadRequestException('Server is not assigned to a node');
}
return server;
}
private async requireStoppedServer(
userId: string,
serverId: string,
): Promise<GameServer> {
const server = await this.requireNode(serverId, userId);
this.serverState.assertNotInProgress(server.status);
if (!WORLD_MANAGE_STATUSES.has(server.status)) {
throw new BadRequestException(
`World changes require server to be STOPPED (current: ${server.status})`,
);
}
return server;
}
private toUploadResponse(upload: WorldUpload): WorldUploadResponse {
return {
id: upload.id,
serverId: upload.serverId,
status: upload.status,
worldName: upload.worldName,
sizeBytes: upload.sizeBytes?.toString() ?? null,
failureReason: upload.failureReason,
createdAt: upload.createdAt.toISOString(),
completedAt: upload.completedAt?.toISOString() ?? null,
};
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long