refactor(session-query): unify query service

This commit is contained in:
Hypatia May
2026-07-23 20:16:14 +08:00
parent a2a89bf300
commit 1e457b22e0
48 changed files with 482 additions and 365 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-session-query-sqlite
SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live-preferred logical session corpus and groups cross-session results by their strongest event.
Concrete `ctx.sessionQuery` backend. `SessionQuerySqlite` inherits exact reads, traces, and provider-independent filters from the interface package and implements its two full-text methods with SQLite FTS5. Search uses the live-preferred logical session corpus and groups cross-session results by their strongest event.
## Search contract
@@ -27,6 +27,7 @@ The database is disposable but reset is guarded: a recognized incompatible searc
| `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. |
| `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. |
| `snippetChars` | `240` | Maximum snippet length in Unicode code points. |
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count for inherited `readEvent()`. |
## Tokenizer and limits

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-session-query-sqlite",
"description": "SQLite FTS5 implementation of ctx.sessionSearch",
"description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,5 +1,5 @@
/**
* SQLite FTS5 search over the live-preferred logical session corpus.
* Concrete session-query service with SQLite FTS5 over the live-preferred corpus.
*
* @module @deepseek-ai/dsh-session-query-sqlite
*/
@@ -14,14 +14,15 @@ import type {
SessionPersistenceRevision,
SessionPersistenceSnapshot,
} from '@deepseek-ai/dsh-session-persistence'
import {
import SessionQueryService, {
SESSION_QUERY_READ_WINDOW_MAX,
SessionQueryError,
SessionSearchCursor,
SessionSearchService,
assertSessionHeadersCompatible,
buildSessionEventSearchDocuments,
} from '@deepseek-ai/dsh-session-query'
import type {
Config as SessionQueryConfig,
SessionEventSearchDocument,
SessionEventSearchHit,
SessionEventSearchRequest,
@@ -69,8 +70,8 @@ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240
// One transient source change gets a retry; repeated churn fails rather than monopolizing the queue.
const STABLE_OBSERVATION_ATTEMPTS = 2
/** SQLite session-search configuration. */
export interface Config {
/** Combined session-query configuration backed by SQLite full-text search. */
export interface Config extends SessionQueryConfig {
/**
* Dedicated derived-index path; `:memory:` is supported for tests. Missing
* directories and database files are created owner-only on POSIX filesystems;
@@ -93,6 +94,7 @@ interface ResolvedConfig {
defaultLimit: number
maxLimit: number
snippetChars: number
readWindowMax: number
}
interface ObservedSession {
@@ -158,9 +160,9 @@ interface CursorPayload {
offset: number
}
/** Concrete SQLite owner of `ctx.sessionSearch`. */
export class SessionSearchSqlite extends SessionSearchService {
static inject = ['sessions']
/** Concrete SQLite owner of the combined `ctx.sessionQuery` service. */
export class SessionQuerySqlite extends SessionQueryService {
static override inject = ['sessions']
static Config: z<Config> = z.object({
path: z.string().required(),
@@ -168,6 +170,7 @@ export class SessionSearchSqlite extends SessionSearchService {
defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT),
maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT),
snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS),
readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX),
})
/** Validated and defaulted backend configuration. */
@@ -187,7 +190,7 @@ export class SessionSearchSqlite extends SessionSearchService {
private readonly _optionalPersistenceFiber: Fiber
constructor(ctx: Context, config: Config) {
super(ctx)
super(ctx, config)
this.config = resolveConfig(config)
this._ready = this._open()
// Attach a rejection observer immediately; callers still receive the same
@@ -201,12 +204,12 @@ export class SessionSearchSqlite extends SessionSearchService {
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
if (this._persistenceBinding !== binding) return
this._persistenceBinding = { identity: Symbol() }
}, 'sessionSearchSqlite.persistenceBinding')
}, 'sessionQuerySqlite.persistenceBinding')
})
ctx.effect(() => {
return () => this._optionalPersistenceFiber.dispose()
}, 'sessionSearchSqlite.optionalPersistence')
ctx.effect(() => async () => this.close(), 'sessionSearchSqlite.close')
}, 'sessionQuerySqlite.optionalPersistence')
ctx.effect(() => async () => this.close(), 'sessionQuerySqlite.close')
}
override async searchSessions(
@@ -882,6 +885,7 @@ function resolveConfig(config: Config): ResolvedConfig {
defaultLimit: config.defaultLimit ?? SESSION_QUERY_SQLITE_DEFAULT_LIMIT,
maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT,
snippetChars: config.snippetChars ?? SESSION_QUERY_SQLITE_SNIPPET_CHARS,
readWindowMax: config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX,
}
if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) {
throw invalidConfig('path must not be blank')
@@ -963,4 +967,4 @@ function isRuntimeArray(value: unknown): boolean {
return Array.isArray(value)
}
export default SessionSearchSqlite
export default SessionQuerySqlite

View File

@@ -1,5 +1,5 @@
/**
* Keyless real-Loader-path smoke for the SQLite session-search service.
* Keyless real-Loader-path smoke for the combined SQLite session-query service.
*
* @module @deepseek-ai/dsh-session-query-sqlite/tests/load-path
*/
@@ -10,7 +10,7 @@ import Loader from '@cordisjs/plugin-loader'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
import SessionSearchSqlite, * as searchModule from '@deepseek-ai/dsh-session-query-sqlite'
import SessionQuerySqlite, * as queryModule from '@deepseek-ai/dsh-session-query-sqlite'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -38,9 +38,9 @@ describe('dsh-session-query-sqlite real Loader path', () => {
const persistence = await ctx.plugin(SessionPersistenceSqlite, { path: persistencePath })
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(searchModule) as Parameters<Context['plugin']>[0]
expect(unwrapped).toBe(SessionSearchSqlite)
const search = await ctx.plugin(unwrapped, { path: searchPath })
const unwrapped = loader.unwrapExports(queryModule) as Parameters<Context['plugin']>[0]
expect(unwrapped).toBe(SessionQuerySqlite)
const query = await ctx.plugin(unwrapped, { path: searchPath })
const id = SessionId('loader-path')
await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id, createdAt: 10 })
@@ -52,9 +52,11 @@ describe('dsh-session-query-sqlite real Loader path', () => {
surfaceOp: 'append',
}])
await expect(ctx.sessionSearch.searchSessions({ query: 'Loader needle' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'Loader needle' }))
.resolves.toMatchObject({ items: [{ header: { id }, persisted: true, live: false }] })
await search.dispose()
await expect(ctx.sessionQuery.listSessions())
.resolves.toMatchObject([{ header: { id }, persisted: true, live: false }])
await query.dispose()
await persistence.dispose()
})
})

