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:
parent
96624f408c
commit
fc516da6eb
3 changed files with 152 additions and 7 deletions
|
|
@ -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 =============
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
tests/unit/vfs-readdir-recursive.test.ts
Normal file
99
tests/unit/vfs-readdir-recursive.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* vfs.readdir()'s `recursive` option: typed since 7.30 but never read, so it
|
||||
* silently behaved exactly like `recursive: false`. This pins the real,
|
||||
* documented contract: a recursive listing returns every descendant (files
|
||||
* AND directories, all depths) as paths RELATIVE TO THE QUERIED DIRECTORY —
|
||||
* the same convention Node's `fs.readdir(dir, { recursive: true })` uses —
|
||||
* for both the plain string-array form and the `withFileTypes` VFSDirent
|
||||
* form (whose `name` carries that same relative path when recursive).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import type { VFSDirent } from '../../src/vfs/types.js'
|
||||
|
||||
describe('vfs.readdir() recursive option', () => {
|
||||
let brain: Brainy
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy({
|
||||
requireSubtype: false,
|
||||
storage: { type: 'memory' },
|
||||
silent: true
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
// Build:
|
||||
// /a/b.txt
|
||||
// /a/sub/c.txt
|
||||
// /a/sub/deeper/d.txt
|
||||
// /a/sub2/ (empty directory)
|
||||
await brain.vfs.writeFile('/a/b.txt', 'B')
|
||||
await brain.vfs.writeFile('/a/sub/c.txt', 'C')
|
||||
await brain.vfs.writeFile('/a/sub/deeper/d.txt', 'D')
|
||||
await brain.vfs.mkdir('/a/sub2', { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('non-recursive (default) still returns only direct children, by basename', async () => {
|
||||
const entries = await brain.vfs.readdir('/a') as string[]
|
||||
expect([...entries].sort()).toEqual(['b.txt', 'sub', 'sub2'])
|
||||
})
|
||||
|
||||
it('recursive: true returns every descendant as a path relative to the queried directory', async () => {
|
||||
const entries = await brain.vfs.readdir('/a', { recursive: true }) as string[]
|
||||
expect([...entries].sort()).toEqual([
|
||||
'b.txt',
|
||||
'sub',
|
||||
'sub/c.txt',
|
||||
'sub/deeper',
|
||||
'sub/deeper/d.txt',
|
||||
'sub2'
|
||||
])
|
||||
})
|
||||
|
||||
it('recursive: true at the root has no leading slash on relative entries', async () => {
|
||||
const entries = await brain.vfs.readdir('/', { recursive: true }) as string[]
|
||||
expect(entries).toContain('a')
|
||||
expect(entries).toContain('a/b.txt')
|
||||
expect(entries).toContain('a/sub/deeper/d.txt')
|
||||
for (const entry of entries) {
|
||||
expect(entry.startsWith('/')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('recursive + withFileTypes: VFSDirent.name is the relative path, .path stays absolute', async () => {
|
||||
const entries = await brain.vfs.readdir('/a', {
|
||||
recursive: true,
|
||||
withFileTypes: true
|
||||
}) as VFSDirent[]
|
||||
|
||||
const byName = new Map(entries.map((e) => [e.name, e]))
|
||||
|
||||
const nested = byName.get('sub/deeper/d.txt')
|
||||
expect(nested).toBeDefined()
|
||||
expect(nested!.path).toBe('/a/sub/deeper/d.txt')
|
||||
expect(nested!.type).toBe('file')
|
||||
|
||||
const nestedDir = byName.get('sub/deeper')
|
||||
expect(nestedDir).toBeDefined()
|
||||
expect(nestedDir!.path).toBe('/a/sub/deeper')
|
||||
expect(nestedDir!.type).toBe('directory')
|
||||
|
||||
// Non-recursive VFSDirent behavior is unchanged: name is the basename.
|
||||
const direct = await brain.vfs.readdir('/a', { withFileTypes: true }) as VFSDirent[]
|
||||
const directEntry = direct.find((e) => e.path === '/a/b.txt')
|
||||
expect(directEntry?.name).toBe('b.txt')
|
||||
})
|
||||
|
||||
it('recursive + filter composes: only files survive a type filter', async () => {
|
||||
const entries = await brain.vfs.readdir('/a', {
|
||||
recursive: true,
|
||||
filter: { type: 'file' }
|
||||
}) as string[]
|
||||
expect([...entries].sort()).toEqual(['b.txt', 'sub/c.txt', 'sub/deeper/d.txt'])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue