/** * @module tests/vfs/vfs-search-path-scope.unit * @description `vfs.search({ path })` scopes with a SERVED filter. * * The scope used to be emitted as `path: { $startsWith }` — an operator that is * not in the filter vocabulary at all, and whose `$`-less spelling the metadata * index refuses by the served-operator law (an equality/range posting index * cannot evaluate a substring without reading every row). Every path-scoped VFS * search threw; none has ever worked on this engine line. * * The scope is now a half-open range over `metadata.path`, which is the VFS's * truth, is indexed on every VFS entity, and is served by the ordered range * operators: `[dir + '/', dir + '0')` — '0' being the code point after '/', so * membership in the range is EXACTLY "carries the prefix `dir/`". The * non-recursive scope is the directory's own identity, `parent`, an equality. * * These pins hold the answer (descendants at every depth, siblings never — the * `/scope-sibling` trap included), the shape (the operators the search emits * are answered by the index's own door, never refused), and the law that the * scope narrows the search BEFORE it runs rather than filtering an over-fetch. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js' import { Brainy } from '../../src/brainy.js' import { VFSErrorCode } from '../../src/vfs/types.js' /** A word every fixture file carries, so the text leg reaches all of them. */ const TOKEN = 'quasar' describe('vfs.search({ path }) scopes with a served filter', () => { let brain: Brainy let vfs: VirtualFileSystem /** In scope for '/scope', at three depths. */ const inScope = ['/scope/a.txt', '/scope/sub/b.txt', '/scope/sub/deep/c.txt'] /** Out of scope — including the two prefix traps a naive test misses. */ const outOfScope = ['/scope-sibling/d.txt', '/scope0/e.txt', '/elsewhere/f.txt', '/g.txt'] beforeAll(async () => { brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await brain.init() vfs = brain.vfs await vfs.init() await vfs.mkdir('/scope/sub/deep', { recursive: true }) await vfs.mkdir('/scope-sibling', { recursive: true }) await vfs.mkdir('/scope0', { recursive: true }) await vfs.mkdir('/elsewhere', { recursive: true }) for (const path of [...inScope, ...outOfScope]) { await vfs.writeFile(path, `${TOKEN} content for ${path}`) } }) afterAll(async () => { await vfs?.close() await brain?.close() }) it('includes every descendant depth and excludes every sibling', async () => { const results = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) const paths = results.map((r) => r.path).sort() expect(paths).toEqual([...inScope].sort()) for (const path of outOfScope) expect(paths).not.toContain(path) }) it('a trailing slash and a doubled slash name the same scope', async () => { const plain = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) const trailing = await vfs.search(TOKEN, { path: '/scope/', limit: 50 }) const doubled = await vfs.search(TOKEN, { path: '//scope//', limit: 50 }) const ids = (rs: Array<{ entityId: string }>) => rs.map((r) => r.entityId).sort() expect(ids(trailing)).toEqual(ids(plain)) expect(ids(doubled)).toEqual(ids(plain)) }) it('the root scope is every VFS file — it adds no clause to narrow with', async () => { const rooted = await vfs.search(TOKEN, { path: '/', limit: 50 }) const unscoped = await vfs.search(TOKEN, { limit: 50 }) const paths = rooted.map((r) => r.path).sort() expect(paths).toEqual([...inScope, ...outOfScope].sort()) expect(paths).toEqual(unscoped.map((r) => r.path).sort()) }) it('recursive: false is the immediate children, not the subtree', async () => { const results = await vfs.search(TOKEN, { path: '/scope', recursive: false, limit: 50 }) expect(results.map((r) => r.path)).toEqual(['/scope/a.txt']) }) it('recursive: false on a path that does not exist refuses by name', async () => { await expect( vfs.search(TOKEN, { path: '/no-such-dir', recursive: false, limit: 50 }) ).rejects.toMatchObject({ code: VFSErrorCode.ENOENT }) }) it('every operator the search emits is ANSWERED by the index door, never refused', async () => { const index = (brain as any).metadataIndex const emitted: any[] = [] const find = vi.spyOn(brain as any, 'find') try { await vfs.search(TOKEN, { path: '/scope', limit: 50 }) await vfs.search(TOKEN, { path: '/scope/sub', where: { mimeType: 'text/plain' }, limit: 50 }) await vfs.search(TOKEN, { path: '/scope', recursive: false, limit: 50 }) await vfs.search(TOKEN, { path: '/', limit: 50 }) for (const call of find.mock.calls) emitted.push((call[0] as any).where) } finally { find.mockRestore() } expect(emitted).toHaveLength(4) for (const where of emitted) { // The door itself is the judge: an operator outside the served set is // REFUSED here (BrainyError INVALID_QUERY), never answered. await expect(index.getIdsForFilter(where)).resolves.toBeInstanceOf(Array) } // And the scope really is a range on the path — the shape this fix chose. expect(emitted[0].path).toEqual({ gte: '/scope/', lt: '/scope0' }) expect(emitted[3].path).toBeUndefined() }) it('the scope narrows the search before it runs — no over-fetch to filter', async () => { const index = (brain as any).metadataIndex const filter = vi.spyOn(index, 'getIdsForFilter') let universe: string[] = [] try { await vfs.search(TOKEN, { path: '/scope', limit: 50 }) // The search's own call — the one carrying the scope. (Path resolution // asks this same door for the root, before the search is built.) const scoped = filter.mock.calls.findIndex( (c) => (c[0] as any)?.path?.gte === '/scope/' ) expect(scoped).toBeGreaterThanOrEqual(0) universe = (await filter.mock.results[scoped].value) as string[] } finally { filter.mockRestore() } // The id universe the index resolved for the search is already the scope: // three files, and not one row from outside it. const rows = await brain.batchGet(universe) const paths = [...rows.values()].map((e: any) => e.metadata.path).sort() expect(paths).toEqual([...inScope].sort()) }) it('the range answers the same ids as walking the tree', async () => { // The path is the truth and the Contains edges are its projection; a scope // read from the truth must agree with one walked over the projection. const walked: string[] = [] const walk = async (dir: string): Promise => { for (const name of await vfs.readdir(dir)) { const child = dir === '/' ? `/${name}` : `${dir}/${name}` const stat = await vfs.stat(child) if (stat.isDirectory()) await walk(child) else walked.push(child) } } await walk('/scope') const searched = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) expect(searched.map((r) => r.path).sort()).toEqual(walked.sort()) }) })