diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4109ce388..88a969194ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,4 @@ - Add a confirmation in `firebase init dataconnect` before asking for app idea description. (#9282) - [BREAKING] Removed deprecated `firebase --open-sesame` and `firebase --close-sesame` commands. Use `firebase experiments:enable` and `firebase experiments:disable` instead. - [BREAKING] Enforce strict timeout validation for functions. (#9540) +- [BREAKING] Update `dataconnect:\*` commands to use flags instead of positional arguments for `--service` & `--location`. Changed output type of `dataconnect:sql:migrate --json` (#9312) diff --git a/firebase-vscode/package-lock.json b/firebase-vscode/package-lock.json index d171ed6d958..2253395206f 100644 --- a/firebase-vscode/package-lock.json +++ b/firebase-vscode/package-lock.json @@ -3290,30 +3290,6 @@ "node": ">=18.20.0" } }, - "node_modules/@wdio/cli/node_modules/@puppeteer/browsers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", - "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.3.5", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.4.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@wdio/cli/node_modules/@wdio/repl": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@wdio/repl/-/repl-9.0.8.tgz", @@ -3347,30 +3323,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@wdio/cli/node_modules/chromium-bidi": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", - "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "mitt": "3.0.1", - "urlpattern-polyfill": "10.0.0", - "zod": "3.23.8" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/@wdio/cli/node_modules/devtools-protocol": { - "version": "0.0.1312386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", - "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@wdio/cli/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -3386,24 +3338,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@wdio/cli/node_modules/puppeteer-core": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", - "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@puppeteer/browsers": "2.3.0", - "chromium-bidi": "0.6.3", - "debug": "^4.3.6", - "devtools-protocol": "0.0.1312386", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@wdio/cli/node_modules/webdriver": { "version": "9.2.5", "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.2.5.tgz", @@ -3545,30 +3479,6 @@ "webdriverio": "9.2.6" } }, - "node_modules/@wdio/globals/node_modules/@puppeteer/browsers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", - "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.3.5", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.4.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@wdio/globals/node_modules/@wdio/repl": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@wdio/repl/-/repl-9.0.8.tgz", @@ -3592,30 +3502,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/@wdio/globals/node_modules/chromium-bidi": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", - "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "mitt": "3.0.1", - "urlpattern-polyfill": "10.0.0", - "zod": "3.23.8" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/@wdio/globals/node_modules/devtools-protocol": { - "version": "0.0.1312386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", - "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@wdio/globals/node_modules/expect-webdriverio": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/expect-webdriverio/-/expect-webdriverio-5.0.3.tgz", @@ -3664,24 +3550,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@wdio/globals/node_modules/puppeteer-core": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", - "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@puppeteer/browsers": "2.3.0", - "chromium-bidi": "0.6.3", - "debug": "^4.3.6", - "devtools-protocol": "0.0.1312386", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@wdio/globals/node_modules/webdriver": { "version": "9.2.5", "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.2.5.tgz", @@ -17727,17 +17595,6 @@ "dependencies": { "safe-buffer": "~5.2.0" } - }, - "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "dev": true, - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/src/commands/dataconnect-execute.ts b/src/commands/dataconnect-execute.ts index 2bd778f337f..91bd01aab9f 100644 --- a/src/commands/dataconnect-execute.ts +++ b/src/commands/dataconnect-execute.ts @@ -2,7 +2,7 @@ import * as clc from "colorette"; import { Command } from "../command"; import { Options } from "../options"; import { getProjectId, needProjectId } from "../projectUtils"; -import { pickService, readGQLFiles, squashGraphQL } from "../dataconnect/load"; +import { pickOneService, readGQLFiles, squashGraphQL } from "../dataconnect/load"; import { requireAuth } from "../requireAuth"; import { Constants } from "../emulator/constants"; import { Client } from "../apiv2"; @@ -207,7 +207,12 @@ export const command = new Command("dataconnect:execute [file] [operationName]") } async function getServiceInfo(): Promise { - return pickService(projectId, options.config, serviceId || undefined).catch((e) => { + return pickOneService( + projectId, + options.config, + serviceId || undefined, + locationId || undefined, + ).catch((e: unknown) => { if (!(e instanceof FirebaseError)) { return Promise.reject(e); } diff --git a/src/commands/dataconnect-sdk-generate.ts b/src/commands/dataconnect-sdk-generate.ts index db587df5176..4dfe9e02ef8 100644 --- a/src/commands/dataconnect-sdk-generate.ts +++ b/src/commands/dataconnect-sdk-generate.ts @@ -4,7 +4,7 @@ import { Command } from "../command"; import { Options } from "../options"; import { DataConnectEmulator } from "../emulator/dataconnectEmulator"; import { getProjectId } from "../projectUtils"; -import { loadAll } from "../dataconnect/load"; +import { pickServices } from "../dataconnect/load"; import { getProjectDefaultAccount } from "../auth"; import { logBullet, logLabeledSuccess, logWarning } from "../utils"; import { ServiceInfo } from "../dataconnect/types"; @@ -16,10 +16,18 @@ import { FirebaseError } from "../error"; import { postInitSaves } from "./init"; import { EmulatorHub } from "../emulator/hub"; -type GenerateOptions = Options & { watch?: boolean }; +type GenerateOptions = Options & { watch?: boolean; service?: string; location?: string }; export const command = new Command("dataconnect:sdk:generate") - .description("generate typed SDKs for your Data Connect connectors") + .description("generate typed SDKs to use Data Connect in your apps") + .option( + "--service ", + "the serviceId of the Data Connect service. If not provided, generates SDKs for all services.", + ) + .option( + "--location ", + "the location of the Data Connect service. Only needed if service ID is used in multiple locations.", + ) .option( "--watch", "watch for changes to your connector GQL files and regenerate your SDKs when updates occur", @@ -59,7 +67,7 @@ export const command = new Command("dataconnect:sdk:generate") options.config = config; } - let serviceInfosWithSDKs = await loadAllWithSDKs(projectId, config); + let serviceInfosWithSDKs = await loadAllWithSDKs(projectId, config, options); if (!serviceInfosWithSDKs.length) { if (justRanInit || options.nonInteractive) { throw new FirebaseError( @@ -82,7 +90,7 @@ export const command = new Command("dataconnect:sdk:generate") await dataconnectSdkInit.askQuestions(setup); await dataconnectSdkInit.actuate(setup, config); justRanInit = true; - serviceInfosWithSDKs = await loadAllWithSDKs(projectId, config); + serviceInfosWithSDKs = await loadAllWithSDKs(projectId, config, options); } await generateSDKsInAll(options, serviceInfosWithSDKs, justRanInit); @@ -91,8 +99,14 @@ export const command = new Command("dataconnect:sdk:generate") async function loadAllWithSDKs( projectId: string | undefined, config: Config, + options: GenerateOptions, ): Promise { - const serviceInfos = await loadAll(projectId || EmulatorHub.MISSING_PROJECT_PLACEHOLDER, config); + const serviceInfos = await pickServices( + projectId || EmulatorHub.MISSING_PROJECT_PLACEHOLDER, + config, + options.service, + options.location, + ); return serviceInfos.filter((serviceInfo) => serviceInfo.connectorInfo.some((c) => { return ( diff --git a/src/commands/dataconnect-sql-diff.ts b/src/commands/dataconnect-sql-diff.ts index f928aadfbdb..87a9e2eeda7 100644 --- a/src/commands/dataconnect-sql-diff.ts +++ b/src/commands/dataconnect-sql-diff.ts @@ -3,14 +3,21 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import { ensureApis } from "../dataconnect/ensureApis"; import { requirePermissions } from "../requirePermissions"; -import { pickService } from "../dataconnect/load"; +import { pickOneService } from "../dataconnect/load"; import { diffSchema } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; import { mainSchema, mainSchemaYaml } from "../dataconnect/types"; -export const command = new Command("dataconnect:sql:diff [serviceId]") +type DiffOptions = Options & { service?: string; location?: string }; + +export const command = new Command("dataconnect:sql:diff") .description( - "display the differences between a local Data Connect schema and your CloudSQL database's current schema", + "display the differences between the local Data Connect schema and your CloudSQL database's schema", + ) + .option("--service ", "the serviceId of the Data Connect service") + .option( + "--location ", + "the location of the Data Connect service. Only needed if service ID is used in multiple locations.", ) .before(requirePermissions, [ "firebasedataconnect.services.list", @@ -18,15 +25,20 @@ export const command = new Command("dataconnect:sql:diff [serviceId]") "firebasedataconnect.schemas.update", ]) .before(requireAuth) - .action(async (serviceId: string, options: Options) => { + .action(async (options: DiffOptions) => { const projectId = needProjectId(options); await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); const diffs = await diffSchema( options, mainSchema(serviceInfo.schemas), mainSchemaYaml(serviceInfo.dataConnectYaml).datasource.postgresql?.schemaValidation, ); - return { projectId, serviceId, diffs }; + return { projectId, diffs }; }); diff --git a/src/commands/dataconnect-sql-grant.ts b/src/commands/dataconnect-sql-grant.ts index 0dbaf575787..e5f8bb9b143 100644 --- a/src/commands/dataconnect-sql-grant.ts +++ b/src/commands/dataconnect-sql-grant.ts @@ -3,7 +3,7 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import { ensureApis } from "../dataconnect/ensureApis"; import { requirePermissions } from "../requirePermissions"; -import { pickService } from "../dataconnect/load"; +import { pickOneService } from "../dataconnect/load"; import { grantRoleToUserInSchema } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; import { FirebaseError } from "../error"; @@ -13,45 +13,60 @@ import { mainSchema } from "../dataconnect/types"; const allowedRoles = Object.keys(fdcSqlRoleMap); -export const command = new Command("dataconnect:sql:grant [serviceId]") +type GrantOptions = Options & { + role?: string; + email?: string; + service?: string; + location?: string; +}; + +export const command = new Command("dataconnect:sql:grant") .description("grants the SQL role to the provided user or service account ") .option("-R, --role ", "The SQL role to grant. One of: owner, writer, or reader.") .option( "-E, --email ", "The email of the user or service account we would like to grant the role to.", ) + .option("--service ", "the serviceId of the Data Connect service") + .option( + "--location ", + "the location of the Data Connect service. Only needed if service ID is used in multiple locations.", + ) .before(requirePermissions, ["firebasedataconnect.services.list"]) .before(requireAuth) - .action(async (serviceId: string, options: Options) => { - const role = options.role as string; - const email = options.email as string; - if (!role) { + .action(async (options: GrantOptions) => { + if (!options.role) { throw new FirebaseError( "-R, --role is required. Run the command with -h for more info.", ); } - if (!email) { + if (!options.email) { throw new FirebaseError( "-E, --email is required. Run the command with -h for more info.", ); } - if (!allowedRoles.includes(role.toLowerCase())) { + if (!allowedRoles.includes(options.role.toLowerCase())) { throw new FirebaseError(`Role should be one of ${allowedRoles.join(" | ")}.`); } + const projectId = needProjectId(options); + await ensureApis(projectId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); + // Make sure current user can perform this action. const userIsCSQLAdmin = await iamUserIsCSQLAdmin(options); if (!userIsCSQLAdmin) { throw new FirebaseError( - `Only users with 'roles/cloudsql.admin' can grant SQL roles. If you do not have this role, ask your database administrator to run this command or manually grant ${role} to ${email}`, + `Only users with 'roles/cloudsql.admin' can grant SQL roles. If you do not have this role, ask your database administrator to run this command or manually grant ${options.role} to ${options.email}`, ); } - const projectId = needProjectId(options); - await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); - await grantRoleToUserInSchema(options, mainSchema(serviceInfo.schemas)); - return { projectId, serviceId }; + return { projectId }; }); diff --git a/src/commands/dataconnect-sql-migrate.ts b/src/commands/dataconnect-sql-migrate.ts index cb246527429..f249e95c5fc 100644 --- a/src/commands/dataconnect-sql-migrate.ts +++ b/src/commands/dataconnect-sql-migrate.ts @@ -1,7 +1,7 @@ import { Command } from "../command"; import { Options } from "../options"; import { needProjectId } from "../projectUtils"; -import { pickService } from "../dataconnect/load"; +import { pickOneService } from "../dataconnect/load"; import { FirebaseError } from "../error"; import { migrateSchema } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; @@ -10,8 +10,15 @@ import { ensureApis } from "../dataconnect/ensureApis"; import { logLabeledSuccess } from "../utils"; import { mainSchema, mainSchemaYaml } from "../dataconnect/types"; -export const command = new Command("dataconnect:sql:migrate [serviceId]") +type MigrateOptions = Options & { service?: string; location?: string }; + +export const command = new Command("dataconnect:sql:migrate") .description("migrate your CloudSQL database's schema to match your local Data Connect schema") + .option("--service ", "the serviceId of the Data Connect service") + .option( + "--location ", + "the location of the Data Connect service. Only needed if service ID is used in multiple locations.", + ) .before(requirePermissions, [ "firebasedataconnect.services.list", "firebasedataconnect.schemas.list", @@ -20,10 +27,15 @@ export const command = new Command("dataconnect:sql:migrate [serviceId]") ]) .before(requireAuth) .withForce("execute any required database changes without prompting") - .action(async (serviceId: string, options: Options) => { + .action(async (options: MigrateOptions) => { const projectId = needProjectId(options); await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); const instanceId = mainSchemaYaml(serviceInfo.dataConnectYaml).datasource.postgresql?.cloudSql .instanceId; if (!instanceId) { @@ -46,5 +58,5 @@ export const command = new Command("dataconnect:sql:migrate [serviceId]") } else { logLabeledSuccess("dataconnect", "Database schema is already up to date!"); } - return { projectId, serviceId, diffs }; + return { projectId, diffs }; }); diff --git a/src/commands/dataconnect-sql-setup.ts b/src/commands/dataconnect-sql-setup.ts index cace511cf5d..7548e90bd37 100644 --- a/src/commands/dataconnect-sql-setup.ts +++ b/src/commands/dataconnect-sql-setup.ts @@ -1,7 +1,6 @@ import { Command } from "../command"; import { Options } from "../options"; import { needProjectId } from "../projectUtils"; -import { pickService } from "../dataconnect/load"; import { FirebaseError } from "../error"; import { requireAuth } from "../requireAuth"; import { requirePermissions } from "../requirePermissions"; @@ -10,10 +9,18 @@ import { setupSQLPermissions, getSchemaMetadata } from "../gcp/cloudsql/permissi import { DEFAULT_SCHEMA } from "../gcp/cloudsql/permissions"; import { getIdentifiers, ensureServiceIsConnectedToCloudSql } from "../dataconnect/schemaMigration"; import { setupIAMUsers } from "../gcp/cloudsql/connect"; +import { pickOneService } from "../dataconnect/load"; import { mainSchema, mainSchemaYaml } from "../dataconnect/types"; -export const command = new Command("dataconnect:sql:setup [serviceId]") +type SetupOptions = Options & { service?: string; location?: string }; + +export const command = new Command("dataconnect:sql:setup") .description("set up your CloudSQL database") + .option("--service ", "the serviceId of the Data Connect service") + .option( + "--location ", + "the location of the Data Connect service. Only needed if service ID is used in multiple locations.", + ) .before(requirePermissions, [ "firebasedataconnect.services.list", "firebasedataconnect.schemas.list", @@ -21,10 +28,15 @@ export const command = new Command("dataconnect:sql:setup [serviceId]") "cloudsql.instances.connect", ]) .before(requireAuth) - .action(async (serviceId: string, options: Options) => { + .action(async (options: SetupOptions) => { const projectId = needProjectId(options); await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); const instanceId = mainSchemaYaml(serviceInfo.dataConnectYaml).datasource.postgresql?.cloudSql .instanceId; if (!instanceId) { diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts index 49436aac7f2..ff7516529ce 100644 --- a/src/commands/dataconnect-sql-shell.ts +++ b/src/commands/dataconnect-sql-shell.ts @@ -7,7 +7,7 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import { ensureApis } from "../dataconnect/ensureApis"; import { requirePermissions } from "../requirePermissions"; -import { pickService } from "../dataconnect/load"; +import { pickOneService } from "../dataconnect/load"; import { getIdentifiers } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; import { getIAMUser } from "../gcp/cloudsql/connect"; @@ -82,16 +82,28 @@ async function mainShellLoop(conn: pg.PoolClient) { } } -export const command = new Command("dataconnect:sql:shell [serviceId]") +type ShellOptions = Options & { service?: string; location?: string }; + +export const command = new Command("dataconnect:sql:shell") .description( "start a shell connected directly to your Data Connect service's linked CloudSQL instance", ) + .option("--service ", "the serviceId of the Data Connect service") + .option( + "--location ", + "the location of the Data Connect service. Only needed if service ID is used in multiple locations.", + ) .before(requirePermissions, ["firebasedataconnect.services.list", "cloudsql.instances.connect"]) .before(requireAuth) - .action(async (serviceId: string, options: Options) => { + .action(async (options: ShellOptions) => { const projectId = needProjectId(options); await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); const { instanceId, databaseId } = getIdentifiers(mainSchema(serviceInfo.schemas)); const { user: username } = await getIAMUser(options); const instance = await cloudSqlAdminClient.getInstance(projectId, instanceId); @@ -135,5 +147,5 @@ export const command = new Command("dataconnect:sql:shell [serviceId]") await pool.end(); connector.close(); - return { projectId, serviceId }; + return { projectId }; }); diff --git a/src/dataconnect/load.ts b/src/dataconnect/load.ts index aedb487b073..c0254e38b0f 100644 --- a/src/dataconnect/load.ts +++ b/src/dataconnect/load.ts @@ -18,44 +18,56 @@ import { readFileFromDirectory, wrappedSafeLoad } from "../utils"; import { DataConnectMultiple } from "../firebaseConfig"; import * as experiments from "../experiments"; -// pickService reads firebase.json and returns all services with a given serviceId. -// If serviceID is not provided and there is a single service, return that. -export async function pickService( +/** Picks exactly one Data Connect service based on flags. */ +export async function pickOneService( projectId: string, config: Config, - serviceId?: string, + service?: string, + location?: string, ): Promise { + const services = await pickServices(projectId, config, service, location); + if (services.length > 1) { + const serviceIds = services.map( + (i) => `${i.dataConnectYaml.location}:${i.dataConnectYaml.serviceId}`, + ); + throw new FirebaseError( + `Multiple services matched. Please specify a service and location. Matched services: ${serviceIds.join( + ", ", + )}`, + ); + } + return services[0]; +} + +/** Picks Data Connect services based on flags. */ +export async function pickServices( + projectId: string, + config: Config, + serviceId?: string, + location?: string, +): Promise { const serviceInfos = await loadAll(projectId, config); if (serviceInfos.length === 0) { throw new FirebaseError( "No Data Connect services found in firebase.json." + `\nYou can run ${clc.bold("firebase init dataconnect")} to add a Data Connect service.`, ); - } else if (serviceInfos.length === 1) { - if (serviceId && serviceId !== serviceInfos[0].dataConnectYaml.serviceId) { - throw new FirebaseError( - `No service named ${serviceId} declared in firebase.json. Found ${serviceInfos[0].dataConnectYaml.serviceId}.` + - `\nYou can run ${clc.bold("firebase init dataconnect")} to add this Data Connect service.`, - ); - } - return serviceInfos[0]; - } else { - if (!serviceId) { - throw new FirebaseError( - "Multiple Data Connect services found in firebase.json. Please specify a service ID to use.", - ); - } - // TODO: handle cases where there are services with the same ID in 2 locations. - const maybe = serviceInfos.find((i) => i.dataConnectYaml.serviceId === serviceId); - if (!maybe) { - const serviceIds = serviceInfos.map((i) => i.dataConnectYaml.serviceId); - throw new FirebaseError( - `No service named ${serviceId} declared in firebase.json. Found ${serviceIds.join(", ")}.` + - `\nYou can run ${clc.bold("firebase init dataconnect")} to add this Data Connect service.`, - ); - } - return maybe; } + + const matchingServices = serviceInfos.filter( + (i) => + (!serviceId || i.dataConnectYaml.serviceId === serviceId) && + (!location || i.dataConnectYaml.location === location), + ); + if (matchingServices.length === 0) { + const serviceIds = serviceInfos.map( + (i) => `${i.dataConnectYaml.location}:${i.dataConnectYaml.serviceId}`, + ); + throw new FirebaseError( + `No service matched service in firebase.json. Available services: ${serviceIds.join(", ")}`, + ); + } + return matchingServices; } /** diff --git a/src/mcp/tools/dataconnect/compile.ts b/src/mcp/tools/dataconnect/compile.ts index 1213b9f1529..d0d9d8a1ed6 100644 --- a/src/mcp/tools/dataconnect/compile.ts +++ b/src/mcp/tools/dataconnect/compile.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { tool } from "../../tool"; -import { pickService } from "../../../dataconnect/load"; import { compileErrors } from "../../util/dataconnect/compile"; +import { pickServices } from "../../../dataconnect/load"; export const compile = tool( "dataconnect", @@ -18,7 +18,13 @@ export const compile = tool( .string() .optional() .describe( - "The Firebase Data Connect service ID to look for. If omitted, builds all services defined in `firebase.json`.", + `Service ID of the Data Connect service to compile. Used to disambiguate when there are multiple Data Connect services in firebase.json.`, + ), + location_id: z + .string() + .optional() + .describe( + `Data Connect Service location ID to disambiguate among multiple Data Connect services.`, ), }), annotations: { @@ -30,15 +36,26 @@ export const compile = tool( requiresAuth: false, }, }, - async ({ service_id, error_filter }, { projectId, config }) => { - const serviceInfo = await pickService(projectId, config, service_id || undefined); - const errors = await compileErrors(serviceInfo.sourceDirectory, error_filter); - if (errors) + async ({ service_id, location_id, error_filter }, { projectId, config }) => { + const serviceInfos = await pickServices( + projectId, + config, + service_id || undefined, + location_id || undefined, + ); + const errors = ( + await Promise.all( + serviceInfos.map(async (serviceInfo) => { + return await compileErrors(serviceInfo.sourceDirectory, error_filter); + }), + ) + ).flat(); + if (errors.length > 0) return { content: [ { type: "text", - text: `The following errors were encountered while compiling Data Connect from directory \`${serviceInfo.sourceDirectory}\`:\n\n${errors}`, + text: `The following errors were encountered while compiling Data Connect:\n\n${errors.join("\n")}`, }, ], isError: true, diff --git a/src/mcp/tools/dataconnect/execute.ts b/src/mcp/tools/dataconnect/execute.ts index 2e0417ef120..1be67d502df 100644 --- a/src/mcp/tools/dataconnect/execute.ts +++ b/src/mcp/tools/dataconnect/execute.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { tool } from "../../tool"; import * as dataplane from "../../../dataconnect/dataplaneClient"; -import { pickService } from "../../../dataconnect/load"; +import { pickOneService } from "../../../dataconnect/load"; import { graphqlResponseToToolResponse, parseVariables } from "../../util/dataconnect/converter"; import { getDataConnectEmulatorClient } from "../../util/dataconnect/emulator"; import { Client } from "../../../apiv2"; @@ -18,11 +18,18 @@ export const execute = tool( You can use the \`dataconnect_generate_operation\` tool to generate a query. Example Data Connect schema and example queries can be found in files ending in \`.graphql\` or \`.gql\`. `), - service_id: z.string().optional() - .describe(`Data Connect Service ID to dis-ambulate if there are multiple. -It's only necessary if there are multiple dataconnect sources in \`firebase.json\`. -You can find candidate service_id in \`dataconnect.yaml\` -`), + service_id: z + .string() + .optional() + .describe( + `Service ID of the Data Connect service to compile. Used to disambiguate when there are multiple Data Connect services in firebase.json.`, + ), + location_id: z + .string() + .optional() + .describe( + `Data Connect Service location ID to disambiguate among multiple Data Connect services.`, + ), variables_json: z .string() .optional() @@ -56,13 +63,19 @@ You can find candidate service_id in \`dataconnect.yaml\` { query, service_id, + location_id, variables_json: unparsedVariables, use_emulator, auth_token_json: unparsedAuthToken, }, { projectId, config, host }, ) => { - const serviceInfo = await pickService(projectId, config, service_id || undefined); + const serviceInfo = await pickOneService( + projectId, + config, + service_id || undefined, + location_id || undefined, + ); let apiClient: Client; if (use_emulator) { apiClient = await getDataConnectEmulatorClient(host); diff --git a/src/mcp/tools/dataconnect/generate_operation.ts b/src/mcp/tools/dataconnect/generate_operation.ts index c432675851f..00002b82a9d 100644 --- a/src/mcp/tools/dataconnect/generate_operation.ts +++ b/src/mcp/tools/dataconnect/generate_operation.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { tool } from "../../tool"; import { toContent } from "../../util"; import { generateOperation } from "../../../gemini/fdcExperience"; -import { pickService } from "../../../dataconnect/load"; +import { pickOneService } from "../../../dataconnect/load"; export const generate_operation = tool( "dataconnect", @@ -21,7 +21,13 @@ export const generate_operation = tool( .string() .optional() .describe( - "Optional: Uses the service ID from the firebase.json file if nothing provided. The service ID of the deployed Firebase resource.", + `Service ID of the Data Connect service to compile. Used to disambiguate when there are multiple Data Connect services in firebase.json.`, + ), + location_id: z + .string() + .optional() + .describe( + `Data Connect Service location ID to disambiguate among multiple Data Connect services.`, ), }), annotations: { @@ -34,8 +40,13 @@ export const generate_operation = tool( requiresGemini: true, }, }, - async ({ prompt, service_id }, { projectId, config }) => { - const serviceInfo = await pickService(projectId, config, service_id || undefined); + async ({ prompt, service_id, location_id }, { projectId, config }) => { + const serviceInfo = await pickOneService( + projectId, + config, + service_id || undefined, + location_id || undefined, + ); const schema = await generateOperation(prompt, serviceInfo.serviceName, projectId); return toContent(schema); },