/** * @module tests/integration/onchange-feed * @description The `brain.onChange` in-process change feed — the authoritative * post-commit signal for every canonical mutation, regardless of origin. * Pins the delivery contract: * - every operation fires exactly once per affected record with the * post-commit payload (deletes carry the record's LAST state); * - batch operations emit one event per item; transact items share one * generation; * - VFS writes (a Tier-1 blind spot for router-synthesized events) emit; * - events arrive in commit order with monotonic generations; * - a losing `ifRev` CAS emits NOTHING (aborted commits are invisible); * - a throwing listener is isolated; unsubscribe stops delivery; * - clear()/restore() emit one store-level "refetch everything" event. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { Brainy } from '../../src/brainy.js' import type { BrainyChangeEvent } from '../../src/events/changeFeed.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' let seq = 0 const freshId = (): string => `00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}` /** Let the microtask-dispatched feed drain. */ const drained = (): Promise => new Promise((r) => setTimeout(r, 0)) describe('brain.onChange — the in-process change feed', () => { let dir: string let brain: any let events: BrainyChangeEvent[] let off: () => void beforeEach(async () => { process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-onchange-')) brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir }, dimensions: 384, silent: true }) await brain.init() events = [] off = brain.onChange((e: BrainyChangeEvent) => events.push(e)) }) afterEach(async () => { off() await brain.close() fs.rmSync(dir, { recursive: true, force: true }) }) it('add → update → remove: one fully-described event each; remove carries the last state', async () => { const id = await brain.add({ id: freshId(), data: 'a document', type: NounType.Document, metadata: { status: 'draft' } }) await brain.update({ id, metadata: { status: 'published' } }) await brain.remove(id) await drained() expect(events.map((e) => e.op)).toEqual(['add', 'update', 'remove']) expect(events.every((e) => e.kind === 'entity' && e.id === id)).toBe(true) expect(events[0].entity).toMatchObject({ id, type: 'document', metadata: { status: 'draft' } }) expect(events[1].entity).toMatchObject({ id, metadata: { status: 'published' } }) // The delete payload is the record's LAST committed state. expect(events[2].entity).toMatchObject({ id, type: 'document', metadata: { status: 'published' } }) // Post-commit ordering: generations are monotonic. const gens = events.map((e) => e.generation!) expect([...gens].sort((a, b) => a - b)).toEqual(gens) expect(new Set(gens).size).toBe(3) }) it('relate → updateRelation → unrelate: relation events with endpoints + type', async () => { const a = await brain.add({ id: freshId(), data: 'a', type: NounType.Thing }) const b = await brain.add({ id: freshId(), data: 'b', type: NounType.Thing }) events.length = 0 const rel = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, metadata: { w: 1 } }) await brain.updateRelation({ id: rel, metadata: { w: 2 } }) await brain.unrelate(rel) await drained() expect(events.map((e) => e.op)).toEqual(['relate', 'updateRelation', 'unrelate']) expect(events.every((e) => e.kind === 'relation' && e.id === rel)).toBe(true) for (const e of events) { expect(e.relation).toMatchObject({ id: rel, from: a, to: b }) } }) it('remove() cascades: deleting an entity emits unrelate for its relationships too', async () => { const a = await brain.add({ id: freshId(), data: 'src', type: NounType.Thing }) const b = await brain.add({ id: freshId(), data: 'tgt', type: NounType.Thing }) const rel = await brain.relate({ from: a, to: b, type: VerbType.Contains }) events.length = 0 await brain.remove(a) await drained() const removeEvent = events.find((e) => e.op === 'remove') const unrelateEvent = events.find((e) => e.op === 'unrelate') expect(removeEvent).toMatchObject({ kind: 'entity', id: a }) expect(unrelateEvent).toMatchObject({ kind: 'relation', id: rel, relation: { from: a, to: b } }) // Entity + cascaded verb share the one remove() generation. expect(removeEvent!.generation).toBe(unrelateEvent!.generation) }) it('batches emit one event per item; removeMany deletes carry payloads', async () => { const ids = [freshId(), freshId(), freshId()] await brain.addMany({ items: ids.map((id, i) => ({ id, data: `item ${i}`, type: NounType.Thing, metadata: { n: i } })) }) await drained() expect(events.filter((e) => e.op === 'add').length).toBe(3) events.length = 0 await brain.removeMany({ ids }) await drained() const removes = events.filter((e) => e.op === 'remove') expect(removes.length).toBe(3) // Every delete is fully described (enriched, not id-only). for (const e of removes) { expect(e.entity).toBeDefined() expect(e.entity!.type).toBe('thing') expect(typeof e.entity!.metadata.n).toBe('number') } }) it('transact(): per-item events sharing ONE generation; a rejected batch emits nothing', async () => { const x = freshId() const y = freshId() await brain.transact([ { op: 'add', id: x, data: 'x', type: NounType.Thing, metadata: { k: 1 } }, { op: 'add', id: y, data: 'y', type: NounType.Thing }, { op: 'relate', from: x, to: y, type: VerbType.RelatedTo } ]) await drained() expect(events.map((e) => e.op)).toEqual(['add', 'add', 'relate']) expect(new Set(events.map((e) => e.generation)).size).toBe(1) // Rejected batch (stale per-op CAS) → zero events. events.length = 0 await expect( brain.transact([{ op: 'update', id: x, metadata: { k: 2 }, ifRev: 999 }]) ).rejects.toMatchObject({ name: 'RevisionConflictError' }) await drained() expect(events.length).toBe(0) }) it('a losing ifRev CAS update emits NOTHING; the winner emits once', async () => { const id = await brain.add({ id: freshId(), data: 'contended', type: NounType.Thing }) const rev = (await brain.get(id))._rev events.length = 0 const results = await Promise.allSettled( Array.from({ length: 4 }, (_, i) => brain.update({ id, metadata: { w: i }, merge: false, ifRev: rev }) ) ) await drained() expect(results.filter((r) => r.status === 'fulfilled').length).toBe(1) expect(events.filter((e) => e.op === 'update').length).toBe(1) // exactly the winner }) it('VFS writes emit (the router-synthesis blind spot)', async () => { await brain.vfs.writeFile('/notes/hello.txt', 'hello feed') await drained() // A VFS write is entities + containment relations under the hood — the // feed sees it because VFS delegates to canonical brain methods. expect(events.length).toBeGreaterThan(0) expect(events.some((e) => e.kind === 'entity' && e.op === 'add')).toBe(true) }) it('listener errors are isolated; unsubscribe stops delivery', async () => { const good: BrainyChangeEvent[] = [] const offBad = brain.onChange(() => { throw new Error('subscriber bug') }) const offGood = brain.onChange((e: BrainyChangeEvent) => good.push(e)) const id = await brain.add({ id: freshId(), data: 'p', type: NounType.Thing }) await drained() expect(good.length).toBeGreaterThan(0) // sibling unaffected by the throwing listener offBad() offGood() good.length = 0 events.length = 0 off() // unsubscribe the outer listener too await brain.update({ id, metadata: { after: true } }) await drained() expect(events.length).toBe(0) expect(good.length).toBe(0) // Re-subscribe for afterEach symmetry. off = brain.onChange((e: BrainyChangeEvent) => events.push(e)) }) it('clear() emits one store-level event meaning "refetch everything"', async () => { await brain.add({ id: freshId(), data: 'doomed', type: NounType.Thing }) events.length = 0 await brain.clear() await drained() const store = events.filter((e) => e.kind === 'store') expect(store).toHaveLength(1) expect(store[0].op).toBe('clear') expect(store[0].generation).toBeUndefined() }) })