fix: vfs.rename() issues a metadata-only update + rollback of fresh adds removes them

Two fixes reported/found by downstream consumers:

(1) vfs.rename() spread the entire fetched entity into brain.update(),
forwarding a vector field that failed the 384-dimension validation when the
entity was fetched without vectors — every rename threw. A rename is a
path/metadata change, never a content change: the update is now metadata-only
(no vector touched, no re-embedding). Regression test covers the verbatim
consumer repro plus cross-directory moves and EEXIST.

(2) AddToHNSWOperation.itemExists() probed an optional getItem capability
with `await (index as any).getItem?.(id)` — `await undefined` resolves
without throwing, so every item was reported as pre-existing and rollback of
fresh adds never removed them, leaving phantom index entries after a failed
transaction. The probe now feature-detects the method and defaults to false
when absent; update flows pair this op with RemoveFromHNSWOperation whose own
rollback restores the prior vector, so reverse-order rollback reconstructs
the original state either way.

1479/1479 unit suite passing.
This commit is contained in:
David Snelling 2026-06-11 14:59:40 -07:00
parent 9b52629a0a
commit ac29b0e650
3 changed files with 97 additions and 18 deletions

View file

@ -51,13 +51,26 @@ export class AddToHNSWOperation implements Operation {
}
/**
* Check if item exists in index
* Check if item exists in index.
*
* `getItem` is an optional provider capability that the built-in HNSW index
* does not implement. When the capability is missing the answer must be
* `false`, not `true`: `await undefined` resolves without throwing, so the
* old probe reported every item as pre-existing and rollback of fresh adds
* never removed them leaving phantom index entries after a failed
* transaction. The safe default is to remove what this operation added;
* update flows pair this op with a RemoveFromHNSWOperation whose own
* rollback restores the prior vector, so reverse-order rollback
* reconstructs the original state either way.
*/
private async itemExists(id: string): Promise<boolean> {
const index = this.index as HNSWIndex & {
getItem?: (id: string) => Promise<unknown>
}
if (typeof index.getItem !== 'function') return false
try {
// Try to get item - if exists, no error
await (this.index as any).getItem?.(id)
return true
const item = await index.getItem(id)
return item !== undefined && item !== null
} catch {
return false
}

View file

@ -1664,17 +1664,6 @@ export class VirtualFileSystem implements IVirtualFileSystem {
if (err.code !== VFSErrorCode.ENOENT) throw err
}
// Update entity metadata
const updatedEntity = {
...entity,
metadata: {
...entity.metadata,
path: newPath,
name: this.getBasename(newPath),
modified: Date.now()
}
}
// Update parent relationships if needed
const oldParentPath = this.getParentPath(oldPath)
const newParentPath = this.getParentPath(newPath)
@ -1700,10 +1689,19 @@ export class VirtualFileSystem implements IVirtualFileSystem {
}
}
// Update the entity
// A rename is a path/metadata change, never a content change — issue a
// metadata-only update. Spreading the whole entity here used to forward
// its vector field into update(), which failed dimension validation when
// the entity was fetched without vectors (and would needlessly touch the
// vector index when it wasn't).
await this.brain.update({
...updatedEntity,
id: entityId
id: entityId,
metadata: {
...entity.metadata,
path: newPath,
name: this.getBasename(newPath),
modified: Date.now()
}
})
// Update path cache