fix(session-query): harden SQLite search reconciliation

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

View File

@@ -7,6 +7,7 @@ This is trusted context-wide infrastructure. It performs no caller authorization
## Reads
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
@@ -21,7 +22,7 @@ The text clause is deliberately independent of FTS providers: caller text is esc
## Full-text seam
`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 opaque cursor pages, 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.
`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.
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).

View File

@@ -22,6 +22,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
@@ -36,6 +37,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",

View File

@@ -1,6 +1,6 @@
/** Live/persisted logical-corpus resolution for session-query. */
import type { Context } from 'cordis'
import type { Context, Fiber } from 'cordis'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import type { SessionRecord } from './types.ts'
@@ -18,18 +18,19 @@ export interface LogicalSession {
/** Resolves a live-preferred corpus against the persistence service mounted now. */
export class SessionCorpus {
private _persistence: SessionPersistence | undefined
private readonly _optionalPersistenceFiber: Fiber
constructor(private readonly _ctx: Context) {
this._optionalPersistenceFiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
const service = childCtx.sessionPersistence
this._persistence = service
childCtx.effect(() => () => {
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
if (this._persistence === service) this._persistence = undefined
}, 'sessionQuery.persistenceBinding')
})
_ctx.effect(() => {
const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
const service = childCtx.sessionPersistence
this._persistence = service
childCtx.effect(() => () => {
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
if (this._persistence === service) this._persistence = undefined
}, 'sessionQuery.persistenceBinding')
})
return () => void fiber.dispose()
return () => this._optionalPersistenceFiber.dispose()
}, 'sessionQuery.optionalPersistence')
}

View File

@@ -0,0 +1,15 @@
/** Opaque cursor identity for session-search pagination. */
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Provider-owned opaque continuation token returned by session search. */
export type SessionSearchCursor = Branded<'SessionSearchCursor'>
/**
* Brand an encoded provider cursor for the public search contract.
* @param value - opaque encoded cursor value.
* @returns the same runtime string with session-search cursor identity.
*/
export function SessionSearchCursor(value: string): SessionSearchCursor {
return value as SessionSearchCursor
}

View File

