| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- import { describe, expect, it, vi } from 'vitest'
- import {
- createSessionFormatCatalog,
- SessionFormatUnsupportedMigrationError,
- type SessionFormatArtifact,
- type SessionFormatCatalogOptions,
- type SessionFormatCodec,
- type SessionFormatCurrentEncoder,
- type SessionFormatEvent,
- type SessionFormatEventRun,
- type SessionFormatMigration,
- type SessionFormatMigrationContext,
- type SessionFormatMigrationStageInput,
- } from '../src/index.ts'
- function codec(version: number, inheritedEventCount = 0): SessionFormatCodec & SessionFormatCurrentEncoder {
- return {
- version,
- decodeHeader(value: unknown) {
- return value as SessionFormatArtifact['header']
- },
- createDecoder(headerValue: unknown) {
- return {
- header: headerValue as SessionFormatArtifact['header'],
- headerInheritedEventCount: inheritedEventCount,
- decodeRow(rowValue: unknown, context: SessionFormatMigrationContext) {
- context.emitEvent(rowValue as SessionFormatEvent)
- },
- finish: () => inheritedEventCount,
- }
- },
- encodeHeader(header) {
- return header
- },
- encodeEvent(event) {
- return event
- },
- }
- }
- function identityMigrationStage(inheritedEventCount: number | undefined) {
- return {
- ...(inheritedEventCount === undefined ? {} : { headerInheritedEventCount: inheritedEventCount }),
- transformEvent(
- event: SessionFormatEvent,
- context: SessionFormatMigrationContext,
- ) {
- context.emitEvent(event)
- },
- transformRun(
- run: SessionFormatEventRun,
- context: SessionFormatMigrationContext,
- ) {
- context.emitRun(run)
- },
- finish: () => inheritedEventCount ?? 0,
- }
- }
- function edge(overrides: Partial<SessionFormatMigration> = {}): SessionFormatMigration {
- return {
- name: '@test/v0-to-v1',
- fromVersion: 0,
- toVersion: 1,
- migrateHeader: header => ({ ...header, version: 1 }),
- createStage: ({ sourceInheritedEventCount }) => identityMigrationStage(sourceInheritedEventCount),
- validateTargetHeader: () => {},
- ...overrides,
- }
- }
- function catalog(
- migration: SessionFormatMigration = edge(),
- restoreVersion = 1,
- sourceCodec: SessionFormatCodec = codec(0),
- currentEncoder: SessionFormatCurrentEncoder = codec(1),
- restoreCurrentHeader: SessionFormatCatalogOptions['restoreCurrentHeader'] = header => header,
- ) {
- const currentCodec = codec(1)
- return createSessionFormatCatalog({
- currentVersion: 1,
- codecs: [sourceCodec, currentCodec],
- currentEncoder,
- migrations: [migration],
- restoreCurrent: artifact => ({
- ...artifact,
- header: { ...artifact.header, version: restoreVersion },
- }),
- restoreTransformedCurrent: artifact => ({
- ...artifact,
- header: { ...artifact.header, version: restoreVersion },
- }),
- restoreCurrentHeader,
- })
- }
- const oldHeader = {
- version: 0,
- id: 'old',
- createdAt: 1,
- isSeeded: false,
- delegationDepth: 0,
- } as const
- const event = { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } } as const
- function throwUnknown(value: unknown): never {
- throw value
- }
- describe('Session format catalog', () => {
- it('classifies headers and restores physical rows through the compiled chain', () => {
- const createStage = vi.fn(({ sourceInheritedEventCount }: SessionFormatMigrationStageInput) =>
- identityMigrationStage(sourceInheritedEventCount))
- const current = catalog(edge({ createStage }))
- expect(current.readHeader(oldHeader)).toEqual({
- status: 'migration-required',
- storedVersion: 0,
- targetVersion: 1,
- header: { ...oldHeader, version: 1 },
- })
- expect(current.readHeader({ version: 2 })).toMatchObject({
- status: 'unsupported', storedVersion: 2, targetVersion: 1,
- })
- expect(current.readHeader({ ...oldHeader, version: 1 })).toMatchObject({
- status: 'current', storedVersion: 1, targetVersion: 1,
- })
- expect(current.readHeader({ version: 'broken' })).toMatchObject({ status: 'malformed', targetVersion: 1 })
- const restore = current.createRestore(oldHeader, { recovery: 'strict', validation: 'current' })
- restore.decodeRow(event)
- expect(restore.finish()).toMatchObject({ header: { version: 1 }, events: [event] })
- expect(createStage).toHaveBeenCalledOnce()
- })
- it('restores current rows without a migration and encodes one record at a time', () => {
- const current = catalog()
- const header = { ...oldHeader, version: 1 }
- const restore = current.createRestore(header, { recovery: 'strict', validation: 'current' })
- restore.decodeRow(event)
- expect(restore.finish()).toEqual({ header, inheritedEventCount: 0, events: [event] })
- expect(current.encodeCurrentHeader(header, 0)).toEqual(header)
- expect(current.encodeCurrentEvent(event)).toEqual(event)
- expect(() => current.encodeCurrentHeader(oldHeader, 0)).toThrow(/requires Session format v1/)
- const physicalOnly = current.createRestore(header, { recovery: 'strict', validation: 'transformed' })
- physicalOnly.decodeRow(event)
- expect(physicalOnly.finish()).toEqual({ header, inheritedEventCount: 0, events: [event] })
- const transformed = current.createRestore(oldHeader, { recovery: 'strict', validation: 'transformed' })
- transformed.decodeRow(event)
- expect(transformed.finish()).toMatchObject({ header: { version: 1 }, events: [event] })
- })
- it.each([
- new Error('target artifact is incompatible'),
- 'non-Error target artifact refusal',
- ])('classifies migrated target validation failures as unsupported', (targetFailure) => {
- const current = createSessionFormatCatalog({
- currentVersion: 1,
- codecs: [codec(0), codec(1)],
- currentEncoder: codec(1),
- migrations: [edge()],
- restoreCurrent: artifact => artifact,
- restoreTransformedCurrent: () => { throw targetFailure },
- restoreCurrentHeader: header => header,
- })
- const restore = current.createRestore(oldHeader, { recovery: 'strict', validation: 'transformed' })
- restore.decodeRow(event)
- try {
- restore.finish()
- throw new Error('expected transformed target validation to fail')
- } catch (error: unknown) {
- expect(error).toBeInstanceOf(SessionFormatUnsupportedMigrationError)
- expect((error as Error).cause).toBe(targetFailure)
- }
- })
- it('preserves current-validation failures and explicit migration refusals', () => {
- const currentFailure = new Error('installed current validation failed')
- const current = createSessionFormatCatalog({
- currentVersion: 1,
- codecs: [codec(0), codec(1)],
- currentEncoder: codec(1),
- migrations: [edge()],
- restoreCurrent: () => { throw currentFailure },
- restoreTransformedCurrent: artifact => artifact,
- restoreCurrentHeader: header => header,
- }).createRestore(oldHeader, { recovery: 'strict', validation: 'current' })
- expect(() => current.finish()).toThrow(currentFailure)
- const explicit = new SessionFormatUnsupportedMigrationError('explicit target refusal')
- const transformed = createSessionFormatCatalog({
- currentVersion: 1,
- codecs: [codec(0), codec(1)],
- currentEncoder: codec(1),
- migrations: [edge()],
- restoreCurrent: artifact => artifact,
- restoreTransformedCurrent: () => { throw explicit },
- restoreCurrentHeader: header => header,
- }).createRestore(oldHeader, { recovery: 'strict', validation: 'transformed' })
- expect(() => transformed.finish()).toThrow(explicit)
- })
- it('rejects a current encoder that returns a non-current header', () => {
- const badEncoder: SessionFormatCurrentEncoder = {
- ...codec(1),
- encodeHeader: header => ({ ...header, version: 0 }),
- }
- const current = catalog(edge(), 1, codec(0), badEncoder)
- expect(() => current.encodeCurrentHeader({ ...oldHeader, version: 1 }, 0))
- .toThrow(/non-current header/)
- })
- it('rejects duplicate, missing, and future codec declarations', () => {
- const options = {
- currentVersion: 1,
- migrations: [edge()],
- currentEncoder: codec(1),
- restoreCurrent: (value: SessionFormatArtifact) => value,
- restoreTransformedCurrent: (value: SessionFormatArtifact) => value,
- restoreCurrentHeader: (value: SessionFormatArtifact['header']) => value,
- }
- expect(() => createSessionFormatCatalog({ ...options, codecs: [codec(0), codec(0), codec(1)] }))
- .toThrow(/codec v0 is duplicated/)
- expect(() => createSessionFormatCatalog({ ...options, codecs: [codec(0)] })).toThrow(/codec v1 is missing/)
- expect(() => createSessionFormatCatalog({ ...options, codecs: [codec(0), codec(1), codec(2)] }))
- .toThrow(/codec v2 is newer/)
- })
- it('preserves unsupported header and migration failures', () => {
- const refusingCodec: SessionFormatCodec = {
- ...codec(0),
- decodeHeader: () => { throw new Error('bad header') },
- createDecoder: () => { throw new Error('bad body') },
- }
- const current = catalog(edge(), 1, refusingCodec)
- expect(current.readHeader(oldHeader)).toMatchObject({ status: 'malformed', reason: 'bad header' })
- expect(() => current.createRestore(oldHeader, { recovery: 'strict', validation: 'current' }))
- .toThrow('bad body')
- expect(() => current.createRestore({ version: 2 }, { recovery: 'strict', validation: 'current' }))
- .toThrow(/newer/)
- const nonError = catalog({
- ...edge(),
- }, 1, {
- ...codec(0),
- decodeHeader: () => throwUnknown('non-Error header failure'),
- })
- expect(nonError.readHeader(oldHeader)).toMatchObject({
- status: 'malformed', reason: 'non-Error header failure',
- })
- })
- it('classifies migrated header refusals as unsupported and current corruption as malformed', () => {
- const unsupported = catalog(edge({
- validateTargetHeader: () => { throw new Error('target header lacks marker') },
- }))
- expect(unsupported.readHeader(oldHeader)).toMatchObject({
- status: 'unsupported',
- reason: expect.stringContaining('target header lacks marker') as string,
- })
- const malformed = catalog(
- edge(),
- 1,
- codec(0),
- codec(1),
- () => { throw new Error('current header is corrupt') },
- )
- expect(malformed.readHeader({ ...oldHeader, version: 1 })).toMatchObject({
- status: 'malformed',
- reason: 'current header is corrupt',
- })
- })
- it('enforces decoder, stage, and restorer lifecycle results', () => {
- const changedCut = edge({
- createStage: () => ({
- headerInheritedEventCount: 1,
- transformEvent: (candidate, context) => { context.emitEvent(candidate) },
- transformRun: (candidate, context) => { context.emitRun(candidate) },
- finish: () => 0,
- }),
- })
- const changed = catalog(changedCut, 1, codec(0, 1)).createRestore(
- { ...oldHeader, isSeeded: true },
- { recovery: 'strict', validation: 'current' },
- )
- expect(() => changed.finish()).toThrow(/changed its predeclared inherited cut/)
- const trailing = catalog(edge({
- createStage: () => ({
- headerInheritedEventCount: 0,
- transformEvent: () => {},
- transformRun: () => {},
- finish: (context) => { context.emitEvent(event); return 0 },
- }),
- })).createRestore(oldHeader, { recovery: 'strict', validation: 'current' })
- expect(trailing.finish().events).toEqual([event])
- const wrongVersion = catalog(edge(), 0).createRestore(
- oldHeader,
- { recovery: 'strict', validation: 'current' },
- )
- expect(() => wrongVersion.finish()).toThrow(/returned v0/)
- const changedDecoderCut: SessionFormatCodec = {
- ...codec(0),
- createDecoder(headerValue) {
- return {
- header: headerValue as SessionFormatArtifact['header'],
- headerInheritedEventCount: 0,
- decodeRow: () => {},
- finish: () => 1,
- }
- },
- }
- const changedDecoder = catalog(edge(), 1, changedDecoderCut).createRestore(
- oldHeader,
- { recovery: 'strict', validation: 'current' },
- )
- expect(() => changedDecoder.finish()).toThrow(/decoder changed its predeclared inherited cut/)
- const deferredCut: SessionFormatCodec = {
- ...codec(0),
- createDecoder(headerValue) {
- return {
- header: headerValue as SessionFormatArtifact['header'],
- decodeRow: () => {},
- finish: () => 0,
- }
- },
- }
- expect(catalog(edge(), 1, deferredCut).createRestore(
- oldHeader,
- { recovery: 'strict', validation: 'current' },
- ).finish().inheritedEventCount).toBe(0)
- })
- it('expands an unhandled compact run without an intermediate array', () => {
- const run: SessionFormatEventRun = {
- runType: 'test-run', firstSeq: 0, eventCount: 1, expand: function* () { yield event },
- }
- const source: SessionFormatCodec = {
- ...codec(0),
- createDecoder(headerValue) {
- return {
- header: headerValue as SessionFormatArtifact['header'],
- headerInheritedEventCount: 0,
- decodeRow(_rowValue, context) { context.emitRun(run) },
- finish: () => 0,
- }
- },
- }
- const restore = catalog(edge(), 1, source).createRestore(
- oldHeader,
- { recovery: 'strict', validation: 'current' },
- )
- restore.decodeRow({})
- expect(restore.finish().events).toEqual([event])
- })
- })
|