From 83dbb7f6c941d7a7533d3a16c3c891cac8931e54 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Fri, 26 Jun 2026 12:07:22 -0300 Subject: [PATCH] Added interactive step for migration method selection w/tests --- src/commands/data/pg/migrate.ts | 242 +++++++++++------- src/lib/data/types.ts | 5 + .../commands/data/pg/migrate.unit.test.ts | 218 +++++++++++++++- 3 files changed, 363 insertions(+), 102 deletions(-) diff --git a/src/commands/data/pg/migrate.ts b/src/commands/data/pg/migrate.ts index 45333531d6..9736fe072f 100644 --- a/src/commands/data/pg/migrate.ts +++ b/src/commands/data/pg/migrate.ts @@ -16,6 +16,7 @@ import { DatabaseStatus, ExtendedPostgresLevelInfo, InfoResponse, + MigrationMethod, MigrationResponse, MigrationStatus, } from '../../../lib/data/types.js' @@ -31,7 +32,6 @@ export default class DataPgMigrate extends BaseCommand { static flags = { app: Flags.app({required: true}), method: Flags.string({ - default: 'snapshot', hidden: true, options: ['snapshot', 'streaming'], }), @@ -41,8 +41,8 @@ export default class DataPgMigrate extends BaseCommand { private appName: string | undefined private classicDatabases: Array = [] private extendedLevelsInfo: ExtendedPostgresLevelInfo[] | undefined - private migrationMethod: 'cdc' | 'full-load' = 'full-load' private migrationTargets: Array = [] + private selectedMigrationMethod?: MigrationMethod public async createAddon(...args: Parameters): Promise { return createAddon(...args) @@ -56,7 +56,10 @@ export default class DataPgMigrate extends BaseCommand { const {flags} = await this.parse(DataPgMigrate) const {app, method} = flags this.appName = app - this.migrationMethod = method === 'streaming' ? 'cdc' : 'full-load' + // If --method flag is provided, convert and store + if (method !== undefined) { + this.selectedMigrationMethod = method === 'streaming' ? MigrationMethod.CDC : MigrationMethod.FULL_LOAD + } ux.stdout(heredoc` @@ -179,35 +182,143 @@ export default class DataPgMigrate extends BaseCommand { let targetDatabaseId: string | undefined let targetDatabaseName: string | undefined - while (currentStep !== '__exit') { - switch (currentStep) { - case '__confirm_migration': { - ux.stdout(color.info(heredoc` + const confirmMigration = async (): Promise => { + ux.stdout(color.info(heredoc` + + By continuing, we prepare the necessary steps for the migration. + Your source database is available while we prepare the migration. + You'll receive an email when the preparation is complete or if there's an error. + You have 24 hours to begin migration after the preparation is complete. + Preparing the migration deletes all the data on the destination database ${color.datastore(targetDatabaseName!)}. + + `)) + const {action} = await this.prompt<{action: string}>({ + choices: [ + {name: 'Confirm', value: '__confirm'}, + {name: 'Go back', value: '__go_back'}, + ], + message: 'Confirm migration configuration:', + name: 'action', + type: 'list', + }) + return action + } - By continuing, we prepare the necessary steps for the migration. - Your source database is available while we prepare the migration. - You'll receive an email when the preparation is complete or if there's an error. - You have 24 hours to begin migration after the preparation is complete. - Your source database will be unavailable during the migration. - Preparing the migration deletes all the data on the destination database ${color.datastore(targetDatabaseName!)}. + const selectMethod = async (): Promise => { + ux.stdout(color.info(heredoc` + + Migration methods: + · Snapshot: Takes a point-in-time copy of your database. Requires downtime on the source. Best for smaller databases or when a maintenance window is acceptable. + · Streaming: Continuously replicates changes from source to target. Minimal downtime, you control when to cut over. Best for larger databases or when you need near-zero downtime. + + `)) + + const {method} = await this.prompt<{method: string}>({ + choices: [ + {name: 'Snapshot', value: '__snapshot'}, + {name: 'Streaming', value: '__streaming'}, + new Separator(), + {name: 'Go back', value: '__go_back'}, + ], + message: 'Select migration method:', + name: 'method', + type: 'list', + }) - `)) - const {action} = await this.prompt<{action: string}>({ - choices: [ - {name: 'Confirm', value: '__confirm'}, - {name: 'Go back', value: '__go_back'}, - ], - message: 'Confirm migration configuration:', - name: 'action', - type: 'list', + return method + } + + const selectSource = async (): Promise => { + const choices: Array>> = [] + for (const database of this.classicDatabases) { + const name = `${color.datastore(database.name)} as ${database.attachment_names!.map(name => color.attachment(name)).join(', ')}` + if (this.migrationTargets.some(migration => migration.source_id === database.id && this.isActiveMigration(migration))) { + choices.push({ + disabled: 'already a source database for an active migration', + name: color.gray(name), + value: database.id, + }) + } else { + choices.push({ + name, + value: database.id, }) + } + } + + choices.push(new Separator(), {name: 'Go back', value: '__go_back'}) + sourceDatabaseId = (await this.prompt<{database: string}>({ + choices, + message: 'Select the source database:', + name: 'database', + type: 'list', + })).database + + return sourceDatabaseId + } + + const selectTarget = async (): Promise => { + const choices: Array>> = [] + for (const database of this.advancedDatabases) { + const name = `${color.datastore(database.name)} as ${database.attachment_names!.map(name => color.attachment(name)).join(', ')}` + if (this.migrationTargets.some(migration => migration.target_id === database.id && this.isActiveMigration(migration))) { + choices.push({ + disabled: 'already a destination database for an active migration', + name: color.gray(name), + value: database.id, + }) + } else if (database.info?.status === DatabaseStatus.AVAILABLE) { + choices.push({ + name, + value: database.id, + }) + } else { + choices.push({ + disabled: 'database isn\'t available', + name: color.gray(name), + value: database.id, + }) + } + } + + if (this.advancedDatabases.length === 0) { + choices.push({ + disabled: true, + name: color.gray(`No Heroku Postgres Advanced databases available for migration on ${color.app(this.appName!)}`), + value: '__no_advanced_databases', + }) + } + + choices.push( + new Separator(), + {name: 'Create a new Advanced database', value: '__create_database'}, + {name: 'Go back', value: '__go_back'}, + ) + targetDatabaseId = (await this.prompt<{database: string}>({ + choices, + message: 'Select the destination database:', + name: 'database', + type: 'list', + })).database + targetDatabaseName = this.advancedDatabases.find(db => db.id === targetDatabaseId)?.name + + return targetDatabaseId + } + + while (currentStep !== '__exit') { + switch (currentStep) { + case '__confirm_migration': { + const action = await confirmMigration() if (action === '__go_back') { currentStep = '__select_target' } else if (action === '__confirm') { ux.stdout('') ux.action.start('Configuring migration') await this.dataApi.post(`/data/postgres/v1/${targetDatabaseId}/migrations`, { - body: {method: this.migrationMethod, source_id: sourceDatabaseId}, + body: { + method: this.selectedMigrationMethod!, + source_id: sourceDatabaseId, + }, }) ux.action.stop() currentStep = '__exit' @@ -216,95 +327,40 @@ export default class DataPgMigrate extends BaseCommand { break } - case '__select_source': { - const choices: Array>> = [] - for (const database of this.classicDatabases) { - const name = `${color.datastore(database.name)} as ${database.attachment_names!.map(name => color.attachment(name)).join(', ')}` - if (this.migrationTargets.some(migration => migration.source_id === database.id && this.isActiveMigration(migration))) { - choices.push({ - disabled: 'already a source database for an active migration', - name: color.gray(name), - value: database.id, - }) - } else { - choices.push({ - name, - value: database.id, - }) - } + case '__select_method': { + const method = await selectMethod() + if (method === '__go_back') { + currentStep = '__select_target' + } else { + this.selectedMigrationMethod = method === '__snapshot' ? MigrationMethod.FULL_LOAD : MigrationMethod.CDC + currentStep = '__confirm_migration' } - choices.push(new Separator(), {name: 'Go back', value: '__go_back'}) - sourceDatabaseId = (await this.prompt<{database: string}>({ - choices, - message: 'Select the source database:', - name: 'database', - type: 'list', - })).database + break + } + case '__select_source': { + const sourceDatabaseId = await selectSource() currentStep = sourceDatabaseId === '__go_back' ? '__exit' : '__select_target' break } case '__select_target': { - const choices: Array>> = [] - for (const database of this.advancedDatabases) { - const name = `${color.datastore(database.name)} as ${database.attachment_names!.map(name => color.attachment(name)).join(', ')}` - if (this.migrationTargets.some(migration => migration.target_id === database.id && this.isActiveMigration(migration))) { - choices.push({ - disabled: 'already a destination database for an active migration', - name: color.gray(name), - value: database.id, - }) - } else if (database.info?.status === DatabaseStatus.AVAILABLE) { - choices.push({ - name, - value: database.id, - }) - } else { - choices.push({ - disabled: 'database isn\'t available', - name: color.gray(name), - value: database.id, - }) - } - } - - if (this.advancedDatabases.length === 0) { - choices.push({ - disabled: true, - name: color.gray(`No Heroku Postgres Advanced databases available for migration on ${color.app(this.appName!)}`), - value: '__no_advanced_databases', - }) - } - - choices.push( - new Separator(), - {name: 'Create a new Advanced database', value: '__create_database'}, - {name: 'Go back', value: '__go_back'}, - ) - targetDatabaseId = (await this.prompt<{database: string}>({ - choices, - message: 'Select the destination database:', - name: 'database', - type: 'list', - })).database - targetDatabaseName = this.advancedDatabases.find(db => db.id === targetDatabaseId)?.name - + await selectTarget() if (targetDatabaseId === '__go_back') { currentStep = '__select_source' } else if (targetDatabaseId === '__create_database') { const addon = await this.createTargetDatabase(sourceDatabaseId!) if (addon) { - targetDatabaseId = addon.id + targetDatabaseId = addon.id! targetDatabaseName = addon.name - currentStep = '__confirm_migration' + currentStep = this.selectedMigrationMethod === undefined ? '__select_method' : '__confirm_migration' } else { currentStep = '__select_target' } } else { - currentStep = '__confirm_migration' + currentStep = this.selectedMigrationMethod === undefined ? '__select_method' : '__confirm_migration' } break diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts index 631b960cb8..4d7065b6fc 100644 --- a/src/lib/data/types.ts +++ b/src/lib/data/types.ts @@ -34,6 +34,11 @@ export enum MigrationStatus { UNKNOWN = 'unknown', } +export enum MigrationMethod { + CDC = 'cdc', + FULL_LOAD = 'full-load', +} + export enum PoolStatus { AVAILABLE = 'available', MODIFYING = 'modifying', diff --git a/test/unit/commands/data/pg/migrate.unit.test.ts b/test/unit/commands/data/pg/migrate.unit.test.ts index f65cdf0694..34a0bb59ac 100644 --- a/test/unit/commands/data/pg/migrate.unit.test.ts +++ b/test/unit/commands/data/pg/migrate.unit.test.ts @@ -365,7 +365,7 @@ describe('data:pg:migrate', function () { '\n', // Main menu: > Exit ] - const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp', '--method=snapshot']) // Verify the confirmation message is shown expect(stdout).to.contain('By continuing, we prepare the necessary steps for the migration.') @@ -385,7 +385,7 @@ describe('data:pg:migrate', function () { '\n', // Main menu: > Exit ] - const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp', '--method=snapshot']) const sourceDatabaseList = stdout.match(/(?<=Select the source database: \(Use arrow keys\)\n)(.*?)(?=Go back)/s)?.[1] expect(stderr).to.equal('Configuring migration... done\n') @@ -416,7 +416,7 @@ describe('data:pg:migrate', function () { '\n', // Main menu: > Exit ] - const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp', '--method=snapshot']) const targetDatabaseList = stdout.match(/(?<=Select the destination database: \(Use arrow keys\)\n)(.*?)(?=Go back)/s)?.[1] expect(stderr).to.equal('Configuring migration... done\n') @@ -451,7 +451,7 @@ describe('data:pg:migrate', function () { '\n', // Main menu: > Exit ] - const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp', '--method=snapshot']) expect(stderr).to.equal('Configuring migration... done\n') expect(stdout.match(/Select the source database: \(Use arrow keys\)/g)?.length).to.equal(2) @@ -580,7 +580,7 @@ describe('data:pg:migrate', function () { '\n', // Main menu: > Exit ] - const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp', '--method=snapshot']) herokuApi.done() dataApi.done() @@ -652,7 +652,7 @@ describe('data:pg:migrate', function () { '\n', // Main menu: > Exit ] - const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp', '--method=snapshot']) herokuApi.done() dataApi.done() @@ -724,7 +724,7 @@ describe('data:pg:migrate', function () { '\n', // Main menu: > Exit ] - const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp', '--method=snapshot']) herokuApi.done() dataApi.done() @@ -832,7 +832,7 @@ describe('data:pg:migrate', function () { '\n', // Select migration: > Choose the first ready migration '\u001B[A\n', // Confirm migration start: > Go back '\n', // Select migration: > Choose the first ready migration - '\n', // Confirm migration start: > Confirm + '\n', // Confirm migration start: > Confirm '\u001B[A\n', // Main menu: > Exit ] @@ -931,7 +931,7 @@ describe('data:pg:migrate', function () { '\n', // Select migration: > Choose the first ready migration '\u001B[A\n', // Confirm migration cancel: > Go back '\n', // Select migration: > Choose the first ready migration - '\n', // Confirm migration cancel: > Confirm + '\n', // Confirm migration cancel: > Confirm '\u001B[A\n', // Main menu: > Exit ] @@ -941,4 +941,204 @@ describe('data:pg:migrate', function () { expect(stdout.match(/Confirm to cancel migration: \(Use arrow keys\)/g)?.length).to.equal(2) }) }) + + describe('interactive migration method selection', function () { + let herokuApi: nock.Scope + let dataApi: nock.Scope + + beforeEach(function () { + herokuApi = nock('https://api.heroku.com') + .persist(true) + .get('/apps/myapp/addon-attachments') + .reply(200, [ + nonTargetAdvancedDbAttachment, + premiumDbAttachment, + ]) + }) + + afterEach(function () { + herokuApi.done() + dataApi.done() + nock.cleanAll() + }) + + it('allows user to select snapshot migration method', async function () { + dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(404, { + id: 'not_found', + message: 'Add-on not found', + }) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + .post(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`, { + method: 'full-load', + source_id: premiumDbAttachment.addon.id, + }) + .reply(200, createdMigrationResponse) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(200, createdMigrationResponse) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + + mockedStdinInput = [ + '\n', // Select configure migration + '\n', // Select source database + '\n', // Select target database + '\n', // Select snapshot method (first option, default) + '\n', // Confirm migration + '\n', // Main menu: > Exit + ] + + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + + expect(stderr).to.equal('Configuring migration... done\n') + expect(stdout).to.match(/Select migration method: Snapshot/) + }) + + it('allows user to select snapshot migration method', async function () { + dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(404, { + id: 'not_found', + message: 'Add-on not found', + }) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + .post(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`, { + method: 'cdc', + source_id: premiumDbAttachment.addon.id, + }) + .reply(200, createdMigrationResponse) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(200, createdMigrationResponse) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + + mockedStdinInput = [ + '\n', // Select configure migration + '\n', // Select source database + '\n', // Select target database + '\u001B[B\n', // Select streaming option from method selection + '\n', // Confirm migration + '\n', // Main menu: > Exit + ] + + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + + expect(stderr).to.equal('Configuring migration... done\n') + expect(stdout).to.match(/Select migration method: Streaming/) + }) + + it('allows user to go back from method selection to target selection', async function () { + dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(404, { + id: 'not_found', + message: 'Add-on not found', + }) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(404, { + id: 'not_found', + message: 'Add-on not found', + }) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + + mockedStdinInput = [ + '\n', // Select configure migration + '\n', // Select source database + '\n', // Select target database + '\u001B[A\n', // Navigate down twice to "Go back", press Enter + '\u001B[A\n', // Go back from target selection + '\u001B[A\n', // Go back from source selection + '\u001B[A\n', // Exit from main menu + ] + + const {stderr, stdout} = await runCommand(DataPgMigrate, ['--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(stderr).to.equal('') + expect(stdout).to.match(/Select migration method: Go back/) + }) + + it('skips method selection prompt when --method=snapshot flag is provided', async function () { + dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(404, { + id: 'not_found', + message: 'Add-on not found', + }) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + .post(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`, { + method: 'full-load', + source_id: premiumDbAttachment.addon.id, + }) + .reply(200, createdMigrationResponse) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(200, createdMigrationResponse) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + + mockedStdinInput = [ + '\n', // Select configure migration + '\n', // Select source database + '\n', // Select target database + '\n', // Confirm migration (no method selection prompt) + '\n', // Main menu: > Exit + ] + + const {stderr, stdout} = await runCommand(DataPgMigrate, [ + '--app=myapp', + '--method=snapshot', + ]) + + herokuApi.done() + dataApi.done() + expect(stderr).to.equal('Configuring migration... done\n') + expect(stdout).not.to.contain('Select migration method') + }) + + it('skips method selection prompt when --method=streaming flag is provided', async function () { + dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(404, { + id: 'not_found', + message: 'Add-on not found', + }) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + .post(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`, { + method: 'cdc', + source_id: premiumDbAttachment.addon.id, + }) + .reply(200, createdMigrationResponse) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/migrations`) + .reply(200, createdMigrationResponse) + .get(`/data/postgres/v1/${nonTargetAdvancedDbAttachment.addon.id}/info`) + .reply(200, nonTargetAdvancedDbInfo) + + mockedStdinInput = [ + '\n', // Select configure migration + '\n', // Select source database + '\n', // Select target database + '\n', // Confirm migration (no method selection prompt) + '\n', // Main menu: > Exit + ] + + const {stderr, stdout} = await runCommand(DataPgMigrate, [ + '--app=myapp', + '--method=streaming', + ]) + + herokuApi.done() + dataApi.done() + expect(stderr).to.equal('Configuring migration... done\n') + expect(stdout).not.to.contain('Select migration method') + }) + }) })