Subscribe once and receive one post-commit event per affected record for
EVERY canonical write, regardless of origin: direct calls, batch methods,
transact(), imports, and Virtual Filesystem writes all funnel through the
same commit points the feed is emitted from. This is the authoritative
in-process signal for live UIs, cache invalidation, and realtime sync layers
that forward it over their own transports.
Architecture — the post-commit dual of the ifRev precommit hook: mutation
methods hand lightweight event descriptors to the commit seam
(persistSingleOp / transact's plan), which stamps the committed
{generation, timestamp}, enriches entity deletes with the record's LAST
committed state from the commit's own before-images (free — Model B reads
them anyway; removeMany's id-only deletes gain full payloads this way), and
emits only after the commit succeeds — a losing CAS or rejected batch never
announces anything. Dispatch is a microtask FIFO after the mutex releases:
commit-ordered, a slow listener never delays a write, a throwing listener is
logged and isolated, and with no subscribers the write path constructs no
events at all. Single-point emission was chosen over per-method hooks
because the aggregation-hook pattern demonstrably drifted (relation ops and
removeMany were silently missing from it).
Coverage: add/update/remove (+ cascade unrelate per deleted relationship),
relate (both edges when bidirectional)/unrelate/updateRelation, per-item
events for addMany/updateMany/relateMany/removeMany, per-item events sharing
one generation for transact(), and transitively imports + VFS. clear() and
restore() — wholesale raw-state operations outside the per-record commit
path — emit a single store-level event meaning "refetch everything".
brain.close() drops all listeners.
New module src/events/changeFeed.ts (BrainyChangeEvent + ChangeFeed,
exported from the package root); guide docs/guides/reacting-to-changes.md.
Integration suite pins the contract: per-op payload fidelity, delete
last-state payloads, batch per-item emission, one-generation transact
batches, VFS-origin events, CAS-loser silence, ordering, listener isolation,
unsubscribe, and store-level events.
228 lines
8.6 KiB
TypeScript
228 lines
8.6 KiB
TypeScript
/**
|
|
* @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<void> => 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()
|
|
})
|
|
})
|