fix: VFS rename moves the containment edge — no ghost in the old directory
A cross-directory rename added the new parent's Contains edge but skipped
removing the old one (a 'not critical' shortcut), leaving the moved entity
a child of BOTH directories: readdir(oldDir) kept listing it, re-creating
the old path showed the name twice, and tree-walking consumers saw the
file in two places. Reported live by a production deployment (37 stale
containment ghosts; blocks tree-sync integrations).
- rename() now removes the old parent's vfs containment edge(s) by edge
id resolved from the graph's own adjacency (the removal law: removal
never requires reading the removed thing), and a move to the root gets
its containment edge (previously skipped -> orphaned from readdir('/')).
- New vfs.repairContainment(): reconciles every VFS entity's containment
edges against canonical metadata.path — removes stale/duplicate vfs
edges, restores missing ones, never touches user knowledge edges. Wired
into repairIndex() so the one operator ritual heals existing ghosts.
- Regression: tests/integration/vfs-rename-containment.test.ts (7).
This commit is contained in:
parent
1a3a493c27
commit
af8c1795bd
3 changed files with 278 additions and 5 deletions
|
|
@ -15301,6 +15301,21 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
await pruner.rebuildTypeCounts?.()
|
await pruner.rebuildTypeCounts?.()
|
||||||
await pruner.rebuildSubtypeCounts?.()
|
await pruner.rebuildSubtypeCounts?.()
|
||||||
|
|
||||||
|
// VFS containment reconciliation: heal "cosmetic ghost" edges left by
|
||||||
|
// pre-fix renames (an entity Contains-linked from BOTH its old and new
|
||||||
|
// directory — readdir listed it in two places) and duplicate edges from
|
||||||
|
// concurrent writers. Canonical metadata.path is the truth; only VFS
|
||||||
|
// containment edges are touched. Loud per repair.
|
||||||
|
if (this._vfsInitialized && this._vfs) {
|
||||||
|
const containment = await this._vfs.repairContainment()
|
||||||
|
if (containment.removed + containment.restored > 0) {
|
||||||
|
prodLog.warn(
|
||||||
|
`[Brainy] repairIndex() reconciled VFS containment: removed ${containment.removed} ` +
|
||||||
|
`stale/duplicate edge(s), restored ${containment.restored} missing edge(s).`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.metadataIndex.detectAndRepairCorruption()
|
await this.metadataIndex.detectAndRepairCorruption()
|
||||||
// Lift a failed-rollback write-quarantine: force a full rebuild so the
|
// Lift a failed-rollback write-quarantine: force a full rebuild so the
|
||||||
// derived indexes are provably reconciled with canonical, then clear the
|
// derived indexes are provably reconciled with canonical, then clear the
|
||||||
|
|
|
||||||
|
|
@ -1954,15 +1954,27 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
||||||
const newParentPath = this.getParentPath(newPath)
|
const newParentPath = this.getParentPath(newPath)
|
||||||
|
|
||||||
if (oldParentPath !== newParentPath) {
|
if (oldParentPath !== newParentPath) {
|
||||||
// Remove from old parent
|
// Remove the OLD parent's containment edge(s) — by edge id, resolved from
|
||||||
|
// the graph's own adjacency (the removal law: a removal never requires
|
||||||
|
// reading the thing being removed). This step used to be skipped as "not
|
||||||
|
// critical", which left the moved entity a child of BOTH directories:
|
||||||
|
// readdir(oldDir) kept listing it, re-creating the old path showed the
|
||||||
|
// name twice, and tree-walking consumers saw the file in two places.
|
||||||
if (oldParentPath) {
|
if (oldParentPath) {
|
||||||
const oldParentId = await this.pathResolver.resolve(oldParentPath)
|
const oldParentId = await this.pathResolver.resolve(oldParentPath)
|
||||||
// unrelate takes the relation ID, not params - need to find and remove relation
|
const staleEdges = await this.brain.related({
|
||||||
// For now, skip unrelate as it's not critical for rename
|
from: oldParentId,
|
||||||
|
to: entityId,
|
||||||
|
type: VerbType.Contains
|
||||||
|
})
|
||||||
|
for (const edge of staleEdges) {
|
||||||
|
await this.brain.unrelate(edge.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to new parent
|
// Add to the new parent. The root ('/') is a REAL parent — skipping it
|
||||||
if (newParentPath && newParentPath !== '/') {
|
// orphaned a move-to-root out of readdir('/') entirely.
|
||||||
|
if (newParentPath) {
|
||||||
const newParentId = await this.pathResolver.resolve(newParentPath)
|
const newParentId = await this.pathResolver.resolve(newParentPath)
|
||||||
await this.brain.relate({
|
await this.brain.relate({
|
||||||
from: newParentId,
|
from: newParentId,
|
||||||
|
|
@ -2003,6 +2015,95 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
||||||
this.triggerWatchers(newPath, 'rename')
|
this.triggerWatchers(newPath, 'rename')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile every VFS entity's containment edges against its canonical
|
||||||
|
* `metadata.path` — the path is the truth (maintained by write/rename); the
|
||||||
|
* `Contains` edges are a projection of it. Heals the "cosmetic ghost" class
|
||||||
|
* left by pre-fix renames that added the new parent's edge without removing
|
||||||
|
* the old one (an entity listed in TWO directories; a re-created old path
|
||||||
|
* showing its name twice), plus duplicate edges from the same parent left by
|
||||||
|
* concurrent writers.
|
||||||
|
*
|
||||||
|
* CONSERVATIVE by design: only VFS containment edges (subtype
|
||||||
|
* `'vfs-contains'` or `metadata.isVFS`) are ever touched — a user's own
|
||||||
|
* knowledge-graph `Contains` edge between the same entities is never
|
||||||
|
* removed. An entity whose expected parent path has no entity is logged
|
||||||
|
* loudly and left alone (never orphaned further). Operator-invoked via
|
||||||
|
* `brain.repairIndex()`.
|
||||||
|
*
|
||||||
|
* The canonical pagination walk (not an index query) is deliberate: this is
|
||||||
|
* a repair op — the projections are the thing under suspicion, so the walk
|
||||||
|
* reads the source of truth.
|
||||||
|
*
|
||||||
|
* @returns Counts of stale edges removed and missing expected edges restored.
|
||||||
|
*/
|
||||||
|
async repairContainment(): Promise<{ removed: number; restored: number }> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
// Pass 1: canonical walk → every VFS entity's id + path.
|
||||||
|
const idByPath = new Map<string, string>()
|
||||||
|
const vfsEntities: Array<{ id: string; path: string }> = []
|
||||||
|
let cursor: string | undefined
|
||||||
|
for (;;) {
|
||||||
|
const page = await (this.brain as any).storage.getNounsWithPagination({ limit: 500, cursor })
|
||||||
|
for (const noun of page.items) {
|
||||||
|
const meta = (noun as any).metadata ?? noun
|
||||||
|
const p = meta?.path
|
||||||
|
if (meta?.vfsType && typeof p === 'string') {
|
||||||
|
idByPath.set(p, noun.id)
|
||||||
|
vfsEntities.push({ id: noun.id, path: p })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!page.hasMore) break
|
||||||
|
cursor = page.nextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = 0
|
||||||
|
let restored = 0
|
||||||
|
for (const { id, path } of vfsEntities) {
|
||||||
|
if (path === '/') continue // the root has no parent
|
||||||
|
const expectedParentId = idByPath.get(this.getParentPath(path))
|
||||||
|
if (!expectedParentId) {
|
||||||
|
console.warn(
|
||||||
|
`[VFS] repairContainment: no entity found for parent of ${path} — leaving its edges untouched.`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const incoming = await this.brain.related({ to: id, type: VerbType.Contains })
|
||||||
|
let expectedSeen = false
|
||||||
|
for (const edge of incoming) {
|
||||||
|
const isVfsEdge = edge.subtype === 'vfs-contains' || (edge.metadata as any)?.isVFS === true
|
||||||
|
if (!isVfsEdge) continue // never touch user knowledge edges
|
||||||
|
if (edge.from === expectedParentId && !expectedSeen) {
|
||||||
|
expectedSeen = true // keep exactly one correct edge
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Stale parent (a pre-fix rename ghost) or a duplicate of the correct
|
||||||
|
// edge (concurrent-writer artifact) — remove it, loudly.
|
||||||
|
await this.brain.unrelate(edge.id)
|
||||||
|
removed++
|
||||||
|
console.warn(
|
||||||
|
`[VFS] repairContainment: removed ${edge.from === expectedParentId ? 'duplicate' : 'stale'} ` +
|
||||||
|
`containment edge ${edge.from} -> ${id} (${path})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!expectedSeen) {
|
||||||
|
await this.brain.relate({
|
||||||
|
from: expectedParentId,
|
||||||
|
to: id,
|
||||||
|
type: VerbType.Contains,
|
||||||
|
subtype: 'vfs-contains',
|
||||||
|
metadata: { isVFS: true }
|
||||||
|
})
|
||||||
|
restored++
|
||||||
|
console.warn(`[VFS] repairContainment: restored missing containment edge for ${path}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { removed, restored }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy a file or directory to a new path.
|
* Copy a file or directory to a new path.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
157
tests/integration/vfs-rename-containment.test.ts
Normal file
157
tests/integration/vfs-rename-containment.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
/**
|
||||||
|
* @module tests/integration/vfs-rename-containment
|
||||||
|
* @description A cross-directory rename must MOVE the containment edge, not
|
||||||
|
* accumulate one per parent. The pre-fix rename added the new parent's
|
||||||
|
* Contains edge but skipped removing the old one ("not critical") — leaving
|
||||||
|
* the entity a child of BOTH directories: readdir(oldDir) kept listing it,
|
||||||
|
* re-creating the old path showed the name twice (the duplicate-readdir /
|
||||||
|
* "cosmetic ghost" field report), and tree-walking consumers saw the file in
|
||||||
|
* two places. Also covers the repair sweep (repairIndex → vfs.repairContainment)
|
||||||
|
* that heals ghosts left by earlier versions, and move-to-root (whose edge
|
||||||
|
* used to be skipped entirely).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { Brainy, VerbType } from '../../src/index.js'
|
||||||
|
|
||||||
|
describe('VFS rename moves the containment edge (no ghost in the old directory)', () => {
|
||||||
|
let brain: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
||||||
|
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true, dimensions: 384 })
|
||||||
|
await brain.init()
|
||||||
|
await brain.vfs.mkdir('/a', { recursive: true })
|
||||||
|
await brain.vfs.mkdir('/b', { recursive: true })
|
||||||
|
})
|
||||||
|
afterEach(async () => {
|
||||||
|
await brain.close?.().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cross-directory move: old dir stops listing it, new dir lists it exactly once', async () => {
|
||||||
|
await brain.vfs.writeFile('/a/x.txt', 'v1')
|
||||||
|
await brain.vfs.rename('/a/x.txt', '/b/x.txt')
|
||||||
|
|
||||||
|
expect(await brain.vfs.readdir('/a')).toEqual([])
|
||||||
|
expect(await brain.vfs.readdir('/b')).toEqual(['x.txt'])
|
||||||
|
expect(String(await brain.vfs.readFile('/b/x.txt'))).toBe('v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-creating the old path lists each name exactly once in each directory', async () => {
|
||||||
|
await brain.vfs.writeFile('/a/x.txt', 'moved away')
|
||||||
|
await brain.vfs.rename('/a/x.txt', '/b/x.txt')
|
||||||
|
await brain.vfs.writeFile('/a/x.txt', 'new file at old path')
|
||||||
|
|
||||||
|
const a = (await brain.vfs.readdir('/a')) as string[]
|
||||||
|
const b = (await brain.vfs.readdir('/b')) as string[]
|
||||||
|
expect(a).toEqual(['x.txt']) // exactly once — the pre-fix ghost made this list the moved entity too
|
||||||
|
expect(b).toEqual(['x.txt'])
|
||||||
|
expect(String(await brain.vfs.readFile('/a/x.txt'))).toBe('new file at old path')
|
||||||
|
expect(String(await brain.vfs.readFile('/b/x.txt'))).toBe('moved away')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repeated moves never accumulate containment edges', async () => {
|
||||||
|
await brain.vfs.writeFile('/a/f.txt', 'wanderer')
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await brain.vfs.rename('/a/f.txt', '/b/f.txt')
|
||||||
|
await brain.vfs.rename('/b/f.txt', '/a/f.txt')
|
||||||
|
}
|
||||||
|
expect(await brain.vfs.readdir('/a')).toEqual(['f.txt'])
|
||||||
|
expect(await brain.vfs.readdir('/b')).toEqual([])
|
||||||
|
|
||||||
|
// Exactly ONE containment edge exists on the entity.
|
||||||
|
const stat = await brain.vfs.stat('/a/f.txt')
|
||||||
|
const edges = await brain.related({ to: stat.entityId, type: VerbType.Contains })
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('move to the root gets a containment edge (used to be skipped → orphan)', async () => {
|
||||||
|
await brain.vfs.writeFile('/a/up.txt', 'to the top')
|
||||||
|
await brain.vfs.rename('/a/up.txt', '/up.txt')
|
||||||
|
|
||||||
|
const rootListing = (await brain.vfs.readdir('/')) as string[]
|
||||||
|
expect(rootListing).toContain('up.txt')
|
||||||
|
expect(await brain.vfs.readdir('/a')).toEqual([])
|
||||||
|
expect(String(await brain.vfs.readFile('/up.txt'))).toBe('to the top')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('repairIndex() heals pre-fix containment ghosts (vfs.repairContainment)', () => {
|
||||||
|
let brain: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
||||||
|
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true, dimensions: 384 })
|
||||||
|
await brain.init()
|
||||||
|
await brain.vfs.mkdir('/a', { recursive: true })
|
||||||
|
await brain.vfs.mkdir('/b', { recursive: true })
|
||||||
|
})
|
||||||
|
afterEach(async () => {
|
||||||
|
await brain.close?.().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Reproduce the PRE-FIX defect state: file lives at /b/g.txt but a stale
|
||||||
|
* vfs-contains edge from /a lingers (what old renames left behind). */
|
||||||
|
const synthesizeGhost = async () => {
|
||||||
|
await brain.vfs.writeFile('/b/g.txt', 'ghost target')
|
||||||
|
const fileId = (await brain.vfs.stat('/b/g.txt')).entityId
|
||||||
|
const aId = (await brain.vfs.stat('/a')).entityId
|
||||||
|
await brain.relate({
|
||||||
|
from: aId,
|
||||||
|
to: fileId,
|
||||||
|
type: VerbType.Contains,
|
||||||
|
subtype: 'vfs-contains',
|
||||||
|
metadata: { isVFS: true }
|
||||||
|
})
|
||||||
|
return { fileId, aId }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a stale old-parent edge is removed; listings become honest', async () => {
|
||||||
|
const { fileId } = await synthesizeGhost()
|
||||||
|
// The defect state is visible: /a lists a file whose path says /b.
|
||||||
|
expect((await brain.vfs.readdir('/a')) as string[]).toContain('g.txt')
|
||||||
|
|
||||||
|
await brain.repairIndex()
|
||||||
|
|
||||||
|
expect(await brain.vfs.readdir('/a')).toEqual([])
|
||||||
|
expect(await brain.vfs.readdir('/b')).toEqual(['g.txt'])
|
||||||
|
const edges = await brain.related({ to: fileId, type: VerbType.Contains })
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("a user's own knowledge Contains edge onto the file survives the repair", async () => {
|
||||||
|
const { fileId } = await synthesizeGhost()
|
||||||
|
// A knowledge-graph containment from a NON-directory entity (a collection
|
||||||
|
// curating the file) — same verb TYPE, not a vfs-contains edge. The repair
|
||||||
|
// must remove only VFS containment ghosts, never user knowledge edges.
|
||||||
|
// (Edges dedupe by (from,to,type), so the user edge needs its own source.)
|
||||||
|
const collectionId = await brain.add({
|
||||||
|
data: 'reading list',
|
||||||
|
type: 'collection',
|
||||||
|
metadata: { kind: 'curation' }
|
||||||
|
})
|
||||||
|
await brain.relate({ from: collectionId, to: fileId, type: VerbType.Contains, subtype: 'curates' })
|
||||||
|
|
||||||
|
await brain.repairIndex()
|
||||||
|
|
||||||
|
const edges = await brain.related({ to: fileId, type: VerbType.Contains })
|
||||||
|
const subtypes = edges.map((e: any) => e.subtype).sort()
|
||||||
|
// The stale vfs ghost edge is gone; the correct vfs edge + the user's
|
||||||
|
// curation edge remain.
|
||||||
|
expect(subtypes).toEqual(['curates', 'vfs-contains'])
|
||||||
|
expect(edges.find((e: any) => e.subtype === 'curates')?.from).toBe(collectionId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a missing expected edge is restored (entity unreachable from its own directory)', async () => {
|
||||||
|
await brain.vfs.writeFile('/b/lost.txt', 'find me')
|
||||||
|
const fileId = (await brain.vfs.stat('/b/lost.txt')).entityId
|
||||||
|
// Simulate total edge loss (an older damage shape).
|
||||||
|
for (const e of await brain.related({ to: fileId, type: VerbType.Contains })) {
|
||||||
|
await brain.unrelate(e.id)
|
||||||
|
}
|
||||||
|
expect((await brain.vfs.readdir('/b')) as string[]).not.toContain('lost.txt')
|
||||||
|
|
||||||
|
await brain.repairIndex()
|
||||||
|
|
||||||
|
expect((await brain.vfs.readdir('/b')) as string[]).toContain('lost.txt')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue