import { afterEach, beforeEach, describe, expect, it } 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 { ThreadInMemory } from '../thread/writeable.ts' import { createEpochSnapshot, resolveTimeSpec, serializeEpochSnapshot, purgeHistory } from './epoch-snapshot.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 — spanning several "epochs" so we can test time-range slicing. // Timestamps are sequential (1s apart) starting at 1700000001000. const baseData: ApplogForInsert[] = [ // People – created first (earliest timestamps) { 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 – created second { 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 let allLogs: Applog[] beforeEach(() => { tsCounter = 0 allLogs = makeApplogs(baseData) db = ThreadInMemory.fromArray(allLogs, 'test-movies') }) afterEach(() => { // Fresh db is created in beforeEach — no global state to reset }) // ═════════════════════════════════════════════════════════════════ // Tests // ═════════════════════════════════════════════════════════════════ describe('createEpochSnapshot', () => { it('returns empty map for an empty thread', () => { const emptyDb = ThreadInMemory.fromArray([], 'empty') const snap = createEpochSnapshot(emptyDb, { start: '2024-01-01T00:00:00.000Z', end: '2025-01-01T00:00:00.000Z', }) expect(snap.size).toBe(0) }) it('groups all logs by attribute when range covers everything', () => { // Find min and max timestamps from the generated data const firstTs = allLogs[0].ts const lastTs = allLogs[allLogs.length - 1].ts const snap = createEpochSnapshot(db, { start: firstTs, end: lastTs, // exclusive, so the last log's exact ts won't be included }) // last log excluded, so count should be allLogs.length - 1 expect(snap.size).toBeGreaterThan(0) // Count total logs across all groups let total = 0 for (const logs of snap.values()) total += logs.length expect(total).toBe(allLogs.length - 1) // last excluded by exclusive end }) it('includes all logs with a generous end bound', () => { const firstTs = allLogs[0].ts const snap = createEpochSnapshot(db, { start: firstTs, end: '3000-01-01T00:00:00.000Z', }) let total = 0 for (const logs of snap.values()) total += logs.length expect(total).toBe(allLogs.length) }) it('has the correct attribute keys', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) const attrs = new Set(snap.keys()) expect(attrs.has('person/name')).toBe(true) expect(attrs.has('movie/title')).toBe(true) expect(attrs.has('movie/year')).toBe(true) expect(attrs.has('movie/director')).toBe(true) expect(attrs.has('movie/cast')).toBe(true) expect(attrs.size).toBe(5) }) it('groups logs correctly — person/name has 5 entries', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) const nameLogs = snap.get('person/name') expect(nameLogs).toBeDefined() expect(nameLogs!.length).toBe(5) }) it('groups logs correctly — movie/title has 4 entries', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) const titleLogs = snap.get('movie/title') expect(titleLogs).toBeDefined() expect(titleLogs!.length).toBe(4) // Verify specific titles const titles = titleLogs!.map(l => l.vl) expect(titles).toContain('The Terminator') expect(titles).toContain('Predator') expect(titles).toContain('Die Hard') expect(titles).toContain('T2: Judgment Day') }) it('groups logs correctly — movie/cast has 6 entries across movies', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) const castLogs = snap.get('movie/cast') expect(castLogs).toBeDefined() expect(castLogs!.length).toBe(6) }) it('returns empty map when no logs fall in the time range', () => { const snap = createEpochSnapshot(db, { start: '1990-01-01T00:00:00.000Z', end: '1990-06-01T00:00:00.000Z', }) expect(snap.size).toBe(0) }) it('slices a partial range — only picks logs with timestamps in window', () => { // Our logs have sequential timestamps. Pick a window that covers // roughly the first half of logs. const midPoint = allLogs[Math.floor(allLogs.length / 2)].ts const snap = createEpochSnapshot(db, { start: allLogs[0].ts, end: midPoint, }) let total = 0 for (const logs of snap.values()) total += logs.length // Should contain roughly half the logs (excluding the one at midPoint) expect(total).toBeGreaterThan(0) expect(total).toBeLessThan(allLogs.length) }) it('sorts each group by timestamp ascending', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) for (const [attr, logs] of snap) { for (let i = 1; i < logs.length; i++) { expect(logs[i].ts.localeCompare(logs[i - 1].ts, 'en-US')).toBeGreaterThanOrEqual(0) } } }) it('throws RangeError when start >= end', () => { expect(() => createEpochSnapshot(db, { start: '2025-01-01T00:00:00.000Z', end: '2024-01-01T00:00:00.000Z', }) ).toThrow(RangeError) }) it('throws RangeError when start equals end', () => { expect(() => createEpochSnapshot(db, { start: '2024-06-01T00:00:00.000Z', end: '2024-06-01T00:00:00.000Z', }) ).toThrow(RangeError) }) it('attribute groups contain applogs from different entities', () => { // The `movie/director` attribute should link movies (m1-m4) to // director entities (p1, p4) — from different entities. const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) const dirLogs = snap.get('movie/director') expect(dirLogs).toBeDefined() expect(dirLogs!.length).toBe(4) // 4 movies × 1 director each const entities = new Set(dirLogs!.map(l => l.en)) expect(entities.has('m1')).toBe(true) expect(entities.has('m2')).toBe(true) expect(entities.has('m3')).toBe(true) expect(entities.has('m4')).toBe(true) }) it('preserves all Applog fields in each entry', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) // Spot-check a single log const titleLogs = snap.get('movie/title') const terminator = titleLogs!.find(l => l.vl === 'The Terminator') expect(terminator).toBeDefined() expect(terminator!.en).toBe('m1') expect(terminator!.at).toBe('movie/title') expect(terminator!.vl).toBe('The Terminator') expect(terminator!.ag).toBe('testAgent') expect(terminator!.cid).toBeDefined() expect(typeof terminator!.cid).toBe('string') }) it('does not mutate when the underlying thread changes', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) // Insert a new log into the thread const newLog = finalizeApplogForInsert({ en: 'm5', at: 'movie/title', vl: 'New Movie', ag: 'testAgent', ts: new Date(1800000000000).toISOString(), pv: null, } as ApplogForInsert, {}) db.insertRaw([newLog]) // The snapshot should still have the original 4 movies const titleLogs = snap.get('movie/title') expect(titleLogs).toBeDefined() expect(titleLogs!.length).toBe(4) }) }) // ═════════════════════════════════════════════════════════════════ // resolveTimeSpec tests // ═════════════════════════════════════════════════════════════════ describe('resolveTimeSpec', () => { // Use getter functions so thread/db is resolved during test execution, // not at describe-time (before beforeEach populates db). const getThread = () => db it('resolves -1 to the earliest log timestamp', () => { const result = resolveTimeSpec(-1, getThread(), new Date('2025-01-01')) expect(result).toBe(allLogs[0].ts) }) it('resolves 0 to now', () => { const now = new Date('2025-06-15T12:00:00.000Z') const result = resolveTimeSpec(0, getThread(), now) expect(result).toBe(now.toISOString()) }) it('resolves -1 to now for an empty thread', () => { const empty = ThreadInMemory.fromArray([], 'empty') const now = new Date('2025-06-15T12:00:00.000Z') const result = resolveTimeSpec(-1, empty, now) expect(result).toBe(now.toISOString()) }) it('resolves "now" to the given now date', () => { const now = new Date('2025-06-15T12:00:00.000Z') const result = resolveTimeSpec('now', getThread(), now) expect(result).toBe(now.toISOString()) }) it('resolves "-1d" to 1 day before now', () => { const now = new Date('2025-06-15T12:00:00.000Z') const result = resolveTimeSpec('-1d', getThread(), now) const expected = new Date('2025-06-14T12:00:00.000Z').toISOString() expect(result).toBe(expected) }) it('resolves "-2h" to 2 hours before now', () => { const now = new Date('2025-06-15T12:00:00.000Z') const result = resolveTimeSpec('-2h', getThread(), now) const expected = new Date('2025-06-15T10:00:00.000Z').toISOString() expect(result).toBe(expected) }) it('resolves "-30m" to 30 minutes before now', () => { const now = new Date('2025-06-15T12:00:00.000Z') const result = resolveTimeSpec('-30m', getThread(), now) const expected = new Date('2025-06-15T11:30:00.000Z').toISOString() expect(result).toBe(expected) }) it('resolves "-5s" to 5 seconds before now', () => { const now = new Date('2025-06-15T12:00:00.000Z') const result = resolveTimeSpec('-5s', getThread(), now) const expected = new Date('2025-06-15T11:59:55.000Z').toISOString() expect(result).toBe(expected) }) it('resolves "+1d" to 1 day after now', () => { const now = new Date('2025-06-15T12:00:00.000Z') const result = resolveTimeSpec('+1d', getThread(), now) const expected = new Date('2025-06-16T12:00:00.000Z').toISOString() expect(result).toBe(expected) }) it('resolves ISO string as-is', () => { const result = resolveTimeSpec('2024-12-25T00:00:00.000Z', getThread(), new Date()) expect(result).toBe('2024-12-25T00:00:00.000Z') }) it('resolves positive number as Unix timestamp', () => { const ts = 1700000000000 const result = resolveTimeSpec(ts, getThread(), new Date()) expect(result).toBe(new Date(ts).toISOString()) }) }) // ═════════════════════════════════════════════════════════════════ // serializeEpochSnapshot tests // ═════════════════════════════════════════════════════════════════ describe('serializeEpochSnapshot', () => { it('produces a serializable object with format marker and metadata', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) const serialized = serializeEpochSnapshot(snap, { attribute: 'movie/title', timeRange: { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z' }, }) expect(serialized.format).toBe('wovin-epoch-snapshot-v1') expect(serialized.attribute).toBe('movie/title') expect(serialized.logCount).toBe(allLogs.length) expect(typeof serialized.createdAt).toBe('string') expect(serialized.groups['movie/title']).toBeDefined() expect(serialized.groups['movie/title'].length).toBe(4) }) it('includes all attribute groups in the serialized output', () => { const snap = createEpochSnapshot(db, { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z', }) const serialized = serializeEpochSnapshot(snap, { attribute: 'movie/cast', timeRange: { start: '2023-01-01T00:00:00.000Z', end: '3000-01-01T00:00:00.000Z' }, }) expect(Object.keys(serialized.groups).sort()).toEqual([ 'movie/cast', 'movie/director', 'movie/title', 'movie/year', 'person/name', ]) }) }) // ═════════════════════════════════════════════════════════════════ // purgeHistory tests // ═════════════════════════════════════════════════════════════════ describe('purgeHistory', () => { it('purges all but the latest log per entity for the target attribute', async () => { // Create a thread with multiple block/content entries per entity const logs = makeApplogs([ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' }, { en: 'b1', at: 'block/content', vl: 'v2', ag: 'testAgent' }, { en: 'b1', at: 'block/content', vl: 'v3', ag: 'testAgent' }, { en: 'b2', at: 'block/content', vl: 'w1', ag: 'testAgent' }, { en: 'b2', at: 'block/content', vl: 'w2', ag: 'testAgent' }, { en: 'b3', at: 'person/name', vl: 'Alice', ag: 'testAgent' }, // different attr — untouched ]) const thread = ThreadInMemory.fromArray(logs, 'purge-test') const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', { now: new Date('2025-01-01'), }) // b1 had 3 logs → kept 1, purged 2 // b2 had 2 logs → kept 1, purged 1 // b3 had 1 log with different attr → untouched (0, not in target attr) expect(result.purgedCount).toBe(3) expect(result.keptCount).toBe(2) // b1 winner + b2 winner // Thread should now have: b1/v3, b2/w2, b3/Alice expect(thread.applogs.length).toBe(3) const b1Logs = thread.applogs.filter(l => l.en === 'b1' && l.at === 'block/content') expect(b1Logs).toHaveLength(1) expect(b1Logs[0].vl).toBe('v3') const b2Logs = thread.applogs.filter(l => l.en === 'b2' && l.at === 'block/content') expect(b2Logs).toHaveLength(1) expect(b2Logs[0].vl).toBe('w2') }) it('does not touch attributes other than the target', async () => { const logs = makeApplogs([ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' }, { en: 'b1', at: 'block/content', vl: 'v2', ag: 'testAgent' }, { en: 'b1', at: 'person/name', vl: 'Alice', ag: 'testAgent' }, ]) const thread = ThreadInMemory.fromArray(logs, 'purge-other-attrs') await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', { now: new Date('2025-01-01'), }) // person/name should still be there const nameLogs = thread.applogs.filter(l => l.at === 'person/name') expect(nameLogs).toHaveLength(1) expect(nameLogs[0].vl).toBe('Alice') }) it('preserves snapshot via callback before purging', async () => { const logs = makeApplogs([ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' }, { en: 'b1', at: 'block/content', vl: 'v2', ag: 'testAgent' }, { en: 'b1', at: 'block/content', vl: 'v3', ag: 'testAgent' }, ]) const thread = ThreadInMemory.fromArray(logs, 'purge-persist') let captured: unknown = null const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', { now: new Date('2025-01-01'), persistSnapshot: async (snap) => { captured = snap }, }) expect(captured).not.toBeNull() const serialized = captured as any expect(serialized.format).toBe('wovin-epoch-snapshot-v1') expect(serialized.attribute).toBe('block/content') // Snapshot includes all attributes, but snapshot has block/content logs expect(serialized.groups['block/content'].length).toBe(3) // After purge, only 1 remains expect(result.purgedCount).toBe(2) expect(result.keptCount).toBe(1) }) it('handles single log per entity — no purge needed', async () => { const logs = makeApplogs([ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' }, { en: 'b2', at: 'block/content', vl: 'w1', ag: 'testAgent' }, ]) const thread = ThreadInMemory.fromArray(logs, 'purge-single') const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', { now: new Date('2025-01-01'), }) expect(result.purgedCount).toBe(0) expect(result.keptCount).toBe(2) expect(thread.applogs.length).toBe(2) }) it('handles empty thread gracefully', async () => { const thread = ThreadInMemory.fromArray([], 'purge-empty') const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', { now: new Date('2025-01-01'), }) expect(result.purgedCount).toBe(0) expect(result.keptCount).toBe(0) expect(result.snapshot.size).toBe(0) }) it('handles no logs of the target attribute in range', async () => { const logs = makeApplogs([ { en: 'b1', at: 'person/name', vl: 'Alice', ag: 'testAgent' }, ]) const thread = ThreadInMemory.fromArray(logs, 'purge-no-target') const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', { now: new Date('2025-01-01'), }) expect(result.purgedCount).toBe(0) expect(result.keptCount).toBe(0) }) it('scopes purge to the given time range', async () => { const logs = makeApplogs([ { en: 'b1', at: 'block/content', vl: 'old', ag: 'testAgent' }, { en: 'b1', at: 'block/content', vl: 'mid', ag: 'testAgent' }, { en: 'b1', at: 'block/content', vl: 'recent', ag: 'testAgent' }, ]) const thread = ThreadInMemory.fromArray(logs, 'purge-range') // Find timestamps to create a slice that includes only 'old' and 'mid' const oldLog = thread.applogs[0] const midLog = thread.applogs[1] const recentLog = thread.applogs[2] // Range: [old.ts, recent.ts) — includes old and mid, excludes recent const result = await purgeHistory(thread, 'block/content', oldLog.ts, recentLog.ts, { now: new Date('2025-01-01'), }) // Within [old.ts, recent.ts): old and mid — old is purged, mid is kept // recent is outside the range so untouched expect(result.purgedCount).toBe(1) // old purged expect(result.keptCount).toBe(1) // mid kept as LWW winner // Thread should still have mid and recent expect(thread.applogs).toHaveLength(2) expect(thread.applogs[0].vl).toBe('mid') expect(thread.applogs[1].vl).toBe('recent') }) it('returns the snapshot and timeRange in the result', async () => { const logs = makeApplogs([ { en: 'b1', at: 'block/content', vl: 'v1', ag: 'testAgent' }, ]) const thread = ThreadInMemory.fromArray(logs, 'purge-result') const result = await purgeHistory(thread, 'block/content', -1, '3000-01-01T00:00:00.000Z', { now: new Date('2025-01-01'), }) expect(result.snapshot).toBeDefined() expect(result.snapshot.size).toBeGreaterThan(0) // resolveTimeSpec(-1, ...) uses the THREAD's earliest log, not the outer dataset expect(result.timeRange.start).toBe(logs[0].ts) // -1 → earliest log in THIS thread expect(result.timeRange.end).toBe('3000-01-01T00:00:00.000Z') }) it('throws RangeError for inverted time range', async () => { const thread = ThreadInMemory.fromArray([], 'purge-inverted') await expect( purgeHistory(thread, 'block/content', '2025-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z'), ).rejects.toThrow(RangeError) }) })