|
|
@@ -1,4 +1,4 @@
|
|
|
-import { describe, expect, it } from 'vitest'
|
|
|
+import { describe, expect, it, vi } from 'vitest'
|
|
|
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'
|
|
|
@@ -27,16 +27,31 @@ function eventLog(text = 'hello'): SessionEvent[] {
|
|
|
class TestPersistence extends SessionPersistence {
|
|
|
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
|
|
static listFailure: unknown
|
|
|
+ static listOverride: ((signal?: AbortSignal) => Promise<SessionHeader[]>) | undefined
|
|
|
static inspectFailure: unknown
|
|
|
static inspectEffect: (() => void) | undefined
|
|
|
+ static inspectOverride: ((
|
|
|
+ id: SessionIdType,
|
|
|
+ signal?: AbortSignal,
|
|
|
+ ) => Promise<{ meta: SessionHeader; events: SessionEvent[] }>) | undefined
|
|
|
static afterList: (() => void) | undefined
|
|
|
+ static listCalls = 0
|
|
|
+ static inspectCalls: SessionIdType[] = []
|
|
|
+ static listSignals: Array<AbortSignal | undefined> = []
|
|
|
+ static inspectSignals: Array<AbortSignal | 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.listOverride = undefined
|
|
|
this.inspectFailure = undefined
|
|
|
this.inspectEffect = undefined
|
|
|
+ this.inspectOverride = undefined
|
|
|
this.afterList = undefined
|
|
|
+ this.listCalls = 0
|
|
|
+ this.inspectCalls = []
|
|
|
+ this.listSignals = []
|
|
|
+ this.inspectSignals = []
|
|
|
}
|
|
|
|
|
|
locate(_meta: SessionHeader): undefined {
|
|
|
@@ -59,7 +74,15 @@ class TestPersistence extends SessionPersistence {
|
|
|
return this.inspect(id)
|
|
|
}
|
|
|
|
|
|
- inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
|
|
+ inspect(
|
|
|
+ id: SessionIdType,
|
|
|
+ signal?: AbortSignal,
|
|
|
+ ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
|
|
+ TestPersistence.inspectCalls.push(id)
|
|
|
+ TestPersistence.inspectSignals.push(signal)
|
|
|
+ if (TestPersistence.inspectOverride !== undefined) {
|
|
|
+ return TestPersistence.inspectOverride(id, signal)
|
|
|
+ }
|
|
|
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'))
|
|
|
@@ -69,7 +92,10 @@ class TestPersistence extends SessionPersistence {
|
|
|
return Promise.resolve(result)
|
|
|
}
|
|
|
|
|
|
- list(): Promise<SessionHeader[]> {
|
|
|
+ list(signal?: AbortSignal): Promise<SessionHeader[]> {
|
|
|
+ TestPersistence.listCalls += 1
|
|
|
+ TestPersistence.listSignals.push(signal)
|
|
|
+ if (TestPersistence.listOverride !== undefined) return TestPersistence.listOverride(signal)
|
|
|
if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure)
|
|
|
const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))
|
|
|
TestPersistence.afterList?.()
|
|
|
@@ -192,6 +218,336 @@ describe('session-query exact reads', () => {
|
|
|
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
|
|
|
})
|
|
|
|
|
|
+ it('batches unique persisted title observations through one cancellable corpus scan', async () => {
|
|
|
+ const first = header('batch-title-first', 1)
|
|
|
+ const second = header('batch-title-second', 2)
|
|
|
+ const titleEvent = (title: string, time: number): SessionEvent => ({
|
|
|
+ type: 'session/title',
|
|
|
+ seq: 0,
|
|
|
+ time,
|
|
|
+ data: {
|
|
|
+ title,
|
|
|
+ messageSeqs: [],
|
|
|
+ source: { kind: 'fallback' },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ TestPersistence.reset([
|
|
|
+ { meta: first, events: [titleEvent('First title', 10)] },
|
|
|
+ { meta: second, events: [titleEvent('Second title', 20)] },
|
|
|
+ ])
|
|
|
+ const ctx = await liveContext()
|
|
|
+ await ctx.plugin(TestPersistence)
|
|
|
+ const signal = new AbortController().signal
|
|
|
+ const missing = SessionId('batch-title-missing')
|
|
|
+
|
|
|
+ const results = await ctx.sessionQuery.readTitleSnapshots(
|
|
|
+ [second.id, first.id, second.id, missing],
|
|
|
+ signal,
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(results.map(result => [result.sessionId, result.status])).toEqual([
|
|
|
+ [second.id, 'fulfilled'],
|
|
|
+ [first.id, 'fulfilled'],
|
|
|
+ [missing, 'rejected'],
|
|
|
+ ])
|
|
|
+ expect(results[0]).toMatchObject({ value: { session: second, title: { title: 'Second title' } } })
|
|
|
+ expect(results[1]).toMatchObject({ value: { session: first, title: { title: 'First title' } } })
|
|
|
+ expect(TestPersistence.listCalls).toBe(1)
|
|
|
+ expect(TestPersistence.inspectCalls).toEqual([second.id, first.id])
|
|
|
+ expect(TestPersistence.listSignals).toEqual([signal])
|
|
|
+ expect(TestPersistence.inspectSignals).toEqual([signal, signal])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('bounds persisted title inspection concurrency while preserving ordered results', async () => {
|
|
|
+ const entries = Array.from({ length: 12 }, (_, index) => {
|
|
|
+ const meta = header(`bounded-title-${index}`, index)
|
|
|
+ return { meta, events: eventLog(`title-${index}`) }
|
|
|
+ })
|
|
|
+ TestPersistence.reset(entries)
|
|
|
+ const ctx = await liveContext()
|
|
|
+ await ctx.plugin(TestPersistence)
|
|
|
+ let active = 0
|
|
|
+ let maximum = 0
|
|
|
+ TestPersistence.inspectOverride = async (id) => {
|
|
|
+ active += 1
|
|
|
+ maximum = Math.max(maximum, active)
|
|
|
+ await new Promise<void>(resolve => setImmediate(resolve))
|
|
|
+ active -= 1
|
|
|
+ const entry = TestPersistence.entries.get(id)
|
|
|
+ if (entry === undefined) throw new Error('missing bounded test session')
|
|
|
+ return structuredClone(entry)
|
|
|
+ }
|
|
|
+
|
|
|
+ const results = await ctx.sessionQuery.readTitleSnapshots(entries.map(entry => entry.meta.id))
|
|
|
+
|
|
|
+ expect(maximum).toBe(4)
|
|
|
+ expect(TestPersistence.listCalls).toBe(1)
|
|
|
+ expect(TestPersistence.inspectCalls).toEqual(entries.map(entry => entry.meta.id))
|
|
|
+ expect(results.map(result => result.sessionId)).toEqual(entries.map(entry => entry.meta.id))
|
|
|
+ expect(results.every(result => result.status === 'fulfilled')).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('folds and discards each completed log before its worker dequeues another inspection', async () => {
|
|
|
+ const entries = Array.from({ length: 5 }, (_, index) => ({
|
|
|
+ meta: header(`project-title-${index}`, index),
|
|
|
+ events: [],
|
|
|
+ }))
|
|
|
+ TestPersistence.reset(entries)
|
|
|
+ const ctx = await liveContext()
|
|
|
+ await ctx.plugin(TestPersistence)
|
|
|
+ const timeline: string[] = []
|
|
|
+ const releases = new Map<SessionIdType, () => void>()
|
|
|
+ TestPersistence.inspectOverride = id => new Promise((resolve) => {
|
|
|
+ timeline.push(`inspect:${id}`)
|
|
|
+ releases.set(id, () => {
|
|
|
+ const marker = `full-log-marker:${id}`
|
|
|
+ const titleEvent = {
|
|
|
+ type: 'session/title',
|
|
|
+ seq: 1,
|
|
|
+ time: 20,
|
|
|
+ data: {
|
|
|
+ title: `Projected ${id}`,
|
|
|
+ get messageSeqs() {
|
|
|
+ timeline.push(`project:${id}`)
|
|
|
+ return []
|
|
|
+ },
|
|
|
+ source: { kind: 'fallback' },
|
|
|
+ },
|
|
|
+ } as unknown as SessionEvent
|
|
|
+ resolve({
|
|
|
+ meta: entries.find(entry => entry.meta.id === id)!.meta,
|
|
|
+ events: [...eventLog(marker), titleEvent],
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+ const release = (id: SessionIdType): void => {
|
|
|
+ const settle = releases.get(id)
|
|
|
+ if (settle === undefined) throw new Error(`inspection ${id} has not started`)
|
|
|
+ settle()
|
|
|
+ }
|
|
|
+ const ids = entries.map(entry => entry.meta.id)
|
|
|
+
|
|
|
+ const pending = ctx.sessionQuery.readTitleSnapshots(ids)
|
|
|
+ await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) })
|
|
|
+ release(ids[0]!)
|
|
|
+ await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(5) })
|
|
|
+
|
|
|
+ // Heap-retention assertions would depend on nondeterministic GC. This ordering
|
|
|
+ // is the deterministic guard: a retain-all implementation cannot touch the
|
|
|
+ // observable title getter until every inspection has completed.
|
|
|
+ expect(timeline.indexOf(`project:${ids[0]}`))
|
|
|
+ .toBeLessThan(timeline.indexOf(`inspect:${ids[4]}`))
|
|
|
+ for (const id of ids.slice(1)) release(id)
|
|
|
+ const results = await pending
|
|
|
+
|
|
|
+ expect(results.map(result => result.sessionId)).toEqual(ids)
|
|
|
+ expect(JSON.stringify(results)).not.toContain('full-log-marker:')
|
|
|
+ expect(results.every(result => result.status === 'fulfilled')).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('passes cancellation into a stalled persisted title batch and rejects with its reason', async () => {
|
|
|
+ const persisted = header('stalled-title', 1)
|
|
|
+ TestPersistence.reset([{ meta: persisted, events: [] }])
|
|
|
+ const ctx = await liveContext()
|
|
|
+ await ctx.plugin(TestPersistence)
|
|
|
+ const controller = new AbortController()
|
|
|
+ const reason = new Error('title deadline')
|
|
|
+ let started!: () => void
|
|
|
+ const inspectStarted = new Promise<void>((resolve) => { started = resolve })
|
|
|
+ TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => {
|
|
|
+ started()
|
|
|
+ signal?.addEventListener('abort', () => { reject(reason) }, { once: true })
|
|
|
+ })
|
|
|
+
|
|
|
+ const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal)
|
|
|
+ await inspectStarted
|
|
|
+ controller.abort(reason)
|
|
|
+
|
|
|
+ await expect(pending).rejects.toBe(reason)
|
|
|
+ expect(TestPersistence.listSignals).toEqual([controller.signal])
|
|
|
+ expect(TestPersistence.inspectSignals).toEqual([controller.signal])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('drains started title inspections after cancellation without starting queued ids', async () => {
|
|
|
+ const entries = Array.from({ length: 8 }, (_, index) => ({
|
|
|
+ meta: header(`cancel-queued-title-${index}`, index),
|
|
|
+ events: eventLog(`queued-${index}`),
|
|
|
+ }))
|
|
|
+ TestPersistence.reset(entries)
|
|
|
+ const ctx = await liveContext()
|
|
|
+ await ctx.plugin(TestPersistence)
|
|
|
+ const controller = new AbortController()
|
|
|
+ const reason = new Error('cancel queued title batch')
|
|
|
+ const releases: Array<() => void> = []
|
|
|
+ let abortsObserved = 0
|
|
|
+ let inspectionsSettled = 0
|
|
|
+ TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => {
|
|
|
+ signal?.addEventListener('abort', () => { abortsObserved += 1 }, { once: true })
|
|
|
+ releases.push(() => {
|
|
|
+ inspectionsSettled += 1
|
|
|
+ reject(reason)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ const pending = ctx.sessionQuery.readTitleSnapshots(
|
|
|
+ entries.map(entry => entry.meta.id),
|
|
|
+ controller.signal,
|
|
|
+ )
|
|
|
+ let batchSettled = false
|
|
|
+ void pending.then(
|
|
|
+ () => { batchSettled = true },
|
|
|
+ () => { batchSettled = true },
|
|
|
+ )
|
|
|
+ await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) })
|
|
|
+ controller.abort(reason)
|
|
|
+ await vi.waitFor(() => { expect(abortsObserved).toBe(4) })
|
|
|
+
|
|
|
+ expect(batchSettled).toBe(false)
|
|
|
+ expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id))
|
|
|
+ for (const release of releases) release()
|
|
|
+
|
|
|
+ await expect(pending).rejects.toBe(reason)
|
|
|
+ expect(inspectionsSettled).toBe(4)
|
|
|
+ expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('passes cancellation into a stalled persisted title listing and rejects with its reason', async () => {
|
|
|
+ const persisted = header('stalled-title-list', 1)
|
|
|
+ TestPersistence.reset([{ meta: persisted, events: [] }])
|
|
|
+ const ctx = await liveContext()
|
|
|
+ await ctx.plugin(TestPersistence)
|
|
|
+ const controller = new AbortController()
|
|
|
+ const reason = new Error('title listing deadline')
|
|
|
+ let started!: () => void
|
|
|
+ const listStarted = new Promise<void>((resolve) => { started = resolve })
|
|
|
+ TestPersistence.listOverride = signal => new Promise((_resolve, reject) => {
|
|
|
+ started()
|
|
|
+ signal?.addEventListener('abort', () => { reject(reason) }, { once: true })
|
|
|
+ })
|
|
|
+
|
|
|
+ const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal)
|
|
|
+ await listStarted
|
|
|
+ controller.abort(reason)
|
|
|
+
|
|
|
+ await expect(pending).rejects.toBe(reason)
|
|
|
+ expect(TestPersistence.listSignals).toEqual([controller.signal])
|
|
|
+ expect(TestPersistence.inspectCalls).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('isolates title read and fold failures while preferring a live owner attached during inspection', async () => {
|
|
|
+ const attached = header('batch-title-attached', 1)
|
|
|
+ const failed = header('batch-title-failed', 2)
|
|
|
+ const malformed = header('batch-title-malformed', 3)
|
|
|
+ const inspectFailure = new Error('one title inspect failed')
|
|
|
+ const malformedTitle = {
|
|
|
+ type: 'session/title',
|
|
|
+ seq: 0,
|
|
|
+ time: 30,
|
|
|
+ data: {
|
|
|
+ title: 'malformed',
|
|
|
+ source: { kind: 'fallback' },
|
|
|
+ },
|
|
|
+ } as unknown as SessionEvent
|
|
|
+ TestPersistence.reset([
|
|
|
+ { meta: attached, events: eventLog('stale persisted') },
|
|
|
+ { meta: failed, events: [] },
|
|
|
+ { meta: malformed, events: [malformedTitle] },
|
|
|
+ ])
|
|
|
+ const ctx = await liveContext()
|
|
|
+ await ctx.plugin(TestPersistence)
|
|
|
+ TestPersistence.inspectOverride = (id) => {
|
|
|
+ if (id === failed.id) return Promise.reject(inspectFailure)
|
|
|
+ const entry = TestPersistence.entries.get(id)
|
|
|
+ if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
|
|
+ if (id === attached.id) {
|
|
|
+ const session = ctx.sessions.create(attached.id, { meta: { createdAt: attached.createdAt } })
|
|
|
+ session.append('session/title', {
|
|
|
+ title: 'Attached live title',
|
|
|
+ messageSeqs: [],
|
|
|
+ source: { kind: 'fallback' },
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return Promise.resolve(structuredClone(entry))
|
|
|
+ }
|
|
|
+
|
|
|
+ const results = await ctx.sessionQuery.readTitleSnapshots([
|
|
|
+ attached.id,
|
|
|
+ failed.id,
|
|
|
+ malformed.id,
|
|
|
+ ])
|
|
|
+
|
|
|
+ expect(results[0]).toMatchObject({
|
|
|
+ status: 'fulfilled',
|
|
|
+ value: { session: attached, title: { title: 'Attached live title' } },
|
|
|
+ })
|
|
|
+ expect(results[1]).toMatchObject({
|
|
|
+ sessionId: failed.id,
|
|
|
+ status: 'rejected',
|
|
|
+ reason: {
|
|
|
+ code: 'SESSION_QUERY_PERSISTENCE_FAILED',
|
|
|
+ cause: inspectFailure,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ expect(results[2]).toMatchObject({ sessionId: malformed.id, status: 'rejected' })
|
|
|
+ if (results[2]?.status !== 'rejected') throw new Error('expected malformed title rejection')
|
|
|
+ expect(results[2].reason).toBeInstanceOf(TypeError)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('preserves live batch results across missing persistence, listing failure, and late attachment', async () => {
|
|
|
+ const liveOnly = await liveContext()
|
|
|
+ const live = liveOnly.sessions.create(SessionId('batch-title-live'))
|
|
|
+ const missing = SessionId('batch-title-no-persistence')
|
|
|
+
|
|
|
+ await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, live.id])).resolves.toEqual([{
|
|
|
+ sessionId: live.id,
|
|
|
+ status: 'fulfilled',
|
|
|
+ value: { session: live.header },
|
|
|
+ }])
|
|
|
+ await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, missing])).resolves.toMatchObject([
|
|
|
+ { sessionId: live.id, status: 'fulfilled' },
|
|
|
+ { sessionId: missing, status: 'rejected' },
|
|
|
+ ])
|
|
|
+ await expect(liveOnly.sessionQuery.readTitleSnapshot(missing))
|
|
|
+ .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
|
|
+
|
|
|
+ const persisted = header('batch-title-persisted', 1)
|
|
|
+ const late = header('batch-title-late', 2)
|
|
|
+ TestPersistence.reset([{ meta: persisted, events: [] }])
|
|
|
+ const mixed = await liveContext()
|
|
|
+ const mixedLive = mixed.sessions.create(SessionId('batch-title-mixed-live'))
|
|
|
+ await mixed.plugin(TestPersistence)
|
|
|
+ TestPersistence.afterList = () => {
|
|
|
+ mixed.sessions.create(late.id, { meta: { createdAt: late.createdAt } })
|
|
|
+ TestPersistence.afterList = undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ await expect(mixed.sessionQuery.readTitleSnapshots([
|
|
|
+ mixedLive.id,
|
|
|
+ persisted.id,
|
|
|
+ late.id,
|
|
|
+ ])).resolves.toMatchObject([
|
|
|
+ { sessionId: mixedLive.id, status: 'fulfilled' },
|
|
|
+ { sessionId: persisted.id, status: 'fulfilled' },
|
|
|
+ { sessionId: late.id, status: 'fulfilled' },
|
|
|
+ ])
|
|
|
+
|
|
|
+ TestPersistence.reset()
|
|
|
+ TestPersistence.listFailure = new Error('title listing failed')
|
|
|
+ const failedList = await liveContext()
|
|
|
+ const survivingLive = failedList.sessions.create(SessionId('batch-title-list-live'))
|
|
|
+ await failedList.plugin(TestPersistence)
|
|
|
+
|
|
|
+ await expect(failedList.sessionQuery.readTitleSnapshots([survivingLive.id, missing]))
|
|
|
+ .resolves.toMatchObject([
|
|
|
+ { sessionId: survivingLive.id, status: 'fulfilled' },
|
|
|
+ {
|
|
|
+ sessionId: missing,
|
|
|
+ status: 'rejected',
|
|
|
+ reason: expectCode('SESSION_QUERY_PERSISTENCE_FAILED'),
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ })
|
|
|
+
|
|
|
it('lists live sessions deterministically and returns detached headers', async () => {
|
|
|
const ctx = await liveContext()
|
|
|
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })
|