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
63 changes: 62 additions & 1 deletion src/lib/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
assertValidMigrationVersion,
canonicalMigrationVersion,
Expand All @@ -10,6 +13,7 @@ import {
getNextLocalMigrationVersion,
getRemoteMigrationVersionStatus,
incrementMigrationVersion,
listLocalMigrationFilenames,
parseStrictLocalMigrations,
parseMigrationFilename,
resolveMigrationTarget,
Expand Down Expand Up @@ -109,6 +113,63 @@ describe('getMigrationsDir', () => {
});
});

describe('listLocalMigrationFilenames', () => {
let projectDir: string;

beforeAll(() => {
projectDir = mkdtempSync(join(tmpdir(), 'cli-migrations-list-'));
const migrationsDir = join(projectDir, 'migrations');
mkdirSync(join(migrationsDir, '_archive'), { recursive: true });
writeFileSync(join(migrationsDir, '_archive', '20260418091400_superseded.sql'), '');
writeFileSync(join(migrationsDir, '20260418091500_create-users.sql'), '');
writeFileSync(join(migrationsDir, 'README.md'), '');
});

afterAll(() => {
rmSync(projectDir, { recursive: true, force: true });
});

it('returns an empty list when migrations/ does not exist', () => {
expect(listLocalMigrationFilenames(join(projectDir, 'missing'))).toEqual([]);
});

it('skips subdirectories and non-SQL files', () => {
expect(listLocalMigrationFilenames(projectDir)).toEqual([
'20260418091500_create-users.sql',
]);
});

it('keeps `db migrations new` and strict validation working next to a subdirectory', () => {
const filenames = listLocalMigrationFilenames(projectDir);
const localMigrations = parseStrictLocalMigrations(filenames);

expect(localMigrations).toEqual([
{
filename: '20260418091500_create-users.sql',
version: '20260418091500',
name: 'create-users',
},
]);
expect(
getNextLocalMigrationVersion(localMigrations, null, new Date('2026-04-18T09:17:30.000Z')),
).toBe('20260418091730');
});

it('still rejects a misnamed .sql file', () => {
const misnamedDir = mkdtempSync(join(tmpdir(), 'cli-migrations-misnamed-'));
mkdirSync(join(misnamedDir, 'migrations'), { recursive: true });
writeFileSync(join(misnamedDir, 'migrations', 'bad-file.sql'), '');

try {
expect(() =>
parseStrictLocalMigrations(listLocalMigrationFilenames(misnamedDir)),
).toThrow(/invalid migration filename/i);
} finally {
rmSync(misnamedDir, { recursive: true, force: true });
}
});
});

describe('getRemoteMigrationVersionStatus', () => {
it('treats exact remote matches as already applied', () => {
expect(
Expand Down
11 changes: 10 additions & 1 deletion src/lib/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,16 @@ export function listLocalMigrationFilenames(cwd: string = process.cwd()): string
return [];
}

return readdirSync(migrationsDir).sort((left, right) => left.localeCompare(right));
// Only `.sql` files are migrations. Subdirectories (e.g. `migrations/_archive/`
// holding superseded SQL) and stray non-SQL files must never reach
// parseStrictLocalMigrations, which rejects anything that isn't
// <migration_version>_<migration-name>.sql. Filter on !isDirectory() rather
// than isFile() so a symlinked migration file is still picked up instead of
// being silently skipped.
return readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => !entry.isDirectory() && entry.name.endsWith('.sql'))
Comment on lines +126 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Directory-targeting symlinks pass filtering

A validly named .sql symlink to a directory passes !entry.isDirectory() because the Dirent describes the link itself; db migrations up then follows the link in readFileSync, fails with EISDIR, and can interrupt a batch after earlier migrations were applied.

Knowledge Base Used: Config Management

.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
}

export function parseStrictLocalMigrations(filenames: string[]): ParsedMigrationFile[] {
Expand Down
Loading