fix(session-query): make persisted observation non-mutating

This commit is contained in:
Hypatia May
2026-07-23 21:33:28 +08:00
parent c1fa985c20
commit 9482affc48
22 changed files with 239 additions and 63 deletions

View File

@@ -27,13 +27,15 @@ function eventLog(text = 'hello'): SessionEvent[] {
class TestPersistence extends SessionPersistence {
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
static listFailure: unknown
static loadFailure: unknown
static inspectFailure: unknown
static inspectEffect: (() => void) | 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.listFailure = undefined
this.loadFailure = undefined
this.inspectFailure = undefined
this.inspectEffect = undefined
this.afterList = undefined
}
@@ -54,10 +56,17 @@ class TestPersistence extends SessionPersistence {
}
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
if (TestPersistence.loadFailure !== undefined) return rejectUnknown(TestPersistence.loadFailure)
return this.inspect(id)
}
inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure)
const entry = TestPersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
return Promise.resolve(structuredClone(entry))
const result = structuredClone(entry)
TestPersistence.inspectEffect?.()
TestPersistence.inspectEffect = undefined
return Promise.resolve(result)
}
list(): Promise<SessionHeader[]> {
@@ -96,6 +105,22 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
}
describe('session-query exact reads', () => {
it('prefers a live owner that attaches while its persisted prefix is inspected', async () => {
const shared = header('attach-during-inspect', 2)
TestPersistence.reset([{ meta: shared, events: eventLog('persisted') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
TestPersistence.inspectEffect = () => {
ctx.sessions.create(shared.id, {
seed: eventLog('live'),
meta: { createdAt: shared.createdAt },
})
}
await expect(ctx.sessionQuery.filterEvents(shared.id, []))
.resolves.toMatchObject([{ sessionId: shared.id, text: 'live' }])
})
it('reads the latest title from one live-preferred or persisted log without widening listSessions', async () => {
const persistedHeader = header('persisted-title', 2)
const sharedHeader = header('shared-title', 3)
@@ -363,7 +388,7 @@ describe('session-query exact reads', () => {
)
await ctx.plugin(TestPersistence)
TestPersistence.listFailure = new Error('list unavailable')
TestPersistence.loadFailure = new Error('load unavailable')
TestPersistence.inspectFailure = new Error('inspect unavailable')
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2)
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } })
@@ -381,10 +406,10 @@ describe('session-query exact reads', () => {
await expect(ctx.sessionQuery.listEvents(SessionId('absent')))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
TestPersistence.loadFailure = 'raw failure'
TestPersistence.inspectFailure = 'raw failure'
await expect(ctx.sessionQuery.listEvents(durable.id))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.loadFailure = undefined
TestPersistence.inspectFailure = undefined
const durableEntry = TestPersistence.entries.get(durable.id)!
durableEntry.meta = { ...durableEntry.meta, cwd: '/changed-after-list' }
TestPersistence.afterList = () => {

View File

@@ -31,17 +31,17 @@ function appendEvent(seq: number, sources?: number[]): SessionEvent {
class TracePersistence extends SessionPersistence {
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
static listCalls = 0
static loadCalls = 0
static inspectCalls = 0
static listFailure: Error | undefined
static loadFailure: Error | undefined
static inspectFailure: 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.inspectCalls = 0
this.listFailure = undefined
this.loadFailure = undefined
this.inspectFailure = undefined
this.afterList = undefined
}
@@ -62,8 +62,12 @@ class TracePersistence extends SessionPersistence {
}
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
TracePersistence.loadCalls += 1
if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure)
return this.inspect(id)
}
inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
TracePersistence.inspectCalls += 1
if (TracePersistence.inspectFailure !== undefined) return Promise.reject(TracePersistence.inspectFailure)
const entry = TracePersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
return Promise.resolve(structuredClone(entry))
@@ -209,7 +213,7 @@ describe('session lineage tracing', () => {
complete: true,
})
expect(TracePersistence.listCalls).toBe(1)
expect(TracePersistence.loadCalls).toBe(0)
expect(TracePersistence.inspectCalls).toBe(0)
TracePersistence.listFailure = new Error('unavailable')
await expect(ctx.sessionQuery.traceSession(durable.id))
@@ -301,7 +305,7 @@ describe('session event tracing', () => {
expect(repeated.derivedEventSeqs).toEqual([8])
})
it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => {
it('inspects 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()
@@ -309,7 +313,7 @@ describe('session event tracing', () => {
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])
expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1])
const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -319,10 +323,10 @@ describe('session event tracing', () => {
{ surfaceOp: 'append' },
)
TracePersistence.listFailure = new Error('list unavailable')
TracePersistence.loadFailure = new Error('load unavailable')
TracePersistence.inspectFailure = new Error('inspect unavailable')
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 }))
.resolves.toMatchObject({ target: { type: 'context/message' } })
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1])
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
const failedCtx = await queryContext()
@@ -331,10 +335,10 @@ describe('session event tracing', () => {
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')
TracePersistence.inspectFailure = new Error('inspect unavailable')
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TracePersistence.loadFailure = undefined
TracePersistence.inspectFailure = undefined
TracePersistence.afterList = () => {
mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed'
}