resolveVerbEndpointInts mirrors the resolved u64 endpoint ints onto the verb object itself as BigInt (verb.sourceInt/targetInt) for the graph legs' own params. The live verb path's delete legs then reused that same object as the metadata-index crossing — and the seam's metadata is JSON-safe by contract (a native provider serializes it; u64 as Number corrupts above 2^53), so JSON.stringify threw and the whole transaction aborted. Found by the first joint pair gate; four downstream suites red from one crossing. The crossing now routes through a JSON-safe view that drops BigInt-valued top-level keys — endpoint ints ride their own op params on the graph legs, exactly as designed, and never the metadata crossing. Applied at the retraction helper (cascade + unrelate + transact mirrors) and updateRelation's remove leg. Pinned by driving the exact shape (relate resolves ints, remove cascades the same object) through a provider shim enforcing the JSON contract — red-proved against the unfixed path (the joint gate's verbatim error), green with the fix.
226 lines
9.4 KiB
TypeScript
226 lines
9.4 KiB
TypeScript
/**
|
|
* @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<any>): MetadataIndexManager {
|
|
return (brain as unknown as { metadataIndex: MetadataIndexManager }).metadataIndex
|
|
}
|
|
|
|
describe('verb metadata rows — the live path matches the rebuild walk', () => {
|
|
let brain: Brainy<any>
|
|
|
|
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<string> {
|
|
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<string, unknown>)) {
|
|
expect(typeof v, `metadata key ${k} must be JSON-safe`).not.toBe('bigint')
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
})
|