@@ -1,6 +1,12 @@
/** Pure provider-independent predicates for logical sessions and event text. */
import type { SessionRecord, SessionEventSearchDocument, SessionEventResultFilter, SessionResultFilter, SessionResultRange } from './types.ts'
import type {
SessionEventResultFilter,
SessionEventSearchDocument,
SessionRecord,
SessionResultFilter,
SessionResultRange,
} from './types.ts'
import { SessionQueryError } from './config.ts'
/**
@@ -31,6 +37,66 @@ export function filterSessionEventDocuments<T extends SessionEventSearchDocument
return documents.filter(document => predicates.every(predicate => predicate(document)))
}
/**
* Copy and validate logical-session filters before an asynchronous boundary.
* @param filters - caller-owned clauses to materialize.
* @returns detached validated clauses.
*/
export function materializeSessionResultFilters(
filters: readonly SessionResultFilter[],
): SessionResultFilter[] {
assertArray(filters)
return filters.map((filter) => {
switch (filter.kind) {
case 'id':
return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) }
case 'cwd':
return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) }
case 'created-at':
return copyRange(filter.kind, filter)
case 'parent':
return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) }
case 'availability': {
const values = copyStrings(filter.kind, filter.values)
assertAllowedValues(filter.kind, values, ['live', 'persisted'])
return { kind: filter.kind, values }
}
default:
return unknownFilter(filter)
}
})
}
/**
* Copy and validate event filters before an asynchronous boundary.
* @param filters - caller-owned clauses to materialize.
* @returns detached validated clauses.
*/
export function materializeSessionEventResultFilters(
filters: readonly SessionEventResultFilter[],
): SessionEventResultFilter[] {
assertArray(filters)
return filters.map((filter) => {
switch (filter.kind) {
case 'seq':
case 'time':
return copyRange(filter.kind, filter)
case 'type':
return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) }
case 'surface': {
const values = copyStrings(filter.kind, filter.values)
assertAllowedValues(filter.kind, values, ['current', 'shadowed', 'log-only'])
return { kind: filter.kind, values }
}
case 'text':
if (typeof filter.text !== 'string') throw invalidFilter('text filter text must be a string')
return { kind: filter.kind, text: filter.text }
default:
return unknownFilter(filter)
}
})
}
/**
* Compile a literal case-insensitive, whitespace-flexible semantic-text match.
* @param text - caller-provided literal text.
@@ -66,6 +132,8 @@ function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord)
case 'availability':
assertAllowedValues(filter.kind, filter.values, ['live', 'persisted'])
return record => filter.values.some(value => value === 'live' ? record.live : record.persisted)
default:
return unknownFilter(filter)
}
}
@@ -88,9 +156,47 @@ function eventPredicate(filter: SessionEventResultFilter): (document: SessionEve
const pattern = compileSessionTextFilter(filter.text)
return document => pattern.test(document.text)
}
default:
return unknownFilter(filter)
}
}
function copyStrings<T extends string>(name: string, values: readonly T[]): T[] {
if (!isRuntimeArray(values) || values.some(value => typeof value !== 'string')) {
throw invalidFilter(`${name} filter values must be an array of strings`)
}
return [...values]
}
function assertArray(value: unknown): void {
if (!Array.isArray(value)) throw invalidFilter('filters must be an array')
}
function copyNullableStrings<T extends string>(name: string, values: readonly (T | null)[]): Array<T | null> {
if (!isRuntimeArray(values) || values.some(value => value !== null && typeof value !== 'string')) {
throw invalidFilter(`${name} filter values must be an array of strings or null`)
}
return [...values]
}
function copyRange<K extends 'created-at' | 'seq' | 'time'>(
kind: K,
range: SessionResultRange,
): { kind: K } & SessionResultRange {
const copy = {
kind,
...range.from === undefined ? {} : { from: range.from },
...range.to === undefined ? {} : { to: range.to },
}
validateRange(kind, copy)
return copy
}
function unknownFilter(filter: never): never {
const kind = (filter as { kind?: unknown }).kind
throw invalidFilter(`unknown filter kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`)
}
function assertAllowedValues(
name: string,
values: readonly string[],
@@ -125,8 +231,13 @@ function matchesRange(value: number, range: SessionResultRange): boolean {
}
function invalidRange(name: string, detail: string): SessionQueryError {
return new SessionQueryError(
`session ${name} filter ${detail}`,
'SESSION_QUERY_INVALID_FILTER',
)
return invalidFilter(`${name} filter ${detail}`)
}
function invalidFilter(detail: string): SessionQueryError {
return new SessionQueryError(`session ${detail}`, 'SESSION_QUERY_INVALID_FILTER')
}
function isRuntimeArray(value: unknown): boolean {
return Array.isArray(value)
}

View File

@@ -16,6 +16,7 @@ import type {
SessionEventSearchRequest,
SessionEventWindow,
SessionRecord,
SessionResultFilter,
SessionSearchExecContext,
SessionSearchHit,
SessionSearchPage,
@@ -28,14 +29,26 @@ import {
} from './config.ts'
import { SessionCorpus } from './corpus.ts'
import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
import { filterSessionEventDocuments } from './filters.ts'
import {
filterSessionEventDocuments,
filterSessionResults,
materializeSessionEventResultFilters,
materializeSessionResultFilters,
} from './filters.ts'
export type * from './types.ts'
export { SessionSearchCursor } from './cursor.ts'
export type { Config, SessionQueryErrorCode } from './config.ts'
export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts'
export { extractSessionEventText } from './extraction.ts'
export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
export { compileSessionTextFilter, filterSessionEventDocuments, filterSessionResults } from './filters.ts'
export {
compileSessionTextFilter,
filterSessionEventDocuments,
filterSessionResults,
materializeSessionEventResultFilters,
materializeSessionResultFilters,
} from './filters.ts'
export { assertSessionHeadersCompatible } from './sources.ts'
declare module 'cordis' {
@@ -109,6 +122,16 @@ export class SessionQueryService extends Service {
return this._corpus.listSessions()
}
/**
* Filter the complete logical corpus with provider-independent predicates.
* @param filters - ANDed session metadata and availability clauses.
* @returns matching cloned records in deterministic newest-first order.
*/
async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
const ownedFilters = materializeSessionResultFilters(filters)
return this._filterSessions(ownedFilters)
}
/**
* List lightweight raw-log event records for one logical session.
* @param sessionId - live-preferred session id to read.
@@ -128,6 +151,18 @@ export class SessionQueryService extends Service {
async filterEvents(
sessionId: SessionId,
filters: readonly SessionEventResultFilter[],
): Promise<SessionEventSearchDocument[]> {
const ownedFilters = materializeSessionEventResultFilters(filters)
return this._filterEvents(sessionId, ownedFilters)
}
private async _filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
return filterSessionResults(await this._corpus.listSessions(), filters)
}
private async _filterEvents(
sessionId: SessionId,
filters: readonly SessionEventResultFilter[],
): Promise<SessionEventSearchDocument[]> {
const loaded = await this._corpus.load(sessionId)
const documents = buildSessionEventSearchDocuments(sessionId, loaded.events)
@@ -142,16 +177,27 @@ export class SessionQueryService extends Service {
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow> {
const before = this._readWindow('before', request.before)
const after = this._readWindow('after', request.after)
const loaded = await this._corpus.load(request.sessionId)
const target = loaded.events[request.seq]
if (target === undefined || target.seq !== request.seq) {
const sessionId = request.sessionId
const seq = request.seq
return this._readEvent(sessionId, seq, before, after)
}
private async _readEvent(
sessionId: SessionId,
seq: number,
before: number,
after: number,
): Promise<SessionEventWindow> {
const loaded = await this._corpus.load(sessionId)
const target = loaded.events[seq]
if (target === undefined || target.seq !== seq) {
throw new SessionQueryError(
`session "${request.sessionId}" has no event at seq ${request.seq}`,
`session "${sessionId}" has no event at seq ${seq}`,
'SESSION_QUERY_EVENT_NOT_FOUND',
)
}
const startSeq = Math.max(0, request.seq - before)
const endSeq = Math.min(loaded.events.length - 1, request.seq + after)
const startSeq = Math.max(0, seq - before)
const endSeq = Math.min(loaded.events.length - 1, seq + after)
return {
session: loaded.header,
target,

View File

@@ -5,6 +5,9 @@
*/
import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionSearchCursor } from './cursor.ts'
export type { SessionSearchCursor } from './cursor.ts'
/** Whether an event is current model context, replaced context, or raw-log-only. */
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
@@ -106,7 +109,7 @@ export interface SessionSearchPage<T> {
/** Results for this page in contract-defined order. */
items: readonly T[]
/** Opaque continuation cursor, absent on the final page. */
nextCursor?: string
nextCursor?: SessionSearchCursor
}
/** Controls shared by cross-session and within-session search calls. */
@@ -126,7 +129,7 @@ export interface SessionSearchRequest {
/** Maximum sessions in this page. */
limit?: number
/** Opaque cursor returned for the identical normalized request. */
cursor?: string
cursor?: SessionSearchCursor
}
/** Within-session full-text search request. */
@@ -140,7 +143,7 @@ export interface SessionEventSearchRequest {
/** Maximum events in this page. */
limit?: number
/** Opaque cursor returned for the identical normalized request. */
cursor?: string
cursor?: SessionSearchCursor
}
/** One event full-text search hit with a bounded plain-text excerpt. */

