feat: deleteDockerEntries mutation (#1564)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added the ability to delete multiple Docker entries (including folders
and their descendants) via a new mutation in the interface.
* **Bug Fixes**
* Ensured that deleting entries handles complex folder hierarchies,
circular references, and missing references robustly.
* **Tests**
* Introduced comprehensive tests for deleting entries and handling
organizer structures, ensuring correct behavior in various scenarios and
edge cases.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pujit Mehrotra
2025-08-07 09:28:09 -04:00
committed by GitHub
parent 3534d6fdd7
commit 78997a02c6
6 changed files with 1571 additions and 0 deletions

View File

@@ -1874,6 +1874,7 @@ type Mutation {
rclone: RCloneMutations!
createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1!
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1!
"""Initiates a flash drive backup using a configured remote."""
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!

View File

@@ -350,4 +350,409 @@ describe('DockerOrganizerService', () => {
).rejects.toThrow();
});
});
describe('deleteEntries', () => {
// Test constants to avoid magic values
const TEST_FOLDER_ID = 'testFolder';
const TEST_ENTRY_ID = 'testEntry';
const PERFORMANCE_TEST_SIZE = 50; // Reduced for faster tests
// Helper function to create test organizer with specific entries
const createTestOrganizer = (entries: Record<string, any> = {}) => {
const organizer = structuredClone(mockOrganizer);
Object.assign(organizer.views.default.entries, entries);
return organizer;
};
// Helper to get typed root folder
const getRootFolder = (result: any) => result.views.default.entries.root;
it('should delete entries and maintain proper orchestration', async () => {
const testOrganizer = createTestOrganizer({
[TEST_FOLDER_ID]: {
id: TEST_FOLDER_ID,
type: 'folder',
name: 'Test Folder',
children: [],
},
});
(configService.getConfig as any).mockReturnValue(testOrganizer);
const result = await service.deleteEntries({
entryIds: new Set([TEST_FOLDER_ID]),
});
// Verify service contract fulfillment
expect(result).toBeDefined();
expect(result.version).toBe(1);
expect(result.views.default).toBeDefined();
// Verify service orchestration without being overly specific
expect(configService.getConfig).toHaveBeenCalled();
expect(configService.validate).toHaveBeenCalled();
expect(configService.replaceConfig).toHaveBeenCalled();
// Verify the deletion outcome
expect(result.views.default.entries[TEST_FOLDER_ID]).toBeUndefined();
});
it('should handle empty entryIds set gracefully', async () => {
const originalEntryCount = Object.keys(mockOrganizer.views.default.entries).length;
const result = await service.deleteEntries({
entryIds: new Set(),
});
// Verify basic service contract
expect(result).toBeDefined();
expect(result.version).toBe(1);
expect(configService.validate).toHaveBeenCalled();
expect(configService.replaceConfig).toHaveBeenCalled();
// Verify no unintended deletions occurred
expect(Object.keys(result.views.default.entries).length).toBeGreaterThanOrEqual(
originalEntryCount
);
expect(result.views.default.entries.existingFolder).toBeDefined();
});
it('should synchronize resources during operation', async () => {
const result = await service.deleteEntries({
entryIds: new Set(),
});
// Verify resources structure is maintained and updated
expect(result.resources).toBeDefined();
expect(typeof result.resources).toBe('object');
// Verify container resources are properly structured
const containerResources = Object.values(result.resources).filter(
(resource: any) => resource.type === 'container'
);
expect(containerResources.length).toBeGreaterThan(0);
// Each container resource should have required properties
containerResources.forEach((resource: any) => {
expect(resource).toHaveProperty('id');
expect(resource).toHaveProperty('type', 'container');
expect(resource).toHaveProperty('name');
expect(resource).toHaveProperty('meta');
});
});
it('should handle deletion of non-existent entries gracefully', async () => {
const NON_EXISTENT_ID = 'definitivelyDoesNotExist';
const originalEntries = Object.keys(mockOrganizer.views.default.entries);
const result = await service.deleteEntries({
entryIds: new Set([NON_EXISTENT_ID]),
});
// Verify service completed successfully
expect(result).toBeDefined();
expect(result.version).toBe(1);
// Verify no existing entries were accidentally deleted
originalEntries.forEach((entryId) => {
expect(result.views.default.entries[entryId]).toBeDefined();
});
});
it('should handle mixed valid and invalid entry deletion', async () => {
const VALID_ENTRY = 'existingFolder';
const INVALID_ENTRY = 'nonExistentEntry';
const result = await service.deleteEntries({
entryIds: new Set([VALID_ENTRY, INVALID_ENTRY]),
});
// Verify operation completed successfully despite invalid entry
expect(result).toBeDefined();
expect(result.version).toBe(1);
// Valid entry should be deleted, invalid entry should be ignored
expect(result.views.default.entries[VALID_ENTRY]).toBeUndefined();
expect(result.views.default.entries[INVALID_ENTRY]).toBeUndefined(); // Never existed
});
it('should perform synchronization as part of operation', async () => {
const syncSpy = vi.spyOn(service, 'syncAndGetOrganizer');
const result = await service.deleteEntries({
entryIds: new Set(),
});
// Verify sync occurred and result reflects synchronized state
expect(syncSpy).toHaveBeenCalled();
expect(result.resources).toBeDefined();
expect(Object.keys(result.resources).length).toBeGreaterThan(0);
});
it('should handle cascading deletions correctly', async () => {
const PARENT_FOLDER = 'parentFolder';
const CHILD_FOLDER = 'childFolder';
const hierarchicalOrganizer = createTestOrganizer({
[PARENT_FOLDER]: {
id: PARENT_FOLDER,
type: 'folder',
name: 'Parent Folder',
children: [CHILD_FOLDER],
},
[CHILD_FOLDER]: {
id: CHILD_FOLDER,
type: 'folder',
name: 'Child Folder',
children: [],
},
});
const rootFolder = getRootFolder(hierarchicalOrganizer);
rootFolder.children = [PARENT_FOLDER];
(configService.getConfig as any).mockReturnValue(hierarchicalOrganizer);
const result = await service.deleteEntries({
entryIds: new Set([PARENT_FOLDER]),
});
// Both parent and child should be deleted due to cascading
expect(result.views.default.entries[PARENT_FOLDER]).toBeUndefined();
expect(result.views.default.entries[CHILD_FOLDER]).toBeUndefined();
// Root should no longer reference deleted parent
const resultRoot = getRootFolder(result);
expect(resultRoot.children).not.toContain(PARENT_FOLDER);
});
it('should handle validation failure appropriately', async () => {
const validationError = new Error('Configuration validation failed');
(configService.validate as any).mockRejectedValue(validationError);
await expect(
service.deleteEntries({
entryIds: new Set([TEST_FOLDER_ID]),
})
).rejects.toThrow();
// Should not save invalid configuration
expect(configService.replaceConfig).not.toHaveBeenCalled();
});
it('should handle docker service failure gracefully', async () => {
const dockerError = new Error('Docker service unavailable');
(dockerService.getContainers as any).mockRejectedValue(dockerError);
await expect(
service.deleteEntries({
entryIds: new Set([TEST_FOLDER_ID]),
})
).rejects.toThrow();
// Should fail early before attempting validation/save
expect(configService.replaceConfig).not.toHaveBeenCalled();
});
it('should handle complex folder hierarchies correctly', async () => {
const PARENT_FOLDER = 'parentFolder';
const CHILD_FOLDER = 'childFolder';
const SIBLING_FOLDER = 'siblingFolder';
const complexOrganizer = createTestOrganizer({
[PARENT_FOLDER]: {
id: PARENT_FOLDER,
type: 'folder',
name: 'Parent Folder',
children: ['existingFolder'], // References existing mock entry
},
[SIBLING_FOLDER]: {
id: SIBLING_FOLDER,
type: 'folder',
name: 'Sibling Folder',
children: [],
},
});
const rootFolder = getRootFolder(complexOrganizer);
rootFolder.children = [PARENT_FOLDER, SIBLING_FOLDER];
(configService.getConfig as any).mockReturnValue(complexOrganizer);
const result = await service.deleteEntries({
entryIds: new Set([PARENT_FOLDER]),
});
// Verify targeted deletion occurred
expect(result.views.default.entries[PARENT_FOLDER]).toBeUndefined();
expect(result.views.default.entries.existingFolder).toBeUndefined(); // Cascaded deletion
// Verify unrelated entries are preserved
expect(result.views.default.entries[SIBLING_FOLDER]).toBeDefined();
// Verify view structure integrity
const resultRoot = getRootFolder(result);
expect(resultRoot.children).not.toContain(PARENT_FOLDER);
expect(resultRoot.children).toContain(SIBLING_FOLDER);
});
it('should maintain resource integrity after operations', async () => {
const result = await service.deleteEntries({
entryIds: new Set(['existingFolder']),
});
// Verify resources maintain expected structure and content
expect(result.resources).toBeDefined();
expect(typeof result.resources).toBe('object');
// Verify each resource has consistent structure
Object.entries(result.resources).forEach(([resourceId, resource]: [string, any]) => {
expect(resource).toHaveProperty('id', resourceId);
expect(resource).toHaveProperty('type');
expect(resource).toHaveProperty('name');
// Container resources should have metadata
if (resource.type === 'container') {
expect(resource).toHaveProperty('meta');
expect(resource.meta).toBeDefined();
}
});
});
it('should maintain data consistency throughout operation', async () => {
// Test that the service maintains data integrity without testing specific call sequences
let configGetCount = 0;
let validateCount = 0;
let replaceCount = 0;
(configService.getConfig as any).mockImplementation(() => {
configGetCount++;
return structuredClone(mockOrganizer);
});
(configService.validate as any).mockImplementation((config: any) => {
validateCount++;
// Validate that we received a proper config object
expect(config).toHaveProperty('version');
expect(config).toHaveProperty('resources');
expect(config).toHaveProperty('views');
return Promise.resolve(config);
});
(configService.replaceConfig as any).mockImplementation((config: any) => {
replaceCount++;
// Validate that we're saving a consistent config
expect(config).toHaveProperty('version');
expect(config.views.default).toBeDefined();
});
const result = await service.deleteEntries({
entryIds: new Set(['existingFolder']),
});
// Verify essential operations occurred without being overly specific about sequence
expect(configGetCount).toBeGreaterThan(0);
expect(validateCount).toBeGreaterThan(0);
expect(replaceCount).toBeGreaterThan(0);
expect(result).toBeDefined();
});
it('should handle deletion when default view is missing', async () => {
const organizerWithoutDefaultView = structuredClone(mockOrganizer);
delete organizerWithoutDefaultView.views.default;
(configService.getConfig as any).mockReturnValue(organizerWithoutDefaultView);
const result = await service.deleteEntries({
entryIds: new Set(['someEntry']),
});
// Should still work and create/maintain proper structure
expect(result.views.default).toBeDefined();
expect(configService.validate).toHaveBeenCalled();
expect(configService.replaceConfig).toHaveBeenCalled();
});
it('should maintain relative order of remaining entries', async () => {
const ENTRIES = ['entryA', 'entryB', 'entryC', 'entryD'];
const TO_DELETE = ['entryB', 'entryD'];
const EXPECTED_REMAINING = ['entryA', 'entryC'];
const organizerWithOrdering = createTestOrganizer();
const rootFolder = getRootFolder(organizerWithOrdering);
rootFolder.children = [...ENTRIES];
// Create the test entries
ENTRIES.forEach((entryId) => {
organizerWithOrdering.views.default.entries[entryId] = {
id: entryId,
type: 'ref',
target: `target_${entryId}`,
};
});
(configService.getConfig as any).mockReturnValue(organizerWithOrdering);
const result = await service.deleteEntries({
entryIds: new Set(TO_DELETE),
});
const resultRoot = getRootFolder(result);
// Verify deleted entries are gone
TO_DELETE.forEach((entryId) => {
expect(result.views.default.entries[entryId]).toBeUndefined();
expect(resultRoot.children).not.toContain(entryId);
});
// Verify remaining entries are present and in relative order
EXPECTED_REMAINING.forEach((entryId) => {
expect(result.views.default.entries[entryId]).toBeDefined();
expect(resultRoot.children).toContain(entryId);
});
// Check that relative order is preserved among remaining entries
const remainingPositions = EXPECTED_REMAINING.map((id) => resultRoot.children.indexOf(id));
expect(remainingPositions[0]).toBeLessThan(remainingPositions[1]); // entryA before entryC
});
it('should handle bulk operations efficiently', async () => {
const bulkOrganizer = createTestOrganizer();
const entriesToDelete = new Set<string>();
// Create test entries for bulk deletion
for (let i = 0; i < PERFORMANCE_TEST_SIZE; i++) {
const entryId = `bulkEntry${i}`;
entriesToDelete.add(entryId);
bulkOrganizer.views.default.entries[entryId] = {
id: entryId,
type: 'ref',
target: `bulkTarget${i}`,
};
}
const rootFolder = getRootFolder(bulkOrganizer);
rootFolder.children.push(...Array.from(entriesToDelete));
(configService.getConfig as any).mockReturnValue(bulkOrganizer);
const startTime = Date.now();
const result = await service.deleteEntries({
entryIds: entriesToDelete,
});
const endTime = Date.now();
// Verify all bulk entries were deleted
entriesToDelete.forEach((entryId) => {
expect(result.views.default.entries[entryId]).toBeUndefined();
});
const resultRoot = getRootFolder(result);
entriesToDelete.forEach((entryId) => {
expect(resultRoot.children).not.toContain(entryId);
});
// Verify operation completed in reasonable time (not a strict performance test)
expect(endTime - startTime).toBeLessThan(5000); // 5 seconds should be more than enough
// Verify service contract still fulfilled
expect(result).toBeDefined();
expect(result.version).toBe(1);
});
});
});

