diff --git a/lib/commands/createEmptyMigration.ts b/lib/commands/createEmptyMigration.ts index e5bca38..816efec 100644 --- a/lib/commands/createEmptyMigration.ts +++ b/lib/commands/createEmptyMigration.ts @@ -4,7 +4,11 @@ import * as ReaderTaskEither from 'fp-ts/ReaderTaskEither'; import * as TaskEither from 'fp-ts/TaskEither'; import { kebabCase } from 'lodash/fp'; import { z as zod } from 'zod'; -import { toValidationError, ValidationError } from 'zod-validation-error'; +import { + isValidationErrorLike, + toValidationError, + ValidationError, +} from 'zod-validation-error'; import { MigrationRepoReadError, @@ -42,7 +46,6 @@ export function createEmptyMigration( props: CreateEmptyMigrationInputProps ): ReaderTaskEither.ReaderTaskEither< CreateEmptyMigrationDeps, - | ValidationError | MigrationRepoWriteError | MigrationTemplateNotFoundError | MigrationRepoReadError, @@ -62,6 +65,15 @@ export function createEmptyMigration( [timestamp, description].join('-') ), Either.flatMap(MigrationId.parse), + Either.mapLeft((err) => { + if (isValidationErrorLike(err)) { + return new MigrationRepoWriteError(`Invalid migration ID`, { + cause: err, + }); + } + + return err; + }), TaskEither.fromEither, TaskEither.bindTo('migrationId'), TaskEither.bindW('template', getMigrationTemplate), diff --git a/lib/commands/index.ts b/lib/commands/index.ts index 5648be3..d69d1a3 100644 --- a/lib/commands/index.ts +++ b/lib/commands/index.ts @@ -1,2 +1,3 @@ export * from './createEmptyMigration'; export * from './collectMigrationState'; +export * from './migrateUp'; diff --git a/lib/commands/migrateUp.ts b/lib/commands/migrateUp.ts new file mode 100644 index 0000000..81e6b8c --- /dev/null +++ b/lib/commands/migrateUp.ts @@ -0,0 +1,162 @@ +import * as ArrayFp from 'fp-ts/Array'; +import * as Either from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; +import * as ReaderTaskEither from 'fp-ts/ReaderTaskEither'; +import * as TaskEither from 'fp-ts/TaskEither'; +import { z as zod } from 'zod'; +import { + isValidationErrorLike, + toValidationError, + ValidationError, +} from 'zod-validation-error'; + +import { + InvalidMigrationHistoryLogError, + InvalidMigrationStateError, + MigrationHistoryLogNotFoundError, + MigrationHistoryLogReadError, + MigrationHistoryLogWriteError, + MigrationNotFoundError, + MigrationRepoNotFoundError, + MigrationRepoReadError, + MigrationRepoWriteError, + MigrationRuntimeError, + MigrationTemplateNotFoundError, +} from '../errors'; +import { + HistoryLogEntry, + Migration, + MigrationId, + MigrationState, +} from '../models'; +import { isPendingMigration } from '../models/MigrationState'; +import { MigrationHistoryLog } from '../ports'; +import { + collectMigrationState, + type CollectMigrationStateDeps, +} from './collectMigrationState'; + +const schema = zod.object({ + migrationId: MigrationId.schema.optional(), +}); + +export type MigrateUpInputProps = zod.infer; + +export function parseMigrateUpInputProps( + value: zod.input +): Either.Either { + return Either.tryCatch(() => schema.parse(value), toValidationError()); +} + +export type MigrateUpDeps = CollectMigrationStateDeps & { + addExecutedMigration: MigrationHistoryLog['addExecutedMigration']; +}; + +export function migrateUp( + props: MigrateUpInputProps +): ReaderTaskEither.ReaderTaskEither< + MigrateUpDeps, + | InvalidMigrationHistoryLogError + | InvalidMigrationStateError + | MigrationHistoryLogNotFoundError + | MigrationHistoryLogReadError + | MigrationHistoryLogWriteError + | MigrationNotFoundError + | MigrationRepoNotFoundError + | MigrationRepoReadError + | MigrationRepoWriteError + | MigrationRuntimeError + | MigrationTemplateNotFoundError, + Array +> { + return pipe( + collectMigrationState(), + ReaderTaskEither.flatMapEither((migrationState) => + calculateMigrationsToApply(migrationState, props.migrationId) + ), + ReaderTaskEither.flatMap((migrationsToApply) => { + return pipe( + ReaderTaskEither.ask(), + ReaderTaskEither.chainTaskEitherK(({ addExecutedMigration }) => { + return pipe( + migrationsToApply, + ArrayFp.map((migration) => + pipe( + TaskEither.tryCatch( + () => migration.up(), + (err) => + err instanceof Error + ? new MigrationRuntimeError( + `Unknown error occurred while applying migration "${migration.id}"`, + { cause: err } + ) + : new MigrationRuntimeError( + `Unknown error occurred while applying migration "${migration.id}"` + ) + ), + TaskEither.flatMapEither(() => + HistoryLogEntry.parse({ + id: migration.id, + executedAt: new Date(), + checksum: migration.checksum, + }) + ), + TaskEither.mapLeft((err) => { + if (isValidationErrorLike(err)) { + return new MigrationRuntimeError( + `Invalid migration history log entry for migration "${migration.id}"`, + { cause: err } + ); + } + + return err; + }), + TaskEither.flatMap(addExecutedMigration), + TaskEither.map(() => migration.id) + ) + ), + ArrayFp.sequence(TaskEither.ApplicativeSeq) + ); + }) + ); + }) + ); +} + +function calculateMigrationsToApply( + migrationState: MigrationState.MigrationState, + targetMigrationId?: MigrationId.MigrationId +): Either.Either< + MigrationNotFoundError | MigrationRuntimeError, + Array +> { + if (targetMigrationId == null) { + return Either.of(migrationState.filter(isPendingMigration)); + } + + const targetIndex = migrationState.findLastIndex( + (migration) => migration.id === targetMigrationId + ); + + if (targetIndex === -1) { + return Either.left( + new MigrationNotFoundError( + `Target migration "${targetMigrationId}" not found` + ) + ); + } + + const migrationRecord = migrationState[targetIndex]; + + if (migrationRecord.status === 'EXECUTED') { + return Either.left( + new MigrationRuntimeError( + `Target migration "${targetMigrationId}" is already applied` + ) + ); + } + + return Either.of( + migrationState.slice(0, targetIndex + 1).filter(isPendingMigration) + ); +} diff --git a/lib/errors.ts b/lib/errors.ts index 974f011..5896c61 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -37,3 +37,7 @@ export class InvalidMigrationHistoryLogError extends Error { export class InvalidMigrationStateError extends Error { name = 'InvalidMigrationStateError' as const; } + +export class MigrationRuntimeError extends Error { + name = 'MigrationRuntimeError' as const; +} diff --git a/lib/models/MigrationState.ts b/lib/models/MigrationState.ts index 551b1c2..468a05e 100644 --- a/lib/models/MigrationState.ts +++ b/lib/models/MigrationState.ts @@ -4,10 +4,21 @@ import { pipe } from 'fp-ts/lib/function'; import { InvalidMigrationStateError } from '../errors'; import * as HistoryLog from './HistoryLog'; +import * as HistoryLogEntry from './HistoryLogEntry'; import * as Migration from './Migration'; -import * as MigrationStateRecord from './MigrationStateRecord'; -export type MigrationState = Array; +export type ExecutedMigration = Migration.Migration & { + status: 'EXECUTED'; + executedAt: HistoryLogEntry.HistoryLogEntry['executedAt']; +}; + +export type PendingMigration = Migration.Migration & { + status: 'PENDING'; + executedAt: null; +}; + +export type MigrationStateRecord = ExecutedMigration | PendingMigration; +export type MigrationState = Array; export function create( migrations: Array, @@ -27,10 +38,7 @@ export function create( ( index, migration - ): Either.Either< - InvalidMigrationStateError, - MigrationStateRecord.MigrationStateRecord - > => { + ): Either.Either => { const historyLogEntry = historyLog.entries[index]; if (historyLogEntry == null) { @@ -67,3 +75,15 @@ export function create( ArrayFp.sequence(Either.Applicative) ); } + +export function isPendingMigration( + record: MigrationStateRecord +): record is PendingMigration { + return record.status === 'PENDING'; +} + +export function isExecutedMigration( + record: MigrationStateRecord +): record is ExecutedMigration { + return record.status === 'EXECUTED'; +} diff --git a/lib/models/MigrationStateRecord.ts b/lib/models/MigrationStateRecord.ts deleted file mode 100644 index 54aee37..0000000 --- a/lib/models/MigrationStateRecord.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as HistoryLogEntry from './HistoryLogEntry'; -import * as Migration from './Migration'; - -export type MigrationStateRecord = Migration.Migration & { - status: 'EXECUTED' | 'PENDING'; - executedAt: HistoryLogEntry.HistoryLogEntry['executedAt'] | null; -}; diff --git a/lib/models/index.ts b/lib/models/index.ts index e98264b..c3772af 100644 --- a/lib/models/index.ts +++ b/lib/models/index.ts @@ -5,4 +5,3 @@ export * as Migration from './Migration'; export * as HistoryLog from './HistoryLog'; export * as HistoryLogEntry from './HistoryLogEntry'; export * as MigrationState from './MigrationState'; -export * as MigrationStateRecord from './MigrationStateRecord';