Skip to content

Commit 0cf5eaa

Browse files
committed
fix(core): Report when migration patterns match no files
`runMigrations()` returns an empty array both when every migration has already been applied and when the configured `migrations` glob patterns matched no files at all. The CLI derives its report from that array alone, so the second case is presented as "No pending migrations found" — a database that is out of sync with the entity schema looks identical to an up-to-date one. The patterns are resolved relative to the current working directory, so this happens when the command is run from an unexpected directory, or when the patterns point at compiled output that has not been built. Detect the case via the migration classes TypeORM actually loaded and hand the diagnostic to the caller through a new optional `RunMigrationsOptions.onNoMigrationsFound` callback. A callback is used rather than logging directly because `log()` is a no-op while running from the CLI, where a spinner is active for the duration of the call. Both CLI entry points surface the message in place of the misleading default. The exit code is deliberately unchanged: a freshly scaffolded project configures a `migrations` glob before any migration file exists, so failing here would break the default project template. Fixes #5001
1 parent 9d4dda1 commit 0cf5eaa

6 files changed

Lines changed: 203 additions & 6 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const runMigrationsMock = vi.fn();
4+
5+
vi.mock('@clack/prompts', () => ({
6+
log: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
7+
}));
8+
vi.mock('../../shared/project-validation', () => ({
9+
validateVendureProjectDirectory: vi.fn(),
10+
}));
11+
vi.mock('../../shared/shared-prompts', () => ({
12+
analyzeProject: vi.fn().mockResolvedValue({ project: {}, tsConfigPath: 'tsconfig.json' }),
13+
}));
14+
vi.mock('../../shared/vendure-config-ref', () => ({
15+
VendureConfigRef: class {
16+
getPathRelativeToProjectRoot() {
17+
return 'src/vendure-config.ts';
18+
}
19+
},
20+
}));
21+
vi.mock('../../shared/load-vendure-config-file', () => ({
22+
loadVendureConfigFile: vi.fn().mockResolvedValue({}),
23+
}));
24+
vi.mock('@vendure/core', () => ({
25+
runMigrations: (...args: any[]) => runMigrationsMock(...args),
26+
generateMigration: vi.fn(),
27+
revertLastMigration: vi.fn(),
28+
}));
29+
30+
// eslint-disable-next-line import/first
31+
import { runMigrationsOperation } from './migration-operations';
32+
33+
describe('runMigrationsOperation() reporting', () => {
34+
beforeEach(() => {
35+
runMigrationsMock.mockReset();
36+
});
37+
38+
it('reports the reason when no migration files were found', async () => {
39+
// #5001 — a non-matching `migrations` glob is reported as success, which makes an
40+
// out-of-sync database look like an up-to-date one.
41+
runMigrationsMock.mockImplementation((config: unknown, options: any) => {
42+
options?.onNoMigrationsFound?.('No migration files matched the configured patterns.');
43+
return Promise.resolve([]);
44+
});
45+
46+
const result = await runMigrationsOperation();
47+
48+
expect(result.success).toBe(true);
49+
expect(result.message).toBe('No migration files matched the configured patterns.');
50+
expect(result.message).not.toBe('No pending migrations found');
51+
});
52+
53+
it('still reports "No pending migrations found" when migration files exist', async () => {
54+
runMigrationsMock.mockResolvedValue([]);
55+
56+
const result = await runMigrationsOperation();
57+
58+
expect(result.success).toBe(true);
59+
expect(result.message).toBe('No pending migrations found');
60+
});
61+
62+
it('reports the number of migrations that ran', async () => {
63+
runMigrationsMock.mockResolvedValue(['1700000000000-first', '1700000000001-second']);
64+
65+
const result = await runMigrationsOperation();
66+
67+
expect(result.success).toBe(true);
68+
expect(result.message).toBe('Successfully ran 2 migrations');
69+
expect(result.migrationsRan).toHaveLength(2);
70+
});
71+
});

packages/cli/src/commands/migrate/migration-operations.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,14 @@ export async function runMigrationsOperation(configFile?: string): Promise<Migra
7878
const config = await loadVendureConfigFile(vendureConfig);
7979