View File

@@ -10,6 +10,8 @@ import SessionQueryService, {
extractSessionEventText,
filterSessionEventDocuments,
filterSessionResults,
materializeSessionEventResultFilters,
materializeSessionResultFilters,
SessionSearchService,
type SessionEventSearchHit,
type SessionEventSearchRequest,
@@ -177,6 +179,31 @@ describe('session-query document and filter helpers', () => {
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('owns filters and rejects malformed runtime filter shapes deterministically', () => {
expect(materializeSessionResultFilters([{ kind: 'created-at', to: 2 }]))
.toEqual([{ kind: 'created-at', to: 2 }])
expect(() => materializeSessionResultFilters('not-an-array' as never))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => materializeSessionResultFilters([{ kind: 'id', values: 'bad' } as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => materializeSessionResultFilters([{ kind: 'id', values: [1] } as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => materializeSessionResultFilters([{ kind: 'cwd', values: 'bad' } as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => materializeSessionResultFilters([{ kind: 'parent', values: [1] } as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => materializeSessionResultFilters([{} as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => materializeSessionEventResultFilters([{ kind: 'text', text: 1 } as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => materializeSessionEventResultFilters([{ kind: 'future' } as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterSessionResults([], [{ kind: 'future' } as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterSessionEventDocuments([], [{ kind: 'future' } as never]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
})
it('exposes the scan path on the concrete exact-read service', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context, type Fiber } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
import SessionQueryService, {
type SessionEventSurface,
type SessionQueryErrorCode,
} from '@deepseek-ai/dsh-session-query'
@@ -59,6 +60,14 @@ class TestPersistence extends SessionPersistence {
TestPersistence.afterList?.()
return Promise.resolve(headers)
}
async listSnapshots() {
return [...TestPersistence.entries.values()].map(entry => ({
header: structuredClone(entry.meta),
revision: SessionPersistenceRevision(`events:${entry.events.length}`),
}))
}
}
async function liveContext(config: ConstructorParameters<typeof SessionQueryService>[1] = {}): Promise<Context> {
@@ -94,6 +103,38 @@ describe('session-query exact reads', () => {
expect(older.header.createdAt).toBe(1)
})
it('filters sessions symmetrically and owns mutable filter values immediately', async () => {
const durable = header('durable-filter', 1)
TestPersistence.reset([{ meta: durable, events: eventLog('durable') }])
const ctx = await liveContext()
const live = ctx.sessions.create(SessionId('live-filter'), { meta: { createdAt: 2 } })
live.append(
'user/message',
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
const persistence = await ctx.plugin(TestPersistence)
const ids = [durable.id]
const filtered = ctx.sessionQuery.filterSessions([{ kind: 'id', values: ids }])
ids[0] = live.id
await expect(filtered).resolves.toEqual([{
header: durable,
live: false,
persisted: true,
}])
const surfaces: SessionEventSurface[] = ['current']
const events = ctx.sessionQuery.filterEvents(live.id, [{ kind: 'surface', values: surfaces }])
surfaces[0] = 'shadowed'
await expect(events).resolves.toMatchObject([{ sessionId: live.id, surface: 'current', text: 'live' }])
await expect(ctx.sessionQuery.filterSessions([{ kind: 'future' } as never]))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionQuery.filterEvents(live.id, [{ kind: 'future' } as never]))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await persistence.dispose()
})
it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('surface'))
@@ -267,4 +308,26 @@ describe('session-query exact reads', () => {
await fiber.dispose()
expect(ctx.sessionQuery).toBeUndefined()
})
it('awaits optional-persistence child-fiber quiescence on disposal', async () => {
TestPersistence.reset()
const ctx = new Context()
await ctx.plugin(SessionStore)
const query = await ctx.plugin(SessionQueryService)
const persistence = await ctx.plugin(TestPersistence)
const optional = (ctx.sessionQuery as unknown as {
_corpus: { _optionalPersistenceFiber: Fiber }
})._corpus._optionalPersistenceFiber
let release!: () => void
const cleanup = new Promise<void>((resolve) => { release = resolve })
optional.ctx.effect(() => () => cleanup)
let settled = false
const disposing = query.dispose().then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
release()
await disposing
await persistence.dispose()
})
})

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},