Skip to content

Commit 2235e13

Browse files
authored
[Storage] Require explicit recursive flag for mkdir (and tweak the semantics of writeFile and listFiles) (#2969)
## Summary This PR changes the filesystem backends to require explicit `recursive: true` when creating nested directories, matching standard filesystem semantics. Previously, `mkdir('/a/b/c')` would silently create all parent directories. Now it throws if `/a/b` doesn't exist, unless you pass `recursive: true`. Similarly, `writeFile('/a/b/file.txt')` now throws if `/a/b` doesn't exist, instead of creating it. **Changes:** - `mkdir(path, recursive?)` - only creates parent dirs when `recursive=true` - `writeFile(path, data)` - throws if parent directory doesn't exist - `listFiles(path)` - returns `[]` for non-existent paths (consistent with FSHelpers) - `mkdir('/')` - now a no-op instead of throwing **Updated interfaces:** - `WritableFilesystemBackend.mkdir(path, recursive?: boolean)` - `AsyncWritableFilesystem.mkdir(path, options?: { recursive?: boolean })` ## Test plan - [x] Added tests for `mkdir` throwing without recursive flag - [x] Added tests for `mkdir` with recursive flag creating nested dirs - [x] Added tests for `writeFile` throwing when parent doesn't exist - [x] Added tests for `listFiles` returning `[]` for non-existent paths - [x] Updated Blueprint Editor to use `{ recursive: true }` when creating parent dirs
1 parent 1344ee7 commit 2235e13

File tree

3 files changed

+91
-22
lines changed

3 files changed

+91
-22
lines changed

packages/playground/storage/src/lib/filesystems.spec.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ describe('InMemoryFilesystemBackend', () => {
469469
});
470470

471471
it('should return true for nested directories', async () => {
472-
await backend.mkdir('/parent/child');
472+
await backend.mkdir('/parent/child', true);
473473
expect(await backend.fileExists('/parent')).toBe(true);
474474
expect(await backend.fileExists('/parent/child')).toBe(true);
475475
});
@@ -501,8 +501,14 @@ describe('InMemoryFilesystemBackend', () => {
501501
expect(await backend.isDir('/newdir')).toBe(true);
502502
});
503503

504-
it('should create nested directories', async () => {
505-
await backend.mkdir('/parent/child/grandchild');
504+
it('should throw when creating nested directories without recursive flag', async () => {
505+
await expect(
506+
backend.mkdir('/parent/child/grandchild')
507+
).rejects.toThrow();
508+
});
509+
510+
it('should create nested directories with recursive flag', async () => {
511+
await backend.mkdir('/parent/child/grandchild', true);
506512
expect(await backend.isDir('/parent')).toBe(true);
507513
expect(await backend.isDir('/parent/child')).toBe(true);
508514
expect(await backend.isDir('/parent/child/grandchild')).toBe(true);
@@ -513,6 +519,12 @@ describe('InMemoryFilesystemBackend', () => {
513519
await backend.mkdir('/mydir');
514520
expect(await backend.isDir('/mydir')).toBe(true);
515521
});
522+
523+
it('should be a no-op for root directory', async () => {
524+
// mkdir('/') should not throw, root always exists
525+
await backend.mkdir('/');
526+
expect(await backend.isDir('/')).toBe(true);
527+
});
516528
});
517529

518530
describe('writeFile and read', () => {
@@ -525,12 +537,17 @@ describe('InMemoryFilesystemBackend', () => {
525537
expect(content).toEqual(data);
526538
});
527539

528-
it('should create parent directories when writing a file', async () => {
540+
it('should throw when parent directory does not exist', async () => {
529541
const data = new Uint8Array([1, 2, 3]);
530-
await backend.writeFile('/deep/nested/file.txt', data);
542+
await expect(
543+
backend.writeFile('/nonexistent/file.txt', data)
544+
).rejects.toThrow();
545+
});
531546

532-
expect(await backend.isDir('/deep')).toBe(true);
533-
expect(await backend.isDir('/deep/nested')).toBe(true);
547+
it('should write to existing nested directory', async () => {
548+
await backend.mkdir('/deep/nested', true);
549+
const data = new Uint8Array([1, 2, 3]);
550+
await backend.writeFile('/deep/nested/file.txt', data);
534551
expect(await backend.fileExists('/deep/nested/file.txt')).toBe(
535552
true
536553
);
@@ -556,6 +573,17 @@ describe('InMemoryFilesystemBackend', () => {
556573
const files = await backend.listFiles('/mydir');
557574
expect(files).toContain('nested.txt');
558575
});
576+
577+
it('should return empty array for non-existent paths', async () => {
578+
const files = await backend.listFiles('/nonexistent');
579+
expect(files).toEqual([]);
580+
});
581+
582+
it('should return empty array when listing a file path', async () => {
583+
await backend.writeFile('/file.txt', new Uint8Array([1]));
584+
const files = await backend.listFiles('/file.txt');
585+
expect(files).toEqual([]);
586+
});
559587
});
560588

561589
describe('unlink', () => {
@@ -576,7 +604,7 @@ describe('InMemoryFilesystemBackend', () => {
576604
});
577605

578606
it('should delete a directory recursively', async () => {
579-
await backend.mkdir('/parent/child');
607+
await backend.mkdir('/parent/child', true);
580608
await backend.writeFile(
581609
'/parent/child/file.txt',
582610
new Uint8Array([1])

packages/playground/storage/src/lib/filesystems.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface TraversableFilesystemBackend extends ReadableFilesystemBackend
2323
export interface WritableFilesystemBackend extends TraversableFilesystemBackend {
2424
fileExists(absolutePath: string): Promise<boolean>;
2525
writeFile(absolutePath: string, data: Uint8Array): Promise<void>;
26-
mkdir(absolutePath: string): Promise<void>;
26+
mkdir(absolutePath: string, recursive?: boolean): Promise<void>;
2727
rmdir(absolutePath: string, recursive: boolean): Promise<void>;
2828
mv(absoluteSource: string, absoluteDestination: string): Promise<void>;
2929
unlink(absolutePath: string): Promise<void>;
@@ -41,7 +41,7 @@ export interface AsyncWritableFilesystem extends EventTarget {
4141
readFileAsText(path: string): Promise<string>;
4242
listFiles(path: string): Promise<string[]>;
4343
writeFile(path: string, data: Uint8Array | string): Promise<void>;
44-
mkdir(path: string): Promise<void>;
44+
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
4545
rmdir(path: string, options?: { recursive?: boolean }): Promise<void>;
4646
mv(source: string, destination: string): Promise<void>;
4747
unlink(path: string): Promise<void>;
@@ -121,8 +121,11 @@ export class EventedFilesystem
121121
this.dispatchEvent(new Event('change'));
122122
}
123123

124-
async mkdir(path: string): Promise<void> {
125-
await this.backend.mkdir(path);
124+
async mkdir(
125+
path: string,
126+
options?: { recursive?: boolean }
127+
): Promise<void> {
128+
await this.backend.mkdir(path, options?.recursive ?? false);
126129
this.dispatchEvent(new Event('change'));
127130
}
128131

@@ -596,21 +599,27 @@ export class OpfsFilesystemBackend implements WritableFilesystemBackend {
596599
if (!fileName) {
597600
throw new Error(`Invalid file path: ${absolutePath}`);
598601
}
602+
// Navigate to parent directory without creating it
599603
let dir = this.opfsRoot;
600604
for (const segment of segments) {
601-
dir = await dir.getDirectoryHandle(segment, { create: true });
605+
dir = await dir.getDirectoryHandle(segment);
602606
}
603607
const handle = await dir.getFileHandle(fileName, { create: true });
604608
const writable = await handle.createWritable();
605609
await writable.write(data);
606610
await writable.close();
607611
}
608612

609-
async mkdir(absolutePath: string): Promise<void> {
613+
async mkdir(absolutePath: string, recursive = false): Promise<void> {
610614
const segments = absolutePath.split('/').filter(Boolean);
611615
let dir = this.opfsRoot;
612-
for (const segment of segments) {
613-
dir = await dir.getDirectoryHandle(segment, { create: true });
616+
for (let i = 0; i < segments.length; i++) {
617+
const segment = segments[i];
618+
const isLast = i === segments.length - 1;
619+
// Only create the final directory; parent dirs must exist unless recursive
620+
dir = await dir.getDirectoryHandle(segment, {
621+
create: recursive || isLast,
622+
});
614623
}
615624
}
616625

@@ -733,16 +742,25 @@ export class InMemoryFilesystemBackend implements WritableFilesystemBackend {
733742
}
734743

735744
async listFiles(absolutePath: string): Promise<string[]> {
736-
const dir = this.getDir(absolutePath);
737-
return Object.keys(dir.children);
745+
const node = this.getNode(absolutePath);
746+
if (!node || node.type !== 'dir') {
747+
return [];
748+
}
749+
return Object.keys(node.children);
738750
}
739751

740752
async writeFile(absolutePath: string, data: Uint8Array): Promise<void> {
741753
this.writeFileSync(absolutePath, data);
742754
}
743755

744-
async mkdir(absolutePath: string): Promise<void> {
745-
const { parent, name } = this.getParent(absolutePath);
756+
async mkdir(absolutePath: string, recursive = false): Promise<void> {
757+
// Root always exists, nothing to create
758+
if (absolutePath === '/') {
759+
return;
760+
}
761+
const { parent, name } = recursive
762+
? this.getOrCreateParent(absolutePath)
763+
: this.getParent(absolutePath);
746764
if (!parent.children[name]) {
747765
parent.children[name] = { type: 'dir', children: {} };
748766
}
@@ -833,14 +851,37 @@ export class InMemoryFilesystemBackend implements WritableFilesystemBackend {
833851
return node;
834852
}
835853

854+
/**
855+
* Get parent directory, throwing if it doesn't exist.
856+
*/
836857
private getParent(absolutePath: string): { parent: DirNode; name: string } {
837858
const segments = absolutePath.split('/').filter(Boolean);
838859
const name = segments.pop();
860+
if (!name) {
861+
throw new Error(`Invalid path: ${absolutePath}`);
862+
}
839863
const parentPath = segments.length ? `/${segments.join('/')}` : '/';
840-
const parent = this.ensureDir(parentPath);
864+
const parent = this.getNode(parentPath);
865+
if (!parent || parent.type !== 'dir') {
866+
throw new Error(`Parent directory not found: ${parentPath}`);
867+
}
868+
return { parent, name };
869+
}
870+
871+
/**
872+
* Get parent directory, creating it if it doesn't exist.
873+
*/
874+
private getOrCreateParent(absolutePath: string): {
875+
parent: DirNode;
876+
name: string;
877+
} {
878+
const segments = absolutePath.split('/').filter(Boolean);
879+
const name = segments.pop();
841880
if (!name) {
842881
throw new Error(`Invalid path: ${absolutePath}`);
843882
}
883+
const parentPath = segments.length ? `/${segments.join('/')}` : '/';
884+
const parent = this.ensureDir(parentPath);
844885
return { parent, name };
845886
}
846887

packages/playground/website/src/components/blueprint-editor/AutosavedBlueprintBundleEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ async function populateFilesystemFromBlueprint(
8888
}
8989
const parent = dirname(absolutePath);
9090
if (!(await fs.fileExists(parent))) {
91-
await fs.mkdir(parent);
91+
await fs.mkdir(parent, { recursive: true });
9292
}
9393
await fs.writeFile(absolutePath, content);
9494
}

0 commit comments

Comments
 (0)