catalog.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import { describe, expect, it, vi } from 'vitest'
  2. import {
  3. createSessionFormatCatalog,
  4. SessionFormatUnsupportedMigrationError,
  5. type SessionFormatArtifact,
  6. type SessionFormatCatalogOptions,
  7. type SessionFormatCodec,
  8. type SessionFormatCurrentEncoder,
  9. type SessionFormatEvent,
  10. type SessionFormatEventRun,
  11. type SessionFormatMigration,
  12. type SessionFormatMigrationContext,
  13. type SessionFormatMigrationStageInput,
  14. } from '../src/index.ts'
  15. function codec(version: number, inheritedEventCount = 0): SessionFormatCodec & SessionFormatCurrentEncoder {
  16. return {
  17. version,
  18. decodeHeader(value: unknown) {
  19. return value as SessionFormatArtifact['header']
  20. },
  21. createDecoder(headerValue: unknown) {
  22. return {
  23. header: headerValue as SessionFormatArtifact['header'],
  24. headerInheritedEventCount: inheritedEventCount,
  25. decodeRow(rowValue: unknown, context: SessionFormatMigrationContext) {
  26. context.emitEvent(rowValue as SessionFormatEvent)
  27. },
  28. finish: () => inheritedEventCount,
  29. }
  30. },
  31. encodeHeader(header) {
  32. return header
  33. },
  34. encodeEvent(event) {
  35. return event
  36. },
  37. }
  38. }
  39. function identityMigrationStage(inheritedEventCount: number | undefined) {
  40. return {
  41. ...(inheritedEventCount === undefined ? {} : { headerInheritedEventCount: inheritedEventCount }),
  42. transformEvent(
  43. event: SessionFormatEvent,
  44. context: SessionFormatMigrationContext,
  45. ) {
  46. context.emitEvent(event)
  47. },
  48. transformRun(
  49. run: SessionFormatEventRun,
  50. context: SessionFormatMigrationContext,
  51. ) {
  52. context.emitRun(run)
  53. },
  54. finish: () => inheritedEventCount ?? 0,
  55. }
  56. }
  57. function edge(overrides: Partial<SessionFormatMigration> = {}): SessionFormatMigration {
  58. return {
  59. name: '@test/v0-to-v1',
  60. fromVersion: 0,
  61. toVersion: 1,
  62. migrateHeader: header => ({ ...header, version: 1 }),
  63. createStage: ({ sourceInheritedEventCount }) => identityMigrationStage(sourceInheritedEventCount),
  64. validateTargetHeader: () => {},
  65. ...overrides,
  66. }
  67. }
  68. function catalog(
  69. migration: SessionFormatMigration = edge(),
  70. restoreVersion = 1,
  71. sourceCodec: SessionFormatCodec = codec(0),
  72. currentEncoder: SessionFormatCurrentEncoder = codec(1),
  73. restoreCurrentHeader: SessionFormatCatalogOptions['restoreCurrentHeader'] = header => header,
  74. ) {
  75. const currentCodec = codec(1)
  76. return createSessionFormatCatalog({
  77. currentVersion: 1,
  78. codecs: [sourceCodec, currentCodec],
  79. currentEncoder,
  80. migrations: [migration],
  81. restoreCurrent: artifact => ({
  82. ...artifact,
  83. header: { ...artifact.header, version: restoreVersion },
  84. }),
  85. restoreTransformedCurrent: artifact => ({
  86. ...artifact,
  87. header: { ...artifact.header, version: restoreVersion },
  88. }),
  89. restoreCurrentHeader,
  90. })
  91. }
  92. const oldHeader = {
  93. version: 0,
  94. id: 'old',
  95. createdAt: 1,
  96. isSeeded: false,
  97. delegationDepth: 0,
  98. } as const
  99. const event = { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } } as const
  100. function throwUnknown(value: unknown): never {
  101. throw value
  102. }
  103. describe('Session format catalog', () => {
  104. it('classifies headers and restores physical rows through the compiled chain', () => {
  105. const createStage = vi.fn(({ sourceInheritedEventCount }: SessionFormatMigrationStageInput) =>
  106. identityMigrationStage(sourceInheritedEventCount))
  107. const current = catalog(edge({ createStage }))
  108. expect(current.readHeader(oldHeader)).toEqual({
  109. status: 'migration-required',
  110. storedVersion: 0,
  111. targetVersion: 1,
  112. header: { ...oldHeader, version: 1 },
  113. })
  114. expect(current.readHeader({ version: 2 })).toMatchObject({
  115. status: 'unsupported', storedVersion: 2, targetVersion: 1,
  116. })
  117. expect(current.readHeader({ ...oldHeader, version: 1 })).toMatchObject({
  118. status: 'current', storedVersion: 1, targetVersion: 1,
  119. })
  120. expect(current.readHeader({ version: 'broken' })).toMatchObject({ status: 'malformed', targetVersion: 1 })
  121. const restore = current.createRestore(oldHeader, { recovery: 'strict', validation: 'current' })
  122. restore.decodeRow(event)
  123. expect(restore.finish()).toMatchObject({ header: { version: 1 }, events: [event] })
  124. expect(createStage).toHaveBeenCalledOnce()
  125. })
  126. it('restores current rows without a migration and encodes one record at a time', () => {
  127. const current = catalog()
  128. const header = { ...oldHeader, version: 1 }
  129. const restore = current.createRestore(header, { recovery: 'strict', validation: 'current' })
  130. restore.decodeRow(event)
  131. expect(restore.finish()).toEqual({ header, inheritedEventCount: 0, events: [event] })
  132. expect(current.encodeCurrentHeader(header, 0)).toEqual(header)
  133. expect(current.encodeCurrentEvent(event)).toEqual(event)
  134. expect(() => current.encodeCurrentHeader(oldHeader, 0)).toThrow(/requires Session format v1/)
  135. const physicalOnly = current.createRestore(header, { recovery: 'strict', validation: 'transformed' })
  136. physicalOnly.decodeRow(event)
  137. expect(physicalOnly.finish()).toEqual({ header, inheritedEventCount: 0, events: [event] })
  138. const transformed = current.createRestore(oldHeader, { recovery: 'strict', validation: 'transformed' })
  139. transformed.decodeRow(event)
  140. expect(transformed.finish()).toMatchObject({ header: { version: 1 }, events: [event] })
  141. })
  142. it.each([
  143. new Error('target artifact is incompatible'),
  144. 'non-Error target artifact refusal',
  145. ])('classifies migrated target validation failures as unsupported', (targetFailure) => {
  146. const current = createSessionFormatCatalog({
  147. currentVersion: 1,
  148. codecs: [codec(0), codec(1)],
  149. currentEncoder: codec(1),
  150. migrations: [edge()],
  151. restoreCurrent: artifact => artifact,
  152. restoreTransformedCurrent: () => { throw targetFailure },
  153. restoreCurrentHeader: header => header,
  154. })
  155. const restore = current.createRestore(oldHeader, { recovery: 'strict', validation: 'transformed' })
  156. restore.decodeRow(event)
  157. try {
  158. restore.finish()
  159. throw new Error('expected transformed target validation to fail')
  160. } catch (error: unknown) {
  161. expect(error).toBeInstanceOf(SessionFormatUnsupportedMigrationError)
  162. expect((error as Error).cause).toBe(targetFailure)
  163. }
  164. })
  165. it('preserves current-validation failures and explicit migration refusals', () => {
  166. const currentFailure = new Error('installed current validation failed')
  167. const current = createSessionFormatCatalog({
  168. currentVersion: 1,
  169. codecs: [codec(0), codec(1)],
  170. currentEncoder: codec(1),
  171. migrations: [edge()],
  172. restoreCurrent: () => { throw currentFailure },
  173. restoreTransformedCurrent: artifact => artifact,
  174. restoreCurrentHeader: header => header,
  175. }).createRestore(oldHeader, { recovery: 'strict', validation: 'current' })
  176. expect(() => current.finish()).toThrow(currentFailure)
  177. const explicit = new SessionFormatUnsupportedMigrationError('explicit target refusal')
  178. const transformed = createSessionFormatCatalog({
  179. currentVersion: 1,
  180. codecs: [codec(0), codec(1)],
  181. currentEncoder: codec(1),
  182. migrations: [edge()],
  183. restoreCurrent: artifact => artifact,
  184. restoreTransformedCurrent: () => { throw explicit },
  185. restoreCurrentHeader: header => header,
  186. }).createRestore(oldHeader, { recovery: 'strict', validation: 'transformed' })
  187. expect(() => transformed.finish()).toThrow(explicit)
  188. })
  189. it('rejects a current encoder that returns a non-current header', () => {
  190. const badEncoder: SessionFormatCurrentEncoder = {
  191. ...codec(1),
  192. encodeHeader: header => ({ ...header, version: 0 }),
  193. }
  194. const current = catalog(edge(), 1, codec(0), badEncoder)
  195. expect(() => current.encodeCurrentHeader({ ...oldHeader, version: 1 }, 0))
  196. .toThrow(/non-current header/)
  197. })
  198. it('rejects duplicate, missing, and future codec declarations', () => {
  199. const options = {
  200. currentVersion: 1,
  201. migrations: [edge()],
  202. currentEncoder: codec(1),
  203. restoreCurrent: (value: SessionFormatArtifact) => value,
  204. restoreTransformedCurrent: (value: SessionFormatArtifact) => value,
  205. restoreCurrentHeader: (value: SessionFormatArtifact['header']) => value,
  206. }
  207. expect(() => createSessionFormatCatalog({ ...options, codecs: [codec(0), codec(0), codec(1)] }))
  208. .toThrow(/codec v0 is duplicated/)
  209. expect(() => createSessionFormatCatalog({ ...options, codecs: [codec(0)] })).toThrow(/codec v1 is missing/)
  210. expect(() => createSessionFormatCatalog({ ...options, codecs: [codec(0), codec(1), codec(2)] }))
  211. .toThrow(/codec v2 is newer/)
  212. })
  213. it('preserves unsupported header and migration failures', () => {
  214. const refusingCodec: SessionFormatCodec = {
  215. ...codec(0),
  216. decodeHeader: () => { throw new Error('bad header') },
  217. createDecoder: () => { throw new Error('bad body') },
  218. }
  219. const current = catalog(edge(), 1, refusingCodec)
  220. expect(current.readHeader(oldHeader)).toMatchObject({ status: 'malformed', reason: 'bad header' })
  221. expect(() => current.createRestore(oldHeader, { recovery: 'strict', validation: 'current' }))
  222. .toThrow('bad body')
  223. expect(() => current.createRestore({ version: 2 }, { recovery: 'strict', validation: 'current' }))
  224. .toThrow(/newer/)
  225. const nonError = catalog({
  226. ...edge(),
  227. }, 1, {
  228. ...codec(0),
  229. decodeHeader: () => throwUnknown('non-Error header failure'),
  230. })
  231. expect(nonError.readHeader(oldHeader)).toMatchObject({
  232. status: 'malformed', reason: 'non-Error header failure',
  233. })
  234. })
  235. it('classifies migrated header refusals as unsupported and current corruption as malformed', () => {
  236. const unsupported = catalog(edge({
  237. validateTargetHeader: () => { throw new Error('target header lacks marker') },
  238. }))
  239. expect(unsupported.readHeader(oldHeader)).toMatchObject({
  240. status: 'unsupported',
  241. reason: expect.stringContaining('target header lacks marker') as string,
  242. })
  243. const malformed = catalog(
  244. edge(),
  245. 1,
  246. codec(0),
  247. codec(1),
  248. () => { throw new Error('current header is corrupt') },
  249. )
  250. expect(malformed.readHeader({ ...oldHeader, version: 1 })).toMatchObject({
  251. status: 'malformed',
  252. reason: 'current header is corrupt',
  253. })
  254. })
  255. it('enforces decoder, stage, and restorer lifecycle results', () => {
  256. const changedCut = edge({
  257. createStage: () => ({
  258. headerInheritedEventCount: 1,
  259. transformEvent: (candidate, context) => { context.emitEvent(candidate) },
  260. transformRun: (candidate, context) => { context.emitRun(candidate) },
  261. finish: () => 0,
  262. }),
  263. })
  264. const changed = catalog(changedCut, 1, codec(0, 1)).createRestore(
  265. { ...oldHeader, isSeeded: true },
  266. { recovery: 'strict', validation: 'current' },
  267. )
  268. expect(() => changed.finish()).toThrow(/changed its predeclared inherited cut/)
  269. const trailing = catalog(edge({
  270. createStage: () => ({
  271. headerInheritedEventCount: 0,
  272. transformEvent: () => {},
  273. transformRun: () => {},
  274. finish: (context) => { context.emitEvent(event); return 0 },
  275. }),
  276. })).createRestore(oldHeader, { recovery: 'strict', validation: 'current' })
  277. expect(trailing.finish().events).toEqual([event])
  278. const wrongVersion = catalog(edge(), 0).createRestore(
  279. oldHeader,
  280. { recovery: 'strict', validation: 'current' },
  281. )
  282. expect(() => wrongVersion.finish()).toThrow(/returned v0/)
  283. const changedDecoderCut: SessionFormatCodec = {
  284. ...codec(0),
  285. createDecoder(headerValue) {
  286. return {
  287. header: headerValue as SessionFormatArtifact['header'],
  288. headerInheritedEventCount: 0,
  289. decodeRow: () => {},
  290. finish: () => 1,
  291. }
  292. },
  293. }
  294. const changedDecoder = catalog(edge(), 1, changedDecoderCut).createRestore(
  295. oldHeader,
  296. { recovery: 'strict', validation: 'current' },
  297. )
  298. expect(() => changedDecoder.finish()).toThrow(/decoder changed its predeclared inherited cut/)
  299. const deferredCut: SessionFormatCodec = {
  300. ...codec(0),
  301. createDecoder(headerValue) {
  302. return {
  303. header: headerValue as SessionFormatArtifact['header'],
  304. decodeRow: () => {},
  305. finish: () => 0,
  306. }
  307. },
  308. }
  309. expect(catalog(edge(), 1, deferredCut).createRestore(
  310. oldHeader,
  311. { recovery: 'strict', validation: 'current' },
  312. ).finish().inheritedEventCount).toBe(0)
  313. })
  314. it('expands an unhandled compact run without an intermediate array', () => {
  315. const run: SessionFormatEventRun = {
  316. runType: 'test-run', firstSeq: 0, eventCount: 1, expand: function* () { yield event },
  317. }
  318. const source: SessionFormatCodec = {
  319. ...codec(0),
  320. createDecoder(headerValue) {
  321. return {
  322. header: headerValue as SessionFormatArtifact['header'],
  323. headerInheritedEventCount: 0,
  324. decodeRow(_rowValue, context) { context.emitRun(run) },
  325. finish: () => 0,
  326. }
  327. },
  328. }
  329. const restore = catalog(edge(), 1, source).createRestore(
  330. oldHeader,
  331. { recovery: 'strict', validation: 'current' },
  332. )
  333. restore.decodeRow({})
  334. expect(restore.finish().events).toEqual([event])
  335. })
  336. })