persistence-schema.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { dirname, join, resolve } from 'node:path'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { collectLogEvents } from './gen-persistence-catalog.ts'
  6. import { extractPersistenceSchema } from './persistence-schema.ts'
  7. import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
  8. const roots: string[] = []
  9. afterEach(() => {
  10. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 3 })
  11. })
  12. function put(root: string, file: string, source: string): void {
  13. const path = join(root, file)
  14. mkdirSync(dirname(path), { recursive: true })
  15. writeFileSync(path, source)
  16. }
  17. function fixture(payload: string, options: { surface?: string; event?: string } = {}): string {
  18. const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-schema-'))
  19. roots.push(root)
  20. put(root, 'tsconfig.host.json', JSON.stringify({ compilerOptions: {
  21. target: 'es2024', module: 'esnext', moduleResolution: 'bundler', strict: true, skipLibCheck: true,
  22. types: [], paths: {
  23. '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'],
  24. '@fixture/payload': ['./packages/domain/payload/src/types.ts'],
  25. },
  26. }, include: ['packages/**/src/**/*.ts'] }))
  27. put(root, 'packages/core/session/package.json', '{"name":"@deepseek-ai/dsh-session"}')
  28. put(root, 'packages/domain/payload/package.json', '{"name":"@fixture/payload"}')
  29. put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {type: 'session'; version: number; id: string; delegationDepth: number}\nexport {}\n")
  30. put(root, 'packages/domain/payload/src/types.ts', payload)
  31. put(root, 'packages/core/session/src/types.ts', `
  32. import type { Payload } from '@fixture/payload'
  33. export interface SessionHeader { version: 3; id: string; createdAt: number }
  34. export interface SessionEventMap {
  35. /** One recorded payload. */
  36. 'test/record': Payload
  37. ${options.event ?? ''}
  38. }
  39. export type SurfaceEventType = ${options.surface ?? 'never'}
  40. export type SessionEvent<T extends keyof SessionEventMap = keyof SessionEventMap> = {
  41. [K in keyof SessionEventMap]: { type: K; seq: number; time: number; data: SessionEventMap[K] }
  42. & (K extends SurfaceEventType ? { surfaceOp: 'append' | { replace: number } } : { surfaceOp?: never })
  43. }[T]
  44. `)
  45. return root
  46. }
  47. function event(inventory: PersistenceSchemaInventory, name = 'test/record'): string {
  48. const root = inventory.roots.find(root => root.event === name)
  49. if (root === undefined) throw new Error(`missing test event ${name}`)
  50. return root.digest
  51. }
  52. describe('persistent source type extraction', () => {
  53. it('ignores alias names, files, documentation, readonly, brands and property order', () => {
  54. const left = fixture('declare const brand: unique symbol; type Id = string & {readonly [brand]: "Id"}; export interface Payload { readonly id: Id; value?: number }')
  55. const right = fixture('type Renamed = string; /** Different documentation. */ export type Payload = {value?: number; id: Renamed}')
  56. expect(event(extractPersistenceSchema(left))).toBe(event(extractPersistenceSchema(right)))
  57. })
  58. it.each([
  59. ['nested field order',
  60. 'export interface Payload {\n id: string\n detail: {\n label: string\n count?: number\n }\n}',
  61. 'export interface Payload {\n detail: {\n count?: number\n label: string\n }\n id: string\n}'],
  62. ['anonymous union order',
  63. 'export type Payload =\n { a: string }\n | { b: number }\n | { c?: boolean }',
  64. 'export type Payload =\n { c?: boolean }\n | { b: number }\n | { a: string }'],
  65. ['recursive union order',
  66. 'export type Payload =\n { a: string; next?: Payload }\n | { b: number; next?: Payload }\n | null',
  67. 'export type Payload =\n null\n | { next?: Payload; b: number }\n | { next?: Payload; a: string }'],
  68. ['intersection and distributed union order',
  69. 'type A = { a: string } | { b: number }; type B = { c: boolean }; export type Payload = A & B',
  70. 'type A = { b: number } | { a: string }; type B = { c: boolean }; export type Payload = B & A'],
  71. ['index signature order',
  72. 'export interface Payload { [key: string]: string | number; [key: number]: number }',
  73. 'export interface Payload { [key: number]: number; [key: string]: number | string }'],
  74. ['type declaration order',
  75. 'interface A { a: string }\ninterface B { b: number }\nexport type Payload = A | B',
  76. 'interface B { b: number }\ninterface A { a: string }\nexport type Payload = B | A'],
  77. ['merged declaration order',
  78. 'export interface Payload { a: string }\nexport interface Payload { b: number }',
  79. 'export interface Payload { b: number }\nexport interface Payload { a: string }'],
  80. ['mapped key order',
  81. 'export type Payload = { [K in "a" | "b" | "c"]: string }',
  82. 'export type Payload = { [K in "c" | "b" | "a"]: string }'],
  83. ['explicit enum member order',
  84. 'enum Value {\n A = "a",\n B = "b",\n}\nexport interface Payload { value: Value }',
  85. 'enum Value {\n B = "b",\n A = "a",\n}\nexport interface Payload { value: Value }'],
  86. ])('keeps every root and type digest unchanged by %s', (_name, before, after) => {
  87. const root = fixture(before)
  88. const inventory = extractPersistenceSchema(root)
  89. put(root, 'packages/domain/payload/src/types.ts', after)
  90. const reordered = extractPersistenceSchema(root)
  91. expect(reordered.roots).toEqual(inventory.roots)
  92. expect(reordered.types.map(({ digest, schema }) => ({ digest, schema })))
  93. .toEqual(inventory.types.map(({ digest, schema }) => ({ digest, schema })))
  94. })
  95. it.each([
  96. ['tuple positions', 'export type Payload = [string, number]', 'export type Payload = [number, string]'],
  97. ['implicit enum values', 'enum Value { A, B }; export interface Payload { value: Value.A }', 'enum Value { B, A }; export interface Payload { value: Value.A }'],
  98. ])('changes the digest when reordering changes %s', (_name, before, after) => {
  99. const root = fixture(before)
  100. const digest = event(extractPersistenceSchema(root))
  101. put(root, 'packages/domain/payload/src/types.ts', after)
  102. expect(event(extractPersistenceSchema(root))).not.toBe(digest)
  103. })
  104. it('keeps header, envelope and event digests unchanged when root declarations are reordered', () => {
  105. const record = " /** One recorded payload. */\n 'test/record': Payload"
  106. const other = "/** Another recorded payload. */\n 'test/other': { value: number }"
  107. const root = fixture('export interface Payload { id: string }', {
  108. surface: "'test/record' | 'test/other'",
  109. event: other,
  110. })
  111. const inventory = extractPersistenceSchema(root)
  112. const source = readFileSync(join(root, 'packages/core/session/src/types.ts'), 'utf8')
  113. const reordered = source
  114. .replace(`${record}\n ${other}`, ` ${other}\n${record}`)
  115. .replace('version: 3; id: string; createdAt: number', 'createdAt: number; id: string; version: 3')
  116. .replace("'test/record' | 'test/other'", "'test/other' | 'test/record'")
  117. .replace('type: K; seq: number; time: number; data: SessionEventMap[K]', 'data: SessionEventMap[K]; time: number; seq: number; type: K')
  118. expect(reordered.indexOf("'test/other':")).toBeLessThan(reordered.indexOf("'test/record':"))
  119. put(root, 'packages/core/session/src/types.ts', reordered)
  120. put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {delegationDepth: number; id: string; version: number; type: 'session'}\nexport {}\n")
  121. const result = extractPersistenceSchema(root)
  122. expect(result.roots).toEqual(inventory.roots)
  123. expect(result.types.map(({ digest, schema }) => ({ digest, schema })))
  124. .toEqual(inventory.types.map(({ digest, schema }) => ({ digest, schema })))
  125. })
  126. it('materializes generic, conditional and mapped aliases into their concrete properties', () => {
  127. const generic = fixture('type Box<T> = { [K in keyof T]: T[K] extends string ? string[] : T[K] }; export type Payload = Box<{label: string; count?: number}>')
  128. const concrete = fixture('export interface Payload {count?: number; label: string[]}')
  129. expect(event(extractPersistenceSchema(generic))).toBe(event(extractPersistenceSchema(concrete)))
  130. })
  131. it('retains transitive recursion without depending on alias factoring', () => {
  132. const self = fixture('export interface Payload { value: string; next?: Payload }')
  133. const mutual = fixture('interface Other {next?: Payload; value: string} export interface Payload {value: string; next?: Other}')
  134. const changed = fixture('interface Other {next?: Payload; value: number} export interface Payload {value: string; next?: Other}')
  135. const digest = event(extractPersistenceSchema(self))
  136. expect(event(extractPersistenceSchema(mutual))).toBe(digest)
  137. expect(event(extractPersistenceSchema(changed))).not.toBe(digest)
  138. })
  139. it('includes independent Host modules that augment nested payload maps without event declarations', () => {
  140. const root = fixture('export interface NestedMap {first: {value: string}}; export type Payload = NestedMap[keyof NestedMap]')
  141. const before = extractPersistenceSchema(root)
  142. put(root, 'packages/domain/extension/src/index.ts', "import '@fixture/payload'; declare module '@fixture/payload' {interface NestedMap {second: {value: number}}}")
  143. const after = extractPersistenceSchema(root)
  144. expect(collectLogEvents(root)).toHaveLength(1)
  145. expect(event(after)).not.toBe(event(before))
  146. const data = after.types.find(item => item.schema.nodes[0]?.kind === 'union' && item.schema.nodes.some(node => node.kind === 'primitive' && node.type === 'number'))
  147. expect(data).toBeDefined()
  148. })
  149. it('discovers plugin event merges and keeps ordinary event additions out of the envelope digest', () => {
  150. const root = fixture('export interface Payload {id: string}')
  151. const before = extractPersistenceSchema(root)
  152. put(root, 'packages/domain/extension/src/index.ts', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
  153. interface SessionEventMap {
  154. /** A plugin event. */
  155. 'plugin/new': { optional?: boolean }
  156. }}
  157. `)
  158. const after = extractPersistenceSchema(root)
  159. expect(after.roots.some(root => root.event === 'plugin/new')).toBe(true)
  160. expect(after.roots.find(root => root.kind === 'envelope')?.digest).toBe(before.roots.find(root => root.kind === 'envelope')?.digest)
  161. expect(event(after)).toBe(event(before))
  162. })
  163. it('includes JSX-module events and rejects unsupported inherited declarations there', () => {
  164. const root = fixture('export interface Payload {id: string}')
  165. put(root, 'packages/domain/extension/src/index.tsx', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
  166. interface SessionEventMap {
  167. /** A JSX-module event. */
  168. 'plugin/tsx': { value: number }
  169. }}
  170. `)
  171. const config = join(root, 'tsconfig.host.json')
  172. const settings = JSON.parse(readFileSync(config, 'utf8')) as { include: string[] }
  173. settings.include.push('packages/**/src/**/*.tsx')
  174. writeFileSync(config, JSON.stringify(settings))
  175. expect(extractPersistenceSchema(root).roots.some(root => root.event === 'plugin/tsx')).toBe(true)
  176. put(root, 'packages/domain/extension/src/index.tsx', `import '@deepseek-ai/dsh-session/types';
  177. interface Extra { 'plugin/inherited': {value: number} }
  178. declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap extends Extra {} }
  179. `)
  180. expect(() => extractPersistenceSchema(root)).toThrow('uses extends')
  181. })
  182. it('rejects an event map whose compiler vocabulary exceeds the discovered source corpus', () => {
  183. const root = fixture("import '../../../../vendor/events.ts'; export interface Payload {id: string}")
  184. put(root, 'vendor/events.ts', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
  185. interface SessionEventMap {
  186. /** A contribution outside the package source corpus. */
  187. 'outside/event': { value: number }
  188. }}
  189. `)
  190. expect(() => extractPersistenceSchema(root)).toThrow('omitted: outside/event')
  191. })
  192. it('keeps surface metadata strict and optional field additions visible', () => {
  193. const old = extractPersistenceSchema(fixture('export interface Payload {id: string}'))
  194. const optional = extractPersistenceSchema(fixture('export interface Payload {id: string; detail?: string}'))
  195. const surface = extractPersistenceSchema(fixture('export interface Payload {id: string}', { surface: "'test/record'" }))
  196. expect(event(old)).not.toBe(event(optional))
  197. expect(event(old)).not.toBe(event(surface))
  198. expect(surface.roots.find(root => root.event === 'test/record')?.surface).toBe(true)
  199. expect(surface.roots.find(root => root.kind === 'envelope')?.digest).not.toBe(old.roots.find(root => root.kind === 'envelope')?.digest)
  200. })
  201. it('tracks the physical JSONL header independently of the logical Session header', () => {
  202. const root = fixture('export interface Payload {id: string}')
  203. const before = extractPersistenceSchema(root)
  204. put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {type: 'session'; version: number; id: string; delegationDepth: number; marker?: string}\nexport {}\n")
  205. const after = extractPersistenceSchema(root)
  206. expect(after.roots.find(root => root.key === 'JsonlHeaderLine')?.digest).not.toBe(before.roots.find(root => root.key === 'JsonlHeaderLine')?.digest)
  207. expect(after.roots.find(root => root.key === 'SessionHeader')?.digest).toBe(before.roots.find(root => root.key === 'SessionHeader')?.digest)
  208. })
  209. it('keeps arbitrary JSON structural while explicitly identifying unknown and any', () => {
  210. const root = fixture('type Arbitrary = null | boolean | number | string | Arbitrary[] | {[key: string]: Arbitrary}; export interface Payload {json: Arbitrary; opaque: unknown; unsafe: any}')
  211. const model = extractPersistenceSchema(root)
  212. expect(model.types.some(item => isArbitraryJsonSchema(item.schema))).toBe(true)
  213. expect(model.types.filter(item => item.schema.nodes[0]?.kind === 'opaque').map(item => item.schema.nodes[0]))
  214. .toEqual(expect.arrayContaining([{ kind: 'opaque', reason: 'unknown' }, { kind: 'opaque', reason: 'any' }]))
  215. put(root, 'packages/domain/payload/src/types.ts', 'type Arbitrary = null | boolean | number | string | Arbitrary[]; export interface Payload {json: Arbitrary; opaque: unknown; unsafe: any}')
  216. const changed = extractPersistenceSchema(root)
  217. expect(event(changed)).not.toBe(event(model))
  218. expect(changed.types.some(item => isArbitraryJsonSchema(item.schema))).toBe(false)
  219. })
  220. it('rejects invalid reachable declaration files despite inherited skipLibCheck', () => {
  221. const root = fixture("export type { Payload } from './declared.js'")
  222. put(root, 'packages/domain/payload/src/declared.d.ts', 'type Bad = ReturnType<42>; export interface Payload {value: Bad}')
  223. expect(() => extractPersistenceSchema(root)).toThrow('TS2344')
  224. })
  225. it('preserves authored declaration-file any without checking unrelated declarations', () => {
  226. const root = fixture("export type { Payload } from './declared.js'")
  227. put(root, 'packages/domain/payload/src/declared.d.ts', 'type Declared = any; export interface Payload {value: Declared}; type Unrelated = ReturnType<42>')
  228. const model = extractPersistenceSchema(root)
  229. expect(model.types.some(item => item.schema.nodes[0]?.kind === 'opaque' && item.schema.nodes[0].reason === 'any')).toBe(true)
  230. })
  231. it('locates named transitive definitions at their declarations instead of their references', () => {
  232. const root = fixture("import type { Detail as ImportedDetail } from './detail.js'\nexport interface Payload { first: ImportedDetail; second: ImportedDetail }")
  233. put(root, 'packages/domain/payload/src/detail.ts', '/** Detailed payload. */\nexport interface Detail { code: string; done: true }\n')
  234. const model = extractPersistenceSchema(root)
  235. const detail = model.types.find(item => item.names.includes('packages/domain/payload/src/detail.ts#Detail'))
  236. expect(detail?.sources).toEqual(['packages/domain/payload/src/detail.ts:2'])
  237. })
  238. it('keeps shared scalar declaration metadata at real aliases and omits plain property references', () => {
  239. const root = fixture([
  240. 'type Label = string',
  241. "type Done = 'done'",
  242. 'export interface Payload {',
  243. ' label: Label',
  244. ' other: string',
  245. ' state: Done',
  246. " fallback: 'done'",
  247. ' count: number',
  248. '}',
  249. ].join('\n'))
  250. const model = extractPersistenceSchema(root)
  251. const string = model.types.find(item => item.schema.nodes[0]?.kind === 'primitive' && item.schema.nodes[0].type === 'string')
  252. const literal = model.types.find(item => item.schema.nodes[0]?.kind === 'literal' && item.schema.nodes[0].value === 'done')
  253. const number = model.types.find(item => item.schema.nodes[0]?.kind === 'primitive' && item.schema.nodes[0].type === 'number')
  254. expect(string?.names).toEqual(['packages/domain/payload/src/types.ts#Label'])
  255. expect(string?.sources).toEqual(['packages/domain/payload/src/types.ts:1'])
  256. expect(literal?.names).toEqual(['packages/domain/payload/src/types.ts#Done'])
  257. expect(literal?.sources).toEqual(['packages/domain/payload/src/types.ts:2'])
  258. expect(number?.names).toEqual([])
  259. expect(number?.sources).toEqual([])
  260. })
  261. it('uses the declaration of an anonymous object literal and never its containing property', () => {
  262. const root = fixture('export interface Payload {\n detail:\n { code: string }\n}')
  263. const model = extractPersistenceSchema(root)
  264. const detail = model.types.find((item) => {
  265. const node = item.schema.nodes[0]
  266. return node?.kind === 'object' && node.properties.length === 1 && node.properties[0]?.name === 'code'
  267. })
  268. expect(detail?.sources).toEqual(['packages/domain/payload/src/types.ts:3'])
  269. })
  270. it.each([
  271. ['missing type', 'export interface Payload {value: Missing}', 'TS2304'],
  272. ['callable value', 'export interface Payload {value: () => void}', 'callable data'],
  273. ['class instance', 'export class Instance {value: string = ""}; export interface Payload {value: Instance}', 'class instances'],
  274. ['required undefined', 'export interface Payload {value: string | undefined}', 'no supported JSON'],
  275. ['unresolved template', 'export interface Payload {value: `prefix-${string}`}', 'no supported JSON'],
  276. ['empty object type', 'export type Payload = {}', 'unconstrained empty object'],
  277. ['symbol property', 'declare const key: unique symbol; export interface Payload {[key]: string; value: number}', 'symbol-keyed data'],
  278. ['conflicting merged property', 'export interface Payload {value: string}; export interface Payload {value: number}', 'TS2717'],
  279. ['class intersection', 'class Instance {value: string = ""}; export type Payload = Instance & {extra: string}', 'class instances'],
  280. ['invalid generic alias', 'type Bad = ReturnType<42>; export interface Payload {value: Bad}', 'TS2344'],
  281. ])('rejects %s rather than weakening the schema', (_name, source, error) => {
  282. expect(() => extractPersistenceSchema(fixture(source))).toThrow(error)
  283. })
  284. it('includes every real repository event and fingerprints every reachable node', () => {
  285. const root = resolve(import.meta.dirname, '..')
  286. const model = extractPersistenceSchema(root)
  287. expect(model.roots.filter(root => root.kind === 'event').map(root => root.event).sort())
  288. .toEqual(collectLogEvents(root).map(event => event.name).sort())
  289. expect(model.roots.some(root => root.kind === 'header')).toBe(true)
  290. expect(model.roots.some(root => root.kind === 'envelope')).toBe(true)
  291. const digests = new Set(model.types.map(type => type.digest))
  292. for (const root of model.roots) {
  293. expect(schemaDigest(root.schema)).toBe(root.digest)
  294. for (let node = 0; node < root.schema.nodes.length; node++) {
  295. expect(digests.has(schemaDigest(canonicalizeSchema(root.schema.nodes, node)))).toBe(true)
  296. }
  297. }
  298. })
  299. })