/** * @module tests/integration/verb-metadata-rows * @description THE LIVE VERB PATH pins. Before this train, verb rows entered * the metadata index ONLY via `MetadataIndexManager.rebuild()`'s canonical * walk — every relate()/unrelate()/updateRelation() call, and every * remove()-cascaded relationship, left the metadata index blind to verb * writes until the next rebuild. This file pins that `relate()`, * `unrelate()`, `updateRelation()`, `remove()`'s cascade, and their * `transact()` mirrors now post/retract the SAME verb rows a rebuild would * derive from canonical (ADR-007 A4: one mechanism for add/update, live and * rebuilt). */ process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' import type { MetadataIndexManager } from '../../src/utils/metadataIndex.js' /** The JS metadata-index manager backing a memory-storage brain in these * tests (feature-detected in production code via `instanceof * MetadataIndexManager`; a narrow test-only reach-in here, matching the * existing idiom in tests/integration/find-where-zero.test.ts and * tests/integration/level-field-shadow.test.ts). */ function metadataIndexOf(brain: Brainy): MetadataIndexManager { return (brain as unknown as { metadataIndex: MetadataIndexManager }).metadataIndex } describe('verb metadata rows — the live path matches the rebuild walk', () => { let brain: Brainy beforeEach(async () => { brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await brain.init() }) afterEach(async () => { await brain.close() }) async function addPerson(label: string): Promise { return brain.add({ data: `person ${label}`, type: NounType.Person, metadata: { label } }) } it('(a) relate() posts a metadata-index-backed verb row a query can find', async () => { const a = await addPerson('a') const b = await addPerson('b') const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead' } }) // Read it back the SAME way a rebuild-sourced row is queried — the // manager's own posting lookup, keyed on the custom field the caller wrote. const index = metadataIndexOf(brain) expect(await index.getIds('role', 'lead')).toEqual([relId]) }) it('(b) unrelate() retracts the row', async () => { const a = await addPerson('a') const b = await addPerson('b') const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead' } }) const index = metadataIndexOf(brain) expect(await index.getIds('role', 'lead')).toEqual([relId]) // Flush BEFORE retracting the field's only occurrence: this durably // persists the 'role' column (a segment on disk/in the store), so the // post-retraction query below reads "this field exists, zero live // postings" (→ []) rather than "this field has never been written" // (→ FIELD_NOT_INDEXED) — an orthogonal column-store characteristic // (an unflushed field with its last live posting removed reverts to // unknown), not a D2 behavior. await brain.flush() await brain.unrelate(relId) expect(await index.getIds('role', 'lead')).toEqual([]) }) it('(c) updateRelation({ metadata }) leaves exactly the new values', async () => { const a = await addPerson('a') const b = await addPerson('b') const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead', team: 'core' } }) const index = metadataIndexOf(brain) expect(await index.getIds('role', 'lead')).toEqual([relId]) // Flush first — see (b)'s note: 'role'/'team' must be durably known // fields before their only value is retracted, or the post-update // "gone" checks below throw FIELD_NOT_INDEXED instead of returning []. await brain.flush() await brain.updateRelation({ id: relId, metadata: { role: 'reviewer' }, merge: false }) // Stale values gone (the old shape AND the merge:false-dropped field)… expect(await index.getIds('role', 'lead')).toEqual([]) expect(await index.getIds('team', 'core')).toEqual([]) // …only the new value serves. expect(await index.getIds('role', 'reviewer')).toEqual([relId]) }) it("(d) remove(entity) cascade retracts every incident relation's metadata row", async () => { const a = await addPerson('a') const b = await addPerson('b') const c = await addPerson('c') const rel1 = await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'cascade-test' } }) const rel2 = await brain.relate({ from: c, to: a, type: VerbType.WorksWith, metadata: { tag: 'cascade-test' } }) const index = metadataIndexOf(brain) expect((await index.getIds('tag', 'cascade-test')).sort()).toEqual([rel1, rel2].sort()) // Flush first — see (b)'s note. await brain.flush() await brain.remove(a) // a is source of rel1, target of rel2 — both cascade expect(await index.getIds('tag', 'cascade-test')).toEqual([]) }) it('(e) a rebuild() reproduces exactly the verb-row population the live path built', async () => { const a = await addPerson('a') const b = await addPerson('b') const c = await addPerson('c') await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'parity', label: 'ab' } }) await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, metadata: { tag: 'parity', label: 'bc' } }) const relId3 = await brain.relate({ from: c, to: a, type: VerbType.WorksWith, metadata: { tag: 'parity', label: 'ca' } }) await brain.unrelate(relId3) // exercise retraction too — the rebuild must NOT resurrect it const index = metadataIndexOf(brain) const beforeIds = (await index.getIds('tag', 'parity')).slice().sort() expect(beforeIds.length).toBe(2) const beforeAb = await index.getIds('label', 'ab') const beforeBc = await index.getIds('label', 'bc') await index.rebuild() const afterIds = (await index.getIds('tag', 'parity')).slice().sort() expect(afterIds).toEqual(beforeIds) expect(await index.getIds('label', 'ab')).toEqual(beforeAb) expect(await index.getIds('label', 'bc')).toEqual(beforeBc) expect(await index.getIds('label', 'ca')).toEqual([]) // the unrelated edge stays gone }) it('(f) transact() relate/unrelate posts/retracts the same metadata-index rows as single-op', async () => { const a = await addPerson('a') const b = await addPerson('b') const c = await addPerson('c') const d = await addPerson('d') // Single-op baseline. const singleOpId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'parity-f' } }) // transact() mirror. const relateDb = await brain.transact([ { op: 'relate', from: c, to: d, type: VerbType.WorksWith, metadata: { tag: 'parity-f' } } ]) const transactId = relateDb.receipt!.ids[0] await relateDb.release() const index = metadataIndexOf(brain) expect((await index.getIds('tag', 'parity-f')).sort()).toEqual([singleOpId, transactId].sort()) // Flush first — see (b)'s note: 'tag' must be durably known before its // last live posting is retracted below. await brain.flush() // Retract both ways — single-op unrelate() and transact() unrelate. await brain.unrelate(singleOpId) const unrelateDb = await brain.transact([{ op: 'unrelate', id: transactId }]) await unrelateDb.release() expect(await index.getIds('tag', 'parity-f')).toEqual([]) }) it('the metadata crossing never carries BigInt endpoint ints — a cascade delete after graph resolution survives JSON', async () => { // resolveVerbEndpointInts MIRRORS the resolved u64 ints onto the verb // object as BigInt (verb.sourceInt/targetInt). A provider that JSON- // serializes the metadata crossing dies on BigInt — found by the first // joint pair gate. This pin drives the exact shape: relate (graph legs // resolve ints), then remove the source entity (the cascade passes the // SAME verb object to the retraction), through a provider shim that // enforces the JSON-safety contract the way a native provider does. const employee = await brain.add({ data: 'cascade employee', type: 'person' }) const invoice = await brain.add({ data: 'cascade invoice', type: 'document' }) await brain.relate({ from: employee, to: invoice, type: 'relatedTo' }) const mgr: any = (brain as any).metadataIndex const origRemove = mgr.removeFromIndex.bind(mgr) const seen: unknown[] = [] mgr.removeFromIndex = async (id: string, metadata?: unknown, generation?: bigint) => { seen.push(metadata) JSON.stringify(metadata) // the contract: throws on BigInt, exactly like a native crossing return origRemove(id, metadata, generation) } try { await brain.remove(employee) // cascades the relation's retraction } finally { mgr.removeFromIndex = origRemove } expect(seen.length).toBeGreaterThan(0) for (const m of seen) { if (m && typeof m === 'object') { for (const [k, v] of Object.entries(m as Record)) { expect(typeof v, `metadata key ${k} must be JSON-safe`).not.toBe('bigint') } } } }) })