import FirmwareEntryModel from '#models/firmware_entry' import MigrationRouteModel from '#models/migration_route' import { FirmwareEntry, MigrationRoute } from '../domain/firmware/firmware_catalog.js' import { MigrationStep } from '../domain/firmware/migration_step.js' import { matchGlob } from '../utils/glob_match.js' import { BadRequestException } from '../exceptions/index.js' export interface CreateFirmwareEntryDto { identifier: string productClass: string version: string downloadUrl: string checksum: string checksumType: 'md5' | 'sha256' fileSize: number } export interface CreateMigrationRouteDto { identifier: string productClass: string sourceVersionPattern: string targetVersion: string downloadUrl: string priority: number } export default class FirmwareCatalogService { /** * Validate that a string is a valid URL. */ private validateUrl(url: string, fieldName: string): void { try { new URL(url) } catch { throw new BadRequestException(`${fieldName} must be a valid URL`) } } /** * Validate that a number is a positive integer (> 0). */ private validatePositiveInteger(value: number, fieldName: string): void { if (!Number.isInteger(value) || value <= 0) { throw new BadRequestException(`${fieldName} must be a positive integer`) } } /** * Validate that a string is non-empty. */ private validateNonEmptyString(value: string, fieldName: string): void { if (!value || value.trim().length === 0) { throw new BadRequestException(`${fieldName} must be a non-empty string`) } } /** * Validate checksumType is 'md5' or 'sha256'. */ private validateChecksumType(value: string, fieldName: string): void { if (value !== 'md5' && value !== 'sha256') { throw new BadRequestException(`${fieldName} must be 'md5' or 'sha256'`) } } /** * Create a new firmware entry after validation. */ async createEntry(data: CreateFirmwareEntryDto): Promise { this.validateNonEmptyString(data.identifier, 'identifier') this.validateUrl(data.downloadUrl, 'downloadUrl') this.validatePositiveInteger(data.fileSize, 'fileSize') this.validateChecksumType(data.checksumType, 'checksumType') return FirmwareEntryModel.createIfNotExists({ identifier: data.identifier, productClass: data.productClass, version: data.version, downloadUrl: data.downloadUrl, checksum: data.checksum, checksumType: data.checksumType, fileSize: data.fileSize, }) } /** * Find firmware entries, optionally filtered by productClass. */ async findEntries(productClass?: string): Promise { if (productClass) { return FirmwareEntryModel.findByProductClass(productClass) } return FirmwareEntryModel.find({}) as unknown as Promise } /** * Delete a firmware entry by identifier. */ async deleteEntry(identifier: string): Promise { const deleted = await FirmwareEntryModel.deleteByIdentifier(identifier) if (!deleted) { throw new BadRequestException(`Firmware entry '${identifier}' not found`) } } /** * Create a new migration route after validation. */ async createMigrationRoute(data: CreateMigrationRouteDto): Promise { this.validateNonEmptyString(data.identifier, 'identifier') this.validateUrl(data.downloadUrl, 'downloadUrl') this.validatePositiveInteger(data.priority, 'priority') return MigrationRouteModel.createIfNotExists({ identifier: data.identifier, productClass: data.productClass, sourceVersionPattern: data.sourceVersionPattern, targetVersion: data.targetVersion, downloadUrl: data.downloadUrl, priority: data.priority, }) } async findAllRoutes(): Promise { return MigrationRouteModel.find({}) as unknown as Promise } async updateEntry(identifier: string, data: Partial): Promise { const collection = FirmwareEntryModel.getCollection() const result = await collection.findOneAndUpdate( { identifier }, { $set: data }, { returnDocument: 'after' } ) if (!result) throw new BadRequestException(`Firmware entry '${identifier}' not found`) return result as unknown as FirmwareEntry } /** * Find migration routes for a productClass, sorted by priority descending. */ async findMigrationRoutes(productClass: string): Promise { return MigrationRouteModel.findByProductClass(productClass, { priority: -1 }) } /** * Delete a migration route by identifier. */ async deleteMigrationRoute(identifier: string): Promise { const deleted = await MigrationRouteModel.deleteByIdentifier(identifier) if (!deleted) { throw new BadRequestException(`Migration route '${identifier}' not found`) } } /** * Resolve the firmware upgrade path from currentVersion to targetVersion. * Uses a greedy best-first algorithm based on route priority with cycle detection. * * @returns MigrationStep[] ordered from first to last, or [] if no path exists */ async resolveUpgradePath( productClass: string, currentVersion: string, targetVersion: string ): Promise { // Trivial case: already at target version if (currentVersion === targetVersion) return [] // Load all routes for this productClass const routes = await MigrationRouteModel.findByProductClass(productClass) const path: MigrationStep[] = [] const visited = new Set() let version = currentVersion visited.add(version) while (version !== targetVersion) { // Find all applicable routes from the current version const applicableRoutes = routes .filter((r) => matchGlob(r.sourceVersionPattern, version)) .sort((a, b) => b.priority - a.priority) // Highest priority first // Take the best route (highest priority) const bestRoute = applicableRoutes[0] if (!bestRoute) { // No route found → no path exists return [] } // Cycle detection if (visited.has(bestRoute.targetVersion)) { return [] } // Add step to path path.push({ fromVersion: version, toVersion: bestRoute.targetVersion, downloadUrl: bestRoute.downloadUrl, fileSize: 0, checksum: '', }) // Advance visited.add(bestRoute.targetVersion) version = bestRoute.targetVersion } // Enrich each step with fileSize and checksum from firmware_entries for (const step of path) { const entry = await FirmwareEntryModel.findByProductClassAndVersion( productClass, step.toVersion ) if (entry) { step.fileSize = entry.fileSize step.checksum = entry.checksum } } return path } }