chain.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { SessionFormatError, SessionFormatUnsupportedMigrationError } from './error.ts'
  2. import {
  3. snapshotSessionFormatHeader,
  4. sessionFormatCount,
  5. sessionFormatVersion,
  6. } from './json.ts'
  7. import type {
  8. SessionFormatChain,
  9. SessionFormatChainOptions,
  10. SessionFormatEvent,
  11. SessionFormatEventRun,
  12. SessionFormatHeader,
  13. SessionFormatMigration,
  14. SessionFormatMigrationContext,
  15. SessionFormatMigrationStage,
  16. SessionFormatMigrationStream,
  17. } from './types.ts'
  18. /**
  19. * Validate and freeze one adjacent migration declaration.
  20. * @param migration - named exact adjacent conversion.
  21. * @returns immutable validated declaration.
  22. */
  23. export function defineSessionFormatMigration(migration: SessionFormatMigration): SessionFormatMigration {
  24. if (typeof migration.name !== 'string' || migration.name.length === 0) {
  25. throw new SessionFormatError('Session migration name must be a non-empty string')
  26. }
  27. const from = sessionFormatVersion(migration.fromVersion, `${migration.name} fromVersion`)
  28. const to = sessionFormatVersion(migration.toVersion, `${migration.name} toVersion`)
  29. if (to !== from + 1) {
  30. throw new SessionFormatError(`${migration.name} must declare adjacent v${from}->v${from + 1}`)
  31. }
  32. return Object.freeze({ ...migration })
  33. }
  34. /**
  35. * Compile a unique, complete adjacent migration chain.
  36. * @param options - current version, adjacent declarations, and current restorer.
  37. * @returns immutable planner and streaming migration compiler.
  38. */
  39. export function createSessionFormatChain(options: SessionFormatChainOptions): SessionFormatChain {
  40. return new CompiledSessionFormatChain(options)
  41. }
  42. class CompiledSessionFormatChain implements SessionFormatChain {
  43. readonly currentVersion: number
  44. private readonly migrations: readonly SessionFormatMigration[]
  45. private readonly restoreCurrentHeader: SessionFormatChainOptions['restoreCurrentHeader']
  46. constructor(options: SessionFormatChainOptions) {
  47. this.currentVersion = sessionFormatVersion(options.currentVersion, 'current Session format version')
  48. this.restoreCurrentHeader = options.restoreCurrentHeader
  49. const byFrom = new Map<number, SessionFormatMigration>()
  50. const names = new Set<string>()
  51. for (const candidate of options.migrations) {
  52. const migration = defineSessionFormatMigration(candidate)
  53. if (byFrom.has(migration.fromVersion)) {
  54. throw new SessionFormatError(`Session migration v${migration.fromVersion}->v${migration.toVersion} is duplicated`)
  55. }
  56. if (names.has(migration.name)) throw new SessionFormatError(`Session migration name ${JSON.stringify(migration.name)} is duplicated`)
  57. byFrom.set(migration.fromVersion, migration)
  58. names.add(migration.name)
  59. }
  60. const ordered: SessionFormatMigration[] = []
  61. for (let version = 0; version < this.currentVersion; version += 1) {
  62. const migration = byFrom.get(version)
  63. if (migration === undefined) {
  64. throw new SessionFormatUnsupportedMigrationError(`Session migration v${version}->v${version + 1} is missing`)
  65. }
  66. ordered.push(migration)
  67. }
  68. if (byFrom.size !== ordered.length) {
  69. const invalid = [...byFrom.keys()].find(version => version >= this.currentVersion) as number
  70. throw new SessionFormatError(`Session migration from v${invalid} does not lead to current v${this.currentVersion}`)
  71. }
  72. this.migrations = Object.freeze(ordered)
  73. }
  74. private plan(fromVersion: number): readonly SessionFormatMigration[] {
  75. const from = sessionFormatVersion(fromVersion, 'stored Session format version')
  76. if (from > this.currentVersion) {
  77. throw new SessionFormatUnsupportedMigrationError(
  78. `stored Session uses newer format v${from}; this build writes v${this.currentVersion}`,
  79. )
  80. }
  81. return Object.freeze(this.migrations.slice(from))
  82. }
  83. createStream(
  84. sourceHeader: SessionFormatHeader,
  85. sourceCut: number,
  86. output: SessionFormatMigrationContext,
  87. ): SessionFormatMigrationStream {
  88. let header = sourceHeader
  89. const validatedSourceCut = sessionFormatCount(sourceCut, 'Session inherited event count')
  90. let inheritedEventCount = validatedSourceCut
  91. const stages: Array<{
  92. readonly migration: SessionFormatMigration
  93. readonly stage: SessionFormatMigrationStage
  94. }> = []
  95. const plan = this.plan(header.version)
  96. for (const [index, migration] of plan.entries()) {
  97. const targetHeader = this.advanceHeader(migration, header)
  98. let stage: SessionFormatMigrationStage
  99. try {
  100. stage = migration.createStage({
  101. sourceHeader: header,
  102. targetHeader,
  103. sourceInheritedEventCount: inheritedEventCount,
  104. sourceKind: index === 0 ? 'decoded' : 'transformed',
  105. })
  106. } catch (error: unknown) {
  107. throwUnsupportedRefusal(migration, error)
  108. }
  109. header = targetHeader
  110. stages.push({ migration, stage })
  111. if (index + 1 < plan.length) {
  112. const targetCut = stage.headerInheritedEventCount
  113. if (targetCut === undefined) {
  114. throw new SessionFormatError(`${migration.name} must expose its inherited cut before the next migration`)
  115. }
  116. inheritedEventCount = targetCut
  117. }
  118. }
  119. return new CompiledSessionFormatMigrationStream(
  120. header,
  121. validatedSourceCut,
  122. stages,
  123. output,
  124. )
  125. }
  126. migrateHeader(source: SessionFormatHeader): SessionFormatHeader {
  127. let current = snapshotSessionFormatHeader(source, 'stored Session header')
  128. for (const migration of this.plan(current.version)) {
  129. current = this.advanceHeader(migration, current)
  130. }
  131. current = snapshotSessionFormatHeader(this.restoreCurrentHeader(current), 'current Session header restoration')
  132. if (current.version !== this.currentVersion) {
  133. throw new SessionFormatError(
  134. `current Session header restorer returned v${current.version}; expected v${this.currentVersion}`,
  135. )
  136. }
  137. return current
  138. }
  139. private advanceHeader(
  140. migration: SessionFormatMigration,
  141. source: SessionFormatHeader,
  142. ): SessionFormatHeader {
  143. let target: SessionFormatHeader
  144. try {
  145. target = migration.migrateHeader(snapshotSessionFormatHeader(source, `${migration.name} header input`))
  146. } catch (error: unknown) {
  147. throwUnsupportedRefusal(migration, error, 'Session header')
  148. }
  149. const current = snapshotSessionFormatHeader(target, `${migration.name} header output`)
  150. if (current.version !== migration.toVersion) {
  151. throw new SessionFormatError(`${migration.name} header returned v${current.version}; expected v${migration.toVersion}`)
  152. }
  153. try {
  154. migration.validateTargetHeader(current)
  155. } catch (error: unknown) {
  156. throwUnsupportedRefusal(migration, error, 'Session header')
  157. }
  158. return current
  159. }
  160. }
  161. interface CompiledMigrationStage {
  162. readonly migration: SessionFormatMigration
  163. readonly stage: SessionFormatMigrationStage
  164. }
  165. class ChainedMigrationContext implements SessionFormatMigrationContext {
  166. constructor(
  167. private readonly entry: CompiledMigrationStage,
  168. private readonly output: SessionFormatMigrationContext,
  169. ) {}
  170. emitEvent(event: SessionFormatEvent): void {
  171. try {
  172. this.entry.stage.transformEvent(event, this.output)
  173. } catch (error: unknown) {
  174. throwUnsupportedRefusal(this.entry.migration, error)
  175. }
  176. }
  177. emitRun(run: SessionFormatEventRun): void {
  178. try {
  179. this.entry.stage.transformRun(run, this.output)
  180. } catch (error: unknown) {
  181. throwUnsupportedRefusal(this.entry.migration, error)
  182. }
  183. }
  184. finish(): number {
  185. let targetCut: number
  186. try {
  187. targetCut = this.entry.stage.finish(this.output)
  188. } catch (error: unknown) {
  189. throwUnsupportedRefusal(this.entry.migration, error)
  190. }
  191. if (this.entry.stage.headerInheritedEventCount !== undefined
  192. && this.entry.stage.headerInheritedEventCount !== targetCut) {
  193. throw new SessionFormatError(`${this.entry.migration.name} changed its predeclared inherited cut`)
  194. }
  195. return targetCut
  196. }
  197. }
  198. class CompiledSessionFormatMigrationStream implements SessionFormatMigrationStream {
  199. private readonly input: SessionFormatMigrationContext
  200. private readonly stages: readonly ChainedMigrationContext[]
  201. constructor(
  202. readonly header: SessionFormatHeader,
  203. private readonly sourceInheritedEventCount: number,
  204. entries: readonly CompiledMigrationStage[],
  205. output: SessionFormatMigrationContext,
  206. ) {
  207. const stages = new Array<ChainedMigrationContext>(entries.length)
  208. let downstream = output
  209. for (const [offset, entry] of entries.toReversed().entries()) {
  210. const context = new ChainedMigrationContext(entry, downstream)
  211. stages[entries.length - offset - 1] = context
  212. downstream = context
  213. }
  214. this.input = downstream
  215. this.stages = stages
  216. }
  217. emitEvent(event: SessionFormatEvent): void {
  218. this.input.emitEvent(event)
  219. }
  220. emitRun(run: SessionFormatEventRun): void {
  221. this.input.emitRun(run)
  222. }
  223. finish(): number {
  224. let inheritedEventCount = this.sourceInheritedEventCount
  225. for (const stage of this.stages) inheritedEventCount = stage.finish()
  226. return inheritedEventCount
  227. }
  228. }
  229. function throwUnsupportedRefusal(
  230. migration: SessionFormatMigration,
  231. error: unknown,
  232. subject = 'Session',
  233. ): never {
  234. if (error instanceof SessionFormatUnsupportedMigrationError) throw error
  235. const detail = error instanceof Error ? error.message : String(error)
  236. throw new SessionFormatUnsupportedMigrationError(
  237. `${migration.name} refuses this format v${migration.fromVersion} ${subject}: ${detail}`,
  238. { cause: error },
  239. )
  240. }