diff --git a/.changeset/eleven-knives-learn.md b/.changeset/eleven-knives-learn.md new file mode 100644 index 0000000..a7110c7 --- /dev/null +++ b/.changeset/eleven-knives-learn.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': patch +--- + +Handle migration history entries without file extensions for migration files with periods in their names that are not part of the file extension. Previously Emigrate would attempt to re-run these migrations, but now it will correctly ignore them. E.g. the migration history contains an entry for "migration.file.name" and the migration file is named "migration.file.name.js" it will not be re-run. diff --git a/packages/cli/src/collect-migrations.test.ts b/packages/cli/src/collect-migrations.test.ts new file mode 100644 index 0000000..f5a2b72 --- /dev/null +++ b/packages/cli/src/collect-migrations.test.ts @@ -0,0 +1,99 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { collectMigrations } from './collect-migrations.js'; +import { toEntries, toEntry, toMigration, toMigrations } from './test-utils.js'; +import { arrayFromAsync } from './array-from-async.js'; +import { MigrationHistoryError } from './errors.js'; + +describe('collect-migrations', () => { + it('returns all migrations from the history and all pending migrations', async () => { + const cwd = '/cwd'; + const directory = 'directory'; + const history = { + async *[Symbol.asyncIterator]() { + yield* toEntries(['migration1.js', 'migration2.js']); + }, + }; + const getMigrations = async () => toMigrations(cwd, directory, ['migration1.js', 'migration2.js', 'migration3.js']); + + const result = await arrayFromAsync(collectMigrations(cwd, directory, history, getMigrations)); + + assert.deepStrictEqual(result, [ + { + ...toMigration(cwd, directory, 'migration1.js'), + duration: 0, + status: 'done', + }, + { + ...toMigration(cwd, directory, 'migration2.js'), + duration: 0, + status: 'done', + }, + toMigration(cwd, directory, 'migration3.js'), + ]); + }); + + it('includes any errors from the history', async () => { + const entry = toEntry('migration1.js', 'failed'); + const cwd = '/cwd'; + const directory = 'directory'; + const history = { + async *[Symbol.asyncIterator]() { + yield* [entry]; + }, + }; + const getMigrations = async () => toMigrations(cwd, directory, ['migration1.js', 'migration2.js', 'migration3.js']); + + const result = await arrayFromAsync(collectMigrations(cwd, directory, history, getMigrations)); + + assert.deepStrictEqual(result, [ + { + ...toMigration(cwd, directory, 'migration1.js'), + duration: 0, + status: 'failed', + error: MigrationHistoryError.fromHistoryEntry(entry), + }, + toMigration(cwd, directory, 'migration2.js'), + toMigration(cwd, directory, 'migration3.js'), + ]); + }); + + it('can handle a migration history without file extensions', async () => { + const cwd = '/cwd'; + const directory = 'directory'; + const history = { + async *[Symbol.asyncIterator]() { + yield* toEntries(['migration1']); + }, + }; + const getMigrations = async () => toMigrations(cwd, directory, ['migration1.js', 'migration2.js', 'migration3.js']); + + const result = await arrayFromAsync(collectMigrations(cwd, directory, history, getMigrations)); + + assert.deepStrictEqual(result, [ + { ...toMigration(cwd, directory, 'migration1.js'), duration: 0, status: 'done' }, + toMigration(cwd, directory, 'migration2.js'), + toMigration(cwd, directory, 'migration3.js'), + ]); + }); + + it('can handle a migration history without file extensions even if the migration name contains periods', async () => { + const cwd = '/cwd'; + const directory = 'directory'; + const history = { + async *[Symbol.asyncIterator]() { + yield* toEntries(['mig.ration1']); + }, + }; + const getMigrations = async () => + toMigrations(cwd, directory, ['mig.ration1.js', 'migration2.js', 'migration3.js']); + + const result = await arrayFromAsync(collectMigrations(cwd, directory, history, getMigrations)); + + assert.deepStrictEqual(result, [ + { ...toMigration(cwd, directory, 'mig.ration1.js'), duration: 0, status: 'done' }, + toMigration(cwd, directory, 'migration2.js'), + toMigration(cwd, directory, 'migration3.js'), + ]); + }); +}); diff --git a/packages/cli/src/collect-migrations.ts b/packages/cli/src/collect-migrations.ts index dc1ce8f..0c1886a 100644 --- a/packages/cli/src/collect-migrations.ts +++ b/packages/cli/src/collect-migrations.ts @@ -1,4 +1,3 @@ -import { extname } from 'node:path'; import { type MigrationHistoryEntry, type MigrationMetadata, type MigrationMetadataFinished } from '@emigrate/types'; import { toMigrationMetadata } from './to-migration-metadata.js'; import { getMigrations as getMigrationsOriginal } from './get-migrations.js'; @@ -12,18 +11,18 @@ export async function* collectMigrations( const allMigrations = await getMigrations(cwd, directory); const seen = new Set(); - for await (const entry_ of history) { - const entry = extname(entry_.name) === '' ? { ...entry_, name: `${entry_.name}.js` } : entry_; + for await (const entry of history) { + const migration = allMigrations.find((migrationFile) => { + return migrationFile.name === entry.name || migrationFile.name === `${entry.name}.js`; + }); - const index = allMigrations.findIndex((migrationFile) => migrationFile.name === entry.name); - - if (index === -1) { + if (!migration) { continue; } - yield toMigrationMetadata(entry, { cwd, directory }); + yield toMigrationMetadata({ ...entry, name: migration.name }, { cwd, directory }); - seen.add(entry.name); + seen.add(migration.name); } yield* allMigrations.filter((migration) => !seen.has(migration.name)); diff --git a/packages/cli/src/commands/up.test.ts b/packages/cli/src/commands/up.test.ts index bc1f840..f58ce8d 100644 --- a/packages/cli/src/commands/up.test.ts +++ b/packages/cli/src/commands/up.test.ts @@ -1,15 +1,11 @@ import { describe, it, mock, type Mock } from 'node:test'; import assert from 'node:assert'; -import path from 'node:path'; import { type EmigrateReporter, type MigrationHistoryEntry, - type MigrationMetadata, type Storage, type Plugin, type SerializedError, - type FailedMigrationHistoryEntry, - type NonFailedMigrationHistoryEntry, type MigrationMetadataFinished, } from '@emigrate/types'; import { deserializeError, serializeError } from 'serialize-error'; @@ -22,6 +18,7 @@ import { MigrationRunError, StorageInitError, } from '../errors.js'; +import { toEntries, toEntry, toMigrations } from '../test-utils.js'; import upCommand from './up.js'; type Mocked = { @@ -619,55 +616,6 @@ function getErrorCause(error: Error | undefined): Error | SerializedError | unde return undefined; } -function toMigration(cwd: string, directory: string, name: string): MigrationMetadata { - return { - name, - filePath: `${cwd}/${directory}/${name}`, - relativeFilePath: `${directory}/${name}`, - extension: path.extname(name), - directory, - cwd, - }; -} - -function toMigrations(cwd: string, directory: string, names: string[]): MigrationMetadata[] { - return names.map((name) => toMigration(cwd, directory, name)); -} - -function toEntry(name: MigrationHistoryEntry): MigrationHistoryEntry; -function toEntry( - name: string, - status?: S, -): S extends 'failed' ? FailedMigrationHistoryEntry : NonFailedMigrationHistoryEntry; - -function toEntry(name: string | MigrationHistoryEntry, status?: 'done' | 'failed'): MigrationHistoryEntry { - if (typeof name !== 'string') { - return name.status === 'failed' ? name : name; - } - - if (status === 'failed') { - return { - name, - status, - date: new Date(), - error: { name: 'Error', message: 'Failed' }, - }; - } - - return { - name, - status: status ?? 'done', - date: new Date(), - }; -} - -function toEntries( - names: Array, - status?: MigrationHistoryEntry['status'], -): MigrationHistoryEntry[] { - return names.map((name) => (typeof name === 'string' ? toEntry(name, status) : name)); -} - async function noop() { // noop } diff --git a/packages/cli/src/test-utils.ts b/packages/cli/src/test-utils.ts new file mode 100644 index 0000000..2e1b7cc --- /dev/null +++ b/packages/cli/src/test-utils.ts @@ -0,0 +1,56 @@ +import path from 'node:path'; +import { + type FailedMigrationHistoryEntry, + type MigrationHistoryEntry, + type MigrationMetadata, + type NonFailedMigrationHistoryEntry, +} from '@emigrate/types'; + +export function toMigration(cwd: string, directory: string, name: string): MigrationMetadata { + return { + name, + filePath: `${cwd}/${directory}/${name}`, + relativeFilePath: `${directory}/${name}`, + extension: path.extname(name), + directory, + cwd, + }; +} + +export function toMigrations(cwd: string, directory: string, names: string[]): MigrationMetadata[] { + return names.map((name) => toMigration(cwd, directory, name)); +} + +export function toEntry(name: MigrationHistoryEntry): MigrationHistoryEntry; +export function toEntry( + name: string, + status?: S, +): S extends 'failed' ? FailedMigrationHistoryEntry : NonFailedMigrationHistoryEntry; + +export function toEntry(name: string | MigrationHistoryEntry, status?: 'done' | 'failed'): MigrationHistoryEntry { + if (typeof name !== 'string') { + return name.status === 'failed' ? name : name; + } + + if (status === 'failed') { + return { + name, + status, + date: new Date(), + error: { name: 'Error', message: 'Failed' }, + }; + } + + return { + name, + status: status ?? 'done', + date: new Date(), + }; +} + +export function toEntries( + names: Array, + status?: MigrationHistoryEntry['status'], +): MigrationHistoryEntry[] { + return names.map((name) => (typeof name === 'string' ? toEntry(name, status) : name)); +}