feat: Refactor feature export service with type guards and parallel conflict checking

This commit is contained in:
Shirone
2026-01-21 13:11:18 +01:00
parent 2214c2700b
commit 7bb97953a7
2 changed files with 61 additions and 47 deletions

View File

@@ -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);

View File

@@ -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<string, unknown>;
return (
'version' in obj &&
'feature' in obj &&
'exportedAt' in obj &&
typeof obj.feature === 'object' &&
obj.feature !== null &&
'id' in (obj.feature as Record<string, unknown>)
);
}
/**
* 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<string, unknown>;
// 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<T extends FeatureExport | BulkExportResult>(
data: T,
format: ExportFormat,
prettyPrint: boolean
): string {