From 7bb97953a7978b97291de8ab8a65957215fa932f Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 21 Jan 2026 13:11:18 +0100 Subject: [PATCH] feat: Refactor feature export service with type guards and parallel conflict checking --- .../src/routes/features/routes/import.ts | 45 ++++++------- .../src/services/feature-export-service.ts | 63 ++++++++++++------- 2 files changed, 61 insertions(+), 47 deletions(-) diff --git a/apps/server/src/routes/features/routes/import.ts b/apps/server/src/routes/features/routes/import.ts index 81f4eb7c..85fb6d9b 100644 --- a/apps/server/src/routes/features/routes/import.ts +++ b/apps/server/src/routes/features/routes/import.ts @@ -158,45 +158,40 @@ export function createConflictCheckHandler(featureLoader: FeatureLoader) { return; } - // Extract features from the data - type FeatureExportType = { feature: { id: string; title?: string } }; - type BulkExportType = { features: FeatureExportType[] }; - type RawFeatureType = { id: string; title?: string }; - + // Extract features from the data using type guards let featuresToCheck: Array<{ id: string; title?: string }> = []; - if ('features' in parsed && Array.isArray((parsed as BulkExportType).features)) { + if (exportService.isBulkExport(parsed)) { // Bulk export format - featuresToCheck = (parsed as BulkExportType).features.map((f) => ({ + featuresToCheck = parsed.features.map((f) => ({ id: f.feature.id, title: f.feature.title, })); - } else if ('feature' in parsed) { + } else if (exportService.isFeatureExport(parsed)) { // Single FeatureExport format - const featureExport = parsed as FeatureExportType; featuresToCheck = [ { - id: featureExport.feature.id, - title: featureExport.feature.title, + id: parsed.feature.id, + title: parsed.feature.title, }, ]; - } else if ('id' in parsed) { + } else if (exportService.isRawFeature(parsed)) { // Raw Feature format - const rawFeature = parsed as RawFeatureType; - featuresToCheck = [{ id: rawFeature.id, title: rawFeature.title }]; + featuresToCheck = [{ id: parsed.id, title: parsed.title }]; } - // Check each feature for conflicts - const conflicts: ConflictInfo[] = []; - for (const feature of featuresToCheck) { - const existing = await featureLoader.get(projectPath, feature.id); - conflicts.push({ - featureId: feature.id, - title: feature.title, - existingTitle: existing?.title, - hasConflict: !!existing, - }); - } + // Check each feature for conflicts in parallel + const conflicts: ConflictInfo[] = await Promise.all( + featuresToCheck.map(async (feature) => { + const existing = await featureLoader.get(projectPath, feature.id); + return { + featureId: feature.id, + title: feature.title, + existingTitle: existing?.title, + hasConflict: !!existing, + }; + }) + ); const hasConflicts = conflicts.some((c) => c.hasConflict); diff --git a/apps/server/src/services/feature-export-service.ts b/apps/server/src/services/feature-export-service.ts index 0f022bbb..a58b6527 100644 --- a/apps/server/src/services/feature-export-service.ts +++ b/apps/server/src/services/feature-export-service.ts @@ -133,7 +133,7 @@ export class FeatureExportService { ...(metadata ? { metadata } : {}), }; - return this.serializeExport(exportData, format, prettyPrint); + return this.serialize(exportData, format, prettyPrint); } /** @@ -170,16 +170,19 @@ export class FeatureExportService { features = features.filter((f) => f.status === status); } + // Generate timestamp once for consistent export time across all features + const exportedAt = new Date().toISOString(); + // Prepare feature exports const featureExports: FeatureExport[] = features.map((feature) => ({ version: FEATURE_EXPORT_VERSION, feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }), - exportedAt: new Date().toISOString(), + exportedAt, })); const bulkExport: BulkExportResult = { version: FEATURE_EXPORT_VERSION, - exportedAt: new Date().toISOString(), + exportedAt, count: featureExports.length, features: featureExports, ...(metadata ? { metadata } : {}), @@ -187,7 +190,7 @@ export class FeatureExportService { logger.info(`Exported ${featureExports.length} features from ${projectPath}`); - return this.serializeBulkExport(bulkExport, format, prettyPrint); + return this.serialize(bulkExport, format, prettyPrint); } /** @@ -449,7 +452,7 @@ export class FeatureExportService { /** * Check if parsed data is a bulk export */ - private isBulkExport(data: unknown): data is BulkExportResult { + isBulkExport(data: unknown): data is BulkExportResult { if (!data || typeof data !== 'object') { return false; } @@ -457,6 +460,36 @@ export class FeatureExportService { return 'version' in obj && 'features' in obj && Array.isArray(obj.features); } + /** + * Check if parsed data is a single FeatureExport + */ + isFeatureExport(data: unknown): data is FeatureExport { + if (!data || typeof data !== 'object') { + return false; + } + const obj = data as Record; + return ( + 'version' in obj && + 'feature' in obj && + 'exportedAt' in obj && + typeof obj.feature === 'object' && + obj.feature !== null && + 'id' in (obj.feature as Record) + ); + } + + /** + * Check if parsed data is a raw Feature + */ + isRawFeature(data: unknown): data is Feature { + if (!data || typeof data !== 'object') { + return false; + } + const obj = data as Record; + // A raw feature has 'id' but not the 'version' + 'feature' wrapper of FeatureExport + return 'id' in obj && !('feature' in obj && 'version' in obj); + } + /** * Validate a feature has required fields */ @@ -475,24 +508,10 @@ export class FeatureExportService { } /** - * Serialize export data to string + * Serialize export data to string (handles both single feature and bulk exports) */ - private serializeExport(data: FeatureExport, format: ExportFormat, prettyPrint: boolean): string { - if (format === 'yaml') { - return yamlStringify(data, { - indent: 2, - lineWidth: 120, - }); - } - - return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data); - } - - /** - * Serialize bulk export data to string - */ - private serializeBulkExport( - data: BulkExportResult, + private serialize( + data: T, format: ExportFormat, prettyPrint: boolean ): string {