diff --git a/src/commands/database/db-status.ts b/src/commands/database/db-status.ts index 5d1748b18ea..0cfdd22e143 100644 --- a/src/commands/database/db-status.ts +++ b/src/commands/database/db-status.ts @@ -1,4 +1,5 @@ import { readFile, readdir } from 'fs/promises' +import { join } from 'path' import { chalk, log, logJson, netlifyCommand } from '../../utils/command-helpers.js' import BaseCommand from '../base-command.js' @@ -10,6 +11,7 @@ import { } from './util/applied-migrations.js' import { connectToDatabase, detectExistingLocalConnectionString } from './util/db-connection.js' import { resolveMigrationsDirectory } from './util/migrations-path.js' +import { fileExistsAsync } from '../../lib/fs.js' export interface DatabaseStatusOptions { branch?: string @@ -50,7 +52,7 @@ const logConnectCommands = () => { } const parseVersion = (name: string): number | null => { - const match = /^(\d+)[_-]/.exec(name) + const match = /^(\d+)_/.exec(name) if (!match) { return null } @@ -58,6 +60,8 @@ const parseVersion = (name: string): number | null => { return Number.isFinite(parsed) ? parsed : null } +const MIGRATION_NAME_PATTERN = /^\d+_[a-z0-9_-]+$/ + const readLocalMigrations = async (migrationsDirectory: string): Promise => { let entries try { @@ -69,16 +73,34 @@ const readLocalMigrations = async (migrationsDirectory: string): Promise a.version - b.version) return migrations diff --git a/tests/unit/commands/database/db-status.test.ts b/tests/unit/commands/database/db-status.test.ts index 2fd5b9450d6..3f32af634bb 100644 --- a/tests/unit/commands/database/db-status.test.ts +++ b/tests/unit/commands/database/db-status.test.ts @@ -1,8 +1,11 @@ +import { relative, sep } from 'path' + import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' const { mockReaddir, mockReadFile, + mockFileExistsAsync, mockConnectToDatabase, mockDetectExisting, mockQuery, @@ -13,6 +16,7 @@ const { } = vi.hoisted(() => { const mockReaddir = vi.fn() const mockReadFile = vi.fn() + const mockFileExistsAsync = vi.fn() const mockQuery = vi.fn() const mockCleanup = vi.fn().mockResolvedValue(undefined) const mockConnectToDatabase = vi.fn() @@ -23,6 +27,7 @@ const { return { mockReaddir, mockReadFile, + mockFileExistsAsync, mockConnectToDatabase, mockDetectExisting, mockQuery, @@ -64,6 +69,12 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({ }, })) +vi.mock('../../../../src/lib/fs.js', async (importOriginal) => ({ + ...(await importOriginal()), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + fileExistsAsync: (path: string) => mockFileExistsAsync(path), +})) + vi.mock('../../../../src/commands/database/util/db-connection.js', () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return connectToDatabase: (...args: unknown[]) => mockConnectToDatabase(...args), @@ -81,11 +92,60 @@ const LOCAL_CONN_NO_CREDS = 'postgres://localhost:5432/postgres' const BRANCH_CONN = 'postgres://admin:secret@branch-host.neon.tech/db' const PROD_CONN = 'postgres://owner:prodsecret@prod-host.neon.tech/db' -const makeDirents = (names: string[]) => - names.map((name) => ({ - name, - isDirectory: () => true, - })) +interface MockFSNode { + files?: Record + dirs?: Record +} + +const DEFAULT_MOCK_FS_ROOT = '/project/netlify/database/migrations' + +const mockFS = (tree: MockFSNode, { root = DEFAULT_MOCK_FS_ROOT }: { root?: string } = {}) => { + const resolve = (absolutePath: string): { kind: 'dir'; node: MockFSNode } | { kind: 'file' } | null => { + const rel = relative(root, absolutePath) + if (rel === '') return { kind: 'dir', node: tree } + if (rel.startsWith('..')) return null + const parts = rel.split(sep) + let cursor: MockFSNode = tree + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i] + const isLast = i === parts.length - 1 + if (isLast && cursor.files && part in cursor.files) return { kind: 'file' } + if (cursor.dirs && part in cursor.dirs) { + cursor = cursor.dirs[part] + if (isLast) return { kind: 'dir', node: cursor } + continue + } + return null + } + return null + } + + mockReaddir.mockImplementation((path: unknown) => { + const resolved = typeof path === 'string' ? resolve(path) : null + if (!resolved || resolved.kind !== 'dir') { + return Promise.reject(Object.assign(new Error(`ENOENT: ${String(path)}`), { code: 'ENOENT' })) + } + const dirEntries = Object.keys(resolved.node.dirs ?? {}).map((name) => ({ + name, + isDirectory: () => true, + isFile: () => false, + })) + const fileEntries = Object.keys(resolved.node.files ?? {}).map((name) => ({ + name, + isDirectory: () => false, + isFile: () => true, + })) + return Promise.resolve([...dirEntries, ...fileEntries]) + }) + + mockFileExistsAsync.mockImplementation((path: unknown) => + Promise.resolve(typeof path === 'string' && resolve(path) !== null), + ) +} + +const migrationsTree = (names: string[]): MockFSNode => ({ + dirs: Object.fromEntries(names.map((name) => [name, { files: { 'migration.sql': '' } }])), +}) function createMockCommand( overrides: { siteRoot?: string | null; migrationsPath?: string | null; siteId?: string | null } = {}, @@ -178,7 +238,7 @@ beforeEach(() => { logMessages.length = 0 jsonMessages.length = 0 vi.clearAllMocks() - mockReaddir.mockResolvedValue([]) + mockFS({}) mockCleanup.mockResolvedValue(undefined) mockLocalAppliedRows([]) setupFetchRouter({ siteDatabase: null }) @@ -288,7 +348,7 @@ describe('statusDb', () => { test('still connects and reads migration state when no local database is already running', async () => { mockDetectExisting.mockReturnValue(null) mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + mockFS(migrationsTree(['0001_a', '0002_b'])) await statusDb({ json: true }, createMockCommand()) @@ -315,7 +375,7 @@ describe('statusDb', () => { test('default output hints at starting a persistent local database when none is running', async () => { mockDetectExisting.mockReturnValue(null) mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + mockFS(migrationsTree(['0001_a', '0002_b'])) await statusDb({}, createMockCommand()) @@ -338,7 +398,7 @@ describe('statusDb', () => { test('reports applied and pending correctly', async () => { mockLocalAppliedRows(['0001_a', '0002_b']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b', '0003_c'])) + mockFS(migrationsTree(['0001_a', '0002_b', '0003_c'])) await statusDb({ json: true }, createMockCommand()) @@ -370,7 +430,7 @@ describe('statusDb', () => { test('default output shows applied and pending as bullets', async () => { mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + mockFS(migrationsTree(['0001_a', '0002_b'])) await statusDb({}, createMockCommand()) @@ -383,7 +443,7 @@ describe('statusDb', () => { test('default output includes the apply-command hint when pending migrations exist on local', async () => { mockLocalAppliedRows([]) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({}, createMockCommand()) @@ -392,7 +452,7 @@ describe('statusDb', () => { test('default output omits the apply-command hint when there are no pending migrations', async () => { mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({}, createMockCommand()) @@ -420,6 +480,85 @@ describe('statusDb', () => { }) }) + describe('local migration discovery', () => { + test('ignores directories that do not contain a migration.sql file', async () => { + mockFS({ + dirs: { + '0001_with_sql': { files: { 'migration.sql': '' } }, + '0002_without_sql': {}, + '0003_wrong_file': { files: { 'readme.md': '' } }, + }, + }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + pending: [{ version: 1, name: '0001_with_sql' }], + }) + }) + + test('includes .sql files sitting directly under the migrations directory', async () => { + mockFS({ + files: { + '0001_a.sql': '', + '0002_b.sql': '', + }, + }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + pending: [ + { version: 1, name: '0001_a' }, + { version: 2, name: '0002_b' }, + ], + }) + }) + + test('ignores directories whose name does not match the migration pattern', async () => { + mockFS({ + dirs: { + '0001_valid': { files: { 'migration.sql': '' } }, + '0002_with-hyphen': { files: { 'migration.sql': '' } }, + 'not-a-migration': { files: { 'migration.sql': '' } }, + '0003_UPPERCASE': { files: { 'migration.sql': '' } }, + '0004-hyphen-separator': { files: { 'migration.sql': '' } }, + no_leading_digits: { files: { 'migration.sql': '' } }, + }, + }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + pending: [ + { version: 1, name: '0001_valid' }, + { version: 2, name: '0002_with-hyphen' }, + ], + }) + }) + + test('ignores .sql files whose name does not match the migration pattern', async () => { + mockFS({ + files: { + '0001_valid.sql': '', + '0002_with-hyphen.sql': '', + 'random.sql': '', + '0003_UPPERCASE.sql': '', + '0004-hyphen-separator.sql': '', + }, + }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + pending: [ + { version: 1, name: '0001_valid' }, + { version: 2, name: '0002_with-hyphen' }, + ], + }) + }) + }) + describe('secondary descriptive lines', () => { test('renders a descriptive line under Enabled when true', async () => { process.env.NETLIFY_DB_URL = 'postgres://x/y' @@ -451,7 +590,6 @@ describe('statusDb', () => { test('renders an installed statement under Package when installed', async () => { await statusDb({}, createMockCommand()) - expect(logMessages.join('\n')).toContain('The @netlify/database package is installed') }) @@ -493,7 +631,7 @@ describe('statusDb', () => { test('always renders the immutability note below the Applied migrations list', async () => { mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({}, createMockCommand()) @@ -540,7 +678,7 @@ describe('statusDb', () => { test('uses the dynamic command in the apply-pending hint', async () => { mockLocalAppliedRows([]) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({}, createMockCommand()) @@ -595,7 +733,7 @@ describe('statusDb', () => { ], }, }) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b', '0003_c'])) + mockFS(migrationsTree(['0001_a', '0002_b', '0003_c'])) await statusDb({ branch: 'feature-x', json: true }, createMockCommand()) @@ -628,7 +766,7 @@ describe('statusDb', () => { branch: { 'feature-x': { connection_string: BRANCH_CONN } }, migrations: { 'feature-x': [] }, }) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({ branch: 'feature-x' }, createMockCommand())