/** * Query engine test suite — tests the push-based query system. * * Part 1: Non-reactive (snapshot) tests — verify query correctness without subscriptions * Part 2: Reactive tests — verify subscribe, insert, event propagation, lazy activation */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { finalizeApplogForInsert } from '../applog/applog-helpers.ts' import { sortApplogsByTs } from '../applog/applog-utils.ts' import type { Applog, ApplogForInsert } from '../applog/datom-types.ts' import { isInitEvent } from '../thread/basic.ts' import { ThreadInMemory } from '../thread/writeable.ts' import { liveQuery, liveQueryNot, query, queryNot } from './basic.ts' import { isArrayInitEvent, type ArrayEvent } from './subscribable.ts' import { QueryNode, QueryResult, LiveQueryResult } from './types.ts' // ─── Test Data ─────────────────────────────────────────────────── // A small movie database — same schema as note3's test-applogs but // with explicit CIDs and a smaller dataset for focused testing. let tsCounter = 0 function makeApplogs(inputs: ApplogForInsert[]): Applog[] { const logs = inputs.map(input => finalizeApplogForInsert({ ts: new Date(1700000000000 + ++tsCounter * 1000).toISOString(), pv: null, ag: 'testAgent', ...input, } as ApplogForInsert, {}), ) sortApplogsByTs(logs) return logs } // Base dataset const baseData: ApplogForInsert[] = [ // People { en: 'p1', at: 'person/name', vl: 'James Cameron', ag: 'testAgent' }, { en: 'p2', at: 'person/name', vl: 'Arnold Schwarzenegger', ag: 'testAgent' }, { en: 'p3', at: 'person/name', vl: 'Linda Hamilton', ag: 'testAgent' }, { en: 'p4', at: 'person/name', vl: 'John McTiernan', ag: 'testAgent' }, { en: 'p5', at: 'person/name', vl: 'Bruce Willis', ag: 'testAgent' }, // Movies { en: 'm1', at: 'movie/title', vl: 'The Terminator', ag: 'testAgent' }, { en: 'm1', at: 'movie/year', vl: 1984, ag: 'testAgent' }, { en: 'm1', at: 'movie/director', vl: 'p1', ag: 'testAgent' }, { en: 'm1', at: 'movie/cast', vl: 'p2', ag: 'testAgent' }, { en: 'm1', at: 'movie/cast', vl: 'p3', ag: 'testAgent' }, { en: 'm2', at: 'movie/title', vl: 'Predator', ag: 'testAgent' }, { en: 'm2', at: 'movie/year', vl: 1987, ag: 'testAgent' }, { en: 'm2', at: 'movie/director', vl: 'p4', ag: 'testAgent' }, { en: 'm2', at: 'movie/cast', vl: 'p2', ag: 'testAgent' }, { en: 'm3', at: 'movie/title', vl: 'Die Hard', ag: 'testAgent' }, { en: 'm3', at: 'movie/year', vl: 1988, ag: 'testAgent' }, { en: 'm3', at: 'movie/director', vl: 'p4', ag: 'testAgent' }, { en: 'm3', at: 'movie/cast', vl: 'p5', ag: 'testAgent' }, { en: 'm4', at: 'movie/title', vl: 'T2: Judgment Day', ag: 'testAgent' }, { en: 'm4', at: 'movie/year', vl: 1991, ag: 'testAgent' }, { en: 'm4', at: 'movie/director', vl: 'p1', ag: 'testAgent' }, { en: 'm4', at: 'movie/cast', vl: 'p2', ag: 'testAgent' }, { en: 'm4', at: 'movie/cast', vl: 'p3', ag: 'testAgent' }, ] // ─── Helpers ───────────────────────────────────────────────────── let db: ReturnType beforeEach(() => { db = ThreadInMemory.fromArray(makeApplogs(baseData), 'test-movies') }) afterEach(() => { // Fresh db is created in beforeEach — no global state to reset }) // ═════════════════════════════════════════════════════════════════ // PART 1: Non-reactive (snapshot) tests // ═════════════════════════════════════════════════════════════════ describe('query engine — non-reactive', () => { describe('single-step query', () => { it('finds all movies by year', () => { const result = query(db, [{ at: 'movie/year', vl: 1987 }]) expect(result.nodes).toHaveLength(1) expect(result.nodes[0].variables).toMatchObject({ }) expect(result.nodes[0].logsOfThisNode.applogs[0].en).toBe('m2') }) it('finds all movies with a specific attribute', () => { const result = query(db, [{ at: 'movie/title' }]) expect(result.nodes).toHaveLength(4) // 4 movies }) it('finds by entity and attribute', () => { const result = query(db, [{ en: 'p1', at: 'person/name' }]) expect(result.nodes).toHaveLength(1) expect(result.nodes[0].logsOfThisNode.applogs[0].vl).toBe('James Cameron') }) it('returns empty for no matches', () => { const result = query(db, [{ at: 'movie/year', vl: 2099 }]) expect(result.nodes).toHaveLength(0) expect(result.isEmpty).toBe(true) expect(result.size).toBe(0) }) }) describe('multi-step query (variable binding)', () => { it('resolves variable across two steps', () => { // Find directors of 1987 movies const result = query(db, [ { en: '?movieId', at: 'movie/year', vl: 1987 }, { en: '?movieId', at: 'movie/director', vl: '?directorId' }, ]) expect(result.records).toEqual([ { movieId: 'm2', directorId: 'p4' }, ]) }) it('resolves three-step query (movie → director → name)', () => { // Find names of directors of 1984 movies const result = query(db, [ { en: '?movieId', at: 'movie/year', vl: 1984 }, { en: '?movieId', at: 'movie/director', vl: '?directorId' }, { en: '?directorId', at: 'person/name', vl: '?directorName' }, ]) expect(result.records).toEqual([ { movieId: 'm1', directorId: 'p1', directorName: 'James Cameron' }, ]) }) it('fan-out: multiple results per step', () => { // Find all cast of 1984 movies const result = query(db, [ { en: '?movieId', at: 'movie/year', vl: 1984 }, { en: '?movieId', at: 'movie/cast', vl: '?actorId' }, ]) expect(result.records).toHaveLength(2) // p2, p3 const actorIds = result.records.map(r => r.actorId).sort() expect(actorIds).toEqual(['p2', 'p3']) }) it('fan-out across multiple input nodes', () => { // Arnold's movies: find movies where p2 is cast, get titles const result = query(db, [ { en: '?movieId', at: 'movie/cast', vl: 'p2' }, { en: '?movieId', at: 'movie/title', vl: '?title' }, ]) const titles = result.records.map(r => r.title).sort() expect(titles).toEqual(['Predator', 'T2: Judgment Day', 'The Terminator']) }) it('four-step query', () => { // Actor name → movies → director → director name const result = query(db, [ { en: '?actorId', at: 'person/name', vl: 'Arnold Schwarzenegger' }, { en: '?movieId', at: 'movie/cast', vl: '?actorId' }, { en: '?movieId', at: 'movie/director', vl: '?directorId' }, { en: '?directorId', at: 'person/name', vl: '?directorName' }, ]) const directors = result.records.map(r => r.directorName).sort() expect(directors).toEqual(['James Cameron', 'James Cameron', 'John McTiernan']) }) }) describe('QueryResult derived getters', () => { it('.records returns variable maps', () => { const result = query(db, [{ en: '?id', at: 'movie/title', vl: '?title' }]) expect(result.records).toHaveLength(4) expect(result.records[0]).toHaveProperty('id') expect(result.records[0]).toHaveProperty('title') }) it('.size and .isEmpty', () => { const result = query(db, [{ at: 'movie/title' }]) expect(result.size).toBe(4) expect(result.isEmpty).toBe(false) const empty = query(db, [{ at: 'nonexistent' }]) expect(empty.size).toBe(0) expect(empty.isEmpty).toBe(true) }) it('.leafNodeLogs returns applogs of leaf nodes', () => { const result = query(db, [{ en: 'm1', at: 'movie/title' }]) expect(result.leafNodeLogs).toHaveLength(1) expect(result.leafNodeLogs[0].vl).toBe('The Terminator') }) it.skip('.threadOfAllTrails joins all trail threads (requires CID-bearing applogs)', () => { // Skipped: joinThreads → removeDuplicateAppLogs requires CIDs on applogs. // Our test data omits CIDs (same as note3's test pattern). // This getter delegates to joinThreads which is tested separately. }) }) describe('one-off use (zero overhead)', () => { it('query result is immediately available without subscribe', () => { const result = query(db, [{ at: 'movie/title' }]) // No subscribe() call — items are available immediately expect(result.nodes).toHaveLength(4) expect(result.records).toHaveLength(4) }) it('query returns QueryResult without subscribe/dispose', () => { const result = query(db, [{ at: 'movie/title' }]) expect(result).toBeInstanceOf(QueryResult) expect(result).not.toHaveProperty('subscribe') expect(result).not.toHaveProperty('dispose') }) }) }) // ═════════════════════════════════════════════════════════════════ // PART 2: Reactive tests // ═════════════════════════════════════════════════════════════════ describe('liveQuery — reactive', () => { describe('subscribe basics', () => { it('liveQuery returns LiveQueryResult', () => { const result = liveQuery(db, [{ at: 'movie/title' }]) expect(result).toBeInstanceOf(LiveQueryResult) expect(result.nodes).toHaveLength(4) result.dispose() }) it('subscribe does NOT send init event — read .nodes for current state', () => { const result = liveQuery(db, [{ at: 'movie/title' }]) const events: ArrayEvent[] = [] const unsub = result.subscribe(e => events.push(e)) // No init event fired expect(events).toHaveLength(0) // Current state available via .nodes expect(result.nodes).toHaveLength(4) unsub() result.dispose() }) it('unsubscribe cleans up without error', () => { const result = liveQuery(db, [{ at: 'movie/title' }]) const unsub = result.subscribe(() => {}) expect(() => unsub()).not.toThrow() result.dispose() }) }) describe('single-step reactive updates', () => { it('receives added event after insert', () => { const result = liveQuery(db, [{ at: 'movie/year', vl: 1987 }]) const events: ArrayEvent[] = [] const unsub = result.subscribe(e => events.push(e)) expect(events).toHaveLength(0) expect(result.nodes).toHaveLength(1) db.insert([ { en: 'm5', at: 'movie/year', vl: 1987, ag: 'testAgent' }, ]) expect(events.length).toBeGreaterThan(0) const addedEvents = events.filter(e => !isArrayInitEvent(e)) expect(addedEvents.length).toBeGreaterThan(0) expect(result.nodes).toHaveLength(2) unsub() result.dispose() }) it('items update live after insert', () => { const result = liveQuery(db, [{ en: '?id', at: 'movie/title', vl: '?title' }]) const titlesBefore = result.records.map(r => r.title).sort() expect(titlesBefore).toEqual(['Die Hard', 'Predator', 'T2: Judgment Day', 'The Terminator']) db.insert([ { en: 'm5', at: 'movie/title', vl: 'Aliens', ag: 'testAgent' }, ]) const titlesAfter = result.records.map(r => r.title).sort() expect(titlesAfter).toEqual(['Aliens', 'Die Hard', 'Predator', 'T2: Judgment Day', 'The Terminator']) result.dispose() }) it('does not receive events after unsubscribe', () => { const result = liveQuery(db, [{ at: 'movie/year', vl: 1987 }]) const cb = vi.fn() const unsub = result.subscribe(cb) expect(cb).toHaveBeenCalledTimes(0) unsub() db.insert([ { en: 'm5', at: 'movie/year', vl: 1987, ag: 'testAgent' }, ]) // Activation unsub still holds, but user callback is gone expect(cb).toHaveBeenCalledTimes(0) result.dispose() }) }) describe('multi-step reactive updates', () => { it('propagates insert through multi-step query', () => { const result = liveQuery(db, [ { en: '?movieId', at: 'movie/year', vl: 1984 }, { en: '?movieId', at: 'movie/director', vl: '?directorId' }, { en: '?directorId', at: 'person/name', vl: '?directorName' }, ]) expect(result.records).toEqual([ { movieId: 'm1', directorId: 'p1', directorName: 'James Cameron' }, ]) db.insert([ { en: 'm5', at: 'movie/year', vl: 1984, ag: 'testAgent' }, { en: 'm5', at: 'movie/director', vl: 'p4', ag: 'testAgent' }, ]) const directorNames = result.records.map(r => r.directorName).sort() expect(directorNames).toContain('James Cameron') expect(directorNames).toContain('John McTiernan') result.dispose() }) it('new data in step 2 propagates to existing step 1 results', () => { const result = liveQuery(db, [ { en: '?movieId', at: 'movie/director', vl: 'p1' }, { en: '?movieId', at: 'movie/cast', vl: '?actorId' }, ]) const actorsBefore = result.records.map(r => r.actorId).sort() expect(actorsBefore).toEqual(['p2', 'p2', 'p3', 'p3']) db.insert([ { en: 'm1', at: 'movie/cast', vl: 'p5', ag: 'testAgent' }, ]) const actorsAfter = result.records.map(r => r.actorId).sort() expect(actorsAfter).toContain('p5') expect(actorsAfter.length).toBe(5) result.dispose() }) }) describe('eagerly activated — always up-to-date', () => { it('nodes update without explicit subscribe', () => { const result = liveQuery(db, [{ at: 'movie/year', vl: 1987 }]) expect(result.nodes).toHaveLength(1) // Insert — liveQuery is eagerly activated, so it tracks changes db.insert([ { en: 'm5', at: 'movie/year', vl: 1987, ag: 'testAgent' }, ]) expect(result.nodes).toHaveLength(2) result.dispose() }) }) describe('dispose', () => { it('dispose tears down subscriptions', () => { const result = liveQuery(db, [{ at: 'movie/title' }]) const cb = vi.fn() result.subscribe(cb) expect(cb).toHaveBeenCalledTimes(0) db.insert([ { en: 'm5', at: 'movie/title', vl: 'Aliens', ag: 'testAgent' }, ]) expect(cb).toHaveBeenCalledTimes(1) result.dispose() db.insert([ { en: 'm6', at: 'movie/title', vl: 'Alien 3', ag: 'testAgent' }, ]) expect(cb).toHaveBeenCalledTimes(1) }) }) describe('event batching', () => { it('multi-step fires single event per upstream change', () => { const result = liveQuery(db, [ { en: '?movieId', at: 'movie/year', vl: 1984 }, { en: '?movieId', at: 'movie/cast', vl: '?actorId' }, ]) const events: ArrayEvent[] = [] const unsub = result.subscribe(e => events.push(e)) db.insert([ { en: 'm5', at: 'movie/year', vl: 1984, ag: 'testAgent' }, ]) db.insert([ { en: 'm5', at: 'movie/cast', vl: 'p4', ag: 'testAgent' }, ]) db.insert([ { en: 'm5', at: 'movie/cast', vl: 'p5', ag: 'testAgent' }, ]) expect(events.length).toBeLessThanOrEqual(6) unsub() result.dispose() }) }) describe('memoization', () => { it('same args return same LiveQueryResult instance', () => { const r1 = liveQuery(db, [{ at: 'movie/title' }]) const r2 = liveQuery(db, [{ at: 'movie/title' }]) expect(r1).toBe(r2) r1.dispose() }) it('different args return different instances', () => { const r1 = liveQuery(db, [{ at: 'movie/title' }]) const r2 = liveQuery(db, [{ at: 'movie/year' }]) expect(r1).not.toBe(r2) r1.dispose() r2.dispose() }) }) }) // ═════════════════════════════════════════════════════════════════ // PART 3: liveQueryNot — incremental NOT-filter // ═════════════════════════════════════════════════════════════════ describe('liveQueryNot', () => { // Schema: movies have directors. queryNot finds movies WITHOUT a director. let db: ReturnType beforeEach(() => { db = ThreadInMemory.fromArray(makeApplogs([ { en: 'm1', at: 'movie/title', vl: 'The Terminator', ag: 'a' }, { en: 'm2', at: 'movie/title', vl: 'Predator', ag: 'a' }, { en: 'm3', at: 'movie/title', vl: 'Die Hard', ag: 'a' }, // m1 and m2 have directors, m3 does not { en: 'm1', at: 'movie/director', vl: 'James Cameron', ag: 'a' }, { en: 'm2', at: 'movie/director', vl: 'John McTiernan', ag: 'a' }, ]), 'test-qnot') }) it('snapshot queryNot: excludes nodes matching the pattern', () => { const movies = query(db, [{ en: '?id', at: 'movie/title' }]) const noDirector = queryNot(db, movies, [{ en: '?id', at: 'movie/director' }]) expect(noDirector.records.map(r => r.id)).toEqual(['m3']) }) it('initial state matches snapshot queryNot', () => { const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }]) const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }]) expect(result.nodes).toHaveLength(1) expect(result.nodes[0].variables.id).toBe('m3') result.dispose() upstream.dispose() }) it('incrementally removes node when new applog matches NOT pattern', () => { const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }]) const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }]) expect(result.nodes).toHaveLength(1) // m3 has no director // Give m3 a director — should be excluded now db.insert([{ en: 'm3', at: 'movie/director', vl: 'John McTiernan', ag: 'a' }]) expect(result.nodes).toHaveLength(0) result.dispose() upstream.dispose() }) it('includes new upstream node that passes NOT filter', () => { const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }]) const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }]) expect(result.nodes).toHaveLength(1) // m3 // Add a new movie WITHOUT a director db.insert([{ en: 'm4', at: 'movie/title', vl: 'Aliens', ag: 'a' }]) expect(result.nodes).toHaveLength(2) expect(result.records.map(r => r.id).sort()).toEqual(['m3', 'm4']) result.dispose() upstream.dispose() }) it('excludes new upstream node that fails NOT filter', () => { const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }]) const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }]) expect(result.nodes).toHaveLength(1) // Add a new movie WITH a director (both in same batch) db.insert([ { en: 'm4', at: 'movie/title', vl: 'Aliens', ag: 'a' }, { en: 'm4', at: 'movie/director', vl: 'James Cameron', ag: 'a' }, ]) // m4 should NOT appear (has director) expect(result.records.map(r => r.id)).toEqual(['m3']) result.dispose() upstream.dispose() }) it('fires subscribe events on changes', () => { const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }]) const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }]) const events: ArrayEvent[] = [] const unsub = result.subscribe(e => events.push(e)) expect(events).toHaveLength(0) // no init on subscribe // Adding director to m3 removes it db.insert([{ en: 'm3', at: 'movie/director', vl: 'Someone', ag: 'a' }]) expect(events.length).toBeGreaterThan(0) unsub() result.dispose() upstream.dispose() }) })