refactor(session-query): narrow phase one to exact reads

This commit is contained in:
Hypatia May
2026-07-11 12:20:35 +08:00
parent 8fd68731ba
commit ad32c57e72
35 changed files with 396 additions and 3036 deletions

View File

@@ -23,7 +23,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, filters, tracing, and full-text provider seam | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |

View File

@@ -137,18 +137,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'sessionQuery',
summary: 'Session-history retrieval and provider coordination service.',
summary: 'Live-preferred logical-corpus and exact-event read service.',
methods: [
'listSessions(): Promise<SessionRecord[]>',
'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
'async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>',
'async traceSession(sessionId: SessionId): Promise<SessionLineageTrace>',
'async traceEvent(sessionId: SessionId, seq: number): Promise<SessionEventTrace>',
'registerSearchProvider(provider: SessionSearchProvider): () => Promise<void>',
'registerEventTextExtractor<K extends SessionEventType>( type: K, extractor: SessionEventTextExtractor<K>, ): () => void',
'registerContentTextExtractor<K extends ContentBlockType>( type: K, extractor: SessionContentTextExtractor<K>, ): () => void',
'searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise<SessionSearchPage<SessionSearchHit>>',
'searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise<SessionSearchPage<SessionEventSearchHit>>',
],
},
{
@@ -337,18 +330,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
signature: '\'session/flush\'(session: Session): Promise<void> | void',
summary: 'Awaited durability checkpoint.',
},
{
name: 'session/persisted',
mode: 'parallel',
signature: '\'session/persisted\'(header: SessionHeader, change: SessionPersistedChange): Promise<void> | void',
summary: 'A persistence backend committed a canonical session-log change.',
},
{
name: 'session/removed',
mode: 'parallel',
signature: '\'session/removed\'(header: SessionHeader): Promise<void> | void',
summary: 'A session left the live store.',
},
{
name: 'subagent/end',
mode: 'emit',
@@ -705,10 +686,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SendOptions',
declaration: 'export interface SendOptions {\n source?: MessageSource;\n}',
},
{
name: 'SessionContentTextExtractor',
declaration: 'export interface SessionContentTextExtractor<K extends ContentBlockType = ContentBlockType> {\n version: string;\n extract(block: ContentBlockMap[K]): readonly string[];\n}',
},
{
name: 'SessionEvent',
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
@@ -725,41 +702,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionEventRecord',
declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}',
},
{
name: 'SessionEventResultFilter',
declaration: 'export type SessionEventResultFilter = {\n kind: \'seq\';\n range: SessionQueryRange;\n} | {\n kind: \'time\';\n range: SessionQueryRange;\n} | {\n kind: \'type\';\n values: readonly SessionEventType[];\n} | {\n kind: \'surface\';\n values: readonly SessionEventSurface[];\n};',
},
{
name: 'SessionEventSearchHit',
declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}',
},
{
name: 'SessionEventSearchRequest',
declaration: 'export interface SessionEventSearchRequest extends SessionSearchPageRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventResultFilter[];\n}',
},
{
name: 'SessionEventSearchSpec',
declaration: 'export interface SessionEventSearchSpec extends SessionEventSearchRequest {\n limit: number;\n}',
},
{
name: 'SessionEventSurface',
declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';',
},
{
name: 'SessionEventTextExtractor',
declaration: 'export interface SessionEventTextExtractor<K extends SessionEventType = SessionEventType> {\n version: string;\n extract(event: SessionEvent<K>): readonly string[];\n}',
},
{
name: 'SessionEventTrace',
declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n shadowedBy?: number;\n replacementChain: number[];\n shadows: number[];\n references: number[];\n referencedBy: number[];\n}',
},
{
name: 'SessionEventType',
declaration: 'export type SessionEventType = keyof SessionEventMap;',
},
{
name: 'SessionEventWindow',
declaration: 'export interface SessionEventWindow {\n session: SessionRecord;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}',
declaration: 'export interface SessionEventWindow {\n session: SessionHeader;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}',
},
{
name: 'SessionForkSource',
@@ -773,70 +726,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionId',
declaration: 'export type SessionId = Branded<\'SessionId\'>;',
},
{
name: 'SessionIndexDocument',
declaration: 'export interface SessionIndexDocument extends SessionEventRecord {\n text: string;\n}',
},
{
name: 'SessionIndexSnapshot',
declaration: 'export interface SessionIndexSnapshot {\n session: SessionRecord;\n fingerprint: string;\n documents: readonly SessionIndexDocument[];\n}',
},
{
name: 'SessionLineageNode',
declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n children: SessionLineageNode[];\n}',
},
{
name: 'SessionLineageTrace',
declaration: 'export interface SessionLineageTrace {\n target: SessionRecord;\n parents: SessionRecord[];\n root?: SessionRecord;\n unresolvedParentId?: SessionId;\n children: SessionLineageNode[];\n}',
},
{
name: 'SessionPersistedIndexEntry',
declaration: 'export interface SessionPersistedIndexEntry {\n sessionId: SessionId;\n fingerprint: string;\n}',
},
{
name: 'SessionQueryExecContext',
declaration: 'export interface SessionQueryExecContext {\n readonly signal?: AbortSignal;\n}',
},
{
name: 'SessionQueryRange',
declaration: 'export interface SessionQueryRange {\n from?: number;\n to?: number;\n}',
},
{
name: 'SessionRecord',
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
},
{
name: 'SessionResultFilter',
declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | {\n kind: \'created-at\';\n range: SessionQueryRange;\n} | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly (\'live\' | \'persisted\')[];\n};',
},
{
name: 'SessionSearchHit',
declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}',
},
{
name: 'SessionSearchPage',
declaration: 'export interface SessionSearchPage<T> {\n providerId: string;\n items: readonly T[];\n nextCursor?: string;\n}',
},
{
name: 'SessionSearchPageRequest',
declaration: 'export interface SessionSearchPageRequest {\n limit?: number;\n cursor?: string;\n}',
},
{
name: 'SessionSearchProvider',
declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise<readonly SessionPersistedIndexEntry[]>;\n setPersistedActive(active: boolean): Promise<void>;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise<void>;\n removePersisted(sessionId: SessionId): Promise<void>;\n replaceLive(snapshot: SessionIndexSnapshot): Promise<void>;\n removeLive(sessionId: SessionId): Promise<void>;\n searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionSearchHit>>;\n searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionEventSearchHit>>;\n}',
},
{
name: 'SessionSearchProviderStatus',
declaration: 'export type SessionSearchProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'misconfigured\' | \'unavailable\';\n};',
},
{
name: 'SessionSearchRequest',
declaration: 'export interface SessionSearchRequest extends SessionSearchPageRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventResultFilter[];\n}',
},
{
name: 'SessionSearchSpec',
declaration: 'export interface SessionSearchSpec extends SessionSearchRequest {\n limit: number;\n}',
},
{
name: 'StreamChunk',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};',

View File

@@ -25,7 +25,11 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Events
The generated [Cordis event catalog](../../../docs/cordis-catalog/events.md) is the signature reference. `session/removed` is an observe-only notification emitted with a cloned header after the entry leaves the store; listener failures cannot fail owner teardown.
| Event | Mode | Purpose |
|---|---|---|
| `session/created` | emit | A session was created |
| `session/event` | emit | An event was appended (sync, fire-and-forget) |
| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) |
### Class: `Session`

View File

@@ -37,14 +37,6 @@ declare module 'cordis' {
* @mode emit
*/
'session/created'(session: Session): void
/**
* A session left the live store. The header is snapshotted after the store
* entry is removed; listener failures are contained and cannot break the
* owning fiber's teardown.
* @param header - immutable identity and lineage of the removed session.
* @mode parallel
*/
'session/removed'(header: SessionHeader): Promise<void> | void
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails.
@@ -509,15 +501,8 @@ export class SessionStore extends Service {
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
this.store.set(session.id, session)
return () => {
if (this.store.get(session.id) !== session) return
session.onAppend = undefined
this.store.delete(session.id)
const header = structuredClone(session.header)
void Promise.resolve()
.then(() => this.ctx.parallel('session/removed', header))
.catch((error: unknown) => {
this.ctx.logger.warn(`session store: session/removed listener failed for "${session.id}": ${String(error)}`)
})
}
}

View File

@@ -350,43 +350,6 @@ describe('SessionStore', () => {
expect(observed).toBe(0)
})
it('announces a cloned header only after the session leaves the store', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const observations: Array<{ id: string; live: boolean }> = []
ctx.on('session/removed', (header) => {
observations.push({ id: header.id, live: ctx.sessions.get(header.id) !== undefined })
header.createdAt = -1
})
const session = ctx.sessions.prepare(SessionId('removed'), { meta: { createdAt: 7 } })
const detach = ctx.sessions.enter(session)
detach()
await Promise.resolve()
await Promise.resolve()
expect(observations).toEqual([{ id: 'removed', live: false }])
expect(session.header.createdAt).toBe(7)
// A repeated disposer cannot remove or announce a later same-id owner.
const replacement = ctx.sessions.create(SessionId('removed'))
detach()
expect(ctx.sessions.get(replacement.id)).toBe(replacement)
expect(observations).toHaveLength(1)
})
it('contains rejected session/removed listeners during teardown', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.on('session/removed', () => Promise.reject(new Error('observer failed')))
const session = ctx.sessions.prepare(SessionId('contained'))
const detach = ctx.sessions.enter(session)
expect(detach).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(ctx.sessions.get(session.id)).toBeUndefined()
})
it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