View File

@@ -11,6 +11,7 @@ import {
createFolderInView,
DEFAULT_ORGANIZER_ROOT_ID,
DEFAULT_ORGANIZER_VIEW_ID,
deleteOrganizerEntries,
resolveOrganizer,
setFolderChildrenInView,
} from '@app/unraid-api/organizer/organizer.js';
@@ -183,4 +184,17 @@ export class DockerOrganizerService {
this.dockerConfigService.replaceConfig(validated);
return validated;
}
async deleteEntries(params: { entryIds: Set<string> }): Promise<OrganizerV1> {
const { entryIds } = params;
const organizer = await this.syncAndGetOrganizer();
const newOrganizer = structuredClone(organizer);
deleteOrganizerEntries(newOrganizer.views.default, entryIds, { mutate: true });
addMissingResourcesToView(newOrganizer.resources, newOrganizer.views.default);
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
return validated;
}
}

View File

@@ -105,4 +105,17 @@ export class DockerResolver {
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.DOCKER,
possession: AuthPossession.ANY,
})
@Mutation(() => ResolvedOrganizerV1)
public async deleteDockerEntries(@Args('entryIds', { type: () => [String] }) entryIds: string[]) {
const organizer = await this.dockerOrganizerService.deleteEntries({
entryIds: new Set(entryIds),
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
}

View File

@@ -0,0 +1,991 @@
import { describe, expect, it } from 'vitest';
import { collectDescendants, deleteOrganizerEntries } from '@app/unraid-api/organizer/organizer.js';
import { OrganizerView } from '@app/unraid-api/organizer/organizer.model.js';
describe('collectDescendants', () => {
const createMockView = (entries: Record<string, any>): OrganizerView => ({
id: 'test-view',
name: 'Test View',
root: 'root',
entries,
});
it('should collect single entry (resource ref)', () => {
const view = createMockView({
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = collectDescendants(view, 'resource1');
expect(result).toEqual(new Set(['resource1']));
});
it('should collect folder with children', () => {
const view = createMockView({
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['resource1', 'resource2'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
});
const result = collectDescendants(view, 'folder1');
expect(result).toEqual(new Set(['folder1', 'resource1', 'resource2']));
});
it('should collect nested folders recursively', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1', 'resource1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['folder2', 'resource2'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['resource3'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
resource3: {
id: 'resource3',
type: 'ref',
target: 'target3',
},
});
const result = collectDescendants(view, 'root');
expect(result).toEqual(
new Set(['root', 'folder1', 'resource1', 'folder2', 'resource2', 'resource3'])
);
});
it('should handle empty folder', () => {
const view = createMockView({
emptyFolder: {
id: 'emptyFolder',
type: 'folder',
name: 'Empty Folder',
children: [],
},
});
const result = collectDescendants(view, 'emptyFolder');
expect(result).toEqual(new Set(['emptyFolder']));
});
it('should return empty set for non-existent entry', () => {
const view = createMockView({});
const result = collectDescendants(view, 'non-existent');
expect(result).toEqual(new Set());
});
it('should handle circular references without infinite loop', () => {
const view = createMockView({
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['folder2'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['folder1', 'resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = collectDescendants(view, 'folder1');
expect(result).toEqual(new Set(['folder1', 'folder2', 'resource1']));
});
it('should use provided collection when passed', () => {
const view = createMockView({
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const existingCollection = new Set(['existing-item']);
const result = collectDescendants(view, 'resource1', existingCollection);
expect(result).toEqual(new Set(['existing-item', 'resource1']));
expect(result).toBe(existingCollection); // Should modify the same set
});
it('should handle deep nesting', () => {
const entries: Record<string, any> = {};
const expectedSet = new Set<string>();
// Create a deeply nested structure: folder0 -> folder1 -> ... -> folder9 -> resource
for (let i = 0; i < 10; i++) {
const folderId = `folder${i}`;
const nextId = i === 9 ? 'deepResource' : `folder${i + 1}`;
entries[folderId] = {
id: folderId,
type: 'folder',
name: `Folder ${i}`,
children: [nextId],
};
expectedSet.add(folderId);
}
entries.deepResource = {
id: 'deepResource',
type: 'ref',
target: 'deepTarget',
};
expectedSet.add('deepResource');
const view = createMockView(entries);
const result = collectDescendants(view, 'folder0');
expect(result).toEqual(expectedSet);
});
it('should handle mixed content (folders and refs)', () => {
const view = createMockView({
mixedFolder: {
id: 'mixedFolder',
type: 'folder',
name: 'Mixed Content',
children: ['subFolder', 'resource1', 'resource2'],
},
subFolder: {
id: 'subFolder',
type: 'folder',
name: 'Sub Folder',
children: ['resource3'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
resource3: {
id: 'resource3',
type: 'ref',
target: 'target3',
},
});
const result = collectDescendants(view, 'mixedFolder');
expect(result).toEqual(
new Set(['mixedFolder', 'subFolder', 'resource1', 'resource2', 'resource3'])
);
});
it('should handle entries with children that do not exist in view', () => {
const view = createMockView({
brokenFolder: {
id: 'brokenFolder',
type: 'folder',
name: 'Broken Folder',
children: ['existingResource', 'nonExistentResource'],
},
existingResource: {
id: 'existingResource',
type: 'ref',
target: 'target1',
},
});
const result = collectDescendants(view, 'brokenFolder');
expect(result).toEqual(new Set(['brokenFolder', 'existingResource']));
});
it('should handle self-referencing entry', () => {
const view = createMockView({
selfRef: {
id: 'selfRef',
type: 'folder',
name: 'Self Reference',
children: ['selfRef', 'resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = collectDescendants(view, 'selfRef');
expect(result).toEqual(new Set(['selfRef', 'resource1']));
});
it('should preserve insertion order in the set', () => {
const view = createMockView({
folder: {
id: 'folder',
type: 'folder',
name: 'Ordered Folder',
children: ['c', 'a', 'b'],
},
a: { id: 'a', type: 'ref', target: 'targetA' },
b: { id: 'b', type: 'ref', target: 'targetB' },
c: { id: 'c', type: 'ref', target: 'targetC' },
});
const result = collectDescendants(view, 'folder');
const resultArray = Array.from(result);
expect(resultArray).toEqual(['folder', 'c', 'a', 'b']);
});
});
describe('deleteOrganizerEntries', () => {
const createMockView = (entries: Record<string, any>): OrganizerView => ({
id: 'test-view',
name: 'Test View',
root: 'root',
entries,
});
it('should delete a single resource ref', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1', 'resource2'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
});
const result = deleteOrganizerEntries(view, new Set(['resource1']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource2'],
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
});
expect(result).not.toBe(view); // Should return new object
});
it('should delete a folder and all its descendants', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1', 'resource1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['resource2', 'resource3'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
resource3: {
id: 'resource3',
type: 'ref',
target: 'target3',
},
});
const result = deleteOrganizerEntries(view, new Set(['folder1']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
});
it('should delete multiple entries at once', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1', 'resource2', 'resource3'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
resource3: {
id: 'resource3',
type: 'ref',
target: 'target3',
},
});
const result = deleteOrganizerEntries(view, new Set(['resource1', 'resource3']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource2'],
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
});
});
it('should delete nested folders recursively', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['folder2'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set(['folder1']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: [],
},
});
});
it('should handle deletion of non-existent entries gracefully', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set(['non-existent']));
expect(result.entries).toEqual(view.entries);
});
it('should mutate original view when mutate=true', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set(['resource1']), { mutate: true });
expect(result).toBe(view); // Should return same object
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: [],
},
});
});
it('should not mutate original view when mutate=false (default)', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const originalEntries = structuredClone(view.entries);
const result = deleteOrganizerEntries(view, new Set(['resource1']));
expect(result).not.toBe(view); // Should return new object
expect(view.entries).toEqual(originalEntries); // Original should be unchanged
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: [],
},
});
});
it('should handle entries referenced by multiple parents', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1', 'folder2'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['resource1'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['resource1', 'resource2'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
});
const result = deleteOrganizerEntries(view, new Set(['resource1']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1', 'folder2'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: [],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['resource2'],
},
resource2: {
id: 'resource2',
type: 'ref',
target: 'target2',
},
});
});
it('should handle circular references during deletion', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['folder2'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['folder1', 'resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set(['folder1']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: [],
},
});
});
it('should handle deletion of root folder', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set(['root']));
expect(result.entries).toEqual({});
});
it('should handle empty deletion set', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set());
expect(result.entries).toEqual(view.entries);
expect(result).not.toBe(view); // Should still return new object
});
it('should handle deletion when parent folders have broken references', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1', 'non-existent'],
},
brokenFolder: {
id: 'brokenFolder',
type: 'folder',
name: 'Broken Folder',
children: ['resource1', 'another-non-existent'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set(['resource1']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['non-existent'],
},
brokenFolder: {
id: 'brokenFolder',
type: 'folder',
name: 'Broken Folder',
children: ['another-non-existent'],
},
});
});
it('should handle deletion of all entries', () => {
const view = createMockView({
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set(['folder1', 'resource1']));
expect(result.entries).toEqual({});
});
it('should handle self-referencing entries', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['selfRef'],
},
selfRef: {
id: 'selfRef',
type: 'folder',
name: 'Self Reference',
children: ['selfRef', 'resource1'],
},
resource1: {
id: 'resource1',
type: 'ref',
target: 'target1',
},
});
const result = deleteOrganizerEntries(view, new Set(['selfRef']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: [],
},
});
});
it('should preserve order of remaining children after deletion', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['a', 'b', 'c', 'd', 'e'],
},
a: { id: 'a', type: 'ref', target: 'targetA' },
b: { id: 'b', type: 'ref', target: 'targetB' },
c: { id: 'c', type: 'ref', target: 'targetC' },
d: { id: 'd', type: 'ref', target: 'targetD' },
e: { id: 'e', type: 'ref', target: 'targetE' },
});
const result = deleteOrganizerEntries(view, new Set(['b', 'd']));
expect((result.entries.root as any).children).toEqual(['a', 'c', 'e']);
});
it('should handle complex nested structure with mixed deletion', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1', 'folder2', 'resource1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['folder3', 'resource2'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['resource3'],
},
folder3: {
id: 'folder3',
type: 'folder',
name: 'Folder 3',
children: ['resource4'],
},
resource1: { id: 'resource1', type: 'ref', target: 'target1' },
resource2: { id: 'resource2', type: 'ref', target: 'target2' },
resource3: { id: 'resource3', type: 'ref', target: 'target3' },
resource4: { id: 'resource4', type: 'ref', target: 'target4' },
});
const result = deleteOrganizerEntries(view, new Set(['folder1', 'resource1']));
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder2'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['resource3'],
},
resource3: { id: 'resource3', type: 'ref', target: 'target3' },
});
});
it('should handle pre-collected descendants (dry run behavior)', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1', 'resource1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['folder2', 'resource2'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['resource3'],
},
resource1: { id: 'resource1', type: 'ref', target: 'target1' },
resource2: { id: 'resource2', type: 'ref', target: 'target2' },
resource3: { id: 'resource3', type: 'ref', target: 'target3' },
});
// Method 1: Direct deletion
const directResult = deleteOrganizerEntries(view, new Set(['folder1']));
// Method 2: Pre-collect descendants (dry run), then delete
const descendants = collectDescendants(view, 'folder1');
const preCollectedResult = deleteOrganizerEntries(view, descendants);
// Results should be identical
expect(preCollectedResult.entries).toEqual(directResult.entries);
expect(descendants).toEqual(new Set(['folder1', 'folder2', 'resource2', 'resource3']));
// Both should produce the same final state
const expectedEntries = {
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['resource1'],
},
resource1: { id: 'resource1', type: 'ref', target: 'target1' },
};
expect(directResult.entries).toEqual(expectedEntries);
expect(preCollectedResult.entries).toEqual(expectedEntries);
});
it('should optimize when descendant collector is called with already-collected entries', () => {
const view = createMockView({
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['resource1', 'resource2'],
},
resource1: { id: 'resource1', type: 'ref', target: 'target1' },
resource2: { id: 'resource2', type: 'ref', target: 'target2' },
});
// Track how many times the collector is meaningfully called
let meaningfulCalls = 0;
const trackingCollector = (view: OrganizerView, entryId: string, collection?: Set<string>) => {
const initialSize = collection?.size ?? 0;
const result = collectDescendants(view, entryId, collection);
if (result.size > initialSize) {
meaningfulCalls++;
}
return result;
};
// Pre-collect all descendants, then pass them to delete
const allDescendants = collectDescendants(view, 'folder1');
expect(allDescendants).toEqual(new Set(['folder1', 'resource1', 'resource2']));
// Reset counter and delete with pre-collected descendants
meaningfulCalls = 0;
const result = deleteOrganizerEntries(view, allDescendants, {
descendantCollector: trackingCollector,
});
// Only the first call should be meaningful (folder1),
// subsequent calls for resource1 and resource2 should early-return
expect(meaningfulCalls).toBe(1);
expect(result.entries).toEqual({});
});
it('should demonstrate early return behavior with mixed pre-collected entries', () => {
const view = createMockView({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: ['folder1', 'folder2'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder 1',
children: ['resource1'],
},
folder2: {
id: 'folder2',
type: 'folder',
name: 'Folder 2',
children: ['resource2'],
},
resource1: { id: 'resource1', type: 'ref', target: 'target1' },
resource2: { id: 'resource2', type: 'ref', target: 'target2' },
});
// Collect descendants of folder1, then add folder2 manually
const descendants = collectDescendants(view, 'folder1');
descendants.add('folder2'); // Add folder2 but not its descendants
// Track collection calls
const collectionCalls: string[] = [];
const trackingCollector = (view: OrganizerView, entryId: string, collection?: Set<string>) => {
collectionCalls.push(entryId);
return collectDescendants(view, entryId, collection);
};
const result = deleteOrganizerEntries(view, descendants, {
descendantCollector: trackingCollector,
});
// Should have been called for each entry in the set
expect(collectionCalls).toEqual(['folder1', 'resource1', 'folder2']);
// folder1 and resource1 calls should early-return since they're already collected
// folder2 call should collect its descendants (resource2)
expect(result.entries).toEqual({
root: {
id: 'root',
type: 'folder',
name: 'Root',
children: [],
},
});
});
});

