/** * Focused tests for `withoutDeleted.mapDelta` covering the four transition * cases of an entity's hidden-state across a delta: * * F→N : was-fine, still-fine (no transition) * F→D : was-fine, becomes hidden (newly deleted) * W→N : was-hidden, becomes visible (newly restored / un-deleted) * W→D : was-hidden, still hidden (idempotent re-deletion) * * The bug being guarded against: pre-fix, `mapDelta` filtered `delta.added` / * `delta.removed` against the post-mutation `isDeleted(en)` predicate. That * misclassifies entities whose hidden-state transitioned during the delta — * causing W→N to crash MappedThread (stale `vl:true` log slips into `removed` * but was never in result) and F→D-with-content-removal to silently keep * stale content in result. */ 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 { lastWriteWins, withoutDeleted } from './basic.ts' import type { ArrayEvent } from './subscribable.ts' 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 } let db: ThreadInMemory let events: ArrayEvent[] let unsub: () => void function recordEvents(thread: { subscribe: (cb: (e: ArrayEvent) => void) => () => void }) { events = [] unsub = thread.subscribe(event => events.push(event)) } beforeEach(() => { tsCounter = 0 const seed: ApplogForInsert[] = [ { en: 'e1', at: 'movie/title', vl: 'Predator', ag: 'testAgent' }, { en: 'e1', at: 'movie/year', vl: 1987, ag: 'testAgent' }, { en: 'e2', at: 'movie/title', vl: 'Lethal Weapon', ag: 'testAgent' }, { en: 'e2', at: 'movie/year', vl: 1987, ag: 'testAgent' }, ] db = ThreadInMemory.fromArray(makeApplogs(seed), 'test-withoutDeleted') }) afterEach(() => { unsub?.() }) describe('withoutDeleted.mapDelta — transition truth table', () => { it('F→N: ordinary content add/remove passes through unchanged', () => { const filtered = withoutDeleted(lastWriteWins(db)) recordEvents(filtered) // Sanity: e1 is visible at start expect(filtered.applogs.some(l => l.en === 'e1' && l.at === 'movie/title')).toBe(true) // Add new content — should pass through (entity stays visible) db.insert([{ en: 'e1', at: 'movie/cast', vl: 'p1', ag: 'testAgent' }]) const lastEvent = events[events.length - 1] as { added: readonly Applog[]; removed: readonly Applog[] | null } expect(lastEvent.added.length).toBe(1) expect(lastEvent.added[0]).toMatchObject({ en: 'e1', at: 'movie/cast', vl: 'p1' }) expect(lastEvent.removed ?? []).toEqual([]) // Result still contains e1 + the new cast log expect(filtered.applogs.some(l => l.en === 'e1' && l.at === 'movie/cast')).toBe(true) }) it('F→D: insertion of isDeleted=true emits all entity applogs as removed', () => { const filtered = withoutDeleted(lastWriteWins(db)) recordEvents(filtered) const e1ApplogsBefore = filtered.applogs.filter(l => l.en === 'e1') expect(e1ApplogsBefore.length).toBeGreaterThan(0) db.insert([{ en: 'e1', at: 'isDeleted', vl: true, ag: 'testAgent' }]) const removedAcrossEvents = events.flatMap(e => 'removed' in e ? (e.removed ?? []) : [], ) const removedEns = new Set(removedAcrossEvents.map(l => l.en)) expect(removedEns).toEqual(new Set(['e1'])) // All e1 applogs that were in result should be removed expect(removedAcrossEvents.length).toBe(e1ApplogsBefore.length) // The isDeleted=true log itself must NOT appear in `added` (entity is now hidden) const addedAcrossEvents = events.flatMap(e => 'added' in e ? (e.added ?? []) : [], ) expect(addedAcrossEvents.some(l => l.en === 'e1' && l.at === 'isDeleted')).toBe(false) // e1 fully gone from result expect(filtered.applogs.some(l => l.en === 'e1')).toBe(false) }) it('W→N: un-deletion via LWW supersession re-adds entity applogs without crashing or duplicating', () => { const filtered = withoutDeleted(lastWriteWins(db)) // Hide e1 db.insert([{ en: 'e1', at: 'isDeleted', vl: true, ag: 'testAgent' }]) expect(filtered.applogs.filter(l => l.en === 'e1').length).toBe(0) // Now subscribe — only un-deletion events should arrive recordEvents(filtered) // LWW supersession: appending vl:false makes the vl:true log non-current, // so withoutDeleted's mapper sees `delta.added=[{vl:false}]` and // `delta.removed=[{vl:true}]`. With the bug, the stale {vl:true} would // slip into our `removed` output and MappedThread.onParentUpdate would // throw "log not found" when trying to splice it from _applogs. expect(() => { db.insert([{ en: 'e1', at: 'isDeleted', vl: false, ag: 'testAgent' }]) }).not.toThrow() // The stale {vl:true} log must NOT appear in any `removed` — it was never in result. const removedAcrossEvents = events.flatMap(e => 'removed' in e ? (e.removed ?? []) : [], ) expect( removedAcrossEvents.some(l => l.en === 'e1' && l.at === 'isDeleted' && l.vl === true), ).toBe(false) // All e1 applogs should be re-added (synthetic additions). No duplicates. const addedAcrossEvents = events.flatMap(e => 'added' in e ? (e.added ?? []) : [], ) const addedE1 = addedAcrossEvents.filter(l => l.en === 'e1') expect(addedE1.length).toBeGreaterThan(0) // No duplicate cids in additions const cids = addedE1.map(l => l.cid) expect(new Set(cids).size).toBe(cids.length) // Result contains e1 again, and original content is back expect(filtered.applogs.some(l => l.en === 'e1' && l.at === 'movie/title' && l.vl === 'Predator')).toBe(true) }) it('W→D: re-deleting an already-hidden entity (multi-attr) is idempotent — no events about that entity', () => { const filtered = withoutDeleted(db) // First mark e1 hidden via 'isDeleted' db.insert([{ en: 'e1', at: 'isDeleted', vl: true, ag: 'testAgent' }]) expect(filtered.applogs.some(l => l.en === 'e1')).toBe(false) // Now subscribe and add a SECOND deletion-class marker (block/isDeleted). // Entity stays hidden; pass-through filter should exclude the marker. recordEvents(filtered) db.insert([{ en: 'e1', at: 'block/isDeleted', vl: true, ag: 'testAgent' }]) const addedAcrossEvents = events.flatMap(e => ('added' in e ? (e.added ?? []) : [])) const removedAcrossEvents = events.flatMap(e => ('removed' in e ? (e.removed ?? []) : [])) // The new isDeleted-class marker must NOT pass through to result (entity still hidden) expect(addedAcrossEvents.some(l => l.en === 'e1' && l.at === 'block/isDeleted')).toBe(false) // And nothing should be removed for e1 — it wasn't in result to begin with expect(removedAcrossEvents.some(l => l.en === 'e1')).toBe(false) // Result still has no e1 applogs expect(filtered.applogs.some(l => l.en === 'e1')).toBe(false) }) it('F→D + same-tick content removal: the removed content log appears in output exactly once', () => { // This guards the silent-drift bug where post-mutation filter excluded // legitimate `delta.removed` content for entities that just became hidden. // We exercise it by going through LWW: a content update supersedes the // previous content log, and in the SAME tick we delete the entity. // The downstream withoutDeleted sees `delta.removed=[oldContent]` and // `delta.added=[newContent, isDeleted=true]` — the old content log must // be reported as removed exactly once. const filtered = withoutDeleted(lastWriteWins(db)) recordEvents(filtered) const oldTitle = filtered.applogs.find(l => l.en === 'e1' && l.at === 'movie/title') expect(oldTitle).toBeDefined() db.insert([ { en: 'e1', at: 'movie/title', vl: 'Predator (rev)', ag: 'testAgent' }, { en: 'e1', at: 'isDeleted', vl: true, ag: 'testAgent' }, ]) const removedAcrossEvents = events.flatMap(e => 'removed' in e ? (e.removed ?? []) : [], ) const oldTitleOccurrences = removedAcrossEvents.filter(l => l.cid === oldTitle!.cid) expect(oldTitleOccurrences.length).toBe(1) // e1 is hidden now expect(filtered.applogs.some(l => l.en === 'e1')).toBe(false) }) })