From 54cc7f967d0711784879d295295a48f800b19d78 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 6 Apr 2026 16:54:40 -0500 Subject: [PATCH 1/5] feat(remote-sync): implement remote sync controller and related functionalities --- .../RemoteSyncErrorHandler.test.ts | 94 ---- .../RemoteSyncErrorHandler.ts | 113 ----- .../remote-sync/RemoteSyncManager.test.ts | 313 -------------- .../main/remote-sync/RemoteSyncManager.ts | 401 ------------------ .../create-remote-sync-error-handler.test.ts | 102 +++++ .../create-remote-sync-error-handler.ts | 44 ++ .../main/remote-sync/fetch-remote-files.ts | 30 ++ .../main/remote-sync/fetch-remote-folders.ts | 30 ++ .../get-last-updated-checkpoint.ts | 27 ++ .../get-remote-sync-error-detail.ts | 81 ++++ src/apps/main/remote-sync/handlers.test.ts | 152 +++++++ src/apps/main/remote-sync/handlers.ts | 10 +- .../patch-drive-file-response-item.ts | 15 + .../patch-drive-folder-response-item.ts | 28 ++ .../change-status.test.ts | 33 ++ .../remote-sync-controller/change-status.ts | 28 ++ .../check-remote-sync-status.test.ts | 47 ++ .../check-remote-sync-status.ts | 26 ++ .../controller-methods.test.ts | 38 ++ .../controller-methods.ts | 26 ++ .../create-controller-state.test.ts | 18 + .../create-controller-state.ts | 12 + .../remote-sync-controller/index.test.ts | 312 ++++++++++++++ .../remote-sync-controller/index.ts | 29 ++ .../reset-remote-sync.test.ts | 28 ++ .../reset-remote-sync.ts | 17 + .../start-remote-sync.ts | 76 ++++ .../sync-remote-files.ts | 54 +++ .../sync-remote-folders.ts | 54 +++ .../remote-sync-controller/types.ts | 49 +++ .../remote-sync/resolve-remote-sync-status.ts | 33 ++ src/apps/main/remote-sync/service.test.ts | 217 ++++++++++ src/apps/main/remote-sync/service.ts | 35 +- .../main/remote-sync/sync-remote-items.ts | 91 ++++ .../remote-sync/to-remote-sync-file-dto.ts | 21 + .../remote-sync/to-remote-sync-folder-dto.ts | 16 + .../main/remote-sync/wait-before-retry.ts | 7 + .../sync/TriggerRemoteSyncOnFileOverridden.ts | 4 +- .../sync/remote-sync-service.test.ts | 30 ++ .../application/sync/remote-sync-service.ts | 21 + .../application/sync/remote-sync.contract.ts | 33 ++ .../SQLiteRemoteItemsGenerator.ts | 8 +- tests/vitest/ipc.helper.ts | 12 + 43 files changed, 1871 insertions(+), 944 deletions(-) delete mode 100644 src/apps/main/remote-sync/RemoteSyncErrorHandler/RemoteSyncErrorHandler.test.ts delete mode 100644 src/apps/main/remote-sync/RemoteSyncErrorHandler/RemoteSyncErrorHandler.ts delete mode 100644 src/apps/main/remote-sync/RemoteSyncManager.test.ts delete mode 100644 src/apps/main/remote-sync/RemoteSyncManager.ts create mode 100644 src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts create mode 100644 src/apps/main/remote-sync/create-remote-sync-error-handler.ts create mode 100644 src/apps/main/remote-sync/fetch-remote-files.ts create mode 100644 src/apps/main/remote-sync/fetch-remote-folders.ts create mode 100644 src/apps/main/remote-sync/get-last-updated-checkpoint.ts create mode 100644 src/apps/main/remote-sync/get-remote-sync-error-detail.ts create mode 100644 src/apps/main/remote-sync/handlers.test.ts create mode 100644 src/apps/main/remote-sync/patch-drive-file-response-item.ts create mode 100644 src/apps/main/remote-sync/patch-drive-folder-response-item.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/change-status.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/create-controller-state.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/index.test.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/index.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/start-remote-sync.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts create mode 100644 src/apps/main/remote-sync/remote-sync-controller/types.ts create mode 100644 src/apps/main/remote-sync/resolve-remote-sync-status.ts create mode 100644 src/apps/main/remote-sync/service.test.ts create mode 100644 src/apps/main/remote-sync/sync-remote-items.ts create mode 100644 src/apps/main/remote-sync/to-remote-sync-file-dto.ts create mode 100644 src/apps/main/remote-sync/to-remote-sync-folder-dto.ts create mode 100644 src/apps/main/remote-sync/wait-before-retry.ts create mode 100644 src/context/shared/application/sync/remote-sync-service.test.ts create mode 100644 src/context/shared/application/sync/remote-sync-service.ts create mode 100644 src/context/shared/application/sync/remote-sync.contract.ts create mode 100644 tests/vitest/ipc.helper.ts diff --git a/src/apps/main/remote-sync/RemoteSyncErrorHandler/RemoteSyncErrorHandler.test.ts b/src/apps/main/remote-sync/RemoteSyncErrorHandler/RemoteSyncErrorHandler.test.ts deleted file mode 100644 index bd03d9551a..0000000000 --- a/src/apps/main/remote-sync/RemoteSyncErrorHandler/RemoteSyncErrorHandler.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -vi.mock('@internxt/drive-desktop-core/build/backend'); - -vi.mock('../../issues/virtual-drive', () => ({ - addVirtualDriveIssue: vi.fn(), -})); - -import { RemoteSyncErrorHandler, syncItemType } from './RemoteSyncErrorHandler'; -import { RemoteSyncError, RemoteSyncNetworkError, RemoteSyncServerError } from '../errors'; -import { addVirtualDriveIssue } from '../../issues/virtual-drive'; -import { VirtualDriveIssue } from '../../../../shared/issues/VirtualDriveIssue'; - -describe('RemoteSyncErrorHandler', () => { - let sut: RemoteSyncErrorHandler; - - beforeEach(() => { - sut = new RemoteSyncErrorHandler(); - vi.clearAllMocks(); - }); - - describe('handleSyncError ', () => { - it('should handle properly a type RemoteSyncNetworkError', () => { - const networkError = new RemoteSyncNetworkError('Test error'); - const syncType: syncItemType = 'files'; - const itemName = 'Test File'; - const checkpoint = new Date('2025-02-24'); - const handleNetworkErrorSpy = vi.spyOn(sut, 'handleNetworkError'); - - sut.handleSyncError(networkError, syncType, itemName, checkpoint); - - expect(handleNetworkErrorSpy).toHaveBeenCalledWith(networkError, syncType, itemName); - expect(addVirtualDriveIssue).toHaveBeenCalled(); - }); - - it('should handle properly a type RemoteSyncServerError', () => { - const serverError = new RemoteSyncServerError(500, { - message: 'Server error occurred', - }); - const syncType: syncItemType = 'folders'; - const itemName = 'Test Folder'; - const checkpoint = new Date('2025-02-24'); - const handleServerErrorSpy = vi.spyOn(sut, 'handleServerError'); - - sut.handleSyncError(serverError, syncType, itemName, checkpoint); - - expect(handleServerErrorSpy).toHaveBeenCalledWith(serverError, syncType, itemName); - expect(addVirtualDriveIssue).toHaveBeenCalled(); - }); - - it('should handle properly the default type RemoteSyncError', () => { - const genericError = new RemoteSyncError('Test generic error'); - const syncType: syncItemType = 'files'; - const itemName = 'Test File'; - const checkpoint = new Date('2025-02-24'); - const handleRemoteSyncErrorSpy = vi.spyOn(sut, 'handleRemoteSyncError'); - - sut.handleSyncError(genericError, syncType, itemName, checkpoint); - - expect(handleRemoteSyncErrorSpy).toHaveBeenCalledWith(genericError, syncType, itemName); - expect(addVirtualDriveIssue).toHaveBeenCalled(); - }); - }); - - describe('handleSyncErrorWithIssue', () => { - it('should properly add a virtual drive issue', () => { - const genericError = new RemoteSyncError('Test generic error'); - const syncType: syncItemType = 'files'; - const errorDetail = { - errorLabel: 'Test error label', - issue: { - error: 'UPLOAD_ERROR', - cause: 'NO_INTERNET', - name: 'Test File', - } as VirtualDriveIssue, - }; - - const issues: Record = { - files: { - error: 'UPLOAD_ERROR', - cause: 'NO_INTERNET', - name: 'Test File', - }, - folders: { - error: 'UPLOAD_ERROR', - cause: 'NO_INTERNET', - name: 'Test Folder', - }, - }; - - sut.handleSyncErrorWithIssue(genericError, syncType, errorDetail); - - expect(addVirtualDriveIssue).toHaveBeenCalledWith(issues[syncType]); - }); - }); -}); diff --git a/src/apps/main/remote-sync/RemoteSyncErrorHandler/RemoteSyncErrorHandler.ts b/src/apps/main/remote-sync/RemoteSyncErrorHandler/RemoteSyncErrorHandler.ts deleted file mode 100644 index e32c170bf6..0000000000 --- a/src/apps/main/remote-sync/RemoteSyncErrorHandler/RemoteSyncErrorHandler.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - RemoteSyncError, - RemoteSyncInvalidResponseError, - RemoteSyncNetworkError, - RemoteSyncServerError, -} from '../errors'; -import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { addVirtualDriveIssue } from '../../issues/virtual-drive'; -import { VirtualDriveIssue } from '../../../../shared/issues/VirtualDriveIssue'; - -export type syncItemType = 'files' | 'folders'; - -export interface VirtualDriveIssueByType { - files: VirtualDriveIssue; - folders: VirtualDriveIssue; -} - -export class RemoteSyncErrorHandler { - public handleSyncError( - error: RemoteSyncError, - syncItemType: syncItemType, - itemName: string, - itemCheckpoint?: Date, - ): void { - switch (true) { - case error instanceof RemoteSyncNetworkError: - this.handleNetworkError(error, syncItemType, itemName); - break; - case error instanceof RemoteSyncServerError: - this.handleServerError(error, syncItemType, itemName); - break; - case error instanceof RemoteSyncInvalidResponseError: - // no-op - break; - default: - this.handleRemoteSyncError(error, syncItemType, itemName); - } - } - - handleSyncErrorWithIssue( - error: RemoteSyncError, - syncItemType: syncItemType, - errorDetail: { - errorLabel: string; - issue: VirtualDriveIssue; - }, - ): void { - logger.error({ - tag: 'SYNC-ENGINE', - msg: `[SYNC MANAGER] Remote ${syncItemType} sync failed with ${errorDetail.errorLabel} error: `, - error, - }); - addVirtualDriveIssue(errorDetail.issue); - } - - handleNetworkError(error: RemoteSyncNetworkError, syncItemType: syncItemType, itemName: string): void { - const issues: VirtualDriveIssueByType = { - files: { - error: 'DOWNLOAD_ERROR', - cause: 'NO_INTERNET', - name: itemName, - }, - folders: { - error: 'FOLDER_CREATE_ERROR', - cause: 'NO_INTERNET', - name: itemName, - }, - }; - this.handleSyncErrorWithIssue(error, syncItemType, { - errorLabel: 'network', - issue: issues[syncItemType], - }); - } - - handleServerError(error: RemoteSyncServerError, syncItemType: syncItemType, itemName: string): void { - const issues: VirtualDriveIssueByType = { - files: { - error: 'DOWNLOAD_ERROR', - cause: 'NO_REMOTE_CONNECTION', - name: itemName, - }, - folders: { - error: 'FOLDER_CREATE_ERROR', - cause: 'NO_REMOTE_CONNECTION', - name: itemName, - }, - }; - this.handleSyncErrorWithIssue(error, syncItemType, { - errorLabel: 'server', - issue: issues[syncItemType], - }); - } - - handleRemoteSyncError(error: RemoteSyncError, syncItemType: syncItemType, itemName: string): void { - const issues: VirtualDriveIssueByType = { - files: { - error: 'DOWNLOAD_ERROR', - cause: 'NO_REMOTE_CONNECTION', - name: itemName, - }, - folders: { - error: 'FOLDER_CREATE_ERROR', - cause: 'NO_REMOTE_CONNECTION', - name: itemName, - }, - }; - - this.handleSyncErrorWithIssue(error, syncItemType, { - errorLabel: 'remote', - issue: issues[syncItemType], - }); - } -} diff --git a/src/apps/main/remote-sync/RemoteSyncManager.test.ts b/src/apps/main/remote-sync/RemoteSyncManager.test.ts deleted file mode 100644 index 88e081daaf..0000000000 --- a/src/apps/main/remote-sync/RemoteSyncManager.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -vi.mock('@internxt/drive-desktop-core/build/backend'); -vi.mock('../../../infra/drive-server/client/drive-server.client.instance', () => ({ - driveServerClient: { - GET: vi.fn(), - }, -})); -vi.mock('./RemoteSyncErrorHandler/RemoteSyncErrorHandler', () => ({ - RemoteSyncErrorHandler: vi.fn().mockImplementation(() => ({ - handleSyncError: vi.fn(), - })), -})); -vi.mock('../../../infra/sqlite/services/file/create-or-update-file-by-batch', () => ({ - createOrUpdateFileByBatch: vi.fn().mockResolvedValue({ data: [] }), -})); -vi.mock('../../../infra/sqlite/services/folder/create-or-update-folder-by-batch', () => ({ - createOrUpdateFolderByBatch: vi.fn().mockResolvedValue({ data: [] }), -})); -import { RemoteSyncErrorHandler } from './RemoteSyncErrorHandler/RemoteSyncErrorHandler'; -import { RemoteSyncManager } from './RemoteSyncManager'; -import { RemoteSyncedFile, RemoteSyncedFolder } from './helpers'; -import * as uuid from 'uuid'; -import { DriveServerError } from '../../../infra/drive-server/drive-server.error'; -import { driveServerClient } from '../../../infra/drive-server/client/drive-server.client.instance'; -import { DatabaseCollectionAdapter } from '../database/adapters/base'; -import { DriveFile } from '../database/entities/DriveFile'; -import { DriveFolder } from '../database/entities/DriveFolder'; -import { createOrUpdateFileByBatch } from '../../../infra/sqlite/services/file/create-or-update-file-by-batch'; -import { createOrUpdateFolderByBatch } from '../../../infra/sqlite/services/folder/create-or-update-folder-by-batch'; - -const mockedGet = vi.mocked(driveServerClient.GET); -const mockedCreateOrUpdateFileByBatch = vi.mocked(createOrUpdateFileByBatch); -const mockedCreateOrUpdateFolderByBatch = vi.mocked(createOrUpdateFolderByBatch); - -const inMemorySyncedFilesCollection: DatabaseCollectionAdapter = { - get: vi.fn(), - connect: vi.fn(), - update: vi.fn(), - create: vi.fn(), - remove: vi.fn(), - getLastUpdated: vi.fn(), -}; - -const inMemorySyncedFoldersCollection: DatabaseCollectionAdapter = { - get: vi.fn(), - connect: vi.fn(), - update: vi.fn(), - create: vi.fn(), - remove: vi.fn(), - getLastUpdated: vi.fn(), -}; - -const createRemoteSyncedFileFixture = (payload: Partial): RemoteSyncedFile => { - const result: RemoteSyncedFile = { - status: 'EXISTS', - name: `name_${uuid.v4()}`, - plainName: `plainname_${Date.now()}`, - id: Date.now(), - uuid: uuid.v4(), - fileId: Date.now().toString(), - type: 'jpg', - size: 999, - bucket: `bucket_${Date.now()}`, - folderId: 555, - folderUuid: uuid.v4(), - userId: 567, - modificationTime: new Date().toISOString(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...payload, - }; - - return result; -}; - -const createRemoteSyncedFolderFixture = (payload: Partial): RemoteSyncedFolder => { - const result: RemoteSyncedFolder = { - name: `name_${uuid.v4()}`, - plainName: `folder_${Date.now()}`, - id: Date.now(), - type: 'folder', - bucket: `bucket_${Date.now()}`, - userId: 567, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - parentId: null, - uuid: uuid.v4(), - status: 'EXISTS', - ...payload, - }; - - return result; -}; - -describe('RemoteSyncManager', () => { - let errorHandler: RemoteSyncErrorHandler; - let sut: RemoteSyncManager; - inMemorySyncedFilesCollection.getLastUpdated = () => Promise.resolve({ success: false, result: null }); - inMemorySyncedFoldersCollection.getLastUpdated = () => Promise.resolve({ success: false, result: null }); - - beforeEach(() => { - errorHandler = new RemoteSyncErrorHandler(); - - sut = new RemoteSyncManager( - { - folders: inMemorySyncedFoldersCollection, - files: inMemorySyncedFilesCollection, - }, - { - fetchFilesLimitPerRequest: 2, - fetchFoldersLimitPerRequest: 2, - syncFiles: true, - syncFolders: true, - }, - errorHandler, - ); - mockedGet.mockClear(); - mockedCreateOrUpdateFileByBatch.mockClear(); - mockedCreateOrUpdateFolderByBatch.mockClear(); - }); - - describe('When there are files in remote, should sync them with local', () => { - it('Should sync all the files', async () => { - const sut = new RemoteSyncManager( - { - folders: inMemorySyncedFoldersCollection, - files: inMemorySyncedFilesCollection, - }, - { - fetchFilesLimitPerRequest: 2, - fetchFoldersLimitPerRequest: 2, - syncFiles: true, - syncFolders: false, - }, - errorHandler, - ); - - mockedGet - .mockResolvedValueOnce({ - data: [ - createRemoteSyncedFileFixture({ - plainName: 'file_1', - }), - createRemoteSyncedFileFixture({ - plainName: 'file_2', - }), - ], - }) - .mockResolvedValueOnce({ - data: [ - createRemoteSyncedFileFixture({ - plainName: 'file_3', - }), - ], - }); - - await sut.startRemoteSync(); - - expect(mockedGet).toBeCalledTimes(2); - expect(sut.getSyncStatus()).toBe('SYNCED'); - }); - it('Should sync all the folders', async () => { - const sut = new RemoteSyncManager( - { - folders: inMemorySyncedFoldersCollection, - files: inMemorySyncedFilesCollection, - }, - { - fetchFilesLimitPerRequest: 2, - fetchFoldersLimitPerRequest: 2, - syncFiles: false, - syncFolders: true, - }, - errorHandler, - ); - - mockedGet - .mockResolvedValueOnce({ - data: [ - createRemoteSyncedFolderFixture({ - plainName: 'folder_1', - }), - createRemoteSyncedFolderFixture({ - plainName: 'folder_2', - }), - ], - }) - .mockResolvedValueOnce({ - data: [ - createRemoteSyncedFolderFixture({ - plainName: 'folder_3', - }), - createRemoteSyncedFolderFixture({ - plainName: 'folder_4', - }), - ], - }) - .mockResolvedValueOnce({ - data: [ - createRemoteSyncedFolderFixture({ - plainName: 'folder_5', - }), - ], - }); - - await sut.startRemoteSync(); - - expect(mockedGet).toBeCalledTimes(3); - expect(sut.getSyncStatus()).toBe('SYNCED'); - }); - - it('Should save the files in the database', async () => { - const sut = new RemoteSyncManager( - { - folders: inMemorySyncedFoldersCollection, - files: inMemorySyncedFilesCollection, - }, - { - fetchFilesLimitPerRequest: 2, - fetchFoldersLimitPerRequest: 2, - syncFiles: true, - syncFolders: false, - }, - errorHandler, - ); - const file1 = createRemoteSyncedFileFixture({ - plainName: 'file_1', - }); - - const file2 = createRemoteSyncedFileFixture({ - plainName: 'file_2', - }); - - mockedGet.mockResolvedValueOnce({ data: [file1, file2] }); - - mockedGet.mockResolvedValueOnce({ data: [] }); - - await sut.startRemoteSync(); - - expect(mockedGet).toBeCalledTimes(2); - expect(sut.getSyncStatus()).toBe('SYNCED'); - expect(mockedCreateOrUpdateFileByBatch).toBeCalledWith({ files: [file1, file2] }); - }); - }); - - describe('When something fails during the sync', () => { - it('Should retry N times and then stop if sync does not succeed', async () => { - mockedGet.mockResolvedValue({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); - - await sut.startRemoteSync(); - - expect(mockedGet).toBeCalledTimes(6); - expect(sut.getSyncStatus()).toBe('SYNC_FAILED'); - }); - - it('Should fail the sync if some files or folders cannot be retrieved', async () => { - mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); - - await sut.startRemoteSync(); - - expect(mockedGet).toBeCalledTimes(6); - expect(sut.getSyncStatus()).toBe('SYNC_FAILED'); - }); - - it('should handle the error while syncing files by calling the error handler properly', async () => { - const sut = new RemoteSyncManager( - { - folders: inMemorySyncedFoldersCollection, - files: inMemorySyncedFilesCollection, - }, - { - fetchFilesLimitPerRequest: 2, - fetchFoldersLimitPerRequest: 2, - syncFiles: true, - syncFolders: false, - }, - errorHandler, - ); - mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); - const errorHandlerInstance = sut['errorHandler']; - const errorHandlerSpy = vi.spyOn(errorHandlerInstance, 'handleSyncError'); - - await sut.startRemoteSync(); - - expect(errorHandlerSpy).toHaveBeenCalled(); - expect(errorHandlerSpy.mock.calls[0][1]).toBe('files'); - }); - - it('should handle the error while syncing folders by calling the error handler properly', async () => { - const sut = new RemoteSyncManager( - { - folders: inMemorySyncedFoldersCollection, - files: inMemorySyncedFilesCollection, - }, - { - fetchFilesLimitPerRequest: 2, - fetchFoldersLimitPerRequest: 2, - syncFiles: false, - syncFolders: true, - }, - errorHandler, - ); - - mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); - const errorHandlerInstance = sut['errorHandler']; - const errorHandlerSpy = vi.spyOn(errorHandlerInstance, 'handleSyncError'); - - await sut.startRemoteSync(); - - expect(errorHandlerSpy).toHaveBeenCalled(); - expect(errorHandlerSpy.mock.calls[0][1]).toBe('folders'); - }); - }); -}); diff --git a/src/apps/main/remote-sync/RemoteSyncManager.ts b/src/apps/main/remote-sync/RemoteSyncManager.ts deleted file mode 100644 index a07a57aa1d..0000000000 --- a/src/apps/main/remote-sync/RemoteSyncManager.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { - RemoteSyncStatus, - RemoteSyncedFolder, - RemoteSyncedFile, - SyncConfig, - rewind, - SIX_HOURS_IN_MILLISECONDS, -} from './helpers'; -import { DatabaseCollectionAdapter } from '../database/adapters/base'; -import { DriveFolder } from '../database/entities/DriveFolder'; -import { DriveFile } from '../database/entities/DriveFile'; -import { Nullable } from '../../shared/types/Nullable'; -import { RemoteSyncError, RemoteSyncNetworkError } from './errors'; -import { RemoteSyncErrorHandler } from './RemoteSyncErrorHandler/RemoteSyncErrorHandler'; -import { createOrUpdateFolderByBatch } from '../../../infra/sqlite/services/folder/create-or-update-folder-by-batch'; -import { createOrUpdateFileByBatch } from '../../../infra/sqlite/services/file/create-or-update-file-by-batch'; -import { fetchFiles } from '../../../infra/drive-server/services/files/services/fetch-files'; -import { fetchFolders } from '../../../infra/drive-server/services/folder/services/fetch-folders'; - -export class RemoteSyncManager { - private foldersSyncStatus: RemoteSyncStatus = 'IDLE'; - private filesSyncStatus: RemoteSyncStatus = 'IDLE'; - private status: RemoteSyncStatus = 'IDLE'; - private onStatusChangeCallbacks: Array<(newStatus: RemoteSyncStatus) => void> = []; - private totalFilesSynced = 0; - private totalFoldersSynced = 0; - - constructor( - private db: { - files: DatabaseCollectionAdapter; - folders: DatabaseCollectionAdapter; - }, - private config: { - fetchFilesLimitPerRequest: number; - fetchFoldersLimitPerRequest: number; - syncFiles: boolean; - syncFolders: boolean; - }, - private errorHandler: RemoteSyncErrorHandler, - ) {} - - getTotalFilesSynced() { - return this.totalFilesSynced; - } - - onStatusChange(callback: (newStatus: RemoteSyncStatus) => void) { - if (typeof callback !== 'function') return; - this.onStatusChangeCallbacks.push(callback); - } - - getSyncStatus(): RemoteSyncStatus { - return this.status; - } - - /** - * Check if the RemoteSyncManager is in SYNCED status - * - * @returns True if local database is synced with remote files and folders - */ - localIsSynced() { - return this.status === 'SYNCED'; - } - - resetRemoteSync() { - this.changeStatus('IDLE'); - this.filesSyncStatus = 'IDLE'; - this.foldersSyncStatus = 'IDLE'; - this.totalFilesSynced = 0; - this.totalFoldersSynced = 0; - } - - /** - * Triggers a remote sync so we can populate the localDB, this sync - * is global and starts pulling all the files the user has in remote. - * - * Throws an error if there's a sync in progress for this class instance - */ - async startRemoteSync() { - const testPassed = this.smokeTest(); - - if (!testPassed) { - return; - } - this.totalFilesSynced = 0; - this.totalFoldersSynced = 0; - this.filesSyncStatus = 'IDLE'; - this.foldersSyncStatus = 'IDLE'; - - await this.db.files.connect(); - await this.db.folders.connect(); - - logger.debug({ tag: 'SYNC-ENGINE', msg: 'Starting' }); - this.changeStatus('SYNCING'); - try { - await Promise.all([ - this.config.syncFiles - ? this.syncRemoteFiles({ - retry: 1, - maxRetries: 3, - }) - : Promise.resolve(), - this.config.syncFolders - ? this.syncRemoteFolders({ - retry: 1, - maxRetries: 3, - }) - : Promise.resolve(), - ]); - } catch (error) { - this.changeStatus('SYNC_FAILED'); - logger.error({ - tag: 'SYNC-ENGINE', - msg: 'Remote sync failed with uncontrolled error: ', - error, - }); - } finally { - logger.debug({ - tag: 'SYNC-ENGINE', - msg: `Total synced files: ${this.totalFilesSynced}`, - }); - logger.debug({ - tag: 'SYNC-ENGINE', - msg: `Total synced folders: ${this.totalFoldersSynced}`, - }); - } - } - - /** - * Run smoke tests before starting the RemoteSyncManager, otherwise fail - */ - private smokeTest() { - if (this.status === 'SYNCING') { - logger.warn({ - tag: 'SYNC-ENGINE', - msg: 'RemoteSyncManager should not be in SYNCING status to start, not starting again', - }); - - return false; - } - - return true; - } - - private changeStatus(newStatus: RemoteSyncStatus) { - if (newStatus === this.status) return; - logger.debug({ - tag: 'SYNC-ENGINE', - msg: `RemoteSyncManager ${this.status} -> ${newStatus}`, - }); - this.status = newStatus; - this.onStatusChangeCallbacks.forEach((callback) => { - if (typeof callback !== 'function') return; - callback(newStatus); - }); - } - - private checkRemoteSyncStatus() { - // We only syncing files - if (this.config.syncFiles && !this.config.syncFolders && this.filesSyncStatus === 'SYNCED') { - this.changeStatus('SYNCED'); - return; - } - - // We only syncing folders - if (!this.config.syncFiles && this.config.syncFolders && this.foldersSyncStatus === 'SYNCED') { - this.changeStatus('SYNCED'); - return; - } - // Files and folders are synced, RemoteSync is Synced - if (this.foldersSyncStatus === 'SYNCED' && this.filesSyncStatus === 'SYNCED') { - this.changeStatus('SYNCED'); - return; - } - - // Files OR Folders sync failed, RemoteSync Failed - if (this.foldersSyncStatus === 'SYNC_FAILED' || this.filesSyncStatus === 'SYNC_FAILED') { - this.changeStatus('SYNC_FAILED'); - return; - } - } - - private async getFileCheckpoint(): Promise> { - const { success, result } = await this.db.files.getLastUpdated(); - - if (!success) return undefined; - - if (!result) return undefined; - - const updatedAt = new Date(result.updatedAt); - - return rewind(updatedAt, SIX_HOURS_IN_MILLISECONDS); - } - - /** - * Syncs all the remote files and saves them into the local db - * @param syncConfig Config to execute the sync with - * @returns - */ - private async syncRemoteFiles(syncConfig: SyncConfig, from?: Date) { - let fileCheckPoint = from ?? (await this.getFileCheckpoint()); - let hasMore = true; - let retryCount = 0; - - while (hasMore && retryCount < syncConfig.maxRetries) { - let lastFileSynced = null; - - try { - const { hasMore: moreAvailable, result } = await this.fetchFilesFromRemote(fileCheckPoint); - - await createOrUpdateFileByBatch({ files: result }); - this.totalFilesSynced += result.length; - lastFileSynced = result.length > 0 ? result[result.length - 1] : null; - - hasMore = moreAvailable; - - if (hasMore && lastFileSynced) { - fileCheckPoint = new Date(lastFileSynced.updatedAt); - } - - retryCount = 0; - } catch (error) { - retryCount++; - - if (error instanceof RemoteSyncError) { - this.errorHandler.handleSyncError(error, 'files', lastFileSynced?.name ?? 'unknown', fileCheckPoint); - } else { - logger.error({ - tag: 'SYNC-ENGINE', - msg: 'Remote files sync failed with uncontrolled error: ', - error, - }); - } - - if (retryCount >= syncConfig.maxRetries) { - this.filesSyncStatus = 'SYNC_FAILED'; - this.checkRemoteSyncStatus(); - return; - } - - await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount)); - } - } - - logger.debug({ - tag: 'SYNC-ENGINE', - msg: 'Remote files sync finished', - }); - this.filesSyncStatus = 'SYNCED'; - this.checkRemoteSyncStatus(); - } - - private async getLastFolderSyncAt(): Promise> { - const { success, result } = await this.db.folders.getLastUpdated(); - - if (!success) return undefined; - - if (!result) return undefined; - - const updatedAt = new Date(result.updatedAt); - return rewind(updatedAt, SIX_HOURS_IN_MILLISECONDS); - } - - /** - * Syncs all the remote folders and saves them into the local db - * @param syncConfig Config to execute the sync with - * @returns - */ - private async syncRemoteFolders(syncConfig: SyncConfig, from?: Date) { - let folderCheckPoint = from ?? (await this.getLastFolderSyncAt()); - let hasMore = true; - let retryCount = 0; - - while (hasMore && retryCount < syncConfig.maxRetries) { - let lastFolderSynced = null; - - try { - const { hasMore: moreAvailable, result } = await this.fetchFoldersFromRemote(folderCheckPoint); - - await createOrUpdateFolderByBatch({ folders: result }); - this.totalFoldersSynced += result.length; - lastFolderSynced = result.length > 0 ? result[result.length - 1] : null; - - hasMore = moreAvailable; - - if (hasMore && lastFolderSynced) { - folderCheckPoint = new Date(lastFolderSynced.updatedAt); - } - - // Reset retry count on successful fetch - retryCount = 0; - } catch (error) { - retryCount++; - - if (error instanceof RemoteSyncError) { - this.errorHandler.handleSyncError(error, 'folders', lastFolderSynced?.name ?? 'unknown', folderCheckPoint); - } else { - logger.error({ - tag: 'SYNC-ENGINE', - msg: 'Remote folders sync failed with uncontrolled error: ', - error, - }); - } - - if (retryCount >= syncConfig.maxRetries) { - this.foldersSyncStatus = 'SYNC_FAILED'; - this.checkRemoteSyncStatus(); - return; - } - - // Brief delay before retry to avoid hammering the server - await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount)); - } - } - - logger.debug({ - tag: 'SYNC-ENGINE', - msg: 'Remote folders sync finished', - }); - this.foldersSyncStatus = 'SYNCED'; - this.checkRemoteSyncStatus(); - } - - /** - * Fetch the files that were updated after the given date - * - * @param updatedAtCheckpoint Retrieve files that were updated after this date - */ - private async fetchFilesFromRemote(updatedAtCheckpoint?: Date): Promise<{ - hasMore: boolean; - result: RemoteSyncedFile[]; - }> { - const { data, error } = await fetchFiles({ - limit: this.config.fetchFilesLimitPerRequest, - offset: 0, - status: 'ALL', - updatedAt: updatedAtCheckpoint?.toISOString(), - }); - - if (error) { - throw new RemoteSyncNetworkError(error.message, undefined, error.statusCode); - } - - return { - hasMore: data.hasMore, - result: data.files.map(this.patchDriveFileResponseItem), - }; - } - - /** - * Fetch the folders that were updated after the given date - * - * @param updatedAtCheckpoint Retrieve folders that were updated after this date - */ - private async fetchFoldersFromRemote(updatedAtCheckpoint?: Date): Promise<{ - hasMore: boolean; - result: RemoteSyncedFolder[]; - }> { - const { data, error } = await fetchFolders({ - limit: this.config.fetchFilesLimitPerRequest, - offset: 0, - status: 'ALL', - updatedAt: updatedAtCheckpoint?.toISOString(), - }); - - if (error) { - throw new RemoteSyncNetworkError(error.message, undefined, error.statusCode); - } - - return { - hasMore: data.hasMore, - result: data.folders.map(this.patchDriveFolderResponseItem), - }; - } - - private patchDriveFolderResponseItem = (payload: Record): RemoteSyncedFolder => { - const status = this.resolveFolderStatus(payload); - - return { - ...(payload as Omit), - status, - name: typeof payload.name === 'string' ? payload.name : undefined, - }; - }; - - private resolveFolderStatus(payload: Record): RemoteSyncedFolder['status'] { - if (typeof payload.status === 'string' && payload.status) return payload.status; - if (payload.removed) return 'REMOVED'; - if (payload.deleted) return 'DELETED'; - return 'EXISTS'; - } - - private readonly patchDriveFileResponseItem = (payload: Record): RemoteSyncedFile => { - return { - ...(payload as Omit), - fileId: typeof payload.fileId === 'string' ? payload.fileId : '', - size: typeof payload.size === 'string' ? Number.parseInt(payload.size) : (payload.size as number), - name: typeof payload.name === 'string' ? payload.name : undefined, - }; - }; -} diff --git a/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts b/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts new file mode 100644 index 0000000000..69b47d2a7c --- /dev/null +++ b/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts @@ -0,0 +1,102 @@ +vi.mock('@internxt/drive-desktop-core/build/backend', () => ({ + logger: { + error: vi.fn(), + }, +})); + +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import * as virtualDriveIssuesModule from '../issues/virtual-drive'; +import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper'; +import { createRemoteSyncErrorHandler } from './create-remote-sync-error-handler'; +import { + RemoteSyncError, + RemoteSyncInvalidResponseError, + RemoteSyncNetworkError, + RemoteSyncServerError, +} from './errors'; + +describe('create-remote-sync-error-handler.test', () => { + const addVirtualDriveIssueMock = partialSpyOn(virtualDriveIssuesModule, 'addVirtualDriveIssue'); + + beforeEach(() => { + addVirtualDriveIssueMock.mockReset(); + vi.mocked(logger.error).mockReset(); + }); + + it('should add a no-internet issue for file network errors', () => { + // Given + const sut = createRemoteSyncErrorHandler(); + + // When + sut.handleSyncError({ + error: new RemoteSyncNetworkError('connection lost'), + syncItemType: 'files', + itemName: 'Test File', + }); + + // Then + call(addVirtualDriveIssueMock).toStrictEqual({ + error: 'DOWNLOAD_ERROR', + cause: 'NO_INTERNET', + name: 'Test File', + }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('should add a remote-connection issue for folder server errors', () => { + // Given + const sut = createRemoteSyncErrorHandler(); + + // When + sut.handleSyncError({ + error: new RemoteSyncServerError(500, { message: 'Server error occurred' }), + syncItemType: 'folders', + itemName: 'Test Folder', + itemCheckpoint: new Date('2025-02-24T00:00:00.000Z'), + }); + + // Then + call(addVirtualDriveIssueMock).toStrictEqual({ + error: 'FOLDER_CREATE_ERROR', + cause: 'NO_REMOTE_CONNECTION', + name: 'Test Folder', + }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('should ignore invalid response errors', () => { + // Given + const sut = createRemoteSyncErrorHandler(); + + // When + sut.handleSyncError({ + error: new RemoteSyncInvalidResponseError({ invalid: true }), + syncItemType: 'files', + itemName: 'Test File', + }); + + // Then + expect(addVirtualDriveIssueMock).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should treat generic remote sync errors as remote connection issues', () => { + // Given + const sut = createRemoteSyncErrorHandler(); + + // When + sut.handleSyncError({ + error: new RemoteSyncError('generic failure'), + syncItemType: 'files', + itemName: 'Another File', + }); + + // Then + call(addVirtualDriveIssueMock).toStrictEqual({ + error: 'DOWNLOAD_ERROR', + cause: 'NO_REMOTE_CONNECTION', + name: 'Another File', + }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/create-remote-sync-error-handler.ts b/src/apps/main/remote-sync/create-remote-sync-error-handler.ts new file mode 100644 index 0000000000..ad94bf85f5 --- /dev/null +++ b/src/apps/main/remote-sync/create-remote-sync-error-handler.ts @@ -0,0 +1,44 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { addVirtualDriveIssue } from '../issues/virtual-drive'; +import { RemoteSyncError } from './errors'; +import { getRemoteSyncErrorDetail, RemoteSyncItemType } from './get-remote-sync-error-detail'; + +export type HandleSyncErrorPops = { + error: RemoteSyncError; + syncItemType: RemoteSyncItemType; + itemName: string; + itemCheckpoint?: Date; +}; + +export type RemoteSyncErrorHandler = { + handleSyncError: (props: HandleSyncErrorPops) => void; +}; + +export function createRemoteSyncErrorHandler(): RemoteSyncErrorHandler { + function handleSyncError({ error, syncItemType, itemName, itemCheckpoint }: HandleSyncErrorPops) { + const errorDetail = getRemoteSyncErrorDetail({ + error, + syncItemType, + itemName, + }); + + if (!errorDetail) { + return; + } + + logger.error({ + tag: 'SYNC-ENGINE', + msg: `Remote ${syncItemType} sync failed`, + error, + errorLabel: errorDetail.errorLabel, + itemName, + itemCheckpoint, + }); + + addVirtualDriveIssue(errorDetail.issue); + } + + return { + handleSyncError, + }; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/fetch-remote-files.ts b/src/apps/main/remote-sync/fetch-remote-files.ts new file mode 100644 index 0000000000..4140f5b901 --- /dev/null +++ b/src/apps/main/remote-sync/fetch-remote-files.ts @@ -0,0 +1,30 @@ +import { RemoteSyncNetworkError } from './errors'; +import { RemoteSyncedFile } from './helpers'; +import { patchDriveFileResponseItem } from './patch-drive-file-response-item'; +import { fetchFiles } from '../../../infra/drive-server/services/files/services/fetch-files'; + +type Pops = { + limit: number; + updatedAtCheckpoint?: Date; +}; + +export async function fetchRemoteFiles({ limit, updatedAtCheckpoint }: Pops): Promise<{ + hasMore: boolean; + result: RemoteSyncedFile[]; +}> { + const { data, error } = await fetchFiles({ + limit, + offset: 0, + status: 'ALL', + updatedAt: updatedAtCheckpoint?.toISOString(), + }); + + if (error) { + throw new RemoteSyncNetworkError(error.message, undefined, error.statusCode); + } + + return { + hasMore: data.hasMore, + result: data.files.map(patchDriveFileResponseItem), + }; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/fetch-remote-folders.ts b/src/apps/main/remote-sync/fetch-remote-folders.ts new file mode 100644 index 0000000000..ad3033c97b --- /dev/null +++ b/src/apps/main/remote-sync/fetch-remote-folders.ts @@ -0,0 +1,30 @@ +import { RemoteSyncNetworkError } from './errors'; +import { RemoteSyncedFolder } from './helpers'; +import { patchDriveFolderResponseItem } from './patch-drive-folder-response-item'; +import { fetchFolders } from '../../../infra/drive-server/services/folder/services/fetch-folders'; + +type Pops = { + limit: number; + updatedAtCheckpoint?: Date; +}; + +export async function fetchRemoteFolders({ limit, updatedAtCheckpoint }: Pops): Promise<{ + hasMore: boolean; + result: RemoteSyncedFolder[]; +}> { + const { data, error } = await fetchFolders({ + limit, + offset: 0, + status: 'ALL', + updatedAt: updatedAtCheckpoint?.toISOString(), + }); + + if (error) { + throw new RemoteSyncNetworkError(error.message, undefined, error.statusCode); + } + + return { + hasMore: data.hasMore, + result: data.folders.map(patchDriveFolderResponseItem), + }; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/get-last-updated-checkpoint.ts b/src/apps/main/remote-sync/get-last-updated-checkpoint.ts new file mode 100644 index 0000000000..9bf2a54d08 --- /dev/null +++ b/src/apps/main/remote-sync/get-last-updated-checkpoint.ts @@ -0,0 +1,27 @@ +import { rewind } from './helpers'; +import { DatabaseCollectionAdapter } from '../database/adapters/base'; +import { Nullable } from '../../shared/types/Nullable'; + +type DatabaseItemWithUpdatedAt = { + updatedAt: string; +}; + +type Pops = { + collection: DatabaseCollectionAdapter; + rewindMilliseconds: number; +}; + +export async function getLastUpdatedCheckpoint({ + collection, + rewindMilliseconds, +}: Pops): Promise> { + const { success, result } = await collection.getLastUpdated(); + + if (!success || !result) { + return undefined; + } + + const updatedAt = new Date(result.updatedAt); + + return rewind(updatedAt, rewindMilliseconds); +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/get-remote-sync-error-detail.ts b/src/apps/main/remote-sync/get-remote-sync-error-detail.ts new file mode 100644 index 0000000000..f67522238d --- /dev/null +++ b/src/apps/main/remote-sync/get-remote-sync-error-detail.ts @@ -0,0 +1,81 @@ +import { + RemoteSyncError, + RemoteSyncInvalidResponseError, + RemoteSyncNetworkError, + RemoteSyncServerError, +} from './errors'; +import { VirtualDriveIssue } from '../../../shared/issues/VirtualDriveIssue'; + +export type RemoteSyncItemType = 'files' | 'folders'; + +export type RemoteSyncErrorDetail = { + errorLabel: 'network' | 'server' | 'remote'; + issue: VirtualDriveIssue; +}; + +type GetRemoteSyncErrorDetailPops = { + error: RemoteSyncError; + syncItemType: RemoteSyncItemType; + itemName: string; +}; + +function createVirtualDriveIssue({ + syncItemType, + itemName, + cause, +}: { + syncItemType: RemoteSyncItemType; + itemName: string; + cause: VirtualDriveIssue['cause']; +}): VirtualDriveIssue { + if (syncItemType === 'files') { + return { + error: 'DOWNLOAD_ERROR', + cause, + name: itemName, + }; + } + + return { + error: 'FOLDER_CREATE_ERROR', + cause, + name: itemName, + }; +} + +export function getRemoteSyncErrorDetail({ error, syncItemType, itemName }: GetRemoteSyncErrorDetailPops) { + if (error instanceof RemoteSyncInvalidResponseError) { + return null; + } + + if (error instanceof RemoteSyncNetworkError) { + return { + errorLabel: 'network', + issue: createVirtualDriveIssue({ + syncItemType, + itemName, + cause: 'NO_INTERNET', + }), + } satisfies RemoteSyncErrorDetail; + } + + if (error instanceof RemoteSyncServerError) { + return { + errorLabel: 'server', + issue: createVirtualDriveIssue({ + syncItemType, + itemName, + cause: 'NO_REMOTE_CONNECTION', + }), + } satisfies RemoteSyncErrorDetail; + } + + return { + errorLabel: 'remote', + issue: createVirtualDriveIssue({ + syncItemType, + itemName, + cause: 'NO_REMOTE_CONNECTION', + }), + } satisfies RemoteSyncErrorDetail; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/handlers.test.ts b/src/apps/main/remote-sync/handlers.test.ts new file mode 100644 index 0000000000..0bec4f6148 --- /dev/null +++ b/src/apps/main/remote-sync/handlers.test.ts @@ -0,0 +1,152 @@ +import { ipcMain } from 'electron'; +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { vi } from 'vitest'; +import { getIpcHandler } from '../../../../tests/vitest/ipc.helper'; +import { call, calls, partialSpyOn } from '../../../../tests/vitest/utils.helper'; + +const mocks = vi.hoisted(() => { + return { + remoteSyncController: { + getSyncStatus: vi.fn(() => 'IDLE'), + resetRemoteSync: vi.fn(), + startRemoteSync: vi.fn(), + }, + resyncRemoteSync: vi.fn(), + startRemoteSync: vi.fn(), + }; +}); + +vi.mock('./service', () => ({ + remoteSyncController: mocks.remoteSyncController, + resyncRemoteSync: mocks.resyncRemoteSync, + startRemoteSync: mocks.startRemoteSync, +})); + +async function loadHandlersModule() { + vi.resetModules(); + + const eventBusModule = await import('../event-bus'); + const initialSyncReadyModule = await import('./InitialSyncReady'); + + const eventBusOnMock = partialSpyOn(eventBusModule.default, 'on', false); + const eventBusEmitMock = partialSpyOn(eventBusModule.default, 'emit'); + const setInitialSyncStateMock = partialSpyOn(initialSyncReadyModule, 'setInitialSyncState'); + + await import('./handlers'); + + return { + eventBusEmitMock, + eventBusOnMock, + setInitialSyncStateMock, + }; +} + +function getEventBusHandler(eventBusOnMock: ReturnType, eventName: string) { + const calls = eventBusOnMock.mock.calls as Array<[string, (...args: unknown[]) => unknown]>; + + return calls.find(([name]) => name === eventName)?.[1] as + | ((...args: unknown[]) => unknown) + | undefined; +} + +describe('handlers.test', () => { + beforeEach(() => { + mocks.remoteSyncController.getSyncStatus.mockReturnValue('IDLE'); + mocks.startRemoteSync.mockResolvedValue(undefined); + mocks.remoteSyncController.startRemoteSync.mockResolvedValue(undefined); + mocks.resyncRemoteSync.mockResolvedValue(undefined); + }); + + it('should register ipc handlers and event listeners', async () => { + // When + const { eventBusOnMock } = await loadHandlersModule(); + + // Then + expect(ipcMain.handle).toHaveBeenCalledWith('START_REMOTE_SYNC', expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith('get-remote-sync-status', expect.any(Function)); + expect(eventBusOnMock).toHaveBeenCalledWith('RECEIVED_REMOTE_CHANGES', expect.any(Function)); + expect(eventBusOnMock).toHaveBeenCalledWith('APP_DATA_SOURCE_INITIALIZED', expect.any(Function)); + expect(eventBusOnMock).toHaveBeenCalledWith('USER_LOGGED_OUT', expect.any(Function)); + }); + + it('should start a sync from the ipc handler and emit remote changes synched', async () => { + // Given + const { eventBusEmitMock } = await loadHandlersModule(); + const handler = getIpcHandler('START_REMOTE_SYNC'); + + // When + await handler?.(); + + // Then + calls(mocks.startRemoteSync).toHaveLength(1); + call(eventBusEmitMock).toStrictEqual('REMOTE_CHANGES_SYNCHED'); + }); + + it('should return the current sync status from the ipc handler', async () => { + // Given + mocks.remoteSyncController.getSyncStatus.mockReturnValue('SYNCED'); + await loadHandlersModule(); + const handler = getIpcHandler('get-remote-sync-status'); + + // When + const result = await handler?.(); + + // Then + expect(result).toBe('SYNCED'); + }); + + it('should resync when remote changes are received', async () => { + // Given + const { eventBusOnMock } = await loadHandlersModule(); + const listener = getEventBusHandler(eventBusOnMock, 'RECEIVED_REMOTE_CHANGES'); + + // When + await listener?.(); + + // Then + calls(mocks.resyncRemoteSync).toHaveLength(1); + }); + + it('should start the sync when the data source is initialized', async () => { + // Given + const { eventBusOnMock } = await loadHandlersModule(); + const listener = getEventBusHandler(eventBusOnMock, 'APP_DATA_SOURCE_INITIALIZED'); + + // When + await listener?.(); + + // Then + calls(mocks.startRemoteSync).toHaveLength(1); + }); + + it('should log an error when sync startup fails during app data source initialization', async () => { + // Given + const error = new Error('sync failed'); + mocks.startRemoteSync.mockRejectedValue(error); + const { eventBusOnMock } = await loadHandlersModule(); + const listener = getEventBusHandler(eventBusOnMock, 'APP_DATA_SOURCE_INITIALIZED'); + + // When + await listener?.(); + + // Then + expect(logger.error).toHaveBeenCalledWith({ + tag: 'SYNC-ENGINE', + msg: 'Error starting remote sync controller', + error, + }); + }); + + it('should reset initial sync state and remote sync state on logout', async () => { + // Given + const { eventBusOnMock, setInitialSyncStateMock } = await loadHandlersModule(); + const listener = getEventBusHandler(eventBusOnMock, 'USER_LOGGED_OUT'); + + // When + listener?.(); + + // Then + call(setInitialSyncStateMock).toBe('NOT_READY'); + calls(mocks.remoteSyncController.resetRemoteSync).toHaveLength(1); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/handlers.ts b/src/apps/main/remote-sync/handlers.ts index 11136f773f..4d085da3bf 100644 --- a/src/apps/main/remote-sync/handlers.ts +++ b/src/apps/main/remote-sync/handlers.ts @@ -2,14 +2,14 @@ import { ipcMain } from 'electron'; import { logger } from '@internxt/drive-desktop-core/build/backend'; import eventBus from '../event-bus'; import { setInitialSyncState } from './InitialSyncReady'; -import { remoteSyncManager, resyncRemoteSync, startRemoteSync } from './service'; +import { remoteSyncController, resyncRemoteSync, startRemoteSync } from './service'; ipcMain.handle('START_REMOTE_SYNC', async () => { await startRemoteSync(); eventBus.emit('REMOTE_CHANGES_SYNCHED'); }); -ipcMain.handle('get-remote-sync-status', () => remoteSyncManager.getSyncStatus()); +ipcMain.handle('get-remote-sync-status', () => remoteSyncController.getSyncStatus()); eventBus.on('RECEIVED_REMOTE_CHANGES', async () => { // Wait before checking for updates, could be possible @@ -19,10 +19,10 @@ eventBus.on('RECEIVED_REMOTE_CHANGES', async () => { }); eventBus.on('APP_DATA_SOURCE_INITIALIZED', async () => { - await remoteSyncManager.startRemoteSync().catch((error) => { + await startRemoteSync().catch((error) => { logger.error({ tag: 'SYNC-ENGINE', - msg: 'Error starting remote sync manager', + msg: 'Error starting remote sync controller', error, }); }); @@ -30,5 +30,5 @@ eventBus.on('APP_DATA_SOURCE_INITIALIZED', async () => { eventBus.on('USER_LOGGED_OUT', () => { setInitialSyncState('NOT_READY'); - remoteSyncManager.resetRemoteSync(); + remoteSyncController.resetRemoteSync(); }); diff --git a/src/apps/main/remote-sync/patch-drive-file-response-item.ts b/src/apps/main/remote-sync/patch-drive-file-response-item.ts new file mode 100644 index 0000000000..0d4d86b03d --- /dev/null +++ b/src/apps/main/remote-sync/patch-drive-file-response-item.ts @@ -0,0 +1,15 @@ +import { RemoteSyncedFile } from './helpers'; + +type DriveFileResponseItem = Partial & { + fileId?: string | null; + size?: number | string; +}; + +export function patchDriveFileResponseItem(payload: DriveFileResponseItem) { + return { + ...payload, + fileId: payload.fileId ?? '', + size: typeof payload.size === 'string' ? parseInt(payload.size) : payload.size, + name: payload.name ?? undefined, + } as RemoteSyncedFile; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/patch-drive-folder-response-item.ts b/src/apps/main/remote-sync/patch-drive-folder-response-item.ts new file mode 100644 index 0000000000..683b15c76c --- /dev/null +++ b/src/apps/main/remote-sync/patch-drive-folder-response-item.ts @@ -0,0 +1,28 @@ +import { RemoteSyncedFolder } from './helpers'; + +type DriveFolderResponseItem = Partial & { + removed?: boolean; + deleted?: boolean; +}; + +export function patchDriveFolderResponseItem(payload: DriveFolderResponseItem) { + let status: RemoteSyncedFolder['status'] = payload.status ?? ''; + + if (!status && !payload.removed) { + status = 'EXISTS'; + } + + if (!status && payload.removed) { + status = 'REMOVED'; + } + + if (!status && payload.deleted) { + status = 'DELETED'; + } + + return { + ...payload, + status, + name: payload.name ?? undefined, + } as RemoteSyncedFolder; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts b/src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts new file mode 100644 index 0000000000..17ea8198a5 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts @@ -0,0 +1,33 @@ +vi.mock('@internxt/drive-desktop-core/build/backend'); + +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { createControllerState } from './create-controller-state'; +import { changeStatus } from './change-status'; + +describe('change-status.test', () => { + it('should update the status and notify callbacks', () => { + // Given + const state = createControllerState(); + const callback = vi.fn(); + state.onStatusChangeCallbacks.push(callback); + + // When + changeStatus({ state, newStatus: 'SYNCING' }); + + // Then + expect(state.status).toBe('SYNCING'); + expect(callback).toHaveBeenCalledWith('SYNCING'); + expect(logger.debug).toHaveBeenCalledTimes(1); + }); + + it('should do nothing when the status is unchanged', () => { + // Given + const state = createControllerState(); + + // When + changeStatus({ state, newStatus: 'IDLE' }); + + // Then + expect(logger.debug).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/change-status.ts b/src/apps/main/remote-sync/remote-sync-controller/change-status.ts new file mode 100644 index 0000000000..08209ad5d2 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/change-status.ts @@ -0,0 +1,28 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { RemoteSyncStatus } from '../helpers'; +import { RemoteSyncControllerState } from './types'; + +type ChangeStatusPops = { + state: RemoteSyncControllerState; + newStatus: RemoteSyncStatus; +}; + +export function changeStatus({ state, newStatus }: ChangeStatusPops) { + if (newStatus === state.status) { + return; + } + + logger.debug({ + tag: 'SYNC-ENGINE', + msg: `Remote sync controller ${state.status} -> ${newStatus}`, + }); + + state.status = newStatus; + state.onStatusChangeCallbacks.forEach((callback) => { + if (typeof callback !== 'function') { + return; + } + + callback(newStatus); + }); +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts b/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts new file mode 100644 index 0000000000..fe5f611bb1 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts @@ -0,0 +1,47 @@ +vi.mock('@internxt/drive-desktop-core/build/backend'); + +import { createControllerState } from './create-controller-state'; +import { checkRemoteSyncStatus } from './check-remote-sync-status'; + +describe('check-remote-sync-status.test', () => { + it('should move the controller to synced when file and folder sync are synced', () => { + // Given + const state = createControllerState(); + state.filesSyncStatus = 'SYNCED'; + state.foldersSyncStatus = 'SYNCED'; + + // When + checkRemoteSyncStatus({ + state, + config: { + fetchFilesLimitPerRequest: 1, + fetchFoldersLimitPerRequest: 1, + syncFiles: true, + syncFolders: true, + }, + }); + + // Then + expect(state.status).toBe('SYNCED'); + }); + + it('should keep the current state when there is no aggregated status yet', () => { + // Given + const state = createControllerState(); + state.status = 'SYNCING'; + + // When + checkRemoteSyncStatus({ + state, + config: { + fetchFilesLimitPerRequest: 1, + fetchFoldersLimitPerRequest: 1, + syncFiles: true, + syncFolders: true, + }, + }); + + // Then + expect(state.status).toBe('SYNCING'); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.ts b/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.ts new file mode 100644 index 0000000000..5c5adc631b --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.ts @@ -0,0 +1,26 @@ +import { resolveRemoteSyncStatus } from '../resolve-remote-sync-status'; +import { changeStatus } from './change-status'; +import { RemoteSyncControllerConfig, RemoteSyncControllerState } from './types'; + +type CheckRemoteSyncStatusPops = { + state: RemoteSyncControllerState; + config: RemoteSyncControllerConfig; +}; + +export function checkRemoteSyncStatus({ state, config }: CheckRemoteSyncStatusPops) { + const nextStatus = resolveRemoteSyncStatus({ + filesSyncStatus: state.filesSyncStatus, + foldersSyncStatus: state.foldersSyncStatus, + syncFiles: config.syncFiles, + syncFolders: config.syncFolders, + }); + + if (!nextStatus) { + return; + } + + changeStatus({ + state, + newStatus: nextStatus, + }); +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts b/src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts new file mode 100644 index 0000000000..49cffc74f7 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts @@ -0,0 +1,38 @@ +import { createControllerState } from './create-controller-state'; +import { getSyncStatus, getTotalFilesSynced, onStatusChange } from './controller-methods'; + +describe('controller-methods.test', () => { + it('should return the status and total files from state', () => { + // Given + const state = createControllerState(); + state.status = 'SYNCED'; + state.totalFilesSynced = 12; + + // Then + expect(getSyncStatus({ state })).toBe('SYNCED'); + expect(getTotalFilesSynced({ state })).toBe(12); + }); + + it('should register status change callbacks', () => { + // Given + const state = createControllerState(); + const callback = vi.fn(); + + // When + onStatusChange({ state, callback }); + + // Then + expect(state.onStatusChangeCallbacks).toStrictEqual([callback]); + }); + + it('should ignore invalid callbacks', () => { + // Given + const state = createControllerState(); + + // When + onStatusChange({ state, callback: undefined as never }); + + // Then + expect(state.onStatusChangeCallbacks).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts b/src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts new file mode 100644 index 0000000000..fe6fe0a08e --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts @@ -0,0 +1,26 @@ +import { RemoteSyncStatus } from '../helpers'; +import { RemoteSyncControllerState } from './types'; + +type ControllerStatePops = { + state: RemoteSyncControllerState; +}; + +type OnStatusChangePops = ControllerStatePops & { + callback: (newStatus: RemoteSyncStatus) => void; +}; + +export function getTotalFilesSynced({ state }: ControllerStatePops) { + return state.totalFilesSynced; +} + +export function onStatusChange({ state, callback }: OnStatusChangePops) { + if (typeof callback !== 'function') { + return; + } + + state.onStatusChangeCallbacks.push(callback); +} + +export function getSyncStatus({ state }: ControllerStatePops) { + return state.status; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts b/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts new file mode 100644 index 0000000000..953914159e --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts @@ -0,0 +1,18 @@ +import { createControllerState } from './create-controller-state'; + +describe('create-controller-state.test', () => { + it('should create the initial controller state', () => { + // When + const result = createControllerState(); + + // Then + expect(result).toStrictEqual({ + foldersSyncStatus: 'IDLE', + filesSyncStatus: 'IDLE', + status: 'IDLE', + totalFilesSynced: 0, + totalFoldersSynced: 0, + onStatusChangeCallbacks: [], + }); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.ts b/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.ts new file mode 100644 index 0000000000..29705fb353 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.ts @@ -0,0 +1,12 @@ +import { RemoteSyncControllerState } from './types'; + +export function createControllerState(): RemoteSyncControllerState { + return { + foldersSyncStatus: 'IDLE', + filesSyncStatus: 'IDLE', + status: 'IDLE', + totalFilesSynced: 0, + totalFoldersSynced: 0, + onStatusChangeCallbacks: [], + }; +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/index.test.ts b/src/apps/main/remote-sync/remote-sync-controller/index.test.ts new file mode 100644 index 0000000000..b71f08dc30 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/index.test.ts @@ -0,0 +1,312 @@ +vi.mock('@internxt/drive-desktop-core/build/backend'); +vi.mock('../../../../infra/drive-server/client/drive-server.client.instance', () => ({ + driveServerClient: { + GET: vi.fn(), + }, +})); +vi.mock('../../../../infra/sqlite/services/file/create-or-update-file-by-batch', () => ({ + createOrUpdateFileByBatch: vi.fn().mockResolvedValue({ data: [] }), +})); +vi.mock('../../../../infra/sqlite/services/folder/create-or-update-folder-by-batch', () => ({ + createOrUpdateFolderByBatch: vi.fn().mockResolvedValue({ data: [] }), +})); + +import * as uuid from 'uuid'; +import { createRemoteSyncController, CreateRemoteSyncControllerPops, RemoteSyncController } from './index'; +import { RemoteSyncErrorHandler } from '../create-remote-sync-error-handler'; +import { RemoteSyncedFile, RemoteSyncedFolder } from '../helpers'; +import { DriveServerError } from '../../../../infra/drive-server/drive-server.error'; +import { driveServerClient } from '../../../../infra/drive-server/client/drive-server.client.instance'; +import { DatabaseCollectionAdapter } from '../../database/adapters/base'; +import { DriveFile } from '../../database/entities/DriveFile'; +import { DriveFolder } from '../../database/entities/DriveFolder'; +import { createOrUpdateFileByBatch } from '../../../../infra/sqlite/services/file/create-or-update-file-by-batch'; +import { createOrUpdateFolderByBatch } from '../../../../infra/sqlite/services/folder/create-or-update-folder-by-batch'; + +const mockedGet = vi.mocked( + driveServerClient.GET as (...args: unknown[]) => Promise<{ data?: unknown; error?: unknown }>, +); +const mockedCreateOrUpdateFileByBatch = vi.mocked(createOrUpdateFileByBatch); +const mockedCreateOrUpdateFolderByBatch = vi.mocked(createOrUpdateFolderByBatch); + +const inMemorySyncedFilesCollection: DatabaseCollectionAdapter = { + get: vi.fn(), + connect: vi.fn(), + update: vi.fn(), + create: vi.fn(), + remove: vi.fn(), + getLastUpdated: vi.fn(), +}; + +const inMemorySyncedFoldersCollection: DatabaseCollectionAdapter = { + get: vi.fn(), + connect: vi.fn(), + update: vi.fn(), + create: vi.fn(), + remove: vi.fn(), + getLastUpdated: vi.fn(), +}; + +function createRemoteSyncedFileFixture(payload: Partial): RemoteSyncedFile { + return { + status: 'EXISTS', + name: `name_${uuid.v4()}`, + plainName: `plainname_${Date.now()}`, + id: Date.now(), + uuid: uuid.v4(), + fileId: Date.now().toString(), + type: 'jpg', + size: 999, + bucket: `bucket_${Date.now()}`, + folderId: 555, + folderUuid: uuid.v4(), + userId: 567, + modificationTime: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...payload, + }; +} + +function createRemoteSyncedFolderFixture(payload: Partial): RemoteSyncedFolder { + return { + name: `name_${uuid.v4()}`, + plainName: `folder_${Date.now()}`, + id: Date.now(), + type: 'folder', + bucket: `bucket_${Date.now()}`, + userId: 567, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + parentId: null, + uuid: uuid.v4(), + status: 'EXISTS', + ...payload, + }; +} + +describe('index.test', () => { + let errorHandler: RemoteSyncErrorHandler; + let sut: RemoteSyncController; + let props: CreateRemoteSyncControllerPops; + + inMemorySyncedFilesCollection.getLastUpdated = () => Promise.resolve({ success: false, result: null }); + inMemorySyncedFoldersCollection.getLastUpdated = () => Promise.resolve({ success: false, result: null }); + + beforeEach(() => { + errorHandler = { + handleSyncError: vi.fn(), + }; + + props = { + db: { + folders: inMemorySyncedFoldersCollection, + files: inMemorySyncedFilesCollection, + }, + config: { + fetchFilesLimitPerRequest: 2, + fetchFoldersLimitPerRequest: 2, + syncFiles: true, + syncFolders: true, + }, + errorHandler, + }; + + sut = createRemoteSyncController(); + mockedGet.mockClear(); + mockedCreateOrUpdateFileByBatch.mockClear(); + mockedCreateOrUpdateFolderByBatch.mockClear(); + }); + + describe('When there are files in remote, should sync them with local', () => { + it('Should sync all the files', async () => { + // Given + const sut = createRemoteSyncController(); + const syncProps = { + ...props, + config: { + ...props.config, + syncFolders: false, + }, + }; + + mockedGet + .mockResolvedValueOnce({ + data: [ + createRemoteSyncedFileFixture({ plainName: 'file_1' }), + createRemoteSyncedFileFixture({ plainName: 'file_2' }), + ], + }) + .mockResolvedValueOnce({ + data: [createRemoteSyncedFileFixture({ plainName: 'file_3' })], + }); + + // When + await sut.startRemoteSync(syncProps); + + // Then + expect(mockedGet).toBeCalledTimes(2); + expect(sut.getSyncStatus()).toBe('SYNCED'); + }); + + it('Should sync all the folders', async () => { + // Given + const sut = createRemoteSyncController(); + const syncProps = { + ...props, + config: { + ...props.config, + syncFiles: false, + }, + }; + + mockedGet + .mockResolvedValueOnce({ + data: [ + createRemoteSyncedFolderFixture({ plainName: 'folder_1' }), + createRemoteSyncedFolderFixture({ plainName: 'folder_2' }), + ], + }) + .mockResolvedValueOnce({ + data: [ + createRemoteSyncedFolderFixture({ plainName: 'folder_3' }), + createRemoteSyncedFolderFixture({ plainName: 'folder_4' }), + ], + }) + .mockResolvedValueOnce({ + data: [createRemoteSyncedFolderFixture({ plainName: 'folder_5' })], + }); + + // When + await sut.startRemoteSync(syncProps); + + // Then + expect(mockedGet).toBeCalledTimes(3); + expect(sut.getSyncStatus()).toBe('SYNCED'); + }); + + it('Should save the files in the database', async () => { + // Given + const sut = createRemoteSyncController(); + const syncProps = { + ...props, + config: { + ...props.config, + syncFolders: false, + }, + }; + const file1 = createRemoteSyncedFileFixture({ plainName: 'file_1' }); + const file2 = createRemoteSyncedFileFixture({ plainName: 'file_2' }); + + mockedGet.mockResolvedValueOnce({ data: [file1, file2] }); + mockedGet.mockResolvedValueOnce({ data: [] }); + + // When + await sut.startRemoteSync(syncProps); + + // Then + expect(mockedGet).toBeCalledTimes(2); + expect(sut.getSyncStatus()).toBe('SYNCED'); + expect(mockedCreateOrUpdateFileByBatch).toBeCalledWith({ files: [file1, file2] }); + }); + + it('Should use the folders limit when syncing folders', async () => { + // Given + const sut = createRemoteSyncController(); + const syncProps = { + ...props, + config: { + ...props.config, + fetchFoldersLimitPerRequest: 7, + syncFiles: false, + }, + }; + + mockedGet.mockResolvedValueOnce({ data: [] }); + + // When + await sut.startRemoteSync(syncProps); + + // Then + expect(mockedGet).toBeCalledWith('/folders', { + query: { + limit: 7, + offset: 0, + status: 'ALL', + updatedAt: undefined, + }, + }); + }); + }); + + describe('When something fails during the sync', () => { + it('Should retry N times and then stop if sync does not succeed', async () => { + // Given + mockedGet.mockResolvedValue({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); + + // When + await sut.startRemoteSync(props); + + // Then + expect(mockedGet).toBeCalledTimes(6); + expect(sut.getSyncStatus()).toBe('SYNC_FAILED'); + }); + + it('Should fail the sync if some files or folders cannot be retrieved', async () => { + // Given + mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); + + // When + await sut.startRemoteSync(props); + + // Then + expect(mockedGet).toBeCalledTimes(6); + expect(sut.getSyncStatus()).toBe('SYNC_FAILED'); + }); + + it('should handle the error while syncing files by calling the error handler properly', async () => { + // Given + const sut = createRemoteSyncController(); + const syncProps = { + ...props, + config: { + ...props.config, + syncFolders: false, + }, + }; + mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); + + // When + await sut.startRemoteSync(syncProps); + + // Then + expect(errorHandler.handleSyncError).toHaveBeenCalled(); + expect(vi.mocked(errorHandler.handleSyncError).mock.calls[0][0]).toMatchObject({ + syncItemType: 'files', + }); + }); + + it('should handle the error while syncing folders by calling the error handler properly', async () => { + // Given + const sut = createRemoteSyncController(); + const syncProps = { + ...props, + config: { + ...props.config, + syncFiles: false, + }, + }; + + mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); + + // When + await sut.startRemoteSync(syncProps); + + // Then + expect(errorHandler.handleSyncError).toHaveBeenCalled(); + expect(vi.mocked(errorHandler.handleSyncError).mock.calls[0][0]).toMatchObject({ + syncItemType: 'folders', + }); + }); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/index.ts b/src/apps/main/remote-sync/remote-sync-controller/index.ts new file mode 100644 index 0000000000..12b96a6ae0 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/index.ts @@ -0,0 +1,29 @@ +import { createControllerState } from './create-controller-state'; +import { getSyncStatus, getTotalFilesSynced, onStatusChange } from './controller-methods'; +import { resetRemoteSync as resetRemoteSyncState } from './reset-remote-sync'; +import { startRemoteSync as startRemoteSyncProcess } from './start-remote-sync'; +import { CreateRemoteSyncControllerPops, RemoteSyncController } from './types'; + +export type { + CreateRemoteSyncControllerPops, + RemoteSyncController, + RemoteSyncControllerConfig, + RemoteSyncControllerDb, + RemoteSyncControllerState, +} from './types'; + +export function createRemoteSyncController(): RemoteSyncController { + const state = createControllerState(); + + return { + getTotalFilesSynced: () => getTotalFilesSynced({ state }), + onStatusChange: (callback) => onStatusChange({ state, callback }), + getSyncStatus: () => getSyncStatus({ state }), + resetRemoteSync: () => resetRemoteSyncState({ state }), + startRemoteSync: (props: CreateRemoteSyncControllerPops) => + startRemoteSyncProcess({ + state, + ...props, + }), + }; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts b/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts new file mode 100644 index 0000000000..9e04e30f7b --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts @@ -0,0 +1,28 @@ +vi.mock('@internxt/drive-desktop-core/build/backend'); + +import { createControllerState } from './create-controller-state'; +import { resetRemoteSync } from './reset-remote-sync'; + +describe('reset-remote-sync.test', () => { + it('should reset sync state and counters', () => { + // Given + const state = createControllerState(); + state.status = 'SYNC_FAILED'; + state.filesSyncStatus = 'SYNC_FAILED'; + state.foldersSyncStatus = 'SYNCED'; + state.totalFilesSynced = 4; + state.totalFoldersSynced = 2; + + // When + resetRemoteSync({ state }); + + // Then + expect(state).toMatchObject({ + status: 'IDLE', + filesSyncStatus: 'IDLE', + foldersSyncStatus: 'IDLE', + totalFilesSynced: 0, + totalFoldersSynced: 0, + }); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.ts b/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.ts new file mode 100644 index 0000000000..4c5d301819 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.ts @@ -0,0 +1,17 @@ +import { changeStatus } from './change-status'; +import { RemoteSyncControllerState } from './types'; + +type ResetRemoteSyncPops = { + state: RemoteSyncControllerState; +}; + +export function resetRemoteSync({ state }: ResetRemoteSyncPops) { + changeStatus({ + state, + newStatus: 'IDLE', + }); + state.filesSyncStatus = 'IDLE'; + state.foldersSyncStatus = 'IDLE'; + state.totalFilesSynced = 0; + state.totalFoldersSynced = 0; +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/start-remote-sync.ts b/src/apps/main/remote-sync/remote-sync-controller/start-remote-sync.ts new file mode 100644 index 0000000000..65bf6761aa --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/start-remote-sync.ts @@ -0,0 +1,76 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { changeStatus } from './change-status'; +import { syncRemoteFiles } from './sync-remote-files'; +import { syncRemoteFolders } from './sync-remote-folders'; +import { CreateRemoteSyncControllerPops, RemoteSyncControllerState } from './types'; + +type StartRemoteSyncPops = CreateRemoteSyncControllerPops & { + state: RemoteSyncControllerState; +}; + +export async function startRemoteSync({ state, db, config, errorHandler }: StartRemoteSyncPops) { + if (state.status === 'SYNCING') { + logger.warn({ + tag: 'SYNC-ENGINE', + msg: 'Remote sync controller should not be in SYNCING status to start, not starting again', + }); + + return; + } + + state.totalFilesSynced = 0; + state.totalFoldersSynced = 0; + state.filesSyncStatus = 'IDLE'; + state.foldersSyncStatus = 'IDLE'; + + await db.files.connect(); + await db.folders.connect(); + + logger.debug({ tag: 'SYNC-ENGINE', msg: 'Starting' }); + changeStatus({ state, newStatus: 'SYNCING' }); + + try { + await Promise.all([ + config.syncFiles + ? syncRemoteFiles({ + state, + db, + config, + errorHandler, + syncConfig: { + retry: 1, + maxRetries: 3, + }, + }) + : Promise.resolve(), + config.syncFolders + ? syncRemoteFolders({ + state, + db, + config, + errorHandler, + syncConfig: { + retry: 1, + maxRetries: 3, + }, + }) + : Promise.resolve(), + ]); + } catch (error) { + changeStatus({ state, newStatus: 'SYNC_FAILED' }); + logger.error({ + tag: 'SYNC-ENGINE', + msg: 'Remote sync failed with uncontrolled error: ', + error, + }); + } finally { + logger.debug({ + tag: 'SYNC-ENGINE', + msg: `Total synced files: ${state.totalFilesSynced}`, + }); + logger.debug({ + tag: 'SYNC-ENGINE', + msg: `Total synced folders: ${state.totalFoldersSynced}`, + }); + } +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts b/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts new file mode 100644 index 0000000000..eb137c9df1 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts @@ -0,0 +1,54 @@ +import { SIX_HOURS_IN_MILLISECONDS } from '../helpers'; +import { createOrUpdateFileByBatch } from '../../../../infra/sqlite/services/file/create-or-update-file-by-batch'; +import { fetchRemoteFiles } from '../fetch-remote-files'; +import { getLastUpdatedCheckpoint } from '../get-last-updated-checkpoint'; +import { syncRemoteItems } from '../sync-remote-items'; +import { checkRemoteSyncStatus } from './check-remote-sync-status'; +import { SyncRemoteItemsPops } from './types'; + +export async function syncRemoteFiles({ + state, + db, + config, + errorHandler, + syncConfig, + from, +}: SyncRemoteItemsPops) { + await syncRemoteItems({ + from, + finishMessage: 'Remote files sync finished', + syncConfig, + syncItemType: 'files', + getCheckpoint: () => + getLastUpdatedCheckpoint({ + collection: db.files, + rewindMilliseconds: SIX_HOURS_IN_MILLISECONDS, + }), + fetchRemoteItems: (updatedAtCheckpoint) => + fetchRemoteFiles({ + limit: config.fetchFilesLimitPerRequest, + updatedAtCheckpoint, + }), + persistRemoteItems: (items) => createOrUpdateFileByBatch({ files: items }), + onSyncFailed: () => { + state.filesSyncStatus = 'SYNC_FAILED'; + }, + onSyncFinished: () => { + state.filesSyncStatus = 'SYNCED'; + }, + onSyncProgress: (items) => { + state.totalFilesSynced += items.length; + }, + onSyncStateChanged: () => { + checkRemoteSyncStatus({ state, config }); + }, + handleSyncError: (error, itemName, checkpoint) => { + errorHandler.handleSyncError({ + error, + syncItemType: 'files', + itemName, + itemCheckpoint: checkpoint, + }); + }, + }); +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts b/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts new file mode 100644 index 0000000000..d6a9cf90c2 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts @@ -0,0 +1,54 @@ +import { SIX_HOURS_IN_MILLISECONDS } from '../helpers'; +import { createOrUpdateFolderByBatch } from '../../../../infra/sqlite/services/folder/create-or-update-folder-by-batch'; +import { fetchRemoteFolders } from '../fetch-remote-folders'; +import { getLastUpdatedCheckpoint } from '../get-last-updated-checkpoint'; +import { syncRemoteItems } from '../sync-remote-items'; +import { checkRemoteSyncStatus } from './check-remote-sync-status'; +import { SyncRemoteItemsPops } from './types'; + +export async function syncRemoteFolders({ + state, + db, + config, + errorHandler, + syncConfig, + from, +}: SyncRemoteItemsPops) { + await syncRemoteItems({ + from, + finishMessage: 'Remote folders sync finished', + syncConfig, + syncItemType: 'folders', + getCheckpoint: () => + getLastUpdatedCheckpoint({ + collection: db.folders, + rewindMilliseconds: SIX_HOURS_IN_MILLISECONDS, + }), + fetchRemoteItems: (updatedAtCheckpoint) => + fetchRemoteFolders({ + limit: config.fetchFoldersLimitPerRequest, + updatedAtCheckpoint, + }), + persistRemoteItems: (items) => createOrUpdateFolderByBatch({ folders: items }), + onSyncFailed: () => { + state.foldersSyncStatus = 'SYNC_FAILED'; + }, + onSyncFinished: () => { + state.foldersSyncStatus = 'SYNCED'; + }, + onSyncProgress: (items) => { + state.totalFoldersSynced += items.length; + }, + onSyncStateChanged: () => { + checkRemoteSyncStatus({ state, config }); + }, + handleSyncError: (error, itemName, checkpoint) => { + errorHandler.handleSyncError({ + error, + syncItemType: 'folders', + itemName, + itemCheckpoint: checkpoint, + }); + }, + }); +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/types.ts b/src/apps/main/remote-sync/remote-sync-controller/types.ts new file mode 100644 index 0000000000..a5e8fc7324 --- /dev/null +++ b/src/apps/main/remote-sync/remote-sync-controller/types.ts @@ -0,0 +1,49 @@ +import { SyncConfig, RemoteSyncStatus } from '../helpers'; +import { DatabaseCollectionAdapter } from '../../database/adapters/base'; +import { DriveFile } from '../../database/entities/DriveFile'; +import { DriveFolder } from '../../database/entities/DriveFolder'; +import { RemoteSyncErrorHandler } from '../create-remote-sync-error-handler'; + +export type RemoteSyncControllerConfig = { + fetchFilesLimitPerRequest: number; + fetchFoldersLimitPerRequest: number; + syncFiles: boolean; + syncFolders: boolean; +}; + +export type RemoteSyncControllerDb = { + files: DatabaseCollectionAdapter; + folders: DatabaseCollectionAdapter; +}; + +export type CreateRemoteSyncControllerPops = { + db: RemoteSyncControllerDb; + config: RemoteSyncControllerConfig; + errorHandler: RemoteSyncErrorHandler; +}; + +export type RemoteSyncController = { + getTotalFilesSynced: () => number; + onStatusChange: (callback: (newStatus: RemoteSyncStatus) => void) => void; + getSyncStatus: () => RemoteSyncStatus; + resetRemoteSync: () => void; + startRemoteSync: (props: CreateRemoteSyncControllerPops) => Promise; +}; + +export type RemoteSyncControllerState = { + foldersSyncStatus: RemoteSyncStatus; + filesSyncStatus: RemoteSyncStatus; + status: RemoteSyncStatus; + totalFilesSynced: number; + totalFoldersSynced: number; + onStatusChangeCallbacks: Array<(newStatus: RemoteSyncStatus) => void>; +}; + +export type SyncRemoteItemsPops = { + state: RemoteSyncControllerState; + db: RemoteSyncControllerDb; + config: RemoteSyncControllerConfig; + errorHandler: RemoteSyncErrorHandler; + syncConfig: SyncConfig; + from?: Date; +}; diff --git a/src/apps/main/remote-sync/resolve-remote-sync-status.ts b/src/apps/main/remote-sync/resolve-remote-sync-status.ts new file mode 100644 index 0000000000..e0e5ff1fd9 --- /dev/null +++ b/src/apps/main/remote-sync/resolve-remote-sync-status.ts @@ -0,0 +1,33 @@ +import { RemoteSyncStatus } from './helpers'; + +type Pops = { + filesSyncStatus: RemoteSyncStatus; + foldersSyncStatus: RemoteSyncStatus; + syncFiles: boolean; + syncFolders: boolean; +}; + +export function resolveRemoteSyncStatus({ + filesSyncStatus, + foldersSyncStatus, + syncFiles, + syncFolders, +}: Pops) { + if (syncFiles && !syncFolders && filesSyncStatus === 'SYNCED') { + return 'SYNCED' as const; + } + + if (!syncFiles && syncFolders && foldersSyncStatus === 'SYNCED') { + return 'SYNCED' as const; + } + + if (foldersSyncStatus === 'SYNCED' && filesSyncStatus === 'SYNCED') { + return 'SYNCED' as const; + } + + if (foldersSyncStatus === 'SYNC_FAILED' || filesSyncStatus === 'SYNC_FAILED') { + return 'SYNC_FAILED' as const; + } + + return undefined; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/service.test.ts b/src/apps/main/remote-sync/service.test.ts new file mode 100644 index 0000000000..6c5bd3e145 --- /dev/null +++ b/src/apps/main/remote-sync/service.test.ts @@ -0,0 +1,217 @@ +import { vi } from 'vitest'; +import { call, calls, partialSpyOn } from '../../../../tests/vitest/utils.helper'; +import { UpdatedRemoteItemsDto } from '../../../context/shared/application/sync/remote-sync.contract'; + +function createUpdatedRemoteItemsDtoFixture(): UpdatedRemoteItemsDto { + return { + files: [ + { + bucket: 'bucket-1', + createdAt: '2024-01-01T00:00:00.000Z', + fileId: 'file-1', + folderId: 11, + id: 1, + modificationTime: '2024-01-02T00:00:00.000Z', + name: 'report.txt', + plainName: 'report', + size: 128, + type: 'txt', + updatedAt: '2024-01-03T00:00:00.000Z', + userId: 21, + status: 'EXISTS', + uuid: 'file-uuid-1', + }, + ], + folders: [ + { + bucket: 'bucket-2', + createdAt: '2024-02-01T00:00:00.000Z', + id: 2, + name: 'Projects', + parentId: null, + plainName: 'Projects', + status: 'EXISTS', + updatedAt: '2024-02-02T00:00:00.000Z', + uuid: 'folder-uuid-1', + }, + ], + }; +} + +const mocks = vi.hoisted(() => { + const filesCollection = { + getAll: vi.fn(), + getAllWhere: vi.fn(), + }; + + const foldersCollection = { + getAll: vi.fn(), + }; + + const controller = { + onStatusChange: vi.fn(), + startRemoteSync: vi.fn(), + getSyncStatus: vi.fn(), + resetRemoteSync: vi.fn(), + }; + + return { + debounce: vi.fn((fn: () => unknown) => fn), + filesCollection, + foldersCollection, + controller, + remoteSyncControllerFactory: vi.fn(() => controller), + }; +}); + +vi.mock('lodash', () => ({ + debounce: mocks.debounce, +})); + +vi.mock('../database/collections/DriveFileCollection', () => ({ + DriveFilesCollection: vi.fn(() => mocks.filesCollection), +})); + +vi.mock('../database/collections/DriveFolderCollection', () => ({ + DriveFoldersCollection: vi.fn(() => mocks.foldersCollection), +})); + +vi.mock('./remote-sync-controller', () => ({ + createRemoteSyncController: mocks.remoteSyncControllerFactory, +})); + +vi.mock('./create-remote-sync-error-handler', () => ({ + createRemoteSyncErrorHandler: vi.fn(() => ({ + handleSyncError: vi.fn(), + })), +})); + +type ServiceModule = typeof import('./service'); + +async function loadServiceModule() { + vi.resetModules(); + + const eventBusModule = await import('../event-bus'); + const initialSyncReadyModule = await import('./InitialSyncReady'); + const windowsModule = await import('../windows'); + + const eventBusEmitMock = partialSpyOn(eventBusModule.default, 'emit'); + const broadcastToWindowsMock = partialSpyOn(windowsModule, 'broadcastToWindows'); + const getIsInitialSyncReadyMock = partialSpyOn(initialSyncReadyModule, 'isInitialSyncReady'); + const setInitialSyncStateMock = partialSpyOn(initialSyncReadyModule, 'setInitialSyncState'); + + getIsInitialSyncReadyMock.mockReturnValue(false); + + const service = (await import('./service')) as ServiceModule; + + return { + broadcastToWindowsMock, + eventBusEmitMock, + getIsInitialSyncReadyMock, + service, + setInitialSyncStateMock, + }; +} + +function getRegisteredStatusChangeCallback() { + return mocks.controller.onStatusChange.mock.calls.at(-1)?.[0] as ((status: string) => Promise) | undefined; +} + +describe('service.test', () => { + beforeEach(() => { + mocks.controller.getSyncStatus.mockReturnValue('IDLE'); + const result = createUpdatedRemoteItemsDtoFixture(); + mocks.filesCollection.getAll.mockResolvedValue({ success: true, result: result.files }); + mocks.foldersCollection.getAll.mockResolvedValue({ success: true, result: result.folders }); + mocks.filesCollection.getAllWhere.mockResolvedValue({ result: [{ uuid: 'file-1' }] }); + }); + + it('should mark initial sync as ready and broadcast when the controller becomes synced for the first time', async () => { + // Given + const { eventBusEmitMock, broadcastToWindowsMock, setInitialSyncStateMock } = await loadServiceModule(); + const callback = getRegisteredStatusChangeCallback(); + + // When + await callback?.('SYNCED'); + + // Then + call(setInitialSyncStateMock).toBe('READY'); + call(eventBusEmitMock).toBe('INITIAL_SYNC_READY'); + call(broadcastToWindowsMock).toStrictEqual(['remote-sync-status-change', 'SYNCED']); + }); + + it('should only broadcast status changes when initial sync is already ready', async () => { + // Given + const { eventBusEmitMock, broadcastToWindowsMock, getIsInitialSyncReadyMock, setInitialSyncStateMock } = + await loadServiceModule(); + getIsInitialSyncReadyMock.mockReturnValue(true); + const callback = getRegisteredStatusChangeCallback(); + + // When + await callback?.('SYNCED'); + + // Then + expect(setInitialSyncStateMock).not.toHaveBeenCalled(); + expect(eventBusEmitMock).not.toHaveBeenCalledWith('INITIAL_SYNC_READY'); + call(broadcastToWindowsMock).toStrictEqual(['remote-sync-status-change', 'SYNCED']); + }); + + it('should return updated remote items from both collections', async () => { + // Given + const { service } = await loadServiceModule(); + const expected = createUpdatedRemoteItemsDtoFixture(); + + // When + const result = await service.getUpdatedRemoteItems(); + + // Then + expect(result).toStrictEqual(expected); + }); + + it('should throw when files cannot be retrieved from the local database', async () => { + // Given + mocks.filesCollection.getAll.mockResolvedValue({ success: false, result: [] }); + const { service } = await loadServiceModule(); + + // Then + await expect(service.getUpdatedRemoteItems()).rejects.toThrow( + 'Failed to retrieve all the drive files from local db', + ); + }); + + it('should delegate startRemoteSync to the controller', async () => { + // Given + const { service } = await loadServiceModule(); + + // When + await service.startRemoteSync(); + + // Then + calls(mocks.controller.startRemoteSync).toHaveLength(1); + }); + + it('should run a debounced resync and emit remote changes synched', async () => { + // Given + const { eventBusEmitMock, service } = await loadServiceModule(); + + // When + await service.resyncRemoteSync(); + + // Then + calls(mocks.debounce).toHaveLength(1); + calls(mocks.controller.startRemoteSync).toHaveLength(1); + call(eventBusEmitMock).toBe('REMOTE_CHANGES_SYNCHED'); + }); + + it('should return only existing files from the local collection', async () => { + // Given + const { service } = await loadServiceModule(); + + // When + const result = await service.getExistingFiles(); + + // Then + call(mocks.filesCollection.getAllWhere).toStrictEqual({ status: 'EXISTS' }); + expect(result).toStrictEqual([{ uuid: 'file-1' }]); + }); +}); \ No newline at end of file diff --git a/src/apps/main/remote-sync/service.ts b/src/apps/main/remote-sync/service.ts index f2468cd661..7ef764c7c2 100644 --- a/src/apps/main/remote-sync/service.ts +++ b/src/apps/main/remote-sync/service.ts @@ -2,32 +2,36 @@ import { debounce } from 'lodash'; import eventBus from '../event-bus'; import { DriveFilesCollection } from '../database/collections/DriveFileCollection'; import { DriveFoldersCollection } from '../database/collections/DriveFolderCollection'; -import { RemoteSyncManager } from './RemoteSyncManager'; +import { createRemoteSyncController } from './remote-sync-controller'; import { broadcastToWindows } from '../windows'; import { isInitialSyncReady, setInitialSyncState } from './InitialSyncReady'; -import { RemoteSyncErrorHandler } from './RemoteSyncErrorHandler/RemoteSyncErrorHandler'; +import { createRemoteSyncErrorHandler } from './create-remote-sync-error-handler'; +import { registerRemoteSyncService } from '../../../context/shared/application/sync/remote-sync-service'; +import { toRemoteSyncFileDto } from './to-remote-sync-file-dto'; +import { toRemoteSyncFolderDto } from './to-remote-sync-folder-dto'; const SYNC_DEBOUNCE_DELAY = 3_000; const driveFilesCollection = new DriveFilesCollection(); const driveFoldersCollection = new DriveFoldersCollection(); -const errorHandler = new RemoteSyncErrorHandler(); - -export const remoteSyncManager = new RemoteSyncManager( - { +const errorHandler = createRemoteSyncErrorHandler(); +const remoteSyncControllerPops = { + db: { files: driveFilesCollection, folders: driveFoldersCollection, }, - { + config: { fetchFilesLimitPerRequest: 1000, fetchFoldersLimitPerRequest: 1000, syncFiles: true, syncFolders: true, }, errorHandler, -); +}; + +export const remoteSyncController = createRemoteSyncController(); -remoteSyncManager.onStatusChange(async (newStatus) => { +remoteSyncController.onStatusChange(async (newStatus) => { if (!isInitialSyncReady() && newStatus === 'SYNCED') { setInitialSyncState('READY'); eventBus.emit('INITIAL_SYNC_READY'); @@ -44,14 +48,15 @@ export async function getUpdatedRemoteItems() { if (!allDriveFiles.success) throw new Error('Failed to retrieve all the drive files from local db'); if (!allDriveFolders.success) throw new Error('Failed to retrieve all the drive folders from local db'); + return { - files: allDriveFiles.result, - folders: allDriveFolders.result, + files: allDriveFiles.result.map(toRemoteSyncFileDto), + folders: allDriveFolders.result.map(toRemoteSyncFolderDto), }; } export async function startRemoteSync(): Promise { - await remoteSyncManager.startRemoteSync(); + await remoteSyncController.startRemoteSync(remoteSyncControllerPops); } const debouncedSynchronization = debounce(async () => { @@ -63,6 +68,12 @@ export async function resyncRemoteSync() { await debouncedSynchronization(); } +registerRemoteSyncService({ + getUpdatedRemoteItems, + startRemoteSync, + resyncRemoteSync, +}); + export async function getExistingFiles() { const allExisting = await driveFilesCollection.getAllWhere({ status: 'EXISTS', diff --git a/src/apps/main/remote-sync/sync-remote-items.ts b/src/apps/main/remote-sync/sync-remote-items.ts new file mode 100644 index 0000000000..d0a640521d --- /dev/null +++ b/src/apps/main/remote-sync/sync-remote-items.ts @@ -0,0 +1,91 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { Nullable } from '../../shared/types/Nullable'; +import { RemoteSyncError } from './errors'; +import { SyncConfig } from './helpers'; +import { waitBeforeRetry } from './wait-before-retry'; + +type RemoteSyncItem = { + updatedAt: string; + name?: string; +}; + +type Pops = { + from?: Date; + finishMessage: string; + syncConfig: SyncConfig; + syncItemType: 'files' | 'folders'; + getCheckpoint: () => Promise>; + fetchRemoteItems: (updatedAtCheckpoint?: Date) => Promise<{ hasMore: boolean; result: TItem[] }>; + persistRemoteItems: (items: TItem[]) => Promise; + onSyncFailed: () => void; + onSyncFinished: () => void; + onSyncProgress: (items: TItem[]) => void; + onSyncStateChanged: () => void; + handleSyncError: (error: RemoteSyncError, itemName: string, checkpoint?: Date) => void; +}; + +export async function syncRemoteItems({ + from, + finishMessage, + syncConfig, + syncItemType, + getCheckpoint, + fetchRemoteItems, + persistRemoteItems, + onSyncFailed, + onSyncFinished, + onSyncProgress, + onSyncStateChanged, + handleSyncError, +}: Pops) { + let checkpoint = from ?? (await getCheckpoint()); + let hasMore = true; + let retryCount = 0; + + while (hasMore && retryCount < syncConfig.maxRetries) { + let lastSyncedItem: TItem | null = null; + + try { + const { hasMore: moreAvailable, result } = await fetchRemoteItems(checkpoint); + + await persistRemoteItems(result); + onSyncProgress(result); + + lastSyncedItem = result.at(-1) ?? null; + hasMore = moreAvailable; + + if (hasMore && lastSyncedItem) { + checkpoint = new Date(lastSyncedItem.updatedAt); + } + + retryCount = 0; + } catch (error) { + retryCount++; + + if (error instanceof RemoteSyncError) { + handleSyncError(error, lastSyncedItem?.name ?? 'unknown', checkpoint); + } else { + logger.error({ + tag: 'SYNC-ENGINE', + msg: `Remote ${syncItemType} sync failed with uncontrolled error: `, + error, + }); + } + + if (retryCount >= syncConfig.maxRetries) { + onSyncFailed(); + onSyncStateChanged(); + return; + } + + await waitBeforeRetry({ retryCount }); + } + } + + logger.debug({ + tag: 'SYNC-ENGINE', + msg: finishMessage, + }); + onSyncFinished(); + onSyncStateChanged(); +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/to-remote-sync-file-dto.ts b/src/apps/main/remote-sync/to-remote-sync-file-dto.ts new file mode 100644 index 0000000000..642d7fdab0 --- /dev/null +++ b/src/apps/main/remote-sync/to-remote-sync-file-dto.ts @@ -0,0 +1,21 @@ +import { DriveFile } from '../database/entities/DriveFile'; +import { RemoteSyncFileDto } from '../../../context/shared/application/sync/remote-sync.contract'; + +export function toRemoteSyncFileDto(file: DriveFile): RemoteSyncFileDto { + return { + bucket: file.bucket, + createdAt: file.createdAt, + fileId: file.fileId, + folderId: file.folderId, + id: file.id, + modificationTime: file.modificationTime, + name: file.name ?? '', + plainName: file.plainName, + size: file.size, + type: file.type ?? null, + updatedAt: file.updatedAt, + userId: file.userId, + status: file.status, + uuid: file.uuid, + }; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts b/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts new file mode 100644 index 0000000000..c7025a0e12 --- /dev/null +++ b/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts @@ -0,0 +1,16 @@ +import { DriveFolder } from '../database/entities/DriveFolder'; +import { RemoteSyncFolderDto } from '../../../context/shared/application/sync/remote-sync.contract'; + +export function toRemoteSyncFolderDto(folder: DriveFolder): RemoteSyncFolderDto { + return { + bucket: folder.bucket ?? null, + createdAt: folder.createdAt, + id: folder.id, + name: folder.name ?? '', + parentId: folder.parentId ?? null, + updatedAt: folder.updatedAt, + plainName: folder.plainName ?? null, + status: folder.status, + uuid: folder.uuid, + }; +} \ No newline at end of file diff --git a/src/apps/main/remote-sync/wait-before-retry.ts b/src/apps/main/remote-sync/wait-before-retry.ts new file mode 100644 index 0000000000..b6a32dfa84 --- /dev/null +++ b/src/apps/main/remote-sync/wait-before-retry.ts @@ -0,0 +1,7 @@ +type Pops = { + retryCount: number; +}; + +export async function waitBeforeRetry({ retryCount }: Pops) { + await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount)); +} \ No newline at end of file diff --git a/src/context/shared/application/sync/TriggerRemoteSyncOnFileOverridden.ts b/src/context/shared/application/sync/TriggerRemoteSyncOnFileOverridden.ts index d352376ae0..84f1a0dca2 100644 --- a/src/context/shared/application/sync/TriggerRemoteSyncOnFileOverridden.ts +++ b/src/context/shared/application/sync/TriggerRemoteSyncOnFileOverridden.ts @@ -2,7 +2,7 @@ import { Service } from 'diod'; import { FileOverriddenDomainEvent } from '../../../virtual-drive/files/domain/events/FileOverriddenDomainEvent'; import { DomainEventSubscriber } from '../../domain/DomainEventSubscriber'; import { DomainEventClass } from '../../domain/DomainEvent'; -import { resyncRemoteSync } from '../../../../apps/main/remote-sync/service'; +import { getRemoteSyncService } from './remote-sync-service'; @Service() export class TriggerRemoteSyncOnFileOverridden implements DomainEventSubscriber { @@ -11,6 +11,6 @@ export class TriggerRemoteSyncOnFileOverridden implements DomainEventSubscriber< } async on(): Promise { - await resyncRemoteSync(); + await getRemoteSyncService().resyncRemoteSync(); } } diff --git a/src/context/shared/application/sync/remote-sync-service.test.ts b/src/context/shared/application/sync/remote-sync-service.test.ts new file mode 100644 index 0000000000..3fca23a17c --- /dev/null +++ b/src/context/shared/application/sync/remote-sync-service.test.ts @@ -0,0 +1,30 @@ +describe('remote-sync-service.test', () => { + it('should throw when the remote sync service has not been registered', async () => { + // Given + vi.resetModules(); + const { getRemoteSyncService } = await import('./remote-sync-service'); + + // When + const getService = () => getRemoteSyncService(); + + // Then + expect(getService).toThrow('Remote sync service has not been registered'); + }); + + it('should return the registered remote sync service', async () => { + // Given + vi.resetModules(); + const { getRemoteSyncService, registerRemoteSyncService } = await import('./remote-sync-service'); + const service = { + getUpdatedRemoteItems: vi.fn(), + startRemoteSync: vi.fn(), + resyncRemoteSync: vi.fn(), + }; + + // When + registerRemoteSyncService(service); + + // Then + expect(getRemoteSyncService()).toBe(service); + }); +}); \ No newline at end of file diff --git a/src/context/shared/application/sync/remote-sync-service.ts b/src/context/shared/application/sync/remote-sync-service.ts new file mode 100644 index 0000000000..ddf0f5218c --- /dev/null +++ b/src/context/shared/application/sync/remote-sync-service.ts @@ -0,0 +1,21 @@ +import { UpdatedRemoteItemsDto } from './remote-sync.contract'; + +export type RemoteSyncService = { + getUpdatedRemoteItems: () => Promise; + startRemoteSync: () => Promise; + resyncRemoteSync: () => Promise; +}; + +let remoteSyncService: RemoteSyncService | undefined; + +export function registerRemoteSyncService(service: RemoteSyncService) { + remoteSyncService = service; +} + +export function getRemoteSyncService() { + if (!remoteSyncService) { + throw new Error('Remote sync service has not been registered'); + } + + return remoteSyncService; +} \ No newline at end of file diff --git a/src/context/shared/application/sync/remote-sync.contract.ts b/src/context/shared/application/sync/remote-sync.contract.ts new file mode 100644 index 0000000000..6965e074ab --- /dev/null +++ b/src/context/shared/application/sync/remote-sync.contract.ts @@ -0,0 +1,33 @@ +export type RemoteSyncFileDto = { + bucket: string; + createdAt: string; + fileId: string; + folderId: number; + id: number; + modificationTime: string; + name: string; + plainName: string; + size: number; + type: string | null; + updatedAt: string; + userId: number; + status: string; + uuid: string; +}; + +export type RemoteSyncFolderDto = { + bucket: string | null; + createdAt: string; + id: number; + name: string; + parentId: number | null; + updatedAt: string; + plainName: string | null; + status: string; + uuid: string; +}; + +export type UpdatedRemoteItemsDto = { + files: RemoteSyncFileDto[]; + folders: RemoteSyncFolderDto[]; +}; \ No newline at end of file diff --git a/src/context/virtual-drive/remoteTree/infrastructure/SQLiteRemoteItemsGenerator.ts b/src/context/virtual-drive/remoteTree/infrastructure/SQLiteRemoteItemsGenerator.ts index 2e75852734..cc685dcda0 100644 --- a/src/context/virtual-drive/remoteTree/infrastructure/SQLiteRemoteItemsGenerator.ts +++ b/src/context/virtual-drive/remoteTree/infrastructure/SQLiteRemoteItemsGenerator.ts @@ -1,13 +1,13 @@ import { Service } from 'diod'; -import { getUpdatedRemoteItems, startRemoteSync } from '../../../../apps/main/remote-sync/service'; import { ServerFile, ServerFileStatus } from '../../../shared/domain/ServerFile'; import { ServerFolder, ServerFolderStatus } from '../../../shared/domain/ServerFolder'; import { RemoteItemsGenerator } from '../domain/RemoteItemsGenerator'; +import { getRemoteSyncService } from '../../../shared/application/sync/remote-sync-service'; @Service() export class SQLiteRemoteItemsGenerator implements RemoteItemsGenerator { async getAll(): Promise<{ files: ServerFile[]; folders: ServerFolder[] }> { - const result = await getUpdatedRemoteItems(); + const result = await getRemoteSyncService().getUpdatedRemoteItems(); const files = result.files.map((updatedFile) => { return { @@ -21,7 +21,7 @@ export class SQLiteRemoteItemsGenerator implements RemoteItemsGenerator { name: updatedFile.name, plainName: updatedFile.plainName, size: updatedFile.size, - type: updatedFile.type ?? null, + type: updatedFile.type ?? '', updatedAt: updatedFile.updatedAt, userId: updatedFile.userId, status: updatedFile.status as ServerFileStatus, @@ -47,6 +47,6 @@ export class SQLiteRemoteItemsGenerator implements RemoteItemsGenerator { } async forceRefresh(): Promise { - await startRemoteSync(); + await getRemoteSyncService().startRemoteSync(); } } diff --git a/tests/vitest/ipc.helper.ts b/tests/vitest/ipc.helper.ts new file mode 100644 index 0000000000..97f955f3d7 --- /dev/null +++ b/tests/vitest/ipc.helper.ts @@ -0,0 +1,12 @@ +import { ipcMain } from 'electron'; + +export function getIpcHandler(eventName: string, useOn = false) { + const ipc = ipcMain as unknown as { + handle: { mock: { calls: Array<[string, (...args: unknown[]) => unknown]> } }; + on: { mock: { calls: Array<[string, (...args: unknown[]) => unknown]> } }; + }; + + const calls = useOn ? ipc.on.mock.calls : ipc.handle.mock.calls; + + return calls.find(([name]) => name === eventName)?.[1]; +} \ No newline at end of file From ba5f3e1b0278b00064b9862e7033948a09b7e9c9 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 6 Apr 2026 17:42:55 -0500 Subject: [PATCH 2/5] chore: fix missing newline at end of file in multiple remote sync files --- .../remote-sync/create-remote-sync-error-handler.test.ts | 2 +- .../main/remote-sync/create-remote-sync-error-handler.ts | 2 +- src/apps/main/remote-sync/fetch-remote-files.ts | 2 +- src/apps/main/remote-sync/fetch-remote-folders.ts | 2 +- src/apps/main/remote-sync/get-last-updated-checkpoint.ts | 2 +- .../main/remote-sync/get-remote-sync-error-detail.ts | 2 +- src/apps/main/remote-sync/handlers.test.ts | 6 ++---- .../main/remote-sync/patch-drive-file-response-item.ts | 2 +- .../main/remote-sync/patch-drive-folder-response-item.ts | 2 +- .../remote-sync-controller/change-status.test.ts | 2 +- .../check-remote-sync-status.test.ts | 2 +- .../remote-sync-controller/controller-methods.test.ts | 2 +- .../remote-sync-controller/controller-methods.ts | 2 +- .../create-controller-state.test.ts | 2 +- .../remote-sync/remote-sync-controller/index.test.ts | 2 +- .../main/remote-sync/remote-sync-controller/index.ts | 2 +- .../remote-sync-controller/reset-remote-sync.test.ts | 2 +- .../remote-sync-controller/sync-remote-files.ts | 9 +-------- .../remote-sync-controller/sync-remote-folders.ts | 9 +-------- src/apps/main/remote-sync/resolve-remote-sync-status.ts | 9 ++------- src/apps/main/remote-sync/service.test.ts | 2 +- src/apps/main/remote-sync/sync-remote-items.ts | 2 +- src/apps/main/remote-sync/to-remote-sync-file-dto.ts | 2 +- src/apps/main/remote-sync/to-remote-sync-folder-dto.ts | 2 +- src/apps/main/remote-sync/wait-before-retry.ts | 2 +- .../shared/application/sync/remote-sync-service.test.ts | 2 +- .../shared/application/sync/remote-sync-service.ts | 2 +- .../shared/application/sync/remote-sync.contract.ts | 2 +- 28 files changed, 30 insertions(+), 51 deletions(-) diff --git a/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts b/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts index 69b47d2a7c..2743b21e47 100644 --- a/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts +++ b/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts @@ -99,4 +99,4 @@ describe('create-remote-sync-error-handler.test', () => { }); expect(logger.error).toHaveBeenCalledTimes(1); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/create-remote-sync-error-handler.ts b/src/apps/main/remote-sync/create-remote-sync-error-handler.ts index ad94bf85f5..e26cdb4e74 100644 --- a/src/apps/main/remote-sync/create-remote-sync-error-handler.ts +++ b/src/apps/main/remote-sync/create-remote-sync-error-handler.ts @@ -41,4 +41,4 @@ export function createRemoteSyncErrorHandler(): RemoteSyncErrorHandler { return { handleSyncError, }; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/fetch-remote-files.ts b/src/apps/main/remote-sync/fetch-remote-files.ts index 4140f5b901..48ebc45478 100644 --- a/src/apps/main/remote-sync/fetch-remote-files.ts +++ b/src/apps/main/remote-sync/fetch-remote-files.ts @@ -27,4 +27,4 @@ export async function fetchRemoteFiles({ limit, updatedAtCheckpoint }: Pops): Pr hasMore: data.hasMore, result: data.files.map(patchDriveFileResponseItem), }; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/fetch-remote-folders.ts b/src/apps/main/remote-sync/fetch-remote-folders.ts index ad3033c97b..c6ac98d9d8 100644 --- a/src/apps/main/remote-sync/fetch-remote-folders.ts +++ b/src/apps/main/remote-sync/fetch-remote-folders.ts @@ -27,4 +27,4 @@ export async function fetchRemoteFolders({ limit, updatedAtCheckpoint }: Pops): hasMore: data.hasMore, result: data.folders.map(patchDriveFolderResponseItem), }; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/get-last-updated-checkpoint.ts b/src/apps/main/remote-sync/get-last-updated-checkpoint.ts index 9bf2a54d08..6a25a7eb80 100644 --- a/src/apps/main/remote-sync/get-last-updated-checkpoint.ts +++ b/src/apps/main/remote-sync/get-last-updated-checkpoint.ts @@ -24,4 +24,4 @@ export async function getLastUpdatedCheckpoint, eventName: string) { const calls = eventBusOnMock.mock.calls as Array<[string, (...args: unknown[]) => unknown]>; - return calls.find(([name]) => name === eventName)?.[1] as - | ((...args: unknown[]) => unknown) - | undefined; + return calls.find(([name]) => name === eventName)?.[1] as ((...args: unknown[]) => unknown) | undefined; } describe('handlers.test', () => { @@ -149,4 +147,4 @@ describe('handlers.test', () => { call(setInitialSyncStateMock).toBe('NOT_READY'); calls(mocks.remoteSyncController.resetRemoteSync).toHaveLength(1); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/patch-drive-file-response-item.ts b/src/apps/main/remote-sync/patch-drive-file-response-item.ts index 0d4d86b03d..a821b3a998 100644 --- a/src/apps/main/remote-sync/patch-drive-file-response-item.ts +++ b/src/apps/main/remote-sync/patch-drive-file-response-item.ts @@ -12,4 +12,4 @@ export function patchDriveFileResponseItem(payload: DriveFileResponseItem) { size: typeof payload.size === 'string' ? parseInt(payload.size) : payload.size, name: payload.name ?? undefined, } as RemoteSyncedFile; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/patch-drive-folder-response-item.ts b/src/apps/main/remote-sync/patch-drive-folder-response-item.ts index 683b15c76c..af35cb1e38 100644 --- a/src/apps/main/remote-sync/patch-drive-folder-response-item.ts +++ b/src/apps/main/remote-sync/patch-drive-folder-response-item.ts @@ -25,4 +25,4 @@ export function patchDriveFolderResponseItem(payload: DriveFolderResponseItem) { status, name: payload.name ?? undefined, } as RemoteSyncedFolder; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts b/src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts index 17ea8198a5..11f7f0dfda 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts @@ -30,4 +30,4 @@ describe('change-status.test', () => { // Then expect(logger.debug).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts b/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts index fe5f611bb1..60b81ed9a5 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts @@ -44,4 +44,4 @@ describe('check-remote-sync-status.test', () => { // Then expect(state.status).toBe('SYNCING'); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts b/src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts index 49cffc74f7..8b89f727a1 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts @@ -35,4 +35,4 @@ describe('controller-methods.test', () => { // Then expect(state.onStatusChangeCallbacks).toHaveLength(0); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts b/src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts index fe6fe0a08e..1d041684de 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts @@ -23,4 +23,4 @@ export function onStatusChange({ state, callback }: OnStatusChangePops) { export function getSyncStatus({ state }: ControllerStatePops) { return state.status; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts b/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts index 953914159e..1a2acea40b 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts @@ -15,4 +15,4 @@ describe('create-controller-state.test', () => { onStatusChangeCallbacks: [], }); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/remote-sync-controller/index.test.ts b/src/apps/main/remote-sync/remote-sync-controller/index.test.ts index b71f08dc30..d19aff8ccc 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/index.test.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/index.test.ts @@ -309,4 +309,4 @@ describe('index.test', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/remote-sync-controller/index.ts b/src/apps/main/remote-sync/remote-sync-controller/index.ts index 12b96a6ae0..52f1fa6034 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/index.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/index.ts @@ -26,4 +26,4 @@ export function createRemoteSyncController(): RemoteSyncController { ...props, }), }; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts b/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts index 9e04e30f7b..76032bb4b2 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts @@ -25,4 +25,4 @@ describe('reset-remote-sync.test', () => { totalFoldersSynced: 0, }); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts b/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts index eb137c9df1..2a49f76f4f 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts @@ -6,14 +6,7 @@ import { syncRemoteItems } from '../sync-remote-items'; import { checkRemoteSyncStatus } from './check-remote-sync-status'; import { SyncRemoteItemsPops } from './types'; -export async function syncRemoteFiles({ - state, - db, - config, - errorHandler, - syncConfig, - from, -}: SyncRemoteItemsPops) { +export async function syncRemoteFiles({ state, db, config, errorHandler, syncConfig, from }: SyncRemoteItemsPops) { await syncRemoteItems({ from, finishMessage: 'Remote files sync finished', diff --git a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts b/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts index d6a9cf90c2..545b58eaff 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts @@ -6,14 +6,7 @@ import { syncRemoteItems } from '../sync-remote-items'; import { checkRemoteSyncStatus } from './check-remote-sync-status'; import { SyncRemoteItemsPops } from './types'; -export async function syncRemoteFolders({ - state, - db, - config, - errorHandler, - syncConfig, - from, -}: SyncRemoteItemsPops) { +export async function syncRemoteFolders({ state, db, config, errorHandler, syncConfig, from }: SyncRemoteItemsPops) { await syncRemoteItems({ from, finishMessage: 'Remote folders sync finished', diff --git a/src/apps/main/remote-sync/resolve-remote-sync-status.ts b/src/apps/main/remote-sync/resolve-remote-sync-status.ts index e0e5ff1fd9..21c57ba09a 100644 --- a/src/apps/main/remote-sync/resolve-remote-sync-status.ts +++ b/src/apps/main/remote-sync/resolve-remote-sync-status.ts @@ -7,12 +7,7 @@ type Pops = { syncFolders: boolean; }; -export function resolveRemoteSyncStatus({ - filesSyncStatus, - foldersSyncStatus, - syncFiles, - syncFolders, -}: Pops) { +export function resolveRemoteSyncStatus({ filesSyncStatus, foldersSyncStatus, syncFiles, syncFolders }: Pops) { if (syncFiles && !syncFolders && filesSyncStatus === 'SYNCED') { return 'SYNCED' as const; } @@ -30,4 +25,4 @@ export function resolveRemoteSyncStatus({ } return undefined; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/service.test.ts b/src/apps/main/remote-sync/service.test.ts index 6c5bd3e145..64f4953588 100644 --- a/src/apps/main/remote-sync/service.test.ts +++ b/src/apps/main/remote-sync/service.test.ts @@ -214,4 +214,4 @@ describe('service.test', () => { call(mocks.filesCollection.getAllWhere).toStrictEqual({ status: 'EXISTS' }); expect(result).toStrictEqual([{ uuid: 'file-1' }]); }); -}); \ No newline at end of file +}); diff --git a/src/apps/main/remote-sync/sync-remote-items.ts b/src/apps/main/remote-sync/sync-remote-items.ts index d0a640521d..9d8eefb6a9 100644 --- a/src/apps/main/remote-sync/sync-remote-items.ts +++ b/src/apps/main/remote-sync/sync-remote-items.ts @@ -88,4 +88,4 @@ export async function syncRemoteItems({ }); onSyncFinished(); onSyncStateChanged(); -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/to-remote-sync-file-dto.ts b/src/apps/main/remote-sync/to-remote-sync-file-dto.ts index 642d7fdab0..7ab318b53c 100644 --- a/src/apps/main/remote-sync/to-remote-sync-file-dto.ts +++ b/src/apps/main/remote-sync/to-remote-sync-file-dto.ts @@ -18,4 +18,4 @@ export function toRemoteSyncFileDto(file: DriveFile): RemoteSyncFileDto { status: file.status, uuid: file.uuid, }; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts b/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts index c7025a0e12..a79e59ef4a 100644 --- a/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts +++ b/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts @@ -13,4 +13,4 @@ export function toRemoteSyncFolderDto(folder: DriveFolder): RemoteSyncFolderDto status: folder.status, uuid: folder.uuid, }; -} \ No newline at end of file +} diff --git a/src/apps/main/remote-sync/wait-before-retry.ts b/src/apps/main/remote-sync/wait-before-retry.ts index b6a32dfa84..f4173067b5 100644 --- a/src/apps/main/remote-sync/wait-before-retry.ts +++ b/src/apps/main/remote-sync/wait-before-retry.ts @@ -4,4 +4,4 @@ type Pops = { export async function waitBeforeRetry({ retryCount }: Pops) { await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount)); -} \ No newline at end of file +} diff --git a/src/context/shared/application/sync/remote-sync-service.test.ts b/src/context/shared/application/sync/remote-sync-service.test.ts index 3fca23a17c..3764002f63 100644 --- a/src/context/shared/application/sync/remote-sync-service.test.ts +++ b/src/context/shared/application/sync/remote-sync-service.test.ts @@ -27,4 +27,4 @@ describe('remote-sync-service.test', () => { // Then expect(getRemoteSyncService()).toBe(service); }); -}); \ No newline at end of file +}); diff --git a/src/context/shared/application/sync/remote-sync-service.ts b/src/context/shared/application/sync/remote-sync-service.ts index ddf0f5218c..c903f37454 100644 --- a/src/context/shared/application/sync/remote-sync-service.ts +++ b/src/context/shared/application/sync/remote-sync-service.ts @@ -18,4 +18,4 @@ export function getRemoteSyncService() { } return remoteSyncService; -} \ No newline at end of file +} diff --git a/src/context/shared/application/sync/remote-sync.contract.ts b/src/context/shared/application/sync/remote-sync.contract.ts index 6965e074ab..efa6b3a23b 100644 --- a/src/context/shared/application/sync/remote-sync.contract.ts +++ b/src/context/shared/application/sync/remote-sync.contract.ts @@ -30,4 +30,4 @@ export type RemoteSyncFolderDto = { export type UpdatedRemoteItemsDto = { files: RemoteSyncFileDto[]; folders: RemoteSyncFolderDto[]; -}; \ No newline at end of file +}; From 787729ad3167b19eb259116980c13c49a92f0881 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 6 Apr 2026 18:07:08 -0500 Subject: [PATCH 3/5] feat(remote-sync): refactor error handling and update imports for consistency --- .../patch-drive-file-response-item.ts | 2 +- .../remote-sync-controller/index.test.ts | 2 +- .../remote-sync-controller/types.ts | 2 +- ...t.ts => remote-sync-error-handler.test.ts} | 4 +- ...andler.ts => remote-sync-error-handler.ts} | 40 +++++++++---------- src/apps/main/remote-sync/service.test.ts | 2 +- src/apps/main/remote-sync/service.ts | 2 +- 7 files changed, 27 insertions(+), 27 deletions(-) rename src/apps/main/remote-sync/{create-remote-sync-error-handler.test.ts => remote-sync-error-handler.test.ts} (95%) rename src/apps/main/remote-sync/{create-remote-sync-error-handler.ts => remote-sync-error-handler.ts} (56%) diff --git a/src/apps/main/remote-sync/patch-drive-file-response-item.ts b/src/apps/main/remote-sync/patch-drive-file-response-item.ts index a821b3a998..e92ed0974c 100644 --- a/src/apps/main/remote-sync/patch-drive-file-response-item.ts +++ b/src/apps/main/remote-sync/patch-drive-file-response-item.ts @@ -9,7 +9,7 @@ export function patchDriveFileResponseItem(payload: DriveFileResponseItem) { return { ...payload, fileId: payload.fileId ?? '', - size: typeof payload.size === 'string' ? parseInt(payload.size) : payload.size, + size: typeof payload.size === 'string' ? Number.parseInt(payload.size) : payload.size, name: payload.name ?? undefined, } as RemoteSyncedFile; } diff --git a/src/apps/main/remote-sync/remote-sync-controller/index.test.ts b/src/apps/main/remote-sync/remote-sync-controller/index.test.ts index d19aff8ccc..961d90a78f 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/index.test.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/index.test.ts @@ -13,7 +13,7 @@ vi.mock('../../../../infra/sqlite/services/folder/create-or-update-folder-by-bat import * as uuid from 'uuid'; import { createRemoteSyncController, CreateRemoteSyncControllerPops, RemoteSyncController } from './index'; -import { RemoteSyncErrorHandler } from '../create-remote-sync-error-handler'; +import { RemoteSyncErrorHandler } from '../remote-sync-error-handler'; import { RemoteSyncedFile, RemoteSyncedFolder } from '../helpers'; import { DriveServerError } from '../../../../infra/drive-server/drive-server.error'; import { driveServerClient } from '../../../../infra/drive-server/client/drive-server.client.instance'; diff --git a/src/apps/main/remote-sync/remote-sync-controller/types.ts b/src/apps/main/remote-sync/remote-sync-controller/types.ts index a5e8fc7324..6a6d81bcb6 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/types.ts +++ b/src/apps/main/remote-sync/remote-sync-controller/types.ts @@ -2,7 +2,7 @@ import { SyncConfig, RemoteSyncStatus } from '../helpers'; import { DatabaseCollectionAdapter } from '../../database/adapters/base'; import { DriveFile } from '../../database/entities/DriveFile'; import { DriveFolder } from '../../database/entities/DriveFolder'; -import { RemoteSyncErrorHandler } from '../create-remote-sync-error-handler'; +import { RemoteSyncErrorHandler } from '../remote-sync-error-handler'; export type RemoteSyncControllerConfig = { fetchFilesLimitPerRequest: number; diff --git a/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts b/src/apps/main/remote-sync/remote-sync-error-handler.test.ts similarity index 95% rename from src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts rename to src/apps/main/remote-sync/remote-sync-error-handler.test.ts index 2743b21e47..3bcb67b2f8 100644 --- a/src/apps/main/remote-sync/create-remote-sync-error-handler.test.ts +++ b/src/apps/main/remote-sync/remote-sync-error-handler.test.ts @@ -7,7 +7,7 @@ vi.mock('@internxt/drive-desktop-core/build/backend', () => ({ import { logger } from '@internxt/drive-desktop-core/build/backend'; import * as virtualDriveIssuesModule from '../issues/virtual-drive'; import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper'; -import { createRemoteSyncErrorHandler } from './create-remote-sync-error-handler'; +import { createRemoteSyncErrorHandler } from './remote-sync-error-handler'; import { RemoteSyncError, RemoteSyncInvalidResponseError, @@ -15,7 +15,7 @@ import { RemoteSyncServerError, } from './errors'; -describe('create-remote-sync-error-handler.test', () => { +describe('remote-sync-error-handler.test', () => { const addVirtualDriveIssueMock = partialSpyOn(virtualDriveIssuesModule, 'addVirtualDriveIssue'); beforeEach(() => { diff --git a/src/apps/main/remote-sync/create-remote-sync-error-handler.ts b/src/apps/main/remote-sync/remote-sync-error-handler.ts similarity index 56% rename from src/apps/main/remote-sync/create-remote-sync-error-handler.ts rename to src/apps/main/remote-sync/remote-sync-error-handler.ts index e26cdb4e74..8f0bf5e48e 100644 --- a/src/apps/main/remote-sync/create-remote-sync-error-handler.ts +++ b/src/apps/main/remote-sync/remote-sync-error-handler.ts @@ -14,30 +14,30 @@ export type RemoteSyncErrorHandler = { handleSyncError: (props: HandleSyncErrorPops) => void; }; -export function createRemoteSyncErrorHandler(): RemoteSyncErrorHandler { - function handleSyncError({ error, syncItemType, itemName, itemCheckpoint }: HandleSyncErrorPops) { - const errorDetail = getRemoteSyncErrorDetail({ - error, - syncItemType, - itemName, - }); +export function handleSyncError({ error, syncItemType, itemName, itemCheckpoint }: HandleSyncErrorPops) { + const errorDetail = getRemoteSyncErrorDetail({ + error, + syncItemType, + itemName, + }); - if (!errorDetail) { - return; - } + if (!errorDetail) { + return; + } - logger.error({ - tag: 'SYNC-ENGINE', - msg: `Remote ${syncItemType} sync failed`, - error, - errorLabel: errorDetail.errorLabel, - itemName, - itemCheckpoint, - }); + logger.error({ + tag: 'SYNC-ENGINE', + msg: `Remote ${syncItemType} sync failed`, + error, + errorLabel: errorDetail.errorLabel, + itemName, + itemCheckpoint, + }); - addVirtualDriveIssue(errorDetail.issue); - } + addVirtualDriveIssue(errorDetail.issue); +} +export function createRemoteSyncErrorHandler(): RemoteSyncErrorHandler { return { handleSyncError, }; diff --git a/src/apps/main/remote-sync/service.test.ts b/src/apps/main/remote-sync/service.test.ts index 64f4953588..1d745490df 100644 --- a/src/apps/main/remote-sync/service.test.ts +++ b/src/apps/main/remote-sync/service.test.ts @@ -80,7 +80,7 @@ vi.mock('./remote-sync-controller', () => ({ createRemoteSyncController: mocks.remoteSyncControllerFactory, })); -vi.mock('./create-remote-sync-error-handler', () => ({ +vi.mock('./remote-sync-error-handler', () => ({ createRemoteSyncErrorHandler: vi.fn(() => ({ handleSyncError: vi.fn(), })), diff --git a/src/apps/main/remote-sync/service.ts b/src/apps/main/remote-sync/service.ts index 7ef764c7c2..7dfa98f61e 100644 --- a/src/apps/main/remote-sync/service.ts +++ b/src/apps/main/remote-sync/service.ts @@ -5,7 +5,7 @@ import { DriveFoldersCollection } from '../database/collections/DriveFolderColle import { createRemoteSyncController } from './remote-sync-controller'; import { broadcastToWindows } from '../windows'; import { isInitialSyncReady, setInitialSyncState } from './InitialSyncReady'; -import { createRemoteSyncErrorHandler } from './create-remote-sync-error-handler'; +import { createRemoteSyncErrorHandler } from './remote-sync-error-handler'; import { registerRemoteSyncService } from '../../../context/shared/application/sync/remote-sync-service'; import { toRemoteSyncFileDto } from './to-remote-sync-file-dto'; import { toRemoteSyncFolderDto } from './to-remote-sync-folder-dto'; From 4e1dfb63ec4cbc47ccc1f41e83d370faaf3e4b82 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 27 Apr 2026 17:03:22 -0500 Subject: [PATCH 4/5] feat: implement remote sync controller and related functionalities --- .../main/auth/deeplink/handle-deeplink.ts | 3 ++- src/apps/main/interface.d.ts | 4 ++-- src/apps/main/main.ts | 2 +- src/apps/main/preload.d.ts | 4 ++-- .../renderer/context/SyncContext.test.tsx | 2 +- src/apps/renderer/context/SyncContext.tsx | 2 +- .../renderer/hooks/useOnSyncRunning.test.tsx | 2 +- .../features}/remote-sync/InitialSyncReady.ts | 0 .../features}/remote-sync/errors.ts | 0 .../remote-sync/fetch-remote-files.ts | 24 ++++++++++--------- .../remote-sync/fetch-remote-folders.ts | 22 ++++++++++------- .../get-last-updated-checkpoint.ts | 8 +++---- .../get-remote-sync-error-detail.ts | 0 .../features}/remote-sync/handlers.test.ts | 2 +- .../features}/remote-sync/handlers.ts | 2 +- .../features}/remote-sync/helpers.ts | 0 .../patch-drive-file-response-item.ts | 2 +- .../patch-drive-folder-response-item.ts | 2 +- .../change-status.test.ts | 0 .../remote-sync-controller/change-status.ts | 0 .../check-remote-sync-status.test.ts | 0 .../check-remote-sync-status.ts | 0 .../controller-methods.test.ts | 0 .../controller-methods.ts | 0 .../create-controller-state.test.ts | 0 .../create-controller-state.ts | 0 .../remote-sync-controller/index.test.ts | 15 ++++++------ .../remote-sync-controller/index.ts | 0 .../reset-remote-sync.test.ts | 0 .../reset-remote-sync.ts | 0 .../start-remote-sync.ts | 2 +- .../sync-remote-files.ts | 9 ++++--- .../sync-remote-folders.ts | 9 ++++--- .../remote-sync-controller/types.ts | 6 ++--- .../remote-sync-error-handler.test.ts | 23 +++++++----------- .../remote-sync/remote-sync-error-handler.ts | 2 +- .../remote-sync/resolve-remote-sync-status.ts | 4 ++-- .../features}/remote-sync/service.test.ts | 8 +++---- .../features}/remote-sync/service.ts | 8 +++---- .../remote-sync/sync-remote-items.ts | 14 +++++++---- .../remote-sync/to-remote-sync-file-dto.ts | 2 +- .../remote-sync/to-remote-sync-folder-dto.ts | 2 +- .../remote-sync/wait-before-retry.ts | 4 ++-- .../file/create-or-update-file-by-batch.ts | 2 +- src/infra/sqlite/services/file/parse-data.ts | 2 +- .../create-or-update-folder-by-batch.ts | 2 +- .../sqlite/services/folder/parse-data.ts | 2 +- 47 files changed, 104 insertions(+), 93 deletions(-) rename src/{apps/main => backend/features}/remote-sync/InitialSyncReady.ts (100%) rename src/{apps/main => backend/features}/remote-sync/errors.ts (100%) rename src/{apps/main => backend/features}/remote-sync/fetch-remote-files.ts (59%) rename src/{apps/main => backend/features}/remote-sync/fetch-remote-folders.ts (62%) rename src/{apps/main => backend/features}/remote-sync/get-last-updated-checkpoint.ts (67%) rename src/{apps/main => backend/features}/remote-sync/get-remote-sync-error-detail.ts (100%) rename src/{apps/main => backend/features}/remote-sync/handlers.test.ts (98%) rename src/{apps/main => backend/features}/remote-sync/handlers.ts (95%) rename src/{apps/main => backend/features}/remote-sync/helpers.ts (100%) rename src/{apps/main => backend/features}/remote-sync/patch-drive-file-response-item.ts (94%) rename src/{apps/main => backend/features}/remote-sync/patch-drive-folder-response-item.ts (95%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/change-status.test.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/change-status.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/check-remote-sync-status.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/controller-methods.test.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/controller-methods.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/create-controller-state.test.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/create-controller-state.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/index.test.ts (93%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/index.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/reset-remote-sync.test.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/reset-remote-sync.ts (100%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/start-remote-sync.ts (95%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/sync-remote-files.ts (87%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/sync-remote-folders.ts (87%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-controller/types.ts (84%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-error-handler.test.ts (78%) rename src/{apps/main => backend/features}/remote-sync/remote-sync-error-handler.ts (92%) rename src/{apps/main => backend/features}/remote-sync/resolve-remote-sync-status.ts (91%) rename src/{apps/main => backend/features}/remote-sync/service.test.ts (95%) rename src/{apps/main => backend/features}/remote-sync/service.ts (88%) rename src/{apps/main => backend/features}/remote-sync/sync-remote-items.ts (82%) rename src/{apps/main => backend/features}/remote-sync/to-remote-sync-file-dto.ts (88%) rename src/{apps/main => backend/features}/remote-sync/to-remote-sync-folder-dto.ts (85%) rename src/{apps/main => backend/features}/remote-sync/wait-before-retry.ts (56%) diff --git a/src/apps/main/auth/deeplink/handle-deeplink.ts b/src/apps/main/auth/deeplink/handle-deeplink.ts index b2df9769a3..1ee9d9f02b 100644 --- a/src/apps/main/auth/deeplink/handle-deeplink.ts +++ b/src/apps/main/auth/deeplink/handle-deeplink.ts @@ -8,6 +8,7 @@ import { setupRootFolder } from '../../virtual-root-folder/service'; import { processDeeplink } from './proccess-deeplink'; import { initializeCurrentUser } from './initialize_current_user'; import configStore from '../../config'; +import { PATHS } from '../../../../core/electron/paths'; type Props = { url: string; @@ -34,7 +35,7 @@ export async function handleDeeplink({ url }: Props) { logger.debug({ tag: 'AUTH', msg: 'Config restoration attempt on login', restored, uuid: userData.uuid }); } - await setupRootFolder(); + await setupRootFolder(PATHS.ROOT_DRIVE_FOLDER); setIsLoggedIn(true); diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index 6b62365cd0..0d5f47f6f8 100644 --- a/src/apps/main/interface.d.ts +++ b/src/apps/main/interface.d.ts @@ -157,8 +157,8 @@ export interface IElectronAPI { startRemoteSync(): Promise; getUpdateStatus(): Promise<{ version: string } | null>; onUpdateAvailable(callback: (info: { version: string }) => void): () => void; - getRemoteSyncStatus(): Promise; - onRemoteSyncStatusChange(callback: (status: import('./remote-sync/helpers').RemoteSyncStatus) => void): () => void; + getRemoteSyncStatus(): Promise; + onRemoteSyncStatusChange(callback: (status: import('../../backend/features/remote-sync/helpers').RemoteSyncStatus) => void): () => void; getVirtualDriveStatus(): Promise; onVirtualDriveStatusChange( callback: (event: { status: import('../drive/fuse/FuseDriveStatus').FuseDriveStatus }) => void, diff --git a/src/apps/main/main.ts b/src/apps/main/main.ts index edc18e0cf5..ad9f3a051d 100644 --- a/src/apps/main/main.ts +++ b/src/apps/main/main.ts @@ -27,7 +27,7 @@ import './analytics/handlers'; import './platform/handlers'; import './config/handlers'; import './app-info/handlers'; -import './remote-sync/handlers'; +import '../../backend/features/remote-sync/handlers'; import './../../backend/features/cleaner/ipc/handlers'; import './virtual-drive'; diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index aca484dcf5..cb44a15773 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -149,8 +149,8 @@ declare interface Window { getUsage: () => Promise; - onRemoteSyncStatusChange(callback: (status: import('./remote-sync/helpers').RemoteSyncStatus) => void): () => void; - getRemoteSyncStatus(): Promise; + onRemoteSyncStatusChange(callback: (status: import('../../backend/features/remote-sync/helpers').RemoteSyncStatus) => void): () => void; + getRemoteSyncStatus(): Promise; getVirtualDriveStatus(): Promise; onVirtualDriveStatusChange( callback: (event: { status: import('../drive/fuse/FuseDriveStatus').FuseDriveStatus }) => void, diff --git a/src/apps/renderer/context/SyncContext.test.tsx b/src/apps/renderer/context/SyncContext.test.tsx index 89ef64887d..92a2dc2c66 100644 --- a/src/apps/renderer/context/SyncContext.test.tsx +++ b/src/apps/renderer/context/SyncContext.test.tsx @@ -1,6 +1,6 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { SyncProvider, useSyncContext } from './SyncContext'; -import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; +import { RemoteSyncStatus } from '../../../backend/features/remote-sync/helpers'; import { partialSpyOn } from '../../../../tests/vitest/utils.helper'; describe('SyncContext', () => { diff --git a/src/apps/renderer/context/SyncContext.tsx b/src/apps/renderer/context/SyncContext.tsx index 2d6ffa6bee..9451bce318 100644 --- a/src/apps/renderer/context/SyncContext.tsx +++ b/src/apps/renderer/context/SyncContext.tsx @@ -1,6 +1,6 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { SyncStatus } from '../../../context/desktop/sync/domain/SyncStatus'; -import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; +import { RemoteSyncStatus } from '../../../backend/features/remote-sync/helpers'; const statusesMap: Record = { SYNCING: 'RUNNING', diff --git a/src/apps/renderer/hooks/useOnSyncRunning.test.tsx b/src/apps/renderer/hooks/useOnSyncRunning.test.tsx index 0fae939d0b..23ae7ff675 100644 --- a/src/apps/renderer/hooks/useOnSyncRunning.test.tsx +++ b/src/apps/renderer/hooks/useOnSyncRunning.test.tsx @@ -1,7 +1,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useOnSyncRunning } from './useOnSyncRunning'; import { SyncProvider } from '../context/SyncContext'; -import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; +import { RemoteSyncStatus } from '../../../backend/features/remote-sync/helpers'; import { partialSpyOn } from '../../../../tests/vitest/utils.helper'; describe('useOnSyncRunning', () => { diff --git a/src/apps/main/remote-sync/InitialSyncReady.ts b/src/backend/features/remote-sync/InitialSyncReady.ts similarity index 100% rename from src/apps/main/remote-sync/InitialSyncReady.ts rename to src/backend/features/remote-sync/InitialSyncReady.ts diff --git a/src/apps/main/remote-sync/errors.ts b/src/backend/features/remote-sync/errors.ts similarity index 100% rename from src/apps/main/remote-sync/errors.ts rename to src/backend/features/remote-sync/errors.ts diff --git a/src/apps/main/remote-sync/fetch-remote-files.ts b/src/backend/features/remote-sync/fetch-remote-files.ts similarity index 59% rename from src/apps/main/remote-sync/fetch-remote-files.ts rename to src/backend/features/remote-sync/fetch-remote-files.ts index 48ebc45478..b5916eaef0 100644 --- a/src/apps/main/remote-sync/fetch-remote-files.ts +++ b/src/backend/features/remote-sync/fetch-remote-files.ts @@ -1,17 +1,19 @@ -import { RemoteSyncNetworkError } from './errors'; import { RemoteSyncedFile } from './helpers'; import { patchDriveFileResponseItem } from './patch-drive-file-response-item'; import { fetchFiles } from '../../../infra/drive-server/services/files/services/fetch-files'; +import { Result } from '../../../context/shared/domain/Result'; -type Pops = { +type Props = { limit: number; updatedAtCheckpoint?: Date; }; -export async function fetchRemoteFiles({ limit, updatedAtCheckpoint }: Pops): Promise<{ +type FetchFilesResponse = { hasMore: boolean; - result: RemoteSyncedFile[]; -}> { + files: RemoteSyncedFile[]; +}; + +export async function fetchRemoteFiles({ limit, updatedAtCheckpoint }: Props): Promise> { const { data, error } = await fetchFiles({ limit, offset: 0, @@ -19,12 +21,12 @@ export async function fetchRemoteFiles({ limit, updatedAtCheckpoint }: Pops): Pr updatedAt: updatedAtCheckpoint?.toISOString(), }); - if (error) { - throw new RemoteSyncNetworkError(error.message, undefined, error.statusCode); - } + if (error) return { error }; - return { - hasMore: data.hasMore, - result: data.files.map(patchDriveFileResponseItem), + return { data: + { + hasMore: data.hasMore, + files: data.files.map(patchDriveFileResponseItem) + } }; } diff --git a/src/apps/main/remote-sync/fetch-remote-folders.ts b/src/backend/features/remote-sync/fetch-remote-folders.ts similarity index 62% rename from src/apps/main/remote-sync/fetch-remote-folders.ts rename to src/backend/features/remote-sync/fetch-remote-folders.ts index c6ac98d9d8..c6d1982806 100644 --- a/src/apps/main/remote-sync/fetch-remote-folders.ts +++ b/src/backend/features/remote-sync/fetch-remote-folders.ts @@ -2,16 +2,20 @@ import { RemoteSyncNetworkError } from './errors'; import { RemoteSyncedFolder } from './helpers'; import { patchDriveFolderResponseItem } from './patch-drive-folder-response-item'; import { fetchFolders } from '../../../infra/drive-server/services/folder/services/fetch-folders'; +import { Result } from '../../../context/shared/domain/Result'; -type Pops = { +type Props = { limit: number; updatedAtCheckpoint?: Date; }; -export async function fetchRemoteFolders({ limit, updatedAtCheckpoint }: Pops): Promise<{ +type FetchFoldersResponse = { hasMore: boolean; - result: RemoteSyncedFolder[]; -}> { + folders: RemoteSyncedFolder[]; +}; + +export async function fetchRemoteFolders({ limit, updatedAtCheckpoint }: Props): Promise> { + const { data, error } = await fetchFolders({ limit, offset: 0, @@ -19,12 +23,12 @@ export async function fetchRemoteFolders({ limit, updatedAtCheckpoint }: Pops): updatedAt: updatedAtCheckpoint?.toISOString(), }); - if (error) { - throw new RemoteSyncNetworkError(error.message, undefined, error.statusCode); - } + if (error) return { error }; return { - hasMore: data.hasMore, - result: data.folders.map(patchDriveFolderResponseItem), + data: { + hasMore: data.hasMore, + folders: data.folders.map(patchDriveFolderResponseItem), + }, }; } diff --git a/src/apps/main/remote-sync/get-last-updated-checkpoint.ts b/src/backend/features/remote-sync/get-last-updated-checkpoint.ts similarity index 67% rename from src/apps/main/remote-sync/get-last-updated-checkpoint.ts rename to src/backend/features/remote-sync/get-last-updated-checkpoint.ts index 6a25a7eb80..68725e45a1 100644 --- a/src/apps/main/remote-sync/get-last-updated-checkpoint.ts +++ b/src/backend/features/remote-sync/get-last-updated-checkpoint.ts @@ -1,12 +1,12 @@ import { rewind } from './helpers'; -import { DatabaseCollectionAdapter } from '../database/adapters/base'; -import { Nullable } from '../../shared/types/Nullable'; +import { DatabaseCollectionAdapter } from '../../../apps/main/database/adapters/base'; +import { Nullable } from '../../../apps/shared/types/Nullable'; type DatabaseItemWithUpdatedAt = { updatedAt: string; }; -type Pops = { +type Props = { collection: DatabaseCollectionAdapter; rewindMilliseconds: number; }; @@ -14,7 +14,7 @@ type Pops = { export async function getLastUpdatedCheckpoint({ collection, rewindMilliseconds, -}: Pops): Promise> { +}: Props): Promise> { const { success, result } = await collection.getLastUpdated(); if (!success || !result) { diff --git a/src/apps/main/remote-sync/get-remote-sync-error-detail.ts b/src/backend/features/remote-sync/get-remote-sync-error-detail.ts similarity index 100% rename from src/apps/main/remote-sync/get-remote-sync-error-detail.ts rename to src/backend/features/remote-sync/get-remote-sync-error-detail.ts diff --git a/src/apps/main/remote-sync/handlers.test.ts b/src/backend/features/remote-sync/handlers.test.ts similarity index 98% rename from src/apps/main/remote-sync/handlers.test.ts rename to src/backend/features/remote-sync/handlers.test.ts index 50886db946..29e0779af1 100644 --- a/src/apps/main/remote-sync/handlers.test.ts +++ b/src/backend/features/remote-sync/handlers.test.ts @@ -25,7 +25,7 @@ vi.mock('./service', () => ({ async function loadHandlersModule() { vi.resetModules(); - const eventBusModule = await import('../event-bus'); + const eventBusModule = await import('../../../apps/main/event-bus'); const initialSyncReadyModule = await import('./InitialSyncReady'); const eventBusOnMock = partialSpyOn(eventBusModule.default, 'on', false); diff --git a/src/apps/main/remote-sync/handlers.ts b/src/backend/features/remote-sync/handlers.ts similarity index 95% rename from src/apps/main/remote-sync/handlers.ts rename to src/backend/features/remote-sync/handlers.ts index 4d085da3bf..3c67ad37c9 100644 --- a/src/apps/main/remote-sync/handlers.ts +++ b/src/backend/features/remote-sync/handlers.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron'; import { logger } from '@internxt/drive-desktop-core/build/backend'; -import eventBus from '../event-bus'; +import eventBus from '../../../apps/main/event-bus'; import { setInitialSyncState } from './InitialSyncReady'; import { remoteSyncController, resyncRemoteSync, startRemoteSync } from './service'; diff --git a/src/apps/main/remote-sync/helpers.ts b/src/backend/features/remote-sync/helpers.ts similarity index 100% rename from src/apps/main/remote-sync/helpers.ts rename to src/backend/features/remote-sync/helpers.ts diff --git a/src/apps/main/remote-sync/patch-drive-file-response-item.ts b/src/backend/features/remote-sync/patch-drive-file-response-item.ts similarity index 94% rename from src/apps/main/remote-sync/patch-drive-file-response-item.ts rename to src/backend/features/remote-sync/patch-drive-file-response-item.ts index e92ed0974c..efe4fb0e94 100644 --- a/src/apps/main/remote-sync/patch-drive-file-response-item.ts +++ b/src/backend/features/remote-sync/patch-drive-file-response-item.ts @@ -11,5 +11,5 @@ export function patchDriveFileResponseItem(payload: DriveFileResponseItem) { fileId: payload.fileId ?? '', size: typeof payload.size === 'string' ? Number.parseInt(payload.size) : payload.size, name: payload.name ?? undefined, - } as RemoteSyncedFile; + }; } diff --git a/src/apps/main/remote-sync/patch-drive-folder-response-item.ts b/src/backend/features/remote-sync/patch-drive-folder-response-item.ts similarity index 95% rename from src/apps/main/remote-sync/patch-drive-folder-response-item.ts rename to src/backend/features/remote-sync/patch-drive-folder-response-item.ts index af35cb1e38..686c46f56a 100644 --- a/src/apps/main/remote-sync/patch-drive-folder-response-item.ts +++ b/src/backend/features/remote-sync/patch-drive-folder-response-item.ts @@ -24,5 +24,5 @@ export function patchDriveFolderResponseItem(payload: DriveFolderResponseItem) { ...payload, status, name: payload.name ?? undefined, - } as RemoteSyncedFolder; + }; } diff --git a/src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts b/src/backend/features/remote-sync/remote-sync-controller/change-status.test.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/change-status.test.ts rename to src/backend/features/remote-sync/remote-sync-controller/change-status.test.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/change-status.ts b/src/backend/features/remote-sync/remote-sync-controller/change-status.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/change-status.ts rename to src/backend/features/remote-sync/remote-sync-controller/change-status.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts b/src/backend/features/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts rename to src/backend/features/remote-sync/remote-sync-controller/check-remote-sync-status.test.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.ts b/src/backend/features/remote-sync/remote-sync-controller/check-remote-sync-status.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/check-remote-sync-status.ts rename to src/backend/features/remote-sync/remote-sync-controller/check-remote-sync-status.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts b/src/backend/features/remote-sync/remote-sync-controller/controller-methods.test.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/controller-methods.test.ts rename to src/backend/features/remote-sync/remote-sync-controller/controller-methods.test.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts b/src/backend/features/remote-sync/remote-sync-controller/controller-methods.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/controller-methods.ts rename to src/backend/features/remote-sync/remote-sync-controller/controller-methods.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts b/src/backend/features/remote-sync/remote-sync-controller/create-controller-state.test.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/create-controller-state.test.ts rename to src/backend/features/remote-sync/remote-sync-controller/create-controller-state.test.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/create-controller-state.ts b/src/backend/features/remote-sync/remote-sync-controller/create-controller-state.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/create-controller-state.ts rename to src/backend/features/remote-sync/remote-sync-controller/create-controller-state.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/index.test.ts b/src/backend/features/remote-sync/remote-sync-controller/index.test.ts similarity index 93% rename from src/apps/main/remote-sync/remote-sync-controller/index.test.ts rename to src/backend/features/remote-sync/remote-sync-controller/index.test.ts index 961d90a78f..ba538c3d5b 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/index.test.ts +++ b/src/backend/features/remote-sync/remote-sync-controller/index.test.ts @@ -16,10 +16,11 @@ import { createRemoteSyncController, CreateRemoteSyncControllerPops, RemoteSyncC import { RemoteSyncErrorHandler } from '../remote-sync-error-handler'; import { RemoteSyncedFile, RemoteSyncedFolder } from '../helpers'; import { DriveServerError } from '../../../../infra/drive-server/drive-server.error'; +import { RemoteSyncServerError } from '../errors'; import { driveServerClient } from '../../../../infra/drive-server/client/drive-server.client.instance'; -import { DatabaseCollectionAdapter } from '../../database/adapters/base'; -import { DriveFile } from '../../database/entities/DriveFile'; -import { DriveFolder } from '../../database/entities/DriveFolder'; +import { DatabaseCollectionAdapter } from '../../../../apps/main/database/adapters/base'; +import { DriveFile } from '../../../../apps/main/database/entities/DriveFile'; +import { DriveFolder } from '../../../../apps/main/database/entities/DriveFolder'; import { createOrUpdateFileByBatch } from '../../../../infra/sqlite/services/file/create-or-update-file-by-batch'; import { createOrUpdateFolderByBatch } from '../../../../infra/sqlite/services/folder/create-or-update-folder-by-batch'; @@ -113,7 +114,7 @@ describe('index.test', () => { }; sut = createRemoteSyncController(); - mockedGet.mockClear(); + mockedGet.mockReset(); mockedCreateOrUpdateFileByBatch.mockClear(); mockedCreateOrUpdateFolderByBatch.mockClear(); }); @@ -254,7 +255,7 @@ describe('index.test', () => { it('Should fail the sync if some files or folders cannot be retrieved', async () => { // Given - mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); + mockedGet.mockResolvedValueOnce({ error: new RemoteSyncServerError(500, { message: 'Fail on purpose' }) }); // When await sut.startRemoteSync(props); @@ -274,7 +275,7 @@ describe('index.test', () => { syncFolders: false, }, }; - mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); + mockedGet.mockResolvedValueOnce({ error: new RemoteSyncServerError(500, { message: 'Fail on purpose' }) }); // When await sut.startRemoteSync(syncProps); @@ -297,7 +298,7 @@ describe('index.test', () => { }, }; - mockedGet.mockResolvedValueOnce({ error: new DriveServerError('UNKNOWN', undefined, 'Fail on purpose') }); + mockedGet.mockResolvedValueOnce({ error: new RemoteSyncServerError(500, { message: 'Fail on purpose' }) }); // When await sut.startRemoteSync(syncProps); diff --git a/src/apps/main/remote-sync/remote-sync-controller/index.ts b/src/backend/features/remote-sync/remote-sync-controller/index.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/index.ts rename to src/backend/features/remote-sync/remote-sync-controller/index.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts b/src/backend/features/remote-sync/remote-sync-controller/reset-remote-sync.test.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.test.ts rename to src/backend/features/remote-sync/remote-sync-controller/reset-remote-sync.test.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.ts b/src/backend/features/remote-sync/remote-sync-controller/reset-remote-sync.ts similarity index 100% rename from src/apps/main/remote-sync/remote-sync-controller/reset-remote-sync.ts rename to src/backend/features/remote-sync/remote-sync-controller/reset-remote-sync.ts diff --git a/src/apps/main/remote-sync/remote-sync-controller/start-remote-sync.ts b/src/backend/features/remote-sync/remote-sync-controller/start-remote-sync.ts similarity index 95% rename from src/apps/main/remote-sync/remote-sync-controller/start-remote-sync.ts rename to src/backend/features/remote-sync/remote-sync-controller/start-remote-sync.ts index 65bf6761aa..a9d059d141 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/start-remote-sync.ts +++ b/src/backend/features/remote-sync/remote-sync-controller/start-remote-sync.ts @@ -12,7 +12,7 @@ export async function startRemoteSync({ state, db, config, errorHandler }: Start if (state.status === 'SYNCING') { logger.warn({ tag: 'SYNC-ENGINE', - msg: 'Remote sync controller should not be in SYNCING status to start, not starting again', + msg: 'Remote sync controller is already in SYNCING status, not starting again', }); return; diff --git a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts b/src/backend/features/remote-sync/remote-sync-controller/sync-remote-files.ts similarity index 87% rename from src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts rename to src/backend/features/remote-sync/remote-sync-controller/sync-remote-files.ts index 2a49f76f4f..852efaa43d 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-files.ts +++ b/src/backend/features/remote-sync/remote-sync-controller/sync-remote-files.ts @@ -17,11 +17,14 @@ export async function syncRemoteFiles({ state, db, config, errorHandler, syncCon collection: db.files, rewindMilliseconds: SIX_HOURS_IN_MILLISECONDS, }), - fetchRemoteItems: (updatedAtCheckpoint) => - fetchRemoteFiles({ + fetchRemoteItems: async (updatedAtCheckpoint) => { + const { error, data } = await fetchRemoteFiles({ limit: config.fetchFilesLimitPerRequest, updatedAtCheckpoint, - }), + }); + if (error) return { error }; + return { data: { hasMore: data.hasMore, result: data.files } }; + }, persistRemoteItems: (items) => createOrUpdateFileByBatch({ files: items }), onSyncFailed: () => { state.filesSyncStatus = 'SYNC_FAILED'; diff --git a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts b/src/backend/features/remote-sync/remote-sync-controller/sync-remote-folders.ts similarity index 87% rename from src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts rename to src/backend/features/remote-sync/remote-sync-controller/sync-remote-folders.ts index 545b58eaff..ddfc5ea21e 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/sync-remote-folders.ts +++ b/src/backend/features/remote-sync/remote-sync-controller/sync-remote-folders.ts @@ -17,11 +17,14 @@ export async function syncRemoteFolders({ state, db, config, errorHandler, syncC collection: db.folders, rewindMilliseconds: SIX_HOURS_IN_MILLISECONDS, }), - fetchRemoteItems: (updatedAtCheckpoint) => - fetchRemoteFolders({ + fetchRemoteItems: async (updatedAtCheckpoint) =>{ + const { error, data } = await fetchRemoteFolders({ limit: config.fetchFoldersLimitPerRequest, updatedAtCheckpoint, - }), + }); + if (error) return { error }; + return { data: { hasMore: data.hasMore, result: data.folders } }; + }, persistRemoteItems: (items) => createOrUpdateFolderByBatch({ folders: items }), onSyncFailed: () => { state.foldersSyncStatus = 'SYNC_FAILED'; diff --git a/src/apps/main/remote-sync/remote-sync-controller/types.ts b/src/backend/features/remote-sync/remote-sync-controller/types.ts similarity index 84% rename from src/apps/main/remote-sync/remote-sync-controller/types.ts rename to src/backend/features/remote-sync/remote-sync-controller/types.ts index 6a6d81bcb6..5ab968210e 100644 --- a/src/apps/main/remote-sync/remote-sync-controller/types.ts +++ b/src/backend/features/remote-sync/remote-sync-controller/types.ts @@ -1,7 +1,7 @@ import { SyncConfig, RemoteSyncStatus } from '../helpers'; -import { DatabaseCollectionAdapter } from '../../database/adapters/base'; -import { DriveFile } from '../../database/entities/DriveFile'; -import { DriveFolder } from '../../database/entities/DriveFolder'; +import { DatabaseCollectionAdapter } from '../../../../apps/main/database/adapters/base'; +import { DriveFile } from '../../../../apps/main/database/entities/DriveFile'; +import { DriveFolder } from '../../../../apps/main/database/entities/DriveFolder'; import { RemoteSyncErrorHandler } from '../remote-sync-error-handler'; export type RemoteSyncControllerConfig = { diff --git a/src/apps/main/remote-sync/remote-sync-error-handler.test.ts b/src/backend/features/remote-sync/remote-sync-error-handler.test.ts similarity index 78% rename from src/apps/main/remote-sync/remote-sync-error-handler.test.ts rename to src/backend/features/remote-sync/remote-sync-error-handler.test.ts index 3bcb67b2f8..0c81bad5d8 100644 --- a/src/apps/main/remote-sync/remote-sync-error-handler.test.ts +++ b/src/backend/features/remote-sync/remote-sync-error-handler.test.ts @@ -1,12 +1,5 @@ -vi.mock('@internxt/drive-desktop-core/build/backend', () => ({ - logger: { - error: vi.fn(), - }, -})); - -import { logger } from '@internxt/drive-desktop-core/build/backend'; -import * as virtualDriveIssuesModule from '../issues/virtual-drive'; -import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper'; +import * as virtualDriveIssuesModule from '../../../apps/main/issues/virtual-drive'; +import { call, calls, partialSpyOn } from '../../../../tests/vitest/utils.helper'; import { createRemoteSyncErrorHandler } from './remote-sync-error-handler'; import { RemoteSyncError, @@ -14,13 +7,13 @@ import { RemoteSyncNetworkError, RemoteSyncServerError, } from './errors'; +import { loggerMock } from 'tests/vitest/mocks.helper'; describe('remote-sync-error-handler.test', () => { const addVirtualDriveIssueMock = partialSpyOn(virtualDriveIssuesModule, 'addVirtualDriveIssue'); beforeEach(() => { addVirtualDriveIssueMock.mockReset(); - vi.mocked(logger.error).mockReset(); }); it('should add a no-internet issue for file network errors', () => { @@ -40,7 +33,7 @@ describe('remote-sync-error-handler.test', () => { cause: 'NO_INTERNET', name: 'Test File', }); - expect(logger.error).toHaveBeenCalledTimes(1); + calls(loggerMock.error).toHaveLength(1); }); it('should add a remote-connection issue for folder server errors', () => { @@ -61,7 +54,7 @@ describe('remote-sync-error-handler.test', () => { cause: 'NO_REMOTE_CONNECTION', name: 'Test Folder', }); - expect(logger.error).toHaveBeenCalledTimes(1); + calls(loggerMock.error).toHaveLength(1); }); it('should ignore invalid response errors', () => { @@ -76,8 +69,8 @@ describe('remote-sync-error-handler.test', () => { }); // Then - expect(addVirtualDriveIssueMock).not.toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); + calls(addVirtualDriveIssueMock).toHaveLength(0); + calls(loggerMock.error).toHaveLength(0); }); it('should treat generic remote sync errors as remote connection issues', () => { @@ -97,6 +90,6 @@ describe('remote-sync-error-handler.test', () => { cause: 'NO_REMOTE_CONNECTION', name: 'Another File', }); - expect(logger.error).toHaveBeenCalledTimes(1); + calls(loggerMock.error).toHaveLength(1); }); }); diff --git a/src/apps/main/remote-sync/remote-sync-error-handler.ts b/src/backend/features/remote-sync/remote-sync-error-handler.ts similarity index 92% rename from src/apps/main/remote-sync/remote-sync-error-handler.ts rename to src/backend/features/remote-sync/remote-sync-error-handler.ts index 8f0bf5e48e..667e1c9f3e 100644 --- a/src/apps/main/remote-sync/remote-sync-error-handler.ts +++ b/src/backend/features/remote-sync/remote-sync-error-handler.ts @@ -1,5 +1,5 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { addVirtualDriveIssue } from '../issues/virtual-drive'; +import { addVirtualDriveIssue } from '../../../apps/main/issues/virtual-drive'; import { RemoteSyncError } from './errors'; import { getRemoteSyncErrorDetail, RemoteSyncItemType } from './get-remote-sync-error-detail'; diff --git a/src/apps/main/remote-sync/resolve-remote-sync-status.ts b/src/backend/features/remote-sync/resolve-remote-sync-status.ts similarity index 91% rename from src/apps/main/remote-sync/resolve-remote-sync-status.ts rename to src/backend/features/remote-sync/resolve-remote-sync-status.ts index 21c57ba09a..0a3ecf5461 100644 --- a/src/apps/main/remote-sync/resolve-remote-sync-status.ts +++ b/src/backend/features/remote-sync/resolve-remote-sync-status.ts @@ -1,13 +1,13 @@ import { RemoteSyncStatus } from './helpers'; -type Pops = { +type Props = { filesSyncStatus: RemoteSyncStatus; foldersSyncStatus: RemoteSyncStatus; syncFiles: boolean; syncFolders: boolean; }; -export function resolveRemoteSyncStatus({ filesSyncStatus, foldersSyncStatus, syncFiles, syncFolders }: Pops) { +export function resolveRemoteSyncStatus({ filesSyncStatus, foldersSyncStatus, syncFiles, syncFolders }: Props) { if (syncFiles && !syncFolders && filesSyncStatus === 'SYNCED') { return 'SYNCED' as const; } diff --git a/src/apps/main/remote-sync/service.test.ts b/src/backend/features/remote-sync/service.test.ts similarity index 95% rename from src/apps/main/remote-sync/service.test.ts rename to src/backend/features/remote-sync/service.test.ts index 1d745490df..b2faa3404a 100644 --- a/src/apps/main/remote-sync/service.test.ts +++ b/src/backend/features/remote-sync/service.test.ts @@ -68,11 +68,11 @@ vi.mock('lodash', () => ({ debounce: mocks.debounce, })); -vi.mock('../database/collections/DriveFileCollection', () => ({ +vi.mock('../../../apps/main/database/collections/DriveFileCollection', () => ({ DriveFilesCollection: vi.fn(() => mocks.filesCollection), })); -vi.mock('../database/collections/DriveFolderCollection', () => ({ +vi.mock('../../../apps/main/database/collections/DriveFolderCollection', () => ({ DriveFoldersCollection: vi.fn(() => mocks.foldersCollection), })); @@ -91,9 +91,9 @@ type ServiceModule = typeof import('./service'); async function loadServiceModule() { vi.resetModules(); - const eventBusModule = await import('../event-bus'); + const eventBusModule = await import('../../../apps/main/event-bus'); const initialSyncReadyModule = await import('./InitialSyncReady'); - const windowsModule = await import('../windows'); + const windowsModule = await import('../../../apps/main/windows'); const eventBusEmitMock = partialSpyOn(eventBusModule.default, 'emit'); const broadcastToWindowsMock = partialSpyOn(windowsModule, 'broadcastToWindows'); diff --git a/src/apps/main/remote-sync/service.ts b/src/backend/features/remote-sync/service.ts similarity index 88% rename from src/apps/main/remote-sync/service.ts rename to src/backend/features/remote-sync/service.ts index 7dfa98f61e..e96e984ac1 100644 --- a/src/apps/main/remote-sync/service.ts +++ b/src/backend/features/remote-sync/service.ts @@ -1,9 +1,9 @@ import { debounce } from 'lodash'; -import eventBus from '../event-bus'; -import { DriveFilesCollection } from '../database/collections/DriveFileCollection'; -import { DriveFoldersCollection } from '../database/collections/DriveFolderCollection'; +import eventBus from '../../../apps/main/event-bus'; +import { DriveFilesCollection } from '../../../apps/main/database/collections/DriveFileCollection'; +import { DriveFoldersCollection } from '../../../apps/main/database/collections/DriveFolderCollection'; import { createRemoteSyncController } from './remote-sync-controller'; -import { broadcastToWindows } from '../windows'; +import { broadcastToWindows } from '../../../apps/main/windows'; import { isInitialSyncReady, setInitialSyncState } from './InitialSyncReady'; import { createRemoteSyncErrorHandler } from './remote-sync-error-handler'; import { registerRemoteSyncService } from '../../../context/shared/application/sync/remote-sync-service'; diff --git a/src/apps/main/remote-sync/sync-remote-items.ts b/src/backend/features/remote-sync/sync-remote-items.ts similarity index 82% rename from src/apps/main/remote-sync/sync-remote-items.ts rename to src/backend/features/remote-sync/sync-remote-items.ts index 9d8eefb6a9..cf4efeb381 100644 --- a/src/apps/main/remote-sync/sync-remote-items.ts +++ b/src/backend/features/remote-sync/sync-remote-items.ts @@ -1,21 +1,22 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { Nullable } from '../../shared/types/Nullable'; +import { Nullable } from '../../../apps/shared/types/Nullable'; import { RemoteSyncError } from './errors'; import { SyncConfig } from './helpers'; import { waitBeforeRetry } from './wait-before-retry'; +import { Result } from '../../../context/shared/domain/Result'; type RemoteSyncItem = { updatedAt: string; name?: string; }; -type Pops = { +type Props = { from?: Date; finishMessage: string; syncConfig: SyncConfig; syncItemType: 'files' | 'folders'; getCheckpoint: () => Promise>; - fetchRemoteItems: (updatedAtCheckpoint?: Date) => Promise<{ hasMore: boolean; result: TItem[] }>; + fetchRemoteItems: (updatedAtCheckpoint?: Date) => Promise>; persistRemoteItems: (items: TItem[]) => Promise; onSyncFailed: () => void; onSyncFinished: () => void; @@ -37,7 +38,7 @@ export async function syncRemoteItems({ onSyncProgress, onSyncStateChanged, handleSyncError, -}: Pops) { +}: Props) { let checkpoint = from ?? (await getCheckpoint()); let hasMore = true; let retryCount = 0; @@ -46,7 +47,10 @@ export async function syncRemoteItems({ let lastSyncedItem: TItem | null = null; try { - const { hasMore: moreAvailable, result } = await fetchRemoteItems(checkpoint); + const { error, data } = await fetchRemoteItems(checkpoint); + if (error) throw error; + + const { hasMore: moreAvailable, result } = data; await persistRemoteItems(result); onSyncProgress(result); diff --git a/src/apps/main/remote-sync/to-remote-sync-file-dto.ts b/src/backend/features/remote-sync/to-remote-sync-file-dto.ts similarity index 88% rename from src/apps/main/remote-sync/to-remote-sync-file-dto.ts rename to src/backend/features/remote-sync/to-remote-sync-file-dto.ts index 7ab318b53c..ff8a12ee32 100644 --- a/src/apps/main/remote-sync/to-remote-sync-file-dto.ts +++ b/src/backend/features/remote-sync/to-remote-sync-file-dto.ts @@ -1,4 +1,4 @@ -import { DriveFile } from '../database/entities/DriveFile'; +import { DriveFile } from '../../../apps/main/database/entities/DriveFile'; import { RemoteSyncFileDto } from '../../../context/shared/application/sync/remote-sync.contract'; export function toRemoteSyncFileDto(file: DriveFile): RemoteSyncFileDto { diff --git a/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts b/src/backend/features/remote-sync/to-remote-sync-folder-dto.ts similarity index 85% rename from src/apps/main/remote-sync/to-remote-sync-folder-dto.ts rename to src/backend/features/remote-sync/to-remote-sync-folder-dto.ts index a79e59ef4a..1482d0e4c1 100644 --- a/src/apps/main/remote-sync/to-remote-sync-folder-dto.ts +++ b/src/backend/features/remote-sync/to-remote-sync-folder-dto.ts @@ -1,4 +1,4 @@ -import { DriveFolder } from '../database/entities/DriveFolder'; +import { DriveFolder } from '../../../apps/main/database/entities/DriveFolder'; import { RemoteSyncFolderDto } from '../../../context/shared/application/sync/remote-sync.contract'; export function toRemoteSyncFolderDto(folder: DriveFolder): RemoteSyncFolderDto { diff --git a/src/apps/main/remote-sync/wait-before-retry.ts b/src/backend/features/remote-sync/wait-before-retry.ts similarity index 56% rename from src/apps/main/remote-sync/wait-before-retry.ts rename to src/backend/features/remote-sync/wait-before-retry.ts index f4173067b5..f94c6ab1ff 100644 --- a/src/apps/main/remote-sync/wait-before-retry.ts +++ b/src/backend/features/remote-sync/wait-before-retry.ts @@ -1,7 +1,7 @@ -type Pops = { +type Props = { retryCount: number; }; -export async function waitBeforeRetry({ retryCount }: Pops) { +export async function waitBeforeRetry({ retryCount }: Props) { await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount)); } diff --git a/src/infra/sqlite/services/file/create-or-update-file-by-batch.ts b/src/infra/sqlite/services/file/create-or-update-file-by-batch.ts index 221897d4c1..26feaf6bdf 100644 --- a/src/infra/sqlite/services/file/create-or-update-file-by-batch.ts +++ b/src/infra/sqlite/services/file/create-or-update-file-by-batch.ts @@ -2,7 +2,7 @@ import { fileRepository } from '../drive-file'; import { logger } from '@internxt/drive-desktop-core/build/backend'; import { parseData } from './parse-data'; import { SqliteError } from '../common/sqlite-error'; -import { RemoteSyncedFile } from 'src/apps/main/remote-sync/helpers'; +import { RemoteSyncedFile } from 'src/backend/features/remote-sync/helpers'; const BATCH_SIZE = 500; diff --git a/src/infra/sqlite/services/file/parse-data.ts b/src/infra/sqlite/services/file/parse-data.ts index 9b64a510af..f1594465b0 100644 --- a/src/infra/sqlite/services/file/parse-data.ts +++ b/src/infra/sqlite/services/file/parse-data.ts @@ -1,4 +1,4 @@ -import { RemoteSyncedFile } from 'src/apps/main/remote-sync/helpers'; +import { RemoteSyncedFile } from 'src/backend/features/remote-sync/helpers'; import { FileUuid, SimpleDriveFile, ContentsId } from '../../../../apps/main/database/entities/DriveFile'; type TProps = { diff --git a/src/infra/sqlite/services/folder/create-or-update-folder-by-batch.ts b/src/infra/sqlite/services/folder/create-or-update-folder-by-batch.ts index 6debdfa3c5..3c95f9db09 100644 --- a/src/infra/sqlite/services/folder/create-or-update-folder-by-batch.ts +++ b/src/infra/sqlite/services/folder/create-or-update-folder-by-batch.ts @@ -2,7 +2,7 @@ import { folderRepository } from '../drive-folder'; import { logger } from '@internxt/drive-desktop-core/build/backend'; import { parseData } from './parse-data'; import { SqliteError } from '../common/sqlite-error'; -import { RemoteSyncedFolder } from 'src/apps/main/remote-sync/helpers'; +import { RemoteSyncedFolder } from 'src/backend/features/remote-sync/helpers'; const BATCH_SIZE = 500; diff --git a/src/infra/sqlite/services/folder/parse-data.ts b/src/infra/sqlite/services/folder/parse-data.ts index 43409909e2..5e99ea5fd0 100644 --- a/src/infra/sqlite/services/folder/parse-data.ts +++ b/src/infra/sqlite/services/folder/parse-data.ts @@ -1,4 +1,4 @@ -import { RemoteSyncedFolder } from 'src/apps/main/remote-sync/helpers'; +import { RemoteSyncedFolder } from 'src/backend/features/remote-sync/helpers'; import { FolderUuid, SimpleDriveFolder } from '../../../../apps/main/database/entities/DriveFolder'; type TProps = { From fa79e4541a78efafa4431c4e4cc8551b020251d8 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Tue, 5 May 2026 16:58:57 -0500 Subject: [PATCH 5/5] refactor: improve code formatting and readability in remote sync files --- src/apps/main/interface.d.ts | 4 +++- src/apps/main/preload.d.ts | 4 +++- .../features/remote-sync/fetch-remote-files.ts | 13 ++++++++----- .../features/remote-sync/fetch-remote-folders.ts | 6 ++++-- .../remote-sync-controller/sync-remote-folders.ts | 2 +- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index 0d5f47f6f8..0389b4dae6 100644 --- a/src/apps/main/interface.d.ts +++ b/src/apps/main/interface.d.ts @@ -158,7 +158,9 @@ export interface IElectronAPI { getUpdateStatus(): Promise<{ version: string } | null>; onUpdateAvailable(callback: (info: { version: string }) => void): () => void; getRemoteSyncStatus(): Promise; - onRemoteSyncStatusChange(callback: (status: import('../../backend/features/remote-sync/helpers').RemoteSyncStatus) => void): () => void; + onRemoteSyncStatusChange( + callback: (status: import('../../backend/features/remote-sync/helpers').RemoteSyncStatus) => void, + ): () => void; getVirtualDriveStatus(): Promise; onVirtualDriveStatusChange( callback: (event: { status: import('../drive/fuse/FuseDriveStatus').FuseDriveStatus }) => void, diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index cb44a15773..1ceb636c31 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -149,7 +149,9 @@ declare interface Window { getUsage: () => Promise; - onRemoteSyncStatusChange(callback: (status: import('../../backend/features/remote-sync/helpers').RemoteSyncStatus) => void): () => void; + onRemoteSyncStatusChange( + callback: (status: import('../../backend/features/remote-sync/helpers').RemoteSyncStatus) => void, + ): () => void; getRemoteSyncStatus(): Promise; getVirtualDriveStatus(): Promise; onVirtualDriveStatusChange( diff --git a/src/backend/features/remote-sync/fetch-remote-files.ts b/src/backend/features/remote-sync/fetch-remote-files.ts index b5916eaef0..b02381ab2b 100644 --- a/src/backend/features/remote-sync/fetch-remote-files.ts +++ b/src/backend/features/remote-sync/fetch-remote-files.ts @@ -13,7 +13,10 @@ type FetchFilesResponse = { files: RemoteSyncedFile[]; }; -export async function fetchRemoteFiles({ limit, updatedAtCheckpoint }: Props): Promise> { +export async function fetchRemoteFiles({ + limit, + updatedAtCheckpoint, +}: Props): Promise> { const { data, error } = await fetchFiles({ limit, offset: 0, @@ -23,10 +26,10 @@ export async function fetchRemoteFiles({ limit, updatedAtCheckpoint }: Props): P if (error) return { error }; - return { data: - { + return { + data: { hasMore: data.hasMore, - files: data.files.map(patchDriveFileResponseItem) - } + files: data.files.map(patchDriveFileResponseItem), + }, }; } diff --git a/src/backend/features/remote-sync/fetch-remote-folders.ts b/src/backend/features/remote-sync/fetch-remote-folders.ts index c6d1982806..0ea5aa40d0 100644 --- a/src/backend/features/remote-sync/fetch-remote-folders.ts +++ b/src/backend/features/remote-sync/fetch-remote-folders.ts @@ -14,8 +14,10 @@ type FetchFoldersResponse = { folders: RemoteSyncedFolder[]; }; -export async function fetchRemoteFolders({ limit, updatedAtCheckpoint }: Props): Promise> { - +export async function fetchRemoteFolders({ + limit, + updatedAtCheckpoint, +}: Props): Promise> { const { data, error } = await fetchFolders({ limit, offset: 0, diff --git a/src/backend/features/remote-sync/remote-sync-controller/sync-remote-folders.ts b/src/backend/features/remote-sync/remote-sync-controller/sync-remote-folders.ts index ddfc5ea21e..db31dfef59 100644 --- a/src/backend/features/remote-sync/remote-sync-controller/sync-remote-folders.ts +++ b/src/backend/features/remote-sync/remote-sync-controller/sync-remote-folders.ts @@ -17,7 +17,7 @@ export async function syncRemoteFolders({ state, db, config, errorHandler, syncC collection: db.folders, rewindMilliseconds: SIX_HOURS_IN_MILLISECONDS, }), - fetchRemoteItems: async (updatedAtCheckpoint) =>{ + fetchRemoteItems: async (updatedAtCheckpoint) => { const { error, data } = await fetchRemoteFolders({ limit: config.fetchFoldersLimitPerRequest, updatedAtCheckpoint,