Merge master into codex/simp-session-log-representation
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
# session-query/ — session retrieval capability family
|
||||
|
||||
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.
|
||||
Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, bounded event reads, lineage, and direct event relationships.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` |
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus exact-read and relationship-tracing service | `ctx.sessionQuery` |
|
||||
|
||||
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.
|
||||
The family is independent of compaction: it reads canonical lineage, surface operations, and logged provenance but does not participate in compaction policy or execution. Full-text search remains a proposed SQLite package rather than a speculative provider seam in this interface package.
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
# @deepseek-ai/dsh-session-query
|
||||
|
||||
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`.
|
||||
Exact session-history retrieval and relationship tracing 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`.
|
||||
|
||||
## Reads
|
||||
|
||||
- `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`.
|
||||
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
|
||||
- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
|
||||
|
||||
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.
|
||||
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations.
|
||||
|
||||
`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`.
|
||||
`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.
|
||||
|
||||
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -25,4 +29,4 @@ None, as this trusted query service returns cloned session records only to its c
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No caller authorization** — this is trusted context-wide infrastructure; a future model tool or UI must constrain which sessions its caller may inspect.
|
||||
- **Exact retrieval only** — filters, lineage/provenance traversal, extraction, search-provider protocol, index synchronization, and a model-facing tool are absent. Full-text search belongs beside its first 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).
|
||||
- **No search or extraction** — filters, extraction registry, search-provider protocol, index synchronization, and a model-facing tool are absent. The [tracing decision](../../../docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md) owns relationship semantics; content-bearing full-text-search results and their chainable filters belong beside their first implementation in the proposed [SQLite package](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-query",
|
||||
"description": "Live-preferred exact session-history retrieval service (ctx.sessionQuery)",
|
||||
"description": "Live-preferred exact session-history retrieval and tracing service (ctx.sessionQuery)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -5,16 +5,17 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
/** Default maximum `before`/`after` raw-event window. */
|
||||
export const SESSION_QUERY_READ_WINDOW_MAX = 50
|
||||
|
||||
/** Configuration for exact session-query reads. */
|
||||
/** Configuration for exact session-query reads and traces. */
|
||||
export interface Config {
|
||||
/** Maximum accepted raw read context on either side. Defaults to 50. */
|
||||
readWindowMax?: number
|
||||
}
|
||||
|
||||
/** Stable machine-routable failure taxonomy for exact session reads. */
|
||||
/** Stable machine-routable failure taxonomy for exact session reads and traces. */
|
||||
export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_EVENT_NOT_FOUND'
|
||||
| 'SESSION_QUERY_INVALID_CONFIG'
|
||||
| 'SESSION_QUERY_INVALID_LINEAGE'
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* Exact session-history reads over live and optionally persisted logs.
|
||||
* Exact session-history reads and traces over live and optionally persisted logs.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-query
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionEventReadRequest,
|
||||
SessionEventRecord,
|
||||
SessionEventTrace,
|
||||
SessionEventTraceRequest,
|
||||
SessionEventWindow,
|
||||
SessionLineageTrace,
|
||||
SessionRecord,
|
||||
} from './types.ts'
|
||||
import {
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import { SessionCorpus } from './corpus.ts'
|
||||
import * as tracing from './tracing.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export type { Config, SessionQueryErrorCode } from './config.ts'
|
||||
@@ -31,7 +34,7 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Live-preferred logical-corpus and exact-event read service. */
|
||||
/** Live-preferred logical-corpus exact-read and relationship-tracing service. */
|
||||
export class SessionQueryService extends Service {
|
||||
static inject = ['sessions']
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -68,7 +71,29 @@ export class SessionQueryService extends Service {
|
||||
*/
|
||||
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return eventRecords(sessionId, loaded.events)
|
||||
return tracing.eventRecords(sessionId, loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace known ancestry and descendants from one corpus observation.
|
||||
* @param sessionId - logical session id to trace.
|
||||
* @returns a complete lineage or an explicit unresolved parent boundary.
|
||||
* @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.
|
||||
*/
|
||||
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace> {
|
||||
const records = await this._corpus.listSessions()
|
||||
return tracing.traceSession(records, sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace one event's direct positional and provenance relationships.
|
||||
* @param request - target session id and event seq.
|
||||
* @returns direct links plus the target's positional replacement chain.
|
||||
* @throws when source resolution fails, the target is absent, or surface/provenance validation fails.
|
||||
*/
|
||||
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace> {
|
||||
const loaded = await this._corpus.load(request.sessionId)
|
||||
return tracing.traceEvent(request.sessionId, loaded.events, request.seq)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,27 +135,4 @@ export class SessionQueryService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
222
packages/session-query/session-query/src/tracing.ts
Normal file
222
packages/session-query/session-query/src/tracing.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/** One-shot session-lineage and event-relationship tracing helpers. */
|
||||
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
import type {
|
||||
SessionEventRecord,
|
||||
SessionEventTrace,
|
||||
SessionLineageNode,
|
||||
SessionLineageTrace,
|
||||
SessionRecord,
|
||||
} from './types.ts'
|
||||
|
||||
interface EventLogAnalysis {
|
||||
records: SessionEventRecord[]
|
||||
replacedBy: Map<number, number>
|
||||
replacedEventSeqs: Map<number, number[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a raw event log with one canonical surface fold.
|
||||
* @param sessionId - owner of the event log.
|
||||
* @param events - detached raw event log.
|
||||
* @returns lightweight records in ascending log order.
|
||||
*/
|
||||
export function eventRecords(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEventRecord[] {
|
||||
return analyzeEventLog(sessionId, events).records
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace one target after one canonical surface fold and whole-log validation.
|
||||
* @param sessionId - owner of the event log.
|
||||
* @param events - detached raw event log.
|
||||
* @param seq - target event seq.
|
||||
* @returns direct surface and provenance relationships.
|
||||
*/
|
||||
export function traceEvent(
|
||||
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 analysis = analyzeEventLog(sessionId, events)
|
||||
|
||||
const replacementChain: number[] = []
|
||||
let replacement = analysis.replacedBy.get(seq)
|
||||
while (replacement !== undefined) {
|
||||
replacementChain.push(replacement)
|
||||
replacement = analysis.replacedBy.get(replacement)
|
||||
}
|
||||
|
||||
const derivedEventSeqs: number[] = []
|
||||
for (const event of events) {
|
||||
if (event.seq <= seq) continue
|
||||
if (eventSources(event).includes(seq)) derivedEventSeqs.push(event.seq)
|
||||
}
|
||||
|
||||
// The target check above proves the parallel record exists at this index.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const targetRecord = analysis.records[seq]!
|
||||
const replacedBy = analysis.replacedBy.get(seq)
|
||||
return {
|
||||
target: targetRecord,
|
||||
...replacedBy === undefined ? {} : { replacedBy },
|
||||
replacementChain,
|
||||
replacedEventSeqs: analysis.replacedEventSeqs.get(seq) ?? [],
|
||||
sourceEventSeqs: [...eventSources(target)],
|
||||
derivedEventSeqs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace one target's known ancestry and recursively known descendants.
|
||||
* @param records - complete logical corpus from one observation.
|
||||
* @param sessionId - target session id.
|
||||
* @returns complete or explicitly partial lineage.
|
||||
*/
|
||||
export function traceSession(
|
||||
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 ancestors: SessionRecord[] = []
|
||||
const ancestrySeen = new Set<SessionId>([sessionId])
|
||||
let unresolvedParentId: SessionId | undefined
|
||||
let parentId = target.header.parentSession
|
||||
while (parentId !== undefined) {
|
||||
if (ancestrySeen.has(parentId)) {
|
||||
throw new SessionQueryError(
|
||||
`session lineage contains a cycle at "${parentId}"`,
|
||||
'SESSION_QUERY_INVALID_LINEAGE',
|
||||
)
|
||||
}
|
||||
ancestrySeen.add(parentId)
|
||||
const parent = byId.get(parentId)
|
||||
if (parent === undefined) {
|
||||
unresolvedParentId = parentId
|
||||
break
|
||||
}
|
||||
ancestors.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((a, b) => a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id))
|
||||
}
|
||||
|
||||
const descendants = buildDescendants(childrenByParent, sessionId)
|
||||
const common = {
|
||||
target: cloneRecord(target),
|
||||
ancestors: ancestors.map(cloneRecord),
|
||||
descendants,
|
||||
}
|
||||
if (unresolvedParentId !== undefined) {
|
||||
return { ...common, complete: false, unresolvedParentId }
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
complete: true,
|
||||
root: cloneRecord(ancestors.at(-1) ?? target),
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeEventLog(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): EventLogAnalysis {
|
||||
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)
|
||||
const replacedBy = new Map<number, number>()
|
||||
const replacedEventSeqs = new Map<number, number[]>()
|
||||
for (const replacement of folded.replacements) {
|
||||
const removed = replacement.shadowedSeqs
|
||||
replacedEventSeqs.set(replacement.seq, removed)
|
||||
for (const removedSeq of removed) {
|
||||
replacedBy.set(removedSeq, replacement.seq)
|
||||
}
|
||||
}
|
||||
return {
|
||||
records: events.map(event => ({
|
||||
sessionId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
time: event.time,
|
||||
surface: current.has(event.seq)
|
||||
? 'current'
|
||||
: replacedBy.has(event.seq) ? 'shadowed' : 'log-only',
|
||||
})),
|
||||
replacedBy,
|
||||
replacedEventSeqs,
|
||||
}
|
||||
}
|
||||
|
||||
function eventSources(event: SessionEvent): readonly number[] {
|
||||
return (event as SessionEvent<SurfaceEventType>).sourceEventSeqs ?? []
|
||||
}
|
||||
|
||||
function buildDescendants(
|
||||
childrenByParent: ReadonlyMap<SessionId, readonly SessionRecord[]>,
|
||||
sessionId: SessionId,
|
||||
): SessionLineageNode[] {
|
||||
const descendants: SessionLineageNode[] = []
|
||||
const stack = [{ sessionId, descendants }]
|
||||
while (stack.length > 0) {
|
||||
// The length guard proves a frame exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const frame = stack.pop()!
|
||||
const nodes: SessionLineageNode[] = []
|
||||
for (const child of childrenByParent.get(frame.sessionId) ?? []) {
|
||||
const node = { session: cloneRecord(child), descendants: [] }
|
||||
nodes.push(node)
|
||||
frame.descendants.push(node)
|
||||
}
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
// The loop bounds prove this indexed node exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const node = nodes[index]!
|
||||
stack.push({ sessionId: node.session.header.id, descendants: node.descendants })
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
function cloneRecord(record: SessionRecord): SessionRecord {
|
||||
return { ...record, header: structuredClone(record.header) }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Public records for exact reads over the live-preferred logical session corpus.
|
||||
* Public records for exact reads and relationship traces over the
|
||||
* live-preferred logical session corpus.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-query/types
|
||||
*/
|
||||
@@ -33,6 +34,61 @@ export interface SessionEventRecord {
|
||||
surface: SessionEventSurface
|
||||
}
|
||||
|
||||
/** Recursive descendant node in a session-lineage trace. */
|
||||
export interface SessionLineageNode {
|
||||
/** Detached logical-corpus record for this descendant. */
|
||||
session: SessionRecord
|
||||
/** Direct children, each carrying its own recursive descendants. */
|
||||
descendants: SessionLineageNode[]
|
||||
}
|
||||
|
||||
/** Known ancestry and descendants for one logical session. */
|
||||
export type SessionLineageTrace = {
|
||||
/** Detached record for the session that was traced. */
|
||||
target: SessionRecord
|
||||
/** Known parents from the immediate parent outward. */
|
||||
ancestors: SessionRecord[]
|
||||
/** Complete known descendant trees rooted at the target's direct children. */
|
||||
descendants: SessionLineageNode[]
|
||||
} & (
|
||||
| {
|
||||
/** The complete parent chain is present in the logical corpus. */
|
||||
complete: true
|
||||
/** Detached record at the top of the complete lineage. */
|
||||
root: SessionRecord
|
||||
}
|
||||
| {
|
||||
/** The parent chain leaves the visible logical corpus. */
|
||||
complete: false
|
||||
/** First parent id that is not present in the logical corpus. */
|
||||
unresolvedParentId: SessionId
|
||||
}
|
||||
)
|
||||
|
||||
/** Request for direct surface and provenance relationships around one event. */
|
||||
export interface SessionEventTraceRequest {
|
||||
/** Session that owns the target event. */
|
||||
sessionId: SessionId
|
||||
/** Target event seq. */
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Direct surface and provenance relationships for one event. */
|
||||
export interface SessionEventTrace {
|
||||
/** Lightweight target record. */
|
||||
target: SessionEventRecord
|
||||
/** Immediate positional replacement event, when the target was shadowed. */
|
||||
replacedBy?: number
|
||||
/** Positional replacers from the immediate replacement to the final replacement. */
|
||||
replacementChain: number[]
|
||||
/** Surface nodes directly removed when the target itself performed a replacement. */
|
||||
replacedEventSeqs: number[]
|
||||
/** Direct logged provenance sources in their recorded order. */
|
||||
sourceEventSeqs: number[]
|
||||
/** Later events that directly name the target as a provenance source, in log order. */
|
||||
derivedEventSeqs: number[]
|
||||
}
|
||||
|
||||
/** Request for one event plus raw neighboring log context. */
|
||||
export interface SessionEventReadRequest {
|
||||
/** Session that owns the target event. */
|
||||
|
||||
@@ -34,6 +34,10 @@ class TestPersistence extends SessionPersistence {
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
locate(_meta: SessionHeader): undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
|
||||
return Promise.resolve()
|
||||
@@ -110,7 +114,7 @@ describe('session-query exact reads', () => {
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq } },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
|
||||
)
|
||||
|
||||
expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface))
|
||||
@@ -227,11 +231,13 @@ describe('session-query exact reads', () => {
|
||||
it('turns malformed surfaces and direct invalid config into typed errors', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('bad-surface'))
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [] },
|
||||
{ surfaceOp: { op: 'replace', start: 9, end: 9 } },
|
||||
)
|
||||
;(session as unknown as { log: SessionEvent[] }).log.push({
|
||||
type: 'assistant/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, step: 1, content: [] },
|
||||
surfaceOp: { op: 'replace', start: 9, end: 9 },
|
||||
})
|
||||
await expect(ctx.sessionQuery.listEvents(session.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
|
||||
|
||||
426
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
426
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
|
||||
|
||||
/** Test-only mutable view used to verify detached returned metadata. */
|
||||
function mutableHeader(value: SessionHeader): MutableSessionHeader {
|
||||
return value
|
||||
}
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
}
|
||||
|
||||
function appendEvent(seq: number, sources?: number[]): SessionEvent {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: seq + 1,
|
||||
data: { content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
...sources === undefined ? {} : { sourceEventSeqs: sources },
|
||||
}
|
||||
}
|
||||
|
||||
class TracePersistence extends SessionPersistence {
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listCalls = 0
|
||||
static loadCalls = 0
|
||||
static listFailure: Error | undefined
|
||||
static loadFailure: Error | undefined
|
||||
static afterList: (() => void) | undefined
|
||||
|
||||
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
|
||||
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
|
||||
this.listCalls = 0
|
||||
this.loadCalls = 0
|
||||
this.listFailure = undefined
|
||||
this.loadFailure = undefined
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
locate(_meta: SessionHeader): undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
append(id: SessionIdType, events: readonly SessionEvent[]): Promise<void> {
|
||||
const entry = TracePersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
entry.events.push(...structuredClone(events))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
TracePersistence.loadCalls += 1
|
||||
if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure)
|
||||
const entry = TracePersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
return Promise.resolve(structuredClone(entry))
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
TracePersistence.listCalls += 1
|
||||
if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure)
|
||||
const result = [...TracePersistence.entries.values()].map(entry => structuredClone(entry.meta))
|
||||
TracePersistence.afterList?.()
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
}
|
||||
|
||||
async function queryContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function expectCode(code: SessionQueryErrorCode): Error {
|
||||
return expect.objectContaining({ code }) as Error
|
||||
}
|
||||
|
||||
function appendTraceEvents(session: Session): void {
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'draft' },
|
||||
})
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [0] },
|
||||
)
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1, 0] },
|
||||
)
|
||||
session.append(
|
||||
'context/message',
|
||||
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [0, 2] },
|
||||
)
|
||||
}
|
||||
|
||||
describe('session lineage tracing', () => {
|
||||
it('returns complete ancestry, deterministic descendant trees, and detached records', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 0 } })
|
||||
const parent = ctx.sessions.create(SessionId('parent'), {
|
||||
meta: { createdAt: 1, parentSession: root.id },
|
||||
})
|
||||
const target = ctx.sessions.create(SessionId('target'), {
|
||||
meta: { createdAt: 2, parentSession: parent.id },
|
||||
})
|
||||
ctx.sessions.create(SessionId('b'), { meta: { createdAt: 4, parentSession: target.id } })
|
||||
const childA = ctx.sessions.create(SessionId('a'), {
|
||||
meta: { createdAt: 4, parentSession: target.id },
|
||||
})
|
||||
ctx.sessions.create(SessionId('older'), { meta: { createdAt: 3, parentSession: target.id } })
|
||||
ctx.sessions.create(SessionId('grandchild'), {
|
||||
meta: { createdAt: 5, parentSession: childA.id },
|
||||
})
|
||||
|
||||
const trace = await ctx.sessionQuery.traceSession(target.id)
|
||||
expect(trace.complete).toBe(true)
|
||||
if (!trace.complete) throw new Error('expected complete lineage')
|
||||
expect(trace.ancestors.map(record => record.header.id)).toEqual([parent.id, root.id])
|
||||
expect(trace.root.header.id).toBe(root.id)
|
||||
expect(trace.descendants.map(node => node.session.header.id))
|
||||
.toEqual([SessionId('older'), SessionId('a'), SessionId('b')])
|
||||
expect(trace.descendants[1]?.descendants.map(node => node.session.header.id))
|
||||
.toEqual([SessionId('grandchild')])
|
||||
|
||||
mutableHeader(trace.target.header).createdAt = 99
|
||||
mutableHeader(trace.ancestors[0]!.header).createdAt = 99
|
||||
mutableHeader(trace.root.header).createdAt = 99
|
||||
mutableHeader(trace.descendants[0]!.session.header).createdAt = 99
|
||||
const repeated = await ctx.sessionQuery.traceSession(target.id)
|
||||
expect(repeated.target.header.createdAt).toBe(2)
|
||||
expect(repeated.ancestors[0]?.header.createdAt).toBe(1)
|
||||
expect(repeated.descendants[0]?.session.header.createdAt).toBe(3)
|
||||
})
|
||||
|
||||
it('represents root and unresolved-parent traces explicitly', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } })
|
||||
const partial = ctx.sessions.create(SessionId('partial'), {
|
||||
meta: { createdAt: 2, parentSession: SessionId('outside') },
|
||||
})
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(root.id)).resolves.toMatchObject({
|
||||
complete: true,
|
||||
root: { header: { id: root.id } },
|
||||
ancestors: [],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({
|
||||
complete: false,
|
||||
unresolvedParentId: SessionId('outside'),
|
||||
ancestors: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects target-connected cycles and missing targets', async () => {
|
||||
const ctx = await queryContext()
|
||||
ctx.sessions.create(SessionId('a'), {
|
||||
meta: { createdAt: 1, parentSession: SessionId('b') },
|
||||
})
|
||||
ctx.sessions.create(SessionId('b'), {
|
||||
meta: { createdAt: 2, parentSession: SessionId('a') },
|
||||
})
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(SessionId('a')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE'))
|
||||
await expect(ctx.sessionQuery.traceSession(SessionId('missing')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
})
|
||||
|
||||
it('uses one cross-corpus observation and preserves persistence failure semantics', async () => {
|
||||
const durable = header('durable')
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(durable.id)).resolves.toMatchObject({
|
||||
target: { live: false, persisted: true },
|
||||
complete: true,
|
||||
})
|
||||
expect(TracePersistence.listCalls).toBe(1)
|
||||
expect(TracePersistence.loadCalls).toBe(0)
|
||||
|
||||
TracePersistence.listFailure = new Error('unavailable')
|
||||
await expect(ctx.sessionQuery.traceSession(durable.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
|
||||
it('constructs deeply nested descendants without consuming the JavaScript call stack', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('deep-0'), { meta: { createdAt: 0 } })
|
||||
let parent = root
|
||||
for (let depth = 1; depth < 3_000; depth += 1) {
|
||||
parent = ctx.sessions.create(SessionId(`deep-${depth}`), {
|
||||
meta: { createdAt: depth, parentSession: parent.id },
|
||||
})
|
||||
}
|
||||
|
||||
const trace = await ctx.sessionQuery.traceSession(root.id)
|
||||
expect(trace.complete).toBe(true)
|
||||
let node = trace.descendants[0]
|
||||
for (let depth = 1; depth < 3_000; depth += 1) {
|
||||
if (node === undefined) throw new Error(`lineage ended before depth ${depth}`)
|
||||
if (depth === 2_999) expect(node.session.header.id).toBe(SessionId('deep-2999'))
|
||||
node = node.descendants[0]
|
||||
}
|
||||
expect(node).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session event tracing', () => {
|
||||
it('returns direct replacement and provenance links in their contract order', async () => {
|
||||
const ctx = await queryContext()
|
||||
const session = ctx.sessions.create(SessionId('trace'))
|
||||
appendTraceEvents(session)
|
||||
|
||||
const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 1 })
|
||||
expect(original.target).toMatchObject({
|
||||
sessionId: session.id,
|
||||
seq: 1,
|
||||
type: 'user/message',
|
||||
surface: 'shadowed',
|
||||
})
|
||||
expect(original).toMatchObject({
|
||||
replacedBy: 2,
|
||||
replacementChain: [2, 4],
|
||||
replacedEventSeqs: [],
|
||||
sourceEventSeqs: [0],
|
||||
derivedEventSeqs: [2],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }))
|
||||
.resolves.toMatchObject({
|
||||
replacedBy: 4,
|
||||
replacementChain: [4],
|
||||
replacedEventSeqs: [1],
|
||||
sourceEventSeqs: [1, 0],
|
||||
derivedEventSeqs: [4],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 0 }))
|
||||
.resolves.toMatchObject({
|
||||
target: { surface: 'log-only' },
|
||||
replacementChain: [],
|
||||
sourceEventSeqs: [],
|
||||
derivedEventSeqs: [1, 2, 4],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }))
|
||||
.resolves.toMatchObject({
|
||||
replacementChain: [],
|
||||
replacedEventSeqs: [2],
|
||||
sourceEventSeqs: [0, 2],
|
||||
derivedEventSeqs: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns fresh trace arrays and target records', async () => {
|
||||
const ctx = await queryContext()
|
||||
const session = ctx.sessions.create(SessionId('detached'))
|
||||
appendTraceEvents(session)
|
||||
|
||||
const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })
|
||||
first.target.time = -1
|
||||
first.replacementChain.push(99)
|
||||
first.replacedEventSeqs.push(99)
|
||||
first.sourceEventSeqs.push(99)
|
||||
first.derivedEventSeqs.push(99)
|
||||
const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })
|
||||
expect(repeated.target.time).not.toBe(-1)
|
||||
expect(repeated.replacementChain).toEqual([4])
|
||||
expect(repeated.replacedEventSeqs).toEqual([1])
|
||||
expect(repeated.sourceEventSeqs).toEqual([1, 0])
|
||||
expect(repeated.derivedEventSeqs).toEqual([4])
|
||||
})
|
||||
|
||||
it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => {
|
||||
const durable = header('shared', 1, { cwd: '/same' })
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } })
|
||||
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
|
||||
|
||||
const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } })
|
||||
live.append(
|
||||
'context/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
TracePersistence.listFailure = new Error('list unavailable')
|
||||
TracePersistence.loadFailure = new Error('load unavailable')
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.resolves.toMatchObject({ target: { type: 'context/message' } })
|
||||
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
|
||||
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const failedCtx = await queryContext()
|
||||
await failedCtx.plugin(TracePersistence)
|
||||
TracePersistence.listFailure = new Error('list unavailable')
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TracePersistence.listFailure = undefined
|
||||
TracePersistence.loadFailure = new Error('load unavailable')
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TracePersistence.loadFailure = undefined
|
||||
TracePersistence.afterList = () => {
|
||||
mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed'
|
||||
}
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
})
|
||||
|
||||
it('checks target existence before surface or provenance analysis', async () => {
|
||||
const bad = header('bad-target')
|
||||
const malformed: SessionEvent[] = [appendEvent(0), {
|
||||
type: 'assistant/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { turn: 1, step: 1, content: [] },
|
||||
surfaceOp: { op: 'replace', start: 9, end: 9 },
|
||||
sourceEventSeqs: [],
|
||||
}]
|
||||
TracePersistence.reset([{ meta: bad, events: malformed }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 9 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-surface sources', [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0] },
|
||||
]],
|
||||
['invalid source array', [
|
||||
{ ...appendEvent(0), sourceEventSeqs: 'invalid' },
|
||||
]],
|
||||
['empty sources', [
|
||||
appendEvent(0, []),
|
||||
]],
|
||||
['sparse sources', [
|
||||
appendEvent(0, Array<number>(1)),
|
||||
]],
|
||||
['duplicate sources', [
|
||||
appendEvent(0),
|
||||
appendEvent(1, [0, 0]),
|
||||
]],
|
||||
['missing earlier source', [
|
||||
appendEvent(0),
|
||||
appendEvent(1, [-1]),
|
||||
]],
|
||||
['future source', [
|
||||
appendEvent(0, [1]),
|
||||
appendEvent(1),
|
||||
]],
|
||||
['replacement without sources', [
|
||||
appendEvent(0),
|
||||
{ ...appendEvent(1), surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
]],
|
||||
['replacement missing a shadowed source', [
|
||||
{ type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } },
|
||||
appendEvent(1),
|
||||
{ ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } },
|
||||
]],
|
||||
] as const)('rejects an invalid surface log: %s', async (_name, rawEvents) => {
|
||||
const durable = header('invalid-provenance')
|
||||
const events = structuredClone(rawEvents) as unknown as SessionEvent[]
|
||||
TracePersistence.reset([{ meta: durable, events }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it('rejects surfaceOp on a non-surface event as an invalid surface', async () => {
|
||||
const durable = header('invalid-non-surface-op')
|
||||
const events = [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
surfaceOp: 'append',
|
||||
}] as unknown as SessionEvent[]
|
||||
TracePersistence.reset([{ meta: durable, events }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it('applies the same surface contract to listEvents', async () => {
|
||||
const durable = header('list-regression')
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.listEvents(durable.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user