fixtures.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. /**
  2. * Cross-version recovery over archived on-disk artifacts. `fixtures/` holds
  3. * real `session_projcache` media, each produced by driving the named release
  4. * through its own web app (session created over RPC, real model turns, a
  5. * rename): the v3 whole-unit file (published 0.1.1-rc.2), a v4 per-record
  6. * document (published 0.1.2-alpha.3), a published v5 document, and the
  7. * v5-stamped lineage-less document reproducing byte-for-byte what the
  8. * formerly unguarded legacy bootstrap wrote over v3 records. Each must open
  9. * through the real storage stack without becoming a fold shortcut for the
  10. * current Session format, then accept a current checkpoint rewrite. A record
  11. * that fails schema validation is backed up and skipped instead of failing the
  12. * boot.
  13. */
  14. import { afterEach, describe, expect, it, vi } from 'vitest'
  15. import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
  16. import { tmpdir } from 'node:os'
  17. import { dirname, join } from 'node:path'
  18. import { fileURLToPath } from 'node:url'
  19. import { Context } from '@deepseek-ai/cordis'
  20. import { z } from 'zod'
  21. import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
  22. import type { SessionHeader } from '@deepseek-ai/dsh-session'
  23. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  24. import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
  25. import Storage from '@deepseek-ai/dsh-storage'
  26. import {
  27. apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName,
  28. } from '@deepseek-ai/dsh-storage-json'
  29. import {
  30. apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName,
  31. } from '@deepseek-ai/dsh-storage-domain'
  32. import SessionProjectionCache from '../src/index.ts'
  33. import { projectionCacheDomainSpec } from '../src/spec.ts'
  34. // Declarations must match the shipped title unit's exactly (the repo-wide
  35. // compile face sees both).
  36. declare module '@deepseek-ai/dsh-session-projection/types' {
  37. interface SessionProjectionStateMap {
  38. title: string | null
  39. }
  40. interface SessionProjectionMap {
  41. title: string | null
  42. }
  43. }
  44. declare module '@deepseek-ai/dsh-session/types' {
  45. interface SessionEventMap {
  46. 'fixtures-test/set-title': { title: string }
  47. }
  48. interface OutOfBandSessionEventMap {
  49. 'fixtures-test/set-title': true
  50. }
  51. }
  52. // Mirrors the shipped title unit's storage face: stateVersion 1, bare-string
  53. // state (the fixture rows carry exactly this shape in every archived
  54. // version), folding a test event so the rewrite path has fresh data.
  55. const titleUnit = {
  56. key: 'title',
  57. stateSchema: z.string().nullable(),
  58. init: () => null,
  59. apply: (state, event) => (event.type === 'fixtures-test/set-title' ? event.data.title : state),
  60. wire: { viewSchema: z.string().nullable(), view: state => state },
  61. stateVersion: 1,
  62. } satisfies ProjectionDefinition<'title', string | null>
  63. const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url))
  64. /** One archived per-record document (`{version, record}`). */
  65. interface FixtureDoc {
  66. version: number
  67. record: {
  68. identity: { createdAt: number; cwd?: string }
  69. rows: Record<string, { ver: number; seq: number; val: unknown }>
  70. }
  71. }
  72. async function fixtureJson<T>(name: string): Promise<T> {
  73. return JSON.parse(await readFile(join(FIXTURES, name), 'utf8')) as T
  74. }
  75. /** Header for the session a fixture record is bound to (identity witness). */
  76. function headerFor(id: SessionId, identity: FixtureDoc['record']['identity']): SessionHeader {
  77. return {
  78. version: SESSION_FORMAT_VERSION,
  79. id,
  80. createdAt: identity.createdAt,
  81. isSeeded: false,
  82. ...identity.cwd === undefined ? {} : { cwd: identity.cwd },
  83. }
  84. }
  85. const contexts: Context[] = []
  86. const roots: string[] = []
  87. async function harness(root: string) {
  88. roots.push(root)
  89. const ctx = new Context()
  90. contexts.push(ctx)
  91. await ctx.plugin(Storage)
  92. await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
  93. await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
  94. await ctx.plugin(SessionStore)
  95. await ctx.plugin(SessionProjectionRegistry)
  96. ctx.sessionProjections.register(titleUnit)
  97. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  98. return { ctx, cache: ctx.sessionProjectionCache }
  99. }
  100. /** Lay one per-record fixture document into a fresh backend root. */
  101. async function placeDoc(root: string, id: string, name: string): Promise<FixtureDoc> {
  102. const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`)
  103. await mkdir(dirname(path), { recursive: true })
  104. await cp(join(FIXTURES, name), path)
  105. return fixtureJson<FixtureDoc>(name)
  106. }
  107. /**
  108. * Drive a live write over a recovered session id and assert the archived
  109. * document is replaced by a current-version one: current domain and Session
  110. * format stamps, lineage, and the freshly folded title.
  111. */
  112. async function assertRewrite(ctx: Context, root: string, id: SessionId): Promise<void> {
  113. const session = ctx.sessions.create(id)
  114. session.append('fixtures-test/set-title', { title: '重写标题' })
  115. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  116. const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`)
  117. await vi.waitFor(async () => {
  118. const doc = JSON.parse(await readFile(path, 'utf8')) as FixtureDoc
  119. expect(doc.version).toBe(projectionCacheDomainSpec.version)
  120. expect(doc.record.identity).toMatchObject({
  121. formatVersion: SESSION_FORMAT_VERSION,
  122. isSeeded: false,
  123. inheritedEventCount: 0,
  124. })
  125. expect(doc.record.rows['title']?.val).toBe('重写标题')
  126. }, { timeout: 5_000 })
  127. }
  128. afterEach(async () => {
  129. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  130. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })))
  131. })
  132. describe('archived version recovery', () => {
  133. it('recovers the v3 whole-unit archive through the legacy bootstrap', async () => {
  134. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
  135. await cp(join(FIXTURES, 'v3-single-unit.json'), join(root, `${projectionCacheDomainSpec.name}.json`))
  136. type SingleUnit = {
  137. unit: { version: number }
  138. tables: { sessions: Record<string, FixtureDoc['record']> }
  139. }
  140. const archive = await fixtureJson<SingleUnit>('v3-single-unit.json')
  141. expect(archive.unit.version).toBe(3) // the fixture IS the old format
  142. const [sid, record] = Object.entries(archive.tables.sessions)[0]!
  143. const { ctx, cache } = await harness(root)
  144. expect(cache.cachedSnapshot(
  145. headerFor(SessionId(sid), record.identity),
  146. SessionLogOffset(0),
  147. ['title'],
  148. )).toBeUndefined()
  149. expect(cache.cachedPredecessorTitle(
  150. headerFor(SessionId(sid), record.identity),
  151. SessionLogOffset(0),
  152. )).toEqual({
  153. asOfSeq: -1,
  154. values: { title: record.rows.title?.val },
  155. })
  156. // The one-time bootstrap materialized a current-version document.
  157. const migrated = JSON.parse(
  158. await readFile(join(root, projectionCacheDomainSpec.name, 'sessions', `${sid}.json`), 'utf8'),
  159. ) as { version: number }
  160. expect(migrated.version).toBe(projectionCacheDomainSpec.version)
  161. await assertRewrite(ctx, root, SessionId(sid))
  162. })
  163. for (const [fixture, storedVersion] of [
  164. ['v4-session-doc.json', 4],
  165. ['v5-session-doc.json', 5],
  166. ['v5-lineageless-doc.json', 5],
  167. ] as const) {
  168. it(`opens ${fixture} without serving its unbound fold, then rewrites it current`, async () => {
  169. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
  170. const id = SessionId('fixture-session')
  171. const doc = await placeDoc(root, id, fixture)
  172. expect(doc.version).toBe(storedVersion)
  173. const { ctx, cache } = await harness(root)
  174. expect(cache.cachedSnapshot(
  175. headerFor(id, doc.record.identity),
  176. SessionLogOffset(0),
  177. ['title'],
  178. )).toBeUndefined()
  179. expect(cache.cachedPredecessorTitle(
  180. headerFor(id, doc.record.identity),
  181. SessionLogOffset(0),
  182. )).toEqual({
  183. asOfSeq: -1,
  184. values: { title: doc.record.rows.title?.val },
  185. })
  186. await assertRewrite(ctx, root, id)
  187. })
  188. }
  189. it('serves an explicitly older format title but never a current or newer one through the predecessor path', async () => {
  190. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
  191. const sessionsDir = join(root, projectionCacheDomainSpec.name, 'sessions')
  192. await mkdir(sessionsDir, { recursive: true })
  193. const write = async (id: string, formatVersion: number, rowVersion = 1): Promise<void> => {
  194. await writeFile(join(sessionsDir, `${id}.json`), JSON.stringify({
  195. version: projectionCacheDomainSpec.version,
  196. record: {
  197. identity: {
  198. formatVersion,
  199. createdAt: 10,
  200. cwd: '/work',
  201. isSeeded: false,
  202. inheritedEventCount: 0,
  203. },
  204. rows: { title: { ver: rowVersion, seq: 2, val: `${id} title` } },
  205. },
  206. }))
  207. }
  208. await write('older', SESSION_FORMAT_VERSION - 1)
  209. await write('current', SESSION_FORMAT_VERSION)
  210. await write('newer', SESSION_FORMAT_VERSION + 1)
  211. await write('stale-title', SESSION_FORMAT_VERSION - 1, 2)
  212. const { cache } = await harness(root)
  213. const listed = (id: string): SessionHeader => ({
  214. version: SESSION_FORMAT_VERSION,
  215. id: SessionId(id),
  216. createdAt: 10,
  217. cwd: '/work',
  218. isSeeded: false,
  219. })
  220. expect(cache.cachedPredecessorTitle(listed('older'), SessionLogOffset(0))).toEqual({
  221. asOfSeq: -1,
  222. values: { title: 'older title' },
  223. })
  224. expect(cache.cachedPredecessorTitle(listed('current'), SessionLogOffset(0))).toBeUndefined()
  225. expect(cache.cachedPredecessorTitle(listed('newer'), SessionLogOffset(0))).toBeUndefined()
  226. expect(cache.cachedPredecessorTitle(listed('stale-title'), SessionLogOffset(0))).toBeUndefined()
  227. expect(cache.cachedPredecessorTitle(listed('missing'), SessionLogOffset(0))).toBeUndefined()
  228. })
  229. it('refuses a lineage-less archive for a seeded caller (identity mismatch, cold rebuild)', async () => {
  230. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
  231. const id = SessionId('fixture-seeded')
  232. const doc = await placeDoc(root, id, 'v5-lineageless-doc.json')
  233. const { cache } = await harness(root)
  234. const seeded = { ...headerFor(id, doc.record.identity), isSeeded: true }
  235. expect(cache.cachedSnapshot(seeded, SessionLogOffset(2), ['title'])).toBeUndefined()
  236. expect(cache.cachedPredecessorTitle(seeded, SessionLogOffset(2))).toBeUndefined()
  237. })
  238. it('backs up and skips a record that fails schema validation instead of failing the boot', async () => {
  239. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
  240. roots.push(root)
  241. const sessionsDir = join(root, projectionCacheDomainSpec.name, 'sessions')
  242. await mkdir(sessionsDir, { recursive: true })
  243. // Current-version stamp, hopeless record content: no compat rung can save it.
  244. await writeFile(join(sessionsDir, 'broken.json'), JSON.stringify({
  245. version: projectionCacheDomainSpec.version,
  246. record: { identity: { createdAt: 'not-a-number' }, rows: 'not-an-object' },
  247. }))
  248. const good = await placeDoc(root, SessionId('survivor'), 'v5-session-doc.json')
  249. const ctx = new Context()
  250. contexts.push(ctx)
  251. await ctx.plugin(Storage)
  252. await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
  253. await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
  254. await ctx.plugin(SessionStore)
  255. await ctx.plugin(SessionProjectionRegistry)
  256. ctx.sessionProjections.register(titleUnit)
  257. const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  258. // The boot survives the broken record — this line rejecting IS the fixed bug.
  259. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  260. // Concrete console diagnostics: which record, where it went, and why.
  261. expect(error).toHaveBeenCalledWith(expect.stringContaining("record 'broken'"))
  262. expect(error).toHaveBeenCalledWith(expect.stringContaining('.json.bak.'))
  263. // The document was moved aside as <key>.json.bak.<YYYYMMDDHHmm>, bytes intact.
  264. const entries = await readdir(sessionsDir)
  265. expect(entries).not.toContain('broken.json')
  266. const backup = entries.find(name => /^broken\.json\.bak\.\d{12}$/.test(name))
  267. expect(backup).toBeDefined()
  268. expect(JSON.parse(await readFile(join(sessionsDir, backup!), 'utf8')))
  269. .toMatchObject({ record: { rows: 'not-an-object' } })
  270. // The broken record reads as absent; its predecessor-stamped neighbor
  271. // remains available for a safe current rewrite.
  272. const cache = ctx.sessionProjectionCache
  273. expect(cache.cachedSnapshot(headerFor(SessionId('broken'), { createdAt: 0 }), SessionLogOffset(0)))
  274. .toBeUndefined()
  275. expect(cache.cachedSnapshot(
  276. headerFor(SessionId('survivor'), good.record.identity),
  277. SessionLogOffset(0),
  278. ['title'],
  279. )).toBeUndefined()
  280. await assertRewrite(ctx, root, SessionId('survivor'))
  281. })
  282. })