refactor(session-query): unify query service
This commit is contained in:
@@ -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