feat(vfs): implement readdir's recursive option — typed since 7.30, never read

vfs.readdir()'s ReaddirOptions.recursive was typed but silently ignored: a
recursive request behaved identically to a non-recursive one. Implemented
properly: recursive listing walks every descendant (files and directories,
any depth) via the same graph-traversal + one-batch-fetch path
getTreeStructure()/getDescendants() already use, and reports each entry as
a path relative to the queried directory (Node's fs.readdir(dir,
{ recursive: true }) convention) — 'sub/file.txt', not just 'file.txt'.

With withFileTypes: true, each VFSDirent.name carries that same relative
path when recursive (matching the string-array form byte for byte);
VFSDirent.path stays the absolute VFS path either way, so no information is
lost. Filter/sort/pagination compose unchanged, now over the full recursive
set. Non-recursive behavior (direct children, named by basename) is
unchanged.
This commit is contained in:
David Snelling 2026-08-25 10:10:01 -07:00
parent 96624f408c
commit fc516da6eb
3 changed files with 152 additions and 7 deletions

View file

@ -1274,7 +1274,20 @@ export class VirtualFileSystem implements IVirtualFileSystem {
}
/**
* Read directory contents
* @description List a directory's contents. Non-recursive (default)
* returns direct children only, named by basename. `recursive: true`
* lists every descendant at any depth (files and directories), each
* reported as a path RELATIVE TO THE QUERIED DIRECTORY matching Node's
* `fs.readdir(dir, { recursive: true })` convention e.g. `'sub'` and
* `'sub/file.txt'` for a nested file. With `withFileTypes: true`, each
* {@link VFSDirent}'s `name` carries that same value (relative when
* recursive, basename otherwise); `path` is always the absolute VFS path
* either way.
* @param path - The directory to list.
* @param options - `recursive`, `withFileTypes`, `filter`, `sort`,
* `offset`/`limit` (pagination applies AFTER filter/sort, over the full
* recursive set when `recursive: true`).
* @throws {VFSError} ENOTDIR when `path` is not a directory.
*/
async readdir(path: string, options?: ReaddirOptions): Promise<string[] | VFSDirent[]> {
await this.ensureInitialized()
@ -1287,8 +1300,12 @@ export class VirtualFileSystem implements IVirtualFileSystem {
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'readdir')
}
// Get children
let children = await this.pathResolver.getChildren(entityId)
// Direct children, or every descendant at any depth. gatherDescendants()
// is the same graph-traversal + ONE-batch-fetch path getTreeStructure()/
// getDescendants() already use — no per-directory storage round trips.
let children = options?.recursive
? await this.gatherDescendants(entityId, Infinity)
: await this.pathResolver.getChildren(entityId)
// Apply filters
if (options?.filter) {
@ -1312,17 +1329,29 @@ export class VirtualFileSystem implements IVirtualFileSystem {
// Directory access time updates caused 50-100ms GCS write on EVERY readdir
// await this.updateAccessTime(entityId) // ← REMOVED
// The queried directory's own canonical (already-normalized) path — the
// base every recursive entry's relative name is computed against. Using
// the resolved entity's OWN path (rather than the raw `path` argument)
// means no separate normalization step is needed here.
const baseDir = entity.metadata.path
const relativeToBase = (childPath: string): string => {
const prefix = baseDir === '/' ? '/' : `${baseDir}/`
return childPath.startsWith(prefix) ? childPath.slice(prefix.length) : childPath
}
// Return appropriate format
if (options?.withFileTypes) {
return children.map(child => ({
name: child.metadata.name,
name: options?.recursive ? relativeToBase(child.metadata.path) : child.metadata.name,
path: child.metadata.path,
type: child.metadata.vfsType,
entityId: child.id
} as VFSDirent))
}
return children.map(child => child.metadata.name)
return children.map(child =>
options?.recursive ? relativeToBase(child.metadata.path) : child.metadata.name
)
}
// ============= Metadata Operations =============

View file

@ -133,8 +133,17 @@ export interface VFSStats {
* Directory entry (for readdir)
*/
export interface VFSDirent {
/**
* The entry's basename (e.g. `'file.txt'`) when `readdir()` was called
* WITHOUT `recursive: true`. When `recursive: true` was set, this is
* instead the entry's path RELATIVE TO THE QUERIED DIRECTORY (e.g.
* `'sub/file.txt'` for a nested file) the same value that would appear
* in the plain string-array form of a recursive `readdir()` call. `path`
* below always carries the absolute VFS path regardless of `recursive`,
* so nothing is lost either way.
*/
name: string
path: string // Full path
path: string // Full (absolute) VFS path — always absolute, recursive or not
type: 'file' | 'directory' | 'symlink'
entityId: string // Underlying entity ID
}
@ -240,7 +249,15 @@ export interface ReaddirOptions {
withFileTypes?: boolean // Return Dirent objects
// VFS-specific options
recursive?: boolean // Include subdirectories
/**
* List every descendant (files and directories, all depths), not just
* direct children. Entries are reported as paths RELATIVE TO THE QUERIED
* DIRECTORY (Node's `fs.readdir(dir, { recursive: true })` convention)
* a string-array result contains e.g. `'sub/file.txt'`, and with
* `withFileTypes: true` each `VFSDirent.name` carries that same relative
* path (see {@link VFSDirent}). Default: `false` (direct children only).
*/
recursive?: boolean
limit?: number // Max results
offset?: number // Skip N results
cursor?: string // Pagination cursor