Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const runMigrationsMock = vi.fn();

vi.mock('@clack/prompts', () => ({
log: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
vi.mock('../../shared/project-validation', () => ({
validateVendureProjectDirectory: vi.fn(),
}));
vi.mock('../../shared/shared-prompts', () => ({
analyzeProject: vi.fn().mockResolvedValue({ project: {}, tsConfigPath: 'tsconfig.json' }),
}));
vi.mock('../../shared/vendure-config-ref', () => ({
VendureConfigRef: class {
getPathRelativeToProjectRoot() {
return 'src/vendure-config.ts';
}
},
}));
vi.mock('../../shared/load-vendure-config-file', () => ({
loadVendureConfigFile: vi.fn().mockResolvedValue({}),
}));
vi.mock('@vendure/core', () => ({
runMigrations: (...args: any[]) => runMigrationsMock(...args),
generateMigration: vi.fn(),
revertLastMigration: vi.fn(),
}));

// eslint-disable-next-line import/first
import { runMigrationsOperation } from './migration-operations';

describe('runMigrationsOperation() reporting', () => {
beforeEach(() => {
runMigrationsMock.mockReset();
});

it('reports the reason when no migration files were found', async () => {
// #5001 — a non-matching `migrations` glob is reported as success, which makes an
// out-of-sync database look like an up-to-date one.
runMigrationsMock.mockImplementation((config: unknown, options: any) => {
options?.onNoMigrationsFound?.('No migration files matched the configured patterns.');
return Promise.resolve([]);
});

const result = await runMigrationsOperation();

expect(result.success).toBe(true);
expect(result.message).toBe('No migration files matched the configured patterns.');
expect(result.message).not.toBe('No pending migrations found');
});

it('still reports "No pending migrations found" when migration files exist', async () => {
runMigrationsMock.mockResolvedValue([]);

const result = await runMigrationsOperation();

expect(result.success).toBe(true);
expect(result.message).toBe('No pending migrations found');
});

it('reports the number of migrations that ran', async () => {
runMigrationsMock.mockResolvedValue(['1700000000000-first', '1700000000001-second']);

const result = await runMigrationsOperation();

expect(result.success).toBe(true);
expect(result.message).toBe('Successfully ran 2 migrations');
expect(result.migrationsRan).toHaveLength(2);
});
});
7 changes: 5 additions & 2 deletions packages/cli/src/commands/migrate/migration-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,14 @@ export async function runMigrationsOperation(configFile?: string): Promise<Migra
const config = await loadVendureConfigFile(vendureConfig);

log.info('Running migrations...');
const migrationsRan = await runMigrations(config);
let noMigrationsFoundMessage: string | undefined;
const migrationsRan = await runMigrations(config, {
onNoMigrationsFound: message => (noMigrationsFoundMessage = message),
});

const report = migrationsRan.length
? `Successfully ran ${migrationsRan.length} migrations`
: 'No pending migrations found';
: (noMigrationsFoundMessage ?? 'No pending migrations found');

return {
success: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ async function runRunMigration(configFile?: string): Promise<CliCommandReturnVal

const runSpinner = spinner();
runSpinner.start('Running migrations...');
const migrationsRan = await runMigrations(config);
let noMigrationsFoundMessage: string | undefined;
const migrationsRan = await runMigrations(config, {
onNoMigrationsFound: message => (noMigrationsFoundMessage = message),
});
const report = migrationsRan.length
? `Successfully ran ${migrationsRan.length} migrations`
: 'No pending migrations found';
: (noMigrationsFoundMessage ?? 'No pending migrations found');
runSpinner.stop(report);
return {
project,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export * from './event-bus/index';
export * from './health-check/index';
export * from './i18n/index';
export * from './job-queue/index';
export { generateMigration, revertLastMigration, runMigrations } from './migrate';
export { RunMigrationsOptions, generateMigration, revertLastMigration, runMigrations } from './migrate';
export * from './migration-utils/index';
export * from './plugin/index';
export * from './process-context/index';
Expand Down
54 changes: 54 additions & 0 deletions packages/core/src/migrate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';

import { getNoMigrationsFoundMessage } from './migrate';

describe('getNoMigrationsFoundMessage()', () => {
class SomeMigration {}

it('returns undefined when migration files were loaded', () => {
expect(getNoMigrationsFoundMessage(3, ['src/migrations/*.ts'], '/project')).toBeUndefined();
});

it('returns undefined when no migrations are configured', () => {
expect(getNoMigrationsFoundMessage(0, undefined, '/project')).toBeUndefined();
expect(getNoMigrationsFoundMessage(0, [], '/project')).toBeUndefined();
});

it('returns undefined when migrations are configured as classes', () => {
// Classes are passed directly rather than resolved from disk, so a zero count
// cannot be attributed to non-matching glob patterns.
expect(getNoMigrationsFoundMessage(0, [SomeMigration], '/project')).toBeUndefined();
});

it('reports the patterns and the cwd they resolve against', () => {
const message = getNoMigrationsFoundMessage(0, ['dist/migrations/*.js'], '/project');

expect(message).toContain('No migration files matched');
expect(message).toContain('/project');
expect(message).toContain('dist/migrations/*.js');
});

it('reports every configured pattern', () => {
const message = getNoMigrationsFoundMessage(
0,
['dist/migrations/*.js', 'plugins/*/migrations/*.js'],
'/project',
);

expect(message).toContain('dist/migrations/*.js');
expect(message).toContain('plugins/*/migrations/*.js');
});

it('supports the object form of the migrations option', () => {
const message = getNoMigrationsFoundMessage(0, { first: 'dist/migrations/*.js' } as any, '/project');

expect(message).toContain('dist/migrations/*.js');
});

it('ignores class entries when reporting patterns', () => {
const message = getNoMigrationsFoundMessage(0, [SomeMigration, 'dist/migrations/*.js'], '/project');

expect(message).toContain('dist/migrations/*.js');
expect(message).not.toContain('SomeMigration');
});
});
68 changes: 67 additions & 1 deletion packages/core/src/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,40 @@ export interface MigrationOptions {
outputDir?: string;
}

/**
* @description
* Options for {@link runMigrations}.
*
* @docsCategory migration
* @since 3.7.2
*/
export interface RunMigrationsOptions {
/**
* @description
* Invoked when the configured `migrations` patterns matched no files at all, which is
* otherwise indistinguishable from "no pending migrations" since both result in an empty
* return value. The message describes the patterns and the directory they were resolved
* against.
*/
onNoMigrationsFound?: (message: string) => void;
}

/**
* @description
* Runs any pending database migrations. See [TypeORM migration docs](https://typeorm.io/#/migrations)
* for more information about the underlying migration mechanism.
*
* @docsCategory migration
*/
export async function runMigrations(userConfig: Partial<VendureConfig>): Promise<string[]> {
export async function runMigrations(
userConfig: Partial<VendureConfig>,
options?: RunMigrationsOptions,
): Promise<string[]> {
const config = await preBootstrapConfig(userConfig);
const connection = await createConnection(createConnectionOptions(config));
const migrationsRan: string[] = [];
try {
warnIfNoMigrationsFound(connection, options?.onNoMigrationsFound);
const migrations = await disableForeignKeysForSqLite(connection, () =>
connection.runMigrations({ transaction: 'each' }),
);
Expand Down Expand Up @@ -79,6 +101,50 @@ async function checkMigrationStatus(connection: Connection) {
}
}

/**
* TypeORM resolves the `migrations` option to zero classes when the configured glob patterns
* match no files. By the time `runMigrations()` returns, that case is indistinguishable from
* "every migration has already been applied" — both yield an empty array — so the command
* reports success while leaving the database untouched.
*
* The patterns are resolved relative to the current working directory, so this typically
* happens when the command is run from a different directory than expected, or when the
* patterns point at compiled output (e.g. `dist/migrations/*.js`) which has not been built.
* Neither is detectable from the return value, so report it here.
*/
export function getNoMigrationsFoundMessage(
loadedMigrationCount: number,
configuredMigrations: DataSourceOptions['migrations'],
cwd: string = process.cwd(),
): string | undefined {
if (loadedMigrationCount) {
return;
}
const patterns = (
Array.isArray(configuredMigrations) ? configuredMigrations : Object.values(configuredMigrations ?? {})
).filter((migration): migration is string => typeof migration === 'string');
if (!patterns.length) {
return;
}
return [
'No migration files matched the configured `migrations` patterns, so no migrations can be run.',
`Patterns are resolved relative to the current directory (${cwd}):`,
...patterns.map(pattern => ' - ' + pattern),
].join('\n');
}

function warnIfNoMigrationsFound(connection: Connection, onNoMigrationsFound?: (message: string) => void) {
const message = getNoMigrationsFoundMessage(connection.migrations.length, connection.options.migrations);
if (!message) {
return;
}
// `log()` is a no-op while running from the CLI, because a spinner is active for the
// duration of this call and writing to stdout would corrupt it. Hand the message to the
// caller instead, so the CLI can report it once the spinner has stopped.
onNoMigrationsFound?.(message);
log(pc.yellow(message));
}

/**
* @description
* Reverts the last applied database migration. See [TypeORM migration docs](https://typeorm.io/#/migrations)
Expand Down
Loading