View File

@@ -231,3 +231,150 @@ export function setFolderChildrenInView(params: SetFolderChildrenInViewParams):
return newView;
}
/**
* Recursively collects all descendants of an entry in an organizer view.
*
* **IMPORTANT: The returned set includes the starting `entryId` as well, not just its descendants.**
*
* This function performs a depth-first traversal of the organizer hierarchy, collecting
* all reachable entry IDs starting from the given entry. It handles various edge cases
* gracefully and prevents infinite loops in circular structures.
*
* @param view - The organizer view containing the entry definitions
* @param entryId - The ID of the entry to start collection from
* @param collection - Optional existing Set to add results to. If provided, this Set
* will be modified in-place and returned.
* @returns A Set containing the entryId and all its descendants
*
* @example
* ```typescript
* // Basic usage - collects entry and all descendants
* const descendants = collectDescendants(view, 'folder1');
* // descendants contains 'folder1' plus all nested children
*
* // Using existing collection
* const existing = new Set(['other-item']);
* const result = collectDescendants(view, 'folder1', existing);
* // result === existing (same Set object, modified in-place)
* ```
*
* @remarks
* **Behavior and Edge Cases:**
*
* - **Self-inclusion**: The starting `entryId` is always included in the result set
* - **Cycle detection**: Automatically prevents infinite loops when entries reference themselves
* or create circular dependencies. Each entry ID is only processed once.
* - **Missing entries**: If `entryId` doesn't exist in the view, returns an empty set (or
* the provided collection unchanged)
* - **Broken references**: If a folder's children array contains IDs that don't exist in the
* view, those missing children are silently skipped without errors
* - **Resource refs**: Entries with `type: 'ref'` are treated as leaf nodes - they are
* collected but don't traverse further (as they have no children)
* - **Empty folders**: Folders with no children are still collected and returned
* - **Collection mutation**: If a collection parameter is provided, it is modified in-place
* rather than creating a new Set
* - **Traversal order**: Uses depth-first traversal, processing folders before their children.
* The Set maintains insertion order, so parent entries appear before their descendants.
*
* **Performance:** O(n) where n is the number of reachable entries. Each entry is visited
* at most once due to the cycle detection mechanism.
*/
export function collectDescendants(
view: OrganizerView,
entryId: string,
collection?: Set<string>
): Set<string> {
collection ??= new Set<string>();
const entry = view.entries[entryId];
if (!entry || collection.has(entryId)) return collection;
collection.add(entryId);
if (entry.type === 'folder') {
for (const childId of entry.children) {
collectDescendants(view, childId, collection);
}
}
return collection;
}
/**
* Deletes entries from an organizer view along with all their descendants.
*
* This function performs a cascading deletion - when you delete a folder, all of its
* nested children (folders and resource references) are also deleted. The function
* automatically handles cleanup by removing deleted entry references from all parent
* folders throughout the view.
*
* @param view - The organizer view to delete entries from
* @param entryIds - Set of entry IDs to delete (folders and/or resource refs)
* @param opts - Options object
* @param opts.mutate - If true, modifies the original view in-place. If false (default),
* returns a new cloned view with deletions applied
* @returns The modified organizer view (original if mutate=true, clone if mutate=false)
*
* @example
* ```typescript
* // Delete multiple entries (returns new view)
* const updatedView = deleteOrganizerEntries(view, new Set(['folder1', 'resource2']));
*
* // Delete in-place (mutates original view)
* const sameView = deleteOrganizerEntries(view, new Set(['folder1']), { mutate: true });
* ```
*
* @remarks
* **Important Behaviors:**
*
* - **Cascading deletion**: Deleting a folder automatically deletes all its descendants
* (nested folders and resource references). Use `collectDescendants()` first if you
* need to know what will be deleted.
*
* - **Reference cleanup**: Removes deleted entry IDs from ALL folder children arrays
* throughout the view, not just direct parents. This handles cases where entries
* are referenced by multiple folders.
*
* - **Circular reference safe**: Handles circular folder structures without infinite
* loops thanks to the underlying `collectDescendants()` function.
*
* - **Graceful handling**: Non-existent entry IDs are silently ignored. Empty deletion
* sets are handled without errors.
*
* - **Immutability control**: By default creates a new view object (`mutate=false`).
* Set `mutate=true` only when you want to modify the original view in-place.
*
* **Performance**: O(n*m) where n is the number of entries in the view and m is the
* average number of children per folder, due to the reference cleanup step.
*/
export function deleteOrganizerEntries(
view: OrganizerView,
entryIds: Set<string>,
opts: {
mutate?: boolean;
descendantCollector?: (
view: OrganizerView,
entryId: string,
collection?: Set<string>
) => Set<string>;
} = {}
): OrganizerView {
const { descendantCollector = collectDescendants } = opts;
// Stage entries for deletion
const toDelete = new Set<string>();
for (const entryId of entryIds) {
descendantCollector(view, entryId, toDelete);
}
const newView = opts.mutate ? view : structuredClone(view);
// Remove references from parent folders
Object.values(newView.entries).forEach((entry) => {
if (entry.type === 'folder') {
entry.children = entry.children.filter((childId) => !toDelete.has(childId));
}
});
// Delete entries
for (const entryId of toDelete) {
delete newView.entries[entryId];
}
return newView;
}