From 12151fcc22901986eee3f95d0626797642fa44a3 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 13 Apr 2026 21:43:16 -0500 Subject: [PATCH] fix: implement PendingFolderCreationTracker to manage folder creation dependencies --- .../virtual-drive/registerFolderServices.ts | 3 + .../callbacks/TrashFolderCallback.test.ts | 103 ++++++++++++++++++ .../fuse/callbacks/TrashFolderCallback.ts | 47 +++++++- .../CreateFileOnTemporalFileUploaded.ts | 2 +- .../application/create/FileCreator.test.ts | 11 +- .../files/application/create/FileCreator.ts | 59 +++++----- .../__mocks__/FolderRemoteFileSystemMock.ts | 11 +- .../application/create/FolderCreator.test.ts | 33 +++++- .../application/create/FolderCreator.ts | 67 ++++++++---- .../PendingFolderCreationTracker.test.ts | 41 +++++++ .../create/PendingFolderCreationTracker.ts | 81 ++++++++++++++ 11 files changed, 403 insertions(+), 55 deletions(-) create mode 100644 src/apps/drive/fuse/callbacks/TrashFolderCallback.test.ts create mode 100644 src/context/virtual-drive/folders/application/create/PendingFolderCreationTracker.test.ts create mode 100644 src/context/virtual-drive/folders/application/create/PendingFolderCreationTracker.ts diff --git a/src/apps/drive/dependency-injection/virtual-drive/registerFolderServices.ts b/src/apps/drive/dependency-injection/virtual-drive/registerFolderServices.ts index b89511491f..af3905796f 100644 --- a/src/apps/drive/dependency-injection/virtual-drive/registerFolderServices.ts +++ b/src/apps/drive/dependency-injection/virtual-drive/registerFolderServices.ts @@ -2,6 +2,7 @@ import { ContainerBuilder } from 'diod'; import { AllParentFoldersStatusIsExists } from '../../../../context/virtual-drive/folders/application/AllParentFoldersStatusIsExists'; import { FolderCreator } from '../../../../context/virtual-drive/folders/application/create/FolderCreator'; import { FolderCreatorFromOfflineFolder } from '../../../../context/virtual-drive/folders/application/create/FolderCreatorFromOfflineFolder'; +import { PendingFolderCreationTracker } from '../../../../context/virtual-drive/folders/application/create/PendingFolderCreationTracker'; import { FolderDeleter } from '../../../../context/virtual-drive/folders/application/FolderDeleter'; import { FolderMover } from '../../../../context/virtual-drive/folders/application/FolderMover'; import { FolderPathUpdater } from '../../../../context/virtual-drive/folders/application/FolderPathUpdater'; @@ -56,6 +57,8 @@ export async function registerFolderServices(builder: ContainerBuilder): Promise builder.registerAndUse(FolderCreatorFromOfflineFolder); + builder.register(PendingFolderCreationTracker).use(PendingFolderCreationTracker).asSingleton().private(); + builder.registerAndUse(FolderCreator); builder.registerAndUse(FolderDeleter); diff --git a/src/apps/drive/fuse/callbacks/TrashFolderCallback.test.ts b/src/apps/drive/fuse/callbacks/TrashFolderCallback.test.ts new file mode 100644 index 0000000000..ab64869527 --- /dev/null +++ b/src/apps/drive/fuse/callbacks/TrashFolderCallback.test.ts @@ -0,0 +1,103 @@ +import { FolderDeleter } from '../../../../context/virtual-drive/folders/application/FolderDeleter'; +import { SingleFolderMatchingFinder } from '../../../../context/virtual-drive/folders/application/SingleFolderMatchingFinder'; +import { FolderMother } from '../../../../context/virtual-drive/folders/domain/__test-helpers__/FolderMother'; +import { FolderStatuses } from '../../../../context/virtual-drive/folders/domain/FolderStatus'; +import { SyncFolderMessenger } from '../../../../context/virtual-drive/folders/domain/SyncFolderMessenger'; +import { ContainerMock } from '../../__mocks__/ContainerMock'; +import { TrashFolderCallback } from './TrashFolderCallback'; + +describe('TrashFolderCallback', () => { + it('returns success even when folder deletion exceeds callback timeout', async () => { + vi.useFakeTimers(); + + try { + const container = new ContainerMock(); + const folder = FolderMother.any(); + + const folderFinder = { + run: vi.fn(async () => { + return folder; + }), + } as unknown as SingleFolderMatchingFinder; + + const folderDeleter = { + run: vi.fn(() => { + return new Promise((resolve) => { + setTimeout(resolve, 5_000); + }); + }), + } as unknown as FolderDeleter; + + container.set(SingleFolderMatchingFinder, folderFinder); + container.set(FolderDeleter, folderDeleter); + + const callback = new TrashFolderCallback(container as never); + const resultPromise = callback.execute('/Files/SlowFolder'); + + await vi.advanceTimersByTimeAsync(1_600); + + const result = await resultPromise; + + expect(result.isRight()).toBe(true); + expect(folderFinder.run).toHaveBeenCalledWith({ + path: '/Files/SlowFolder', + status: FolderStatuses.EXISTS, + }); + expect(folderDeleter.run).toHaveBeenCalledWith(folder.uuid); + } finally { + vi.useRealTimers(); + } + }); + + it('reports issue when background deletion fails after timeout', async () => { + vi.useFakeTimers(); + + try { + const container = new ContainerMock(); + const folder = FolderMother.any(); + + const folderFinder = { + run: vi.fn(async () => { + return folder; + }), + } as unknown as SingleFolderMatchingFinder; + + const folderDeleter = { + run: vi.fn(() => { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error('slow-delete-failed')); + }, 5_000); + }); + }), + } as unknown as FolderDeleter; + + const syncFolderMessenger = { + issue: vi.fn(async () => undefined), + } as unknown as SyncFolderMessenger; + + container.set(SingleFolderMatchingFinder, folderFinder); + container.set(FolderDeleter, folderDeleter); + container.set(SyncFolderMessenger, syncFolderMessenger); + + const callback = new TrashFolderCallback(container as never); + const resultPromise = callback.execute('/Files/SlowFolder'); + + await vi.advanceTimersByTimeAsync(1_600); + + const result = await resultPromise; + expect(result.isRight()).toBe(true); + + await vi.advanceTimersByTimeAsync(3_500); + await Promise.resolve(); + + expect(syncFolderMessenger.issue).toHaveBeenCalledWith({ + error: 'FOLDER_TRASH_ERROR', + cause: 'UNKNOWN', + name: 'SlowFolder', + }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/apps/drive/fuse/callbacks/TrashFolderCallback.ts b/src/apps/drive/fuse/callbacks/TrashFolderCallback.ts index 89fed0cb47..a93c7beb46 100644 --- a/src/apps/drive/fuse/callbacks/TrashFolderCallback.ts +++ b/src/apps/drive/fuse/callbacks/TrashFolderCallback.ts @@ -1,11 +1,30 @@ import { Container } from 'diod'; import { basename } from 'path'; +import { logger } from '@internxt/drive-desktop-core/build/backend'; import { FolderDeleter } from '../../../../context/virtual-drive/folders/application/FolderDeleter'; import { SingleFolderMatchingFinder } from '../../../../context/virtual-drive/folders/application/SingleFolderMatchingFinder'; import { FolderStatuses } from '../../../../context/virtual-drive/folders/domain/FolderStatus'; import { SyncFolderMessenger } from '../../../../context/virtual-drive/folders/domain/SyncFolderMessenger'; import { NotifyFuseCallback } from './FuseCallback'; +const FOLDER_TRASH_CALLBACK_TIMEOUT_MS = 1_500; + +type WaitWithTimeoutPops = { + promise: Promise; + timeoutMs: number; +}; + +async function waitWithTimeout({ promise, timeoutMs }: WaitWithTimeoutPops) { + const completion = promise.then(() => true); + const timeout = new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, timeoutMs); + }); + + return Promise.race([completion, timeout]); +} + export class TrashFolderCallback extends NotifyFuseCallback { constructor(private readonly container: Container) { super('Trash Folder'); @@ -18,7 +37,33 @@ export class TrashFolderCallback extends NotifyFuseCallback { status: FolderStatuses.EXISTS, }); - await this.container.get(FolderDeleter).run(folder.uuid); + const deletionPromise = this.container.get(FolderDeleter).run(folder.uuid); + const deletionCompletedInTime = await waitWithTimeout({ + promise: deletionPromise, + timeoutMs: FOLDER_TRASH_CALLBACK_TIMEOUT_MS, + }); + + if (!deletionCompletedInTime) { + logger.warn({ + msg: 'Folder deletion exceeded callback timeout. Continuing deletion in background.', + path, + timeoutMs: FOLDER_TRASH_CALLBACK_TIMEOUT_MS, + }); + + void deletionPromise.catch(async (error) => { + logger.error({ + msg: 'Background folder deletion failed after callback timeout', + path, + error, + }); + + await this.container.get(SyncFolderMessenger).issue({ + error: 'FOLDER_TRASH_ERROR', + cause: 'UNKNOWN', + name: basename(path), + }); + }); + } return this.right(); } catch (throwed: unknown) { diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts index f409c270c7..8e4070eff7 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts @@ -50,7 +50,7 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { try { - this.create(event); + await this.create(event); } catch (err) { logger.error({ msg: '[CreateFileOnOfflineFileUploaded] Error creating file:', diff --git a/src/context/virtual-drive/files/application/create/FileCreator.test.ts b/src/context/virtual-drive/files/application/create/FileCreator.test.ts index 103847c136..f39e6ec001 100644 --- a/src/context/virtual-drive/files/application/create/FileCreator.test.ts +++ b/src/context/virtual-drive/files/application/create/FileCreator.test.ts @@ -10,6 +10,7 @@ import { FileMother } from '../../domain/__test-helpers__/FileMother'; import { FileSizeMother } from '../../domain/__test-helpers__/FileSizeMother'; import { right } from '../../../../shared/domain/Either'; import { EventBusMock } from '../../../../../context/virtual-drive/shared/__mocks__/EventBusMock'; +import { PendingFolderCreationTracker } from '../../../folders/application/create/PendingFolderCreationTracker'; describe('File Creator', () => { let remoteFileSystemMock: RemoteFileSystemMock; @@ -25,8 +26,16 @@ describe('File Creator', () => { const parentFolderFinder = FolderFinderFactory.existingFolder(); eventBus = new EventBusMock(); notifier = new FileSyncNotifierMock(); + const pendingFolderCreationTracker = new PendingFolderCreationTracker(); - SUT = new FileCreator(remoteFileSystemMock, fileRepository, parentFolderFinder, eventBus, notifier); + SUT = new FileCreator( + remoteFileSystemMock, + fileRepository, + parentFolderFinder, + eventBus, + notifier, + pendingFolderCreationTracker, + ); }); it('creates the file on the drive server', async () => { diff --git a/src/context/virtual-drive/files/application/create/FileCreator.ts b/src/context/virtual-drive/files/application/create/FileCreator.ts index 30030c2a85..552ff0314c 100644 --- a/src/context/virtual-drive/files/application/create/FileCreator.ts +++ b/src/context/virtual-drive/files/application/create/FileCreator.ts @@ -12,6 +12,7 @@ import { SyncFileMessenger } from '../../domain/SyncFileMessenger'; import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; import { FileContentsId } from '../../domain/FileContentsId'; import { FileFolderId } from '../../domain/FileFolderId'; +import { PendingFolderCreationTracker } from '../../../folders/application/create/PendingFolderCreationTracker'; @Service() export class FileCreator { @@ -21,41 +22,47 @@ export class FileCreator { private readonly parentFolderFinder: ParentFolderFinder, private readonly eventBus: EventBus, private readonly notifier: SyncFileMessenger, + private readonly pendingFolderCreationTracker: PendingFolderCreationTracker, ) {} async run(path: string, contentsId: string, size: number): Promise { try { - const fileSize = new FileSize(size); - const fileContentsId = new FileContentsId(contentsId); - const filePath = new FilePath(path); + const file = await this.pendingFolderCreationTracker.runAfterParentCreations({ + path, + action: async () => { + const fileSize = new FileSize(size); + const fileContentsId = new FileContentsId(contentsId); + const filePath = new FilePath(path); - const folder = await this.parentFolderFinder.run(filePath); - const fileFolderId = new FileFolderId(folder.id); + const folder = await this.parentFolderFinder.run(filePath); + const fileFolderId = new FileFolderId(folder.id); - const either = await this.remote.persist({ - contentsId: fileContentsId, - path: filePath, - size: fileSize, - folderId: fileFolderId, - folderUuid: folder.uuid, - }); + const either = await this.remote.persist({ + contentsId: fileContentsId, + path: filePath, + size: fileSize, + folderId: fileFolderId, + folderUuid: folder.uuid, + }); - if (either.isLeft()) { - throw either.getLeft(); - } + if (either.isLeft()) { + throw either.getLeft(); + } - const { modificationTime, id, uuid, createdAt } = either.getRight(); + const { modificationTime, id, uuid, createdAt } = either.getRight(); - const file = File.create({ - id, - uuid, - contentsId: fileContentsId.value, - folderId: fileFolderId.value, - createdAt, - modificationTime, - path: filePath.value, - size: fileSize.value, - updatedAt: modificationTime, + return File.create({ + id, + uuid, + contentsId: fileContentsId.value, + folderId: fileFolderId.value, + createdAt, + modificationTime, + path: filePath.value, + size: fileSize.value, + updatedAt: modificationTime, + }); + }, }); await this.repository.upsert(file); diff --git a/src/context/virtual-drive/folders/__mocks__/FolderRemoteFileSystemMock.ts b/src/context/virtual-drive/folders/__mocks__/FolderRemoteFileSystemMock.ts index 6a3d76e2ab..450be67619 100644 --- a/src/context/virtual-drive/folders/__mocks__/FolderRemoteFileSystemMock.ts +++ b/src/context/virtual-drive/folders/__mocks__/FolderRemoteFileSystemMock.ts @@ -1,4 +1,4 @@ -import { Either, right } from '../../../shared/domain/Either'; +import { Either, left, right } from '../../../shared/domain/Either'; import { Folder } from '../domain/Folder'; import { FolderId } from '../domain/FolderId'; import { FolderPath } from '../domain/FolderPath'; @@ -39,6 +39,15 @@ export class FolderRemoteFileSystemMock implements RemoteFileSystem { ); } + shouldFailPersistWith(plainName: string, parentFolderUuid: string, error: RemoteFileSystemErrors) { + this.persistMock(plainName, parentFolderUuid); + this.persistMock.mockResolvedValueOnce(left(error)); + } + + shouldFindFolder(folder?: Folder) { + this.searchWithMock.mockResolvedValueOnce(folder); + } + shouldTrash(folder: Folder, error?: Error) { this.trashMock(folder.id); diff --git a/src/context/virtual-drive/folders/application/create/FolderCreator.test.ts b/src/context/virtual-drive/folders/application/create/FolderCreator.test.ts index 5579a14235..b1d4996027 100644 --- a/src/context/virtual-drive/folders/application/create/FolderCreator.test.ts +++ b/src/context/virtual-drive/folders/application/create/FolderCreator.test.ts @@ -9,6 +9,7 @@ import { FolderRemoteFileSystemMock } from '../../__mocks__/FolderRemoteFileSyst import { FolderRepositoryMock } from '../../__mocks__/FolderRepositoryMock'; import { FolderPathMother } from '../../domain/__test-helpers__/FolderPathMother'; import { FolderMother } from '../../domain/__test-helpers__/FolderMother'; +import { PendingFolderCreationTracker } from './PendingFolderCreationTracker'; describe('Folder Creator', () => { let repository: FolderRepositoryMock; @@ -21,10 +22,11 @@ describe('Folder Creator', () => { repository = new FolderRepositoryMock(); remote = new FolderRemoteFileSystemMock(); eventBus = new EventBusMock(); + const pendingFolderCreationTracker = new PendingFolderCreationTracker(); const parentFolderFinder = new ParentFolderFinder(repository); - SUT = new FolderCreator(repository, parentFolderFinder, remote, eventBus); + SUT = new FolderCreator(repository, parentFolderFinder, remote, eventBus, pendingFolderCreationTracker); }); it('throws an InvalidArgument error if the path is not a valid posix path', async () => { @@ -106,4 +108,33 @@ describe('Folder Creator', () => { expect.arrayContaining([expect.objectContaining({ aggregateId: createdFolder.uuid })]), ); }); + + it('throws when remote folder creation fails with non-recoverable error', async () => { + const path = FolderPathMother.any(); + const parent = FolderMother.fromPartial({ path: path.dirname() }); + + remote.shouldFailPersistWith(path.name(), parent.uuid, 'UNHANDLED'); + repository.matchingPartialMock.mockReturnValueOnce([]).mockReturnValueOnce([parent]).mockReturnValueOnce([parent]); + + await expect(SUT.run(path.value)).rejects.toThrow(`Could not create folder ${path.value}: UNHANDLED`); + }); + + it('recovers from ALREADY_EXISTS by finding the folder remotely', async () => { + const path = FolderPathMother.any(); + const parent = FolderMother.fromPartial({ path: path.dirname() }); + const existingFolder = FolderMother.fromPartial({ + path: path.value, + parentId: parent.id, + }); + + remote.shouldFailPersistWith(path.name(), parent.uuid, 'ALREADY_EXISTS'); + remote.shouldFindFolder(existingFolder); + + repository.matchingPartialMock.mockReturnValueOnce([]).mockReturnValueOnce([parent]).mockReturnValueOnce([parent]); + + await SUT.run(path.value); + + expect(repository.addMock).toBeCalledWith(expect.objectContaining({ uuid: existingFolder.uuid })); + expect(eventBus.publishMock).not.toBeCalled(); + }); }); diff --git a/src/context/virtual-drive/folders/application/create/FolderCreator.ts b/src/context/virtual-drive/folders/application/create/FolderCreator.ts index d884069072..abd7735854 100644 --- a/src/context/virtual-drive/folders/application/create/FolderCreator.ts +++ b/src/context/virtual-drive/folders/application/create/FolderCreator.ts @@ -12,6 +12,7 @@ import { FolderUuid } from '../../domain/FolderUuid'; import { FolderInPathAlreadyExistsError } from '../../domain/errors/FolderInPathAlreadyExistsError'; import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; import { ParentFolderFinder } from '../ParentFolderFinder'; +import { PendingFolderCreationTracker } from './PendingFolderCreationTracker'; @Service() export class FolderCreator { @@ -20,6 +21,7 @@ export class FolderCreator { private readonly parentFolderFinder: ParentFolderFinder, private readonly remote: RemoteFileSystem, private readonly eventBus: EventBus, + private readonly pendingFolderCreationTracker: PendingFolderCreationTracker, ) {} private async ensureItDoesNotExists(path: FolderPath): Promise { @@ -39,36 +41,53 @@ export class FolderCreator { } async run(path: string): Promise { - const folderPath = new FolderPath(path); + await this.pendingFolderCreationTracker.runTrackingCreation({ + path, + action: async () => { + const folderPath = new FolderPath(path); - await this.ensureItDoesNotExists(folderPath); - const parent = await this.parentFolderFinder.run(folderPath); - const parentId = await this.findParentId(folderPath); + await this.ensureItDoesNotExists(folderPath); + const parent = await this.parentFolderFinder.run(folderPath); + const parentId = await this.findParentId(folderPath); - const response = await this.remote.persist(folderPath.name(), parent.uuid); + const response = await this.remote.persist(folderPath.name(), parent.uuid); - if (response.isLeft()) { - logger.error({ - msg: 'Error creating folder:', - error: response.getLeft(), - }); - return; - } + if (response.isLeft()) { + const error = response.getLeft(); + + logger.error({ + msg: 'Error creating folder:', + error, + }); + + if (error === 'ALREADY_EXISTS') { + const existingFolder = await this.remote.searchWith(parentId, folderPath); - const dto = response.getRight(); + if (existingFolder) { + await this.repository.add(existingFolder); + return; + } + } - const folder = Folder.create( - new FolderId(dto.id), - new FolderUuid(dto.uuid), - folderPath, - parentId, - FolderCreatedAt.fromString(dto.createdAt), - FolderUpdatedAt.fromString(dto.updatedAt), - ); + throw new Error(`Could not create folder ${folderPath.value}: ${error}`); + } - await this.repository.add(folder); + const dto = response.getRight(); - const events = folder.pullDomainEvents(); - this.eventBus.publish(events); + const folder = Folder.create( + new FolderId(dto.id), + new FolderUuid(dto.uuid), + folderPath, + parentId, + FolderCreatedAt.fromString(dto.createdAt), + FolderUpdatedAt.fromString(dto.updatedAt), + ); + + await this.repository.add(folder); + + const events = folder.pullDomainEvents(); + this.eventBus.publish(events); + }, + }); } } diff --git a/src/context/virtual-drive/folders/application/create/PendingFolderCreationTracker.test.ts b/src/context/virtual-drive/folders/application/create/PendingFolderCreationTracker.test.ts new file mode 100644 index 0000000000..f9cc992bdb --- /dev/null +++ b/src/context/virtual-drive/folders/application/create/PendingFolderCreationTracker.test.ts @@ -0,0 +1,41 @@ +import { PendingFolderCreationTracker } from './PendingFolderCreationTracker'; + +describe('PendingFolderCreationTracker', () => { + it('waits for a parent folder creation before running child action', async () => { + const tracker = new PendingFolderCreationTracker(); + + let resolveParentCreation: (() => void) | undefined; + const events: string[] = []; + + const parentPromise = tracker.runTrackingCreation({ + path: '/Documents', + action: async () => { + events.push('parent-started'); + + await new Promise((resolve) => { + resolveParentCreation = resolve; + }); + + events.push('parent-finished'); + }, + }); + + const childPromise = tracker.runAfterParentCreations({ + path: '/Documents/Taxes/file.txt', + action: async () => { + events.push('child-started'); + }, + }); + + await Promise.resolve(); + + expect(events).toStrictEqual(['parent-started']); + + resolveParentCreation?.(); + + await parentPromise; + await childPromise; + + expect(events).toStrictEqual(['parent-started', 'parent-finished', 'child-started']); + }); +}); diff --git a/src/context/virtual-drive/folders/application/create/PendingFolderCreationTracker.ts b/src/context/virtual-drive/folders/application/create/PendingFolderCreationTracker.ts new file mode 100644 index 0000000000..a60cfbd45e --- /dev/null +++ b/src/context/virtual-drive/folders/application/create/PendingFolderCreationTracker.ts @@ -0,0 +1,81 @@ +import { posix } from 'node:path'; +import { Service } from 'diod'; + +type RunAfterParentCreationsPops = { + path: string; + action: () => Promise; +}; + +function normalizePath(path: string): string { + const normalizedPath = posix.normalize(path); + + if (normalizedPath.length > 1 && normalizedPath.endsWith('/')) { + return normalizedPath.slice(0, -1); + } + + return normalizedPath; +} + +function getParentPaths(path: string): string[] { + const normalizedPath = normalizePath(path); + const parentPaths: string[] = []; + + let currentPath = posix.dirname(normalizedPath); + + while (currentPath !== '.' && currentPath !== '/') { + parentPaths.unshift(currentPath); + currentPath = posix.dirname(currentPath); + } + + return parentPaths; +} + +@Service() +export class PendingFolderCreationTracker { + private readonly pendingFolderCreationByPath = new Map>(); + + async runAfterParentCreations({ path, action }: RunAfterParentCreationsPops): Promise { + const pendingParentCreations = this.getPendingParentCreations(path); + + if (pendingParentCreations.length > 0) { + await Promise.all(pendingParentCreations); + } + + return action(); + } + + async runTrackingCreation({ path, action }: RunAfterParentCreationsPops): Promise { + const pendingParentCreations = this.getPendingParentCreations(path); + + if (pendingParentCreations.length > 0) { + await Promise.all(pendingParentCreations); + } + + const creationPromise = action(); + this.track(path, creationPromise); + + return creationPromise; + } + + private track(path: string, creationPromise: Promise): void { + const normalizedPath = normalizePath(path); + + const pendingPromise = creationPromise.then(() => undefined).catch(() => undefined); + + this.pendingFolderCreationByPath.set(normalizedPath, pendingPromise); + + void pendingPromise.finally(() => { + if (this.pendingFolderCreationByPath.get(normalizedPath) === pendingPromise) { + this.pendingFolderCreationByPath.delete(normalizedPath); + } + }); + } + + private getPendingParentCreations(path: string): Promise[] { + const parentPaths = getParentPaths(path); + + return parentPaths + .map((parentPath) => this.pendingFolderCreationByPath.get(parentPath)) + .filter((pending): pending is Promise => Boolean(pending)); + } +}