cache.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. /**
  2. * SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
  3. * count/interval throttling between them, fail-soft durability (a failed
  4. * write logs and stays stale, never throws into the event path), and the
  5. * cold-read ladder (cached row + readFrom tail + registry restore +
  6. * write-back; version bump and shrunk-log rows degrade to a full re-read).
  7. */
  8. import { afterEach, describe, expect, it, vi } from 'vitest'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { z } from 'zod'
  11. import Storage from '@deepseek-ai/dsh-storage'
  12. import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
  13. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  14. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  15. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  16. import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
  17. import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
  18. import SessionProjectionCache from '../src/index.ts'
  19. declare module '@deepseek-ai/dsh-session-projection/types' {
  20. interface SessionProjectionStateMap {
  21. 'cache-test/marks': MarksState
  22. 'cache-test/marks2': Map<string, string>
  23. }
  24. interface SessionProjectionMap {
  25. 'cache-test/marks': { marks: string[] }
  26. }
  27. }
  28. declare module '@deepseek-ai/dsh-session/types' {
  29. interface SessionEventMap {
  30. 'cache-test/mark': { marks: string[] }
  31. }
  32. interface OutOfBandSessionEventMap {
  33. 'cache-test/mark': true
  34. }
  35. }
  36. type MarksState = { marks: string[] } | null
  37. const marksUnit = (stateVersion = 1) => ({
  38. key: 'cache-test/marks',
  39. stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
  40. init: () => null,
  41. apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
  42. wire: {
  43. viewSchema: z.object({ marks: z.array(z.string()) }),
  44. view: state => state ?? { marks: [] },
  45. },
  46. stateVersion,
  47. }) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
  48. /** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
  49. function fakePersistence(logs: Map<string, SessionEvent[]>, seedLength?: number) {
  50. const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => {
  51. const events = logs.get(String(id))
  52. if (events === undefined) throw new Error(`session "${id}" not found`)
  53. return {
  54. meta: {
  55. version: 0,
  56. id,
  57. createdAt: 0,
  58. ...seedLength === undefined ? {} : { seedLength },
  59. },
  60. events: events.filter(event => event.seq >= fromSeq),
  61. }
  62. })
  63. return { readFrom }
  64. }
  65. /** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */
  66. const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
  67. ({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
  68. interface HarnessOptions {
  69. pool?: MemoryMediaPool
  70. config?: { writeEveryEvents: number; writeIntervalMs: number }
  71. stateVersion?: number
  72. logs?: Map<string, SessionEvent[]>
  73. seedLength?: number
  74. }
  75. const contexts: Context[] = []
  76. async function harness(options: HarnessOptions = {}) {
  77. const pool = options.pool ?? new MemoryMediaPool()
  78. const logs = options.logs ?? new Map<string, SessionEvent[]>()
  79. const ctx = new Context()
  80. contexts.push(ctx)
  81. await ctx.plugin(Storage)
  82. ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
  83. const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
  84. ctx.storage.mount('domain', facility)
  85. ctx.provide('storageDomain', facility)
  86. await ctx.plugin(SessionStore)
  87. await ctx.plugin(SessionProjectionRegistry)
  88. ctx.sessionProjections.register(marksUnit(options.stateVersion))
  89. const persistence = fakePersistence(logs, options.seedLength)
  90. ctx.provide('sessionPersistence', persistence as never)
  91. const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  92. return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache }
  93. }
  94. const mark = (session: Session, marks: string[]): SessionEvent =>
  95. session.append('cache-test/mark', { marks })
  96. const endTurn = (session: Session): SessionEvent =>
  97. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  98. /** The stored medium record for one session id (undefined = never written). */
  99. function storedRecord(pool: MemoryMediaPool, id: Session['id']) {
  100. return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as
  101. {
  102. identity: { createdAt: number; cwd?: string }
  103. rows: Record<string, { ver: number; seq: number; val: unknown }>
  104. } | undefined
  105. }
  106. /** The stored medium rows for one session id (undefined = never written). */
  107. function storedRows(pool: MemoryMediaPool, id: Session['id']) {
  108. return storedRecord(pool, id)?.rows
  109. }
  110. /** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */
  111. const settle = () => new Promise(resolve => setTimeout(resolve, 0))
  112. afterEach(async () => {
  113. vi.useRealTimers()
  114. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  115. })
  116. describe('SessionProjectionCache write policy', () => {
  117. it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
  118. const { ctx, pool } = await harness()
  119. const session = ctx.sessions.create(SessionId('turn-end'))
  120. mark(session, ['a'])
  121. expect(storedRows(pool, session.id)).toBeUndefined() // throttled: no write yet
  122. const end = endTurn(session)
  123. await settle()
  124. const rows = storedRows(pool, session.id)
  125. expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
  126. })
  127. it('writes at session disposal (detach, the live-to-cold moment)', async () => {
  128. const { ctx, pool } = await harness()
  129. // Sessions dispose with their owning fiber: create in a child plugin.
  130. let session: Session | undefined
  131. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  132. session = inner.sessions.create(SessionId('detach'))
  133. }, { inject: ['sessions'] }))
  134. if (session === undefined) throw new Error('session was not created')
  135. mark(session, ['live'])
  136. await owner.dispose()
  137. await settle()
  138. expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
  139. })
  140. it('flushes when the in-turn event count reaches the configured threshold', async () => {
  141. const { ctx, pool } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
  142. const session = ctx.sessions.create(SessionId('count'))
  143. mark(session, ['1'])
  144. mark(session, ['2'])
  145. await settle()
  146. expect(storedRows(pool, session.id)).toBeUndefined()
  147. mark(session, ['3'])
  148. await settle()
  149. expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
  150. })
  151. it('flushes on the configured interval when the count threshold is not reached', async () => {
  152. vi.useFakeTimers()
  153. const { ctx, pool } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 250 } })
  154. const session = ctx.sessions.create(SessionId('interval'))
  155. mark(session, ['slow'])
  156. await vi.advanceTimersByTimeAsync(249)
  157. expect(storedRows(pool, session.id)).toBeUndefined()
  158. await vi.advanceTimersByTimeAsync(1)
  159. await vi.advanceTimersByTimeAsync(0)
  160. expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
  161. })
  162. it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
  163. const { ctx, pool } = await harness()
  164. // Never dirtied: no events — write() still lands the init-derived cut.
  165. const clean = ctx.sessions.create(SessionId('clean-write'))
  166. await ctx.sessionProjectionCache.write(clean)
  167. expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
  168. // A unit whose state violates the plain-JSON contract fails the write loud.
  169. ctx.sessionProjections.register({
  170. key: 'cache-test/marks2',
  171. stateSchema: z.custom<Map<string, string>>(() => true),
  172. init: () => new Map<string, string>(),
  173. apply: state => state,
  174. stateVersion: 1,
  175. })
  176. await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable')
  177. })
  178. it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
  179. vi.useFakeTimers()
  180. const { ctx, pool, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
  181. const armed = ctx.sessions.create(SessionId('armed'))
  182. const cleaned = ctx.sessions.create(SessionId('cleaned'))
  183. mark(armed, ['pending']) // timer armed, no write yet
  184. mark(cleaned, ['done'])
  185. endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map
  186. await vi.advanceTimersByTimeAsync(0)
  187. await fiber.dispose()
  188. // The armed timer died with the plugin: advancing time writes nothing.
  189. await vi.advanceTimersByTimeAsync(10_000)
  190. expect(storedRows(pool, armed.id)).toBeUndefined()
  191. })
  192. it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
  193. const { ctx, pool } = await harness()
  194. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  195. const session = ctx.sessions.create(SessionId('fail-soft'))
  196. mark(session, ['x'])
  197. pool.failNextWrites = 1
  198. endTurn(session)
  199. await settle()
  200. expect(storedRows(pool, session.id)).toBeUndefined()
  201. expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
  202. // Self-heal: the next mandatory point writes the current cut.
  203. mark(session, ['y'])
  204. endTurn(session)
  205. await settle()
  206. expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
  207. })
  208. })
  209. describe('SessionProjectionCache cold read', () => {
  210. const storedLog = (marks: string[][]): SessionEvent[] => {
  211. const events: SessionEvent[] = [
  212. { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
  213. ]
  214. for (const m of marks) {
  215. events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } })
  216. }
  217. events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } })
  218. return events
  219. }
  220. /** Pre-seed the medium with one stored checkpoint record (before the domain opens). */
  221. function seedRow(
  222. pool: MemoryMediaPool,
  223. id: string,
  224. row: { ver: number; seq: number; val: unknown },
  225. identity: { createdAt: number; cwd?: string } = { createdAt: 0 },
  226. ): void {
  227. pool.versions.set('session_projcache', 3)
  228. pool.media.set('session_projcache', {
  229. tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]),
  230. global: null,
  231. })
  232. }
  233. it('retries prepared hydration without a malformed cached checkpoint', async () => {
  234. const pool = new MemoryMediaPool()
  235. const id = SessionId('prepared-cache-fallback')
  236. seedRow(pool, id, { ver: 1, seq: 1, val: { marks: 'malformed' } })
  237. const events = storedLog([['fresh']])
  238. const { cache } = await harness({ pool })
  239. const meta = headerOf(id)
  240. const session = Session.create(id, events, meta)
  241. expect(cache.hydratePrepared(session, meta, events)).toEqual({
  242. asOfSeq: 2,
  243. values: { 'cache-test/marks': { marks: ['fresh'] } },
  244. })
  245. })
  246. it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => {
  247. const pool = new MemoryMediaPool()
  248. const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]])
  249. // A warm-era checkpoint at watermark 1 (only ['a'] folded).
  250. seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } })
  251. const { cache, persistence, pool: samePool } = await harness({ pool, logs })
  252. const id = SessionId('cold')
  253. const snapshot = await cache.coldSnapshot(id)
  254. expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a', 'b'] })
  255. expect(snapshot.asOfSeq).toBe(3)
  256. // The tail read was bounded by the anchored floor (watermark 1 -> floor 1), not 0.
  257. expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined)
  258. // Write-back: the stored row advanced to the served cut.
  259. expect(storedRows(samePool, id)?.['cache-test/marks'])
  260. .toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } })
  261. })
  262. it('discards a version-mismatched row and refolds the full log', async () => {
  263. const pool = new MemoryMediaPool()
  264. const logs = new Map([['bumped', storedLog([['a']])]])
  265. seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } })
  266. const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 })
  267. const snapshot = await cache.coldSnapshot(SessionId('bumped'))
  268. expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
  269. // Mismatch pulls the floor to 0: one full read, no second pass needed.
  270. expect(persistence.readFrom).toHaveBeenCalledTimes(1)
  271. expect(persistence.readFrom).toHaveBeenCalledWith(SessionId('bumped'), 0, undefined)
  272. })
  273. it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => {
  274. const pool = new MemoryMediaPool()
  275. const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2
  276. seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } })
  277. const { cache, persistence } = await harness({ pool, logs })
  278. const snapshot = await cache.coldSnapshot(SessionId('shrunk'))
  279. expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
  280. expect(snapshot.asOfSeq).toBe(2)
  281. // Anchored tail read (floor 9) came back empty -> full re-read from 0.
  282. expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('shrunk'), 9, undefined)
  283. expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined)
  284. })
  285. it('discards malformed persisted state and degrades to one full re-read', async () => {
  286. const pool = new MemoryMediaPool()
  287. const logs = new Map([['malformed', storedLog([['real']])]])
  288. seedRow(pool, 'malformed', { ver: 1, seq: 1, val: { marks: 'not-an-array' } })
  289. const { cache, persistence } = await harness({ pool, logs })
  290. const snapshot = await cache.coldSnapshot(SessionId('malformed'))
  291. expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
  292. expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('malformed'), 1, undefined)
  293. expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined)
  294. })
  295. it('passes the stored seed boundary through bounded and fallback full restores', async () => {
  296. const pool = new MemoryMediaPool()
  297. const logs = new Map([['seeded', storedLog([['real']])]])
  298. seedRow(pool, 'seeded', { ver: 1, seq: 1, val: { marks: 'not-an-array' } })
  299. const { ctx, cache } = await harness({ pool, logs, seedLength: 2 })
  300. const restore = vi.spyOn(ctx.sessionProjections, 'restore')
  301. await cache.coldSnapshot(SessionId('seeded'))
  302. const expectedHeader = {
  303. version: 0,
  304. id: SessionId('seeded'),
  305. createdAt: 0,
  306. seedLength: 2,
  307. }
  308. expect(restore).toHaveBeenNthCalledWith(1, expect.any(Object), expect.any(Array), 1, expectedHeader)
  309. expect(restore).toHaveBeenNthCalledWith(2, {}, expect.any(Array), 0, expectedHeader)
  310. })
  311. it('write-back failure is contained: the snapshot is still served', async () => {
  312. const pool = new MemoryMediaPool()
  313. const logs = new Map([['soft', storedLog([['a']])]])
  314. const { ctx, cache } = await harness({ pool, logs })
  315. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  316. pool.failNextWrites = 1
  317. const snapshot = await cache.coldSnapshot(SessionId('soft'))
  318. expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
  319. expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "soft" failed'))
  320. })
  321. it('rejects for a session with no persisted log', async () => {
  322. const { cache } = await harness()
  323. await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
  324. })
  325. it('discards a record bound to a different log lifecycle and refolds from the actual log', async () => {
  326. const pool = new MemoryMediaPool()
  327. const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0
  328. // A checkpoint from a PRIOR lifecycle of the same id (different createdAt):
  329. // its rows pass every watermark check, but the identity does not match.
  330. seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 })
  331. const { cache, pool: samePool } = await harness({ pool, logs })
  332. const snapshot = await cache.coldSnapshot(SessionId('reborn'))
  333. expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
  334. // The write-back rebinds the record to the actual log's identity.
  335. expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 })
  336. })
  337. it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => {
  338. const pool = new MemoryMediaPool()
  339. seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } })
  340. const { cache } = await harness({ pool })
  341. expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
  342. })
  343. it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
  344. const pool = new MemoryMediaPool()
  345. seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
  346. const { cache } = await harness({ pool })
  347. const id = SessionId('homed')
  348. expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
  349. expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
  350. expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
  351. })
  352. it('dates an empty stored log at -1 in the zero-units topology', async () => {
  353. const pool = new MemoryMediaPool()
  354. const logs = new Map([['empty', [] as SessionEvent[]]])
  355. const ctx = new Context()
  356. contexts.push(ctx)
  357. await ctx.plugin(Storage)
  358. ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
  359. const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
  360. ctx.storage.mount('domain', facility)
  361. ctx.provide('storageDomain', facility)
  362. await ctx.plugin(SessionStore)
  363. await ctx.plugin(SessionProjectionRegistry)
  364. ctx.provide('sessionPersistence', fakePersistence(logs) as never)
  365. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  366. await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('empty')))
  367. .resolves.toEqual({ asOfSeq: -1, values: {} })
  368. })
  369. it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
  370. const pool = new MemoryMediaPool()
  371. seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } })
  372. const { cache } = await harness({ pool })
  373. const id = SessionId('listed')
  374. // Matching header: values plus the watermark the client seeds under.
  375. expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
  376. // A recreated id (different createdAt): the record is unrelated — no block.
  377. expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
  378. // Unknown id: no block.
  379. expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
  380. })
  381. it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => {
  382. // Same composition minus any registered unit: restoreFloor is undefined,
  383. // yet coldSnapshot must still reject for an absent log (probe read) and
  384. // serve an empty cut at the stored end for a present one.
  385. const pool = new MemoryMediaPool()
  386. const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
  387. const ctx = new Context()
  388. contexts.push(ctx)
  389. await ctx.plugin(Storage)
  390. ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
  391. const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
  392. ctx.storage.mount('domain', facility)
  393. ctx.provide('storageDomain', facility)
  394. await ctx.plugin(SessionStore)
  395. await ctx.plugin(SessionProjectionRegistry)
  396. ctx.provide('sessionPersistence', fakePersistence(logs) as never)
  397. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  398. await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
  399. await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare')))
  400. .resolves.toEqual({ asOfSeq: 2, values: {} })
  401. })
  402. })