mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(session-query): harden SQLite search reconciliation
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user