refactor(session-query): unify query service
This commit is contained in:
@@ -4,7 +4,7 @@ Trusted exact reads, relationship traces, provider-independent semantic filterin
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus title, event, lineage, relationship, and semantic-filter reads plus the abstract search seam | `ctx.sessionQuery`, `ctx.sessionSearch` |
|
||||
| [`session-query-sqlite/`](session-query-sqlite/README.md) | SQLite FTS5 search with persistent bases and live overlays | `ctx.sessionSearch` |
|
||||
| [`session-query/`](session-query/README.md) | Combined service contract with concrete logical-corpus reads, traces, and semantic filters plus abstract full-text methods | `ctx.sessionQuery` |
|
||||
| [`session-query-sqlite/`](session-query-sqlite/README.md) | Concrete service backend with SQLite FTS5 persistent bases and live overlays | `ctx.sessionQuery` |
|
||||
|
||||
The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. Search uses one abstract service and one concrete owner, not a provider registry or coordinator.
|
||||
The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, and one concrete backend owns the full-text lifecycle without a provider registry or coordinator.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-session-query
|
||||
|
||||
Exact session-history retrieval, relationship tracing, and provider-independent filtering through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry.
|
||||
`SessionQueryService` is the combined abstract `ctx.sessionQuery` contract. It implements exact session-history retrieval, relationship tracing, and provider-independent filtering over live `ctx.sessions` plus optional dynamically mounted `ctx.sessionPersistence`; concrete backends implement its two full-text methods. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
|
||||
## Reads
|
||||
|
||||
@@ -22,11 +22,11 @@ Persistence is optional and may mount or unmount dynamically. Cross-corpus listi
|
||||
|
||||
The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document.
|
||||
|
||||
## Full-text seam
|
||||
## Full-text methods
|
||||
|
||||
`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
|
||||
`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
|
||||
|
||||
The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md).
|
||||
The package has no provider coordinator, fallback implementation, or standalone concrete plugin. A concrete service backend inherits the implemented reads, filters, and traces while owning full-text observation, reconciliation, ranking, cursor generations, and query execution; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md).
|
||||
|
||||
`SessionQueryError.code` is a closed union covering request validation, missing targets, malformed surfaces, source conflicts, persistence/index failures, cancellation, and invalid or stale cursors; the exact literals are defined in [`src/config.ts`](src/config.ts).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-query",
|
||||
"description": "Live-preferred exact session-history retrieval and tracing service (ctx.sessionQuery)",
|
||||
"description": "Combined session query service contract with concrete reads, traces, and filters",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -40,9 +40,6 @@
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Public configuration and typed failures for session-query and search. */
|
||||
/** Public configuration and typed failures for the combined session-query service. */
|
||||
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Default maximum `before`/`after` raw-event window. */
|
||||
export const SESSION_QUERY_READ_WINDOW_MAX = 50
|
||||
|
||||
/** Configuration for exact session-query reads and traces. */
|
||||
/** Backend-independent configuration inherited by every session-query implementation. */
|
||||
export interface Config {
|
||||
/** Maximum accepted raw read context on either side. Defaults to 50. */
|
||||
readWindowMax?: number
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Exact session-history reads and traces over live and optionally persisted logs.
|
||||
* Combined session-history reads, traces, filters, and full-text search seam.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-query
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
|
||||
@@ -61,19 +60,32 @@ export { assertSessionHeadersCompatible } from './sources.ts'
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionQuery: SessionQueryService
|
||||
sessionSearch: SessionSearchService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract full-text search service implemented by one concrete backend.
|
||||
* Unified live-preferred session query service.
|
||||
*
|
||||
* The implementation owns source observation, reconciliation, cursor
|
||||
* generations, ranking, and query execution as one lifecycle.
|
||||
* Exact reads, filters, and traces are backend-independent concrete behavior.
|
||||
* A backend implements full-text observation, reconciliation, ranking, cursor
|
||||
* generations, and query execution on the same `ctx.sessionQuery` service.
|
||||
*/
|
||||
export abstract class SessionSearchService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessionSearch')
|
||||
export abstract class SessionQueryService extends Service {
|
||||
static inject = ['sessions']
|
||||
|
||||
private readonly _readWindowMax: number
|
||||
private readonly _corpus: SessionCorpus
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'sessionQuery')
|
||||
this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX
|
||||
if (!Number.isInteger(this._readWindowMax) || this._readWindowMax < 0) {
|
||||
throw new SessionQueryError(
|
||||
'session-query: readWindowMax must be a non-negative integer',
|
||||
'SESSION_QUERY_INVALID_CONFIG',
|
||||
)
|
||||
}
|
||||
this._corpus = new SessionCorpus(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,29 +109,6 @@ export abstract class SessionSearchService extends Service {
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>>
|
||||
}
|
||||
|
||||
/** Live-preferred logical-corpus read, filtering, and relationship-tracing service. */
|
||||
export class SessionQueryService extends Service {
|
||||
static inject = ['sessions']
|
||||
static Config: z<Config> = z.object({
|
||||
readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX),
|
||||
})
|
||||
|
||||
private readonly _readWindowMax: number
|
||||
private readonly _corpus: SessionCorpus
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'sessionQuery')
|
||||
this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX
|
||||
if (!Number.isInteger(this._readWindowMax) || this._readWindowMax < 0) {
|
||||
throw new SessionQueryError(
|
||||
'session-query: readWindowMax must be a non-negative integer',
|
||||
'SESSION_QUERY_INVALID_CONFIG',
|
||||
)
|
||||
}
|
||||
this._corpus = new SessionCorpus(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* List the complete logical corpus using live-preferred records.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionQueryService, {
|
||||
import {
|
||||
buildSessionEventRecords,
|
||||
buildSessionEventSearchDocuments,
|
||||
compileSessionTextFilter,
|
||||
@@ -12,15 +12,9 @@ import SessionQueryService, {
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
SessionSearchService,
|
||||
type SessionEventSearchHit,
|
||||
type SessionEventSearchRequest,
|
||||
type SessionQueryErrorCode,
|
||||
type SessionSearchExecContext,
|
||||
type SessionSearchHit,
|
||||
type SessionSearchPage,
|
||||
type SessionSearchRequest,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import { TestSessionQueryService } from './test-service.ts'
|
||||
|
||||
const id = SessionId('session')
|
||||
|
||||
@@ -203,10 +197,10 @@ describe('session-query document and filter helpers', () => {
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
})
|
||||
|
||||
it('exposes the scan path on the concrete exact-read service', async () => {
|
||||
it('exposes the scan path on the combined query service', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
const session = ctx.sessions.create(id)
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
@@ -215,21 +209,12 @@ describe('session-query document and filter helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
class TestSearchService extends SessionSearchService {
|
||||
searchSessions(_request: SessionSearchRequest, _exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
|
||||
searchEvents(_request: SessionEventSearchRequest, _exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
}
|
||||
|
||||
it('registers the abstract search seam under its independent ctx key', async () => {
|
||||
it('registers exact and abstract search behavior under one ctx key', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(TestSearchService)
|
||||
await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionSearch.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(TestSessionQueryService)
|
||||
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionSearch).toBeUndefined()
|
||||
expect(ctx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import SessionQueryService, {
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
|
||||
import { TestSessionQueryService } from './test-service.ts'
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
@@ -75,10 +76,10 @@ class TestPersistence extends SessionPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
async function liveContext(config: ConstructorParameters<typeof SessionQueryService>[1] = {}): Promise<Context> {
|
||||
async function liveContext(config: ConstructorParameters<typeof TestSessionQueryService>[1] = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService, config)
|
||||
await ctx.plugin(TestSessionQueryService, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -413,18 +414,18 @@ describe('session-query exact reads', () => {
|
||||
|
||||
const direct = new Context()
|
||||
await direct.plugin(SessionStore)
|
||||
expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService)
|
||||
expect(new TestSessionQueryService(direct)).toBeInstanceOf(SessionQueryService)
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SessionStore)
|
||||
expect(() => new SessionQueryService(invalid, { readWindowMax: -1 }))
|
||||
expect(() => new TestSessionQueryService(invalid, { readWindowMax: -1 }))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
|
||||
})
|
||||
|
||||
it('leaves the optional persistence dependency optional', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionQueryService)
|
||||
expect(ctx.sessionQuery).toBeInstanceOf(SessionQueryService)
|
||||
const fiber = await ctx.plugin(TestSessionQueryService)
|
||||
expect(ctx.sessionQuery).toBeInstanceOf(TestSessionQueryService)
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
@@ -433,7 +434,7 @@ describe('session-query exact reads', () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const query = await ctx.plugin(SessionQueryService)
|
||||
const query = await ctx.plugin(TestSessionQueryService)
|
||||
const persistence = await ctx.plugin(TestPersistence)
|
||||
const optional = (ctx.sessionQuery as unknown as {
|
||||
_corpus: { _optionalPersistenceFiber: Fiber }
|
||||
|
||||
26
packages/session-query/session-query/tests/test-service.ts
Normal file
26
packages/session-query/session-query/tests/test-service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import type {
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchRequest,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
SessionSearchPage,
|
||||
SessionSearchRequest,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
/** Test-only concrete query service for backend-independent behavior. */
|
||||
export class TestSessionQueryService extends SessionQueryService {
|
||||
override searchSessions(
|
||||
_request: SessionSearchRequest,
|
||||
_exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
_request: SessionEventSearchRequest,
|
||||
_exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { Context } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
|
||||
import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
|
||||
import { TestSessionQueryService } from './test-service.ts'
|
||||
|
||||
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
|
||||
|
||||
@@ -84,7 +85,7 @@ class TracePersistence extends SessionPersistence {
|
||||
async function queryContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user