diff --git a/src/main/engine/MetadataDiffEngine.ts b/src/main/engine/MetadataDiffEngine.ts index 09ca154..a7effad 100644 --- a/src/main/engine/MetadataDiffEngine.ts +++ b/src/main/engine/MetadataDiffEngine.ts @@ -77,6 +77,45 @@ export interface TableStats { export class MetadataDiffEngine extends EventEmitter { private currentProjectId = 'default'; + private async runSyncLoop( + postIds: string[], + onProgress: ((percent: number, message: string) => void) | undefined, + processPost: (postId: string) => Promise, + errorMessage: (postId: string) => string + ): Promise<{ success: number; failed: number }> { + const total = postIds.length; + let success = 0; + let failed = 0; + + for (let i = 0; i < postIds.length; i++) { + const postId = postIds[i]; + try { + const processed = await processPost(postId); + if (processed) { + success++; + } else { + failed++; + } + } catch (error) { + console.error(errorMessage(postId), error); + failed++; + } + + // Report progress every 10 posts or on last post + if (onProgress && (i % 10 === 0 || i === postIds.length - 1)) { + const percent = Math.round(((i + 1) / total) * 100); + onProgress(percent, `Synced ${i + 1} of ${total} posts...`); + } + + // Yield to event loop every 20 posts + if (i % 20 === 0) { + await new Promise(resolve => setImmediate(resolve)); + } + } + + return { success, failed }; + } + setProjectContext(projectId: string): void { this.currentProjectId = projectId; } @@ -325,37 +364,12 @@ export class MetadataDiffEngine extends EventEmitter { onProgress?: (percent: number, message: string) => void ): Promise<{ success: number; failed: number }> { const postEngine = getPostEngine(); - const total = postIds.length; - let success = 0; - let failed = 0; - - for (let i = 0; i < postIds.length; i++) { - const postId = postIds[i]; - try { - const synced = await postEngine.syncPublishedPostFile(postId); - if (synced) { - success++; - } else { - failed++; - } - } catch (error) { - console.error(`[MetadataDiffEngine] Failed to sync post ${postId} to file:`, error); - failed++; - } - - // Report progress every 10 posts or on last post - if (onProgress && (i % 10 === 0 || i === postIds.length - 1)) { - const percent = Math.round(((i + 1) / total) * 100); - onProgress(percent, `Synced ${i + 1} of ${total} posts...`); - } - - // Yield to event loop every 20 posts - if (i % 20 === 0) { - await new Promise(resolve => setImmediate(resolve)); - } - } - - return { success, failed }; + return this.runSyncLoop( + postIds, + onProgress, + async (postId) => postEngine.syncPublishedPostFile(postId), + (postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to file:` + ); } /** @@ -368,13 +382,10 @@ export class MetadataDiffEngine extends EventEmitter { onProgress?: (percent: number, message: string) => void ): Promise<{ success: number; failed: number }> { const db = this.getDb(); - const total = postIds.length; - let success = 0; - let failed = 0; - - for (let i = 0; i < postIds.length; i++) { - const postId = postIds[i]; - try { + return this.runSyncLoop( + postIds, + onProgress, + async (postId) => { // Get the post from DB to get file path const dbPost = await db .select() @@ -383,15 +394,13 @@ export class MetadataDiffEngine extends EventEmitter { .get(); if (!dbPost || !dbPost.filePath) { - failed++; - continue; + return false; } // Read file metadata const fileData = await readPostFile(dbPost.filePath); if (!fileData) { - failed++; - continue; + return false; } // Build update object based on field or all fields @@ -421,25 +430,10 @@ export class MetadataDiffEngine extends EventEmitter { .set(updateData) .where(eq(posts.id, postId)); - success++; - } catch (error) { - console.error(`[MetadataDiffEngine] Failed to sync post ${postId} to DB:`, error); - failed++; - } - - // Report progress every 10 posts or on last post - if (onProgress && (i % 10 === 0 || i === postIds.length - 1)) { - const percent = Math.round(((i + 1) / total) * 100); - onProgress(percent, `Synced ${i + 1} of ${total} posts...`); - } - - // Yield to event loop every 20 posts - if (i % 20 === 0) { - await new Promise(resolve => setImmediate(resolve)); - } - } - - return { success, failed }; + return true; + }, + (postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to DB:` + ); } /** diff --git a/tests/engine/MetadataDiffEngine.test.ts b/tests/engine/MetadataDiffEngine.test.ts index 8ede98a..7e8d040 100644 --- a/tests/engine/MetadataDiffEngine.test.ts +++ b/tests/engine/MetadataDiffEngine.test.ts @@ -491,6 +491,29 @@ Content`); expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('post-1'); expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('post-2'); }); + + it('should report progress on first and final items based on cadence', async () => { + const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`); + const onProgress = vi.fn(); + + await engine.syncDbToFile(postIds, onProgress); + + expect(onProgress).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, 9, 'Synced 1 of 11 posts...'); + expect(onProgress).toHaveBeenNthCalledWith(2, 100, 'Synced 11 of 11 posts...'); + }); + + it('should keep processing and count failures when sync throws or returns false', async () => { + mockSyncPublishedPostFile + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error('sync failure')); + + const result = await engine.syncDbToFile(['post-1', 'post-2', 'post-3']); + + expect(result).toEqual({ success: 1, failed: 2 }); + expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(3); + }); }); describe('syncFileToDb', () => { @@ -529,5 +552,83 @@ Content here`); // Verify the database update was called expect(mockLocalDb.update).toHaveBeenCalled(); }); + + it('should report progress on first and final items based on cadence', async () => { + const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`); + + mockPostsGetQueue = postIds.map((postId) => ({ + id: postId, + projectId: 'test-project', + title: `Post ${postId}`, + slug: postId, + status: 'published', + filePath: `/mock/userData/posts/2024/01/${postId}.md`, + tags: '[]', + categories: '[]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + })); + + for (const postId of postIds) { + mockFileData.set(`/mock/userData/posts/2024/01/${postId}.md`, `---\nid: ${postId}\nprojectId: test-project\ntitle: "${postId}"\nslug: ${postId}\nstatus: published\ntags: []\ncategories: []\n---\nContent`); + } + + const onProgress = vi.fn(); + await engine.syncFileToDb(postIds, undefined, onProgress); + + expect(onProgress).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, 9, 'Synced 1 of 11 posts...'); + expect(onProgress).toHaveBeenNthCalledWith(2, 100, 'Synced 11 of 11 posts...'); + }); + + it('should continue after missing file path and file read failures', async () => { + const postIds = ['post-1', 'post-2', 'post-3']; + + mockPostsGetQueue = [ + { + id: 'post-1', + projectId: 'test-project', + title: 'Post 1', + slug: 'post-1', + status: 'published', + filePath: null, + tags: '[]', + categories: '[]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + }, + { + id: 'post-2', + projectId: 'test-project', + title: 'Post 2', + slug: 'post-2', + status: 'published', + filePath: '/mock/userData/posts/2024/01/post-2.md', + tags: '[]', + categories: '[]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + }, + { + id: 'post-3', + projectId: 'test-project', + title: 'Post 3', + slug: 'post-3', + status: 'published', + filePath: '/mock/userData/posts/2024/01/post-3.md', + tags: '[]', + categories: '[]', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + }, + ]; + + mockFileData.set('/mock/userData/posts/2024/01/post-3.md', `---\nid: post-3\nprojectId: test-project\ntitle: "Post 3"\nslug: post-3\nstatus: published\ntags: ["from-file"]\ncategories: []\n---\nContent`); + + const result = await engine.syncFileToDb(postIds, 'tags'); + + expect(result).toEqual({ success: 1, failed: 2 }); + expect(mockLocalDb.update).toHaveBeenCalledTimes(1); + }); }); });