From 60ddbda8086f5be711dbb4f7adbfb6da97e8b176 Mon Sep 17 00:00:00 2001 From: "agent-zhang-beihai[bot]" <289036412+agent-zhang-beihai[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:21:40 -0700 Subject: [PATCH] fix(db): ignore subdirectories and non-SQL files in migrations/ listLocalMigrationFilenames returned every entry in migrations/, so a subdirectory such as migrations/_archive/ (a common convention for superseded reference SQL) was validated as a migration filename. That made `db migrations new ` and `db migrations up --all` fail with "Invalid migration filename: _archive. Expected _.sql." Only return `.sql` entries that are not directories, so subdirectories and stray non-SQL files never reach parseStrictLocalMigrations. Filtering on !isDirectory() rather than isFile() keeps symlinked migration files visible instead of silently skipping them. --- src/lib/migrations.test.ts | 63 +++++++++++++++++++++++++++++++++++++- src/lib/migrations.ts | 11 ++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index f92bd6c6..0f457cf4 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -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, @@ -10,6 +13,7 @@ import { getNextLocalMigrationVersion, getRemoteMigrationVersionStatus, incrementMigrationVersion, + listLocalMigrationFilenames, parseStrictLocalMigrations, parseMigrationFilename, resolveMigrationTarget, @@ -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( diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index 30056e3f..09709e8a 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -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 + // _.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')) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); } export function parseStrictLocalMigrations(filenames: string[]): ParsedMigrationFile[] {