fix(session-query): make persisted observation non-mutating
This commit is contained in:
@@ -72,16 +72,18 @@ export class SessionCorpus {
|
||||
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']>>
|
||||
let loaded: Awaited<ReturnType<SessionPersistence['inspect']>>
|
||||
try {
|
||||
loaded = await persistence.load(sessionId)
|
||||
loaded = await persistence.inspect(sessionId)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
`failed to load session "${sessionId}": ${errorMessage(error)}`,
|
||||
`failed to inspect session "${sessionId}": ${errorMessage(error)}`,
|
||||
'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const attached = this._ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) return snapshotLive(attached)
|
||||
assertSessionHeadersCompatible(loaded.meta, listed)
|
||||
return {
|
||||
header: structuredClone(loaded.meta),
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user