persistence-formats.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. /** Format reference coverage follows the writer version and retains complete historical schemas. */
  2. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { dump } from 'js-yaml'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { loadPersistenceFormats, runPersistenceFormats } from './persistence-formats.ts'
  8. import { parseHistoricalPersistenceSnapshot } from './persistence-changes.ts'
  9. import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
  10. import type { PersistenceRoot, PersistenceSchemaInventory, PersistenceType, SchemaNode } from './persistence-schema-model.ts'
  11. const temporary: string[] = []
  12. afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }) })
  13. function rootSchema(metadata: Omit<PersistenceRoot, 'schema' | 'digest'>, nodes: readonly SchemaNode[]): PersistenceRoot {
  14. const schema = canonicalizeSchema(nodes, 0)
  15. return { ...metadata, schema, digest: schemaDigest(schema) }
  16. }
  17. function completeInventory(roots: readonly PersistenceRoot[]): PersistenceSchemaInventory {
  18. const types = new Map<string, PersistenceType>()
  19. for (const root of roots) {
  20. root.schema.nodes.forEach((_, index) => {
  21. const schema = canonicalizeSchema(root.schema.nodes, index)
  22. const digest = schemaDigest(schema)
  23. types.set(digest, { digest, schema, names: [], sources: [] })
  24. })
  25. }
  26. return { formatVersion: 1, roots, types: [...types.values()] }
  27. }
  28. function header(key: string, version: number | 'number', optional = false): PersistenceRoot {
  29. return rootSchema({ key, kind: 'header' }, [
  30. { kind: 'object', indices: [], properties: [{ name: 'version', type: 1, optional }] },
  31. version === 'number' ? { kind: 'primitive', type: 'number' } : { kind: 'literal', value: version },
  32. ])
  33. }
  34. function inventory(version: number): PersistenceSchemaInventory {
  35. return completeInventory([
  36. header('SessionHeader', version), header('JsonlHeaderLine', 'number'),
  37. rootSchema({ key: 'SessionEventEnvelope', kind: 'envelope' }, [{ kind: 'object', indices: [], properties: [] }]),
  38. rootSchema({ key: 'event:example/value', kind: 'event', event: 'example/value', surface: false }, [
  39. { kind: 'object', indices: [], properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }] },
  40. { kind: 'literal', value: 'example/value' }, { kind: 'primitive', type: 'string' },
  41. ]),
  42. ])
  43. }
  44. function write(root: string, path: string, content: string): void {
  45. writeFileSync(join(root, path), content)
  46. }
  47. function schemaPath(version: number, currentVersion: number): string {
  48. return version === currentVersion ? 'docs/persistence-schema.json' : `docs/persistence-changes/historical-formats/v${version}.schema.json`
  49. }
  50. function replaceSnapshot(root: string, version: number, currentVersion: number, snapshot: PersistenceSchemaInventory): void {
  51. write(root, schemaPath(version, currentVersion), JSON.stringify(snapshot))
  52. }
  53. function saveReference(root: string, version: number, currentVersion: number, snapshot = inventory(version)): void {
  54. const current = version === currentVersion
  55. const filename = current ? 'persistence-catalog' : `persistence-changes/historical-formats/v${version}`
  56. const schemaName = current ? 'persistence-schema.json' : `v${version}.schema.json`
  57. const record = {
  58. schemaVersion: 1, sessionFormatVersion: version,
  59. source: version === 1 ? { pullRequest: 3349 } : { tag: 'dsh-v0.1.0-rc.5' },
  60. roots: Object.fromEntries(snapshot.roots.map(root => [root.key, root.digest])),
  61. }
  62. const lines = [
  63. ...current ? [] : ['---', 'kind: persistence-format', '---', '', '```yaml persistence-format', dump(record).trimEnd(), '```', ''],
  64. `# Session format v${version}`, '', `[Inventory](${schemaName})`, '', '| Root | Kind | SHA-256 |', '|---|---|---|',
  65. ...snapshot.roots.map(root => `| \`${root.key}\` | ${root.kind} | \`${root.digest}\` |`), '',
  66. ]
  67. for (const suffix of ['.md', '.zh.md']) write(root, `docs/${filename}${suffix}`, lines.join('\n'))
  68. replaceSnapshot(root, version, currentVersion, snapshot)
  69. }
  70. function fixture(currentVersion = 3): string {
  71. const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-formats-'))
  72. temporary.push(root)
  73. mkdirSync(join(root, 'packages/core/session/src'), { recursive: true })
  74. mkdirSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
  75. write(root, 'packages/core/session/src/types.ts', `export const SESSION_FORMAT_VERSION = ${currentVersion} as const\n`)
  76. for (let version = 0; version <= currentVersion; version += 1) saveReference(root, version, currentVersion)
  77. return root
  78. }
  79. function edit(root: string, path: string, from: string, to: string): void {
  80. const source = readFileSync(join(root, path), 'utf8')
  81. expect(source).toContain(from)
  82. write(root, path, source.replace(from, to))
  83. }
  84. function editPair(root: string, version: number, from: string, to: string): void {
  85. for (const suffix of ['.md', '.zh.md']) edit(root, `docs/persistence-changes/historical-formats/v${version}${suffix}`, from, to)
  86. }
  87. function prepareFacts(root: string, currentVersion = 3): void {
  88. for (const suffix of ['.md', '.zh.md']) {
  89. for (let version = 0; version < currentVersion; version += 1) {
  90. const path = `docs/persistence-changes/historical-formats/v${version}${suffix}`
  91. const switcher = suffix === '.md' ? `English | [中文](v${version}.zh.md)` : `[English](v${version}.md) | 中文`
  92. write(root, path, readFileSync(join(root, path), 'utf8') + `\n${switcher}\n\n<!-- persistence-format-schema:start -->\n\nPending schemas.\n\n<!-- persistence-format-schema:end -->\n`)
  93. }
  94. const switcher = suffix === '.md' ? 'English | [中文](README.zh.md)' : '[English](README.md) | 中文'
  95. write(root, `docs/persistence-changes/historical-formats/README${suffix}`, `# Session formats\n\n${switcher}\n\nAuthored context.\n\n<!-- persistence-format-index:start -->\n\nPending formats.\n\n<!-- persistence-format-index:end -->\n`)
  96. }
  97. }
  98. describe('archive current persistence format', () => {
  99. it('keeps every reachable type and drops extractor-retained types and source line numbers', () => {
  100. const root = fixture()
  101. const original = inventory(3)
  102. const sources = [
  103. 'packages/example/src/types.ts:12', 'packages/other/src/types.ts:8:2', 'packages/example/src/types.ts:34:5',
  104. 'packages/plain/src/types.ts', 'packages/fragment/src/types.ts#L8', 'packages/fragment/src/types.ts#L8-L12',
  105. ]
  106. const types = original.types.map(type => ({ ...type, names: ['Example'], sources }))
  107. const schema = canonicalizeSchema([{ kind: 'literal', value: 'unreferenced' }], 0)
  108. replaceSnapshot(root, 3, 3, { ...original, types: [...types, { schema, digest: schemaDigest(schema), names: [], sources: [] }] })
  109. const before = readFileSync(join(root, 'docs/persistence-schema.json'), 'utf8')
  110. expect(runPersistenceFormats(['--archive', '3'], root)).toBe('Archived Session format v3 to docs/persistence-changes/historical-formats/v3.schema.json.')
  111. const serialized = readFileSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'), 'utf8')
  112. const archived = parseHistoricalPersistenceSnapshot(JSON.parse(serialized))
  113. expect(archived).toEqual({
  114. ...original,
  115. types: types.map(type => ({ ...type, sources: [
  116. 'packages/example/src/types.ts', 'packages/other/src/types.ts', 'packages/plain/src/types.ts', 'packages/fragment/src/types.ts',
  117. ] })),
  118. })
  119. expect(serialized).toBe(JSON.stringify(archived, null, 2) + '\n')
  120. expect(readFileSync(join(root, 'docs/persistence-schema.json'), 'utf8')).toBe(before)
  121. expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.md'))).toBe(false)
  122. saveReference(root, 3, 4, archived)
  123. write(root, 'packages/core/session/src/types.ts', 'export const SESSION_FORMAT_VERSION = 4 as const\n')
  124. saveReference(root, 4, 4)
  125. expect(loadPersistenceFormats(root).entries[3]?.inventory).toEqual(archived)
  126. })
  127. it('creates the first archive directory and honors --root', () => {
  128. const root = fixture(0)
  129. rmSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
  130. expect(runPersistenceFormats(['--root', root, '--archive', '0'])).toBe('Archived Session format v0 to docs/persistence-changes/historical-formats/v0.schema.json.')
  131. expect(JSON.parse(readFileSync(join(root, 'docs/persistence-changes/historical-formats/v0.schema.json'), 'utf8'))).toEqual(inventory(0))
  132. })
  133. it.each(['-1', '1.5', '01', '3junk', '9007199254740992'])('rejects an invalid archive version %s', (version) => {
  134. const root = fixture()
  135. expect(() => runPersistenceFormats([`--archive=${version}`], root)).toThrow('--archive must be a non-negative safe integer')
  136. expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
  137. })
  138. it.each(['2', '4'])('rejects archive v%s when the current writer is v3', (version) => {
  139. const root = fixture()
  140. expect(() => runPersistenceFormats(['--archive', version], root)).toThrow(`Cannot archive v${version}: current writer is v3`)
  141. expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
  142. })
  143. it('refuses to overwrite an existing archive', () => {
  144. const root = fixture()
  145. write(root, 'docs/persistence-changes/historical-formats/v3.schema.json', 'Retained history.\n')
  146. expect(() => runPersistenceFormats(['--archive', '3'], root)).toThrow('docs/persistence-changes/historical-formats/v3.schema.json already exists')
  147. expect(readFileSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'), 'utf8')).toBe('Retained history.\n')
  148. })
  149. it('rejects --archive with --write before writing', () => {
  150. const root = fixture()
  151. expect(() => runPersistenceFormats(['--archive', '3', '--write'], root)).toThrow('--archive and --write cannot be combined')
  152. expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
  153. })
  154. it.each(['invalid-json', 'digest-drift', 'missing-type', 'missing-source-metadata', 'outdated-header'])('rejects %s before creating an archive', (variant) => {
  155. const root = fixture()
  156. const original = inventory(3)
  157. if (variant === 'invalid-json') write(root, 'docs/persistence-schema.json', '{')
  158. else if (variant === 'digest-drift') replaceSnapshot(root, 3, 3, { ...original, roots: [{ ...original.roots[0]!, digest: '0'.repeat(64) }, ...original.roots.slice(1)] })
  159. else if (variant === 'missing-type') replaceSnapshot(root, 3, 3, { ...original, types: original.types.slice(1) })
  160. else if (variant === 'missing-source-metadata') edit(root, 'docs/persistence-schema.json', ',"sources":[]', '')
  161. else replaceSnapshot(root, 3, 3, inventory(2))
  162. expect(() => runPersistenceFormats(['--archive', '3'], root)).toThrow(variant === 'invalid-json' ? SyntaxError
  163. : variant === 'digest-drift' ? 'schema digest mismatch' : variant === 'missing-type' ? 'cover every reachable type'
  164. : variant === 'missing-source-metadata' ? 'missing field sources' : 'SessionHeader.version must match')
  165. expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
  166. })
  167. })
  168. describe('complete persistence format references', () => {
  169. it.each(['.md', '.zh.md'])('rejects source coordinates in authored format evidence in %s', (suffix) => {
  170. const root = fixture()
  171. const path = `docs/persistence-changes/historical-formats/v1${suffix}`
  172. const original = readFileSync(join(root, path), 'utf8')
  173. for (const position of [':36', ':36:2', '#L36', '#L36-L38']) {
  174. write(root, path, original + `\nSource: \`packages/core/session/src/types.ts${position}\`.\n`)
  175. expect(() => loadPersistenceFormats(root)).toThrow('historical source references must omit line numbers')
  176. }
  177. write(root, path, original + '\nSource: `packages/core/session/src/types.ts`.\n')
  178. expect(loadPersistenceFormats(root).entries).toHaveLength(4)
  179. })
  180. it('loads a contiguous archive and the current catalog without Git or historical package sources', () => {
  181. const formats = loadPersistenceFormats(fixture())
  182. expect(formats.currentVersion).toBe(3)
  183. expect(formats.entries.map(entry => entry.version)).toEqual([0, 1, 2, 3])
  184. expect(formats.entries[0]?.source).toEqual({ tag: 'dsh-v0.1.0-rc.5' })
  185. expect(formats.entries[1]?.source).toEqual({ pullRequest: 3349 })
  186. expect(formats.entries[3]).toMatchObject({ document: 'docs/persistence-catalog.md', schemaPath: 'docs/persistence-schema.json' })
  187. expect(formats.entries[3]?.source).toBeUndefined()
  188. })
  189. it('accepts a version-zero writer without a historical directory', () => {
  190. const root = fixture(0)
  191. rmSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
  192. expect(loadPersistenceFormats(root).entries).toHaveLength(1)
  193. })
  194. it.each(['.md', '.zh.md', '.schema.json'])('requires every historical companion %s', (suffix) => {
  195. const root = fixture()
  196. rmSync(join(root, `docs/persistence-changes/historical-formats/v1${suffix}`))
  197. expect(() => loadPersistenceFormats(root)).toThrow(`missing persistence format artifact v1${suffix}`)
  198. })
  199. it('rejects a gap even when all remaining versions have complete companions', () => {
  200. const root = fixture()
  201. for (const suffix of ['.md', '.zh.md', '.schema.json']) rmSync(join(root, `docs/persistence-changes/historical-formats/v1${suffix}`))
  202. expect(() => loadPersistenceFormats(root)).toThrow('v1: missing persistence format artifact')
  203. })
  204. it.each(['persistence-catalog.md', 'persistence-catalog.zh.md', 'persistence-schema.json'])('requires the current reference artifact %s', (filename) => {
  205. const root = fixture()
  206. rmSync(join(root, `docs/${filename}`))
  207. expect(() => loadPersistenceFormats(root)).toThrow(`missing persistence format artifact docs/${filename}`)
  208. })
  209. it('requires a formerly current archive when the writer advances', () => {
  210. const root = fixture()
  211. write(root, 'packages/core/session/src/types.ts', 'export const SESSION_FORMAT_VERSION = 4 as const\n')
  212. saveReference(root, 4, 4)
  213. expect(() => loadPersistenceFormats(root)).toThrow('v3: missing persistence format artifact')
  214. saveReference(root, 3, 4)
  215. expect(loadPersistenceFormats(root).entries.map(entry => entry.version)).toEqual([0, 1, 2, 3, 4])
  216. })
  217. it.each(['v3.md', 'v9.schema.json', 'v01.md', 'v-1.md', 'v2.schema.yaml', 'v3.i18n.yaml'])('rejects unexpected or duplicate format filename %s', (filename) => {
  218. const root = fixture()
  219. write(root, `docs/persistence-changes/historical-formats/${filename}`, '{}')
  220. expect(() => loadPersistenceFormats(root)).toThrow(`unexpected persistence format artifact ${filename}`)
  221. })
  222. it('rejects bilingual declarations that differ', () => {
  223. const root = fixture()
  224. edit(root, 'docs/persistence-changes/historical-formats/v1.zh.md', 'pullRequest: 3349', 'pullRequest: 3397')
  225. expect(() => loadPersistenceFormats(root)).toThrow('bilingual machine records differ')
  226. })
  227. it.each([
  228. ['kind: persistence-format', 'kind: persistence-release', 'frontmatter kind'],
  229. ['sessionFormatVersion: 1', 'sessionFormatVersion: 0', 'must match its filename'],
  230. ['schemaVersion: 1', 'schemaVersion: 2', 'unsupported persistence format record schema version'],
  231. ['pullRequest: 3349', 'pullRequest: 0', 'positive integer'],
  232. ['pullRequest: 3349', 'pullRequest: 1.5', 'positive integer'],
  233. ['pullRequest: 3349', 'pullRequest: 3349\n tag: dsh-v0.1.0', 'expected fields'],
  234. ['pullRequest: 3349', 'other: 3349', 'expected fields'],
  235. ['source:\n pullRequest: 3349', 'source: null', 'expected an object'],
  236. ['schemaVersion: 1', 'schemaVersion: 1\nunknown: true', 'expected fields'],
  237. ])('rejects inconsistent machine data: %s', (from, to, error) => {
  238. const root = fixture()
  239. editPair(root, 1, from, to)
  240. expect(() => loadPersistenceFormats(root)).toThrow(error)
  241. })
  242. it('rejects a source tag outside the DSH release namespace', () => {
  243. const root = fixture()
  244. editPair(root, 0, 'tag: dsh-v0.1.0-rc.5', 'tag: latest')
  245. expect(() => loadPersistenceFormats(root)).toThrow('invalid source tag')
  246. })
  247. it.each(['missing', 'extra', 'wrong-digest'])('rejects a %s recorded root', (variant) => {
  248. const root = fixture()
  249. const digest = inventory(1).roots[0]!.digest
  250. const from = variant === 'extra' ? 'roots:\n' : ` SessionHeader: ${digest}\n`
  251. const to = variant === 'missing' ? '' : variant === 'extra' ? `roots:\n event:undeclared: ${'f'.repeat(64)}\n`
  252. : ` SessionHeader: ${'f'.repeat(64)}\n`
  253. editPair(root, 1, from, to)
  254. expect(() => loadPersistenceFormats(root)).toThrow('recorded roots do not match the complete schema inventory')
  255. })
  256. it.each(['unknown-key', 'invalid-digest', 'duplicate-key', 'not-a-mapping'])('rejects invalid recorded roots: %s', (variant) => {
  257. const root = fixture()
  258. const digest = inventory(1).roots[0]!.digest
  259. const from = variant === 'not-a-mapping' ? dump({ roots: Object.fromEntries(inventory(1).roots.map(root => [root.key, root.digest])) })
  260. : ` SessionHeader: ${digest}\n`
  261. const to = variant === 'unknown-key' ? ` unknown: ${digest}\n` : variant === 'invalid-digest' ? ' SessionHeader: invalid\n'
  262. : variant === 'duplicate-key' ? from + from : 'roots: []\n'
  263. editPair(root, 1, from, to)
  264. expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-key' ? 'duplicated mapping key'
  265. : variant === 'not-a-mapping' ? 'roots must be a mapping' : 'invalid recorded root')
  266. })
  267. it.each(['missing', 'duplicate'])('rejects a %s machine block', (variant) => {
  268. const root = fixture()
  269. for (const suffix of ['.md', '.zh.md']) {
  270. const path = `docs/persistence-changes/historical-formats/v1${suffix}`
  271. const text = readFileSync(join(root, path), 'utf8')
  272. write(root, path, variant === 'missing' ? text.replace('```yaml persistence-format', '```yaml') : text + '\n```yaml persistence-format\n{}\n```\n')
  273. }
  274. expect(() => loadPersistenceFormats(root)).toThrow('expected exactly one persistence-format machine record')
  275. })
  276. it.each(['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope', 'event:example/value'])('rejects an inventory missing %s', (key) => {
  277. const root = fixture()
  278. replaceSnapshot(root, 1, 3, completeInventory(inventory(1).roots.filter(root => root.key !== key)))
  279. expect(() => loadPersistenceFormats(root)).toThrow(key.startsWith('event:') ? 'must include an event root' : `missing schema root ${key}`)
  280. })
  281. it.each([0, 1, 2, 3])('rejects a mismatched header version at v%s', (version) => {
  282. const root = fixture()
  283. replaceSnapshot(root, version, 3, completeInventory(inventory(version).roots.map(root => root.key === 'SessionHeader' ? header(root.key, version + 1) : root)))
  284. expect(() => loadPersistenceFormats(root)).toThrow('SessionHeader.version must match')
  285. })
  286. it('retains historical number declarations but requires the current logical header literal', () => {
  287. const root = fixture()
  288. for (const version of [0, 1, 2]) {
  289. saveReference(root, version, 3, completeInventory(inventory(version).roots.map(root => root.key === 'SessionHeader' ? header(root.key, 'number') : root)))
  290. }
  291. expect(loadPersistenceFormats(root).entries).toHaveLength(4)
  292. replaceSnapshot(root, 3, 3, completeInventory(inventory(3).roots.map(root => root.key === 'SessionHeader' ? header(root.key, 'number') : root)))
  293. expect(() => loadPersistenceFormats(root)).toThrow('SessionHeader.version must match the current writer version')
  294. })
  295. it.each(['SessionHeader', 'JsonlHeaderLine'])('requires a present %s version property', (key) => {
  296. const root = fixture()
  297. replaceSnapshot(root, 1, 3, completeInventory(inventory(1).roots.map(root => root.key === key ? header(key, 1, true) : root)))
  298. expect(() => loadPersistenceFormats(root)).toThrow(`${key}.version must match`)
  299. })
  300. it('uses historical surface rules only for historical references', () => {
  301. const root = fixture()
  302. const event = rootSchema({ key: 'event:example/value', kind: 'event', event: 'example/value', surface: true }, [
  303. { kind: 'object', indices: [], properties: [
  304. { name: 'type', type: 1, optional: false }, { name: 'surfaceOp', type: 2, optional: true },
  305. ] },
  306. { kind: 'literal', value: 'example/value' }, { kind: 'primitive', type: 'string' },
  307. ])
  308. saveReference(root, 1, 3, completeInventory(inventory(1).roots.map(root => root.kind === 'event' ? event : root)))
  309. expect(loadPersistenceFormats(root).entries).toHaveLength(4)
  310. replaceSnapshot(root, 3, 3, completeInventory(inventory(3).roots.map(root => root.kind === 'event' ? event : root)))
  311. expect(() => loadPersistenceFormats(root)).toThrow('surface metadata does not match its schema')
  312. })
  313. it.each(['missing', 'duplicate', 'extra'])('rejects %s reachable type coverage', (variant) => {
  314. const root = fixture()
  315. const original = inventory(1)
  316. const extraSchema = canonicalizeSchema([{ kind: 'literal', value: 'unreferenced' }], 0)
  317. const types = variant === 'missing' ? original.types.slice(1)
  318. : variant === 'duplicate' ? [...original.types, original.types[0]!]
  319. : [...original.types, { schema: extraSchema, digest: schemaDigest(extraSchema), names: [], sources: [] }]
  320. replaceSnapshot(root, 1, 3, { ...original, types })
  321. expect(() => loadPersistenceFormats(root)).toThrow(variant === 'missing' ? 'cover every reachable type' : variant === 'duplicate' ? 'duplicate schema type' : 'unreferenced schema type')
  322. })
  323. it('permits extractor-retained unreachable types only in the current inventory', () => {
  324. const root = fixture()
  325. const original = inventory(3)
  326. const schema = canonicalizeSchema([{ kind: 'literal', value: false }], 0)
  327. replaceSnapshot(root, 3, 3, {
  328. ...original, types: [...original.types, { schema, digest: schemaDigest(schema), names: [], sources: [] }],
  329. })
  330. expect(loadPersistenceFormats(root).entries).toHaveLength(4)
  331. })
  332. it.each(['duplicate-root', 'invalid-digest', 'invalid-graph', 'unexpected-field'])('rejects malformed schema inventory: %s', (variant) => {
  333. const root = fixture()
  334. const original = inventory(1)
  335. const first = original.roots[0]!
  336. const changed = variant === 'duplicate-root' ? { ...original, roots: [...original.roots, first] }
  337. : variant === 'invalid-digest' ? { ...original, roots: [{ ...first, digest: '0'.repeat(64) }, ...original.roots.slice(1)] }
  338. : variant === 'invalid-graph' ? { ...original, roots: [{ ...first, schema: { root: 0, nodes: [{ kind: 'array', element: 99 }] } }, ...original.roots.slice(1)] }
  339. : { ...original, extra: true }
  340. write(root, schemaPath(1, 3), JSON.stringify(changed))
  341. expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-root' ? 'duplicate schema root' : variant === 'invalid-digest' ? 'schema digest mismatch' : variant === 'invalid-graph' ? 'unknown schema node' : 'unknown field extra')
  342. })
  343. it.each(['persistence-changes/historical-formats/v1.md', 'persistence-changes/historical-formats/v1.zh.md', 'persistence-catalog.md', 'persistence-catalog.zh.md'])('requires the matching schema link in %s', (document) => {
  344. const root = fixture()
  345. const schema = document.startsWith('persistence-changes/historical-formats/') ? 'v1.schema.json' : 'persistence-schema.json'
  346. edit(root, `docs/${document}`, `](${schema})`, '](wrong.schema.json)')
  347. expect(() => loadPersistenceFormats(root)).toThrow(`missing link to ${schema}`)
  348. })
  349. it.each(['persistence-catalog.md', 'persistence-catalog.zh.md'])('rejects a placeholder current catalog in %s', (document) => {
  350. const root = fixture()
  351. write(root, `docs/${document}`, '# Placeholder\n\n[Inventory](persistence-schema.json)\n')
  352. expect(() => loadPersistenceFormats(root)).toThrow('missing schema index entry for SessionHeader')
  353. })
  354. it('checks generated schema regions and refreshes them without changing the recorded schemas or authored context', () => {
  355. const root = fixture()
  356. prepareFacts(root)
  357. const paths = [0, 1, 2, 3].map(version => schemaPath(version, 3))
  358. const before = paths.map(path => readFileSync(join(root, path), 'utf8'))
  359. expect(() => runPersistenceFormats([], root)).toThrow('Stale persistence format facts')
  360. expect(runPersistenceFormats(['--write'], root)).toContain('Refreshed')
  361. expect(runPersistenceFormats([], root)).toBe('Persistence formats: v0 through v3 verified (4 complete references).')
  362. expect(paths.map(path => readFileSync(join(root, path), 'utf8'))).toEqual(before)
  363. const index = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')
  364. expect(index).toContain('Authored context.')
  365. expect(index).toContain('[V1](v1.md) | [JSON](v1.schema.json)')
  366. expect(index).toContain('[Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json)')
  367. const translatedIndex = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.zh.md'), 'utf8')
  368. expect(translatedIndex).toContain('[V1](v1.zh.md) | [JSON](v1.schema.json)')
  369. expect(translatedIndex).toContain('[当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json)')
  370. const english = readFileSync(join(root, 'docs/persistence-changes/historical-formats/v1.md'), 'utf8')
  371. expect(english).toContain('pullRequest: 3349')
  372. const [visible, collapsed] = english.split('<details>')
  373. expect(visible).toContain('\n## Complete schemas\n')
  374. expect(visible).toContain('\n### Persistence type fingerprints\n')
  375. expect(collapsed).toContain('\n### Resolved persistence types\n')
  376. expect(collapsed).toContain('\n#### `SessionHeader`\n')
  377. expect(english.match(/^## /gmu)).toHaveLength(1)
  378. expect(english.match(/^### /gmu)).toHaveLength(2)
  379. expect(english.match(/^#### /gmu)).toHaveLength(inventory(1).types.length)
  380. edit(root, 'docs/persistence-changes/historical-formats/v1.md', '<summary>Complete resolved types</summary>', '<summary>Missing definitions</summary>')
  381. expect(() => runPersistenceFormats([], root)).toThrow('Stale persistence format facts')
  382. })
  383. it('renders a format index for a current version-zero writer', () => {
  384. const root = fixture(0)
  385. prepareFacts(root, 0)
  386. runPersistenceFormats(['--write'], root)
  387. expect(runPersistenceFormats([], root)).toBe('Persistence formats: v0 through v0 verified (1 complete reference).')
  388. })
  389. it.each(['missing', 'stale'])('rejects a %s format pairing record and refreshes it with --write', (variant) => {
  390. const root = fixture()
  391. prepareFacts(root)
  392. runPersistenceFormats(['--write'], root)
  393. const sidecar = 'docs/persistence-changes/historical-formats/v1.i18n.yaml'
  394. if (variant === 'missing') rmSync(join(root, sidecar))
  395. else write(root, sidecar, 'schemaVersion: 1\n')
  396. expect(() => runPersistenceFormats([], root)).toThrow(`Stale persistence format facts: ${sidecar}`)
  397. runPersistenceFormats(['--write'], root)
  398. expect(() => runPersistenceFormats([], root)).not.toThrow()
  399. })
  400. it('refuses to refresh facts until every format reference passes validation', () => {
  401. const root = fixture()
  402. prepareFacts(root)
  403. const index = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')
  404. rmSync(join(root, 'docs/persistence-changes/historical-formats/v1.schema.json'))
  405. expect(() => runPersistenceFormats(['--write'], root)).toThrow('missing persistence format artifact')
  406. expect(readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')).toBe(index)
  407. })
  408. it('refuses to refresh an archive after an event and all its types are removed from its schema', () => {
  409. const root = fixture()
  410. const retained = rootSchema({ key: 'event:example/retained', kind: 'event', event: 'example/retained', surface: false }, [
  411. { kind: 'object', indices: [], properties: [{ name: 'type', type: 1, optional: false }] },
  412. { kind: 'literal', value: 'example/retained' },
  413. ])
  414. const complete = completeInventory([...inventory(1).roots, retained])
  415. saveReference(root, 1, 3, complete)
  416. prepareFacts(root)
  417. runPersistenceFormats(['--write'], root)
  418. const document = 'docs/persistence-changes/historical-formats/v1.md'
  419. const before = readFileSync(join(root, document), 'utf8')
  420. replaceSnapshot(root, 1, 3, completeInventory(complete.roots.filter(root => root.key !== 'event:example/value')))
  421. expect(() => runPersistenceFormats(['--write'], root)).toThrow('recorded roots do not match the complete schema inventory')
  422. expect(readFileSync(join(root, document), 'utf8')).toBe(before)
  423. })
  424. })