@@ -26,8 +26,6 @@ The two first-party backends were byte-identical (or same-algorithm) for ALL of
`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice).
After an append or load-time repair commits, the coordinator emits the observe-only `session/persisted` notification described in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Its snapshotted header and seq range let derived read models invalidate safely; synchronous dispatch failures and rejected listeners are contained and never fail durability. Truncate-only HMR adoption emits no repair notification while the live session still owns the open turn.
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
| Hook | Role |

View File

@@ -27,7 +27,7 @@
import { Context } from 'cordis'
import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import { assertSerializable, seedCoversPrefix, type SessionPersistedChange } from './index.ts'
import { assertSerializable, seedCoversPrefix } from './index.ts'
/**
* A stored session's durable prefix as read back from a backend: its
@@ -229,13 +229,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// event inside it — before the op runs would otherwise have those changes
// persisted. The clone is taken synchronously (at call time).
const batch = events.map(e => structuredClone(e))
return this.serialize(id, () => this._appendCore(id, batch))
return this.serialize(id, () => this.appendCore(id, batch))
}
private async _appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
if (events.length === 0) return
let state = this.states.get(id)
if (state === undefined) state = await this.adopt(id) // calls _loadCore, not load
if (state === undefined) state = await this.adopt(id) // calls loadCore, not load
// Contiguity contract: each event's seq must continue the stored log.
for (const [i, event] of events.entries()) {
@@ -247,14 +247,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
await this.backend.appendBatch(state.meta, events, state.materialized)
// The durable write is the transaction: mark materialized + advance the
// cursor as soon as it commits (uniform across backends).
const fromSeq = state.cursor
state.materialized = true
state.cursor += events.length
this._notifyPersisted(state.meta, {
kind: 'append',
fromSeq,
toSeq: state.cursor - 1,
})
}
/**
@@ -265,10 +259,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* @returns the header plus the event log, ending on a balanced `turn/end`.
*/
load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.serialize(id, () => this._loadCore(id))
return this.serialize(id, () => this.loadCore(id))
}
private async _loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const stored = await this.backend.loadStored(id)
if (stored === undefined) throw new Error(`session "${id}" not found`)
const { meta, events, tornMarker } = stored
@@ -287,21 +281,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// there is no state-path ordering dependency (uniform across backends).
if (tornMarker !== undefined || closers.length > 0) {
await this.backend.commitRepair(meta, tornMarker, closers)
this._notifyPersisted(meta, {
kind: 'repair',
fromSeq: events.length,
toSeq: balanced.length - 1,
})
}
const owner = this.states.get(id)?.owner
// The state keeps its OWN copy of the meta; preserve a live owner already
// bound to the id so a read-side load cannot downgrade adoption state.
this.states.set(id, {
meta: { ...meta },
cursor: balanced.length,
materialized: true,
...owner !== undefined ? { owner } : {},
})
// The state keeps its OWN copy of the meta; the returned value is separate so
// a consumer mutating loaded.meta cannot corrupt the backend's metadata.
this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true })
return { meta, events: balanced }
}
@@ -331,11 +314,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/** Build a state for a session discovered in storage but not yet in memory. */
private async adopt(id: SessionId): Promise<SessionState> {
// _loadCore (NOT load) — adopt runs inside an already-serialized op, so
// loadCore (NOT load) — adopt runs inside an already-serialized op, so
// re-entering the chain via the public load() would deadlock.
await this._loadCore(id)
await this.loadCore(id)
const state = this.states.get(id)
/* v8 ignore next -- _loadCore always sets the state for the id */
/* v8 ignore next -- loadCore always sets the state for the id */
if (!state) throw new Error(`failed to adopt session "${id}"`)
return state
}
@@ -492,7 +475,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// resume.
const live = await this.backend.loadLive(id, session.header.cwd)
if (live !== undefined) {
// Do NOT route through _loadCore(): that crash-repairs open turns as
// Do NOT route through loadCore(): that crash-repairs open turns as
// interrupted, which is wrong for HMR while the live Session is still the
// authority and may append the real step/turn end later.
await this.serialize(id, () => this.adoptLivePrefix(session, seed, live))
@@ -532,7 +515,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
owner: session,
})
const suffix = seed.slice(events.length)
if (suffix.length > 0) await this._appendCore(session.header.id, suffix)
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
}
private async flush(session: Session): Promise<void> {
@@ -563,20 +546,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/* v8 ignore next -- state is always set by the awaited init before flush */
const cursor = state?.cursor ?? 0
const fresh = batch.filter(e => e.seq >= cursor)
// _appendCore (NOT the serialized append) — drain already runs inside the
// appendCore (NOT the serialized append) — drain already runs inside the
// per-session chain, so re-entering via append() would deadlock.
if (fresh.length > 0) await this._appendCore(session.header.id, fresh)
if (fresh.length > 0) await this.appendCore(session.header.id, fresh)
buffer.splice(0, batch.length)
}
/** Notify derived read models after source data commits. */
private _notifyPersisted(meta: SessionHeader, change: SessionPersistedChange): void {
const header = structuredClone(meta)
const snapshot = structuredClone(change)
void Promise.resolve()
.then(() => this.ctx.parallel('session/persisted', header, snapshot))
.catch((error: unknown) => {
this.ctx.logger.warn(`${this.backend.name}: session/persisted listener failed after ${change.kind} for "${meta.id}": ${String(error)}`)
})
}
}

View File

@@ -36,29 +36,6 @@ declare module 'cordis' {
interface Context {
sessionPersistence: SessionPersistence
}
interface Events {
/**
* A persistence backend committed a canonical session-log change. This is
* an observe-only notification for derived read models: the durable write
* has already succeeded, and listener failures are contained rather than
* propagated into append, load, flush, or teardown.
* @param header - snapshotted persisted session metadata.
* @param change - committed seq range and whether it was an append or repair.
* @mode parallel
*/
'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise<void> | void
}
}
/** A committed persisted-log change observed by derived read models. */
export interface SessionPersistedChange {
/** Whether ordinary append or load-time repair committed the change. */
kind: 'append' | 'repair'
/** First seq affected by the commit. */
fromSeq: number
/** Last seq appended; less than `fromSeq` when repair only removed a torn fragment. */
toSeq: number
}
/**

View File

@@ -31,7 +31,6 @@ import { Context, type Fiber } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '../src/index.ts'
import type { SessionPersistedChange } from '../src/index.ts'
import { meta, oneTurnLog, appendLog } from './contract.ts'
/**
@@ -124,40 +123,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('announces committed append and repair ranges without coupling listener failures to writes', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = []
ctx.on('session/persisted', (header, change) => {
observed.push({ headerId: header.id, change: structuredClone(change) })
header.createdAt = -1
return Promise.reject(new Error('derived read model failed'))
})
try {
const m = meta('notifications', WORK)
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
])
await expect(ctx.sessionPersistence.load(m.id)).resolves.toMatchObject({ meta: { createdAt: m.createdAt } })
await Promise.resolve()
await Promise.resolve()
expect(observed).toEqual([
{ headerId: m.id, change: { kind: 'append', fromSeq: 0, toSeq: 5 } },
{ headerId: m.id, change: { kind: 'append', fromSeq: 6, toSeq: 7 } },
{ headerId: m.id, change: { kind: 'repair', fromSeq: 8, toSeq: 9 } },
])
expect((await ctx.sessionPersistence.load(m.id)).meta.createdAt).toBe(m.createdAt)
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
await fix.cleanup()
}
})
it('round-trips the seed boundary (seedLength) through persistence', async () => {
// A forked child records how many leading events were inherited via the
// seed; the boundary must survive a reload (so a resume/replay can tell the
@@ -403,10 +368,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Crash-tail a torn fragment past the (open) committed turn, then reload.
await first.dispose()
if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK)
const repairs: SessionPersistedChange[] = []
ctx.on('session/persisted', (_header, change) => {
if (change.kind === 'repair') repairs.push(structuredClone(change))
})
const second = await fix.mount(ctx)
// The live session is still the authority: it appends the REAL step/turn
// end. Adoption must truncate the torn tail but NOT synthesize closers.
@@ -417,7 +378,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
expect(repairs).toEqual([])
await second.dispose()
} finally {
await ctx.fiber.dispose()
@@ -425,34 +385,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('a query-side load preserves the existing live owner binding', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
let session!: Session
const liveFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('load-owner'), { meta: { cwd: WORK } })
send(session, oneTurnLog())
}, { inject: ['sessions'] }))
try {
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(session.id)
await liveFiber.dispose()
let replacement!: Session
await ctx.plugin(Object.assign((inner: Context) => {
replacement = inner.sessions.create(session.id, {
seed: loaded.events,
meta: { cwd: WORK, createdAt: loaded.meta.createdAt },
})
}, { inject: ['sessions'] }))
await expect(inits(ctx.sessionPersistence).get(replacement)).rejects.toThrow(/different live session|id collision/)
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
await fix.cleanup()
}
})
// --- collision / id reuse ---
it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => {

View File

@@ -1,9 +1,9 @@
# session-query/ — session retrieval capability family
Trusted read-model infrastructure over live and durable session logs. The interface package owns `ctx.sessionQuery`, logical-corpus resolution, filters, traces, text extractors, and the full-text provider contract. A search backend is a separate implementation package; a model tool or UI remains a separate consumer.
Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads.
| Package | Role | ctx key |
|---|---|---|
| [`session-query/`](session-query/README.md) | Retrieval service and provider contract | `ctx.sessionQuery` |
| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` |
The family is independent of the [compaction capability](../compact/README.md): it reads compaction provenance from the canonical session log but does not participate in compaction policy or execution. The provider-neutral decision is recorded in the [session-query RFC](../../docs/rfc/implemented/feature/2026-07-10-session-query-service.md); the first proposed backend is specified separately in the [SQLite provider RFC](../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package.

View File

@@ -1,52 +1,23 @@
# @deepseek-ai/dsh-session-query
Provider-neutral session-history retrieval (`ctx.sessionQuery`). The service presents live `ctx.sessions` state and, when mounted, `ctx.sessionPersistence` state as one logical corpus. A matching id produces one record: live events win, while independent `live` and `persisted` flags report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT` instead of silently merging unrelated histories.
Exact session-history retrieval 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`.
This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect.
## Reads and traces
## Reads
- `listSessions()` returns cloned lightweight records in deterministic newest-first order.
- `listEvents(sessionId)` classifies each raw event as `current`, `shadowed`, or `log-only` using the shared `dsh-session` surface fold.
- `readEvent(request)` returns the cloned target and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax` (default 50).
- `traceSession(sessionId)` returns nearest-first parents, a known root or explicit unresolved parent id, and the complete deterministic descendant tree. A connected lineage cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(sessionId, seq)` returns direct provenance references and reverse references, direct shadows, the immediate replacer, and the transitive replacement chain toward the current surface node. Related nodes stay seq links; callers use `readEvent()` for content.
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
- `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.
- `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`.
An installed persistence backend is optional and may mount or unmount dynamically. Cross-session operations fail with `SESSION_QUERY_PERSISTENCE_FAILED` while installed persistence is unreadable. A read targeting a known live session never depends on persistence health. Provider-side persisted rows are deactivated rather than deleted when persistence is absent.
Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations.
## Filters
`filterSessionResults()` and `filterEventResults()` are pure generic transforms over records or richer hits. Each discriminated filter is serializable. Values within one filter are OR alternatives; filters in the supplied array are an AND chain. The functions preserve order and item identity and return a fresh array.
Session filters cover id, exact cwd, inclusive creation time, parent id/root, and live/persisted availability. Event filters cover inclusive seq/time, event type, and surface status. Search requests accept the same specs as pre-ranking filters. Applying the pure functions to a materialized provider page is a post-filter: it never fetches replacement hits to refill the page.
## Full-text providers
`registerSearchProvider(provider)` is effect-scoped and ids are unique. Its async disposer removes the provider from selection immediately, lets already accepted transactions finish, and settles after they drain. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event.
The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search.
Persisted snapshots carry a SHA-256 fingerprint over canonical header/events plus the versions of relevant extractors. Reconciliation still loads and hashes canonical logs, but a provider replacement occurs only for a new or changed fingerprint; stale durable inventory entries are removed only while persistence is active and authoritative.
Providers receive resolved `SessionSearchSpec` and `SessionEventSearchSpec` values whose `limit` is required after service defaulting and validation. Public service callers use `SessionSearchRequest` and `SessionEventSearchRequest`, where `limit` remains optional.
## Errors
`SessionQueryError.code` is the closed `SessionQueryErrorCode` union: `SESSION_QUERY_ABORTED`, `SESSION_QUERY_DUPLICATE_EXTRACTOR`, `SESSION_QUERY_DUPLICATE_PROVIDER`, `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INDEX_FAILED`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_EXTRACTOR`, `SESSION_QUERY_INVALID_FILTER`, `SESSION_QUERY_INVALID_LIMIT`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_QUERY`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_PROVIDER_AMBIGUOUS`, `SESSION_QUERY_PROVIDER_CONFIGURED_MISSING`, `SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE`, `SESSION_QUERY_PROVIDER_ERROR`, `SESSION_QUERY_PROVIDER_UNAVAILABLE`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
## Text extractors
Core extraction indexes semantic message text and reasoning, tool names/arguments/results, blocked prompts, context and steering, todos, and error/status detail. Stream chunks, request headers, and structural-only events contribute no document. Unknown event and content-block types contribute no text until their owner registers a versioned extractor with `registerEventTextExtractor()` or `registerContentTextExtractor()`.
Extractor registrations are unique per discriminant and effect-scoped. Their stable versions participate in fingerprints, so changing extraction semantics invalidates only sessions whose indexed source uses that extractor.
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
## Configuration
| Key | Default | Contract |
|---|---:|---|
| `searchProvider` | omitted | Explicit provider id; omission requires exactly one available provider. |
| `defaultLimit` | `20` | Search page size when the request omits `limit`. |
| `maxLimit` | `100` | Maximum accepted search page size; must be at least `defaultLimit`. |
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. |
The package ships no full-text backend and no model-facing tool. The proposed SQLite implementation is a later, independent phase described in the [SQLite provider RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-session-query",
"description": "Provider-neutral live and persisted session retrieval service (ctx.sessionQuery)",
"description": "Live-preferred exact session-history retrieval service (ctx.sessionQuery)",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,51 +1,23 @@
/**
* Public configuration, defaults, and typed failures for session-query.
*
* @module @deepseek-ai/dsh-session-query/config
*/
/** Public configuration and typed failures for session-query. */
import { HarnessError } from '@deepseek-ai/dsh-llm'
/** Default page size for provider-backed search. */
export const SESSION_QUERY_DEFAULT_LIMIT = 20
/** Maximum page size accepted by provider-backed search. */
export const SESSION_QUERY_MAX_LIMIT = 100
/** Default maximum `before`/`after` raw-event window. */
export const SESSION_QUERY_READ_WINDOW_MAX = 50
/** Configuration for the provider-neutral session-query service. */
/** Configuration for exact session-query reads. */
export interface Config {
/** Explicit provider id; omitted auto-selects exactly one usable provider. */
searchProvider?: string
/** Default search result page size. Defaults to 20. */
defaultLimit?: number
/** Maximum accepted search page size. Defaults to 100. */
maxLimit?: number
/** Maximum accepted raw read context on either side. Defaults to 50. */
readWindowMax?: number
}
/** Complete stable machine-routable failure taxonomy for session-query. */
/** Stable machine-routable failure taxonomy for exact session reads. */
export type SessionQueryErrorCode =
| 'SESSION_QUERY_ABORTED'
| 'SESSION_QUERY_DUPLICATE_EXTRACTOR'
| 'SESSION_QUERY_DUPLICATE_PROVIDER'
| 'SESSION_QUERY_EVENT_NOT_FOUND'
| 'SESSION_QUERY_INDEX_FAILED'
| 'SESSION_QUERY_INVALID_CONFIG'
| 'SESSION_QUERY_INVALID_EXTRACTOR'
| 'SESSION_QUERY_INVALID_FILTER'
| 'SESSION_QUERY_INVALID_LIMIT'
| 'SESSION_QUERY_INVALID_LINEAGE'
| 'SESSION_QUERY_INVALID_QUERY'
| 'SESSION_QUERY_INVALID_SURFACE'
| 'SESSION_QUERY_INVALID_WINDOW'
| 'SESSION_QUERY_PERSISTENCE_FAILED'
| 'SESSION_QUERY_PROVIDER_AMBIGUOUS'
| 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING'
| 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE'
| 'SESSION_QUERY_PROVIDER_ERROR'
| 'SESSION_QUERY_PROVIDER_UNAVAILABLE'
| 'SESSION_QUERY_SESSION_NOT_FOUND'
| 'SESSION_QUERY_SOURCE_CONFLICT'

View File

@@ -1,45 +1,32 @@
/** Live/persisted logical-corpus resolution for session-query. */
import type { Context } from 'cordis'
import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
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'
import type { LoadedSession } from './extraction.ts'
import { canonicalJson } from './extraction.ts'
import { SessionQueryError } from './config.ts'
interface PersistenceBinding {
token: symbol
service: SessionPersistence
headers: Map<SessionId, SessionHeader>
/** Notifications retained until a list that began after them completes. */
observations: Map<SessionId, PersistedObservation>
observationGeneration: number
error?: unknown
refreshing: Promise<void> | undefined
}
interface PersistedObservation {
generation: number
/** Detached source selected for one exact read. */
export interface LogicalSession {
/** Cloned source header. */
header: SessionHeader
/** Cloned raw event log. */
events: SessionEvent[]
}
/** Active persistence view used by provider reconciliation. */
export interface PersistenceView {
/** Canonical headers in deterministic creation order. */
headers: SessionHeader[]
/** Load one canonical persisted source. */
load(id: SessionId): Promise<LoadedSession>
}
/** Resolves one live-preferred corpus while containing optional persistence lifecycle. */
/** Resolves a live-preferred corpus against the persistence service mounted now. */
export class SessionCorpus {
private _persistence: PersistenceBinding | undefined
private _persistence: SessionPersistence | undefined
constructor(private readonly _ctx: Context) {
_ctx.effect(() => {
const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
this._attachPersistence(childCtx, childCtx.sessionPersistence)
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()
}, 'sessionQuery.optionalPersistence')
@@ -47,23 +34,22 @@ export class SessionCorpus {
/**
* List the complete logical corpus with live precedence and cloned headers.
* @returns logical records in deterministic newest-first order.
* @returns records in deterministic newest-first order.
*/
async listSessions(): Promise<SessionRecord[]> {
const binding = await this._ensurePersistence()
const persistence = this._persistence
const persisted = persistence === undefined ? [] : await listPersisted(persistence)
const records = new Map<SessionId, SessionRecord>()
if (binding !== undefined) {
for (const header of binding.headers.values()) {
records.set(header.id, { header: structuredClone(header), live: false, persisted: true })
}
for (const header of persisted) {
records.set(header.id, { header: structuredClone(header), live: false, persisted: true })
}
for (const session of this._ctx.sessions.list()) {
const persisted = binding?.headers.get(session.id)
if (persisted !== undefined) this._assertCompatibleHeaders(session.header, persisted)
const durable = records.get(session.id)
if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header)
records.set(session.id, {
header: structuredClone(session.header),
live: true,
persisted: persisted !== undefined,
persisted: durable !== undefined,
})
}
return [...records.values()].sort(compareSessions)
@@ -71,156 +57,69 @@ export class SessionCorpus {
/**
* Load one logical source, preferring a detached live snapshot.
*
* A known live target never consults persistence, so an optional backend's
* failure cannot make current in-memory history unreadable.
* @param sessionId - session to resolve.
* @returns detached live-preferred metadata and events.
* @returns detached live-preferred header and events.
*/
async loadLogical(sessionId: SessionId): Promise<LoadedSession> {
async load(sessionId: SessionId): Promise<LogicalSession> {
const live = this._ctx.sessions.get(sessionId)
if (live !== undefined) return this.snapshotLive(live)
const binding = await this._ensurePersistence()
if (binding === undefined || !binding.headers.has(sessionId)) {
throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
}
return this._loadPersisted(binding, sessionId)
}
/**
* Return a detached live source with current availability flags.
* @param session - live session to snapshot.
* @returns detached metadata and events.
*/
snapshotLive(session: Session): LoadedSession {
const persistedHeader = this._persistence?.headers.get(session.id)
if (persistedHeader !== undefined) this._assertCompatibleHeaders(session.header, persistedHeader)
return {
record: {
header: structuredClone(session.header),
live: true,
persisted: persistedHeader !== undefined,
},
events: session.events.map(event => structuredClone(event)),
}
}
/**
* Get one live session without consulting persistence.
* @param sessionId - live id to resolve.
* @returns current store object, or undefined.
*/
getLive(sessionId: SessionId): Session | undefined {
return this._ctx.sessions.get(sessionId)
}
/**
* List live sessions in store order.
* @returns fresh array of current store objects.
*/
listLive(): Session[] {
return this._ctx.sessions.list()
}
/**
* Resolve an authoritative persisted view.
* @returns cloned headers and loader, or undefined while unmounted.
*/
async persistenceView(): Promise<PersistenceView | undefined> {
const binding = await this._ensurePersistence()
if (binding === undefined) return undefined
return {
headers: [...binding.headers.values()].map(header => structuredClone(header)).sort(compareHeadersAscending),
load: id => this._loadPersisted(binding, id),
}
}
private _attachPersistence(ctx: Context, service: SessionPersistence): void {
const binding: PersistenceBinding = {
token: Symbol('session-query-persistence'),
service,
headers: new Map(),
observations: new Map(),
observationGeneration: 0,
refreshing: undefined,
}
this._persistence = binding
void this._refreshPersistence(binding)
ctx.on('session/persisted', (header) => {
/* v8 ignore next -- a stale notification can race optional-service disposal */
if (this._persistence?.token !== binding.token) return
const snapshot = structuredClone(header)
const observation = { generation: ++binding.observationGeneration, header: snapshot }
binding.headers.set(header.id, snapshot)
binding.observations.set(header.id, observation)
})
ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding')
}
private _detachPersistence(binding: PersistenceBinding): void {
/* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */
if (this._persistence?.token !== binding.token) return
this._persistence = undefined
}
private _refreshPersistence(binding: PersistenceBinding): Promise<void> {
if (binding.refreshing !== undefined) return binding.refreshing
const startGeneration = binding.observationGeneration
const refresh = binding.service.list().then((headers) => {
/* v8 ignore next -- a list completion can race optional-service disposal */
if (this._persistence?.token !== binding.token) return
const nextHeaders = new Map(headers.map(header => [header.id, structuredClone(header)]))
for (const [id, observation] of binding.observations) {
// A notification newer than this list's snapshot is the authoritative
// read-your-writes layer; older ones must already be present in list().
if (observation.generation > startGeneration) {
nextHeaders.set(id, structuredClone(observation.header))
} else {
binding.observations.delete(id)
}
}
binding.headers = nextHeaders
binding.error = undefined
}).catch((error: unknown) => {
/* v8 ignore next -- a failed list can race optional-service disposal */
if (this._persistence?.token !== binding.token) return
binding.error = error
}).finally(() => {
/* v8 ignore next -- a newer refresh may already own the slot */
if (binding.refreshing === refresh) binding.refreshing = undefined
})
binding.refreshing = refresh
return refresh
}
private async _ensurePersistence(): Promise<PersistenceBinding | undefined> {
const binding = this._persistence
if (binding === undefined) return undefined
await this._refreshPersistence(binding)
if (binding.error !== undefined) {
const cause = binding.error
throw new SessionQueryError(`session persistence listing failed: ${errorMessage(cause)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause })
}
return binding
}
private async _loadPersisted(binding: PersistenceBinding, sessionId: SessionId): Promise<LoadedSession> {
if (live !== undefined) return snapshotLive(live)
const persistence = this._persistence
if (persistence === undefined) throw notFound(sessionId)
const listed = (await listPersisted(persistence)).find(header => header.id === sessionId)
if (listed === undefined) throw notFound(sessionId)
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
try {
const loaded = await binding.service.load(sessionId)
const listed = binding.headers.get(sessionId)
/* v8 ignore else -- every internal persisted load starts from a listed header */
if (listed !== undefined) this._assertCompatibleHeaders(loaded.meta, listed)
return {
record: { header: structuredClone(loaded.meta), live: false, persisted: true },
events: loaded.events.map(event => structuredClone(event)),
}
loaded = await persistence.load(sessionId)
} catch (error: unknown) {
if (error instanceof SessionQueryError) throw error
throw new SessionQueryError(`failed to load session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error })
throw new SessionQueryError(
`failed to load session "${sessionId}": ${errorMessage(error)}`,
'SESSION_QUERY_PERSISTENCE_FAILED',
{ cause: error },
)
}
assertCompatibleHeaders(loaded.meta, listed)
return {
header: structuredClone(loaded.meta),
events: loaded.events.map(event => structuredClone(event)),
}
}
}
private _assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void {
if (canonicalJson(a) !== canonicalJson(b)) {
throw new SessionQueryError(`live and persisted headers conflict for session "${a.id}"`, 'SESSION_QUERY_SOURCE_CONFLICT')
}
async function listPersisted(persistence: SessionPersistence): Promise<SessionHeader[]> {
try {
return await persistence.list()
} catch (error: unknown) {
throw new SessionQueryError(
`session persistence listing failed: ${errorMessage(error)}`,
'SESSION_QUERY_PERSISTENCE_FAILED',
{ cause: error },
)
}
}
function snapshotLive(session: Session): LogicalSession {
return {
header: structuredClone(session.header),
events: session.events.map(event => structuredClone(event)),
}
}
function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void {
if (
a.version !== b.version
|| a.id !== b.id
|| a.createdAt !== b.createdAt
|| a.cwd !== b.cwd
|| a.parentSession !== b.parentSession
|| a.seedLength !== b.seedLength
) {
throw new SessionQueryError(
`live and persisted headers conflict for session "${a.id}"`,
'SESSION_QUERY_SOURCE_CONFLICT',
)
}
}
@@ -228,11 +127,10 @@ function compareSessions(a: SessionRecord, b: SessionRecord): number {
return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id)
}
function compareHeadersAscending(a: SessionHeader, b: SessionHeader): number {
return a.createdAt - b.createdAt || a.id.localeCompare(b.id)
function notFound(sessionId: SessionId): SessionQueryError {
return new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
}
function errorMessage(error: unknown): string {
/* v8 ignore next -- persistence service contracts reject Error instances */
return error instanceof Error ? error.message : 'unknown error'
}

View File

@@ -1,254 +0,0 @@
/** Semantic text extraction and stable provider snapshot fingerprints. */
import { createHash } from 'node:crypto'
import type { Context } from 'cordis'
import type { ContentBlock, ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionEventType } from '@deepseek-ai/dsh-session'
import type {
SessionContentTextExtractor,
SessionEventTextExtractor,
SessionIndexDocument,
SessionIndexSnapshot,
SessionRecord,
} from './types.ts'
import { SessionQueryError } from './config.ts'
import { eventRecords } from './tracing.ts'
/** Canonical session source consumed by extraction and provider reconciliation. */
export interface LoadedSession {
/** Logical source metadata. */
record: SessionRecord
/** Detached canonical events. */
events: SessionEvent[]
}
interface StoredEventExtractor {
version: string
extract(event: SessionEvent): readonly string[]
}
interface StoredContentExtractor {
version: string
extract(block: ContentBlock): readonly string[]
}
/** Owns core/custom semantic extractors and builds versioned index snapshots. */
export class SessionTextExtractors {
private readonly _eventExtractors = new Map<SessionEventType, StoredEventExtractor>()
private readonly _contentExtractors = new Map<ContentBlockType, StoredContentExtractor>()
constructor() {
this._installCoreExtractors()
}
/**
* Register one effect-scoped event extractor.
* @param ctx - contributing caller context.
* @param type - event discriminant.
* @param extractor - versioned semantic extractor.
* @returns disposer for the registration.
*/
registerEvent<K extends SessionEventType>(
ctx: Context,
type: K,
extractor: SessionEventTextExtractor<K>,
): () => void {
this._validateVersion(type, extractor.version)
if (this._eventExtractors.has(type)) {
throw new SessionQueryError(`session event text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR')
}
const stored: StoredEventExtractor = {
version: extractor.version,
extract: event => extractor.extract(event as SessionEvent<K>),
}
const dispose = ctx.effect(function* (this: SessionTextExtractors) {
this._eventExtractors.set(type, stored)
yield () => {
this._eventExtractors.delete(type)
}
}.bind(this), `sessionQuery.eventExtractor(${type})`)
return () => void dispose()
}
/**
* Register one effect-scoped content-block extractor.
* @param ctx - contributing caller context.
* @param type - content-block discriminant.
* @param extractor - versioned semantic extractor.
* @returns disposer for the registration.
*/
registerContent<K extends ContentBlockType>(
ctx: Context,
type: K,
extractor: SessionContentTextExtractor<K>,
): () => void {
this._validateVersion(type, extractor.version)
if (this._contentExtractors.has(type)) {
throw new SessionQueryError(`session content text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR')
}
const stored: StoredContentExtractor = {
version: extractor.version,
extract: block => extractor.extract(block as ContentBlockMap[K]),
}
const dispose = ctx.effect(function* (this: SessionTextExtractors) {
this._contentExtractors.set(type, stored)
yield () => {
this._contentExtractors.delete(type)
}
}.bind(this), `sessionQuery.contentExtractor(${type})`)
return () => void dispose()
}
/**
* Build one provider-neutral snapshot and SHA-256 source/version fingerprint.
* @param loaded - detached canonical source.
* @returns lightweight documents and stable fingerprint.
*/
buildSnapshot(loaded: LoadedSession): SessionIndexSnapshot {
const records = eventRecords(loaded.record.header.id, loaded.events)
const documents: SessionIndexDocument[] = []
const eventVersions = new Set<string>()
const blockVersions = new Set<string>()
for (const event of loaded.events) {
const extractor = this._eventExtractors.get(event.type)
if (extractor === undefined) continue
eventVersions.add(`${event.type}@${extractor.version}`)
collectBlockVersions(event.data, this._contentExtractors, blockVersions)
const text = normalizeText(extractor.extract(event))
if (text.length === 0) continue
// The event record array parallels the contiguous log.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
documents.push({ ...records[event.seq]!, text })
}
const fingerprint = createHash('sha256').update(canonicalJson({
header: loaded.record.header,
events: loaded.events,
eventExtractors: [...eventVersions].sort(),
contentExtractors: [...blockVersions].sort(),
})).digest('hex')
return {
session: cloneRecord(loaded.record),
fingerprint,
documents,
}
}
private _installCoreExtractors(): void {
this._contentExtractors.set('text', { version: '1', extract: block => [(block as ContentBlockMap['text']).text] })
this._contentExtractors.set('reasoning', { version: '1', extract: block => [(block as ContentBlockMap['reasoning']).text] })
this._contentExtractors.set('tool-call', {
version: '1',
extract: (block) => {
const call = block as ContentBlockMap['tool-call']
return [call.name, call.arguments]
},
})
this._contentExtractors.set('tool-result', {
version: '1',
extract: block => this._extractBlocks((block as ContentBlockMap['tool-result']).content),
})
for (const type of ['user/message', 'assistant/message', 'context/message', 'steering/message'] as const) {
this._eventExtractors.set(type, {
version: '1',
extract: event => this._extractBlocks((event as SessionEvent<typeof type>).data.content),
})
}
this._eventExtractors.set('prompt/blocked', {
version: '1',
extract: (event) => {
const data = (event as SessionEvent<'prompt/blocked'>).data
return [...this._extractBlocks(data.content), data.reason]
},
})
this._eventExtractors.set('tool/call', {
version: '1',
extract: (event) => {
const data = (event as SessionEvent<'tool/call'>).data
return [data.name, data.arguments]
},
})
this._eventExtractors.set('tool/result', {
version: '1',
extract: (event) => {
const data = (event as SessionEvent<'tool/result'>).data
return [...this._extractBlocks(data.content), data.error?.name ?? '', data.error?.code ?? '']
},
})
this._eventExtractors.set('todo/write', {
version: '1',
extract: event => (event as SessionEvent<'todo/write'>).data.todos.map(todo => `${todo.status} ${todo.content}`),
})
this._eventExtractors.set('turn/end', {
version: '1',
extract: (event) => {
const reason = (event as SessionEvent<'turn/end'>).data.reason
switch (reason.kind) {
case 'error': return ['error', reason.message, reason.code ?? '']
case 'aborted': return ['aborted', reason.reason ?? '']
case 'rejected': return ['rejected', reason.reason]
case 'disposed': return ['disposed']
case 'max-tokens': return ['max-tokens']
case 'interrupted': return ['interrupted']
case 'completed': return []
// TurnEndReasonMap is merge-extensible; unknown variants contribute no text.
/* v8 ignore next -- only an external declaration-merged reason can reach this fallback */
default: return []
}
},
})
}
private _extractBlocks(blocks: readonly ContentBlock[]): string[] {
const fragments: string[] = []
for (const block of blocks) {
const extractor = this._contentExtractors.get(block.type)
if (extractor !== undefined) fragments.push(...extractor.extract(block))
}
return fragments
}
private _validateVersion(type: string, version: string): void {
if (version.trim().length === 0) {
throw new SessionQueryError(`session-query extractor "${type}" requires a non-blank version`, 'SESSION_QUERY_INVALID_EXTRACTOR')
}
}
}
/**
* Encode canonical JSON with recursively sorted object keys.
* @param value - JSON-compatible source value.
* @returns deterministic JSON text.
*/
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
const object = value as Record<string, unknown>
return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}`
}
function normalizeText(fragments: readonly string[]): string {
return fragments.map(fragment => fragment.trim()).filter(Boolean).join('\n')
}
function collectBlockVersions(
value: unknown,
extractors: ReadonlyMap<ContentBlockType, StoredContentExtractor>,
versions: Set<string>,
): void {
if (Array.isArray(value)) {
for (const item of value) collectBlockVersions(item, extractors, versions)
return
}
if (value === null || typeof value !== 'object') return
const object = value as Record<string, unknown>
if (typeof object.type === 'string') {
const type = object.type as ContentBlockType
const extractor = extractors.get(type)
if (extractor !== undefined) versions.add(`${type}@${extractor.version}`)
}
for (const nested of Object.values(object)) collectBlockVersions(nested, extractors, versions)
}
function cloneRecord(record: SessionRecord): SessionRecord {
return { ...record, header: structuredClone(record.header) }
}

View File

@@ -1,123 +0,0 @@
/** Pure serializable session-query result filters. */
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {
SessionEventRecord,
SessionEventResultFilter,
SessionQueryRange,
SessionRecord,
SessionResultFilter,
} from './types.ts'
import { SessionQueryError } from './config.ts'
const AVAILABILITIES = ['live', 'persisted'] as const
const SURFACE_STATES = ['current', 'shadowed', 'log-only'] as const
/**
* Apply an ordered AND-chain of session filters while preserving item order
* and the concrete generic item type.
* @param results - session records or richer session search hits.
* @param filters - serializable filters applied in order.
* @returns a fresh filtered array.
*/
export function filterSessionResults<T extends SessionRecord>(
results: readonly T[],
filters: readonly SessionResultFilter[],
): T[] {
for (const filter of filters) validateSessionFilter(filter)
return results.filter(result => filters.every(filter => matchesSessionFilter(result, filter)))
}
/**
* Apply an ordered AND-chain of event filters while preserving item order and
* the concrete generic item type.
* @param results - event records or richer event search hits.
* @param filters - serializable filters applied in order.
* @returns a fresh filtered array.
*/
export function filterEventResults<T extends SessionEventRecord>(
results: readonly T[],
filters: readonly SessionEventResultFilter[],
): T[] {
for (const filter of filters) validateEventFilter(filter)
return results.filter(result => filters.every(filter => matchesEventFilter(result, filter)))
}
function matchesSessionFilter(record: SessionRecord, filter: SessionResultFilter): boolean {
switch (filter.kind) {
case 'id': return filter.values.includes(record.header.id)
case 'cwd': return filter.values.includes(record.header.cwd ?? null)
case 'created-at': return inRange(record.header.createdAt, filter.range)
case 'parent': return filter.values.includes(record.header.parentSession ?? null)
case 'availability': return filter.values.some(value => value === 'live' ? record.live : record.persisted)
/* v8 ignore next -- closed discriminated union exhaustiveness guard */
default: return assertNever(filter)
}
}
function matchesEventFilter(record: SessionEventRecord, filter: SessionEventResultFilter): boolean {
switch (filter.kind) {
case 'seq': return inRange(record.seq, filter.range)
case 'time': return inRange(record.time, filter.range)
case 'type': return filter.values.includes(record.type)
case 'surface': return filter.values.includes(record.surface)
/* v8 ignore next -- closed discriminated union exhaustiveness guard */
default: return assertNever(filter)
}
}
function validateSessionFilter(filter: SessionResultFilter): void {
switch (filter.kind) {
case 'id':
case 'cwd':
case 'parent':
return
case 'created-at':
validateRange('created-at', filter.range)
return
case 'availability':
for (const value of filter.values) {
if (!(AVAILABILITIES as readonly string[]).includes(value)) invalidFilter(`unknown availability "${value}"`)
}
return
/* v8 ignore next -- closed discriminated union exhaustiveness guard */
default:
assertNever(filter)
}
}
function validateEventFilter(filter: SessionEventResultFilter): void {
switch (filter.kind) {
case 'seq':
case 'time':
validateRange(filter.kind, filter.range)
return
case 'type':
return
case 'surface':
for (const value of filter.values) {
if (!(SURFACE_STATES as readonly string[]).includes(value)) invalidFilter(`unknown surface status "${value}"`)
}
return
/* v8 ignore next -- closed discriminated union exhaustiveness guard */
default:
assertNever(filter)
}
}
function validateRange(name: string, range: SessionQueryRange): void {
if (range.from !== undefined && !Number.isFinite(range.from)) invalidFilter(`${name}.from must be finite`)
if (range.to !== undefined && !Number.isFinite(range.to)) invalidFilter(`${name}.to must be finite`)
if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
invalidFilter(`${name}.from must be <= ${name}.to`)
}
}
function invalidFilter(message: string): never {
throw new SessionQueryError(`session-query filter: ${message}`, 'SESSION_QUERY_INVALID_FILTER')
}
function inRange(value: number, range: SessionQueryRange): boolean {
return (range.from === undefined || value >= range.from)
&& (range.to === undefined || value <= range.to)
}

View File

@@ -1,53 +1,29 @@
/**
* Provider-neutral session-history retrieval over live and optionally
* persisted session logs. The public service composes logical-corpus reads,
* pure filters and tracing, semantic extraction, and provider coordination.
* Exact session-history reads over live and optionally persisted logs.
*
* @module @deepseek-ai/dsh-session-query
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { ContentBlockType } from '@deepseek-ai/dsh-llm'
import type { SessionEventType, SessionId } from '@deepseek-ai/dsh-session'
import { foldSurface } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {
SessionContentTextExtractor,
SessionEventReadRequest,
SessionEventRecord,
SessionEventSearchHit,
SessionEventSearchRequest,
SessionEventTextExtractor,
SessionEventTrace,
SessionEventWindow,
SessionLineageTrace,
SessionRecord,
SessionSearchHit,
SessionSearchPage,
SessionSearchProvider,
SessionSearchRequest,
SessionQueryExecContext,
} from './types.ts'
import {
SESSION_QUERY_DEFAULT_LIMIT,
SESSION_QUERY_MAX_LIMIT,
SESSION_QUERY_READ_WINDOW_MAX,
SessionQueryError,
type Config,
} from './config.ts'
import { SessionTextExtractors } from './extraction.ts'
import { SessionCorpus } from './corpus.ts'
import { SessionProviderCoordinator } from './provider.ts'
import { eventRecords, traceEventLog, traceLineage } from './tracing.ts'
export type * from './types.ts'
export type { Config, SessionQueryErrorCode } from './config.ts'
export {
SESSION_QUERY_DEFAULT_LIMIT,
SESSION_QUERY_MAX_LIMIT,
SESSION_QUERY_READ_WINDOW_MAX,
SessionQueryError,
} from './config.ts'
export { filterEventResults, filterSessionResults } from './filters.ts'
export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts'
declare module 'cordis' {
interface Context {
@@ -55,35 +31,25 @@ declare module 'cordis' {
}
}
/** Session-history retrieval and provider coordination service. */
/** Live-preferred logical-corpus and exact-event read service. */
export class SessionQueryService extends Service {
static inject = ['sessions']
static Config: z<Config> = z.object({
searchProvider: z.string(),
defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_DEFAULT_LIMIT),
maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_MAX_LIMIT),
readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX),
})
private readonly _readWindowMax: number
private readonly _extractors: SessionTextExtractors
private readonly _providers: SessionProviderCoordinator
private readonly _corpus: SessionCorpus
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'sessionQuery')
const defaultLimit = config.defaultLimit ?? SESSION_QUERY_DEFAULT_LIMIT
const maxLimit = config.maxLimit ?? SESSION_QUERY_MAX_LIMIT
this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX
if (defaultLimit > maxLimit) {
throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG')
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._extractors = new SessionTextExtractors()
this._providers = new SessionProviderCoordinator({
...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {},
defaultLimit,
maxLimit,
}, () => this._corpus, this._extractors)
this._corpus = new SessionCorpus(ctx)
}
@@ -101,7 +67,7 @@ export class SessionQueryService extends Service {
* @returns event records in ascending seq order.
*/
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
const loaded = await this._corpus.loadLogical(sessionId)
const loaded = await this._corpus.load(sessionId)
return eventRecords(sessionId, loaded.events)
}
@@ -113,113 +79,58 @@ 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.loadLogical(request.sessionId)
const loaded = await this._corpus.load(request.sessionId)
const target = loaded.events[request.seq]
if (target === undefined || target.seq !== request.seq) {
throw new SessionQueryError(`session "${request.sessionId}" has no event at seq ${request.seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND')
throw new SessionQueryError(
`session "${request.sessionId}" has no event at seq ${request.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)
return {
session: cloneRecord(loaded.record),
target: structuredClone(target),
events: loaded.events.slice(startSeq, endSeq + 1).map(event => structuredClone(event)),
session: loaded.header,
target,
events: loaded.events.slice(startSeq, endSeq + 1),
startSeq,
endSeq,
}
}
/**
* Trace parent ancestry and the complete known descendant tree of a session.
* @param sessionId - logical session id to trace.
* @returns complete or explicitly partial lineage.
*/
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace> {
return traceLineage(await this._corpus.listSessions(), sessionId)
}
/**
* Trace direct provenance and surface replacement relationships for any event.
* @param sessionId - logical session containing the target.
* @param seq - target event seq.
* @returns lightweight trace with related seq links.
*/
async traceEvent(sessionId: SessionId, seq: number): Promise<SessionEventTrace> {
return traceEventLog(sessionId, (await this._corpus.loadLogical(sessionId)).events, seq)
}
/**
* Register one full-text provider with effect-scoped disposal.
* @param provider - provider and synchronization implementation.
* @returns async disposer that immediately unregisters selection and awaits accepted provider work.
*/
registerSearchProvider(provider: SessionSearchProvider): () => Promise<void> {
return this._providers.register(this.ctx, provider)
}
/**
* Register semantic text extraction for one event type.
* @param type - declaration-merged event discriminant.
* @param extractor - stable version and typed extraction callback.
* @returns disposer that removes the extractor.
*/
registerEventTextExtractor<K extends SessionEventType>(
type: K,
extractor: SessionEventTextExtractor<K>,
): () => void {
return this._extractors.registerEvent(this.ctx, type, extractor)
}
/**
* Register semantic text extraction for one content block type.
* @param type - declaration-merged content-block discriminant.
* @param extractor - stable version and typed extraction callback.
* @returns disposer that removes the extractor.
*/
registerContentTextExtractor<K extends ContentBlockType>(
type: K,
extractor: SessionContentTextExtractor<K>,
): () => void {
return this._extractors.registerContent(this.ctx, type, extractor)
}
/**
* Search the complete logical corpus and rank one result per session.
* @param request - query, pre-ranking filters, and pagination.
* @param exec - optional cancellation context.
* @returns ranked provider page.
*/
searchSessions(
request: SessionSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionSearchHit>> {
return this._providers.searchSessions(request, exec)
}
/**
* Search events within one logical session.
* @param request - target session, query, filters, and pagination.
* @param exec - optional cancellation context.
* @returns ranked provider page.
*/
searchEvents(
request: SessionEventSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
return this._providers.searchEvents(request, exec)
}
private _readWindow(name: 'before' | 'after', value: number | undefined): number {
if (value === undefined) return 0
if (!Number.isInteger(value) || value < 0 || value > this._readWindowMax) {
throw new SessionQueryError(`${name} must be an integer between 0 and ${this._readWindowMax}`, 'SESSION_QUERY_INVALID_WINDOW')
throw new SessionQueryError(
`${name} must be an integer between 0 and ${this._readWindowMax}`,
'SESSION_QUERY_INVALID_WINDOW',
)
}
return value
}
}
function cloneRecord(record: SessionRecord): SessionRecord {
return { ...record, header: structuredClone(record.header) }
function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] {
let folded: ReturnType<typeof foldSurface>
try {
folded = foldSurface(events)
} catch (error: unknown) {
throw new SessionQueryError(
/* v8 ignore next -- foldSurface throws Error instances */
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
'SESSION_QUERY_INVALID_SURFACE',
{ cause: error },
)
}
const current = new Set(folded.nodes.map(node => node.seq))
const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs))
return events.map(event => ({
sessionId,
seq: event.seq,
type: event.type,
time: event.time,
surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only',
}))
}
export default SessionQueryService

View File

@@ -1,310 +0,0 @@
/** Search-provider selection, synchronization, pagination, and cancellation. */
import type { Context } from 'cordis'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionTextExtractors } from './extraction.ts'
import type { PersistenceView, SessionCorpus } from './corpus.ts'
import type {
SessionEventRecord,
SessionEventSearchHit,
SessionEventSearchRequest,
SessionEventSearchSpec,
SessionQueryExecContext,
SessionRecord,
SessionSearchHit,
SessionSearchPage,
SessionSearchProvider,
SessionSearchRequest,
SessionSearchSpec,
} from './types.ts'
import type { Config } from './config.ts'
import { SessionQueryError } from './config.ts'
import { filterEventResults, filterSessionResults } from './filters.ts'
interface ProviderState {
provider: SessionSearchProvider
chain: Promise<void>
liveIds: Set<SessionId>
}
/** Coordinates one selected provider against live and persisted corpus layers. */
export class SessionProviderCoordinator {
private readonly _configuredProviderId: string | undefined
private readonly _defaultLimit: number
private readonly _maxLimit: number
private readonly _providers = new Map<string, ProviderState>()
constructor(
config: Required<Pick<Config, 'defaultLimit' | 'maxLimit'>> & Pick<Config, 'searchProvider'>,
private readonly _corpus: () => SessionCorpus,
private readonly _extractors: SessionTextExtractors,
) {
this._configuredProviderId = config.searchProvider
this._defaultLimit = config.defaultLimit
this._maxLimit = config.maxLimit
}
/**
* Register one effect-scoped provider.
* @param ctx - contributing caller context.
* @param provider - provider implementation.
* @returns async disposer that deselects immediately and drains accepted work.
*/
register(ctx: Context, provider: SessionSearchProvider): () => Promise<void> {
if (this._providers.has(provider.id)) {
throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER')
}
const state: ProviderState = {
provider,
chain: Promise.resolve(),
liveIds: new Set(),
}
const dispose = ctx.effect(function* (this: SessionProviderCoordinator) {
this._providers.set(provider.id, state)
yield async () => {
this._providers.delete(provider.id)
await state.chain
}
}.bind(this), 'sessionQuery.registerSearchProvider()')
return async () => { await dispose() }
}
/**
* Search and group the complete logical corpus.
* @param request - normalized provider-neutral request input.
* @param exec - optional cancellation controls.
* @returns ranked session page.
*/
async searchSessions(
request: SessionSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionSearchHit>> {
const state = this._resolveProvider()
const normalized = this._normalizeSessionSearch(request)
const work = this._runFullSearch(state, undefined, async () => {
if (exec?.signal?.aborted) throw aborted()
const result = await state.provider.searchSessions(normalized, exec)
return this._validateSearchPage(state, result, normalized.limit)
})
return waitFor(work, exec?.signal)
}
/**
* Search events within one logical session.
* @param request - target and provider-neutral request input.
* @param exec - optional cancellation controls.
* @returns ranked event page.
*/
async searchEvents(
request: SessionEventSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
const state = this._resolveProvider()
const normalized = this._normalizeEventSearch(request)
const query = async (): Promise<SessionSearchPage<SessionEventSearchHit>> => {
if (exec?.signal?.aborted) throw aborted()
const result = await state.provider.searchEvents(normalized, exec)
return this._validateSearchPage(state, result, normalized.limit)
}
const live = this._corpus().getLive(request.sessionId)
let work: Promise<SessionSearchPage<SessionEventSearchHit>>
if (live !== undefined) {
work = this._runLiveSearch(state, live, query)
} else {
work = this._runFullSearch(state, request.sessionId, query)
}
return waitFor(work, exec?.signal)
}
private _runFullSearch<T>(
state: ProviderState,
requiredSessionId: SessionId | undefined,
query: () => Promise<T>,
): Promise<T> {
const liveSessions = this._corpus().listLive()
return this._serialize(state, async () => {
await this._synchronize(state, async () => {
const persistence = await this._corpus().persistenceView()
const missingRequired = requiredSessionId !== undefined
&& (persistence === undefined || !persistence.headers.some(header => header.id === requiredSessionId))
if (missingRequired) {
throw new SessionQueryError(`session "${requiredSessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
}
if (persistence === undefined) {
await state.provider.setPersistedActive(false)
} else {
await this._syncPersisted(state, persistence)
}
await this._replaceLiveCorpus(state, liveSessions)
})
return query()
})
}
private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise<void> {
await state.provider.setPersistedActive(false)
const inventory = new Map((await state.provider.persistedInventory()).map(entry => [entry.sessionId, entry.fingerprint]))
for (const header of persistence.headers) {
const snapshot = this._extractors.buildSnapshot(await persistence.load(header.id))
if (inventory.get(header.id) !== snapshot.fingerprint) await state.provider.replacePersisted(snapshot)
inventory.delete(header.id)
}
for (const staleId of inventory.keys()) await state.provider.removePersisted(staleId)
await state.provider.setPersistedActive(true)
}
private async _replaceLiveCorpus(state: ProviderState, sessions: readonly Session[]): Promise<void> {
const liveIds = new Set(sessions.map(session => session.id))
for (const staleId of state.liveIds) {
if (!liveIds.has(staleId)) await state.provider.removeLive(staleId)
}
for (const session of sessions) {
await state.provider.replaceLive(this._snapshotLive(session))
}
state.liveIds = liveIds
}
private _runLiveSearch<T>(state: ProviderState, session: Session, query: () => Promise<T>): Promise<T> {
let snapshot: ReturnType<SessionTextExtractors['buildSnapshot']>
try {
snapshot = this._snapshotLive(session)
} catch (error: unknown) {
return Promise.reject(this._synchronizationError(state, error))
}
return this._serialize(state, async () => {
await this._synchronize(state, async () => {
await state.provider.replaceLive(snapshot)
state.liveIds.add(session.id)
})
return query()
})
}
private _snapshotLive(session: Session): ReturnType<SessionTextExtractors['buildSnapshot']> {
return this._extractors.buildSnapshot(this._corpus().snapshotLive(session))
}
/** Serialize reconciliation and its provider query as one stable transaction. */
private _serialize<T>(state: ProviderState, operation: () => Promise<T>): Promise<T> {
const next = state.chain.then(operation, operation)
state.chain = next.then(() => undefined, () => undefined)
return next
}
/** Translate only derived-index update failures, never provider query failures. */
private async _synchronize(state: ProviderState, operation: () => Promise<void>): Promise<void> {
try {
await operation()
} catch (error: unknown) {
throw this._synchronizationError(state, error)
}
}
private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError {
/* v8 ignore next -- service-created typed synchronization errors pass through unchanged */
if (error instanceof SessionQueryError) return error
return new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error })
}
private _resolveProvider(): ProviderState {
if (this._configuredProviderId !== undefined) {
const state = this._providers.get(this._configuredProviderId)
if (state === undefined) {
throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is not registered`, 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING')
}
if (!state.provider.status().available) {
throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is unavailable`, 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE')
}
return state
}
const usable = [...this._providers.values()].filter(state => state.provider.status().available)
const [single] = usable
if (single === undefined) {
throw new SessionQueryError('no usable session-query provider is registered', 'SESSION_QUERY_PROVIDER_UNAVAILABLE')
}
if (usable.length > 1) {
throw new SessionQueryError(`multiple usable session-query providers are registered (${usable.map(state => state.provider.id).join(', ')}); configure one explicitly`, 'SESSION_QUERY_PROVIDER_AMBIGUOUS')
}
return single
}
private _normalizeSessionSearch(request: SessionSearchRequest): SessionSearchSpec {
const query = this._queryText(request.query)
const limit = this._limitValue(request.limit)
filterSessionResults<SessionRecord>([], request.sessionFilters ?? [])
filterEventResults<SessionEventRecord>([], request.eventFilters ?? [])
return { ...request, query, limit }
}
private _normalizeEventSearch(request: SessionEventSearchRequest): SessionEventSearchSpec {
const query = this._queryText(request.query)
const limit = this._limitValue(request.limit)
filterEventResults<SessionEventRecord>([], request.filters ?? [])
return { ...request, query, limit }
}
private _queryText(query: string): string {
const normalized = query.trim()
if (normalized.length === 0) {
throw new SessionQueryError('session-query search text must not be blank', 'SESSION_QUERY_INVALID_QUERY')
}
return normalized
}
private _limitValue(limit: number | undefined): number {
const value = limit ?? this._defaultLimit
if (!Number.isInteger(value) || value < 1 || value > this._maxLimit) {
throw new SessionQueryError(`session-query limit must be an integer between 1 and ${this._maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT')
}
return value
}
private _validateSearchPage<T>(state: ProviderState, page: SessionSearchPage<T>, limit: number): SessionSearchPage<T> {
if (page.providerId !== state.provider.id) {
throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR')
}
if (page.items.length > limit) {
throw new SessionQueryError(`session-query provider "${state.provider.id}" returned ${page.items.length} items for limit ${limit}`, 'SESSION_QUERY_PROVIDER_ERROR')
}
return page
}
}
function waitFor<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
const observed = work.catch((error: unknown) => { throw operationError(error) })
if (signal === undefined) return observed
if (signal.aborted) {
// Cancellation supersedes the caller's result, but shared work must still
// have a rejection observer when it has already failed synchronously.
void observed.catch((_supersededError: unknown) => undefined)
return Promise.reject(aborted())
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => { reject(aborted()) }
signal.addEventListener('abort', onAbort, { once: true })
observed.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(operationError(error))
},
)
})
}
function operationError(error: unknown): Error {
if (error instanceof Error) return error
return new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error })
}
function aborted(): SessionQueryError {
return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED')
}
function errorMessage(error: unknown): string {
/* v8 ignore next -- provider update contracts reject Error instances */
return error instanceof Error ? error.message : 'unknown error'
}

View File

@@ -1,158 +0,0 @@
/** Session lineage and event surface/provenance tracing. */
import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {
SessionEventRecord,
SessionEventTrace,
SessionLineageNode,
SessionLineageTrace,
SessionRecord,
} from './types.ts'
import { SessionQueryError } from './config.ts'
/**
* Classify raw events against the canonical surface fold.
* @param sessionId - owner of the event log.
* @param events - detached raw log.
* @returns lightweight records in seq order.
*/
export function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] {
const fold = safeFold(events)
const current = new Set(fold.nodes.map(node => node.seq))
const shadowed = new Set(fold.replacements.flatMap(replacement => replacement.shadowedSeqs))
return events.map(event => ({
sessionId,
seq: event.seq,
type: event.type,
time: event.time,
surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only',
}))
}
/**
* Build one event trace from a validated logical event log.
* @param sessionId - owner of the event log.
* @param events - detached raw log.
* @param seq - target event seq.
* @returns direct provenance and replacement relationships.
*/
export function traceEventLog(sessionId: SessionId, events: readonly SessionEvent[], seq: number): SessionEventTrace {
const target = events[seq]
if (target === undefined || target.seq !== seq) {
throw new SessionQueryError(`session "${sessionId}" has no event at seq ${seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND')
}
const records = eventRecords(sessionId, events)
const fold = safeFold(events)
const shadowedBy = new Map<number, number>()
const shadows = new Map<number, number[]>()
for (const replacement of fold.replacements) {
shadows.set(replacement.seq, [...replacement.shadowedSeqs])
for (const shadowed of replacement.shadowedSeqs) shadowedBy.set(shadowed, replacement.seq)
}
const references: number[] = []
const referencedBy: number[] = []
for (const event of events) {
if (!isSurfaceEvent(event)) continue
for (const source of event.sourceEventSeqs ?? []) {
if (event.seq === seq) references.push(source)
if (source === seq) referencedBy.push(event.seq)
}
}
const replacementChain: number[] = []
let replacement = shadowedBy.get(seq)
while (replacement !== undefined) {
replacementChain.push(replacement)
replacement = shadowedBy.get(replacement)
}
const immediate = shadowedBy.get(seq)
// seq was checked against the contiguous event log, so its parallel record exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const targetRecord = records[seq]!
return {
target: { ...targetRecord },
...immediate !== undefined ? { shadowedBy: immediate } : {},
replacementChain,
shadows: shadows.get(seq) ?? [],
references,
referencedBy,
}
}
/**
* Trace ancestry and descendants within one materialized logical corpus.
* @param records - complete visible logical corpus.
* @param sessionId - target session id.
* @returns complete known lineage or explicit unresolved parent.
*/
export function traceLineage(records: readonly SessionRecord[], sessionId: SessionId): SessionLineageTrace {
const byId = new Map(records.map(record => [record.header.id, record]))
const target = byId.get(sessionId)
if (target === undefined) {
throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
}
const parents: SessionRecord[] = []
const ancestrySeen = new Set<SessionId>([sessionId])
let unresolvedParentId: SessionId | undefined
let parentId = target.header.parentSession
while (parentId !== undefined) {
if (ancestrySeen.has(parentId)) lineageCycle(parentId)
ancestrySeen.add(parentId)
const parent = byId.get(parentId)
if (parent === undefined) {
unresolvedParentId = parentId
break
}
parents.push(parent)
parentId = parent.header.parentSession
}
const childrenByParent = new Map<SessionId, SessionRecord[]>()
for (const record of records) {
const parent = record.header.parentSession
if (parent === undefined) continue
const children = childrenByParent.get(parent) ?? []
children.push(record)
childrenByParent.set(parent, children)
}
for (const children of childrenByParent.values()) children.sort(compareSessionsAscending)
const buildChildren = (id: SessionId): SessionLineageNode[] => (childrenByParent.get(id) ?? []).map(child => ({
session: cloneRecord(child),
children: buildChildren(child.header.id),
}))
return {
target: cloneRecord(target),
parents: parents.map(cloneRecord),
...unresolvedParentId !== undefined
? { unresolvedParentId }
: { root: cloneRecord(parents.at(-1) ?? target) },
children: buildChildren(sessionId),
}
}
function safeFold(events: readonly SessionEvent[]): ReturnType<typeof foldSurface> {
try {
return foldSurface(events)
} catch (error: unknown) {
throw new SessionQueryError(`invalid session surface: ${errorMessage(error)}`, 'SESSION_QUERY_INVALID_SURFACE', { cause: error })
}
}
function cloneRecord(record: SessionRecord): SessionRecord {
return { ...record, header: structuredClone(record.header) }
}
function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number {
return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id)
}
function lineageCycle(id: SessionId): never {
throw new SessionQueryError(`session lineage contains a cycle at "${id}"`, 'SESSION_QUERY_INVALID_LINEAGE')
}
function errorMessage(error: unknown): string {
/* v8 ignore next -- foldSurface throws Error instances */
return error instanceof Error ? error.message : 'unknown error'
}

View File

@@ -1,31 +1,24 @@
/**
* Public vocabulary for the session-query retrieval service: lightweight
* records, composable filters, traces, search requests/results, extractor
* registrations, and the provider synchronization contract.
* Public records for exact reads over the live-preferred logical session corpus.
*
* @module @deepseek-ai/dsh-session-query/types
*/
import type { ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm'
import type {
SessionEvent,
SessionEventType,
SessionHeader,
SessionId,
} from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
/** Whether an event is on the current surface, was replaced, or is log-only. */
/** Whether an event is current model context, replaced context, or raw-log-only. */
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
/** Lightweight identity and availability for one logical session. */
/** Lightweight identity and source availability for one logical session. */
export interface SessionRecord {
/** Cloned immutable session header selected from the live-preferred corpus. */
/** Cloned session header selected from the live-preferred corpus. */
header: SessionHeader
/** Whether the id currently exists in `ctx.sessions`. */
live: boolean
/** Whether the active persistence backend currently materializes the id. */
persisted: boolean
}
/** Lightweight metadata for one event within a logical session. */
export interface SessionEventRecord {
/** Session that owns the event. */
@@ -40,102 +33,6 @@ export interface SessionEventRecord {
surface: SessionEventSurface
}
/** Inclusive numeric range used by result and search filters. */
export interface SessionQueryRange {
/** Inclusive lower bound. */
from?: number
/** Inclusive upper bound. */
to?: number
}
/** Serializable filter applied to session records. */
export type SessionResultFilter =
| { kind: 'id'; values: readonly SessionId[] }
| { kind: 'cwd'; values: readonly (string | null)[] }
| { kind: 'created-at'; range: SessionQueryRange }
| { kind: 'parent'; values: readonly (SessionId | null)[] }
| { kind: 'availability'; values: readonly ('live' | 'persisted')[] }
/** Serializable filter applied to event records. */
export type SessionEventResultFilter =
| { kind: 'seq'; range: SessionQueryRange }
| { kind: 'time'; range: SessionQueryRange }
| { kind: 'type'; values: readonly SessionEventType[] }
| { kind: 'surface'; values: readonly SessionEventSurface[] }
/** Caller cancellation threaded through synchronization and provider search. */
export interface SessionQueryExecContext {
/** Abort signal for waiting and provider-owned query work. */
readonly signal?: AbortSignal
}
/** Cheap local usability status returned by a search provider. */
export type SessionSearchProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' }
/** Common pagination fields accepted by both search scopes. */
export interface SessionSearchPageRequest {
/** Maximum number of hits on this page. */
limit?: number
/** Opaque cursor returned by the same provider/request. */
cursor?: string
}
/** Cross-session full-text request. */
export interface SessionSearchRequest extends SessionSearchPageRequest {
/** Plain text query interpreted by the selected provider. */
query: string
/** Session metadata filters applied before event ranking/grouping. */
sessionFilters?: readonly SessionResultFilter[]
/** Event metadata filters applied before best-event grouping. */
eventFilters?: readonly SessionEventResultFilter[]
}
/** Full-text request scoped to one session's events. */
export interface SessionEventSearchRequest extends SessionSearchPageRequest {
/** Session whose events form the search corpus. */
sessionId: SessionId
/** Plain text query interpreted by the selected provider. */
query: string
/** Event metadata filters applied before ranking. */
filters?: readonly SessionEventResultFilter[]
}
/** Provider-facing cross-session search spec after service normalization. */
export interface SessionSearchSpec extends SessionSearchRequest {
/** Required page size validated and defaulted by the query service. */
limit: number
}
/** Provider-facing event search spec after service normalization. */
export interface SessionEventSearchSpec extends SessionEventSearchRequest {
/** Required page size validated and defaulted by the query service. */
limit: number
}
/** One lightweight event search hit with provider-produced evidence text. */
export interface SessionEventSearchHit extends SessionEventRecord {
/** Plain-text excerpt explaining the match. */
snippet: string
}
/** One session-ranked search hit and its strongest matching event. */
export interface SessionSearchHit extends SessionRecord {
/** Strongest matching event used as the session's ranking evidence. */
bestMatch: SessionEventSearchHit
}
/** One provider-owned page of search results. */
export interface SessionSearchPage<T> {
/** Stable id of the provider that produced this page. */
providerId: string
/** Ranked hits in deterministic provider order, no longer than the requested limit. */
items: readonly T[]
/** Opaque next-page cursor, absent when the result is exhausted. */
nextCursor?: string
}
/** Request for one event plus raw neighboring log context. */
export interface SessionEventReadRequest {
/** Session that owns the target event. */
@@ -150,8 +47,8 @@ export interface SessionEventReadRequest {
/** Full target event and a bounded raw-log window. */
export interface SessionEventWindow {
/** Logical session metadata at read time. */
session: SessionRecord
/** Cloned header for the live-preferred source read. */
session: SessionHeader
/** Full cloned target event. */
target: SessionEvent
/** Full cloned events from `startSeq` through `endSeq`. */
@@ -161,144 +58,3 @@ export interface SessionEventWindow {
/** Last seq included in `events`. */
endSeq: number
}
/** Recursive child node in a session lineage trace. */
export interface SessionLineageNode {
/** Session represented by this lineage node. */
session: SessionRecord
/** Direct children in deterministic creation order. */
children: SessionLineageNode[]
}
/** Complete known lineage around one session. */
export interface SessionLineageTrace {
/** Session that was traced. */
target: SessionRecord
/** Known parents from immediate parent outward. */
parents: SessionRecord[]
/** Root when the complete parent chain is available. */
root?: SessionRecord
/** First parent id outside the visible corpus, when the trace is partial. */
unresolvedParentId?: SessionId
/** Complete known descendant forest rooted at the target's direct children. */
children: SessionLineageNode[]
}
/** Surface and provenance relationships for one event. */
export interface SessionEventTrace {
/** Lightweight target record. */
target: SessionEventRecord
/** Immediate replacement event that shadowed the target. */
shadowedBy?: number
/** Replacement seqs from the target toward the current descendant. */
replacementChain: number[]
/** Surface nodes directly shadowed by the target replacement event. */
shadows: number[]
/** Direct provenance sources from `sourceEventSeqs`. */
references: number[]
/** Events that directly name the target in `sourceEventSeqs`. */
referencedBy: number[]
}
/** Typed extractor for one declaration-merged session event type. */
export interface SessionEventTextExtractor<K extends SessionEventType = SessionEventType> {
/** Stable cache-invalidation version chosen by the extractor owner. */
version: string
/**
* Extract semantic searchable fragments from one event.
* @param event - event narrowed to the registered type.
* @returns plain-text fragments; blanks are discarded by the service.
*/
extract(event: SessionEvent<K>): readonly string[]
}
/** Typed extractor for one declaration-merged content block type. */
export interface SessionContentTextExtractor<K extends ContentBlockType = ContentBlockType> {
/** Stable cache-invalidation version chosen by the extractor owner. */
version: string
/**
* Extract semantic searchable fragments from one content block.
* @param block - block narrowed to the registered type.
* @returns plain-text fragments; blanks are discarded by the service.
*/
extract(block: ContentBlockMap[K]): readonly string[]
}
/** One provider-neutral event document produced by registered extractors. */
export interface SessionIndexDocument extends SessionEventRecord {
/** Normalized newline-joined text indexed by a search provider. */
text: string
}
/** One complete index layer for a live session or persisted checkpoint. */
export interface SessionIndexSnapshot {
/** Layer metadata and live/persisted availability exposed in results. */
session: SessionRecord
/** Stable SHA-256 identity of canonical source data and extractor versions. */
fingerprint: string
/** Searchable event documents in seq order. */
documents: readonly SessionIndexDocument[]
}
/** Durable provider inventory entry used to reuse unchanged persisted rows. */
export interface SessionPersistedIndexEntry {
/** Persisted session id. */
sessionId: SessionId
/** Last indexed source/extractor fingerprint. */
fingerprint: string
}
/** Search and synchronization backend registered into `ctx.sessionQuery`. */
export interface SessionSearchProvider {
/** Stable provider id, unique within the query service. */
readonly id: string
/**
* Return cheap local usability without performing index or search I/O.
* @returns whether the provider can be selected.
*/
status(): SessionSearchProviderStatus
/**
* Read reusable persisted-layer fingerprints from derived storage.
* @returns durable inventory entries.
*/
persistedInventory(): Promise<readonly SessionPersistedIndexEntry[]>
/**
* Hide or expose reconciled persisted rows without deleting their cache.
* @param active - whether canonical persistence is mounted and reconciled.
*/
setPersistedActive(active: boolean): Promise<void>
/**
* Atomically replace one persisted session's derived documents.
* @param snapshot - canonical persisted checkpoint and fingerprint.
*/
replacePersisted(snapshot: SessionIndexSnapshot): Promise<void>
/**
* Delete one durable derived entry after canonical reconciliation proves it absent.
* @param sessionId - persisted id to remove.
*/
removePersisted(sessionId: SessionId): Promise<void>
/**
* Replace one connection-local live override.
* @param snapshot - current live snapshot and availability.
*/
replaceLive(snapshot: SessionIndexSnapshot): Promise<void>
/**
* Drop one live override, revealing its active persisted base when present.
* @param sessionId - live id to remove.
*/
removeLive(sessionId: SessionId): Promise<void>
/**
* Search and group the complete logical corpus by session.
* @param request - query, pre-ranking filters, and pagination.
* @param exec - optional cancellation context.
* @returns one ranked session page.
*/
searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionSearchHit>>
/**
* Search events within one logical session.
* @param request - target session, query, filters, and pagination.
* @param exec - optional cancellation context.
* @returns one ranked event page.
*/
searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionEventSearchHit>>
}