From f2f6b80e68be4d76f4c458eff8384d6dc8f83a6e Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Wed, 30 Sep 2026 15:08:22 -0500 Subject: [PATCH 1/3] feat: implement lazy virtual drive metadata synchronization --- .../VirtualDriveDependencyContainerFactory.ts | 12 +- ...tualDriveMetadataSynchronizationService.ts | 34 +++++ .../directory-state-sqlite-repository.test.ts | 9 ++ .../lazy/directory-state-sqlite-repository.ts | 9 ++ ...irtual-drive-metadata-synchronizer.test.ts | 77 ++++++++++ ...azy-virtual-drive-metadata-synchronizer.ts | 126 ++++++++++++++++ .../lazy/remote-synced-mappers.test.ts | 53 +++++++ .../services/lazy/remote-synced-mappers.ts | 69 +++++++++ .../synchronize-directory-metadata.test.ts | 138 ++++++++++++++++++ .../lazy/synchronize-directory-metadata.ts | 105 +++++++++++++ .../virtual-drive/services/lazy/types.ts | 2 + .../operations/get-attributes.service.test.ts | 31 +++- .../operations/get-attributes.service.ts | 130 ++++++++++++----- .../services/operations/open.service.test.ts | 18 +++ .../services/operations/open.service.ts | 9 ++ .../operations/opendir.service.test.ts | 34 ++--- .../services/operations/opendir.service.ts | 22 +-- .../create/FolderCreatorFromServerFolder.ts | 6 +- .../application/FileCreatorFromServerFile.ts | 9 +- 19 files changed, 817 insertions(+), 76 deletions(-) create mode 100644 src/backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService.ts create mode 100644 src/backend/features/virtual-drive/services/lazy/lazy-virtual-drive-metadata-synchronizer.test.ts create mode 100644 src/backend/features/virtual-drive/services/lazy/lazy-virtual-drive-metadata-synchronizer.ts create mode 100644 src/backend/features/virtual-drive/services/lazy/remote-synced-mappers.test.ts create mode 100644 src/backend/features/virtual-drive/services/lazy/remote-synced-mappers.ts create mode 100644 src/backend/features/virtual-drive/services/lazy/synchronize-directory-metadata.test.ts create mode 100644 src/backend/features/virtual-drive/services/lazy/synchronize-directory-metadata.ts diff --git a/src/apps/drive/dependency-injection/virtual-drive/VirtualDriveDependencyContainerFactory.ts b/src/apps/drive/dependency-injection/virtual-drive/VirtualDriveDependencyContainerFactory.ts index 897395584e..4c8f2fe3dc 100644 --- a/src/apps/drive/dependency-injection/virtual-drive/VirtualDriveDependencyContainerFactory.ts +++ b/src/apps/drive/dependency-injection/virtual-drive/VirtualDriveDependencyContainerFactory.ts @@ -3,6 +3,9 @@ import { registerFilesServices } from './registerFilesServices'; import { registerFolderServices } from './registerFolderServices'; import { registerVirtualDriveSharedServices } from './registerVirtualDriveSharedServices'; import { registerTreeServices } from './registerTreeServices'; +import { LazyVirtualDriveMetadataSynchronizationService } from '../../../../backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService'; +import { FileRepository } from '../../../../context/virtual-drive/files/domain/FileRepository'; +import { FolderRepository } from '../../../../context/virtual-drive/folders/domain/FolderRepository'; export class VirtualDriveDependencyContainerFactory { static async build(builder: ContainerBuilder): Promise { @@ -12,6 +15,13 @@ export class VirtualDriveDependencyContainerFactory { await registerFolderServices(builder); - registerFilesServices(builder); + await registerFilesServices(builder); + + builder.register(LazyVirtualDriveMetadataSynchronizationService).useFactory((container) => { + return new LazyVirtualDriveMetadataSynchronizationService({ + folderRepository: container.get(FolderRepository), + fileRepository: container.get(FileRepository), + }); + }); } } diff --git a/src/backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService.ts b/src/backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService.ts new file mode 100644 index 0000000000..4f52f6a161 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService.ts @@ -0,0 +1,34 @@ +import { FileRepository } from '../../../../../context/virtual-drive/files/domain/FileRepository'; +import { FolderRepository } from '../../../../../context/virtual-drive/folders/domain/FolderRepository'; +import { LazyVirtualDriveMetadataSynchronizer } from './lazy-virtual-drive-metadata-synchronizer'; + +type Props = { + folderRepository: FolderRepository; + fileRepository: FileRepository; +}; + +export class LazyVirtualDriveMetadataSynchronizationService { + constructor({ folderRepository, fileRepository }: Props) { + this.folderRepository = folderRepository; + this.fileRepository = fileRepository; + } + + private readonly folderRepository: FolderRepository; + private readonly fileRepository: FileRepository; + + readDirectory({ path }: { path: string }) { + return LazyVirtualDriveMetadataSynchronizer.readDirectory({ + path, + folderRepository: this.folderRepository, + fileRepository: this.fileRepository, + }); + } + + ensurePathMetadataSynchronized({ path }: { path: string }) { + return LazyVirtualDriveMetadataSynchronizer.ensurePathMetadataSynchronized({ + path, + folderRepository: this.folderRepository, + fileRepository: this.fileRepository, + }); + } +} \ No newline at end of file diff --git a/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.test.ts b/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.test.ts index 4f1c6bd1eb..920801368f 100644 --- a/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.test.ts +++ b/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.test.ts @@ -56,6 +56,15 @@ describe('directory-state-sqlite-repository', () => { ]); }); + it('should invalidate a directory state', async () => { + await DirectoryStateRepository.invalidate({ dataSource, ...stateProps }); + + call(queryMock).toStrictEqual([ + 'DELETE FROM drive_directory_state WHERE folder_id = ? AND status_scope = ?', + [42, 'EXISTS'], + ]); + }); + it('should clear all directory states', async () => { await DirectoryStateRepository.clear({ dataSource }); diff --git a/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.ts b/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.ts index 68383f0623..a7d3221805 100644 --- a/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.ts +++ b/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.ts @@ -2,6 +2,7 @@ import { AppDataSource } from '../../../../../apps/main/database/data-source'; import { DIRECTORY_STATE_TTL_MS } from './constants'; import type { DirectoryStateFreshnessProps, + DirectoryStateInvalidationProps, DirectoryStateOperationProps, DirectoryStateRepositoryProps, } from './types'; @@ -45,6 +46,13 @@ async function markError({ dataSource = AppDataSource, folderId, statusScope }: ); } +async function invalidate({ dataSource = AppDataSource, folderId, statusScope }: DirectoryStateInvalidationProps) { + await dataSource.query('DELETE FROM drive_directory_state WHERE folder_id = ? AND status_scope = ?', [ + folderId, + statusScope, + ]); +} + async function clear({ dataSource = AppDataSource }: DirectoryStateRepositoryProps = {}) { await dataSource.query('DELETE FROM drive_directory_state'); } @@ -53,5 +61,6 @@ export const DirectoryStateRepository = { isFresh, markLoaded, markError, + invalidate, clear, }; diff --git a/src/backend/features/virtual-drive/services/lazy/lazy-virtual-drive-metadata-synchronizer.test.ts b/src/backend/features/virtual-drive/services/lazy/lazy-virtual-drive-metadata-synchronizer.test.ts new file mode 100644 index 0000000000..12bddeec88 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/lazy-virtual-drive-metadata-synchronizer.test.ts @@ -0,0 +1,77 @@ +import { mockDeep } from 'vitest-mock-extended'; +import { FolderRepository } from '../../../../../context/virtual-drive/folders/domain/FolderRepository'; +import { FileRepository } from '../../../../../context/virtual-drive/files/domain/FileRepository'; +import { FolderMother } from '../../../../../context/virtual-drive/folders/domain/__test-helpers__/FolderMother'; +import { LazyVirtualDriveMetadataSynchronizer } from './lazy-virtual-drive-metadata-synchronizer'; + +const { isFreshMock, synchronizeDirectoryMetadataMock } = vi.hoisted(() => ({ + isFreshMock: vi.fn(), + synchronizeDirectoryMetadataMock: vi.fn(), +})); + +vi.mock('./directory-state-sqlite-repository', () => ({ + DirectoryStateRepository: { isFresh: isFreshMock }, +})); + +vi.mock('./synchronize-directory-metadata', () => ({ + synchronizeDirectoryMetadata: synchronizeDirectoryMetadataMock, +})); + +describe('lazy-virtual-drive-metadata-synchronizer', () => { + const rootFolder = FolderMother.root(); + const folderRepository = mockDeep(); + const fileRepository = mockDeep(); + + beforeEach(() => { + folderRepository.matchingPartial.mockImplementation((partial) => (partial.path === '/' ? [rootFolder] : [])); + fileRepository.matchingPartial.mockReturnValue([]); + isFreshMock.mockResolvedValue(true); + synchronizeDirectoryMetadataMock.mockResolvedValue(undefined); + }); + + it('should list locally cached children when the directory state is fresh', async () => { + const childFolder = FolderMother.fromPartial({ id: 99, parentId: rootFolder.id, path: '/documents' }); + folderRepository.matchingPartial.mockImplementation((partial) => { + if (partial.path === '/') return [rootFolder]; + if (partial.parentId === rootFolder.id) return [childFolder]; + return []; + }); + + const entries = await LazyVirtualDriveMetadataSynchronizer.readDirectory({ + path: '/', + folderRepository, + fileRepository, + }); + + expect(entries).toStrictEqual({ folders: [childFolder.name], files: [] }); + expect(synchronizeDirectoryMetadataMock).not.toHaveBeenCalled(); + }); + + it('should synchronize a stale directory before returning its children', async () => { + isFreshMock.mockResolvedValue(false); + + await LazyVirtualDriveMetadataSynchronizer.readDirectory({ path: '/', folderRepository, fileRepository }); + + expect(synchronizeDirectoryMetadataMock).toHaveBeenCalledWith({ + folder: rootFolder, + folderRepository, + fileRepository, + }); + }); + + it('should synchronize the parent directory metadata before resolving a missing path', async () => { + isFreshMock.mockResolvedValue(false); + + await LazyVirtualDriveMetadataSynchronizer.ensurePathMetadataSynchronized({ + path: '/missing-file.txt', + folderRepository, + fileRepository, + }); + + expect(synchronizeDirectoryMetadataMock).toHaveBeenCalledWith({ + folder: rootFolder, + folderRepository, + fileRepository, + }); + }); +}); diff --git a/src/backend/features/virtual-drive/services/lazy/lazy-virtual-drive-metadata-synchronizer.ts b/src/backend/features/virtual-drive/services/lazy/lazy-virtual-drive-metadata-synchronizer.ts new file mode 100644 index 0000000000..27a3aac6c1 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/lazy-virtual-drive-metadata-synchronizer.ts @@ -0,0 +1,126 @@ +import path from 'node:path'; +import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { FuseError } from '../../../../../apps/drive/fuse/callbacks/FuseErrors'; +import { FileRepository } from '../../../../../context/virtual-drive/files/domain/FileRepository'; +import { Folder } from '../../../../../context/virtual-drive/folders/domain/Folder'; +import { FolderRepository } from '../../../../../context/virtual-drive/folders/domain/FolderRepository'; +import { FileStatuses } from '../../../../../context/virtual-drive/files/domain/FileStatus'; +import { FolderStatuses } from '../../../../../context/virtual-drive/folders/domain/FolderStatus'; +import { DirectoryStateRepository } from './directory-state-sqlite-repository'; +import { synchronizeDirectoryMetadata } from './synchronize-directory-metadata'; + +type Props = { + path: string; + folderRepository: FolderRepository; + fileRepository: FileRepository; +}; + +type DirectoryEntries = { + folders: Array; + files: Array; +}; + +const inFlightRefreshes = new Map>(); + +async function readDirectory({ + path: requestedPath, + folderRepository, + fileRepository, +}: Props): Promise { + const folder = await resolveFolder({ path: requestedPath, folderRepository, fileRepository }); + + await synchronizeChildrenIfStale({ folder, folderRepository, fileRepository }); + + return { + folders: folderRepository + .matchingPartial({ parentId: folder.id, status: FolderStatuses.EXISTS }) + .map((child) => child.name), + files: fileRepository + .matchingPartial({ folderId: folder.id, status: FileStatuses.EXISTS }) + .map((child) => child.nameWithExtension), + }; +} + +async function ensurePathMetadataSynchronized({ path: requestedPath, folderRepository, fileRepository }: Props) { + const normalizedPath = normalizePath(requestedPath); + const parentPath = normalizedPath === '/' ? '/' : path.posix.dirname(normalizedPath); + const parentFolder = await resolveFolder({ path: parentPath, folderRepository, fileRepository }); + + await synchronizeChildrenIfStale({ folder: parentFolder, folderRepository, fileRepository }); +} + +async function resolveFolder({ path: requestedPath, folderRepository, fileRepository }: Props): Promise { + const rootFolder = folderRepository.matchingPartial({ path: '/' })[0]; + + if (!rootFolder) { + throw new FuseError(FuseCodes.EIO, '[FUSE - Metadata sync] Root folder not initialized'); + } + + let currentFolder = rootFolder; + let currentPath = ''; + + for (const segment of normalizePath(requestedPath).split('/').filter(Boolean)) { + currentPath = path.posix.join(currentPath, segment); + const absolutePath = `/${currentPath}`; + const existingFolder = folderRepository.matchingPartial({ path: absolutePath })[0]; + + if (existingFolder) { + currentFolder = existingFolder; + continue; + } + + await synchronizeChildrenIfStale({ folder: currentFolder, folderRepository, fileRepository }); + + const synchronizedFolder = folderRepository.matchingPartial({ path: absolutePath })[0]; + + if (!synchronizedFolder) { + throw new FuseError(FuseCodes.ENOENT, `[FUSE - Metadata sync] Folder not found: ${absolutePath}`); + } + + currentFolder = synchronizedFolder; + } + + return currentFolder; +} + +async function synchronizeChildrenIfStale({ + folder, + folderRepository, + fileRepository, +}: { + folder: Folder; + folderRepository: FolderRepository; + fileRepository: FileRepository; +}) { + const statusScope = 'EXISTS' as const; + + if (await DirectoryStateRepository.isFresh({ folderId: folder.id, statusScope })) { + return; + } + + const key = `${folder.id}:${statusScope}`; + const inFlightRefresh = inFlightRefreshes.get(key); + + if (inFlightRefresh) { + return inFlightRefresh; + } + + const refresh = synchronizeDirectoryMetadata({ folder, folderRepository, fileRepository }).finally(() => { + inFlightRefreshes.delete(key); + }); + + inFlightRefreshes.set(key, refresh); + + return refresh; +} + +function normalizePath(requestedPath: string) { + const normalizedPath = path.posix.normalize(requestedPath || '/'); + + return normalizedPath === '.' ? '/' : normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`; +} + +export const LazyVirtualDriveMetadataSynchronizer = { + readDirectory, + ensurePathMetadataSynchronized, +}; diff --git a/src/backend/features/virtual-drive/services/lazy/remote-synced-mappers.test.ts b/src/backend/features/virtual-drive/services/lazy/remote-synced-mappers.test.ts new file mode 100644 index 0000000000..013c827e45 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/remote-synced-mappers.test.ts @@ -0,0 +1,53 @@ +import { ServerFileStatus } from '../../../../../context/shared/domain/ServerFile'; +import { ServerFolderStatus } from '../../../../../context/shared/domain/ServerFolder'; +import { toRemoteFile, toRemoteFolder } from './remote-synced-mappers'; + +describe('remote-synced-mappers', () => { + it('should map remote folders as existing', () => { + const folder = toRemoteFolder({ + type: 'folder', + id: 1, + parentId: 0, + bucket: 'bucket', + userId: 2, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + uuid: 'folder-uuid', + plainName: 'Documents', + name: 'Documents', + }); + + expect(folder).toMatchObject({ + id: 1, + parentId: 0, + plainName: 'Documents', + status: ServerFolderStatus.EXISTS, + }); + }); + + it('should normalize nullable identifiers and size for remote files', () => { + const file = toRemoteFile({ + id: 1, + uuid: 'file-uuid', + fileId: null, + type: 'txt', + size: '42', + bucket: 'bucket', + folderId: 2, + folderUuid: 'folder-uuid', + userId: 3, + modificationTime: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + plainName: 'notes', + name: 'notes.txt', + }); + + expect(file).toMatchObject({ + fileId: '', + size: 42, + plainName: 'notes', + status: ServerFileStatus.EXISTS, + }); + }); +}); diff --git a/src/backend/features/virtual-drive/services/lazy/remote-synced-mappers.ts b/src/backend/features/virtual-drive/services/lazy/remote-synced-mappers.ts new file mode 100644 index 0000000000..c440859619 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/remote-synced-mappers.ts @@ -0,0 +1,69 @@ +import { RemoteSyncedFile, RemoteSyncedFolder } from '../../../../../apps/main/remote-sync/helpers'; +import { ServerFileStatus } from '../../../../../context/shared/domain/ServerFile'; +import { ServerFolderStatus } from '../../../../../context/shared/domain/ServerFolder'; + +type RemoteFolder = { + type: string; + id: number; + parentId: number; + bucket: string; + userId: number; + createdAt: string; + updatedAt: string; + uuid: string; + plainName: string; + name: string; +}; + +type RemoteFile = { + id: number; + uuid: string; + fileId: string | null; + type: string; + size: string; + bucket: string; + folderId: number; + folderUuid: string; + userId: number; + modificationTime: string; + createdAt: string; + updatedAt: string; + plainName: string; + name: string; +}; + +export function toRemoteFolder(folder: RemoteFolder): RemoteSyncedFolder { + return { + type: folder.type, + id: folder.id, + parentId: folder.parentId, + bucket: folder.bucket, + userId: folder.userId, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + uuid: folder.uuid, + plainName: folder.plainName, + name: folder.name, + status: ServerFolderStatus.EXISTS, + }; +} + +export function toRemoteFile(file: RemoteFile): RemoteSyncedFile { + return { + id: file.id, + uuid: file.uuid, + fileId: file.fileId ?? '', + type: file.type, + size: Number.parseInt(file.size, 10), + bucket: file.bucket, + folderId: file.folderId, + folderUuid: file.folderUuid, + userId: file.userId, + modificationTime: file.modificationTime, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + plainName: file.plainName, + name: file.name, + status: ServerFileStatus.EXISTS, + }; +} diff --git a/src/backend/features/virtual-drive/services/lazy/synchronize-directory-metadata.test.ts b/src/backend/features/virtual-drive/services/lazy/synchronize-directory-metadata.test.ts new file mode 100644 index 0000000000..fd4d770651 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/synchronize-directory-metadata.test.ts @@ -0,0 +1,138 @@ +import { mockDeep } from 'vitest-mock-extended'; +import { FolderRepository } from '../../../../../context/virtual-drive/folders/domain/FolderRepository'; +import { FileRepository } from '../../../../../context/virtual-drive/files/domain/FileRepository'; +import { FolderMother } from '../../../../../context/virtual-drive/folders/domain/__test-helpers__/FolderMother'; +import { InMemoryFolderRepository } from '../../../../../context/virtual-drive/folders/infrastructure/InMemoryFolderRepository'; +import { InMemoryFileRepository } from '../../../../../context/virtual-drive/files/infrastructure/InMemoryFileRepository'; +import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { DriveServerError } from '../../../../../infra/drive-server/drive-server.error'; +import { synchronizeDirectoryMetadata } from './synchronize-directory-metadata'; + +const { + fetchFolderMock, + createOrUpdateFolderByBatchMock, + createOrUpdateFileByBatchMock, + markLoadedMock, + markErrorMock, + invalidateMock, +} = vi.hoisted(() => ({ + fetchFolderMock: vi.fn(), + createOrUpdateFolderByBatchMock: vi.fn(), + createOrUpdateFileByBatchMock: vi.fn(), + markLoadedMock: vi.fn(), + markErrorMock: vi.fn(), + invalidateMock: vi.fn(), +})); + +vi.mock('../../../../../infra/drive-server/services/folder/services/fetch-folder', () => ({ + fetchFolder: fetchFolderMock, +})); + +vi.mock('../../../../../infra/sqlite/services/folder/create-or-update-folder-by-batch', () => ({ + createOrUpdateFolderByBatch: createOrUpdateFolderByBatchMock, +})); + +vi.mock('../../../../../infra/sqlite/services/file/create-or-update-file-by-batch', () => ({ + createOrUpdateFileByBatch: createOrUpdateFileByBatchMock, +})); + +vi.mock('./directory-state-sqlite-repository', () => ({ + DirectoryStateRepository: { + markLoaded: markLoadedMock, + markError: markErrorMock, + invalidate: invalidateMock, + }, +})); + +describe('synchronize-directory-metadata', () => { + const folder = FolderMother.root(); + const folderRepository = mockDeep(); + const fileRepository = mockDeep(); + + beforeEach(() => { + folderRepository.searchByPathPrefix.mockReturnValue([]); + folderRepository.add.mockResolvedValue(undefined); + fileRepository.deleteByFolderPath.mockResolvedValue(undefined); + fileRepository.upsert.mockResolvedValue(false); + fetchFolderMock.mockResolvedValue({ data: { children: [], files: [] } }); + createOrUpdateFolderByBatchMock.mockResolvedValue({ data: [] }); + createOrUpdateFileByBatchMock.mockResolvedValue({ data: [] }); + markLoadedMock.mockResolvedValue(undefined); + markErrorMock.mockResolvedValue(undefined); + invalidateMock.mockResolvedValue(undefined); + }); + + it('should persist remote children and mark the directory as loaded', async () => { + await synchronizeDirectoryMetadata({ folder, folderRepository, fileRepository }); + + expect(fetchFolderMock).toHaveBeenCalledWith(folder.uuid); + expect(createOrUpdateFolderByBatchMock).toHaveBeenCalledWith({ folders: [] }); + expect(createOrUpdateFileByBatchMock).toHaveBeenCalledWith({ files: [] }); + expect(fileRepository.deleteByFolderPath).toHaveBeenCalledWith(folder.path); + expect(markLoadedMock).toHaveBeenCalledWith({ folderId: folder.id, statusScope: 'EXISTS' }); + expect(markErrorMock).not.toHaveBeenCalled(); + }); + + it('should mark the directory as errored and return EIO when fetching fails', async () => { + fetchFolderMock.mockResolvedValue({ error: new Error('network failure') }); + + await expect(synchronizeDirectoryMetadata({ folder, folderRepository, fileRepository })).rejects.toMatchObject({ + code: FuseCodes.EIO, + }); + + expect(markErrorMock).toHaveBeenCalledWith({ folderId: folder.id, statusScope: 'EXISTS' }); + }); + + it('should return ENOENT when the directory no longer exists remotely', async () => { + fetchFolderMock.mockResolvedValue({ error: new DriveServerError('NOT_FOUND') }); + + await expect(synchronizeDirectoryMetadata({ folder, folderRepository, fileRepository })).rejects.toMatchObject({ + code: FuseCodes.ENOENT, + }); + + expect(markErrorMock).toHaveBeenCalledWith({ folderId: folder.id, statusScope: 'EXISTS' }); + }); + + it('should preserve the root folder when refreshing its children', async () => { + const rootFolder = FolderMother.root(); + const inMemoryFolderRepository = new InMemoryFolderRepository(); + const inMemoryFileRepository = new InMemoryFileRepository(); + await inMemoryFolderRepository.add(rootFolder); + + await synchronizeDirectoryMetadata({ + folder: rootFolder, + folderRepository: inMemoryFolderRepository, + fileRepository: inMemoryFileRepository, + }); + + expect(await inMemoryFolderRepository.searchById(rootFolder.id)).toMatchObject({ + id: rootFolder.id, + path: '/', + }); + }); + + it('should mark the directory as errored without mutating cached children when folder persistence fails', async () => { + createOrUpdateFolderByBatchMock.mockResolvedValue({ error: new Error('database failure') }); + + await expect(synchronizeDirectoryMetadata({ folder, folderRepository, fileRepository })).rejects.toMatchObject({ + code: FuseCodes.EIO, + }); + + expect(createOrUpdateFileByBatchMock).not.toHaveBeenCalled(); + expect(fileRepository.deleteByFolderPath).not.toHaveBeenCalled(); + expect(markLoadedMock).not.toHaveBeenCalled(); + expect(markErrorMock).toHaveBeenCalledWith({ folderId: folder.id, statusScope: 'EXISTS' }); + }); + + it('should mark the directory as errored without mutating cached children when file persistence fails', async () => { + createOrUpdateFileByBatchMock.mockResolvedValue({ error: new Error('database failure') }); + + await expect(synchronizeDirectoryMetadata({ folder, folderRepository, fileRepository })).rejects.toMatchObject({ + code: FuseCodes.EIO, + }); + + expect(fileRepository.deleteByFolderPath).not.toHaveBeenCalled(); + expect(markLoadedMock).not.toHaveBeenCalled(); + expect(markErrorMock).toHaveBeenCalledWith({ folderId: folder.id, statusScope: 'EXISTS' }); + }); +}); diff --git a/src/backend/features/virtual-drive/services/lazy/synchronize-directory-metadata.ts b/src/backend/features/virtual-drive/services/lazy/synchronize-directory-metadata.ts new file mode 100644 index 0000000000..07c80966e3 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/synchronize-directory-metadata.ts @@ -0,0 +1,105 @@ +import path from 'node:path'; +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { FuseError } from '../../../../../apps/drive/fuse/callbacks/FuseErrors'; +import { RemoteSyncedFile } from '../../../../../apps/main/remote-sync/helpers'; +import { FileRepository } from '../../../../../context/virtual-drive/files/domain/FileRepository'; +import { Folder } from '../../../../../context/virtual-drive/folders/domain/Folder'; +import { FolderRepository } from '../../../../../context/virtual-drive/folders/domain/FolderRepository'; +import { createFileFromServerFile } from '../../../../../context/virtual-drive/remoteTree/application/FileCreatorFromServerFile'; +import { createFolderFromServerFolder } from '../../../../../context/virtual-drive/folders/application/create/FolderCreatorFromServerFolder'; +import { DriveServerError } from '../../../../../infra/drive-server/drive-server.error'; +import { fetchFolder } from '../../../../../infra/drive-server/services/folder/services/fetch-folder'; +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'; +import { DirectoryStateRepository } from './directory-state-sqlite-repository'; +import { toRemoteFile, toRemoteFolder } from './remote-synced-mappers'; + +type Props = { + folder: Folder; + folderRepository: FolderRepository; + fileRepository: FileRepository; +}; + +export async function synchronizeDirectoryMetadata({ folder, folderRepository, fileRepository }: Props) { + try { + const response = await fetchFolder(folder.uuid); + + if (response.error) { + throw response.error; + } + + const remoteFolders = response.data.children.filter(isExistingFolder).map(toRemoteFolder); + const remoteFiles = response.data.files.filter(isExistingFile).map(toRemoteFile); + + // better-sqlite3 has one writer connection, so these batches must remain sequential. + const folderPersistence = await createOrUpdateFolderByBatch({ folders: remoteFolders }); + if (folderPersistence.error) throw folderPersistence.error; + + const filePersistence = await createOrUpdateFileByBatch({ files: remoteFiles }); + if (filePersistence.error) throw filePersistence.error; + + await removeStaleChildren({ folder, folderRepository, fileRepository }); + + const localFolders = remoteFolders.map((remoteFolder) => { + return createFolderFromServerFolder( + remoteFolder, + joinPath({ parentPath: folder.path, name: remoteFolder.plainName }), + ); + }); + const localFiles = remoteFiles.map((remoteFile) => { + return createFileFromServerFile(remoteFile, joinPath({ parentPath: folder.path, name: fileName(remoteFile) })); + }); + + await Promise.all([ + ...localFolders.map((child) => folderRepository.add(child)), + ...localFiles.map((child) => fileRepository.upsert(child)), + ]); + await DirectoryStateRepository.markLoaded({ folderId: folder.id, statusScope: 'EXISTS' }); + } catch (error) { + await DirectoryStateRepository.markError({ folderId: folder.id, statusScope: 'EXISTS' }); + logger.error({ msg: '[FUSE - Metadata sync] Failed to synchronize directory metadata', error, path: folder.path }); + const code = error instanceof DriveServerError && error.cause === 'NOT_FOUND' ? FuseCodes.ENOENT : FuseCodes.EIO; + throw new FuseError(code, `[FUSE - Metadata sync] Unable to synchronize directory metadata: ${folder.path}`); + } +} + +async function removeStaleChildren({ + folder, + folderRepository, + fileRepository, +}: { + folder: Folder; + folderRepository: FolderRepository; + fileRepository: FileRepository; +}) { + const childPathPrefix = `${folder.path.replace(/\/$/, '')}/`; + const staleFolders = folderRepository + .searchByPathPrefix(childPathPrefix) + .filter((staleFolder) => staleFolder.id !== folder.id) + .sort((left, right) => right.path.length - left.path.length); + + await Promise.all([ + ...staleFolders.map(async (staleFolder) => { + await DirectoryStateRepository.invalidate({ folderId: staleFolder.id, statusScope: 'EXISTS' }); + await folderRepository.delete(staleFolder.id); + }), + fileRepository.deleteByFolderPath(folder.path), + ]); +} + +function isExistingFolder(folder: { status: string; deleted: boolean; removed: boolean }) { + return folder.status === 'EXISTS' && !folder.deleted && !folder.removed; +} + +function isExistingFile(file: { status: string }) { + return file.status === 'EXISTS'; +} + +function joinPath({ parentPath, name }: { parentPath: string; name: string }) { + return path.posix.join(parentPath, name); +} + +function fileName(file: RemoteSyncedFile) { + return file.type ? `${file.plainName}.${file.type}` : file.plainName; +} diff --git a/src/backend/features/virtual-drive/services/lazy/types.ts b/src/backend/features/virtual-drive/services/lazy/types.ts index eac6337bd5..1526f24a1c 100644 --- a/src/backend/features/virtual-drive/services/lazy/types.ts +++ b/src/backend/features/virtual-drive/services/lazy/types.ts @@ -19,6 +19,8 @@ export type DirectoryStateRepositoryProps = { export type DirectoryStateOperationProps = DirectoryStateProps & DirectoryStateRepositoryProps; +export type DirectoryStateInvalidationProps = DirectoryStateOperationProps; + export type DirectoryStateFreshnessProps = DirectoryStateOperationProps & { ttlMs?: number; }; diff --git a/src/backend/features/virtual-drive/services/operations/get-attributes.service.test.ts b/src/backend/features/virtual-drive/services/operations/get-attributes.service.test.ts index fa3d752152..1992e8a92d 100644 --- a/src/backend/features/virtual-drive/services/operations/get-attributes.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/get-attributes.service.test.ts @@ -6,6 +6,7 @@ import { FirstsFileSearcher } from '../../../../../context/virtual-drive/files/a import { SingleFolderMatchingSearcher } from '../../../../../context/virtual-drive/folders/application/SingleFolderMatchingSearcher'; import { TemporalFileByPathFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { LazyVirtualDriveMetadataSynchronizationService } from '../lazy/LazyVirtualDriveMetadataSynchronizationService'; import type { File } from '../../../../../context/virtual-drive/files/domain/File'; import type { Folder } from '../../../../../context/virtual-drive/folders/domain/Folder'; import type { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; @@ -18,13 +19,21 @@ describe('getAttributes', () => { const fileSearcher = mockDeep(); const folderSearcher = mockDeep(); const temporalFinder = mockDeep(); + const lazyMetadataSynchronizationService = mockDeep(); beforeEach(() => { now = new Date(); container = mockDeep(); container.get.calledWith(FirstsFileSearcher).mockReturnValue(fileSearcher); + container.get.calledWith(SingleFolderMatchingSearcher).mockReturnValue(folderSearcher); + container.get.calledWith(TemporalFileByPathFinder).mockReturnValue(temporalFinder); + container + .get.calledWith(LazyVirtualDriveMetadataSynchronizationService) + .mockReturnValue(lazyMetadataSynchronizationService); fileSearcher.run.mockResolvedValue(undefined); folderSearcher.run.mockResolvedValue(undefined); + temporalFinder.run.mockResolvedValue(undefined); + lazyMetadataSynchronizationService.ensurePathMetadataSynchronized.mockResolvedValue(undefined); }); describe('when path is root', () => { @@ -84,10 +93,7 @@ describe('getAttributes', () => { describe('when a temporal file is found', () => { it('should return file attributes', async () => { - container.get.calledWith(SingleFolderMatchingSearcher).mockReturnValue(folderSearcher); - temporalFinder.run.mockResolvedValue({ size: { value: 2048 }, createdAt: now } as unknown as TemporalFile); - container.get.calledWith(TemporalFileByPathFinder).mockReturnValue(temporalFinder); const { data, error } = await getAttributes('/some/temp.txt', container); @@ -98,15 +104,24 @@ describe('getAttributes', () => { describe('when nothing is found', () => { it('should return ENOENT error', async () => { - container.get.calledWith(SingleFolderMatchingSearcher).mockReturnValue(folderSearcher); - - temporalFinder.run.mockResolvedValue(undefined); - container.get.calledWith(TemporalFileByPathFinder).mockReturnValue(temporalFinder); - const { data, error } = await getAttributes('/missing/file.txt', container); expect(data).toBeUndefined(); expect(error?.code).toBe(FuseCodes.ENOENT); }); + + it('should retry the local lookup after hydrating the parent directory', async () => { + fileSearcher.run + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ size: 10, createdAt: now, updatedAt: now } as File); + + const { data, error } = await getAttributes('/hydrated/file.txt', container); + + expect(error).toBeUndefined(); + expect(data?.size).toBe(10); + expect(lazyMetadataSynchronizationService.ensurePathMetadataSynchronized).toHaveBeenCalledWith({ + path: '/hydrated/file.txt', + }); + }); }); }); diff --git a/src/backend/features/virtual-drive/services/operations/get-attributes.service.ts b/src/backend/features/virtual-drive/services/operations/get-attributes.service.ts index 5b048d224f..c38492da3e 100644 --- a/src/backend/features/virtual-drive/services/operations/get-attributes.service.ts +++ b/src/backend/features/virtual-drive/services/operations/get-attributes.service.ts @@ -7,6 +7,36 @@ import { FirstsFileSearcher } from '../../../../../context/virtual-drive/files/a import { SingleFolderMatchingSearcher } from '../../../../../context/virtual-drive/folders/application/SingleFolderMatchingSearcher'; import { TemporalFileByPathFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { LazyVirtualDriveMetadataSynchronizationService } from '../lazy/LazyVirtualDriveMetadataSynchronizationService'; + +type AttributesProps = { + mode: number; + size: number; + createdAt: Date; + modificationTime: Date; + accessTime?: Date; + links: number; +}; + +function getAttributesData({ + mode, + size, + createdAt, + modificationTime, + accessTime, + links, +}: AttributesProps): GetAttributesCallbackData { + return { + mode, + size, + ctime: createdAt, + mtime: modificationTime, + atime: accessTime, + uid: process.getuid?.() || 0, + gid: process.getgid?.() || 0, + nlink: links, + }; +} export async function getAttributes( path: string, @@ -14,73 +44,105 @@ export async function getAttributes( ): Promise> { if (path === '/' || path === '') { return { - data: { + data: getAttributesData({ mode: FOLDER_MODE, size: 0, - mtime: new Date(), - ctime: new Date(), - atime: undefined, - uid: process.getuid?.() || 0, - gid: process.getgid?.() || 0, - nlink: 2, - }, + createdAt: new Date(), + modificationTime: new Date(), + links: 2, + }), }; } - const file = await container.get(FirstsFileSearcher).run({ + const fileSearcher = container.get(FirstsFileSearcher); + const folderSearcher = container.get(SingleFolderMatchingSearcher); + const file = await fileSearcher.run({ path, status: FileStatuses.EXISTS, }); if (file) { return { - data: { + data: getAttributesData({ mode: FILE_MODE, size: file.size, - ctime: file.createdAt, + createdAt: file.createdAt, // The contents' modification time, not the row's. These differ once a // time has been set through utimensat; before that the getter falls // back to updatedAt, which is what this used to read directly. - mtime: file.modificationTime, - atime: new Date(), - uid: process.getuid?.() || 0, - gid: process.getgid?.() || 0, - nlink: 1, - }, + modificationTime: file.modificationTime, + accessTime: new Date(), + links: 1, + }), }; } - const folder = await container.get(SingleFolderMatchingSearcher).run({ + const folder = await folderSearcher.run({ path, }); if (folder) { return { - data: { + data: getAttributesData({ mode: FOLDER_MODE, size: 0, - ctime: folder.createdAt, - mtime: folder.updatedAt, - atime: folder.createdAt, - uid: process.getuid?.() || 0, - gid: process.getgid?.() || 0, - nlink: 2, - }, + createdAt: folder.createdAt, + modificationTime: folder.updatedAt, + accessTime: folder.createdAt, + links: 2, + }), }; } const document = await container.get(TemporalFileByPathFinder).run(path); if (document) { return { - data: { + data: getAttributesData({ mode: FILE_MODE, size: document.size.value, - mtime: new Date(), - ctime: document.createdAt, - atime: document.createdAt, - uid: process.getuid?.() || 0, - gid: process.getgid?.() || 0, - nlink: 1, - }, + createdAt: document.createdAt, + modificationTime: new Date(), + accessTime: document.createdAt, + links: 1, + }), + }; + } + + try { + await container.get(LazyVirtualDriveMetadataSynchronizationService).ensurePathMetadataSynchronized({ path }); + } catch (error) { + if (error instanceof FuseError) { + return { error }; + } + + return { error: new FuseError(FuseCodes.EIO, `[FUSE - GetAttributes] IO error: ${path}`) }; + } + + const hydratedFile = await fileSearcher.run({ path, status: FileStatuses.EXISTS }); + if (hydratedFile) { + return { + data: getAttributesData({ + mode: FILE_MODE, + size: hydratedFile.size, + createdAt: hydratedFile.createdAt, + modificationTime: hydratedFile.modificationTime, + accessTime: new Date(), + links: 1, + }), }; } + + const hydratedFolder = await folderSearcher.run({ path }); + if (hydratedFolder) { + return { + data: getAttributesData({ + mode: FOLDER_MODE, + size: 0, + createdAt: hydratedFolder.createdAt, + modificationTime: hydratedFolder.updatedAt, + accessTime: hydratedFolder.createdAt, + links: 2, + }), + }; + } + const msg = `[FUSE - GetAttributes] File not found: ${path}`; return { error: new FuseError(FuseCodes.ENOENT, msg) }; } diff --git a/src/backend/features/virtual-drive/services/operations/open.service.test.ts b/src/backend/features/virtual-drive/services/operations/open.service.test.ts index 69bf8a5f76..8c0733755a 100644 --- a/src/backend/features/virtual-drive/services/operations/open.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/open.service.test.ts @@ -5,19 +5,25 @@ import { FirstsFileSearcher } from '../../../../../context/virtual-drive/files/a import { TemporalFileByPathFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { LazyVirtualDriveMetadataSynchronizationService } from '../lazy/LazyVirtualDriveMetadataSynchronizationService'; import type { File } from '../../../../../context/virtual-drive/files/domain/File'; describe('open', () => { let container: ReturnType>; const fileSearcher = mockDeep(); const temporalFinder = mockDeep(); + const lazyMetadataSynchronizationService = mockDeep(); beforeEach(() => { container = mockDeep(); container.get.calledWith(FirstsFileSearcher).mockReturnValue(fileSearcher); container.get.calledWith(TemporalFileByPathFinder).mockReturnValue(temporalFinder); + container + .get.calledWith(LazyVirtualDriveMetadataSynchronizationService) + .mockReturnValue(lazyMetadataSynchronizationService); fileSearcher.run.mockResolvedValue(undefined); temporalFinder.run.mockResolvedValue(undefined); + lazyMetadataSynchronizationService.ensurePathMetadataSynchronized.mockResolvedValue(undefined); }); describe('when a virtual file is found', () => { @@ -49,6 +55,18 @@ describe('open', () => { expect(data).toBeUndefined(); expect(error?.code).toBe(FuseCodes.ENOENT); }); + + it('should retry after hydrating the parent directory', async () => { + fileSearcher.run.mockResolvedValueOnce(undefined).mockResolvedValue({} as File); + + const { data, error } = await open('/hydrated/file.txt', 'cat', container); + + expect(error).toBeUndefined(); + expect(data).toBeUndefined(); + expect(lazyMetadataSynchronizationService.ensurePathMetadataSynchronized).toHaveBeenCalledWith({ + path: '/hydrated/file.txt', + }); + }); }); describe('when an unexpected error is thrown on a non-temporary path', () => { diff --git a/src/backend/features/virtual-drive/services/operations/open.service.ts b/src/backend/features/virtual-drive/services/operations/open.service.ts index f7d357d458..68515236bb 100644 --- a/src/backend/features/virtual-drive/services/operations/open.service.ts +++ b/src/backend/features/virtual-drive/services/operations/open.service.ts @@ -6,6 +6,7 @@ import { FirstsFileSearcher } from '../../../../../context/virtual-drive/files/a import { TemporalFileByPathFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { LazyVirtualDriveMetadataSynchronizationService } from '../lazy/LazyVirtualDriveMetadataSynchronizationService'; export async function open(path: string, processName: string, container: Container): Promise> { try { @@ -21,10 +22,18 @@ export async function open(path: string, processName: string, container: Contain return { data: undefined }; } + await container.get(LazyVirtualDriveMetadataSynchronizationService).ensurePathMetadataSynchronized({ path }); + + const hydratedFile = await container.get(FirstsFileSearcher).run({ path }); + + if (hydratedFile) return { data: undefined }; + const msg = `[FUSE - Open] File not found: ${path}`; logger.error({ msg, processName }); return { error: new FuseError(FuseCodes.ENOENT, msg) }; } catch (err) { + if (err instanceof FuseError) return { error: err }; + if (TemporalFile.isTemporaryPath(path)) { const msg = `[FUSE - Open] Auxiliary path conflict: ${path}`; return { error: new FuseError(FuseCodes.EEXIST, msg) }; diff --git a/src/backend/features/virtual-drive/services/operations/opendir.service.test.ts b/src/backend/features/virtual-drive/services/operations/opendir.service.test.ts index 45401df69f..3201eab08e 100644 --- a/src/backend/features/virtual-drive/services/operations/opendir.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/opendir.service.test.ts @@ -1,34 +1,34 @@ import { mockDeep } from 'vitest-mock-extended'; import { Container } from 'diod'; import { opendir } from './opendir.service'; -import { FilesByFolderPathSearcher } from '../../../../../context/virtual-drive/files/application/search/FilesByFolderPathSearcher'; -import { FoldersByParentPathLister } from '../../../../../context/virtual-drive/folders/application/FoldersByParentPathLister'; import { TemporalFileByFolderFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByFolderFinder'; -import { FolderNotFoundError } from '../../../../../context/virtual-drive/folders/domain/errors/FolderNotFoundError'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { FuseError } from '../../../../../apps/drive/fuse/callbacks/FuseErrors'; import { FILE_MODE, FOLDER_MODE } from '../../constants'; +import { LazyVirtualDriveMetadataSynchronizationService } from '../lazy/LazyVirtualDriveMetadataSynchronizationService'; import type { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; describe('opendir', () => { let container: ReturnType>; - const fileSearcher = mockDeep(); - const folderLister = mockDeep(); const temporalFinder = mockDeep(); + const lazyMetadataSynchronizationService = mockDeep(); beforeEach(() => { container = mockDeep(); - container.get.calledWith(FilesByFolderPathSearcher).mockReturnValue(fileSearcher); - container.get.calledWith(FoldersByParentPathLister).mockReturnValue(folderLister); container.get.calledWith(TemporalFileByFolderFinder).mockReturnValue(temporalFinder); - fileSearcher.run.mockResolvedValue([]); - folderLister.run.mockResolvedValue([]); + container + .get.calledWith(LazyVirtualDriveMetadataSynchronizationService) + .mockReturnValue(lazyMetadataSynchronizationService); temporalFinder.run.mockResolvedValue([]); + lazyMetadataSynchronizationService.readDirectory.mockResolvedValue({ files: [], folders: [] }); }); describe('when directory has files and subfolders', () => { it('should return entries with correct modes', async () => { - fileSearcher.run.mockResolvedValue(['file.txt', 'photo.jpg']); - folderLister.run.mockResolvedValue(['subdir']); + lazyMetadataSynchronizationService.readDirectory.mockResolvedValue({ + files: ['file.txt', 'photo.jpg'], + folders: ['subdir'], + }); const { data, error } = await opendir('/some/folder', container); @@ -59,20 +59,20 @@ describe('opendir', () => { }); }); - describe('when folder is not yet synced', () => { - it('should return empty entries', async () => { - folderLister.run.mockRejectedValue(new FolderNotFoundError('not synced')); + describe('when the directory does not exist remotely', () => { + it('should return ENOENT', async () => { + lazyMetadataSynchronizationService.readDirectory.mockRejectedValue(new FuseError(FuseCodes.ENOENT, 'not found')); const { data, error } = await opendir('/unsynced/folder', container); - expect(error).toBeUndefined(); - expect(data?.entries).toStrictEqual([]); + expect(data).toBeUndefined(); + expect(error?.code).toBe(FuseCodes.ENOENT); }); }); describe('when an unexpected error is thrown', () => { it('should return EIO', async () => { - fileSearcher.run.mockRejectedValue(new Error('unexpected')); + lazyMetadataSynchronizationService.readDirectory.mockRejectedValue(new Error('unexpected')); const { data, error } = await opendir('/some/folder', container); diff --git a/src/backend/features/virtual-drive/services/operations/opendir.service.ts b/src/backend/features/virtual-drive/services/operations/opendir.service.ts index 0731053d6c..cdfe17f631 100644 --- a/src/backend/features/virtual-drive/services/operations/opendir.service.ts +++ b/src/backend/features/virtual-drive/services/operations/opendir.service.ts @@ -3,11 +3,9 @@ import { Result } from '../../../../../context/shared/domain/Result'; import { FILE_MODE, FOLDER_MODE } from '../../constants'; import { FuseError } from '../../../../../apps/drive/fuse/callbacks/FuseErrors'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; -import { FilesByFolderPathSearcher } from '../../../../../context/virtual-drive/files/application/search/FilesByFolderPathSearcher'; -import { FoldersByParentPathLister } from '../../../../../context/virtual-drive/folders/application/FoldersByParentPathLister'; import { TemporalFileByFolderFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByFolderFinder'; -import { FolderNotFoundError } from '../../../../../context/virtual-drive/folders/domain/errors/FolderNotFoundError'; import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { LazyVirtualDriveMetadataSynchronizationService } from '../lazy/LazyVirtualDriveMetadataSynchronizationService'; export type DirEntry = { name: string; @@ -20,24 +18,20 @@ export type OpenDirData = { export async function opendir(path: string, container: Container): Promise> { try { - const [fileNames, folderNames, temporalFiles] = await Promise.all([ - container.get(FilesByFolderPathSearcher).run(path), - container.get(FoldersByParentPathLister).run(path), + const [entries, temporalFiles] = await Promise.all([ + container.get(LazyVirtualDriveMetadataSynchronizationService).readDirectory({ path }), container.get(TemporalFileByFolderFinder).run(path), ]); - const entries: DirEntry[] = [ - ...fileNames.map((name) => ({ name, mode: FILE_MODE })), - ...folderNames.map((name) => ({ name, mode: FOLDER_MODE })), + const directoryEntries: DirEntry[] = [ + ...entries.files.map((name) => ({ name, mode: FILE_MODE })), + ...entries.folders.map((name) => ({ name, mode: FOLDER_MODE })), ...temporalFiles.filter((f) => f.isAuxiliary()).map((f) => ({ name: f.name, mode: FILE_MODE })), ]; - return { data: { entries } }; + return { data: { entries: directoryEntries } }; } catch (err) { - if (err instanceof FolderNotFoundError) { - logger.debug({ msg: '[FUSE - OpenDir] Folder not yet synced, returning empty', path }); - return { data: { entries: [] } }; - } + if (err instanceof FuseError) return { error: err }; logger.error({ msg: '[FUSE - OpenDir] Error reading directory', error: err, path }); return { error: new FuseError(FuseCodes.EIO, `[FUSE - OpenDir] IO error: ${path}`) }; } diff --git a/src/context/virtual-drive/folders/application/create/FolderCreatorFromServerFolder.ts b/src/context/virtual-drive/folders/application/create/FolderCreatorFromServerFolder.ts index e903df7c6f..3aac862c96 100644 --- a/src/context/virtual-drive/folders/application/create/FolderCreatorFromServerFolder.ts +++ b/src/context/virtual-drive/folders/application/create/FolderCreatorFromServerFolder.ts @@ -1,7 +1,11 @@ import { ServerFolder } from '../../../../shared/domain/ServerFolder'; import { Folder } from '../../domain/Folder'; -export function createFolderFromServerFolder(server: ServerFolder, relativePath: string): Folder { +type Props = Pick & { + status: string; +}; + +export function createFolderFromServerFolder(server: Props, relativePath: string): Folder { const path = relativePath.replaceAll('//', '/'); return Folder.from({ diff --git a/src/context/virtual-drive/remoteTree/application/FileCreatorFromServerFile.ts b/src/context/virtual-drive/remoteTree/application/FileCreatorFromServerFile.ts index ddf0a84dde..f8842f3fa9 100644 --- a/src/context/virtual-drive/remoteTree/application/FileCreatorFromServerFile.ts +++ b/src/context/virtual-drive/remoteTree/application/FileCreatorFromServerFile.ts @@ -2,7 +2,14 @@ import { ServerFile } from '../../../shared/domain/ServerFile'; import { File } from '../../files/domain/File'; import { FileStatuses } from '../../files/domain/FileStatus'; -export function createFileFromServerFile(server: ServerFile, relativePath: string): File { +type Props = Pick< + ServerFile, + 'id' | 'uuid' | 'folderId' | 'fileId' | 'modificationTime' | 'size' | 'createdAt' | 'updatedAt' +> & { + status: string; +}; + +export function createFileFromServerFile(server: Props, relativePath: string): File { return File.from({ id: server.id, uuid: server.uuid, From 425bca8fe3565d70a9b265cafd2f5174eca81450 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Thu, 1 Oct 2026 17:40:34 -0500 Subject: [PATCH 2/3] fix(tests): correct formatting of container.get calls for LazyVirtualDriveMetadataSynchronizationService --- .../lazy/LazyVirtualDriveMetadataSynchronizationService.ts | 2 +- .../services/operations/get-attributes.service.test.ts | 4 ++-- .../virtual-drive/services/operations/open.service.test.ts | 4 ++-- .../virtual-drive/services/operations/opendir.service.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService.ts b/src/backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService.ts index 4f52f6a161..2eb0884eb1 100644 --- a/src/backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService.ts +++ b/src/backend/features/virtual-drive/services/lazy/LazyVirtualDriveMetadataSynchronizationService.ts @@ -31,4 +31,4 @@ export class LazyVirtualDriveMetadataSynchronizationService { fileRepository: this.fileRepository, }); } -} \ No newline at end of file +} diff --git a/src/backend/features/virtual-drive/services/operations/get-attributes.service.test.ts b/src/backend/features/virtual-drive/services/operations/get-attributes.service.test.ts index 1992e8a92d..d2ec3e9c07 100644 --- a/src/backend/features/virtual-drive/services/operations/get-attributes.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/get-attributes.service.test.ts @@ -27,8 +27,8 @@ describe('getAttributes', () => { container.get.calledWith(FirstsFileSearcher).mockReturnValue(fileSearcher); container.get.calledWith(SingleFolderMatchingSearcher).mockReturnValue(folderSearcher); container.get.calledWith(TemporalFileByPathFinder).mockReturnValue(temporalFinder); - container - .get.calledWith(LazyVirtualDriveMetadataSynchronizationService) + container.get + .calledWith(LazyVirtualDriveMetadataSynchronizationService) .mockReturnValue(lazyMetadataSynchronizationService); fileSearcher.run.mockResolvedValue(undefined); folderSearcher.run.mockResolvedValue(undefined); diff --git a/src/backend/features/virtual-drive/services/operations/open.service.test.ts b/src/backend/features/virtual-drive/services/operations/open.service.test.ts index 8c0733755a..af98c71280 100644 --- a/src/backend/features/virtual-drive/services/operations/open.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/open.service.test.ts @@ -18,8 +18,8 @@ describe('open', () => { container = mockDeep(); container.get.calledWith(FirstsFileSearcher).mockReturnValue(fileSearcher); container.get.calledWith(TemporalFileByPathFinder).mockReturnValue(temporalFinder); - container - .get.calledWith(LazyVirtualDriveMetadataSynchronizationService) + container.get + .calledWith(LazyVirtualDriveMetadataSynchronizationService) .mockReturnValue(lazyMetadataSynchronizationService); fileSearcher.run.mockResolvedValue(undefined); temporalFinder.run.mockResolvedValue(undefined); diff --git a/src/backend/features/virtual-drive/services/operations/opendir.service.test.ts b/src/backend/features/virtual-drive/services/operations/opendir.service.test.ts index 3201eab08e..8075d67acd 100644 --- a/src/backend/features/virtual-drive/services/operations/opendir.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/opendir.service.test.ts @@ -16,8 +16,8 @@ describe('opendir', () => { beforeEach(() => { container = mockDeep(); container.get.calledWith(TemporalFileByFolderFinder).mockReturnValue(temporalFinder); - container - .get.calledWith(LazyVirtualDriveMetadataSynchronizationService) + container.get + .calledWith(LazyVirtualDriveMetadataSynchronizationService) .mockReturnValue(lazyMetadataSynchronizationService); temporalFinder.run.mockResolvedValue([]); lazyMetadataSynchronizationService.readDirectory.mockResolvedValue({ files: [], folders: [] }); From 1a95a46438f4765ceda8b76a37d05ad6c951d6e5 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Fri, 9 Oct 2026 15:30:53 -0500 Subject: [PATCH 3/3] style: format code in getUserOrThrow function for consistency --- src/backend/features/auth/get-user-or-throw.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/backend/features/auth/get-user-or-throw.ts b/src/backend/features/auth/get-user-or-throw.ts index 46c65e2499..d223102e00 100644 --- a/src/backend/features/auth/get-user-or-throw.ts +++ b/src/backend/features/auth/get-user-or-throw.ts @@ -1,9 +1,8 @@ import { getUser } from './get-user'; - export function getUserOrThrow() { - const { data: user, error } = getUser(); - if (error) throw error; + const { data: user, error } = getUser(); + if (error) throw error; - return user; -} \ No newline at end of file + return user; +}