From 2c4286644bf93212bc6838e6dc68ec7b5e41a55c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 4 Mar 2026 21:02:58 +0000 Subject: [PATCH 1/7] feat: add `db migrate` command --- src/commands/database/database.ts | 19 ++- src/commands/database/migrate.ts | 58 ++++++++ src/commands/dev/programmatic-netlify-dev.ts | 2 +- src/lib/build.ts | 5 + tests/unit/commands/database/migrate.test.ts | 145 +++++++++++++++++++ 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/commands/database/migrate.ts create mode 100644 tests/unit/commands/database/migrate.test.ts diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 3e101dd35a2..1d487a412ee 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -27,7 +27,12 @@ export const createDatabaseCommand = (program: BaseCommand) => { .command('db') .alias('database') .description(`Provision a production ready Postgres database with a single command`) - .addExamples(['netlify db status', 'netlify db init', 'netlify db init --help']) + .addExamples([ + 'netlify db status', + 'netlify db init', + 'netlify db init --help', + ...(process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1' ? ['netlify db migrate'] : []), + ]) dbCommand .command('init') @@ -80,4 +85,16 @@ export const createDatabaseCommand = (program: BaseCommand) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument await status(options, command) }) + + if (process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED === '1') { + dbCommand + .command('migrate') + .description('Apply database migrations to the local development database') + .option('--to ', 'Target migration name or prefix to apply up to (applies all if omitted)') + .option('--json', 'Output result as JSON') + .action(async (options: { to?: string; json?: boolean }, command: BaseCommand) => { + const { migrate } = await import('./migrate.js') + await migrate(options, command) + }) + } } diff --git a/src/commands/database/migrate.ts b/src/commands/database/migrate.ts new file mode 100644 index 00000000000..60e434d9794 --- /dev/null +++ b/src/commands/database/migrate.ts @@ -0,0 +1,58 @@ +import path from 'node:path' + +import { log } from '../../utils/command-helpers.js' +import BaseCommand from '../base-command.js' + +export interface MigrateOptions { + to?: string + json?: boolean +} + +export const migrate = async (options: MigrateOptions, command: BaseCommand) => { + const { to: name, json } = options + const buildDir = command.netlify.site.root ?? command.project.root ?? command.project.baseDirectory + if (!buildDir) { + throw new Error('Could not determine the project root directory.') + } + + const migrationsDirectory = command.netlify.config.db?.migrations?.path + if (!migrationsDirectory) { + throw new Error( + 'No migrations directory found. Create a directory at netlify/db/migrations or set `db.migrations.path` in `netlify.toml`.', + ) + } + + const dbDirectory = path.join(buildDir, '.netlify', 'db') + + let NetlifyDB: typeof import('@netlify/db-dev').NetlifyDB + + try { + const dbDev = await import('@netlify/db-dev') + NetlifyDB = dbDev.NetlifyDB + } catch { + throw new Error( + 'The @netlify/db-dev package is required for local database migrations. Install it with: npm install @netlify/db-dev', + ) + } + + const db = new NetlifyDB({ directory: dbDirectory }) + + try { + await db.start() + + const applied = await db.applyMigrations(migrationsDirectory, name) + + if (json) { + log(JSON.stringify({ migrations_applied: applied })) + } else if (applied.length === 0) { + log('No pending migrations to apply.') + } else { + log(`Applied ${String(applied.length)} migration${applied.length === 1 ? '' : 's'}:`) + for (const migration of applied) { + log(` - ${migration}`) + } + } + } finally { + await db.stop() + } +} diff --git a/src/commands/dev/programmatic-netlify-dev.ts b/src/commands/dev/programmatic-netlify-dev.ts index deab3d1cfd5..994a2fc5732 100644 --- a/src/commands/dev/programmatic-netlify-dev.ts +++ b/src/commands/dev/programmatic-netlify-dev.ts @@ -25,7 +25,7 @@ export const startNetlifyDev = async ({ apiToken, env, projectRoot, -}: StartNetlifyDevOptions): Promise => { +}: StartNetlifyDevOptions): Promise | undefined> => { if (process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED !== '1') { return } diff --git a/src/lib/build.ts b/src/lib/build.ts index f15a8c538df..dafc5d07f2f 100644 --- a/src/lib/build.ts +++ b/src/lib/build.ts @@ -74,6 +74,11 @@ export interface CachedConfig { } } } + db?: { + migrations?: { + path?: string + } + } edge_functions?: EdgeFunctionDeclaration[] functions?: NetlifyConfig['functions'] functionsDirectory?: undefined | string diff --git a/tests/unit/commands/database/migrate.test.ts b/tests/unit/commands/database/migrate.test.ts new file mode 100644 index 00000000000..6820ad3f33f --- /dev/null +++ b/tests/unit/commands/database/migrate.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest' + +const mockStart = vi.fn().mockResolvedValue('postgres://localhost:5432/postgres') +const mockStop = vi.fn().mockResolvedValue(undefined) +const mockApplyMigrations = vi.fn().mockResolvedValue([]) +const MockNetlifyDB = vi.fn().mockImplementation(() => ({ + start: mockStart, + stop: mockStop, + applyMigrations: mockApplyMigrations, +})) + +vi.mock('@netlify/db-dev', () => ({ + NetlifyDB: MockNetlifyDB, +})) + +const logMessages: string[] = [] + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, +})) + +// eslint-disable-next-line import/first +import { migrate } from '../../../../src/commands/database/migrate.js' + +function createMockCommand(overrides: { buildDir?: string; projectRoot?: string; migrationsPath?: string } = {}) { + const { buildDir = '/project', projectRoot = '/project', migrationsPath = '/project/netlify/db/migrations' } = overrides + + return { + project: { root: projectRoot, baseDirectory: undefined }, + netlify: { + site: { root: buildDir }, + config: { db: { migrations: { path: migrationsPath } } }, + }, + } as unknown as Parameters[1] +} + +describe('migrate', () => { + beforeEach(() => { + logMessages.length = 0 + vi.clearAllMocks() + mockApplyMigrations.mockResolvedValue([]) + }) + + test('creates NetlifyDB with the correct directory', async () => { + await migrate({}, createMockCommand({ buildDir: '/my/project' })) + + expect(MockNetlifyDB).toHaveBeenCalledWith({ directory: '/my/project/.netlify/db' }) + }) + + test('starts and stops the database', async () => { + await migrate({}, createMockCommand()) + + expect(mockStart).toHaveBeenCalledOnce() + expect(mockStop).toHaveBeenCalledOnce() + }) + + test('stops the database even when applyMigrations throws', async () => { + mockApplyMigrations.mockRejectedValueOnce(new Error('migration failed')) + + await expect(migrate({}, createMockCommand())).rejects.toThrow('migration failed') + + expect(mockStop).toHaveBeenCalledOnce() + }) + + test('uses migrations directory from config', async () => { + await migrate({}, createMockCommand({ migrationsPath: '/custom/migrations' })) + + expect(mockApplyMigrations).toHaveBeenCalledWith('/custom/migrations', undefined) + }) + + test('throws when no migrations directory is configured', async () => { + const command = { + project: { root: '/project', baseDirectory: undefined }, + netlify: { site: { root: '/project' }, config: {} }, + } as unknown as Parameters[1] + + await expect(migrate({}, command)).rejects.toThrow('No migrations directory found') + }) + + test('passes the --to target to applyMigrations', async () => { + await migrate({ to: '0002_add_posts' }, createMockCommand()) + + expect(mockApplyMigrations).toHaveBeenCalledWith(expect.any(String), '0002_add_posts') + }) + + test('logs message when no migrations are applied', async () => { + mockApplyMigrations.mockResolvedValueOnce([]) + + await migrate({}, createMockCommand()) + + expect(logMessages).toContain('No pending migrations to apply.') + }) + + test('logs each applied migration', async () => { + mockApplyMigrations.mockResolvedValueOnce(['0001_create_users', '0002_add_posts']) + + await migrate({}, createMockCommand()) + + expect(logMessages[0]).toContain('2 migrations') + expect(logMessages).toContain(' - 0001_create_users') + expect(logMessages).toContain(' - 0002_add_posts') + }) + + test('uses singular "migration" when only one is applied', async () => { + mockApplyMigrations.mockResolvedValueOnce(['0001_create_users']) + + await migrate({}, createMockCommand()) + + expect(logMessages[0]).toMatch(/1 migration:$/) + }) + + test('outputs JSON when --json flag is set', async () => { + mockApplyMigrations.mockResolvedValueOnce(['0001_create_users', '0002_add_posts']) + + await migrate({ json: true }, createMockCommand()) + + expect(logMessages).toHaveLength(1) + expect(JSON.parse(logMessages[0])).toEqual({ + migrations_applied: ['0001_create_users', '0002_add_posts'], + }) + }) + + test('outputs empty migrations_applied array in JSON mode when none applied', async () => { + mockApplyMigrations.mockResolvedValueOnce([]) + + await migrate({ json: true }, createMockCommand()) + + expect(logMessages).toHaveLength(1) + expect(JSON.parse(logMessages[0])).toEqual({ + migrations_applied: [], + }) + }) + + test('throws when project root cannot be determined', async () => { + const command = { + project: { root: undefined, baseDirectory: undefined }, + netlify: { site: { root: undefined }, config: {} }, + } as unknown as Parameters[1] + + await expect(migrate({}, command)).rejects.toThrow('Could not determine the project root directory.') + }) +}) From 756f774c693336bc38e2cc1d89a7f2451506a73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 4 Mar 2026 21:25:43 +0000 Subject: [PATCH 2/7] chore: format --- tests/unit/commands/database/migrate.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/commands/database/migrate.test.ts b/tests/unit/commands/database/migrate.test.ts index 6820ad3f33f..755c9d5ecee 100644 --- a/tests/unit/commands/database/migrate.test.ts +++ b/tests/unit/commands/database/migrate.test.ts @@ -26,7 +26,11 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({ import { migrate } from '../../../../src/commands/database/migrate.js' function createMockCommand(overrides: { buildDir?: string; projectRoot?: string; migrationsPath?: string } = {}) { - const { buildDir = '/project', projectRoot = '/project', migrationsPath = '/project/netlify/db/migrations' } = overrides + const { + buildDir = '/project', + projectRoot = '/project', + migrationsPath = '/project/netlify/db/migrations', + } = overrides return { project: { root: projectRoot, baseDirectory: undefined }, From dfb5a65ef48d15d2391515fd050578d725299b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 4 Mar 2026 21:26:55 +0000 Subject: [PATCH 3/7] chore: lint --- tests/unit/commands/database/migrate.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/commands/database/migrate.test.ts b/tests/unit/commands/database/migrate.test.ts index 755c9d5ecee..084adce4194 100644 --- a/tests/unit/commands/database/migrate.test.ts +++ b/tests/unit/commands/database/migrate.test.ts @@ -22,7 +22,6 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({ }, })) -// eslint-disable-next-line import/first import { migrate } from '../../../../src/commands/database/migrate.js' function createMockCommand(overrides: { buildDir?: string; projectRoot?: string; migrationsPath?: string } = {}) { From bdb6fca04221e830e6fd5dca5bbb41ec59b46075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 4 Mar 2026 22:39:01 +0000 Subject: [PATCH 4/7] fix: fix type --- src/commands/database/migrate.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commands/database/migrate.ts b/src/commands/database/migrate.ts index 60e434d9794..2a39f31bd59 100644 --- a/src/commands/database/migrate.ts +++ b/src/commands/database/migrate.ts @@ -24,18 +24,25 @@ export const migrate = async (options: MigrateOptions, command: BaseCommand) => const dbDirectory = path.join(buildDir, '.netlify', 'db') - let NetlifyDB: typeof import('@netlify/db-dev').NetlifyDB + // TODO: We should grab the db from the `NetlifyDev` instance, so this type + // would go away. + let dbDev: { + NetlifyDB: new (opts: { directory: string }) => { + start(): Promise + stop(): Promise + applyMigrations(migrationsDirectory: string, target?: string): Promise + } + } try { - const dbDev = await import('@netlify/db-dev') - NetlifyDB = dbDev.NetlifyDB + dbDev = await import('@netlify/db-dev') } catch { throw new Error( 'The @netlify/db-dev package is required for local database migrations. Install it with: npm install @netlify/db-dev', ) } - const db = new NetlifyDB({ directory: dbDirectory }) + const db = new dbDev.NetlifyDB({ directory: dbDirectory }) try { await db.start() From 1cdcc0704a14b215924f2c4f45a2ef08a113aa10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Thu, 5 Mar 2026 15:18:21 +0100 Subject: [PATCH 5/7] use db instance from dev --- package-lock.json | 41 ++++++-------- package.json | 2 +- src/commands/database/migrate.ts | 44 +++++++-------- tests/unit/commands/database/migrate.test.ts | 59 ++++++++++++++------ 4 files changed, 82 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index 261b1bbecf7..5a36d3eefbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@netlify/build": "35.8.6", "@netlify/build-info": "10.3.1", "@netlify/config": "24.4.3", - "@netlify/dev": "4.12.1", + "@netlify/dev": "4.14.0", "@netlify/dev-utils": "4.3.3", "@netlify/edge-bundler": "14.9.13", "@netlify/edge-functions": "3.0.3", @@ -664,6 +664,12 @@ "node": ">=18" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -3184,10 +3190,10 @@ } }, "node_modules/@netlify/db-dev": { - "version": "0.2.0", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@netlify/db-dev/-/db-dev-0.4.0.tgz", + "integrity": "sha512-ww9/z6makIPeH/m5p8blyPIXJTjpO1ngp5sZaCdNSE83xKJYcp5nEZyKufaezvKZugt0IzTqfBvU/W1i7lkgQg==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@electric-sql/pglite": "^0.3.15", "pg-gateway": "0.3.0-beta.4" @@ -3196,21 +3202,16 @@ "node": ">=20.6.1" } }, - "node_modules/@netlify/db-dev/node_modules/@electric-sql/pglite": { - "version": "0.3.15", - "license": "Apache-2.0", - "optional": true, - "peer": true - }, "node_modules/@netlify/dev": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@netlify/dev/-/dev-4.12.1.tgz", - "integrity": "sha512-CjI7ig6/T4VbgBO/ohDDoXw5atutRXEHBpaUBDNqiWN+6phT8AVKasTwxQxtZzadH0yWGjXsHJYfjEoVpgd2og==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@netlify/dev/-/dev-4.14.0.tgz", + "integrity": "sha512-kWjTfGVPUFYKLCVBnOaO/3IAL9/Jxl5mJ3AaqxqhZgl/0ePyCDAjpDK3AAQk4UVaA6yaGDthSW8hPBN1i7n6Ug==", "license": "MIT", "dependencies": { "@netlify/ai": "^0.4.0", "@netlify/blobs": "10.7.0", "@netlify/config": "^24.4.0", + "@netlify/db-dev": "0.4.0", "@netlify/dev-utils": "4.3.3", "@netlify/edge-functions-dev": "1.0.11", "@netlify/functions-dev": "1.1.12", @@ -3223,14 +3224,6 @@ }, "engines": { "node": ">=20.6.1" - }, - "peerDependencies": { - "@netlify/db-dev": "0.2.0" - }, - "peerDependenciesMeta": { - "@netlify/db-dev": { - "optional": true - } } }, "node_modules/@netlify/dev-utils": { @@ -15727,9 +15720,9 @@ }, "node_modules/pg-gateway": { "version": "0.3.0-beta.4", - "license": "MIT", - "optional": true, - "peer": true + "resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-beta.4.tgz", + "integrity": "sha512-CTjsM7Z+0Nx2/dyZ6r8zRsc3f9FScoD5UAOlfUx1Fdv/JOIWvRbF7gou6l6vP+uypXQVoYPgw8xZDXgMGvBa4Q==", + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", diff --git a/package.json b/package.json index f0ea3f6c345..8ab5992473c 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@netlify/build": "35.8.6", "@netlify/build-info": "10.3.1", "@netlify/config": "24.4.3", - "@netlify/dev": "4.12.1", + "@netlify/dev": "4.14.0", "@netlify/dev-utils": "4.3.3", "@netlify/edge-bundler": "14.9.13", "@netlify/edge-functions": "3.0.3", diff --git a/src/commands/database/migrate.ts b/src/commands/database/migrate.ts index 2a39f31bd59..1225ba0f44f 100644 --- a/src/commands/database/migrate.ts +++ b/src/commands/database/migrate.ts @@ -1,4 +1,4 @@ -import path from 'node:path' +import { NetlifyDev } from '@netlify/dev' import { log } from '../../utils/command-helpers.js' import BaseCommand from '../base-command.js' @@ -22,30 +22,28 @@ export const migrate = async (options: MigrateOptions, command: BaseCommand) => ) } - const dbDirectory = path.join(buildDir, '.netlify', 'db') - - // TODO: We should grab the db from the `NetlifyDev` instance, so this type - // would go away. - let dbDev: { - NetlifyDB: new (opts: { directory: string }) => { - start(): Promise - stop(): Promise - applyMigrations(migrationsDirectory: string, target?: string): Promise - } - } + const netlifyDev = new NetlifyDev({ + projectRoot: buildDir, + aiGateway: { enabled: false }, + blobs: { enabled: false }, + edgeFunctions: { enabled: false }, + environmentVariables: { enabled: false }, + functions: { enabled: false }, + geolocation: { enabled: false }, + headers: { enabled: false }, + images: { enabled: false }, + redirects: { enabled: false }, + staticFiles: { enabled: false }, + serverAddress: null, + }) try { - dbDev = await import('@netlify/db-dev') - } catch { - throw new Error( - 'The @netlify/db-dev package is required for local database migrations. Install it with: npm install @netlify/db-dev', - ) - } - - const db = new dbDev.NetlifyDB({ directory: dbDirectory }) + await netlifyDev.start() - try { - await db.start() + const { db } = netlifyDev + if (!db) { + throw new Error('Local database failed to start. Set EXPERIMENTAL_NETLIFY_DB_ENABLED=1 to enable.') + } const applied = await db.applyMigrations(migrationsDirectory, name) @@ -60,6 +58,6 @@ export const migrate = async (options: MigrateOptions, command: BaseCommand) => } } } finally { - await db.stop() + await netlifyDev.stop() } } diff --git a/tests/unit/commands/database/migrate.test.ts b/tests/unit/commands/database/migrate.test.ts index 084adce4194..76ff4ea4210 100644 --- a/tests/unit/commands/database/migrate.test.ts +++ b/tests/unit/commands/database/migrate.test.ts @@ -1,20 +1,22 @@ import { describe, expect, test, vi, beforeEach } from 'vitest' -const mockStart = vi.fn().mockResolvedValue('postgres://localhost:5432/postgres') -const mockStop = vi.fn().mockResolvedValue(undefined) -const mockApplyMigrations = vi.fn().mockResolvedValue([]) -const MockNetlifyDB = vi.fn().mockImplementation(() => ({ - start: mockStart, - stop: mockStop, - applyMigrations: mockApplyMigrations, -})) +const { mockStart, mockStop, mockApplyMigrations, MockNetlifyDev, logMessages } = vi.hoisted(() => { + const mockStart = vi.fn().mockResolvedValue({}) + const mockStop = vi.fn().mockResolvedValue(undefined) + const mockApplyMigrations = vi.fn().mockResolvedValue([]) + const MockNetlifyDev = vi.fn().mockImplementation(() => ({ + start: mockStart, + stop: mockStop, + db: { applyMigrations: mockApplyMigrations }, + })) + const logMessages: string[] = [] + return { mockStart, mockStop, mockApplyMigrations, MockNetlifyDev, logMessages } +}) -vi.mock('@netlify/db-dev', () => ({ - NetlifyDB: MockNetlifyDB, +vi.mock('@netlify/dev', () => ({ + NetlifyDev: MockNetlifyDev, })) -const logMessages: string[] = [] - vi.mock('../../../../src/utils/command-helpers.js', async () => ({ ...(await vi.importActual('../../../../src/utils/command-helpers.js')), log: (...args: string[]) => { @@ -47,20 +49,35 @@ describe('migrate', () => { mockApplyMigrations.mockResolvedValue([]) }) - test('creates NetlifyDB with the correct directory', async () => { + test('creates NetlifyDev with the correct project root and all non-db features disabled', async () => { await migrate({}, createMockCommand({ buildDir: '/my/project' })) - expect(MockNetlifyDB).toHaveBeenCalledWith({ directory: '/my/project/.netlify/db' }) + expect(MockNetlifyDev).toHaveBeenCalledWith( + expect.objectContaining({ + projectRoot: '/my/project', + aiGateway: { enabled: false }, + blobs: { enabled: false }, + edgeFunctions: { enabled: false }, + environmentVariables: { enabled: false }, + functions: { enabled: false }, + geolocation: { enabled: false }, + headers: { enabled: false }, + images: { enabled: false }, + redirects: { enabled: false }, + staticFiles: { enabled: false }, + serverAddress: null, + }), + ) }) - test('starts and stops the database', async () => { + test('starts and stops NetlifyDev', async () => { await migrate({}, createMockCommand()) expect(mockStart).toHaveBeenCalledOnce() expect(mockStop).toHaveBeenCalledOnce() }) - test('stops the database even when applyMigrations throws', async () => { + test('stops NetlifyDev even when applyMigrations throws', async () => { mockApplyMigrations.mockRejectedValueOnce(new Error('migration failed')) await expect(migrate({}, createMockCommand())).rejects.toThrow('migration failed') @@ -68,6 +85,16 @@ describe('migrate', () => { expect(mockStop).toHaveBeenCalledOnce() }) + test('throws when db is not available after start', async () => { + MockNetlifyDev.mockImplementationOnce(() => ({ + start: mockStart, + stop: mockStop, + db: undefined, + })) + + await expect(migrate({}, createMockCommand())).rejects.toThrow('Local database failed to start') + }) + test('uses migrations directory from config', async () => { await migrate({}, createMockCommand({ migrationsPath: '/custom/migrations' })) From 5e830f91664bc814cf226a99093a263fdfcee6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= <4138302+paulo@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:00:44 +0100 Subject: [PATCH 6/7] Update src/commands/database/migrate.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Eduardo Bouças --- src/commands/database/migrate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/database/migrate.ts b/src/commands/database/migrate.ts index 1225ba0f44f..a0c623c3006 100644 --- a/src/commands/database/migrate.ts +++ b/src/commands/database/migrate.ts @@ -48,7 +48,7 @@ export const migrate = async (options: MigrateOptions, command: BaseCommand) => const applied = await db.applyMigrations(migrationsDirectory, name) if (json) { - log(JSON.stringify({ migrations_applied: applied })) + logJson(JSON.stringify({ migrations_applied: applied })) } else if (applied.length === 0) { log('No pending migrations to apply.') } else { From ecd6554125bfd703191e951a8462c9d9d4f85bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20Ara=C3=BAjo?= Date: Thu, 5 Mar 2026 16:04:18 +0100 Subject: [PATCH 7/7] minor fix --- src/commands/database/migrate.ts | 4 ++-- tests/unit/commands/database/migrate.test.ts | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/commands/database/migrate.ts b/src/commands/database/migrate.ts index a0c623c3006..1fd0f2d3017 100644 --- a/src/commands/database/migrate.ts +++ b/src/commands/database/migrate.ts @@ -1,6 +1,6 @@ import { NetlifyDev } from '@netlify/dev' -import { log } from '../../utils/command-helpers.js' +import { log, logJson } from '../../utils/command-helpers.js' import BaseCommand from '../base-command.js' export interface MigrateOptions { @@ -48,7 +48,7 @@ export const migrate = async (options: MigrateOptions, command: BaseCommand) => const applied = await db.applyMigrations(migrationsDirectory, name) if (json) { - logJson(JSON.stringify({ migrations_applied: applied })) + logJson({ migrations_applied: applied }) } else if (applied.length === 0) { log('No pending migrations to apply.') } else { diff --git a/tests/unit/commands/database/migrate.test.ts b/tests/unit/commands/database/migrate.test.ts index 76ff4ea4210..3898a6d19ed 100644 --- a/tests/unit/commands/database/migrate.test.ts +++ b/tests/unit/commands/database/migrate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi, beforeEach } from 'vitest' -const { mockStart, mockStop, mockApplyMigrations, MockNetlifyDev, logMessages } = vi.hoisted(() => { +const { mockStart, mockStop, mockApplyMigrations, MockNetlifyDev, logMessages, jsonMessages } = vi.hoisted(() => { const mockStart = vi.fn().mockResolvedValue({}) const mockStop = vi.fn().mockResolvedValue(undefined) const mockApplyMigrations = vi.fn().mockResolvedValue([]) @@ -10,7 +10,8 @@ const { mockStart, mockStop, mockApplyMigrations, MockNetlifyDev, logMessages } db: { applyMigrations: mockApplyMigrations }, })) const logMessages: string[] = [] - return { mockStart, mockStop, mockApplyMigrations, MockNetlifyDev, logMessages } + const jsonMessages: unknown[] = [] + return { mockStart, mockStop, mockApplyMigrations, MockNetlifyDev, logMessages, jsonMessages } }) vi.mock('@netlify/dev', () => ({ @@ -22,6 +23,9 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({ log: (...args: string[]) => { logMessages.push(args.join(' ')) }, + logJson: (message: unknown) => { + jsonMessages.push(message) + }, })) import { migrate } from '../../../../src/commands/database/migrate.js' @@ -45,6 +49,7 @@ function createMockCommand(overrides: { buildDir?: string; projectRoot?: string; describe('migrate', () => { beforeEach(() => { logMessages.length = 0 + jsonMessages.length = 0 vi.clearAllMocks() mockApplyMigrations.mockResolvedValue([]) }) @@ -147,8 +152,8 @@ describe('migrate', () => { await migrate({ json: true }, createMockCommand()) - expect(logMessages).toHaveLength(1) - expect(JSON.parse(logMessages[0])).toEqual({ + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ migrations_applied: ['0001_create_users', '0002_add_posts'], }) }) @@ -158,8 +163,8 @@ describe('migrate', () => { await migrate({ json: true }, createMockCommand()) - expect(logMessages).toHaveLength(1) - expect(JSON.parse(logMessages[0])).toEqual({ + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ migrations_applied: [], }) })