cache.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  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. * synchronous cached listing read. The durable medium is the
  6. * `session_projcache` storage domain in per-record layout: one
  7. * version-stamped document per session under the json backend root at
  8. * `<root>/session_projcache/sessions/<id>.json`. Reads never touch the
  9. * medium — they come from the domain's in-memory tables, which writes mutate
  10. * only after durability.
  11. */
  12. import { afterEach, describe, expect, it, vi } from 'vitest'
  13. import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  14. import { tmpdir } from 'node:os'
  15. import { dirname, join } from 'node:path'
  16. import { Context } from '@deepseek-ai/cordis'
  17. import { z } from 'zod'
  18. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  19. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  20. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  21. import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
  22. import Storage from '@deepseek-ai/dsh-storage'
  23. import {
  24. apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName,
  25. } from '@deepseek-ai/dsh-storage-json'
  26. import {
  27. apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName,
  28. } from '@deepseek-ai/dsh-storage-domain'
  29. import SessionProjectionCache from '../src/index.ts'
  30. import { checkpointRecord, projectionCacheDomainSpec } from '../src/spec.ts'
  31. import type { CheckpointRecord } from '../src/spec.ts'
  32. declare module '@deepseek-ai/dsh-session-projection/types' {
  33. interface SessionProjectionStateMap {
  34. 'cache-test/marks': MarksState
  35. 'cache-test/marks2': Map<string, string>
  36. 'cache-test/count': number
  37. }
  38. interface SessionProjectionMap {
  39. 'cache-test/marks': { marks: string[] }
  40. }
  41. }
  42. declare module '@deepseek-ai/dsh-session/types' {
  43. interface SessionEventMap {
  44. 'cache-test/mark': { marks: string[] }
  45. }
  46. interface OutOfBandSessionEventMap {
  47. 'cache-test/mark': true
  48. }
  49. }
  50. type MarksState = { marks: string[] } | null
  51. const marksUnit = (stateVersion = 1) => ({
  52. key: 'cache-test/marks',
  53. stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
  54. init: () => null,
  55. apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
  56. wire: {
  57. viewSchema: z.object({ marks: z.array(z.string()) }),
  58. view: state => state ?? { marks: [] },
  59. },
  60. stateVersion,
  61. }) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
  62. /** One session's record document on the per-record medium. */
  63. const recordPath = (root: string, id: Session['id']): string =>
  64. join(root, projectionCacheDomainSpec.name, 'sessions', `${String(id)}.json`)
  65. /** Header shape for cachedSnapshot calls. */
  66. const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
  67. ({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
  68. interface HarnessOptions {
  69. root?: string
  70. config?: { writeEveryEvents: number; writeIntervalMs: number }
  71. stateVersion?: number
  72. }
  73. const contexts: Context[] = []
  74. const roots: string[] = []
  75. async function harness(options: HarnessOptions = {}) {
  76. const root = options.root ?? await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  77. roots.push(root)
  78. const ctx = new Context()
  79. contexts.push(ctx)
  80. // The cache opens its domain through the storage stack; the json backend
  81. // lands the per-record tree under this tmp root.
  82. await ctx.plugin(Storage)
  83. await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
  84. await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
  85. await ctx.plugin(SessionStore)
  86. await ctx.plugin(SessionProjectionRegistry)
  87. ctx.sessionProjections.register(marksUnit(options.stateVersion))
  88. const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  89. return { ctx, root, fiber, cache: ctx.sessionProjectionCache }
  90. }
  91. const mark = (session: Session, marks: string[]): SessionEvent =>
  92. session.append('cache-test/mark', { marks })
  93. const endTurn = (session: Session): SessionEvent =>
  94. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  95. /** The stored record for one session id (undefined = absent or unreadable). */
  96. async function storedRecord(root: string, id: Session['id']): Promise<CheckpointRecord | undefined> {
  97. try {
  98. const document = JSON.parse(await readFile(recordPath(root, id), 'utf8')) as { record: unknown }
  99. return checkpointRecord.parse(document.record)
  100. } catch {
  101. return undefined
  102. }
  103. }
  104. /** The stored rows for one session id (undefined = absent or unreadable). */
  105. async function storedRows(root: string, id: Session['id']): Promise<CheckpointRecord['rows'] | undefined> {
  106. return (await storedRecord(root, id))?.rows
  107. }
  108. /** Pre-seed one session's record document with a stored checkpoint record. */
  109. async function seedRecord(
  110. root: string,
  111. id: string,
  112. rows: CheckpointRecord['rows'],
  113. identity: CheckpointRecord['identity'] = { createdAt: 0 },
  114. ): Promise<void> {
  115. const path = recordPath(root, SessionId(id))
  116. await mkdir(dirname(path), { recursive: true })
  117. await writeFile(path, JSON.stringify({ version: projectionCacheDomainSpec.version, record: { identity, rows } }))
  118. }
  119. /** Wait until queued fail-soft writes (event-listener fire-and-forget over real fs I/O) drain. */
  120. const settle = () => new Promise(resolve => setTimeout(resolve, 40))
  121. afterEach(async () => {
  122. vi.useRealTimers()
  123. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  124. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })))
  125. })
  126. describe('SessionProjectionCache write policy', () => {
  127. it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
  128. const { ctx, root } = await harness()
  129. const session = ctx.sessions.create(SessionId('turn-end'))
  130. mark(session, ['a'])
  131. // Creation already wrote the init cut; the mark is throttled, so the
  132. // stored row is still the creation-time cut (no marks folded).
  133. await settle()
  134. expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1)
  135. const end = endTurn(session)
  136. await settle()
  137. const rows = await storedRows(root, session.id)
  138. expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
  139. })
  140. it('writes a checkpoint at session creation, capturing the seed-derived cut', async () => {
  141. const { ctx, root } = await harness()
  142. // A forked child seeded with its ancestor's title-like event: no
  143. // conversation follows, yet the creation write must capture the fold so
  144. // a crash or a live-held fork still lists the derived value.
  145. const session = ctx.sessions.create(SessionId('seeded'), {
  146. seed: [{ type: 'cache-test/mark', seq: 0, time: 1, data: { marks: ['seed'] } }] as SessionEvent[],
  147. })
  148. await settle()
  149. expect((await storedRows(root, session.id))?.['cache-test/marks']?.val)
  150. .toEqual({ marks: ['seed'] })
  151. })
  152. it('writes at session disposal (detach, the live-to-cold moment)', async () => {
  153. const { ctx, root } = await harness()
  154. // Sessions dispose with their owning fiber: create in a child plugin.
  155. let session: Session | undefined
  156. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  157. session = inner.sessions.create(SessionId('detach'))
  158. }, { inject: ['sessions'] }))
  159. if (session === undefined) throw new Error('session was not created')
  160. mark(session, ['live'])
  161. await owner.dispose()
  162. await settle()
  163. expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
  164. })
  165. it('flushes when the in-turn event count reaches the configured threshold', async () => {
  166. const { ctx, root } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
  167. const session = ctx.sessions.create(SessionId('count'))
  168. mark(session, ['1'])
  169. mark(session, ['2'])
  170. await settle()
  171. expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut
  172. mark(session, ['3'])
  173. await settle()
  174. expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
  175. })
  176. it('flushes on the configured interval when the count threshold is not reached', async () => {
  177. const { ctx, cache } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } })
  178. const write = vi.spyOn(cache, 'write').mockResolvedValue()
  179. vi.useFakeTimers()
  180. const session = ctx.sessions.create(SessionId('interval'))
  181. write.mockClear()
  182. mark(session, ['slow'])
  183. await vi.advanceTimersByTimeAsync(19)
  184. expect(write).not.toHaveBeenCalled()
  185. await vi.advanceTimersByTimeAsync(1)
  186. expect(write).toHaveBeenCalledExactlyOnceWith(session)
  187. })
  188. it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
  189. const { ctx, root } = await harness()
  190. // Never dirtied: no events — write() still lands the init-derived cut.
  191. const clean = ctx.sessions.create(SessionId('clean-write'))
  192. await ctx.sessionProjectionCache.write(clean)
  193. expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
  194. // A unit whose state violates the plain-JSON contract fails the write loud.
  195. ctx.sessionProjections.register({
  196. key: 'cache-test/marks2',
  197. stateSchema: z.custom<Map<string, string>>(() => true),
  198. init: () => new Map<string, string>(),
  199. apply: state => state,
  200. stateVersion: 1,
  201. })
  202. await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable')
  203. })
  204. it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
  205. vi.useFakeTimers()
  206. const { ctx, root, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
  207. const armed = ctx.sessions.create(SessionId('armed'))
  208. const cleaned = ctx.sessions.create(SessionId('cleaned'))
  209. mark(armed, ['pending']) // timer armed, no write yet
  210. mark(cleaned, ['done'])
  211. endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map
  212. await vi.advanceTimersByTimeAsync(0)
  213. await fiber.dispose()
  214. // The armed timer died with the plugin: advancing time writes nothing.
  215. await vi.advanceTimersByTimeAsync(10_000)
  216. // Only the creation cut exists: the armed mark never wrote.
  217. expect((await storedRows(root, armed.id))?.['cache-test/marks']?.seq).toBe(-1)
  218. })
  219. it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
  220. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  221. roots.push(root)
  222. const ctx = new Context()
  223. contexts.push(ctx)
  224. await ctx.plugin(Storage)
  225. await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
  226. await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
  227. await ctx.plugin(SessionStore)
  228. await ctx.plugin(SessionProjectionRegistry)
  229. ctx.sessionProjections.register(marksUnit())
  230. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  231. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  232. // A directory where the record document must land makes the atomic
  233. // rename fail — including the creation write, so no row ever lands.
  234. const blocker = recordPath(root, SessionId('fail-soft'))
  235. await mkdir(blocker, { recursive: true })
  236. const session = ctx.sessions.create(SessionId('fail-soft'))
  237. mark(session, ['x'])
  238. endTurn(session)
  239. await settle()
  240. expect(await storedRows(root, session.id)).toBeUndefined()
  241. expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
  242. // Self-heal: once the blocker clears, the next mandatory point writes.
  243. await rm(recordPath(root, session.id), { recursive: true })
  244. mark(session, ['y'])
  245. endTurn(session)
  246. await settle()
  247. expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
  248. })
  249. })
  250. describe('SessionProjectionCache listing read', () => {
  251. it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
  252. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  253. roots.push(root)
  254. await seedRecord(root, 'listed', { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['t'] } } })
  255. const { cache } = await harness({ root })
  256. const id = SessionId('listed')
  257. // Matching header: values plus the watermark the client seeds under.
  258. expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
  259. // A recreated id (different createdAt): the record is unrelated — no block.
  260. expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
  261. // Unknown id: no block.
  262. expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
  263. })
  264. it('returns undefined when the stored record is version-mismatched', async () => {
  265. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  266. roots.push(root)
  267. // A stale version-stamped document is discarded at open: absent record.
  268. const path = recordPath(root, SessionId('all-stale'))
  269. await mkdir(dirname(path), { recursive: true })
  270. await writeFile(path, JSON.stringify({
  271. version: projectionCacheDomainSpec.version + 1,
  272. record: { identity: { createdAt: 0 }, rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['old'] } } } },
  273. }))
  274. const { cache } = await harness({ root })
  275. expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
  276. })
  277. it('returns undefined when every stored row is version-mismatched', async () => {
  278. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  279. roots.push(root)
  280. // A current document whose rows all fail the live unit's stateVersion:
  281. // the listing view is empty, so no block is served.
  282. await seedRecord(root, 'row-stale', { 'cache-test/marks': { ver: 99, seq: 4, val: { marks: ['old'] } } })
  283. const { cache } = await harness({ root })
  284. expect(cache.cachedSnapshot(headerOf(SessionId('row-stale')))).toBeUndefined()
  285. })
  286. it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
  287. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  288. roots.push(root)
  289. await seedRecord(root, 'homed', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['w'] } } }, { createdAt: 0, cwd: '/work' })
  290. const { cache } = await harness({ root })
  291. const id = SessionId('homed')
  292. expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
  293. expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
  294. expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
  295. })
  296. it('returns undefined for a malformed record document (refold from the log on the caller side)', async () => {
  297. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  298. roots.push(root)
  299. const path = recordPath(root, SessionId('malformed'))
  300. await mkdir(dirname(path), { recursive: true })
  301. await writeFile(path, 'not json at all')
  302. const { cache } = await harness({ root })
  303. expect(cache.cachedSnapshot(headerOf(SessionId('malformed')))).toBeUndefined()
  304. })
  305. })
  306. describe('SessionProjectionCache cold-read seeding', () => {
  307. /** One session's event log: turn/start, one mark per group, turn/end. */
  308. const storedLog = (marks: string[][]): SessionEvent[] => {
  309. const events: SessionEvent[] = [
  310. { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
  311. ]
  312. for (const m of marks) {
  313. events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } })
  314. }
  315. events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } })
  316. return events
  317. }
  318. it('hydratePrepared seeds from a matching row and retries from the exact log on a malformed one', async () => {
  319. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  320. roots.push(root)
  321. // Records land on disk before the domain opens, so the in-memory table
  322. // picks them up at init.
  323. await seedRecord(root, 'prepared-seeded', {
  324. 'cache-test/marks': { ver: 1, seq: 1, val: { marks: ['cached'] } },
  325. })
  326. await seedRecord(root, 'prepared-fallback', {
  327. 'cache-test/marks': { ver: 1, seq: 1, val: { marks: 'malformed' } },
  328. })
  329. const { cache } = await harness({ root })
  330. const events = storedLog([['fresh']])
  331. // A matching row hydrates the prepared Session without a persistence read.
  332. const seeded = headerOf(SessionId('prepared-seeded'))
  333. const seededSession = Session.create(seeded.id, events, seeded)
  334. expect(cache.hydratePrepared(seededSession, seeded, events)).toEqual({
  335. asOfSeq: 2,
  336. values: { 'cache-test/marks': { marks: ['cached'] } },
  337. })
  338. // A malformed row cannot seed the fold; hydration falls back to the
  339. // exact log so a valid Session stays readable.
  340. const fallback = headerOf(SessionId('prepared-fallback'))
  341. const fallbackSession = Session.create(fallback.id, events, fallback)
  342. expect(cache.hydratePrepared(fallbackSession, fallback, events)).toEqual({
  343. asOfSeq: 2,
  344. values: { 'cache-test/marks': { marks: ['fresh'] } },
  345. })
  346. // No row at all: hydrate from init over the exact log.
  347. const bare = headerOf(SessionId('prepared-bare'))
  348. const bareSession = Session.create(bare.id, events, bare)
  349. expect(cache.hydratePrepared(bareSession, bare, events)).toEqual({
  350. asOfSeq: 2,
  351. values: { 'cache-test/marks': { marks: ['fresh'] } },
  352. })
  353. })
  354. it('coldSnapshot traverses the full log but applies only the events after each cached watermark', async () => {
  355. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  356. roots.push(root)
  357. // A cached row covering the prefix through seq 2 (three applies folded).
  358. await seedRecord(root, 'cold-snap', {
  359. 'cache-test/count': { ver: 1, seq: 2, val: 3 },
  360. }, { createdAt: 9 })
  361. const { cache, ctx } = await harness({ root })
  362. const apply = vi.fn((_state: number, _event: SessionEvent) => 1)
  363. ctx.sessionProjections.register({
  364. key: 'cache-test/count',
  365. stateSchema: z.number().int().nonnegative(),
  366. init: () => 0,
  367. apply,
  368. stateVersion: 1,
  369. } satisfies ProjectionDefinition<'cache-test/count', number>)
  370. const meta = headerOf(SessionId('cold-snap'), 9)
  371. const events = Array.from({ length: 5 }, (_, seq) => ({
  372. type: 'cache-test/mark', seq, time: seq, data: { marks: [`m${seq}`] },
  373. })) as SessionEvent[]
  374. const snapshot = cache.coldSnapshot(meta, events)
  375. // The full log was traversed, but the fold applied only seqs 3 and 4.
  376. expect(apply).toHaveBeenCalledTimes(2)
  377. expect(apply.mock.calls.map(call => call[1].seq)).toEqual([3, 4])
  378. expect(snapshot.asOfSeq).toBe(4)
  379. // Host-only unit: folded but not served; the refreshed row is written
  380. // back (fail-soft, fire-and-forget) once the write lands.
  381. expect(Object.keys(snapshot.values)).not.toContain('cache-test/count')
  382. await settle()
  383. expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
  384. // No cached row yet: the first cold read folds from init over the full
  385. // log and creates the cache row (the `?? {}` seed path).
  386. const fresh = headerOf(SessionId('cold-fresh'), 10)
  387. cache.coldSnapshot(fresh, events)
  388. expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
  389. await settle()
  390. expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
  391. })
  392. it('coldSnapshot write-back is fail-soft: a failed durable write logs and never throws', async () => {
  393. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  394. roots.push(root)
  395. const ctx = new Context()
  396. contexts.push(ctx)
  397. await ctx.plugin(Storage)
  398. await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
  399. await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
  400. await ctx.plugin(SessionStore)
  401. await ctx.plugin(SessionProjectionRegistry)
  402. ctx.sessionProjections.register(marksUnit())
  403. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  404. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  405. // A directory where the record document must land makes the write-back
  406. // fail; the cold read itself still succeeds and never throws.
  407. const meta = headerOf(SessionId('cold-fail'))
  408. await mkdir(recordPath(root, meta.id), { recursive: true })
  409. expect(ctx.sessionProjectionCache.coldSnapshot(meta, [])).toBeDefined()
  410. await settle()
  411. expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "cold-fail" failed'))
  412. })
  413. })