chain.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import { describe, expect, it, vi } from 'vitest'
  2. import {
  3. createSessionFormatChain,
  4. defineSessionFormatMigration,
  5. SessionFormatEventCollector,
  6. SessionFormatUnsupportedMigrationError,
  7. type SessionFormatEvent,
  8. type SessionFormatEventRun,
  9. type SessionFormatHeader,
  10. type SessionFormatMigration,
  11. type SessionFormatMigrationContext,
  12. type SessionFormatMigrationStageInput,
  13. } from '../src/index.ts'
  14. const currentHeader: SessionFormatHeader = {
  15. version: 1,
  16. id: 'session-1',
  17. createdAt: 1,
  18. isSeeded: false,
  19. delegationDepth: 0,
  20. }
  21. const event: SessionFormatEvent = {
  22. type: 'turn/start', seq: 0, time: 2, data: { turn: 1 },
  23. }
  24. const discard: SessionFormatMigrationContext = {
  25. emitEvent() {},
  26. emitRun() {},
  27. }
  28. function throwUnknown(value: unknown): never {
  29. throw value
  30. }
  31. function identityStage(inheritedEventCount: number | undefined) {
  32. return {
  33. ...(inheritedEventCount === undefined ? {} : { headerInheritedEventCount: inheritedEventCount }),
  34. transformEvent(
  35. value: SessionFormatEvent,
  36. context: SessionFormatMigrationContext,
  37. ) {
  38. context.emitEvent(value)
  39. },
  40. transformRun(
  41. run: SessionFormatEventRun,
  42. context: SessionFormatMigrationContext,
  43. ) {
  44. context.emitRun(run)
  45. },
  46. finish: () => inheritedEventCount ?? 0,
  47. }
  48. }
  49. function migration(overrides: Partial<SessionFormatMigration> = {}): SessionFormatMigration {
  50. return {
  51. name: '@test/v0-to-v1',
  52. fromVersion: 0,
  53. toVersion: 1,
  54. migrateHeader: header => ({ ...header, version: 1 }),
  55. createStage: ({ sourceInheritedEventCount }) => identityStage(sourceInheritedEventCount),
  56. validateTargetHeader: () => {},
  57. ...overrides,
  58. }
  59. }
  60. function chain(edge: SessionFormatMigration = migration()) {
  61. return createSessionFormatChain({
  62. currentVersion: 1,
  63. migrations: [edge],
  64. restoreCurrentHeader: header => header,
  65. })
  66. }
  67. function captureError(run: () => unknown): Error {
  68. try {
  69. run()
  70. } catch (error: unknown) {
  71. if (error instanceof Error) return error
  72. throw new Error('expected an Error object', { cause: error })
  73. }
  74. throw new Error('expected callback to throw')
  75. }
  76. describe('Session format chain', () => {
  77. it('bypasses adjacent stages for current input', () => {
  78. const createStage = vi.fn(({ sourceInheritedEventCount }: SessionFormatMigrationStageInput) =>
  79. identityStage(sourceInheritedEventCount))
  80. const current = chain(migration({ createStage }))
  81. const events = new SessionFormatEventCollector()
  82. const stream = current.createStream(currentHeader, 0, events)
  83. stream.emitEvent(event)
  84. expect(stream.header).toEqual(currentHeader)
  85. expect(stream.finish()).toBe(0)
  86. expect(events.values).toEqual([event])
  87. expect(createStage).not.toHaveBeenCalled()
  88. })
  89. it('composes one adjacent stage and its header conversion', () => {
  90. const current = chain()
  91. const sourceHeader = { ...currentHeader, version: 0 }
  92. const events = new SessionFormatEventCollector()
  93. const stream = current.createStream(sourceHeader, 0, events)
  94. stream.emitEvent(event)
  95. expect(stream.header.version).toBe(1)
  96. expect(stream.finish()).toBe(0)
  97. expect(events.values).toEqual([event])
  98. expect(current.migrateHeader(sourceHeader).version).toBe(1)
  99. })
  100. it('rejects invalid declarations and incomplete chain construction', () => {
  101. const base = migration()
  102. expect(() => defineSessionFormatMigration({ ...base, name: '' })).toThrow(/name/)
  103. expect(() => defineSessionFormatMigration({ ...base, toVersion: 2 })).toThrow(/adjacent/)
  104. expect(() => createSessionFormatChain({
  105. currentVersion: 1, migrations: [], restoreCurrentHeader: value => value,
  106. })).toThrow(/missing/)
  107. expect(() => createSessionFormatChain({
  108. currentVersion: 1, migrations: [base, base], restoreCurrentHeader: value => value,
  109. })).toThrow(/duplicated/)
  110. expect(() => createSessionFormatChain({
  111. currentVersion: 2,
  112. migrations: [base, { ...base, name: base.name, fromVersion: 1, toVersion: 2 }],
  113. restoreCurrentHeader: value => value,
  114. })).toThrow(/name .* duplicated/)
  115. expect(() => createSessionFormatChain({
  116. currentVersion: 1,
  117. migrations: [base, { ...base, name: '@test/v1-to-v2', fromVersion: 1, toVersion: 2 }],
  118. restoreCurrentHeader: value => value,
  119. })).toThrow(/does not lead/)
  120. })
  121. it('composes stages whose inherited cut is derived from the body', () => {
  122. const first = migration({
  123. createStage: () => ({
  124. transformEvent: (value, context) => { context.emitEvent(value) },
  125. transformRun: (value, context) => { context.emitRun(value) },
  126. finish: () => 0,
  127. }),
  128. })
  129. const second: SessionFormatMigration = {
  130. ...migration(),
  131. name: '@test/v1-to-v2',
  132. fromVersion: 1,
  133. toVersion: 2,
  134. migrateHeader: header => ({ ...header, version: 2 }),
  135. }
  136. const current = createSessionFormatChain({
  137. currentVersion: 2,
  138. migrations: [first, second],
  139. restoreCurrentHeader: header => header,
  140. })
  141. expect(current.createStream(
  142. { ...currentHeader, version: 0 },
  143. 0,
  144. discard,
  145. ).finish()).toBe(0)
  146. const createSecondStage = vi.fn(({ sourceKind }: SessionFormatMigrationStageInput) => {
  147. expect(sourceKind).toBe('transformed')
  148. return identityStage(0)
  149. })
  150. const complete = createSessionFormatChain({
  151. currentVersion: 2,
  152. migrations: [migration(), { ...second, createStage: createSecondStage }],
  153. restoreCurrentHeader: header => header,
  154. })
  155. const stream = complete.createStream(
  156. { ...currentHeader, version: 0 },
  157. 0,
  158. discard,
  159. )
  160. expect(stream.finish()).toBe(0)
  161. expect(createSecondStage).toHaveBeenCalledOnce()
  162. })
  163. it('rejects newer inputs and wrong header versions', () => {
  164. const current = chain(migration({ migrateHeader: header => header }))
  165. const source = { ...currentHeader, version: 0 }
  166. expect(() => current.createStream({ ...currentHeader, version: 2 }, 0, discard)).toThrow(/newer/)
  167. expect(() => current.createStream({ ...currentHeader, version: -1 }, 0, discard)).toThrow(/non-negative/)
  168. expect(() => current.createStream(source, 0, discard)).toThrow(/header returned v0/)
  169. expect(() => current.migrateHeader(source)).toThrow(/header returned v0/)
  170. })
  171. it('classifies stage failures as unsupported and preserves explicit refusals', () => {
  172. const policyFailure = new Error('target relationship is invalid')
  173. const source = { ...currentHeader, version: 0 }
  174. const failed = chain(migration({ createStage: () => { throw policyFailure } }))
  175. const refusal = captureError(() => failed.createStream(source, 0, discard))
  176. expect(refusal).toBeInstanceOf(SessionFormatUnsupportedMigrationError)
  177. expect(refusal.message).toContain('target relationship is invalid')
  178. expect(refusal.cause).toBe(policyFailure)
  179. const alreadyUnsupported = new SessionFormatUnsupportedMigrationError('explicit edge refusal')
  180. const preserved = chain(migration({ createStage: () => { throw alreadyUnsupported } }))
  181. expect(() => preserved.createStream(source, 0, discard)).toThrow(alreadyUnsupported)
  182. const finishFailure = new Error('finish relationship is invalid')
  183. const finishing = chain(migration({
  184. createStage: () => ({
  185. ...identityStage(0),
  186. finish: () => { throw finishFailure },
  187. }),
  188. })).createStream(source, 0, discard)
  189. const finishRefusal = captureError(() => finishing.finish())
  190. expect(finishRefusal).toBeInstanceOf(SessionFormatUnsupportedMigrationError)
  191. expect(finishRefusal.cause).toBe(finishFailure)
  192. const eventFailure = new Error('event relationship is invalid')
  193. const eventFailing = chain(migration({
  194. createStage: () => ({
  195. ...identityStage(0),
  196. transformEvent: () => { throw eventFailure },
  197. }),
  198. })).createStream(source, 0, discard)
  199. expect(() => { eventFailing.emitEvent(event) }).toThrow(/event relationship is invalid/)
  200. const runFailure = new Error('run relationship is invalid')
  201. const runFailing = chain(migration({
  202. createStage: () => ({
  203. ...identityStage(0),
  204. transformRun: () => { throw runFailure },
  205. }),
  206. })).createStream(source, 0, discard)
  207. const runRefusal = captureError(() => {
  208. runFailing.emitRun({
  209. runType: 'test-run',
  210. firstSeq: 0,
  211. eventCount: 1,
  212. *expand() { yield event },
  213. })
  214. })
  215. expect(runRefusal).toBeInstanceOf(SessionFormatUnsupportedMigrationError)
  216. expect(runRefusal.cause).toBe(runFailure)
  217. const nonError = chain(migration({
  218. createStage: () => throwUnknown('non-Error stage refusal'),
  219. }))
  220. expect(() => nonError.createStream(source, 0, discard))
  221. .toThrow(/non-Error stage refusal/)
  222. })
  223. it('collects compact runs as expanded events', () => {
  224. const collector = new SessionFormatEventCollector()
  225. collector.emitRun({
  226. runType: 'test-run',
  227. firstSeq: 0,
  228. eventCount: 1,
  229. *expand() { yield event },
  230. })
  231. expect(collector.values).toEqual([event])
  232. })
  233. it('validates every adjacent target header and the final current header', () => {
  234. const validateTargetHeader = vi.fn((header: SessionFormatHeader) => {
  235. if (header['targetMarker'] !== true) throw new Error('target header lacks marker')
  236. })
  237. const restoreCurrentHeader = vi.fn((header: SessionFormatHeader) => {
  238. if (typeof header.id !== 'string') throw new Error('current header lacks id')
  239. return header
  240. })
  241. const current = createSessionFormatChain({
  242. currentVersion: 1,
  243. migrations: [migration({ validateTargetHeader })],
  244. restoreCurrentHeader,
  245. })
  246. const source = { ...currentHeader, version: 0 }
  247. const refusal = captureError(() => current.migrateHeader(source))
  248. expect(refusal).toBeInstanceOf(SessionFormatUnsupportedMigrationError)
  249. expect(refusal.message).toContain('target header lacks marker')
  250. expect(validateTargetHeader).toHaveBeenCalledOnce()
  251. const rejecting = createSessionFormatChain({
  252. currentVersion: 1,
  253. migrations: [migration({ migrateHeader: () => { throw new Error('historical header policy') } })],
  254. restoreCurrentHeader,
  255. })
  256. expect(() => rejecting.migrateHeader(source)).toThrow(/historical header policy/)
  257. const badCurrent = createSessionFormatChain({
  258. currentVersion: 1,
  259. migrations: [migration()],
  260. restoreCurrentHeader: () => ({ version: 1 } as never),
  261. })
  262. expect(() => badCurrent.migrateHeader(currentHeader)).toThrow(/current Session header restoration id/)
  263. const wrongCurrentVersion = createSessionFormatChain({
  264. currentVersion: 1,
  265. migrations: [migration()],
  266. restoreCurrentHeader: header => ({ ...header, version: 0 }),
  267. })
  268. expect(() => wrongCurrentVersion.migrateHeader(currentHeader)).toThrow(/header restorer returned v0/)
  269. })
  270. })