mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(session-query): harden SQLite search reconciliation
This commit is contained in:
@@ -7,6 +7,7 @@ This is trusted context-wide infrastructure. It performs no caller authorization
|
||||
## Reads
|
||||
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
|
||||
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
|
||||
@@ -21,7 +22,7 @@ The text clause is deliberately independent of FTS providers: caller text is esc
|
||||
|
||||
## Full-text seam
|
||||
|
||||
`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return opaque cursor pages, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
|
||||
`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
|
||||
|
||||
The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md).
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
@@ -36,6 +37,7 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Live/persisted logical-corpus resolution for session-query. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionRecord } from './types.ts'
|
||||
@@ -18,18 +18,19 @@ export interface LogicalSession {
|
||||
/** Resolves a live-preferred corpus against the persistence service mounted now. */
|
||||
export class SessionCorpus {
|
||||
private _persistence: SessionPersistence | undefined
|
||||
private readonly _optionalPersistenceFiber: Fiber
|
||||
|
||||
constructor(private readonly _ctx: Context) {
|
||||
this._optionalPersistenceFiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
const service = childCtx.sessionPersistence
|
||||
this._persistence = service
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
|
||||
if (this._persistence === service) this._persistence = undefined
|
||||
}, 'sessionQuery.persistenceBinding')
|
||||
})
|
||||
_ctx.effect(() => {
|
||||
const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
const service = childCtx.sessionPersistence
|
||||
this._persistence = service
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
|
||||
if (this._persistence === service) this._persistence = undefined
|
||||
}, 'sessionQuery.persistenceBinding')
|
||||
})
|
||||
return () => void fiber.dispose()
|
||||
return () => this._optionalPersistenceFiber.dispose()
|
||||
}, 'sessionQuery.optionalPersistence')
|
||||
}
|
||||
|
||||
|
||||
15
packages/session-query/session-query/src/cursor.ts
Normal file
15
packages/session-query/session-query/src/cursor.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Opaque cursor identity for session-search pagination. */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Provider-owned opaque continuation token returned by session search. */
|
||||
export type SessionSearchCursor = Branded<'SessionSearchCursor'>
|
||||
|
||||
/**
|
||||
* Brand an encoded provider cursor for the public search contract.
|
||||
* @param value - opaque encoded cursor value.
|
||||
* @returns the same runtime string with session-search cursor identity.
|
||||
*/
|
||||
export function SessionSearchCursor(value: string): SessionSearchCursor {
|
||||
return value as SessionSearchCursor
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
/** Pure provider-independent predicates for logical sessions and event text. */
|
||||
|
||||
import type { SessionRecord, SessionEventSearchDocument, SessionEventResultFilter, SessionResultFilter, SessionResultRange } from './types.ts'
|
||||
import type {
|
||||
SessionEventResultFilter,
|
||||
SessionEventSearchDocument,
|
||||
SessionRecord,
|
||||
SessionResultFilter,
|
||||
SessionResultRange,
|
||||
} from './types.ts'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
|
||||
/**
|
||||
@@ -31,6 +37,66 @@ export function filterSessionEventDocuments<T extends SessionEventSearchDocument
|
||||
return documents.filter(document => predicates.every(predicate => predicate(document)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and validate logical-session filters before an asynchronous boundary.
|
||||
* @param filters - caller-owned clauses to materialize.
|
||||
* @returns detached validated clauses.
|
||||
*/
|
||||
export function materializeSessionResultFilters(
|
||||
filters: readonly SessionResultFilter[],
|
||||
): SessionResultFilter[] {
|
||||
assertArray(filters)
|
||||
return filters.map((filter) => {
|
||||
switch (filter.kind) {
|
||||
case 'id':
|
||||
return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) }
|
||||
case 'cwd':
|
||||
return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) }
|
||||
case 'created-at':
|
||||
return copyRange(filter.kind, filter)
|
||||
case 'parent':
|
||||
return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) }
|
||||
case 'availability': {
|
||||
const values = copyStrings(filter.kind, filter.values)
|
||||
assertAllowedValues(filter.kind, values, ['live', 'persisted'])
|
||||
return { kind: filter.kind, values }
|
||||
}
|
||||
default:
|
||||
return unknownFilter(filter)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and validate event filters before an asynchronous boundary.
|
||||
* @param filters - caller-owned clauses to materialize.
|
||||
* @returns detached validated clauses.
|
||||
*/
|
||||
export function materializeSessionEventResultFilters(
|
||||
filters: readonly SessionEventResultFilter[],
|
||||
): SessionEventResultFilter[] {
|
||||
assertArray(filters)
|
||||
return filters.map((filter) => {
|
||||
switch (filter.kind) {
|
||||
case 'seq':
|
||||
case 'time':
|
||||
return copyRange(filter.kind, filter)
|
||||
case 'type':
|
||||
return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) }
|
||||
case 'surface': {
|
||||
const values = copyStrings(filter.kind, filter.values)
|
||||
assertAllowedValues(filter.kind, values, ['current', 'shadowed', 'log-only'])
|
||||
return { kind: filter.kind, values }
|
||||
}
|
||||
case 'text':
|
||||
if (typeof filter.text !== 'string') throw invalidFilter('text filter text must be a string')
|
||||
return { kind: filter.kind, text: filter.text }
|
||||
default:
|
||||
return unknownFilter(filter)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a literal case-insensitive, whitespace-flexible semantic-text match.
|
||||
* @param text - caller-provided literal text.
|
||||
@@ -66,6 +132,8 @@ function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord)
|
||||
case 'availability':
|
||||
assertAllowedValues(filter.kind, filter.values, ['live', 'persisted'])
|
||||
return record => filter.values.some(value => value === 'live' ? record.live : record.persisted)
|
||||
default:
|
||||
return unknownFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +156,47 @@ function eventPredicate(filter: SessionEventResultFilter): (document: SessionEve
|
||||
const pattern = compileSessionTextFilter(filter.text)
|
||||
return document => pattern.test(document.text)
|
||||
}
|
||||
default:
|
||||
return unknownFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
function copyStrings<T extends string>(name: string, values: readonly T[]): T[] {
|
||||
if (!isRuntimeArray(values) || values.some(value => typeof value !== 'string')) {
|
||||
throw invalidFilter(`${name} filter values must be an array of strings`)
|
||||
}
|
||||
return [...values]
|
||||
}
|
||||
|
||||
function assertArray(value: unknown): void {
|
||||
if (!Array.isArray(value)) throw invalidFilter('filters must be an array')
|
||||
}
|
||||
|
||||
function copyNullableStrings<T extends string>(name: string, values: readonly (T | null)[]): Array<T | null> {
|
||||
if (!isRuntimeArray(values) || values.some(value => value !== null && typeof value !== 'string')) {
|
||||
throw invalidFilter(`${name} filter values must be an array of strings or null`)
|
||||
}
|
||||
return [...values]
|
||||
}
|
||||
|
||||
function copyRange<K extends 'created-at' | 'seq' | 'time'>(
|
||||
kind: K,
|
||||
range: SessionResultRange,
|
||||
): { kind: K } & SessionResultRange {
|
||||
const copy = {
|
||||
kind,
|
||||
...range.from === undefined ? {} : { from: range.from },
|
||||
...range.to === undefined ? {} : { to: range.to },
|
||||
}
|
||||
validateRange(kind, copy)
|
||||
return copy
|
||||
}
|
||||
|
||||
function unknownFilter(filter: never): never {
|
||||
const kind = (filter as { kind?: unknown }).kind
|
||||
throw invalidFilter(`unknown filter kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`)
|
||||
}
|
||||
|
||||
function assertAllowedValues(
|
||||
name: string,
|
||||
values: readonly string[],
|
||||
@@ -125,8 +231,13 @@ function matchesRange(value: number, range: SessionResultRange): boolean {
|
||||
}
|
||||
|
||||
function invalidRange(name: string, detail: string): SessionQueryError {
|
||||
return new SessionQueryError(
|
||||
`session ${name} filter ${detail}`,
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
return invalidFilter(`${name} filter ${detail}`)
|
||||
}
|
||||
|
||||
function invalidFilter(detail: string): SessionQueryError {
|
||||
return new SessionQueryError(`session ${detail}`, 'SESSION_QUERY_INVALID_FILTER')
|
||||
}
|
||||
|
||||
function isRuntimeArray(value: unknown): boolean {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SessionEventSearchRequest,
|
||||
SessionEventWindow,
|
||||
SessionRecord,
|
||||
SessionResultFilter,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
SessionSearchPage,
|
||||
@@ -28,14 +29,26 @@ import {
|
||||
} from './config.ts'
|
||||
import { SessionCorpus } from './corpus.ts'
|
||||
import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
|
||||
import { filterSessionEventDocuments } from './filters.ts'
|
||||
import {
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
} from './filters.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export { SessionSearchCursor } from './cursor.ts'
|
||||
export type { Config, SessionQueryErrorCode } from './config.ts'
|
||||
export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts'
|
||||
export { extractSessionEventText } from './extraction.ts'
|
||||
export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
|
||||
export { compileSessionTextFilter, filterSessionEventDocuments, filterSessionResults } from './filters.ts'
|
||||
export {
|
||||
compileSessionTextFilter,
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
} from './filters.ts'
|
||||
export { assertSessionHeadersCompatible } from './sources.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -109,6 +122,16 @@ export class SessionQueryService extends Service {
|
||||
return this._corpus.listSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the complete logical corpus with provider-independent predicates.
|
||||
* @param filters - ANDed session metadata and availability clauses.
|
||||
* @returns matching cloned records in deterministic newest-first order.
|
||||
*/
|
||||
async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
|
||||
const ownedFilters = materializeSessionResultFilters(filters)
|
||||
return this._filterSessions(ownedFilters)
|
||||
}
|
||||
|
||||
/**
|
||||
* List lightweight raw-log event records for one logical session.
|
||||
* @param sessionId - live-preferred session id to read.
|
||||
@@ -128,6 +151,18 @@ export class SessionQueryService extends Service {
|
||||
async filterEvents(
|
||||
sessionId: SessionId,
|
||||
filters: readonly SessionEventResultFilter[],
|
||||
): Promise<SessionEventSearchDocument[]> {
|
||||
const ownedFilters = materializeSessionEventResultFilters(filters)
|
||||
return this._filterEvents(sessionId, ownedFilters)
|
||||
}
|
||||
|
||||
private async _filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
|
||||
return filterSessionResults(await this._corpus.listSessions(), filters)
|
||||
}
|
||||
|
||||
private async _filterEvents(
|
||||
sessionId: SessionId,
|
||||
filters: readonly SessionEventResultFilter[],
|
||||
): Promise<SessionEventSearchDocument[]> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
const documents = buildSessionEventSearchDocuments(sessionId, loaded.events)
|
||||
@@ -142,16 +177,27 @@ export class SessionQueryService extends Service {
|
||||
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow> {
|
||||
const before = this._readWindow('before', request.before)
|
||||
const after = this._readWindow('after', request.after)
|
||||
const loaded = await this._corpus.load(request.sessionId)
|
||||
const target = loaded.events[request.seq]
|
||||
if (target === undefined || target.seq !== request.seq) {
|
||||
const sessionId = request.sessionId
|
||||
const seq = request.seq
|
||||
return this._readEvent(sessionId, seq, before, after)
|
||||
}
|
||||
|
||||
private async _readEvent(
|
||||
sessionId: SessionId,
|
||||
seq: number,
|
||||
before: number,
|
||||
after: number,
|
||||
): Promise<SessionEventWindow> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
const target = loaded.events[seq]
|
||||
if (target === undefined || target.seq !== seq) {
|
||||
throw new SessionQueryError(
|
||||
`session "${request.sessionId}" has no event at seq ${request.seq}`,
|
||||
`session "${sessionId}" has no event at seq ${seq}`,
|
||||
'SESSION_QUERY_EVENT_NOT_FOUND',
|
||||
)
|
||||
}
|
||||
const startSeq = Math.max(0, request.seq - before)
|
||||
const endSeq = Math.min(loaded.events.length - 1, request.seq + after)
|
||||
const startSeq = Math.max(0, seq - before)
|
||||
const endSeq = Math.min(loaded.events.length - 1, seq + after)
|
||||
return {
|
||||
session: loaded.header,
|
||||
target,
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
*/
|
||||
|
||||
import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionSearchCursor } from './cursor.ts'
|
||||
|
||||
export type { SessionSearchCursor } from './cursor.ts'
|
||||
|
||||
/** Whether an event is current model context, replaced context, or raw-log-only. */
|
||||
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
|
||||
@@ -106,7 +109,7 @@ export interface SessionSearchPage<T> {
|
||||
/** Results for this page in contract-defined order. */
|
||||
items: readonly T[]
|
||||
/** Opaque continuation cursor, absent on the final page. */
|
||||
nextCursor?: string
|
||||
nextCursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** Controls shared by cross-session and within-session search calls. */
|
||||
@@ -126,7 +129,7 @@ export interface SessionSearchRequest {
|
||||
/** Maximum sessions in this page. */
|
||||
limit?: number
|
||||
/** Opaque cursor returned for the identical normalized request. */
|
||||
cursor?: string
|
||||
cursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** Within-session full-text search request. */
|
||||
@@ -140,7 +143,7 @@ export interface SessionEventSearchRequest {
|
||||
/** Maximum events in this page. */
|
||||
limit?: number
|
||||
/** Opaque cursor returned for the identical normalized request. */
|
||||
cursor?: string
|
||||
cursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** One event full-text search hit with a bounded plain-text excerpt. */
|
||||
|
||||
@@ -10,6 +10,8 @@ import SessionQueryService, {
|
||||
extractSessionEventText,
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
SessionSearchService,
|
||||
type SessionEventSearchHit,
|
||||
type SessionEventSearchRequest,
|
||||
@@ -177,6 +179,31 @@ describe('session-query document and filter helpers', () => {
|
||||
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it('owns filters and rejects malformed runtime filter shapes deterministically', () => {
|
||||
expect(materializeSessionResultFilters([{ kind: 'created-at', to: 2 }]))
|
||||
.toEqual([{ kind: 'created-at', to: 2 }])
|
||||
expect(() => materializeSessionResultFilters('not-an-array' as never))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{ kind: 'id', values: 'bad' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{ kind: 'id', values: [1] } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{ kind: 'cwd', values: 'bad' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{ kind: 'parent', values: [1] } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{} as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionEventResultFilters([{ kind: 'text', text: 1 } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionEventResultFilters([{ kind: 'future' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => filterSessionResults([], [{ kind: 'future' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => filterSessionEventDocuments([], [{ kind: 'future' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
})
|
||||
|
||||
it('exposes the scan path on the concrete exact-read service', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, {
|
||||
type SessionEventSurface,
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
@@ -59,6 +60,14 @@ class TestPersistence extends SessionPersistence {
|
||||
TestPersistence.afterList?.()
|
||||
return Promise.resolve(headers)
|
||||
}
|
||||
|
||||
|
||||
async listSnapshots() {
|
||||
return [...TestPersistence.entries.values()].map(entry => ({
|
||||
header: structuredClone(entry.meta),
|
||||
revision: SessionPersistenceRevision(`events:${entry.events.length}`),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function liveContext(config: ConstructorParameters<typeof SessionQueryService>[1] = {}): Promise<Context> {
|
||||
@@ -94,6 +103,38 @@ describe('session-query exact reads', () => {
|
||||
expect(older.header.createdAt).toBe(1)
|
||||
})
|
||||
|
||||
it('filters sessions symmetrically and owns mutable filter values immediately', async () => {
|
||||
const durable = header('durable-filter', 1)
|
||||
TestPersistence.reset([{ meta: durable, events: eventLog('durable') }])
|
||||
const ctx = await liveContext()
|
||||
const live = ctx.sessions.create(SessionId('live-filter'), { meta: { createdAt: 2 } })
|
||||
live.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
const persistence = await ctx.plugin(TestPersistence)
|
||||
|
||||
const ids = [durable.id]
|
||||
const filtered = ctx.sessionQuery.filterSessions([{ kind: 'id', values: ids }])
|
||||
ids[0] = live.id
|
||||
await expect(filtered).resolves.toEqual([{
|
||||
header: durable,
|
||||
live: false,
|
||||
persisted: true,
|
||||
}])
|
||||
|
||||
const surfaces: SessionEventSurface[] = ['current']
|
||||
const events = ctx.sessionQuery.filterEvents(live.id, [{ kind: 'surface', values: surfaces }])
|
||||
surfaces[0] = 'shadowed'
|
||||
await expect(events).resolves.toMatchObject([{ sessionId: live.id, surface: 'current', text: 'live' }])
|
||||
await expect(ctx.sessionQuery.filterSessions([{ kind: 'future' } as never]))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
await expect(ctx.sessionQuery.filterEvents(live.id, [{ kind: 'future' } as never]))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
await persistence.dispose()
|
||||
})
|
||||
|
||||
it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('surface'))
|
||||
@@ -267,4 +308,26 @@ describe('session-query exact reads', () => {
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits optional-persistence child-fiber quiescence on disposal', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const query = await ctx.plugin(SessionQueryService)
|
||||
const persistence = await ctx.plugin(TestPersistence)
|
||||
const optional = (ctx.sessionQuery as unknown as {
|
||||
_corpus: { _optionalPersistenceFiber: Fiber }
|
||||
})._corpus._optionalPersistenceFiber
|
||||
let release!: () => void
|
||||
const cleanup = new Promise<void>((resolve) => { release = resolve })
|
||||
optional.ctx.effect(() => () => cleanup)
|
||||
|
||||
let settled = false
|
||||
const disposing = query.dispose().then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
release()
|
||||
await disposing
|
||||
await persistence.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user