diff --git a/src/brainy.ts b/src/brainy.ts index bd2e540b..6ebce2fd 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -3692,9 +3692,22 @@ export class Brainy implements BrainyInterface { // stored, so remove() deletes the same entity. A real UUID passes through. id = resolveEntityId(id) - // Get entity metadata and related verbs before deletion - const metadata = await this.storage.getNounMetadata(id) - const noun = await this.storage.getNoun(id) + // Get entity metadata and related verbs before deletion. TORN-TOLERANT: + // a torn record must still be deletable (the delete IS the cure) — a + // torn pre-read reads as null and the null-path below handles it loudly. + let metadata: any = null + let noun: any = null + try { + metadata = await this.storage.getNounMetadata(id) + } catch (err) { + if ((err as { code?: string }).code !== 'TORN_RECORD') throw err + prodLog.warn(`[Brainy] remove(${id}): metadata pre-read is TORN — deleting anyway; index legs run id-keyed`) + } + try { + noun = await this.storage.getNoun(id) + } catch (err) { + if ((err as { code?: string }).code !== 'TORN_RECORD') throw err + } const verbs = await this.storage.getVerbsBySource(id) const targetVerbs = await this.storage.getVerbsByTarget(id) const allVerbs = [...verbs, ...targetVerbs] @@ -3712,11 +3725,61 @@ export class Brainy implements BrainyInterface { ) } - // Operation 2: Remove from metadata index + // Operation 2: Remove from metadata index. THE NULL-METADATA SKIP IS + // CLOSED (a posting-leak class, confirmed at this site): when the + // pre-read missed, the leg no longer silently skips — + // - a provider exposing removeEntityById (the id-keyed contract) + // gets it: exact per-entity retraction via its reverse record; + // - the JS index gets removeFromIndex(id) — safe id-keyed cleanup + // (deleted bitmap + id mapper; field stats reconcile at rebuild); + // - a NATIVE provider WITHOUT the contract is never called + // metadata-omitted (that path walks its value space) — the skip + // happens, but NARRATED and tracked in the degraded set so + // repairIndex reconciles it. Silence is the only thing outlawed. if (metadata) { tx.addOperation( new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata, this.indexWriteGeneration) ) + } else { + const prov = this.metadataIndex as unknown as { + removeEntityById?: (id: string) => Promise + removeFromIndex?: (id: string, metadata?: unknown, generation?: bigint) => Promise + } + if (typeof prov.removeEntityById === 'function') { + const g = this.indexWriteGeneration + tx.addOperation({ + name: 'RemoveEntityByIdTombstone', + execute: async () => { + await prov.removeEntityById!(id) + return async () => { + // Undo of an id-keyed tombstone on an absent row: nothing + // to restore (the row had no readable metadata to re-post). + void g + } + } + }) + } else if (this.metadataIndex instanceof MetadataIndexManager) { + const gv = this.indexWriteGeneration + tx.addOperation({ + name: 'IdKeyedIndexCleanup', + execute: async () => { + await prov.removeFromIndex!(id, undefined, typeof gv === 'function' ? gv() : gv) + return async () => {} + } + }) + prodLog.warn( + `[Brainy] remove(${id}): no metadata at delete — id-keyed index cleanup ran ` + + `(deleted bitmap + id mapper); field statistics reconcile at the next rebuild/repairIndex.` + ) + } else { + this._indexDegradedIds.add(id) + prodLog.warn( + `[Brainy] remove(${id}): no metadata at delete and this provider has no id-keyed ` + + `removal — its postings for this id are NOT tombstoned yet (tracked as degraded; ` + + `repairIndex() reconciles). Never calling a metadata-omitted native removal: that ` + + `path walks the store's value space.` + ) + } } // Operation 3: Delete noun (full removal). The pre-read metadata rides diff --git a/tests/integration/null-metadata-delete.test.ts b/tests/integration/null-metadata-delete.test.ts new file mode 100644 index 00000000..46b69030 --- /dev/null +++ b/tests/integration/null-metadata-delete.test.ts @@ -0,0 +1,62 @@ +/** + * @module tests/integration/null-metadata-delete + * @description The null-metadata delete skip is CLOSED. remove() used to + * guard its index legs with `if (metadata)` — a row whose canonical + * metadata was unreadable at delete time (torn, or a leg lost to an old + * defect) kept its postings FOREVER, silently. Now: the JS index gets an + * id-keyed cleanup (deleted bitmap + id mapper), the id-keyed native + * contract is used when a provider offers it, and the one remaining + * skip-shape (native without the contract) is narrated and tracked, never + * silent. Pinned: a metadata-less row with live postings deletes cleanly + * and leaves the query universe. + */ +import { describe, it, expect, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Brainy } from '../../src/index.js' +import { NounType } from '../../src/types/graphTypes.js' + +type RawBox = { + storage: { + readNounRaw(id: string): Promise<{ metadata: unknown; vector: unknown }> + writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise + } +} + +const dirs: string[] = [] +const brains: Brainy[] = [] +afterEach(async () => { + for (const b of brains.splice(0)) await b.close().catch(() => {}) + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +describe('null-metadata delete', () => { + it('a row whose metadata leg is gone still deletes — id-keyed cleanup, no silent skip, gone from the query universe', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brainy-nullmeta-del-')) + dirs.push(dir) + const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true }) + await brain.init() + brains.push(brain) + + const keep = await brain.add({ data: 'survivor', type: NounType.Document, metadata: { team: 'atlas' } }) + const victim = await brain.add({ data: 'doomed', type: NounType.Document, metadata: { team: 'atlas' } }) + await brain.flush() + expect((await brain.find({ where: { team: 'atlas' } })).length).toBe(2) + + // Manufacture the shape: the victim's metadata leg vanishes behind the + // engine's back (vector leg + postings stay live). + const storage = (brain as unknown as RawBox).storage + const raw = await storage.readNounRaw(victim) + await storage.writeNounRaw(victim, { metadata: null, vector: raw.vector }) + + // THE PIN: the delete neither throws nor silently strands postings. + await brain.remove(victim) + await brain.flush() + + const after = await brain.find({ where: { team: 'atlas' } }) + expect(after.length, 'victim left the query universe; survivor serves').toBe(1) + expect(after[0].id).toBe(keep) + expect(await brain.get(victim)).toBeNull() + }, 120000) +})