8080
log.info('Running migrations...');
81-
const migrationsRan = await runMigrations(config);
81+
let noMigrationsFoundMessage: string | undefined;
82+
const migrationsRan = await runMigrations(config, {
83+
onNoMigrationsFound: message => (noMigrationsFoundMessage = message),
84+
});
8285

8386
const report = migrationsRan.length
8487
? `Successfully ran ${migrationsRan.length} migrations`
85-
: 'No pending migrations found';
88+
: (noMigrationsFoundMessage ?? 'No pending migrations found');
8689

8790
return {
8891
success: true,

packages/cli/src/commands/migrate/run-migration/run-migration.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ async function runRunMigration(configFile?: string): Promise<CliCommandReturnVal
2323

2424
const runSpinner = spinner();
2525
runSpinner.start('Running migrations...');
26-
const migrationsRan = await runMigrations(config);
26+
let noMigrationsFoundMessage: string | undefined;
27+
const migrationsRan = await runMigrations(config, {
28+
onNoMigrationsFound: message => (noMigrationsFoundMessage = message),
29+
});
2730
const report = migrationsRan.length
2831
? `Successfully ran ${migrationsRan.length} migrations`
29-
: 'No pending migrations found';
32+
: (noMigrationsFoundMessage ?? 'No pending migrations found');
3033
runSpinner.stop(report);
3134
return {
3235
project,

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export * from './event-bus/index';
1818
export * from './health-check/index';
1919
export * from './i18n/index';
2020
export * from './job-queue/index';
21-
export { generateMigration, revertLastMigration, runMigrations } from './migrate';
21+
export { RunMigrationsOptions, generateMigration, revertLastMigration, runMigrations } from './migrate';
2222
export * from './migration-utils/index';
2323
export * from './plugin/index';
2424
export * from './process-context/index';

packages/core/src/migrate.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getNoMigrationsFoundMessage } from './migrate';
4+
5+
describe('getNoMigrationsFoundMessage()', () => {
6+
class SomeMigration {}
7+
8+
it('returns undefined when migration files were loaded', () => {
9+
expect(getNoMigrationsFoundMessage(3, ['src/migrations/*.ts'], '/project')).toBeUndefined();
10+
});
11+
12+
it('returns undefined when no migrations are configured', () => {
13+
expect(getNoMigrationsFoundMessage(0, undefined, '/project')).toBeUndefined();
14+
expect(getNoMigrationsFoundMessage(0, [], '/project')).toBeUndefined();
15+
});
16+
17+
it('returns undefined when migrations are configured as classes', () => {
18+
// Classes are passed directly rather than resolved from disk, so a zero count
19+
// cannot be attributed to non-matching glob patterns.
20+
expect(getNoMigrationsFoundMessage(0, [SomeMigration], '/project')).toBeUndefined();
21+
});
22+
23+
it('reports the patterns and the cwd they resolve against', () => {
24+
const message = getNoMigrationsFoundMessage(0, ['dist/migrations/*.js'], '/project');
25+
26+
expect(message).toContain('No migration files matched');
27+
expect(message).toContain('/project');
28+
expect(message).toContain('dist/migrations/*.js');
29+
});
30+
31+
it('reports every configured pattern', () => {
32+
const message = getNoMigrationsFoundMessage(
33+
0,
34+
['dist/migrations/*.js', 'plugins/*/migrations/*.js'],
35+
'/project',
36+
);
37+
38+
expect(message).toContain('dist/migrations/*.js');
39+
expect(message).toContain('plugins/*/migrations/*.js');
40+
});
41+
42+
it('supports the object form of the migrations option', () => {
43+
const message = getNoMigrationsFoundMessage(0, { first: 'dist/migrations/*.js' } as any, '/project');
44+
45+
expect(message).toContain('dist/migrations/*.js');
46+
});
47+
48+
it('ignores class entries when reporting patterns', () => {
49+
const message = getNoMigrationsFoundMessage(0, [SomeMigration, 'dist/migrations/*.js'], '/project');
50+
51+
expect(message).toContain('dist/migrations/*.js');
52+
expect(message).not.toContain('SomeMigration');
53+
});
54+
});

packages/core/src/migrate.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,40 @@ export interface MigrationOptions {
3030
outputDir?: string;
3131
}
3232

33+
/**
34+
* @description
35+
* Options for {@link runMigrations}.
36+
*
37+
* @docsCategory migration
38+
* @since 3.7.2
39+
*/
40+
export interface RunMigrationsOptions {
41+
/**
42+
* @description
43+
* Invoked when the configured `migrations` patterns matched no files at all, which is
44+
* otherwise indistinguishable from "no pending migrations" since both result in an empty
45+
* return value. The message describes the patterns and the directory they were resolved
46+
* against.
47+
*/
48+
onNoMigrationsFound?: (message: string) => void;
49+
}
50+
3351
/**
3452
* @description
3553
* Runs any pending database migrations. See [TypeORM migration docs](https://typeorm.io/#/migrations)
3654
* for more information about the underlying migration mechanism.
3755
*
3856
* @docsCategory migration
3957
*/
40-
export async function runMigrations(userConfig: Partial<VendureConfig>): Promise<string[]> {
58+
export async function runMigrations(
59+
userConfig: Partial<VendureConfig>,
60+
options?: RunMigrationsOptions,
61+
): Promise<string[]> {
4162
const config = await preBootstrapConfig(userConfig);
4263
const connection = await createConnection(createConnectionOptions(config));
4364
const migrationsRan: string[] = [];
4465
try {
66+
warnIfNoMigrationsFound(connection, options?.onNoMigrationsFound);
4567
const migrations = await disableForeignKeysForSqLite(connection, () =>
4668
connection.runMigrations({ transaction: 'each' }),
4769
);
@@ -79,6 +101,50 @@ async function checkMigrationStatus(connection: Connection) {
79101
}
80102
}
81103

104+
/**
105+
* TypeORM resolves the `migrations` option to zero classes when the configured glob patterns
106+
* match no files. By the time `runMigrations()` returns, that case is indistinguishable from
107+
* "every migration has already been applied" — both yield an empty array — so the command
108+
* reports success while leaving the database untouched.
109+
*
110+
* The patterns are resolved relative to the current working directory, so this typically
111+
* happens when the command is run from a different directory than expected, or when the
112+
* patterns point at compiled output (e.g. `dist/migrations/*.js`) which has not been built.
113+
* Neither is detectable from the return value, so report it here.
114+
*/
115+
export function getNoMigrationsFoundMessage(
116+
loadedMigrationCount: number,
117+
configuredMigrations: DataSourceOptions['migrations'],
118+
cwd: string = process.cwd(),
119+
): string | undefined {
120+
if (loadedMigrationCount) {
121+
return;
122+
}
123+
const patterns = (
124+
Array.isArray(configuredMigrations) ? configuredMigrations : Object.values(configuredMigrations ?? {})
125+
).filter((migration): migration is string => typeof migration === 'string');
126+
if (!patterns.length) {
127+
return;
128+
}
129+
return [
130+
'No migration files matched the configured `migrations` patterns, so no migrations can be run.',
131+
`Patterns are resolved relative to the current directory (${cwd}):`,
132+
...patterns.map(pattern => ' - ' + pattern),
133+
].join('\n');
134+
}
135+
136+
function warnIfNoMigrationsFound(connection: Connection, onNoMigrationsFound?: (message: string) => void) {
137+
const message = getNoMigrationsFoundMessage(connection.migrations.length, connection.options.migrations);
138+
if (!message) {
139+
return;
140+
}
141+
// `log()` is a no-op while running from the CLI, because a spinner is active for the
142+
// duration of this call and writing to stdout would corrupt it. Hand the message to the
143+
// caller instead, so the CLI can report it once the spinner has stopped.
144+
onNoMigrationsFound?.(message);
145+
log(pc.yellow(message));
146+
}
147+
82148
/**
83149
* @description
84150
* Reverts the last applied database migration. See [TypeORM migration docs](https://typeorm.io/#/migrations)

0 commit comments

Comments
 (0)