From fe2a5e41c5a64a6228863d47017ee39d103570a8 Mon Sep 17 00:00:00 2001 From: Justin Downing Date: Wed, 2 Sep 2026 17:04:36 -0700 Subject: [PATCH 1/2] feat(data): manage Postgres logical replication Add commands to enable logical replication publishing and subscribing\non Postgres Advanced databases.\n\nAdd publication list, info, create, update, and destroy operations\nwith table- and schema-scoped targets. Include --all-schemas as an\nopinionated setup path for all current customer schemas, while making\nits non-continuous behavior explicit.\n\nResolve and reject non-Advanced databases before contacting the Data\nAPI, preserve destructive confirmation for publication removal, and\ncover the command flows with unit tests. --- .../publications/create.ts | 63 +++++++ .../publications/destroy.ts | 40 ++++ .../logical-replication/publications/index.ts | 47 +++++ .../logical-replication/publications/info.ts | 39 ++++ .../publications/update.ts | 53 ++++++ .../logical-replication/publishing/enable.ts | 38 ++++ .../logical-replication/subscribing/enable.ts | 39 ++++ src/lib/data/logical-replication.ts | 51 +++++ .../data/pg/logical-replication.unit.test.ts | 176 ++++++++++++++++++ 9 files changed, 546 insertions(+) create mode 100644 src/commands/data/pg/logical-replication/publications/create.ts create mode 100644 src/commands/data/pg/logical-replication/publications/destroy.ts create mode 100644 src/commands/data/pg/logical-replication/publications/index.ts create mode 100644 src/commands/data/pg/logical-replication/publications/info.ts create mode 100644 src/commands/data/pg/logical-replication/publications/update.ts create mode 100644 src/commands/data/pg/logical-replication/publishing/enable.ts create mode 100644 src/commands/data/pg/logical-replication/subscribing/enable.ts create mode 100644 src/lib/data/logical-replication.ts create mode 100644 test/unit/commands/data/pg/logical-replication.unit.test.ts diff --git a/src/commands/data/pg/logical-replication/publications/create.ts b/src/commands/data/pg/logical-replication/publications/create.ts new file mode 100644 index 0000000000..ca99896ef6 --- /dev/null +++ b/src/commands/data/pg/logical-replication/publications/create.ts @@ -0,0 +1,63 @@ +import {flags as Flags} from '@heroku-cli/command' +import {color} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' + +import BaseCommand from '../../../../../lib/data/base-command.js' +import {PublicationTarget, resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' + +export default class DataPgLogicalReplicationPublicationsCreate extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + static description = 'create a logical replication publication on a Postgres Advanced database' + static examples = [ + '<%= config.bin %> <%= command.id %> DATABASE --name orders --table public.orders --app example-app', + '<%= config.bin %> <%= command.id %> DATABASE --name application --schema public --app example-app', + '<%= config.bin %> <%= command.id %> DATABASE --name application --all-schemas --app example-app', + ] + static flags = { + 'all-schemas': Flags.boolean({ + description: 'include all current customer schemas', + exclusive: ['schema', 'table'], + }), + app: Flags.app({required: true}), + name: Flags.string({description: 'lowercase name for the publication', required: true}), + remote: Flags.remote(), + schema: Flags.string({description: 'schema to include, including new tables created in the schema', multiple: true}), + table: Flags.string({description: 'fully-qualified table to include', multiple: true}), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgLogicalReplicationPublicationsCreate) + const addon = await resolveAdvancedDatabase(this, args.database, flags.app) + const target = this.publicationTarget(flags['all-schemas'], flags.table, flags.schema) + + try { + ux.action.start(`Creating publication ${color.name(flags.name)} on ${color.datastore(addon.name)}`) + await this.dataApi.post(`/data/postgres/v1/${addon.id}/logical-replication/publications`, {body: {name: flags.name, target}}) + ux.action.stop() + if (flags['all-schemas']) { + ux.stdout('The publication includes all current customer schemas. Tables created later and new schemas are not added automatically.') + } + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } + + private publicationTarget(allSchemas: boolean, tables?: string[], schemas?: string[]): PublicationTarget { + if (allSchemas) return {type: 'all_customer_schemas'} + + if (tables && schemas) { + ux.error('Specify either --table or --schema, not both.') + } + + if (tables) return {tables, type: 'tables'} + if (schemas) return {schemas, type: 'schemas'} + + ux.error('Specify --all-schemas, at least one --table, or at least one --schema.') + } +} diff --git a/src/commands/data/pg/logical-replication/publications/destroy.ts b/src/commands/data/pg/logical-replication/publications/destroy.ts new file mode 100644 index 0000000000..80895c0576 --- /dev/null +++ b/src/commands/data/pg/logical-replication/publications/destroy.ts @@ -0,0 +1,40 @@ +import {flags as Flags} from '@heroku-cli/command' +import {color, hux} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' + +import BaseCommand from '../../../../../lib/data/base-command.js' +import {resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' + +export default class DataPgLogicalReplicationPublicationsDestroy extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + static description = 'destroy a logical replication publication' + static examples = [ + '<%= config.bin %> <%= command.id %> DATABASE --name orders --app example-app --confirm example-app', + ] + static flags = { + app: Flags.app({required: true}), + confirm: Flags.string({char: 'c', description: 'pass in the app name to skip confirmation prompts'}), + name: Flags.string({description: 'name of the publication', required: true}), + remote: Flags.remote(), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgLogicalReplicationPublicationsDestroy) + const addon = await resolveAdvancedDatabase(this, args.database, flags.app) + await hux.confirmCommand({comparison: flags.app, confirmation: flags.confirm}) + + try { + ux.action.start(`Destroying publication ${color.name(flags.name)} on ${color.datastore(addon.name)}`) + await this.dataApi.delete(`/data/postgres/v1/${addon.id}/logical-replication/publications/${encodeURIComponent(flags.name)}`) + ux.action.stop() + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } +} diff --git a/src/commands/data/pg/logical-replication/publications/index.ts b/src/commands/data/pg/logical-replication/publications/index.ts new file mode 100644 index 0000000000..8f13a07716 --- /dev/null +++ b/src/commands/data/pg/logical-replication/publications/index.ts @@ -0,0 +1,47 @@ +import {flags as Flags} from '@heroku-cli/command' +import {hux} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' + +import BaseCommand from '../../../../../lib/data/base-command.js' +import {LogicalReplicationPublicationsResponse, resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' +import {huxTableNoWrapOptions} from '../../../../../lib/utils/table-utils.js' + +export default class DataPgLogicalReplicationPublicationsIndex extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + static description = 'list logical replication publications on a Postgres Advanced database' + static examples = [ + '<%= config.bin %> <%= command.id %> DATABASE --app example-app', + ] + static flags = { + app: Flags.app({required: true}), + 'no-wrap': Flags.noWrap(), + remote: Flags.remote(), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgLogicalReplicationPublicationsIndex) + const addon = await resolveAdvancedDatabase(this, args.database, flags.app) + const {body: {publications}} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/logical-replication/publications`) + + if (publications.length === 0) { + ux.stdout(`No logical replication publications exist on ${addon.name}.`) + return + } + + hux.table(publications, { + Name: {get: publication => publication.name}, + 'New Tables': {get: publication => publication.target.automatically_includes_new_tables ? 'included' : 'not included'}, + Owner: {get: publication => publication.owner}, + Target: { + get: publication => publication.target.type === 'schemas' + ? `schemas: ${publication.target.schemas.join(', ')}` + : `tables: ${publication.current_tables.join(', ')}`, + }, + }, huxTableNoWrapOptions(flags['no-wrap'])) + } +} diff --git a/src/commands/data/pg/logical-replication/publications/info.ts b/src/commands/data/pg/logical-replication/publications/info.ts new file mode 100644 index 0000000000..9e05aea541 --- /dev/null +++ b/src/commands/data/pg/logical-replication/publications/info.ts @@ -0,0 +1,39 @@ +import {flags as Flags} from '@heroku-cli/command' +import {hux} from '@heroku/heroku-cli-util' +import {Args} from '@oclif/core' + +import BaseCommand from '../../../../../lib/data/base-command.js' +import {LogicalReplicationPublicationResponse, resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' + +export default class DataPgLogicalReplicationPublicationsInfo extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + static description = 'show a logical replication publication on a Postgres Advanced database' + static examples = [ + '<%= config.bin %> <%= command.id %> DATABASE --name orders --app example-app', + ] + static flags = { + app: Flags.app({required: true}), + name: Flags.string({description: 'name of the publication', required: true}), + remote: Flags.remote(), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgLogicalReplicationPublicationsInfo) + const addon = await resolveAdvancedDatabase(this, args.database, flags.app) + const {body: {publication}} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/logical-replication/publications/${encodeURIComponent(flags.name)}`) + const target = publication.target.type === 'schemas' ? publication.target.schemas.join(', ') : publication.current_tables.join(', ') + + hux.styledObject({ + 'Current Tables': publication.current_tables.join(', '), + 'Includes New Tables': publication.target.automatically_includes_new_tables ? 'yes' : 'no', + Name: publication.name, + Owner: publication.owner, + Target: `${publication.target.type}: ${target}`, + }, ['Name', 'Owner', 'Target', 'Current Tables', 'Includes New Tables']) + } +} diff --git a/src/commands/data/pg/logical-replication/publications/update.ts b/src/commands/data/pg/logical-replication/publications/update.ts new file mode 100644 index 0000000000..10501f419b --- /dev/null +++ b/src/commands/data/pg/logical-replication/publications/update.ts @@ -0,0 +1,53 @@ +import {flags as Flags} from '@heroku-cli/command' +import {color} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' + +import BaseCommand from '../../../../../lib/data/base-command.js' +import {PublicationTarget, resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' + +export default class DataPgLogicalReplicationPublicationsUpdate extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + static description = 'replace the target of a logical replication publication' + static examples = [ + '<%= config.bin %> <%= command.id %> DATABASE --name orders --table public.orders --app example-app', + '<%= config.bin %> <%= command.id %> DATABASE --name application --schema public --app example-app', + ] + static flags = { + app: Flags.app({required: true}), + name: Flags.string({description: 'name of the publication', required: true}), + remote: Flags.remote(), + schema: Flags.string({description: 'schema to include, including new tables created in the schema', multiple: true}), + table: Flags.string({description: 'fully-qualified table to include', multiple: true}), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgLogicalReplicationPublicationsUpdate) + const addon = await resolveAdvancedDatabase(this, args.database, flags.app) + const target = this.publicationTarget(flags.table, flags.schema) + + try { + ux.action.start(`Updating publication ${color.name(flags.name)} on ${color.datastore(addon.name)}`) + await this.dataApi.put(`/data/postgres/v1/${addon.id}/logical-replication/publications/${encodeURIComponent(flags.name)}`, {body: {target}}) + ux.action.stop() + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } + + private publicationTarget(tables?: string[], schemas?: string[]): PublicationTarget { + if (tables && schemas) { + ux.error('Specify either --table or --schema, not both.') + } + + if (tables) return {tables, type: 'tables'} + if (schemas) return {schemas, type: 'schemas'} + + ux.error('Specify at least one --table or --schema.') + } +} diff --git a/src/commands/data/pg/logical-replication/publishing/enable.ts b/src/commands/data/pg/logical-replication/publishing/enable.ts new file mode 100644 index 0000000000..0ddb9cec86 --- /dev/null +++ b/src/commands/data/pg/logical-replication/publishing/enable.ts @@ -0,0 +1,38 @@ +import {flags as Flags} from '@heroku-cli/command' +import {color} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' + +import BaseCommand from '../../../../../lib/data/base-command.js' +import {resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' + +export default class DataPgLogicalReplicationPublishingEnable extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + static description = 'enable logical replication publishing for a Postgres Advanced database' + static examples = [ + '<%= config.bin %> <%= command.id %> DATABASE --app example-app', + ] + static flags = { + app: Flags.app({required: true}), + remote: Flags.remote(), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgLogicalReplicationPublishingEnable) + const addon = await resolveAdvancedDatabase(this, args.database, flags.app) + + try { + ux.action.start(`Enabling logical replication publishing for ${color.datastore(addon.name)}`) + await this.dataApi.post(`/data/postgres/v1/${addon.id}/logical-replication/publishing/enable`) + ux.action.stop('requested') + ux.stdout(`Wait for ${color.datastore(addon.name)} to finish updating before creating publications. Use ${color.code(`heroku data:pg:info ${addon.name} --app ${flags.app}`)} to track progress.`) + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } +} diff --git a/src/commands/data/pg/logical-replication/subscribing/enable.ts b/src/commands/data/pg/logical-replication/subscribing/enable.ts new file mode 100644 index 0000000000..e0fc40acdc --- /dev/null +++ b/src/commands/data/pg/logical-replication/subscribing/enable.ts @@ -0,0 +1,39 @@ +import {flags as Flags} from '@heroku-cli/command' +import {color} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' + +import BaseCommand from '../../../../../lib/data/base-command.js' +import {resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' + +export default class DataPgLogicalReplicationSubscribingEnable extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + static description = 'enable logical replication subscribing for a Postgres Advanced database' + static examples = [ + '<%= config.bin %> <%= command.id %> DATABASE --app example-app', + ] + static flags = { + app: Flags.app({required: true}), + remote: Flags.remote(), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgLogicalReplicationSubscribingEnable) + const addon = await resolveAdvancedDatabase(this, args.database, flags.app) + + try { + ux.action.start(`Enabling logical replication subscribing for ${color.datastore(addon.name)}`) + await this.dataApi.post(`/data/postgres/v1/${addon.id}/logical-replication/subscribing/enable`) + ux.action.stop('requested') + ux.stdout(`Wait for ${color.datastore(addon.name)} to finish updating before creating subscriptions. ` + + `Use ${color.code(`heroku data:pg:info ${addon.name} --app ${flags.app}`)} to track progress.`) + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } +} diff --git a/src/lib/data/logical-replication.ts b/src/lib/data/logical-replication.ts new file mode 100644 index 0000000000..a0f97c8d43 --- /dev/null +++ b/src/lib/data/logical-replication.ts @@ -0,0 +1,51 @@ +import type {pg} from '@heroku/heroku-cli-util' + +import {color, utils} from '@heroku/heroku-cli-util' +import {ux} from '@oclif/core' + +import type BaseCommand from './base-command.js' + +export type PublicationTarget + = | {schemas: string[], type: 'schemas'} + | {tables: string[], type: 'tables'} + | {type: 'all_customer_schemas'} + +type PublicationResponseTarget + = | { + automatically_includes_new_schemas: boolean + automatically_includes_new_tables: boolean + schemas: string[] + type: 'schemas' + } + | { + automatically_includes_new_schemas: boolean + automatically_includes_new_tables: boolean + tables: string[] + type: 'tables' + } + +export type LogicalReplicationPublication = { + current_tables: string[] + name: string + owner: string + target: PublicationResponseTarget +} + +export type LogicalReplicationPublicationsResponse = { + publications: LogicalReplicationPublication[] +} + +export type LogicalReplicationPublicationResponse = { + publication: LogicalReplicationPublication +} + +export async function resolveAdvancedDatabase(command: BaseCommand, database: string, app: string): Promise { + const addonResolver = new utils.AddonResolver(command.heroku) + const addon = await addonResolver.resolve(database, app, utils.pg.addonService()) + + if (!utils.pg.isAdvancedDatabase(addon)) { + ux.error(`You can only use this command on Advanced-tier databases.\nUse ${color.code(`heroku data:pg:info ${database} --app ${app}`)} to inspect an Advanced database.`) + } + + return addon +} diff --git a/test/unit/commands/data/pg/logical-replication.unit.test.ts b/test/unit/commands/data/pg/logical-replication.unit.test.ts new file mode 100644 index 0000000000..3131ec3159 --- /dev/null +++ b/test/unit/commands/data/pg/logical-replication.unit.test.ts @@ -0,0 +1,176 @@ +import {runCommand} from '@heroku-cli/test-utils' +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' + +import DataPgLogicalReplicationPublicationsCreate from '../../../../../src/commands/data/pg/logical-replication/publications/create.js' +import DataPgLogicalReplicationPublicationsDestroy from '../../../../../src/commands/data/pg/logical-replication/publications/destroy.js' +import DataPgLogicalReplicationPublicationsIndex from '../../../../../src/commands/data/pg/logical-replication/publications/index.js' +import DataPgLogicalReplicationPublicationsInfo from '../../../../../src/commands/data/pg/logical-replication/publications/info.js' +import DataPgLogicalReplicationPublicationsUpdate from '../../../../../src/commands/data/pg/logical-replication/publications/update.js' +import DataPgLogicalReplicationPublishingEnable from '../../../../../src/commands/data/pg/logical-replication/publishing/enable.js' +import DataPgLogicalReplicationSubscribingEnable from '../../../../../src/commands/data/pg/logical-replication/subscribing/enable.js' +import {addon} from '../../../../fixtures/data/pg/fixtures.js' +import removeAllWhitespace from '../../../../helpers/utils/remove-whitespaces.js' + +const publicationsResponse = { + publications: [{ + current_tables: ['public.orders'], + name: 'orders', + owner: 'u12345', + target: { + automatically_includes_new_schemas: false, + automatically_includes_new_tables: false, + tables: ['public.orders'], + type: 'tables', + }, + }], +} + +const resolveAddon = () => nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [{...addon, addon_service: {...addon.addon_service, name: 'heroku-postgresql'}}]) + +describe('data:pg:logical-replication', function () { + it('enables publishing and explains how to track the asynchronous operation', async function () { + const herokuApi = resolveAddon() + const dataApi = nock('https://api.data.heroku.com') + .post(`/data/postgres/v1/${addon.id}/logical-replication/publishing/enable`) + .reply(202) + + const {error, stderr, stdout} = await runCommand(DataPgLogicalReplicationPublishingEnable, ['DATABASE', '--app=myapp']) + + expect(error).to.be.undefined + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr)).to.include('Enabling logical replication publishing for') + expect(ansis.strip(stderr)).to.include('advanced-horizontal-01234... requested') + expect(ansis.strip(stdout)).to.include('to finish updating before creating publications.') + }) + + it('enables subscribing and explains how to track the asynchronous operation', async function () { + const herokuApi = resolveAddon() + const dataApi = nock('https://api.data.heroku.com') + .post(`/data/postgres/v1/${addon.id}/logical-replication/subscribing/enable`) + .reply(202) + + const {stderr, stdout} = await runCommand(DataPgLogicalReplicationSubscribingEnable, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr)).to.include('Enabling logical replication subscribing for') + expect(ansis.strip(stderr)).to.include('advanced-horizontal-01234... requested') + expect(ansis.strip(stdout)).to.include('to finish updating before creating subscriptions.') + }) + + it('creates table-scoped publications', async function () { + const herokuApi = resolveAddon() + const dataApi = nock('https://api.data.heroku.com') + .post(`/data/postgres/v1/${addon.id}/logical-replication/publications`, { + name: 'orders', target: {tables: ['public.orders', 'public.order_items'], type: 'tables'}, + }) + .reply(201) + + const {stderr} = await runCommand(DataPgLogicalReplicationPublicationsCreate, [ + 'DATABASE', '--app=myapp', '--name=orders', '--table=public.orders', '--table=public.order_items', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr)).to.include('Creating publication orders on') + expect(ansis.strip(stderr)).to.include('advanced-horizontal-01234... done') + }) + + it('requires exactly one publication target type', async function () { + const herokuApi = resolveAddon() + const {error} = await runCommand(DataPgLogicalReplicationPublicationsCreate, [ + 'DATABASE', '--app=myapp', '--name=orders', '--table=public.orders', '--schema=public', + ]) + + herokuApi.done() + expect((error as Error).message).to.equal('Specify either --table or --schema, not both.') + }) + + it('creates a publication for all current customer schemas', async function () { + const herokuApi = resolveAddon() + const dataApi = nock('https://api.data.heroku.com') + .post(`/data/postgres/v1/${addon.id}/logical-replication/publications`, { + name: 'application', target: {type: 'all_customer_schemas'}, + }) + .reply(201) + + const {stdout} = await runCommand(DataPgLogicalReplicationPublicationsCreate, [ + 'DATABASE', '--app=myapp', '--name=application', '--all-schemas', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stdout)).to.equal('The publication includes all current customer schemas. Tables created later and new schemas are not added automatically.\n') + }) + + it('lists publications', async function () { + const herokuApi = resolveAddon() + const dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/logical-replication/publications`) + .reply(200, publicationsResponse) + + const {stdout} = await runCommand(DataPgLogicalReplicationPublicationsIndex, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + const actual = removeAllWhitespace(stdout) + expect(actual).to.include(removeAllWhitespace('Name New Tables Owner Target')) + expect(actual).to.include(removeAllWhitespace('orders not included u12345 tables: public.orders')) + }) + + it('shows publication details', async function () { + const herokuApi = resolveAddon() + const dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/logical-replication/publications/orders`) + .reply(200, {publication: publicationsResponse.publications[0]}) + + const {stdout} = await runCommand(DataPgLogicalReplicationPublicationsInfo, [ + 'DATABASE', '--app=myapp', '--name=orders', + ]) + + herokuApi.done() + dataApi.done() + const actual = removeAllWhitespace(stdout) + expect(actual).to.include(removeAllWhitespace('Name: orders')) + expect(actual).to.include(removeAllWhitespace('Target: tables: public.orders')) + }) + + it('replaces a publication target', async function () { + const herokuApi = resolveAddon() + const dataApi = nock('https://api.data.heroku.com') + .put(`/data/postgres/v1/${addon.id}/logical-replication/publications/orders`, { + target: {schemas: ['public'], type: 'schemas'}, + }) + .reply(204) + + const {stderr} = await runCommand(DataPgLogicalReplicationPublicationsUpdate, [ + 'DATABASE', '--app=myapp', '--name=orders', '--schema=public', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr)).to.include('Updating publication orders on') + expect(ansis.strip(stderr)).to.include('advanced-horizontal-01234... done') + }) + + it('destroys a publication with explicit confirmation', async function () { + const herokuApi = resolveAddon() + const dataApi = nock('https://api.data.heroku.com') + .delete(`/data/postgres/v1/${addon.id}/logical-replication/publications/orders`) + .reply(204) + + const {stderr} = await runCommand(DataPgLogicalReplicationPublicationsDestroy, [ + 'DATABASE', '--app=myapp', '--name=orders', '--confirm=myapp', + ]) + + herokuApi.done() + dataApi.done() + expect(ansis.strip(stderr)).to.include('Destroying publication orders on') + expect(ansis.strip(stderr)).to.include('advanced-horizontal-01234... done') + }) +}) From 3a64e2ee82235c8b489d85ea58e547ce3677228a Mon Sep 17 00:00:00 2001 From: Justin Downing Date: Thu, 3 Sep 2026 13:44:27 -0700 Subject: [PATCH 2/2] fix(data): align logical replication commands with API Consume publication collection responses through items, count, and limit\nand read individual publications directly from the response body.\n\nAdd data:pg:lr aliases for logical replication enablement and\npublication management commands. --- .../data/pg/logical-replication/publications/create.ts | 1 + .../data/pg/logical-replication/publications/destroy.ts | 1 + .../data/pg/logical-replication/publications/index.ts | 7 ++++--- .../data/pg/logical-replication/publications/info.ts | 5 +++-- .../data/pg/logical-replication/publications/update.ts | 1 + .../data/pg/logical-replication/publishing/enable.ts | 1 + .../data/pg/logical-replication/subscribing/enable.ts | 1 + src/lib/data/logical-replication.ts | 8 +++----- .../commands/data/pg/logical-replication.unit.test.ts | 6 ++++-- 9 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/commands/data/pg/logical-replication/publications/create.ts b/src/commands/data/pg/logical-replication/publications/create.ts index ca99896ef6..c821efb63f 100644 --- a/src/commands/data/pg/logical-replication/publications/create.ts +++ b/src/commands/data/pg/logical-replication/publications/create.ts @@ -6,6 +6,7 @@ import BaseCommand from '../../../../../lib/data/base-command.js' import {PublicationTarget, resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' export default class DataPgLogicalReplicationPublicationsCreate extends BaseCommand { + static aliases = ['data:pg:lr:publications:create'] static args = { database: Args.string({ description: 'database name, database attachment name, or related config var on an app', diff --git a/src/commands/data/pg/logical-replication/publications/destroy.ts b/src/commands/data/pg/logical-replication/publications/destroy.ts index 80895c0576..3621e6d5c8 100644 --- a/src/commands/data/pg/logical-replication/publications/destroy.ts +++ b/src/commands/data/pg/logical-replication/publications/destroy.ts @@ -6,6 +6,7 @@ import BaseCommand from '../../../../../lib/data/base-command.js' import {resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' export default class DataPgLogicalReplicationPublicationsDestroy extends BaseCommand { + static aliases = ['data:pg:lr:publications:destroy'] static args = { database: Args.string({ description: 'database name, database attachment name, or related config var on an app', diff --git a/src/commands/data/pg/logical-replication/publications/index.ts b/src/commands/data/pg/logical-replication/publications/index.ts index 8f13a07716..3fb21a7d23 100644 --- a/src/commands/data/pg/logical-replication/publications/index.ts +++ b/src/commands/data/pg/logical-replication/publications/index.ts @@ -7,6 +7,7 @@ import {LogicalReplicationPublicationsResponse, resolveAdvancedDatabase} from '. import {huxTableNoWrapOptions} from '../../../../../lib/utils/table-utils.js' export default class DataPgLogicalReplicationPublicationsIndex extends BaseCommand { + static aliases = ['data:pg:lr:publications'] static args = { database: Args.string({ description: 'database name, database attachment name, or related config var on an app', @@ -26,14 +27,14 @@ export default class DataPgLogicalReplicationPublicationsIndex extends BaseComma async run(): Promise { const {args, flags} = await this.parse(DataPgLogicalReplicationPublicationsIndex) const addon = await resolveAdvancedDatabase(this, args.database, flags.app) - const {body: {publications}} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/logical-replication/publications`) + const {body: {items}} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/logical-replication/publications`) - if (publications.length === 0) { + if (items.length === 0) { ux.stdout(`No logical replication publications exist on ${addon.name}.`) return } - hux.table(publications, { + hux.table(items, { Name: {get: publication => publication.name}, 'New Tables': {get: publication => publication.target.automatically_includes_new_tables ? 'included' : 'not included'}, Owner: {get: publication => publication.owner}, diff --git a/src/commands/data/pg/logical-replication/publications/info.ts b/src/commands/data/pg/logical-replication/publications/info.ts index 9e05aea541..d74350a367 100644 --- a/src/commands/data/pg/logical-replication/publications/info.ts +++ b/src/commands/data/pg/logical-replication/publications/info.ts @@ -3,9 +3,10 @@ import {hux} from '@heroku/heroku-cli-util' import {Args} from '@oclif/core' import BaseCommand from '../../../../../lib/data/base-command.js' -import {LogicalReplicationPublicationResponse, resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' +import {LogicalReplicationPublication, resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' export default class DataPgLogicalReplicationPublicationsInfo extends BaseCommand { + static aliases = ['data:pg:lr:publications:info'] static args = { database: Args.string({ description: 'database name, database attachment name, or related config var on an app', @@ -25,7 +26,7 @@ export default class DataPgLogicalReplicationPublicationsInfo extends BaseComman async run(): Promise { const {args, flags} = await this.parse(DataPgLogicalReplicationPublicationsInfo) const addon = await resolveAdvancedDatabase(this, args.database, flags.app) - const {body: {publication}} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/logical-replication/publications/${encodeURIComponent(flags.name)}`) + const {body: publication} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/logical-replication/publications/${encodeURIComponent(flags.name)}`) const target = publication.target.type === 'schemas' ? publication.target.schemas.join(', ') : publication.current_tables.join(', ') hux.styledObject({ diff --git a/src/commands/data/pg/logical-replication/publications/update.ts b/src/commands/data/pg/logical-replication/publications/update.ts index 10501f419b..a7251c5677 100644 --- a/src/commands/data/pg/logical-replication/publications/update.ts +++ b/src/commands/data/pg/logical-replication/publications/update.ts @@ -6,6 +6,7 @@ import BaseCommand from '../../../../../lib/data/base-command.js' import {PublicationTarget, resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' export default class DataPgLogicalReplicationPublicationsUpdate extends BaseCommand { + static aliases = ['data:pg:lr:publications:update'] static args = { database: Args.string({ description: 'database name, database attachment name, or related config var on an app', diff --git a/src/commands/data/pg/logical-replication/publishing/enable.ts b/src/commands/data/pg/logical-replication/publishing/enable.ts index 0ddb9cec86..18f30a2c18 100644 --- a/src/commands/data/pg/logical-replication/publishing/enable.ts +++ b/src/commands/data/pg/logical-replication/publishing/enable.ts @@ -6,6 +6,7 @@ import BaseCommand from '../../../../../lib/data/base-command.js' import {resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' export default class DataPgLogicalReplicationPublishingEnable extends BaseCommand { + static aliases = ['data:pg:lr:publishing:enable'] static args = { database: Args.string({ description: 'database name, database attachment name, or related config var on an app', diff --git a/src/commands/data/pg/logical-replication/subscribing/enable.ts b/src/commands/data/pg/logical-replication/subscribing/enable.ts index e0fc40acdc..4e4efa0287 100644 --- a/src/commands/data/pg/logical-replication/subscribing/enable.ts +++ b/src/commands/data/pg/logical-replication/subscribing/enable.ts @@ -6,6 +6,7 @@ import BaseCommand from '../../../../../lib/data/base-command.js' import {resolveAdvancedDatabase} from '../../../../../lib/data/logical-replication.js' export default class DataPgLogicalReplicationSubscribingEnable extends BaseCommand { + static aliases = ['data:pg:lr:subscribing:enable'] static args = { database: Args.string({ description: 'database name, database attachment name, or related config var on an app', diff --git a/src/lib/data/logical-replication.ts b/src/lib/data/logical-replication.ts index a0f97c8d43..2636511821 100644 --- a/src/lib/data/logical-replication.ts +++ b/src/lib/data/logical-replication.ts @@ -32,11 +32,9 @@ export type LogicalReplicationPublication = { } export type LogicalReplicationPublicationsResponse = { - publications: LogicalReplicationPublication[] -} - -export type LogicalReplicationPublicationResponse = { - publication: LogicalReplicationPublication + count: number + items: LogicalReplicationPublication[] + limit: number } export async function resolveAdvancedDatabase(command: BaseCommand, database: string, app: string): Promise { diff --git a/test/unit/commands/data/pg/logical-replication.unit.test.ts b/test/unit/commands/data/pg/logical-replication.unit.test.ts index 3131ec3159..c1803e8957 100644 --- a/test/unit/commands/data/pg/logical-replication.unit.test.ts +++ b/test/unit/commands/data/pg/logical-replication.unit.test.ts @@ -14,7 +14,8 @@ import {addon} from '../../../../fixtures/data/pg/fixtures.js' import removeAllWhitespace from '../../../../helpers/utils/remove-whitespaces.js' const publicationsResponse = { - publications: [{ + count: 1, + items: [{ current_tables: ['public.orders'], name: 'orders', owner: 'u12345', @@ -25,6 +26,7 @@ const publicationsResponse = { type: 'tables', }, }], + limit: 50, } const resolveAddon = () => nock('https://api.heroku.com') @@ -127,7 +129,7 @@ describe('data:pg:logical-replication', function () { const herokuApi = resolveAddon() const dataApi = nock('https://api.data.heroku.com') .get(`/data/postgres/v1/${addon.id}/logical-replication/publications/orders`) - .reply(200, {publication: publicationsResponse.publications[0]}) + .reply(200, publicationsResponse.items[0]) const {stdout} = await runCommand(DataPgLogicalReplicationPublicationsInfo, [ 'DATABASE', '--app=myapp', '--name=orders',