fix(session-query): harden SQLite search reconciliation

This commit is contained in:
Hypatia May
2026-07-15 12:10:24 +08:00
parent ecf90ff382
commit f88ca85ffd
40 changed files with 1315 additions and 227 deletions

View File

@@ -1,18 +1,25 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { DatabaseSync } from 'node:sqlite'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
import SessionSearchSqlite, {
SESSION_QUERY_SQLITE_APPLICATION_ID,
SESSION_QUERY_SQLITE_SCHEMA_VERSION,
} from '@deepseek-ai/dsh-session-query-sqlite'
import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
import {
SessionQueryError,
SessionSearchCursor,
type SessionAvailability,
type SessionQueryErrorCode,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
const temporaryDirectories: string[] = []
@@ -48,19 +55,36 @@ function expectCode(code: SessionQueryErrorCode): Error {
class TestPersistence extends SessionPersistence {
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
static revisions = new Map<SessionIdType, number>()
static nextRevision = 0
static loads = new Map<SessionIdType, number>()
static loadEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void) | undefined
static listGate: Promise<void> | undefined
static listStarted: (() => void) | undefined
static snapshotEffect: (() => void | Promise<void>) | undefined
static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined
static failure: unknown
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
this.entries = new Map()
this.revisions = new Map()
this.loads = new Map()
this.loadEffect = undefined
for (const entry of entries) this.set(entry)
this.listGate = undefined
this.listStarted = undefined
this.snapshotEffect = undefined
this.snapshotOverride = undefined
this.failure = undefined
}
static set(entry: { meta: SessionHeader; events: SessionEvent[] }): void {
this.entries.set(entry.meta.id, structuredClone(entry))
this.revisions.set(entry.meta.id, ++this.nextRevision)
}
create(meta: SessionHeader): Promise<void> {
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
TestPersistence.set({ meta, events: [] })
return Promise.resolve()
}
@@ -68,13 +92,21 @@ class TestPersistence extends SessionPersistence {
const entry = TestPersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
entry.events.push(...structuredClone(events))
TestPersistence.revisions.set(id, ++TestPersistence.nextRevision)
return Promise.resolve()
}
async load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
TestPersistence.loads.set(id, (TestPersistence.loads.get(id) ?? 0) + 1)
if (TestPersistence.failure !== undefined) throw TestPersistence.failure
const entry = TestPersistence.entries.get(id)
if (entry === undefined) throw new Error('missing test session')
if (TestPersistence.loadEffect !== undefined) {
const effect = TestPersistence.loadEffect
TestPersistence.loadEffect = undefined
effect(entry)
TestPersistence.revisions.set(id, ++TestPersistence.nextRevision)
}
return structuredClone(entry)
}
@@ -84,6 +116,20 @@ class TestPersistence extends SessionPersistence {
if (TestPersistence.failure !== undefined) throw TestPersistence.failure
return [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))
}
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
TestPersistence.listStarted?.()
await TestPersistence.listGate
if (TestPersistence.failure !== undefined) throw TestPersistence.failure
const snapshots = TestPersistence.snapshotOverride?.()
?? [...TestPersistence.entries.values()].map(entry => ({
header: structuredClone(entry.meta),
revision: SessionPersistenceRevision(`test:${TestPersistence.revisions.get(entry.meta.id)}`),
}))
await TestPersistence.snapshotEffect?.()
return snapshots
}
}
async function liveContext(config: ConstructorParameters<typeof SessionSearchSqlite>[1] = { path: ':memory:' }): Promise<Context> {
@@ -175,6 +221,44 @@ describe('SQLite session search', () => {
await expect(ctx.sessionSearch.searchSessions({ query: '*' })).resolves.toEqual({ items: [] })
})
it('ranks live and persisted matches on one source-comparable contract', async () => {
const persisted = header('z-persisted')
TestPersistence.reset([
{ meta: persisted, events: messageEvents('needle needle', 10) },
...Array.from({ length: 12 }, (_, index) => ({
meta: header(`filler-${index}`),
events: messageEvents('needle', 10),
})),
])
const ctx = await liveContext()
const persistence = await ctx.plugin(TestPersistence)
ctx.sessions.create(SessionId('a-live'), {
seed: messageEvents('needle needle', 10),
meta: { createdAt: persisted.createdAt },
})
const result = await ctx.sessionSearch.searchSessions({
query: 'needle',
sessionFilters: [{ kind: 'id', values: [SessionId('a-live'), persisted.id] }],
})
expect(result.items.map(item => item.header.id)).toEqual([SessionId('a-live'), persisted.id])
await persistence.dispose()
})
it('positions snippets from FTS5 matches across diacritics and punctuation', async () => {
const ctx = await liveContext({ path: ':memory:', snippetChars: 14 })
const session = ctx.sessions.create(SessionId('snippet'), {
seed: messageEvents('long long long—café,\nnext value', 10),
})
const page = await ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'CAFE' })
expect(page.items).toHaveLength(1)
expect(page.items[0]!.snippet).toContain('café')
expect(page.items[0]!.snippet).toContain('—')
expect(page.items[0]!.snippet).not.toContain('\n')
expect(Array.from(page.items[0]!.snippet).length).toBeLessThanOrEqual(14)
})
it('binds cursors to requests and only invalidates within-session pages for target changes', async () => {
const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 })
const target = ctx.sessions.create(SessionId('target'), {
@@ -193,7 +277,7 @@ describe('SQLite session search', () => {
if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors')
const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`)
let eventCursor: string | undefined = eventPage.nextCursor
let eventCursor: ReturnType<typeof SessionSearchCursor> | undefined = eventPage.nextCursor
while (eventCursor !== undefined) {
const next = await ctx.sessionSearch.searchEvents({
sessionId: target.id,
@@ -208,7 +292,7 @@ describe('SQLite session search', () => {
expect(new Set(eventKeys).size).toBe(eventKeys.length)
const sessionIds = sessionPage.items.map(item => item.header.id)
let sessionCursor: string | undefined = sessionPage.nextCursor
let sessionCursor: ReturnType<typeof SessionSearchCursor> | undefined = sessionPage.nextCursor
while (sessionCursor !== undefined) {
const next = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor })
sessionIds.push(...next.items.map(item => item.header.id))
@@ -251,6 +335,7 @@ describe('SQLite session search', () => {
{ sessionId: session.id, query: 'needle', limit: 4 },
{ sessionId: session.id, query: 'needle', filters: [{ kind: 'seq', from: 2, to: 1 }] },
{ sessionId: session.id, query: 'needle', filters: [{ kind: 'surface', values: ['future'] }] },
{ sessionId: session.id, query: 'bad\0query' },
] as const) {
await expect(ctx.sessionSearch.searchEvents(request as never)).rejects.toBeInstanceOf(Error)
}
@@ -258,7 +343,24 @@ describe('SQLite session search', () => {
query: 'needle',
sessionFilters: [{ kind: 'availability', values: ['remote' as never] }],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'needle', cursor: 'not-json' }))
await expect(ctx.sessionSearch.searchSessions({
query: 'needle',
sessionFilters: [{ kind: 'future' } as never],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchSessions({
query: 'needle',
eventFilters: [{ kind: 'future' } as never],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
sessionId: session.id,
query: 'needle',
filters: [{ kind: 'future' } as never],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
sessionId: session.id,
query: 'needle',
cursor: SessionSearchCursor('not-json'),
}))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR'))
await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
@@ -280,6 +382,37 @@ describe('SQLite session search', () => {
})
describe('SQLite reconciliation and source lifecycle', () => {
it('owns queued request and filter values before waiting for the serializer', async () => {
const durable = header('owned')
TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }])
const ctx = await liveContext()
const persistence = await ctx.plugin(TestPersistence)
let release!: () => void
TestPersistence.listGate = new Promise<void>((resolve) => { release = resolve })
let markStarted!: () => void
const started = new Promise<void>((resolve) => { markStarted = resolve })
TestPersistence.listStarted = () => {
TestPersistence.listStarted = undefined
markStarted()
}
const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' })
await started
const availability: SessionAvailability[] = ['persisted']
const request: SessionSearchRequest = {
query: 'needle',
sessionFilters: [{ kind: 'availability', values: availability }],
}
const queued = ctx.sessionSearch.searchSessions(request)
request.query = 'absent'
availability[0] = 'live'
release()
await expect(blocking).resolves.toMatchObject({ items: [{ header: durable }] })
await expect(queued).resolves.toMatchObject({ items: [{ header: durable }] })
await persistence.dispose()
})
it('mounts persistence dynamically, shadows with TEMP live rows, reveals, and hides on unmount', async () => {
const shared = header('shared', 10, { cwd: '/work' })
const durable = header('durable', 5)
@@ -311,7 +444,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
})
it('restarts observation when persistence unmounts during an asynchronous list', async () => {
it('discards a stale list rejection when persistence unmounts during observation', async () => {
const durable = header('racing')
TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }])
const ctx = await liveContext()
@@ -328,10 +461,140 @@ describe('SQLite reconciliation and source lifecycle', () => {
const search = ctx.sessionSearch.searchSessions({ query: 'needle' })
await started
await persistenceFiber.dispose()
TestPersistence.failure = new Error('stale backend rejection')
release()
await expect(search).resolves.toEqual({ items: [] })
})
it('retries against a replacement after the prior binding rejects', async () => {
const durable = header('replacement')
TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }])
const ctx = await liveContext()
const prior = await ctx.plugin(TestPersistence)
let rejectPrior!: (reason: unknown) => void
TestPersistence.listGate = new Promise<void>((_resolve, reject) => { rejectPrior = reject })
let markStarted!: () => void
const started = new Promise<void>((resolve) => { markStarted = resolve })
TestPersistence.listStarted = () => {
TestPersistence.listStarted = undefined
markStarted()
}
const search = ctx.sessionSearch.searchSessions({ query: 'needle' })
await started
await prior.dispose()
TestPersistence.listGate = undefined
const replacement = await ctx.plugin(TestPersistence)
rejectPrior(new Error('stale prior binding'))
await expect(search).resolves.toMatchObject({ items: [{ header: durable }] })
await replacement.dispose()
})
it('reloads a replacement source even when its opaque revisions collide', async () => {
const durable = header('colliding-replacement')
TestPersistence.reset([{ meta: durable, events: messageEvents('old content') }])
const revision = TestPersistence.revisions.get(durable.id)!
const ctx = await liveContext()
const prior = await ctx.plugin(TestPersistence)
await expect(ctx.sessionSearch.searchSessions({ query: 'old' }))
.resolves.toMatchObject({ items: [{ header: durable }] })
await prior.dispose()
TestPersistence.set({ meta: durable, events: messageEvents('new needle') })
TestPersistence.revisions.set(durable.id, revision)
const replacement = await ctx.plugin(TestPersistence)
const internals = ctx.sessionSearch as unknown as {
_lastPersistenceRevision: number
_persistenceRevision: number
}
expect(internals._persistenceRevision).not.toBe(internals._lastPersistenceRevision)
const page = await ctx.sessionSearch.searchSessions({ query: 'new needle' })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
expect(page).toMatchObject({ items: [{ header: durable }] })
await expect(ctx.sessionSearch.searchSessions({ query: 'old' })).resolves.toEqual({ items: [] })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
await replacement.dispose()
})
it('retries when a successful observation belongs to a source unmounted during listing', async () => {
const durable = header('successful-unmount')
TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }])
const ctx = await liveContext()
const persistence = await ctx.plugin(TestPersistence)
let lists = 0
TestPersistence.snapshotEffect = async () => {
lists += 1
if (lists === 2) await persistence.dispose()
}
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toEqual({ items: [] })
expect(lists).toBe(2)
})
it('retries when the snapshot population changes during observation', async () => {
const first = header('first')
const added = header('added-during-list')
TestPersistence.reset([{ meta: first, events: messageEvents('first needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
TestPersistence.snapshotEffect = () => {
TestPersistence.snapshotEffect = undefined
TestPersistence.set({ meta: added, events: messageEvents('added needle') })
}
const page = await ctx.sessionSearch.searchSessions({ query: 'needle' })
expect(page.items.map(item => item.header.id).sort()).toEqual([added.id, first.id].sort())
expect(TestPersistence.loads.get(first.id)).toBe(2)
expect(TestPersistence.loads.get(added.id)).toBe(1)
})
it('retries if the source revision changes while live sessions are observed', async () => {
const durable = header('live-boundary-retry')
TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const internals = ctx.sessionSearch as unknown as { _persistenceRevision: number }
const originalList = ctx.sessions.list.bind(ctx.sessions)
let bumped = false
const list = vi.spyOn(ctx.sessions, 'list').mockImplementation(() => {
if (!bumped) {
bumped = true
internals._persistenceRevision += 1
}
return originalList()
})
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
.resolves.toMatchObject({ items: [{ header: durable }] })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
list.mockRestore()
})
it('rejects malformed snapshots and preserves typed persistence failures', async () => {
const durable = header('invalid-snapshot')
TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
TestPersistence.snapshotOverride = () => 'not-an-array' as never
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.snapshotOverride = () => [{ header: durable, revision: 1 as never }]
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.snapshotOverride = () => [
{ header: durable, revision: SessionPersistenceRevision('duplicate:1') },
{ header: durable, revision: SessionPersistenceRevision('duplicate:2') },
]
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.snapshotOverride = undefined
const typed = new SessionQueryError('typed persistence failure', 'SESSION_QUERY_PERSISTENCE_FAILED')
TestPersistence.failure = typed
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).rejects.toBe(typed)
})
it('rejects immutable header conflicts between live and persisted sources', async () => {
const shared = header('conflict', 10)
TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }])
@@ -358,6 +621,9 @@ describe('SQLite reconciliation and source lifecycle', () => {
const firstPersistence = await first.plugin(TestPersistence)
const firstSearch = await first.plugin(SessionSearchSqlite, { path })
await first.sessionSearch.searchSessions({ query: 'needle' })
expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 })
await first.sessionSearch.searchSessions({ query: 'needle' })
expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 })
await firstSearch.dispose()
await firstPersistence.dispose()
@@ -368,14 +634,20 @@ describe('SQLite reconciliation and source lifecycle', () => {
const added = header('added')
TestPersistence.entries.delete(deleted.id)
TestPersistence.entries.set(changed.id, { meta: changed, events: messageEvents('changed needle') })
TestPersistence.entries.set(added.id, { meta: added, events: messageEvents('added needle') })
TestPersistence.set({ meta: changed, events: messageEvents('changed needle') })
TestPersistence.set({ meta: added, events: messageEvents('added needle') })
const second = new Context()
await second.plugin(SessionStore)
const secondPersistence = await second.plugin(TestPersistence)
const secondSearch = await second.plugin(SessionSearchSqlite, { path })
const result = await second.sessionSearch.searchSessions({ query: 'needle' })
expect(result.items.map(item => item.header.id).sort()).toEqual([added.id, changed.id, unchanged.id].sort())
expect(Object.fromEntries(TestPersistence.loads)).toEqual({
unchanged: 1,
changed: 2,
deleted: 1,
added: 1,
})
await secondSearch.dispose()
await secondPersistence.dispose()
@@ -409,10 +681,27 @@ describe('SQLite reconciliation and source lifecycle', () => {
await expect(second.sessionSearch.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] })
await expect(second.sessionSearch.searchSessions({ query: 'persisted' }))
.resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] })
expect(TestPersistence.loads.get(shared.id)).toBe(1)
await searchAgain.dispose()
await persistenceAgain.dispose()
})
it('refreshes the stored revision after a mutating load repair', async () => {
const durable = header('repair')
TestPersistence.reset([{ meta: durable, events: messageEvents('before repair') }])
TestPersistence.loadEffect = (entry) => {
entry.events = messageEvents('repaired needle')
}
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
await expect(ctx.sessionSearch.searchSessions({ query: 'repaired' }))
.resolves.toMatchObject({ items: [{ header: durable }] })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
await ctx.sessionSearch.searchSessions({ query: 'repaired' })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
})
it('recovers on the next search after source and SQLite transaction failures', async () => {
TestPersistence.reset([{ meta: header('durable'), events: messageEvents('durable needle') }])
const ctx = await liveContext()
@@ -462,15 +751,18 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const foreignPath = await temporaryPath('foreign.db')
const foreign = new DatabaseSync(foreignPath)
foreign.exec('PRAGMA journal_mode = WAL')
foreign.exec('CREATE TABLE canonical(value TEXT)')
foreign.exec("INSERT INTO canonical VALUES ('safe')")
foreign.close()
const foreignCtx = await liveContext({ path: foreignPath })
const foreignCtx = await liveContext({ path: foreignPath, journalMode: 'delete' })
await expect(foreignCtx.sessionSearch.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
const stillForeign = new DatabaseSync(foreignPath)
expect(stillForeign.prepare('SELECT value FROM canonical').get()).toEqual({ value: 'safe' })
expect(stillForeign.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' })
stillForeign.close()
await (foreignCtx.sessionSearch as SessionSearchSqlite).close()
const otherAppPath = await temporaryPath('other-app.db')
const otherApp = new DatabaseSync(otherAppPath)
@@ -479,6 +771,25 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const otherAppCtx = await liveContext({ path: otherAppPath })
await expect(otherAppCtx.sessionSearch.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await (otherAppCtx.sessionSearch as SessionSearchSqlite).close()
})
it('observes asynchronous open rejection even when no query is made', async () => {
const path = await temporaryPath('never-queried.db')
const foreign = new DatabaseSync(path)
foreign.exec('CREATE TABLE canonical(value TEXT)')
foreign.close()
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown) => { unhandled.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
const ctx = await liveContext({ path })
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(unhandled).toEqual([])
await (ctx.sessionSearch as SessionSearchSqlite).close()
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('cancels both queued and in-flight source waits without committing them', async () => {
@@ -518,7 +829,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
releaseBlocking()
await expect(blocking).resolves.toEqual({ items: [] })
TestPersistence.entries.set(SessionId('uncommitted'), {
TestPersistence.set({
meta: header('uncommitted'),
events: messageEvents('durable needle'),
})
@@ -560,14 +871,38 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
await started
const queued = search.searchSessions({ query: 'needle' })
const closing = search.close()
const repeatedClose = search.close()
expect(repeatedClose).toBe(closing)
release()
await expect(accepted).resolves.toEqual({ items: [] })
await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await closing
await Promise.all([closing, repeatedClose])
await expect(search.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await search.close()
expect(search.close()).toBe(closing)
})
it('awaits optional-persistence child-fiber quiescence on disposal', async () => {
TestPersistence.reset()
const ctx = new Context()
await ctx.plugin(SessionStore)
const search = await ctx.plugin(SessionSearchSqlite, { path: ':memory:' })
const persistence = await ctx.plugin(TestPersistence)
const optional = (ctx.sessionSearch as unknown as {
_optionalPersistenceFiber: Fiber
})._optionalPersistenceFiber
let release!: () => void
const cleanup = new Promise<void>((resolve) => { release = resolve })
optional.ctx.effect(() => () => cleanup)
let settled = false
const disposing = search.dispose().then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
release()
await disposing
await persistence.dispose()
})
it('combines the real SQLite persistence backend with the real search service keylessly', async () => {