View File

@@ -9,7 +9,7 @@ import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@d
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, {
import SessionQuerySqlite, {
SESSION_QUERY_SQLITE_APPLICATION_ID,
SESSION_QUERY_SQLITE_SCHEMA_VERSION,
} from '@deepseek-ai/dsh-session-query-sqlite'
@@ -146,10 +146,10 @@ class TestPersistence extends SessionPersistence {
}
}
async function liveContext(config: ConstructorParameters<typeof SessionSearchSqlite>[1] = { path: ':memory:' }): Promise<Context> {
async function liveContext(config: ConstructorParameters<typeof SessionQuerySqlite>[1] = { path: ':memory:' }): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionSearchSqlite, config)
await ctx.plugin(SessionQuerySqlite, config)
return ctx
}
@@ -165,9 +165,9 @@ describe('SQLite session search', () => {
{ surfaceOp: 'append' },
)
await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'AI' }))
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'AI' }))
.resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }] })
await expect(ctx.sessionSearch.searchSessions({ query: 'AI' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' }))
.resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] })
})
@@ -183,9 +183,9 @@ describe('SQLite session search', () => {
ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } })
ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } })
const all = await ctx.sessionSearch.searchEvents({ sessionId: SessionId('a'), query: 'needle' })
const all = await ctx.sessionQuery.searchEvents({ sessionId: SessionId('a'), query: 'needle' })
expect(new Set(all.items.map(item => item.surface))).toEqual(new Set(['current', 'shadowed', 'log-only']))
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: SessionId('a'),
query: 'needle',
filters: [
@@ -196,7 +196,7 @@ describe('SQLite session search', () => {
],
})).resolves.toMatchObject({ items: [{ seq: 2, surface: 'current' }] })
const grouped = await ctx.sessionSearch.searchSessions({
const grouped = await ctx.sessionQuery.searchSessions({
query: 'needle',
sessionFilters: [
{ kind: 'id', values: [SessionId('a')] },
@@ -231,9 +231,9 @@ describe('SQLite session search', () => {
() => ({ kind: 'type' as const, values: ['user/message' as const] }),
)
await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters }))
.resolves.toMatchObject({ items: [{ header: { id: session.id } }] })
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: session.id,
query: 'needle',
filters: eventFilters,
@@ -252,19 +252,19 @@ describe('SQLite session search', () => {
() => ({ kind: 'type' as const, values: ['user/message' as const] }),
)
await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: session.id,
query: 'needle',
filters: eventFilters,
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchSessions({
await expect(ctx.sessionQuery.searchSessions({
query: 'needle',
sessionFilters: sessionFilters.slice(0, 7),
eventFilters: eventFilters.slice(0, 8),
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: session.id,
query: 'needle',
filters: eventFilters.slice(0, 14),
@@ -281,15 +281,15 @@ describe('SQLite session search', () => {
ctx.sessions.create(SessionId('only'), { seed: messageEvents('needle only', 10), meta: { createdAt: 1 } })
ctx.sessions.create(SessionId('quote'), { seed: messageEvents('say "needle" exactly', 10), meta: { createdAt: 1 } })
const phrase = await ctx.sessionSearch.searchSessions({ query: 'alpha beta' })
const phrase = await ctx.sessionQuery.searchSessions({ query: 'alpha beta' })
expect(phrase.items.map(item => item.header.id)).toEqual([SessionId('b'), SessionId('d'), SessionId('a')])
expect(phrase.items.every(item => Array.from(item.bestMatch.snippet).length <= 5)).toBe(true)
await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionSearch.searchSessions({ query: 'needle OR absent' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchSessions({ query: 'needle OR absent' }))
.resolves.toMatchObject({ items: [{ header: { id: SessionId('operator') } }] })
await expect(ctx.sessionSearch.searchSessions({ query: 'say "needle"' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'say "needle"' }))
.resolves.toMatchObject({ items: [{ header: { id: SessionId('quote') } }] })
await expect(ctx.sessionSearch.searchSessions({ query: '*' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchSessions({ query: '*' })).resolves.toEqual({ items: [] })
})
it('ranks live and persisted matches on one source-comparable contract', async () => {
@@ -308,7 +308,7 @@ describe('SQLite session search', () => {
meta: { createdAt: persisted.createdAt },
})
const result = await ctx.sessionSearch.searchSessions({
const result = await ctx.sessionQuery.searchSessions({
query: 'needle',
sessionFilters: [{ kind: 'id', values: [SessionId('a-live'), persisted.id] }],
})
@@ -322,7 +322,7 @@ describe('SQLite session search', () => {
seed: messageEvents('long long long—café,\nnext value', 10),
})
const page = await ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'CAFE' })
const page = await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'CAFE' })
expect(page.items).toHaveLength(1)
expect(page.items[0]!.snippet).toContain('café')
expect(page.items[0]!.snippet).toContain('—')
@@ -341,14 +341,14 @@ describe('SQLite session search', () => {
})
ctx.sessions.create(SessionId('other'), { seed: messageEvents('needle other', 10) })
const eventPage = await ctx.sessionSearch.searchEvents({ sessionId: target.id, query: 'needle', limit: 1 })
const sessionPage = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1 })
const eventPage = await ctx.sessionQuery.searchEvents({ sessionId: target.id, query: 'needle', limit: 1 })
const sessionPage = await ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1 })
expect(eventPage.nextCursor).toEqual(expect.any(String))
expect(sessionPage.nextCursor).toEqual(expect.any(String))
if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors')
const unsafeOffsetCursor = replaceCursorOffset(eventPage.nextCursor, 1e100)
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: target.id,
query: 'needle',
limit: 1,
@@ -358,7 +358,7 @@ describe('SQLite session search', () => {
const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`)
let eventCursor: ReturnType<typeof SessionSearchCursor> | undefined = eventPage.nextCursor
while (eventCursor !== undefined) {
const next = await ctx.sessionSearch.searchEvents({
const next = await ctx.sessionQuery.searchEvents({
sessionId: target.id,
query: 'needle',
limit: 1,
@@ -373,7 +373,7 @@ describe('SQLite session search', () => {
const sessionIds = sessionPage.items.map(item => item.header.id)
let sessionCursor: ReturnType<typeof SessionSearchCursor> | undefined = sessionPage.nextCursor
while (sessionCursor !== undefined) {
const next = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor })
const next = await ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor })
sessionIds.push(...next.items.map(item => item.header.id))
sessionCursor = next.nextCursor
}
@@ -381,15 +381,15 @@ describe('SQLite session search', () => {
expect(new Set(sessionIds).size).toBe(sessionIds.length)
ctx.sessions.create(SessionId('unrelated'), { seed: messageEvents('needle unrelated', 20) })
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: target.id,
query: 'needle',
limit: 1,
cursor: eventPage.nextCursor,
})).resolves.toMatchObject({ items: [{ sessionId: target.id }] })
await expect(ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionPage.nextCursor }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1, cursor: sessionPage.nextCursor }))
.rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR'))
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: target.id,
query: 'different',
limit: 1,
@@ -397,7 +397,7 @@ describe('SQLite session search', () => {
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR'))
target.append('user/message', { content: [{ type: 'text', text: 'needle four' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: target.id,
query: 'needle',
limit: 1,
@@ -410,13 +410,13 @@ describe('SQLite session search', () => {
const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 })
ctx.sessions.create(SessionId('first'), { seed: messageEvents('needle first') })
ctx.sessions.create(SessionId('second'), { seed: messageEvents('needle second') })
const page = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1 })
const page = await ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1 })
if (page.nextCursor === undefined) throw new Error('expected cursor')
const persistence = await ctx.plugin(TestPersistence)
await persistence.dispose()
await expect(ctx.sessionSearch.searchSessions({
await expect(ctx.sessionQuery.searchSessions({
query: 'needle',
limit: 1,
cursor: page.nextCursor,
@@ -434,32 +434,32 @@ describe('SQLite session search', () => {
{ 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)
await expect(ctx.sessionQuery.searchEvents(request as never)).rejects.toBeInstanceOf(Error)
}
await expect(ctx.sessionSearch.searchSessions({
await expect(ctx.sessionQuery.searchSessions({
query: 'needle',
sessionFilters: [{ kind: 'availability', values: ['remote' as never] }],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchSessions({
await expect(ctx.sessionQuery.searchSessions({
query: 'needle',
sessionFilters: [{ kind: 'future' } as never],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchSessions({
await expect(ctx.sessionQuery.searchSessions({
query: 'needle',
eventFilters: [{ kind: 'future' } as never],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: session.id,
query: 'needle',
filters: [{ kind: 'future' } as never],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.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' }))
await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
for (const config of [
@@ -474,7 +474,7 @@ describe('SQLite session search', () => {
]) {
const direct = new Context()
await direct.plugin(SessionStore)
expect(() => new SessionSearchSqlite(direct, config as never))
expect(() => new SessionQuerySqlite(direct, config as never))
.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
}
})
@@ -492,12 +492,12 @@ describe('SQLite session search', () => {
const types = Array.from({ length: halfPortableLimit }, () => 'user/message' as const)
const surfaces = Array.from({ length: halfPortableLimit }, () => 'current' as const)
await expect(ctx.sessionSearch.searchSessions({
await expect(ctx.sessionQuery.searchSessions({
query: 'needle',
sessionFilters: [{ kind: 'id', values: ids }],
eventFilters: [{ kind: 'type', values: types }],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
await expect(ctx.sessionQuery.searchEvents({
sessionId: session.id,
query: 'needle',
filters: [
@@ -514,7 +514,7 @@ describe('SQLite session search', () => {
(_, index) => SessionId(`oversized-binding-${index}`),
)
await expect(ctx.sessionSearch.searchSessions({
await expect(ctx.sessionQuery.searchSessions({
query: 'needle',
sessionFilters: [{ kind: 'id', values: ids }],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
@@ -535,7 +535,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
TestPersistence.listStarted = undefined
markStarted()
}
const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' })
const blocking = ctx.sessionQuery.searchSessions({ query: 'needle' })
await started
const availability: SessionAvailability[] = ['persisted']
@@ -543,7 +543,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
query: 'needle',
sessionFilters: [{ kind: 'availability', values: availability }],
}
const queued = ctx.sessionSearch.searchSessions(request)
const queued = ctx.sessionQuery.searchSessions(request)
request.query = 'absent'
availability[0] = 'live'
release()
@@ -561,26 +561,26 @@ describe('SQLite reconciliation and source lifecycle', () => {
{ meta: durable, events: messageEvents('durable needle') },
])
const ctx = await liveContext()
await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] })
const persistenceFiber = await ctx.plugin(TestPersistence)
await expect(ctx.sessionSearch.searchSessions({ query: 'durable' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'durable' }))
.resolves.toMatchObject({ items: [{ header: durable, live: false, persisted: true }] })
const live = ctx.sessions.prepare(shared.id, { meta: { createdAt: 10, cwd: '/work' } })
live.append('user/message', { content: [{ type: 'text', text: 'live needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const detach = ctx.sessions.enter(live)
ctx.sessions.announce(live)
await expect(ctx.sessionSearch.searchSessions({ query: 'persisted' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionSearch.searchSessions({ query: 'live' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchSessions({ query: 'live' }))
.resolves.toMatchObject({ items: [{ header: shared, live: true, persisted: true }] })
detach()
await expect(ctx.sessionSearch.searchSessions({ query: 'persisted' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' }))
.resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] })
await persistenceFiber.dispose()
await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionSearch.searchEvents({ sessionId: durable.id, query: 'needle' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchEvents({ sessionId: durable.id, query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
})
@@ -592,7 +592,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
] }])
const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 2 })
const persistence = await ctx.plugin(TestPersistence)
const internals = ctx.sessionSearch as unknown as {
const internals = ctx.sessionQuery as unknown as {
_reconcile(signal: AbortSignal | undefined): Promise<{
identity: symbol
service?: SessionPersistence
@@ -605,7 +605,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
return binding
})
const page = await ctx.sessionSearch.searchEvents({
const page = await ctx.sessionQuery.searchEvents({
sessionId: durable.id,
query: 'needle',
limit: 1,
@@ -613,7 +613,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
expect(page.items).toMatchObject([{ sessionId: durable.id }])
expect(page.nextCursor).toEqual(expect.any(String))
boundary.mockRestore()
await expect(ctx.sessionSearch.searchEvents({ sessionId: durable.id, query: 'needle' }))
await expect(ctx.sessionQuery.searchEvents({ sessionId: durable.id, query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
})
@@ -631,7 +631,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
markStarted()
}
const search = ctx.sessionSearch.searchSessions({ query: 'needle' })
const search = ctx.sessionQuery.searchSessions({ query: 'needle' })
await started
await persistenceFiber.dispose()
TestPersistence.failure = new Error('stale backend rejection')
@@ -653,7 +653,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
markStarted()
}
const search = ctx.sessionSearch.searchSessions({ query: 'needle' })
const search = ctx.sessionQuery.searchSessions({ query: 'needle' })
await started
await prior.dispose()
TestPersistence.listGate = undefined
@@ -669,17 +669,17 @@ describe('SQLite reconciliation and source lifecycle', () => {
const revision = TestPersistence.revisions.get(durable.id)!
const ctx = await liveContext()
const prior = await ctx.plugin(TestPersistence)
await expect(ctx.sessionSearch.searchSessions({ query: 'old' }))
await expect(ctx.sessionQuery.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 page = await ctx.sessionSearch.searchSessions({ query: 'new needle' })
const page = await ctx.sessionQuery.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: [] })
await expect(ctx.sessionQuery.searchSessions({ query: 'old' })).resolves.toEqual({ items: [] })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
await replacement.dispose()
})
@@ -695,7 +695,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
if (lists === 2) await persistence.dispose()
}
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).resolves.toEqual({ items: [] })
expect(lists).toBe(2)
})
@@ -710,7 +710,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
TestPersistence.set({ meta: added, events: messageEvents('added needle') })
}
const page = await ctx.sessionSearch.searchSessions({ query: 'needle' })
const page = await ctx.sessionQuery.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)
@@ -727,7 +727,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
TestPersistence.set({ meta: durable, events: messageEvents(`durable needle ${lists}`) })
}
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
expect(lists).toBe(4)
})
@@ -737,7 +737,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const internals = ctx.sessionSearch as unknown as {
const internals = ctx.sessionQuery as unknown as {
_persistenceBinding: { identity: symbol; service?: SessionPersistence }
}
const originalList = ctx.sessions.list.bind(ctx.sessions)
@@ -753,7 +753,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
return originalList()
})
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }))
.resolves.toMatchObject({ items: [{ header: durable }] })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
list.mockRestore()
@@ -766,22 +766,22 @@ describe('SQLite reconciliation and source lifecycle', () => {
await ctx.plugin(TestPersistence)
TestPersistence.snapshotOverride = () => 'not-an-array' as never
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
await expect(ctx.sessionQuery.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' }))
await expect(ctx.sessionQuery.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' }))
await expect(ctx.sessionQuery.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)
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).rejects.toBe(typed)
})
it('rejects immutable header conflicts between live and persisted sources', async () => {
@@ -794,7 +794,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
meta: { createdAt: 10, delegationDepth: 2 },
})
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
})
@@ -811,10 +811,10 @@ describe('SQLite reconciliation and source lifecycle', () => {
const first = new Context()
await first.plugin(SessionStore)
const firstPersistence = await first.plugin(TestPersistence)
const firstSearch = await first.plugin(SessionSearchSqlite, { path })
await first.sessionSearch.searchSessions({ query: 'needle' })
const firstSearch = await first.plugin(SessionQuerySqlite, { path })
await first.sessionQuery.searchSessions({ query: 'needle' })
expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 })
await first.sessionSearch.searchSessions({ query: 'needle' })
await first.sessionQuery.searchSessions({ query: 'needle' })
expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 })
await firstSearch.dispose()
await firstPersistence.dispose()
@@ -831,8 +831,8 @@ describe('SQLite reconciliation and source lifecycle', () => {
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' })
const secondSearch = await second.plugin(SessionQuerySqlite, { path })
const result = await second.sessionQuery.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,
@@ -861,17 +861,17 @@ describe('SQLite reconciliation and source lifecycle', () => {
await first.plugin(SessionStore)
const persistence = await first.plugin(TestPersistence)
const live = first.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 10 } })
const search = await first.plugin(SessionSearchSqlite, { path })
await expect(first.sessionSearch.searchEvents({ sessionId: live.id, query: 'live' })).resolves.toMatchObject({ items: [{}] })
const search = await first.plugin(SessionQuerySqlite, { path })
await expect(first.sessionQuery.searchEvents({ sessionId: live.id, query: 'live' })).resolves.toMatchObject({ items: [{}] })
await search.dispose()
await persistence.dispose()
const second = new Context()
await second.plugin(SessionStore)
const persistenceAgain = await second.plugin(TestPersistence)
const searchAgain = await second.plugin(SessionSearchSqlite, { path })
await expect(second.sessionSearch.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] })
await expect(second.sessionSearch.searchSessions({ query: 'persisted' }))
const searchAgain = await second.plugin(SessionQuerySqlite, { path })
await expect(second.sessionQuery.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] })
await expect(second.sessionQuery.searchSessions({ query: 'persisted' }))
.resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] })
expect(TestPersistence.loads.get(shared.id)).toBe(1)
await searchAgain.dispose()
@@ -887,10 +887,10 @@ describe('SQLite reconciliation and source lifecycle', () => {
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
await expect(ctx.sessionSearch.searchSessions({ query: 'repaired' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'repaired' }))
.resolves.toMatchObject({ items: [{ header: durable }] })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
await ctx.sessionSearch.searchSessions({ query: 'repaired' })
await ctx.sessionQuery.searchSessions({ query: 'repaired' })
expect(TestPersistence.loads.get(durable.id)).toBe(2)
})
@@ -899,26 +899,26 @@ describe('SQLite reconciliation and source lifecycle', () => {
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
TestPersistence.failure = 'offline'
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
const signal = new AbortController().signal
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.failure = new Error('still offline')
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.failure = undefined
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toMatchObject({ items: [{}] })
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).resolves.toMatchObject({ items: [{}] })
const live = ctx.sessions.create(SessionId('live'), { seed: messageEvents('base') })
await ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'base' })
const db = (ctx.sessionSearch as unknown as { _db: DatabaseSync })._db
await ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'base' })
const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db
db.exec('PRAGMA query_only = ON')
live.append('user/message', { content: [{ type: 'text', text: 'retry needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
await expect(ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'needle' }))
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
db.exec('PRAGMA query_only = OFF')
await expect(ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'needle' }))
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
.resolves.toMatchObject({ items: [{ seq: 1 }] })
})
})
@@ -931,24 +931,24 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
await chmod(directory, 0o755)
const ctx = await liveContext({ path })
await ctx.sessionSearch.searchSessions({ query: 'needle' })
await ctx.sessionQuery.searchSessions({ query: 'needle' })
expect((await stat(directory)).mode & 0o777).toBe(0o755)
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600)
expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600)
await (ctx.sessionSearch as SessionSearchSqlite).close()
await (ctx.sessionQuery as SessionQuerySqlite).close()
})
it('creates a persistent rollback journal owner-only', async () => {
if (process.platform === 'win32') return
const path = await temporaryPath()
const ctx = await liveContext({ path, journalMode: 'persist' })
await ctx.sessionSearch.searchSessions({ query: 'needle' })
await ctx.sessionQuery.searchSessions({ query: 'needle' })
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600)
await (ctx.sessionSearch as SessionSearchSqlite).close()
await (ctx.sessionQuery as SessionQuerySqlite).close()
})
it('preserves the mode of an existing database file', async () => {
@@ -958,21 +958,21 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
await chmod(path, 0o644)
const ctx = await liveContext({ path, journalMode: 'delete' })
await ctx.sessionSearch.searchSessions({ query: 'needle' })
await ctx.sessionQuery.searchSessions({ query: 'needle' })
expect((await stat(path)).mode & 0o777).toBe(0o644)
await (ctx.sessionSearch as SessionSearchSqlite).close()
await (ctx.sessionQuery as SessionQuerySqlite).close()
})
it('surfaces filesystem failures while pre-creating the database', async () => {
const path = `${await temporaryPath()}\0`
const ctx = await liveContext({ path })
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).rejects.toMatchObject({
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).rejects.toMatchObject({
code: 'SESSION_QUERY_INDEX_FAILED',
cause: { code: 'ERR_INVALID_ARG_VALUE' },
})
await (ctx.sessionSearch as SessionSearchSqlite).close()
await (ctx.sessionQuery as SessionQuerySqlite).close()
})
it('resets a recognized incompatible derived schema but refuses a foreign database', async () => {
@@ -984,8 +984,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
stale.close()
const staleCtx = await liveContext({ path: stalePath })
staleCtx.sessions.create(SessionId('live'), { seed: messageEvents('needle') })
await staleCtx.sessionSearch.searchSessions({ query: 'needle' })
await (staleCtx.sessionSearch as SessionSearchSqlite).close()
await staleCtx.sessionQuery.searchSessions({ query: 'needle' })
await (staleCtx.sessionQuery as SessionQuerySqlite).close()
const rebuilt = new DatabaseSync(stalePath)
expect((rebuilt.prepare('PRAGMA user_version').get() as { user_version: number }).user_version)
.toBe(SESSION_QUERY_SQLITE_SCHEMA_VERSION)
@@ -999,22 +999,22 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
foreign.exec("INSERT INTO canonical VALUES ('safe')")
foreign.close()
const foreignCtx = await liveContext({ path: foreignPath, journalMode: 'delete' })
await expect(foreignCtx.sessionSearch.searchSessions({ query: 'needle' }))
await expect(foreignCtx.sessionQuery.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()
await (foreignCtx.sessionQuery as SessionQuerySqlite).close()
const otherAppPath = await temporaryPath('other-app.db')
const otherApp = new DatabaseSync(otherAppPath)
otherApp.exec('PRAGMA application_id = 123')
otherApp.close()
const otherAppCtx = await liveContext({ path: otherAppPath })
await expect(otherAppCtx.sessionSearch.searchSessions({ query: 'needle' }))
await expect(otherAppCtx.sessionQuery.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await (otherAppCtx.sessionSearch as SessionSearchSqlite).close()
await (otherAppCtx.sessionQuery as SessionQuerySqlite).close()
})
it('observes asynchronous open rejection even when no query is made', async () => {
@@ -1029,7 +1029,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const ctx = await liveContext({ path })
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(unhandled).toEqual([])
await (ctx.sessionSearch as SessionSearchSqlite).close()
await (ctx.sessionQuery as SessionQuerySqlite).close()
} finally {
process.off('unhandledRejection', onUnhandled)
}
@@ -1041,13 +1041,13 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
await ctx.plugin(TestPersistence)
const boundaryController = new AbortController()
const boundary = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: boundaryController.signal })
const boundary = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: boundaryController.signal })
queueMicrotask(() => { boundaryController.abort() })
await expect(boundary).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
const readyController = new AbortController()
readyController.abort()
const internals = ctx.sessionSearch as unknown as {
const internals = ctx.sessionQuery as unknown as {
_ensureReady(signal: AbortSignal): Promise<void>
}
await expect(internals._ensureReady(readyController.signal))
@@ -1061,11 +1061,11 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
TestPersistence.listStarted = undefined
markBlockingStarted()
}
const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' })
const blocking = ctx.sessionQuery.searchSessions({ query: 'needle' })
await blockingStarted
const queuedController = new AbortController()
const queued = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: queuedController.signal })
const queued = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: queuedController.signal })
queuedController.abort()
await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
@@ -1085,15 +1085,15 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
markActiveStarted()
}
const activeController = new AbortController()
const active = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: activeController.signal })
const active = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: activeController.signal })
await activeStarted
activeController.abort()
await expect(active).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
releaseActive()
const db = (ctx.sessionSearch as unknown as { _db: DatabaseSync })._db
const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db
expect(db.prepare('SELECT COUNT(*) AS count FROM persisted_sessions').get()).toEqual({ count: 0 })
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }))
.resolves.toMatchObject({ items: [{ header: { id: SessionId('uncommitted') } }] })
})
@@ -1109,7 +1109,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
}
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const search = ctx.sessionSearch as SessionSearchSqlite
const search = ctx.sessionQuery as SessionQuerySqlite
const accepted = search.searchSessions({ query: 'needle' })
await started
const queued = search.searchSessions({ query: 'needle' })
@@ -1130,9 +1130,9 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
TestPersistence.reset()
const ctx = new Context()
await ctx.plugin(SessionStore)
const search = await ctx.plugin(SessionSearchSqlite, { path: ':memory:' })
const search = await ctx.plugin(SessionQuerySqlite, { path: ':memory:' })
const persistence = await ctx.plugin(TestPersistence)
const optional = (ctx.sessionSearch as unknown as {
const optional = (ctx.sessionQuery as unknown as {
_optionalPersistenceFiber: Fiber
})._optionalPersistenceFiber
let release!: () => void
@@ -1154,16 +1154,16 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const ctx = new Context()
await ctx.plugin(SessionStore)
const persistence = await ctx.plugin(SessionPersistenceSqlite, { path: persistencePath })
const search = await ctx.plugin(SessionSearchSqlite, { path: searchPath })
const search = await ctx.plugin(SessionQuerySqlite, { path: searchPath })
const meta = header('real', 10, { cwd: '/work' })
await ctx.sessionPersistence.create(meta)
await ctx.sessionPersistence.append(meta.id, messageEvents('real SQLite needle'))
await expect(ctx.sessionSearch.searchSessions({ query: 'SQLite needle' }))
await expect(ctx.sessionQuery.searchSessions({ query: 'SQLite needle' }))
.resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] })
await expect(ctx.sessionSearch.searchEvents({ sessionId: meta.id, query: 'SQLite needle' }))
await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'SQLite needle' }))
.resolves.toMatchObject({ items: [{ sessionId: meta.id, seq: 0 }] })
await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' }))
await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
await search.dispose()
await expect(ctx.sessionPersistence.load(meta.id)).resolves.toMatchObject({ meta, events: [{ seq: 0 }] })
@@ -1182,8 +1182,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
await first.sessionPersistence.create(shared)
await first.sessionPersistence.append(shared.id, messageEvents('alpha source'))
const loadA = vi.spyOn(first.sessionPersistence, 'load')
const searchA = await first.plugin(SessionSearchSqlite, { path: searchPath })
await expect(first.sessionSearch.searchSessions({ query: 'alpha' }))
const searchA = await first.plugin(SessionQuerySqlite, { path: searchPath })
await expect(first.sessionQuery.searchSessions({ query: 'alpha' }))
.resolves.toMatchObject({ items: [{ header: shared }] })
expect(loadA).toHaveBeenCalledTimes(1)
await searchA.dispose()
@@ -1193,8 +1193,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
await reopened.plugin(SessionStore)
const persistenceAAgain = await reopened.plugin(SessionPersistenceSqlite, { path: persistencePathA })
const reopenedLoad = vi.spyOn(reopened.sessionPersistence, 'load')
const searchAAgain = await reopened.plugin(SessionSearchSqlite, { path: searchPath })
await expect(reopened.sessionSearch.searchSessions({ query: 'alpha' }))
const searchAAgain = await reopened.plugin(SessionQuerySqlite, { path: searchPath })
await expect(reopened.sessionQuery.searchSessions({ query: 'alpha' }))
.resolves.toMatchObject({ items: [{ header: shared }] })
expect(reopenedLoad).not.toHaveBeenCalled()
await searchAAgain.dispose()
@@ -1206,10 +1206,10 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
await second.sessionPersistence.create(shared)
await second.sessionPersistence.append(shared.id, messageEvents('bravo source'))
const loadB = vi.spyOn(second.sessionPersistence, 'load')
const searchB = await second.plugin(SessionSearchSqlite, { path: searchPath })
await expect(second.sessionSearch.searchSessions({ query: 'bravo' }))
const searchB = await second.plugin(SessionQuerySqlite, { path: searchPath })
await expect(second.sessionQuery.searchSessions({ query: 'bravo' }))
.resolves.toMatchObject({ items: [{ header: shared }] })
await expect(second.sessionSearch.searchSessions({ query: 'alpha' })).resolves.toEqual({ items: [] })
await expect(second.sessionQuery.searchSessions({ query: 'alpha' })).resolves.toEqual({ items: [] })
expect(loadB).toHaveBeenCalledTimes(1)
await searchB.dispose()
await persistenceB.dispose()