diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 6a629ea694c..fc55932ae09 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -89,6 +89,11 @@ jobs: distribution: temurin - uses: actions/checkout@v4 + - name: Setup Chrome + uses: browser-actions/setup-chrome@v1.7.2 + with: + install-dependencies: true + install-chromedriver: true - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -146,7 +151,7 @@ jobs: integration: needs: unit if: contains(fromJSON('["push", "merge_group"]'), github.event_name) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 env: FIREBASE_EMULATORS_PATH: ${{ github.workspace }}/emulator-cache @@ -176,6 +181,7 @@ jobs: - npm run test:triggers-end-to-end - npm run test:triggers-end-to-end:inspect - npm run test:dataconnect-deploy + - npm run test:dataconnect-emulator steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 @@ -183,6 +189,11 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm cache-dependency-path: npm-shrinkwrap.json + - name: Setup Chrome + uses: browser-actions/setup-chrome@v1.7.2 + with: + install-dependencies: true + install-chromedriver: true - name: Cache firebase emulators uses: actions/cache@v3 with: @@ -231,6 +242,7 @@ jobs: - npm run test:storage-deploy # - npm run test:storage-emulator-integration # - npm run test:dataconnect-deploy # TODO (joehanley): Reenable this - it should be safe to run in parallel + # - npm run test:dataconnect-emulator # TODO (joehanley): Figure out why this is failing - npm run test:frameworks steps: - name: Setup Java JDK @@ -260,7 +272,7 @@ jobs: - run: ${{ matrix.script }} - name: Print debug logs if: failure() - run: type *debug.log + run: dir "*.log" /s/b | type check-package-lock: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d805d2b5509..695bbf3c996 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ src/test/emulators/extensions/firebase/storage-resize-images@0.1.18/functions/pa src/test/emulators/extensions/firebase/storage-resize-images@0.1.18/functions/package-lock.json scripts/functions-deploy-tests/**/package-lock.json scripts/functions-discover-tests/**/**/package-lock.json +.dataconnect +*-debug.log /.vscode node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index b13e382db88..e69de29bb2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +0,0 @@ -- Enable apphosting:rollouts:create command. (#8031) -- Added default value for `emulators.dataconnect.dataDir` to `init dataconnect`. -- Fixed issue where expired auth tokens would not refresh when running on IDX. -- Fixed an issue where `firebase` would error out instead of displaying help text. -- Fixed an issue where `firebase init genkit` would error on Windows machines. -- Fixed an issue where emulator returned error when emulating alerts functions written in python (#8019) -- Better error message for emulator binary architecture incompatibility on MacOS (#7995). -- Deprecated `emulators.apphosting.startCommandOverride`. Please use `emulators.apphosting.startCommand` instead. -- Updated `superstatic` to `9.1.0` in package.json. -- Updated the Firebase Data Connect local toolkit to v1.7.4, which includes a fix for an issue that caused duplicate installations of the Firebase JS SDK. (#8028) diff --git a/firebase-vscode/CHANGELOG.md b/firebase-vscode/CHANGELOG.md index 8ac9660b24b..1fdd30c52e4 100644 --- a/firebase-vscode/CHANGELOG.md +++ b/firebase-vscode/CHANGELOG.md @@ -1,5 +1,17 @@ ## NEXT +## 0.12.0 + +- Updated internal firebase-tools dependency to 13.29.1 +- [Fixed] Fixed firebase binary detection for analytics + +## 0.11.1 + +- [Fixed] Fixed IDX analytics issue + +## 0.11.0 + +- Updated internal firebase-tools dependency to 13.28.0 - [Fixed] Fixed an issue where generating an ad-hoc file would break codelenses ## 0.10.8 diff --git a/firebase-vscode/package-lock.json b/firebase-vscode/package-lock.json index 5590a66ff5b..e1822db306a 100644 --- a/firebase-vscode/package-lock.json +++ b/firebase-vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "firebase-dataconnect-vscode", - "version": "0.10.8", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firebase-dataconnect-vscode", - "version": "0.10.8", + "version": "0.12.0", "dependencies": { "@preact/signals-core": "^1.4.0", "@preact/signals-react": "1.3.6", diff --git a/firebase-vscode/package.json b/firebase-vscode/package.json index b9dd9d1a5d1..6de4ca57c69 100644 --- a/firebase-vscode/package.json +++ b/firebase-vscode/package.json @@ -4,7 +4,7 @@ "publisher": "GoogleCloudTools", "icon": "./resources/firebase_dataconnect_logo.png", "description": "Firebase Data Connect for VSCode", - "version": "0.10.8", + "version": "0.12.0", "engines": { "vscode": "^1.69.0" }, diff --git a/firebase-vscode/src/analytics.ts b/firebase-vscode/src/analytics.ts index 6ae5376a3cb..58516fd17eb 100644 --- a/firebase-vscode/src/analytics.ts +++ b/firebase-vscode/src/analytics.ts @@ -2,6 +2,7 @@ import vscode, { env, TelemetryLogger, TelemetrySender } from "vscode"; import { pluginLogger } from "./logger-wrapper"; import { AnalyticsParams, trackVSCode } from "../../src/track"; import { env as monospaceEnv } from "../src/core/env"; +import { getSettings } from "./utils/settings"; export const IDX_METRIC_NOTICE = ` When you use the Firebase Data Connect Extension, Google collects telemetry data such as usage statistics, error metrics, and crash reports. Telemetry helps us better understand how the Firebase Extension is performing, where improvements need to be made, and how features are being used. Firebase uses this data, consistent with our [Google Privacy Policy](https://policies.google.com/privacy?hl=en-US), to provide, improve, and develop Firebase products and services. @@ -35,12 +36,12 @@ export enum DATA_CONNECT_EVENT_NAME { } export class AnalyticsLogger { - readonly logger: TelemetryLogger; + readonly logger: TelemetryLogger | IDXLogger; private disposable: vscode.Disposable; private sessionCharCount = 0; // Track total chars for the session - constructor() { - this.logger = env.createTelemetryLogger( + constructor(context: vscode.ExtensionContext) { + this.logger = monospaceEnv.value.isMonospace ? new IDXLogger(new GA4TelemetrySender(pluginLogger), context) : env.createTelemetryLogger( new GA4TelemetrySender(pluginLogger), ); @@ -146,6 +147,19 @@ export class AnalyticsLogger { } } +export class IDXLogger { + constructor(private sender: GA4TelemetrySender, private context: vscode.ExtensionContext) {} + public logUsage(eventName: string, data?: any) { + const packageJson = this.context.extension.packageJSON; + data = { ...data, extversion: packageJson.version, extname: this.context.extension.id, isidx: true }; + this.sender.sendEventData(eventName, data); + } + + public logError() { + // TODO + } +} + class GA4TelemetrySender implements TelemetrySender { private hasSentData = false; constructor(readonly pluginLogger: { warn: (s: string) => void }) {} @@ -154,12 +168,6 @@ class GA4TelemetrySender implements TelemetrySender { eventName: string, data?: Record | undefined, ): void { - // telemtry flag does not exist in monospace - if (!env.isTelemetryEnabled && !monospaceEnv.value.isMonospace) { - this.pluginLogger.warn("Telemetry is not enabled."); - return; - } - // telemetry logger adds prefixes to eventName and params that are disallowed in GA4 eventName = eventName.replace( "GoogleCloudTools.firebase-dataconnect-vscode/", @@ -176,15 +184,15 @@ class GA4TelemetrySender implements TelemetrySender { } } data = { ...data }; - const idxPrepend = monospaceEnv.value.isMonospace ? "idx_" : ""; + data = addFirebaseBinaryMetadata(data); if (!this.hasSentData) { trackVSCode( - `${idxPrepend}${DATA_CONNECT_EVENT_NAME.EXTENSION_USED}`, + DATA_CONNECT_EVENT_NAME.EXTENSION_USED, data as AnalyticsParams, ); this.hasSentData = true; } - trackVSCode(`${idxPrepend}${eventName}`, data as AnalyticsParams); + trackVSCode(eventName, data as AnalyticsParams); } sendErrorData(error: Error, data?: Record | undefined): void { @@ -192,3 +200,8 @@ class GA4TelemetrySender implements TelemetrySender { // TODO: Sanatize error messages for user data } } + +function addFirebaseBinaryMetadata(data?: Record | undefined) { + const settings = getSettings(); + return { ...data, binary_kind: settings.firebaseBinaryKind }; +} diff --git a/firebase-vscode/src/core/index.ts b/firebase-vscode/src/core/index.ts index 9c20fb6567d..1b70508edd1 100644 --- a/firebase-vscode/src/core/index.ts +++ b/firebase-vscode/src/core/index.ts @@ -1,4 +1,4 @@ -import vscode, { Disposable, ExtensionContext, TelemetryLogger } from "vscode"; +import vscode, { Disposable, ExtensionContext } from "vscode"; import { ExtensionBrokerImpl } from "../extension-broker"; import { getRootFolders, registerConfig } from "./config"; import { EmulatorsController } from "./emulators"; @@ -14,11 +14,12 @@ import { upsertFile } from "../data-connect/file-utils"; import { registerWebhooks } from "./webhook"; import { createE2eMockable } from "../utils/test_hooks"; import { runTerminalTask } from "../data-connect/terminal"; +import { AnalyticsLogger } from "../analytics"; export async function registerCore( broker: ExtensionBrokerImpl, context: ExtensionContext, - telemetryLogger: TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): Promise<[EmulatorsController, vscode.Disposable]> { const settings = getSettings(); @@ -69,7 +70,7 @@ export async function registerCore( ? `${settings.firebasePath} init dataconnect --project ${currentProjectId.value}` : `${settings.firebasePath} init dataconnect`; - initSpy.call("firebase init", initCommand, {focus: true}); + initSpy.call("firebase init", initCommand, { focus: true }); }); const emulatorsController = new EmulatorsController(broker); @@ -103,8 +104,8 @@ export async function registerCore( initSpy, registerOptions(context), registerEnv(broker), - registerUser(broker, telemetryLogger), - registerProject(broker, telemetryLogger), + registerUser(broker, analyticsLogger), + registerProject(broker, analyticsLogger), registerQuickstart(broker), await registerWebhooks(), { dispose: sub1 }, diff --git a/firebase-vscode/src/core/project.ts b/firebase-vscode/src/core/project.ts index d55cc5cd240..7bca63c8b63 100644 --- a/firebase-vscode/src/core/project.ts +++ b/firebase-vscode/src/core/project.ts @@ -1,4 +1,4 @@ -import vscode, { Disposable, TelemetryLogger } from "vscode"; +import vscode, { Disposable } from "vscode"; import { ExtensionBrokerImpl } from "../extension-broker"; import { computed, effect, Signal } from "@preact/signals-react"; import { firebaseRC, updateFirebaseRCProject } from "./config"; @@ -9,7 +9,7 @@ import { pluginLogger } from "../logger-wrapper"; import { globalSignal } from "../utils/globals"; import { firstWhereDefined } from "../utils/signal"; import { User } from "../types/auth"; -import { DATA_CONNECT_EVENT_NAME } from "../analytics"; +import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../analytics"; /** Available projects */ export const projects = globalSignal>( {}, @@ -30,7 +30,7 @@ const userScopedProjects = computed( export function registerProject( broker: ExtensionBrokerImpl, - telemetryLogger: TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): Disposable { // For testing purposes. const demoProjectCommand = vscode.commands.registerCommand( @@ -103,7 +103,7 @@ export function registerProject( return; } else { try { - telemetryLogger.logUsage( + analyticsLogger.logger.logUsage( DATA_CONNECT_EVENT_NAME.PROJECT_SELECT_CLICKED, ); @@ -120,7 +120,9 @@ export function registerProject( } : undefined, }); - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.PROJECT_SELECTED); + analyticsLogger.logger.logUsage( + DATA_CONNECT_EVENT_NAME.PROJECT_SELECTED, + ); } catch (e: any) { vscode.window.showErrorMessage(e.message); } diff --git a/firebase-vscode/src/core/user.ts b/firebase-vscode/src/core/user.ts index 139bdbc9329..8485cead304 100644 --- a/firebase-vscode/src/core/user.ts +++ b/firebase-vscode/src/core/user.ts @@ -1,11 +1,11 @@ import { Signal, computed, effect } from "@preact/signals-react"; -import { Disposable, TelemetryLogger } from "vscode"; +import { Disposable } from "vscode"; import { ServiceAccountUser } from "../types"; import { User as AuthUser } from "../../../src/types/auth"; import { ExtensionBrokerImpl } from "../extension-broker"; import { login, logoutUser, requireAuthWrapper } from "../cli"; import { globalSignal } from "../utils/globals"; -import { DATA_CONNECT_EVENT_NAME } from "../analytics"; +import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../analytics"; import * as vscode from "vscode"; type User = ServiceAccountUser | AuthUser; @@ -24,7 +24,7 @@ export async function checkLogin() { export function registerUser( broker: ExtensionBrokerImpl, - telemetryLogger: TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): Disposable { // For testing purposes. const userMockCommand = vscode.commands.registerCommand( @@ -58,7 +58,7 @@ export function registerUser( }); const addUserSub = broker.on("addUser", async () => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.LOGIN); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.LOGIN); const { user } = await login(); currentUser.value = user; }); diff --git a/firebase-vscode/src/data-connect/ad-hoc-mutations.ts b/firebase-vscode/src/data-connect/ad-hoc-mutations.ts index a5de72d1e69..eaa3f3157b0 100644 --- a/firebase-vscode/src/data-connect/ad-hoc-mutations.ts +++ b/firebase-vscode/src/data-connect/ad-hoc-mutations.ts @@ -1,4 +1,4 @@ -import vscode, { Disposable, TelemetryLogger } from "vscode"; +import vscode, { Disposable } from "vscode"; import { DocumentNode, GraphQLInputField, @@ -18,10 +18,11 @@ import { DataConnectService } from "./service"; import { DATA_CONNECT_EVENT_NAME } from "../analytics"; import { dataConnectConfigs } from "./config"; import { firstWhereDefined } from "../utils/signal"; +import {AnalyticsLogger} from "../analytics"; export function registerAdHoc( dataConnectService: DataConnectService, - telemetryLogger: TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): Disposable { const defaultScalarValues = { Any: "{}", @@ -262,14 +263,14 @@ query { vscode.commands.registerCommand( "firebase.dataConnect.schemaAddData", (ast, uri) => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.ADD_DATA); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.ADD_DATA); schemaAddData(ast, uri); }, ), vscode.commands.registerCommand( "firebase.dataConnect.schemaReadData", (document, ast, uri) => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.READ_DATA); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.READ_DATA); schemaReadData(document, ast, uri); }, ), diff --git a/firebase-vscode/src/data-connect/connectors.ts b/firebase-vscode/src/data-connect/connectors.ts index 7de76c517b6..be4d3f5b816 100644 --- a/firebase-vscode/src/data-connect/connectors.ts +++ b/firebase-vscode/src/data-connect/connectors.ts @@ -3,7 +3,6 @@ import vscode, { ExtensionContext, InputBoxValidationMessage, InputBoxValidationSeverity, - TelemetryLogger, } from "vscode"; import { ExtensionBrokerImpl } from "../extension-broker"; import { @@ -39,13 +38,13 @@ import { DataConnectService } from "./service"; import { OperationLocation } from "./types"; import { checkIfFileExists } from "./file-utils"; import * as path from "path"; -import { DATA_CONNECT_EVENT_NAME } from "../analytics"; +import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../analytics"; export function registerConnectors( context: ExtensionContext, broker: ExtensionBrokerImpl, dataConnectService: DataConnectService, - telemetryLogger: TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): Disposable { async function moveOperationToConnector( defIndex: number, // The index of the definition to move. @@ -477,7 +476,9 @@ export function registerConnectors( vscode.commands.registerCommand( "firebase.dataConnect.moveOperationToConnector", (number, location, connectorPath) => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.MOVE_TO_CONNECTOR); + analyticsLogger.logger.logUsage( + DATA_CONNECT_EVENT_NAME.MOVE_TO_CONNECTOR, + ); moveOperationToConnector(number, location, connectorPath); }, ), diff --git a/firebase-vscode/src/data-connect/deploy.ts b/firebase-vscode/src/data-connect/deploy.ts index a199b723ffd..e15877503e7 100644 --- a/firebase-vscode/src/data-connect/deploy.ts +++ b/firebase-vscode/src/data-connect/deploy.ts @@ -5,7 +5,7 @@ import { dataConnectConfigs } from "./config"; import { createE2eMockable } from "../utils/test_hooks"; import { runCommand } from "./terminal"; import { ExtensionBrokerImpl } from "../extension-broker"; -import { DATA_CONNECT_EVENT_NAME } from "../analytics"; +import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../analytics"; import { getSettings } from "../utils/settings"; function createDeployOnlyCommand(serviceConnectorMap: { @@ -28,7 +28,7 @@ function createDeployOnlyCommand(serviceConnectorMap: { export function registerFdcDeploy( broker: ExtensionBrokerImpl, - telemetryLogger: vscode.TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): vscode.Disposable { const settings = getSettings(); @@ -42,16 +42,12 @@ export function registerFdcDeploy( ); const deployAllCmd = vscode.commands.registerCommand("fdc.deploy-all", () => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.DEPLOY_ALL, { - firebase_binary_kind: settings.firebaseBinaryKind, - }); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.DEPLOY_ALL); deploySpy.call(`${settings.firebasePath} deploy --only dataconnect`); }); const deployCmd = vscode.commands.registerCommand("fdc.deploy", async () => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.DEPLOY_INDIVIDUAL, { - firebase_binary_kind: settings.firebaseBinaryKind, - }); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.DEPLOY_INDIVIDUAL); const configs = await firstWhereDefined(dataConnectConfigs).then( (c) => c.requireValue, ); diff --git a/firebase-vscode/src/data-connect/execution.ts b/firebase-vscode/src/data-connect/execution.ts index 1a2da686552..25d2282d1b2 100644 --- a/firebase-vscode/src/data-connect/execution.ts +++ b/firebase-vscode/src/data-connect/execution.ts @@ -2,7 +2,6 @@ import vscode, { ConfigurationTarget, Disposable, ExtensionContext, - TelemetryLogger, } from "vscode"; import { ExtensionBrokerImpl } from "../extension-broker"; import { registerWebview } from "../webview"; @@ -23,13 +22,13 @@ import { DataConnectService } from "./service"; import { DataConnectError, toSerializedError } from "../../common/error"; import { OperationLocation } from "./types"; import { InstanceType } from "./code-lens-provider"; -import { DATA_CONNECT_EVENT_NAME } from "../analytics"; +import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../analytics"; export function registerExecution( context: ExtensionContext, broker: ExtensionBrokerImpl, dataConnectService: DataConnectService, - telemetryLogger: TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): Disposable { const treeDataProvider = new ExecutionHistoryTreeDataProvider(); const executionHistoryTreeView = vscode.window.createTreeView( @@ -190,7 +189,7 @@ export function registerExecution( vscode.commands.registerCommand( "firebase.dataConnect.executeOperation", (ast, location, instanceType: InstanceType) => { - telemetryLogger.logUsage( + analyticsLogger.logger.logUsage( instanceType === InstanceType.LOCAL ? DATA_CONNECT_EVENT_NAME.RUN_LOCAL : DATA_CONNECT_EVENT_NAME.RUN_PROD, diff --git a/firebase-vscode/src/data-connect/index.ts b/firebase-vscode/src/data-connect/index.ts index d7a155d5204..ba2f75c227c 100644 --- a/firebase-vscode/src/data-connect/index.ts +++ b/firebase-vscode/src/data-connect/index.ts @@ -28,10 +28,10 @@ import { Result } from "../result"; import { LanguageClient } from "vscode-languageclient/node"; import { registerTerminalTasks } from "./terminal"; import { registerWebview } from "../webview"; - import { DataConnectToolkit } from "./toolkit"; import { registerFdcSdkGeneration } from "./sdk-generation"; import { registerDiagnostics } from "./diagnostics"; +import { AnalyticsLogger } from "../analytics"; class CodeActionsProvider implements vscode.CodeActionProvider { constructor( @@ -133,7 +133,7 @@ export function registerFdc( broker: ExtensionBrokerImpl, authService: AuthService, emulatorController: EmulatorsController, - telemetryLogger: TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): Disposable { registerDiagnostics(context, dataConnectConfigs); const dataConnectToolkit = new DataConnectToolkit(broker); @@ -222,14 +222,14 @@ export function registerFdc( selectedProjectStatus.show(); }), }, - registerExecution(context, broker, fdcService, telemetryLogger), + registerExecution(context, broker, fdcService, analyticsLogger), registerExplorer(context, broker, fdcService), registerWebview({ name: "data-connect", context, broker }), - registerAdHoc(fdcService, telemetryLogger), - registerConnectors(context, broker, fdcService, telemetryLogger), - registerFdcDeploy(broker, telemetryLogger), - registerFdcSdkGeneration(broker, telemetryLogger), - registerTerminalTasks(broker, telemetryLogger), + registerAdHoc(fdcService, analyticsLogger), + registerConnectors(context, broker, fdcService, analyticsLogger), + registerFdcDeploy(broker, analyticsLogger), + registerFdcSdkGeneration(broker, analyticsLogger), + registerTerminalTasks(broker, analyticsLogger), operationCodeLensProvider, vscode.languages.registerCodeLensProvider( // **Hack**: For testing purposes, enable code lenses on all graphql files diff --git a/firebase-vscode/src/data-connect/sdk-generation.ts b/firebase-vscode/src/data-connect/sdk-generation.ts index 4c1ec5ba69c..abf9b1a20cb 100644 --- a/firebase-vscode/src/data-connect/sdk-generation.ts +++ b/firebase-vscode/src/data-connect/sdk-generation.ts @@ -16,10 +16,11 @@ import { generateSdkYaml, } from "../../../src/init/features/dataconnect/sdk"; import { createE2eMockable } from "../utils/test_hooks"; +import { AnalyticsLogger} from "../analytics"; export function registerFdcSdkGeneration( broker: ExtensionBrokerImpl, - telemetryLogger: vscode.TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): vscode.Disposable { const settings = getSettings(); @@ -37,9 +38,7 @@ export function registerFdcSdkGeneration( const initSdkCmd = vscode.commands.registerCommand( "fdc.init-sdk", (args: { appFolder: string }) => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.INIT_SDK_CLI, { - firebase_binary_kind: settings.firebaseBinaryKind, - }); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.INIT_SDK_CLI); // Lets do it from the right directory setTerminalEnvVars(FDC_APP_FOLDER, args.appFolder); runCommand(`${settings.firebasePath} init dataconnect:sdk`); @@ -50,7 +49,9 @@ export function registerFdcSdkGeneration( const configureSDKCodelense = vscode.commands.registerCommand( "fdc.connector.configure-sdk", async (connectorConfig) => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.INIT_SDK_CODELENSE); + analyticsLogger.logger.logUsage( + DATA_CONNECT_EVENT_NAME.INIT_SDK_CODELENSE, + ); const configs = await firstWhereDefined(dataConnectConfigs).then( (c) => c.requireValue, ); @@ -62,7 +63,7 @@ export function registerFdcSdkGeneration( const configureSDK = vscode.commands.registerCommand( "fdc.configure-sdk", async () => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.INIT_SDK); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.INIT_SDK); const configs = await firstWhereDefined(dataConnectConfigs).then( (c) => c.requireValue, ); diff --git a/firebase-vscode/src/data-connect/terminal.ts b/firebase-vscode/src/data-connect/terminal.ts index 0de7382da8a..d09c8ce73b0 100644 --- a/firebase-vscode/src/data-connect/terminal.ts +++ b/firebase-vscode/src/data-connect/terminal.ts @@ -2,7 +2,7 @@ import { TelemetryLogger, TerminalOptions } from "vscode"; import { ExtensionBrokerImpl } from "../extension-broker"; import vscode, { Disposable } from "vscode"; import { checkLogin } from "../core/user"; -import { DATA_CONNECT_EVENT_NAME } from "../analytics"; +import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../analytics"; import { getSettings } from "../utils/settings"; import { currentProjectId } from "../core/project"; @@ -69,14 +69,12 @@ export function runTerminalTask( export function registerTerminalTasks( broker: ExtensionBrokerImpl, - telemetryLogger: TelemetryLogger, + analyticsLogger: AnalyticsLogger, ): Disposable { const settings = getSettings(); const loginTaskBroker = broker.on("executeLogin", () => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.IDX_LOGIN, { - firebase_binary_kind: settings.firebaseBinaryKind, - }); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.IDX_LOGIN); runTerminalTask( "firebase login", `${settings.firebasePath} login --no-localhost`, @@ -86,9 +84,7 @@ export function registerTerminalTasks( }); const startEmulatorsTaskBroker = broker.on("runStartEmulators", () => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.START_EMULATORS, { - firebase_binary_kind: settings.firebaseBinaryKind, - }); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.START_EMULATORS); // TODO: optional debug mode runTerminalTask( "firebase emulators", @@ -104,9 +100,12 @@ export function registerTerminalTasks( vscode.commands.registerCommand( "firebase.dataConnect.runTerminalTask", (taskName, command) => { - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.COMMAND_EXECUTION, { - commandName: command, - }); + analyticsLogger.logger.logUsage( + DATA_CONNECT_EVENT_NAME.COMMAND_EXECUTION, + { + commandName: command, + }, + ); runTerminalTask(taskName, command); }, ), diff --git a/firebase-vscode/src/extension.ts b/firebase-vscode/src/extension.ts index 8644eddbac0..d74ef6f6831 100644 --- a/firebase-vscode/src/extension.ts +++ b/firebase-vscode/src/extension.ts @@ -20,7 +20,6 @@ import { registerFdc } from "./data-connect"; import { AuthService } from "./auth/service"; import { AnalyticsLogger, - DATA_CONNECT_EVENT_NAME, IDX_METRIC_NOTICE, } from "./analytics"; import { env } from "./core/env"; @@ -29,12 +28,12 @@ import { suggestGraphqlSyntaxExtension } from "./data-connect/graphql-syntax-hig // This method is called when your extension is activated export async function activate(context: vscode.ExtensionContext) { - const analyticsLogger = new AnalyticsLogger(); + const analyticsLogger = new AnalyticsLogger(context); // Suggest installing the GraphQL syntax highlighter extension await suggestGraphqlSyntaxExtension(); - await setupFirebasePath(analyticsLogger.logger); + await setupFirebasePath(analyticsLogger); const settings = getSettings(); logSetup(); pluginLogger.debug("Activating Firebase extension."); @@ -60,7 +59,7 @@ export async function activate(context: vscode.ExtensionContext) { const [emulatorsController, coreDisposable] = await registerCore( broker, context, - analyticsLogger.logger, + analyticsLogger, ); context.subscriptions.push( @@ -78,7 +77,7 @@ export async function activate(context: vscode.ExtensionContext) { broker, authService, emulatorsController, - analyticsLogger.logger, + analyticsLogger, ), ); } diff --git a/firebase-vscode/src/logger-wrapper.ts b/firebase-vscode/src/logger-wrapper.ts index 4b453b8fbb4..20cd3c11023 100644 --- a/firebase-vscode/src/logger-wrapper.ts +++ b/firebase-vscode/src/logger-wrapper.ts @@ -44,28 +44,37 @@ export function logSetup() { // Log to file // Only log to file if firebase.debug extension setting is true. - // Re-implement file logger call from ../../src/bin/firebase.ts to not bring - // in the entire firebase.ts file - const rootFolders = getRootFolders(); - // Default to a central path, but write files to a local path if we're in a Firebase directory. - let filePath = path.join(os.homedir(), ".cache", "firebase", "logs", "vsce-debug.log"); - if (fs.existsSync(path.join(rootFolders[0], "firebase.json"))) { - filePath = path.join(rootFolders[0], ".firebase", "logs", "vsce-debug.log"); - } - pluginLogger.info("Logging to path", filePath); - cliLogger.add( - new transports.File({ - level: "debug", - filename: filePath, - format: format.printf((info) => { - const segments = [info.message, ...(info[SPLAT] || [])].map( - tryStringify, - ); - return `[${info.level}] ${stripVTControlCharacters(segments.join(" "))}`; - }), + // Re-implement file logger call from ../../src/bin/firebase.ts to not bring + // in the entire firebase.ts file + const rootFolders = getRootFolders(); + // Default to a central path, but write files to a local path if we're in a Firebase directory. + let filePath = path.join( + os.homedir(), + ".cache", + "firebase", + "logs", + "vsce-debug.log", + ); + if ( + rootFolders.length > 0 && + fs.existsSync(path.join(rootFolders[0], "firebase.json")) + ) { + filePath = path.join(rootFolders[0], ".firebase", "logs", "vsce-debug.log"); + } + pluginLogger.info("Logging to path", filePath); + cliLogger.add( + new transports.File({ + level: "debug", + filename: filePath, + format: format.printf((info) => { + const segments = [info.message, ...(info[SPLAT] || [])].map( + tryStringify, + ); + return `[${info.level}] ${stripVTControlCharacters(segments.join(" "))}`; }), - ); - cliLogger.add(new VSCodeOutputTransport({ level: "info" })); + }), + ); + cliLogger.add(new VSCodeOutputTransport({ level: "info" })); } /** diff --git a/firebase-vscode/src/utils/settings.ts b/firebase-vscode/src/utils/settings.ts index 3f9d3ac1b44..a1ba94a1568 100644 --- a/firebase-vscode/src/utils/settings.ts +++ b/firebase-vscode/src/utils/settings.ts @@ -1,6 +1,5 @@ -import * as vscode from "vscode"; -import { ConfigurationTarget, window, workspace } from "vscode"; -import { DATA_CONNECT_EVENT_NAME } from "../analytics"; +import { ConfigurationTarget, workspace } from "vscode"; +import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../analytics"; export interface Settings { readonly firebasePath: string; @@ -47,7 +46,7 @@ export function updateIdxSetting(shouldShow: boolean) { } // Persist env var as path setting when path setting doesn't exist -export function setupFirebasePath(telemetryLogger: vscode.TelemetryLogger) { +export function setupFirebasePath(analyticsLogger: AnalyticsLogger) { const config = workspace.getConfiguration("firebase"); if (process.env.FIREBASE_BINARY && !config.get("firebasePath")) { config.update( @@ -56,5 +55,5 @@ export function setupFirebasePath(telemetryLogger: vscode.TelemetryLogger) { ConfigurationTarget.Global, ); } - telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.SETUP_FIREBASE_BINARY); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.SETUP_FIREBASE_BINARY); } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 90d83e18542..81d46dac925 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "firebase-tools", - "version": "13.27.0", + "version": "13.29.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "firebase-tools", - "version": "13.27.0", + "version": "13.29.1", "license": "MIT", "dependencies": { "@electric-sql/pglite": "^0.2.0", diff --git a/package.json b/package.json index 23c0e7df5b6..b25f40497f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebase-tools", - "version": "13.27.0", + "version": "13.29.1", "description": "Command-Line Interface for Firebase", "main": "./lib/index.js", "bin": { @@ -29,6 +29,7 @@ "test:client-integration": "bash ./scripts/client-integration-tests/run.sh", "test:compile": "tsc --project tsconfig.compile.json", "test:dataconnect-deploy": "bash ./scripts/dataconnect-test/run.sh", + "test:dataconnect-emulator": "bash ./scripts/dataconnect-emulator-tests/run.sh", "test:all-emulators": "npm run test:emulator && npm run test:extensions-emulator && npm run test:import-export && npm run test:storage-emulator-integration", "test:emulator": "bash ./scripts/emulator-tests/run.sh", "test:extensions-deploy": "bash ./scripts/extensions-deploy-tests/run.sh", diff --git a/scripts/clean-install.sh b/scripts/clean-install.sh index ce66373508f..dcc9f2a5fa3 100755 --- a/scripts/clean-install.sh +++ b/scripts/clean-install.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -ex function cleanup() { echo "Cleaning up artifacts..." diff --git a/scripts/dataconnect-emulator-tests/fdc-test/connector/connector.yaml b/scripts/dataconnect-emulator-tests/fdc-test/connector/connector.yaml new file mode 100644 index 00000000000..68215053ca4 --- /dev/null +++ b/scripts/dataconnect-emulator-tests/fdc-test/connector/connector.yaml @@ -0,0 +1,2 @@ +connectorId: "connectorId" +authMode: "PUBLIC" diff --git a/scripts/dataconnect-emulator-tests/fdc-test/connector/queries.gql b/scripts/dataconnect-emulator-tests/fdc-test/connector/queries.gql new file mode 100644 index 00000000000..51e05570ca5 --- /dev/null +++ b/scripts/dataconnect-emulator-tests/fdc-test/connector/queries.gql @@ -0,0 +1,3 @@ +mutation createOrder($name: String!) { + order_insert(data : {name: $name}) +} diff --git a/scripts/dataconnect-emulator-tests/fdc-test/dataconnect.yaml b/scripts/dataconnect-emulator-tests/fdc-test/dataconnect.yaml new file mode 100644 index 00000000000..6a2d2fe1c2a --- /dev/null +++ b/scripts/dataconnect-emulator-tests/fdc-test/dataconnect.yaml @@ -0,0 +1,11 @@ +specVersion: "v1beta" +serviceId: "fake-service" +location: "us-central1" +schema: + source: "./schema" + datasource: + postgresql: + database: "postgres" + cloudSql: + instanceId: "dataconnect-test" +connectorDirs: ["./connector"] diff --git a/scripts/dataconnect-emulator-tests/fdc-test/schema/schema.gql b/scripts/dataconnect-emulator-tests/fdc-test/schema/schema.gql new file mode 100644 index 00000000000..b6ea799498c --- /dev/null +++ b/scripts/dataconnect-emulator-tests/fdc-test/schema/schema.gql @@ -0,0 +1,3 @@ +type Order @table { + name: String! +} diff --git a/scripts/dataconnect-emulator-tests/firebase.json b/scripts/dataconnect-emulator-tests/firebase.json new file mode 100644 index 00000000000..bb70b69cbe2 --- /dev/null +++ b/scripts/dataconnect-emulator-tests/firebase.json @@ -0,0 +1,5 @@ +{ + "dataconnect": { + "source": "fdc-test" + } +} diff --git a/scripts/dataconnect-emulator-tests/run.sh b/scripts/dataconnect-emulator-tests/run.sh new file mode 100644 index 00000000000..fcb35afa786 --- /dev/null +++ b/scripts/dataconnect-emulator-tests/run.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -ex # Immediately exit on failure +# Globally link the CLI for the testing framework +./scripts/clean-install.sh +source scripts/set-default-credentials.sh + +echo "Running in ${CWD}" +echo "Running with node: $(which node)" +echo "Running with npm: $(which npm)" +echo "Running with Application Creds: ${GOOGLE_APPLICATION_CREDENTIALS}" + +cd scripts/dataconnect-emulator-tests +firebase emulators:exec "cd ." --only dataconnect -P demo-test +# rm -rf ../../clean diff --git a/scripts/storage-emulator-integration/run.sh b/scripts/storage-emulator-integration/run.sh index f2152963321..1b19adf5fef 100755 --- a/scripts/storage-emulator-integration/run.sh +++ b/scripts/storage-emulator-integration/run.sh @@ -12,10 +12,19 @@ firebase setup:emulators:storage mocha scripts/storage-emulator-integration/internal/tests.ts +# Brief sleep between tests to make sure emulators shut down fully. +sleep 5 + mocha scripts/storage-emulator-integration/rules/*.test.ts +sleep 5 + mocha scripts/storage-emulator-integration/import/tests.ts +sleep 5 + mocha scripts/storage-emulator-integration/multiple-targets/tests.ts +sleep 5 + mocha scripts/storage-emulator-integration/conformance/*.test.ts diff --git a/src/commands/appdistribution-distribute.ts b/src/commands/appdistribution-distribute.ts index c5c4e6d5ed2..61fb435fb2c 100644 --- a/src/commands/appdistribution-distribute.ts +++ b/src/commands/appdistribution-distribute.ts @@ -94,7 +94,7 @@ export const command = new Command("appdistribution:distribute { + if (!fs.existsSync(sourceDir)) { + return []; + } const files = await fs.readdir(sourceDir); // TODO: Handle files in subdirectories such as `foo/a.gql` and `bar/baz/b.gql`. return files diff --git a/src/dataconnect/freeTrial.ts b/src/dataconnect/freeTrial.ts index c4d3841defb..1a5f2fd910f 100644 --- a/src/dataconnect/freeTrial.ts +++ b/src/dataconnect/freeTrial.ts @@ -35,6 +35,13 @@ export async function getFreeTrialInstanceId(projectId: string): Promise i.settings.userLabels?.["firebase-data-connect"] === "ft")?.name; } +export async function isFreeTrialError(err: any, projectId: string): Promise { + // checkFreeTrialInstanceUsed is also called to ensure the request didn't fail due to an unrelated quota issue. + return err.message.includes("Quota Exhausted") && (await checkFreeTrialInstanceUsed(projectId)) + ? true + : false; +} + export function printFreeTrialUnavailable( projectId: string, configYamlPath: string, @@ -42,26 +49,26 @@ export function printFreeTrialUnavailable( ): void { if (!instanceId) { utils.logLabeledError( - "data connect", + "dataconnect", "The CloudSQL free trial has already been used on this project.", ); utils.logLabeledError( - "data connect", + "dataconnect", `You may create or use a paid CloudSQL instance by visiting https://console.cloud.google.com/sql/instances`, ); return; } utils.logLabeledError( - "data connect", + "dataconnect", `Project '${projectId} already has a CloudSQL instance '${instanceId}' on the Firebase Data Connect no-cost trial.`, ); const reuseHint = `To use a different database in the same instance, ${clc.bold(`change the ${clc.blue("instanceId")} to "${instanceId}"`)} and update ${clc.blue("location")} in ` + `${clc.green(configYamlPath)}.`; - utils.logLabeledError("data connect", reuseHint); + utils.logLabeledError("dataconnect", reuseHint); utils.logLabeledError( - "data connect", + "dataconnect", `Alternatively, you may create a new (paid) CloudSQL instance at https://console.cloud.google.com/sql/instances`, ); } diff --git a/src/dataconnect/provisionCloudSql.ts b/src/dataconnect/provisionCloudSql.ts index 57993b9ae44..20b63907308 100755 --- a/src/dataconnect/provisionCloudSql.ts +++ b/src/dataconnect/provisionCloudSql.ts @@ -11,7 +11,7 @@ import { getFreeTrialInstanceId, freeTrialTermsLink, printFreeTrialUnavailable, - checkFreeTrialInstanceUsed, + isFreeTrialError, } from "./freeTrial"; import { FirebaseError } from "../error"; @@ -69,11 +69,6 @@ export async function provisionCloudSql(args: { if (err.status !== 404) { throw err; } - const freeTrialInstanceId = await getFreeTrialInstanceId(projectId); - if (await checkFreeTrialInstanceUsed(projectId)) { - printFreeTrialUnavailable(projectId, configYamlPath, freeTrialInstanceId); - throw new FirebaseError("No-cost Cloud SQL trial has already been used on this project."); - } const cta = dryRun ? "It will be created on your next deploy" : "Creating it now."; silent || utils.logLabeledBullet( @@ -84,27 +79,36 @@ export async function provisionCloudSql(args: { `\nMonitor the progress at ${cloudSqlAdminClient.instanceConsoleLink(projectId, instanceId)}`, ); if (!dryRun) { - const newInstance = await promiseWithSpinner( - () => - cloudSqlAdminClient.createInstance( - projectId, - locationId, - instanceId, - enableGoogleMlIntegration, - waitForCreation, - ), - "Creating your instance...", - ); - if (newInstance) { - silent || utils.logLabeledBullet("dataconnect", "Instance created"); - connectionName = newInstance?.connectionName || ""; - } else { - silent || - utils.logLabeledBullet( - "dataconnect", - "Cloud SQL instance creation started - it should be ready shortly. Database and users will be created on your next deploy.", - ); - return connectionName; + try { + const newInstance = await promiseWithSpinner( + () => + cloudSqlAdminClient.createInstance( + projectId, + locationId, + instanceId, + enableGoogleMlIntegration, + waitForCreation, + ), + "Creating your instance...", + ); + if (newInstance) { + silent || utils.logLabeledBullet("dataconnect", "Instance created"); + connectionName = newInstance?.connectionName || ""; + } else { + silent || + utils.logLabeledBullet( + "dataconnect", + "Cloud SQL instance creation started - it should be ready shortly. Database and users will be created on your next deploy.", + ); + return connectionName; + } + } catch (err: any) { + if (await isFreeTrialError(err, projectId)) { + const freeTrialInstanceId = await getFreeTrialInstanceId(projectId); + printFreeTrialUnavailable(projectId, configYamlPath, freeTrialInstanceId); + throw new FirebaseError("No-cost Cloud SQL trial has already been used on this project."); + } + throw err; } } } diff --git a/src/dataconnect/schemaMigration.ts b/src/dataconnect/schemaMigration.ts index 1741a35bd5a..c6c05fb76fe 100644 --- a/src/dataconnect/schemaMigration.ts +++ b/src/dataconnect/schemaMigration.ts @@ -543,6 +543,7 @@ async function ensureServiceIsConnectedToCloudSql( { postgresql: { database: databaseId, + schemaValidation: "NONE", cloudSql: { instance: instanceId, }, @@ -566,7 +567,8 @@ async function ensureServiceIsConnectedToCloudSql( `Switching connected Postgres database from ${postgresql?.database} to ${databaseId}`, ); } - if (!postgresql || postgresql.schemaValidation === "STRICT") { + if (!postgresql || postgresql.schemaValidation !== "NONE") { + // Skip provisioning connectvity if it is already connected. return; } postgresql.schemaValidation = "STRICT"; diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 0b5e4cd041a..113d1ac09dd 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -4,7 +4,7 @@ import * as utils from "../../utils"; import { Runtime } from "./runtimes/supported"; import { FirebaseError } from "../../error"; import { Context } from "./args"; -import { flattenArray } from "../../functional"; +import { assertExhaustive, flattenArray } from "../../functional"; /** Retry settings for a ScheduleSpec. */ export interface ScheduleRetryConfig { @@ -41,7 +41,9 @@ export interface HttpsTriggered { } /** API agnostic version of a Firebase callable function. */ -export type CallableTrigger = Record; +export type CallableTrigger = { + genkitAction?: string; +}; /** Something that has a callable trigger */ export interface CallableTriggered { @@ -135,6 +137,7 @@ export interface BlockingTrigger { eventType: string; options?: Record; } + export interface BlockingTriggered { blockingTrigger: BlockingTrigger; } @@ -153,9 +156,8 @@ export function endpointTriggerType(endpoint: Endpoint): string { return "taskQueue"; } else if (isBlockingTriggered(endpoint)) { return endpoint.blockingTrigger.eventType; - } else { - throw new Error("Unexpected trigger type for endpoint " + JSON.stringify(endpoint)); } + assertExhaustive(endpoint); } // TODO(inlined): Enum types should be singularly named diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index 6dc379c545d..4e5a0e5f6ec 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -74,8 +74,9 @@ export interface HttpsTrigger { // Trigger definitions for RPCs servers using the HTTP protocol defined at // https://firebase.google.com/docs/functions/callable-reference -// eslint-disable-next-line -interface CallableTrigger {} +interface CallableTrigger { + genkitAction?: string; +} // Trigger definitions for endpoints that should be called as a delegate for other operations. // For example, before user login. @@ -568,7 +569,9 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe } return { httpsTrigger }; } else if (isCallableTriggered(endpoint)) { - return { callableTrigger: {} }; + const trigger: CallableTriggered = { callableTrigger: {} }; + proto.copyIfPresent(trigger.callableTrigger, endpoint.callableTrigger, "genkitAction"); + return trigger; } else if (isBlockingTriggered(endpoint)) { return { blockingTrigger: endpoint.blockingTrigger }; } else if (isEventTriggered(endpoint)) { diff --git a/src/deploy/functions/release/planner.spec.ts b/src/deploy/functions/release/planner.spec.ts index 0e8de3127a3..80be985ef9f 100644 --- a/src/deploy/functions/release/planner.spec.ts +++ b/src/deploy/functions/release/planner.spec.ts @@ -46,6 +46,16 @@ describe("planner", () => { expect(() => planner.calculateUpdate(httpsFunc, scheduleFunc)).to.throw(); }); + it("allows upgrades of genkit functions from the genkit plugin to firebase-functions SDK", () => { + const httpsFunc = func("a", "b", { httpsTrigger: {} }); + const genkitFunc = func("a", "b", { callableTrigger: { genkitAction: "flows/flow" } }); + expect(planner.calculateUpdate(genkitFunc, httpsFunc)).to.deep.equal({ + // Missing: deleteAndRecreate + endpoint: genkitFunc, + unsafe: false, + }); + }); + it("knows to delete & recreate for v2 topic changes", () => { const original: backend.Endpoint = { ...func("a", "b", { diff --git a/src/deploy/functions/release/planner.ts b/src/deploy/functions/release/planner.ts index 74a137b24d0..9ae8872e215 100644 --- a/src/deploy/functions/release/planner.ts +++ b/src/deploy/functions/release/planner.ts @@ -8,10 +8,6 @@ import { FirebaseError } from "../../../error"; import * as utils from "../../../utils"; import * as backend from "../backend"; import * as v2events from "../../../functions/events/v2"; -import { - FIRESTORE_EVENT_REGEX, - FIRESTORE_EVENT_WITH_AUTH_CONTEXT_REGEX, -} from "../../../functions/events/v2"; export interface EndpointUpdate { endpoint: backend.Endpoint; @@ -261,9 +257,9 @@ export function upgradedScheduleFromV1ToV2( export function checkForUnsafeUpdate(want: backend.Endpoint, have: backend.Endpoint): boolean { return ( backend.isEventTriggered(want) && - FIRESTORE_EVENT_WITH_AUTH_CONTEXT_REGEX.test(want.eventTrigger.eventType) && backend.isEventTriggered(have) && - FIRESTORE_EVENT_REGEX.test(have.eventTrigger.eventType) + want.eventTrigger.eventType === + v2events.CONVERTABLE_EVENTS[have.eventTrigger.eventType as v2events.Event] ); } @@ -289,7 +285,12 @@ export function checkForIllegalUpdate(want: backend.Endpoint, have: backend.Endp }; const wantType = triggerType(want); const haveType = triggerType(have); - if (wantType !== haveType) { + + // Originally, @genkit-ai/firebase/functions defined onFlow which created an HTTPS trigger that implemented the streaming callable protocol for the Flow. + // The new version is firebase-functions/https which defines onCallFlow + const upgradingHttpsFunction = + backend.isHttpsTriggered(have) && backend.isCallableTriggered(want); + if (wantType !== haveType && !upgradingHttpsFunction) { throw new FirebaseError( `[${getFunctionLabel( want, diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts index bba3b9b0484..f66349b0699 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts @@ -162,6 +162,35 @@ describe("buildFromV1Alpha", () => { }); }); + describe("genkitTriggers", () => { + it("fails with invalid fields", () => { + assertParserError({ + endpoints: { + func: { + ...MIN_ENDPOINT, + genkitTrigger: { + tool: "tools are not supported", + }, + }, + }, + }); + }); + + it("cannot be used with 1st gen", () => { + assertParserError({ + endpoints: { + func: { + ...MIN_ENDPOINT, + platform: "gcfv1", + genkitTrigger: { + flow: "agent", + }, + }, + }, + }); + }); + }); + describe("scheduleTriggers", () => { const validTrigger: build.ScheduleTrigger = { schedule: "every 5 minutes", diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index cc3a6e00c42..0436335d364 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -214,7 +214,9 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void { invoker: "array?", }); } else if (build.isCallableTriggered(ep)) { - // no-op + assertKeyTypes(prefix + ".callableTrigger", ep.callableTrigger, { + genkitAction: "string?", + }); } else if (build.isScheduleTriggered(ep)) { assertKeyTypes(prefix + ".scheduleTrigger", ep.scheduleTrigger, { schedule: "Field", @@ -263,6 +265,7 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void { options: "object", }); } else { + // TODO: Replace with assertExhaustive, which needs some type magic here because we have an any throw new FirebaseError( `Do not recognize trigger type for endpoint ${id}. Try upgrading ` + "firebase-tools with npm install -g firebase-tools@latest", @@ -310,6 +313,7 @@ function parseEndpointForBuild( copyIfPresent(triggered.httpsTrigger, ep.httpsTrigger, "invoker"); } else if (build.isCallableTriggered(ep)) { triggered = { callableTrigger: {} }; + copyIfPresent(triggered.callableTrigger, ep.callableTrigger, "genkitAction"); } else if (build.isScheduleTriggered(ep)) { const st: build.ScheduleTrigger = { // TODO: consider adding validation for fields like this that reject diff --git a/src/deploy/functions/runtimes/supported/types.ts b/src/deploy/functions/runtimes/supported/types.ts index 36680c62209..bc741ec2455 100644 --- a/src/deploy/functions/runtimes/supported/types.ts +++ b/src/deploy/functions/runtimes/supported/types.ts @@ -85,7 +85,7 @@ export const RUNTIMES = runtimes({ }, nodejs22: { friendly: "Node.js 22", - status: "beta", + status: "GA", deprecationDate: "2027-04-30", decommissionDate: "2027-10-31", }, diff --git a/src/emulator/auth/operations.ts b/src/emulator/auth/operations.ts index 7d03b52ab90..4bc93c36b3c 100644 --- a/src/emulator/auth/operations.ts +++ b/src/emulator/auth/operations.ts @@ -1030,11 +1030,7 @@ export function setAccountInfoImpl( { privileged = false, emulatorUrl = undefined }: { privileged?: boolean; emulatorUrl?: URL } = {}, ): Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoResponse"] { // TODO: Implement these. - const unimplementedFields: (keyof typeof reqBody)[] = [ - "provider", - "upgradeToFederatedLogin", - "linkProviderUserInfo", - ]; + const unimplementedFields: (keyof typeof reqBody)[] = ["provider", "upgradeToFederatedLogin"]; for (const field of unimplementedFields) { if (field in reqBody) { throw new NotImplementedError(`${field} is not implemented yet.`); @@ -1232,8 +1228,16 @@ export function setAccountInfoImpl( } } + if (reqBody.linkProviderUserInfo) { + assert(reqBody.linkProviderUserInfo.providerId, "MISSING_PROVIDER_ID"); + assert(reqBody.linkProviderUserInfo.rawId, "MISSING_RAW_ID"); + } + user = state.updateUserByLocalId(user.localId, updates, { deleteProviders: reqBody.deleteProvider, + upsertProviders: reqBody.linkProviderUserInfo + ? [reqBody.linkProviderUserInfo as ProviderUserInfo] + : undefined, }); // Only initiate the recover email OOB flow for non-anonymous users diff --git a/src/emulator/auth/setAccountInfo.spec.ts b/src/emulator/auth/setAccountInfo.spec.ts index 719a012d8eb..6d2fe4481bc 100644 --- a/src/emulator/auth/setAccountInfo.spec.ts +++ b/src/emulator/auth/setAccountInfo.spec.ts @@ -1254,4 +1254,100 @@ describeAuthEmulator("accounts:update", ({ authApi, getClock }) => { expect(oobs[0].requestType).to.equal("RECOVER_EMAIL"); expect(oobs[0].oobLink).to.include(tenant.tenantId); }); + + it("should link provider account with existing user account", async () => { + const { idToken } = await registerUser(authApi(), { + email: "test@example.com", + password: "password", + }); + + const providerId = "google.com"; + const rawId = "google_user_id"; + const providerUserInfo = { + providerId, + rawId, + email: "linked@example.com", + displayName: "Linked User", + photoUrl: "https://example.com/photo.jpg", + }; + + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:update") + .query({ key: "fake-api-key" }) + .send({ idToken, linkProviderUserInfo: providerUserInfo }) + .then((res) => { + expectStatusCode(200, res); + const providers = res.body.providerUserInfo; + expect(providers).to.have.length(2); // Original email/password + linked provider + + const linkedProvider = providers.find((p: ProviderUserInfo) => p.providerId === providerId); + expect(linkedProvider).to.deep.equal(providerUserInfo); + }); + + const accountInfo = await getAccountInfoByIdToken(authApi(), idToken); + expect(accountInfo.providerUserInfo).to.have.length(2); + const linkedProviderInfo = accountInfo.providerUserInfo?.find( + (p: ProviderUserInfo) => p.providerId === providerId, + ); + expect(linkedProviderInfo).to.deep.equal(providerUserInfo); + }); + + it("should error if linkProviderUserInfo is missing required fields", async () => { + const { idToken } = await registerUser(authApi(), { + email: "test@example.com", + password: "password", + }); + + const incompleteProviderUserInfo1 = { + providerId: "google.com", + email: "linked@example.com", + }; + + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:update") + .query({ key: "fake-api-key" }) + .send({ idToken, linkProviderUserInfo: incompleteProviderUserInfo1 }) + .then((res) => { + expectStatusCode(400, res); + expect(res.body.error.message).to.contain("MISSING_RAW_ID"); + }); + + const incompleteProviderUserInfo2 = { + rawId: "google_user_id", + email: "linked@example.com", + }; + + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:update") + .query({ key: "fake-api-key" }) + .send({ idToken, linkProviderUserInfo: incompleteProviderUserInfo2 }) + .then((res) => { + expectStatusCode(400, res); + expect(res.body.error.message).to.contain("MISSING_PROVIDER_ID"); + }); + }); + + it("should error if user is disabled when linking a provider", async () => { + const { localId, idToken } = await registerUser(authApi(), { + email: "test@example.com", + password: "password", + }); + + await updateAccountByLocalId(authApi(), localId, { disableUser: true }); + + const providerUserInfo = { + providerId: "google.com", + rawId: "google_user_id", + email: "linked@example.com", + }; + + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:update") + .query({ key: "fake-api-key" }) + .send({ idToken, linkProviderUserInfo: providerUserInfo }) + .then((res) => { + expectStatusCode(400, res); + expect(res.body.error.message).to.equal("USER_DISABLED"); + }); + }); }); diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 5668381c7b8..87842286e92 100755 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -28,7 +28,7 @@ import { LoggingEmulator } from "./loggingEmulator"; import * as dbRulesConfig from "../database/rulesConfig"; import { EmulatorLogger, Verbosity } from "./emulatorLogger"; import { EmulatorHubClient } from "./hubClient"; -import { confirm } from "../prompt"; +import { confirm, promptOnce } from "../prompt"; import { FLAG_EXPORT_ON_EXIT_NAME, JAVA_DEPRECATION_WARNING, @@ -884,6 +884,7 @@ export async function startAll( postgresListen: listenForEmulator["dataconnect.postgres"], enable_output_generated_sdk: true, // TODO: source from arguments enable_output_schema_extensions: true, + debug: options.debug, }; if (exportMetadata.dataconnect) { @@ -893,6 +894,26 @@ export async function startAll( importDirAbsPath, exportMetadata.dataconnect.path, ); + const dataDirectory = options.config.get("emulators.dataconnect.dataDir"); + if (exportMetadataFilePath && dataDirectory) { + EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled( + "WARN", + "dataconnect", + "'firebase.json#emulators.dataconnect.dataDir' is set and `--import` flag was passed. " + + "This will overwrite any data saved from previous runs.", + ); + if ( + !options.nonInteractive && + !(await promptOnce({ + type: "confirm", + message: `Do you wish to continue and overwrite data in ${dataDirectory}?`, + default: false, + })) + ) { + await cleanShutdown(); + return { deprecationNotices: [] }; + } + } EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled( "BULLET", diff --git a/src/emulator/dataconnect/pgliteServer.ts b/src/emulator/dataconnect/pgliteServer.ts index 3550ff859ad..22cb7c47cd1 100644 --- a/src/emulator/dataconnect/pgliteServer.ts +++ b/src/emulator/dataconnect/pgliteServer.ts @@ -1,6 +1,6 @@ // https://github.com/supabase-community/pg-gateway -import { PGlite, PGliteOptions } from "@electric-sql/pglite"; +import { DebugLevel, PGlite, PGliteOptions } from "@electric-sql/pglite"; // Unfortunately, we need to dynamically import the Postgres extensions. // They are only available as ESM, and if we import them normally, // our tsconfig will convert them to requires, which will cause errors @@ -17,25 +17,29 @@ import { } from "./pg-gateway/index"; import { fromNodeSocket } from "./pg-gateway/platforms/node"; import { logger } from "../../logger"; +import { hasMessage } from "../../error"; + export const TRUNCATE_TABLES_SQL = ` DO $do$ +DECLARE _clear text; BEGIN - EXECUTE - (SELECT 'TRUNCATE TABLE ' || string_agg(oid::regclass::text, ', ') || ' CASCADE' + SELECT 'TRUNCATE TABLE ' || string_agg(oid::regclass::text, ', ') || ' CASCADE' FROM pg_class WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace - ); + INTO _clear; + EXECUTE COALESCE(_clear, 'select now()'); END $do$;`; export class PostgresServer { - private username: string; - private database: string; private dataDirectory?: string; private importPath?: string; + private debug: DebugLevel; public db: PGlite | undefined = undefined; + private server: net.Server | undefined = undefined; + public async createPGServer(host: string = "127.0.0.1", port: number): Promise { const getDb = this.getDb.bind(this); @@ -66,6 +70,7 @@ export class PostgresServer { server.emit("error", err); }); }); + this.server = server; const listeningPromise = new Promise((resolve) => { server.listen(port, host, () => { @@ -83,9 +88,7 @@ export class PostgresServer { const vector = (await dynamicImport("@electric-sql/pglite/vector")).vector; const uuidOssp = (await dynamicImport("@electric-sql/pglite/contrib/uuid_ossp")).uuid_ossp; const pgliteArgs: PGliteOptions = { - username: this.username, - database: this.database, - debug: 0, + debug: this.debug, extensions: { vector, uuidOssp, @@ -98,7 +101,7 @@ export class PostgresServer { const file = new File([rf], this.importPath); pgliteArgs.loadDataDir = file; } - this.db = await PGlite.create(pgliteArgs); + this.db = await this.forceCreateDB(pgliteArgs); await this.db.waitReady; } return this.db; @@ -116,11 +119,35 @@ export class PostgresServer { fs.writeFileSync(exportPath, new Uint8Array(arrayBuff)); } - constructor(database: string, username: string, dataDirectory?: string, importPath?: string) { - this.username = username; - this.database = database; - this.dataDirectory = dataDirectory; - this.importPath = importPath; + async forceCreateDB(pgliteArgs: PGliteOptions): Promise { + try { + const db = await PGlite.create(pgliteArgs); + return db; + } catch (err: unknown) { + if (pgliteArgs.dataDir && hasMessage(err) && /Database already exists/.test(err.message)) { + // Clear out the current pglite data + fs.rmSync(pgliteArgs.dataDir, { force: true, recursive: true }); + const db = await PGlite.create(pgliteArgs); + return db; + } + throw err; + } + } + + public async stop(): Promise { + if (this.db) { + await this.db.close(); + } + if (this.server) { + this.server.close(); + } + return; + } + + constructor(args: { dataDirectory?: string; importPath?: string; debug?: boolean }) { + this.dataDirectory = args.dataDirectory; + this.importPath = args.importPath; + this.debug = args.debug ? 5 : 0; } } diff --git a/src/emulator/dataconnectEmulator.ts b/src/emulator/dataconnectEmulator.ts index 2b56346c8c0..2273bbf47be 100644 --- a/src/emulator/dataconnectEmulator.ts +++ b/src/emulator/dataconnectEmulator.ts @@ -40,6 +40,7 @@ export interface DataConnectEmulatorArgs { enable_output_schema_extensions: boolean; enable_output_generated_sdk: boolean; importPath?: string; + debug?: boolean; } export interface DataConnectGenerateArgs { @@ -117,7 +118,11 @@ export class DataConnectEmulator implements EmulatorInstance { const postgresDumpPath = this.args.importPath ? path.join(this.args.importPath, "postgres.tar.gz") : undefined; - this.postgresServer = new PostgresServer(dbId, "postgres", dataDirectory, postgresDumpPath); + this.postgresServer = new PostgresServer({ + dataDirectory, + importPath: postgresDumpPath, + debug: this.args.debug, + }); const server = await this.postgresServer.createPGServer(pgHost, pgPort); const connectableHost = connectableHostname(pgHost); connStr = `postgres://${connectableHost}:${pgPort}/${dbId}?sslmode=disable`; @@ -167,6 +172,9 @@ export class DataConnectEmulator implements EmulatorInstance { ); return; } + if (this.postgresServer) { + await this.postgresServer.stop(); + } return stop(Emulators.DATACONNECT); } diff --git a/src/emulator/downloadableEmulators.ts b/src/emulator/downloadableEmulators.ts index 8df3b721bfd..b9be3704f2b 100755 --- a/src/emulator/downloadableEmulators.ts +++ b/src/emulator/downloadableEmulators.ts @@ -9,7 +9,7 @@ import { } from "./types"; import { Constants } from "./constants"; -import { FirebaseError } from "../error"; +import { FirebaseError, hasMessage } from "../error"; import * as childProcess from "child_process"; import * as utils from "../utils"; import { EmulatorLogger } from "./emulatorLogger"; @@ -59,20 +59,20 @@ const EMULATOR_UPDATE_DETAILS: { [s in DownloadableEmulators]: EmulatorUpdateDet dataconnect: process.platform === "darwin" ? { - version: "1.7.4", - expectedSize: 25277184, - expectedChecksum: "74f6b66c79a8a903132c7ab26c644593", + version: "1.7.5", + expectedSize: 25281280, + expectedChecksum: "85d0de96b5c08b553fd8506a2bc381bb", } : process.platform === "win32" ? { - version: "1.7.4", - expectedSize: 25707520, - expectedChecksum: "66eec92e2d57ae42a8b58f33b65b4184", + version: "1.7.5", + expectedSize: 25711616, + expectedChecksum: "c99d67fa8e74d41760b96122b055b8e2", } : { - version: "1.7.4", + version: "1.7.5", expectedSize: 25190552, - expectedChecksum: "acb7be487020afa6e1a597ceb8c6e862", + expectedChecksum: "61d966b781e6f2887f8b38ec271b54e2", }, }; @@ -655,7 +655,6 @@ export async function start( } export function isIncomaptibleArchError(err: unknown): boolean { - const hasMessage = (e: any): e is { message: string } => !!e?.message; return ( hasMessage(err) && /Unknown system error/.test(err.message ?? "") && diff --git a/src/error.ts b/src/error.ts index 93a5ac62f03..e85ddeaef71 100644 --- a/src/error.ts +++ b/src/error.ts @@ -121,3 +121,8 @@ export function isBillingError(e: { ); }); } + +/** + * Checks whether an unknown object (such as an error) has a message field + */ +export const hasMessage = (e: any): e is { message: string } => !!e?.message; diff --git a/src/functions/events/v2.ts b/src/functions/events/v2.ts index 15132856a93..cd897d9a5ee 100644 --- a/src/functions/events/v2.ts +++ b/src/functions/events/v2.ts @@ -33,10 +33,6 @@ export const FIRESTORE_EVENTS = [ export const FIREALERTS_EVENT = "google.firebase.firebasealerts.alerts.v1.published"; -export const FIRESTORE_EVENT_REGEX = /^google\.cloud\.firestore\.document\.v1\.[^\.]*$/; -export const FIRESTORE_EVENT_WITH_AUTH_CONTEXT_REGEX = - /^google\.cloud\.firestore\.document\.v1\..*\.withAuthContext$/; - export type Event = | typeof PUBSUB_PUBLISH_EVENT | (typeof STORAGE_EVENTS)[number] @@ -46,3 +42,17 @@ export type Event = | typeof TEST_LAB_EVENT | (typeof FIRESTORE_EVENTS)[number] | typeof FIREALERTS_EVENT; + +// Why can't auth context be removed? This is map was added to correct a bug where a regex +// allowed any non-auth type to be converted to any auth type, but we should follow up for why +// a functon can't opt into reducing PII. +export const CONVERTABLE_EVENTS: Partial> = { + "google.cloud.firestore.document.v1.created": + "google.cloud.firestore.document.v1.created.withAuthContext", + "google.cloud.firestore.document.v1.updated": + "google.cloud.firestore.document.v1.updated.withAuthContext", + "google.cloud.firestore.document.v1.deleted": + "google.cloud.firestore.document.v1.deleted.withAuthContext", + "google.cloud.firestore.document.v1.written": + "google.cloud.firestore.document.v1.written.withAuthContext", +}; diff --git a/src/gcp/cloudfunctionsv2.spec.ts b/src/gcp/cloudfunctionsv2.spec.ts index 4d3b89eab35..ef9486f9b9f 100644 --- a/src/gcp/cloudfunctionsv2.spec.ts +++ b/src/gcp/cloudfunctionsv2.spec.ts @@ -221,6 +221,23 @@ describe("cloudfunctionsv2", () => { [BLOCKING_LABEL]: "before-sign-in", }, }); + + expect( + cloudfunctionsv2.functionFromEndpoint({ + ...ENDPOINT, + platform: "gcfv2", + callableTrigger: { + genkitAction: "flows/flow", + }, + }), + ).to.deep.equal({ + ...CLOUD_FUNCTION_V2, + labels: { + ...CLOUD_FUNCTION_V2.labels, + "deployment-callable": "true", + "genkit-action": "flows/flow", + }, + }); }); it("should copy trival fields", () => { @@ -637,6 +654,29 @@ describe("cloudfunctionsv2", () => { }); }); + it("should translate genkit callables", () => { + expect( + cloudfunctionsv2.endpointFromFunction({ + ...HAVE_CLOUD_FUNCTION_V2, + labels: { + "deployment-callable": "true", + "genkit-action": "flows/flow", + }, + }), + ).to.deep.equal({ + ...ENDPOINT, + callableTrigger: { + genkitAction: "flows/flow", + }, + platform: "gcfv2", + uri: GCF_URL, + labels: { + "deployment-callable": "true", + "genkit-action": "flows/flow", + }, + }); + }); + it("should copy optional fields", () => { const extraFields: backend.ServiceConfiguration = { ingressSettings: "ALLOW_ALL", diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index af1cb92984a..6d17c607bbc 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -609,6 +609,9 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc gcfFunction.labels = { ...gcfFunction.labels, "deployment-taskqueue": "true" }; } else if (backend.isCallableTriggered(endpoint)) { gcfFunction.labels = { ...gcfFunction.labels, "deployment-callable": "true" }; + if (endpoint.callableTrigger.genkitAction) { + gcfFunction.labels["genkit-action"] = endpoint.callableTrigger.genkitAction; + } } else if (backend.isBlockingTriggered(endpoint)) { gcfFunction.labels = { ...gcfFunction.labels, @@ -654,6 +657,9 @@ export function endpointFromFunction(gcfFunction: OutputCloudFunction): backend. trigger = { callableTrigger: {}, }; + if (gcfFunction.labels["genkit-action"]) { + trigger.callableTrigger.genkitAction = gcfFunction.labels["genkit-action"]; + } } else if (gcfFunction.labels?.[BLOCKING_LABEL]) { trigger = { blockingTrigger: { diff --git a/src/init/features/dataconnect/index.spec.ts b/src/init/features/dataconnect/index.spec.ts index 7f792e4172c..c221868fdc7 100644 --- a/src/init/features/dataconnect/index.spec.ts +++ b/src/init/features/dataconnect/index.spec.ts @@ -1,5 +1,6 @@ import * as sinon from "sinon"; import { expect } from "chai"; +import * as fs from "fs-extra"; import * as init from "./index"; import { Config } from "../../../config"; @@ -17,9 +18,11 @@ describe("init dataconnect", () => { const sandbox = sinon.createSandbox(); let provisionCSQLStub: sinon.SinonStub; let askWriteProjectFileStub: sinon.SinonStub; + let ensureSyncStub: sinon.SinonStub; beforeEach(() => { provisionCSQLStub = sandbox.stub(provison, "provisionCloudSql"); + ensureSyncStub = sandbox.stub(fs, "ensureFileSync"); }); afterEach(() => { @@ -33,6 +36,7 @@ describe("init dataconnect", () => { expectedSource: string; expectedFiles: string[]; expectCSQLProvisioning: boolean; + expectEnsureSchemaGQL: boolean; }[] = [ { desc: "empty project should generate template", @@ -47,6 +51,7 @@ describe("init dataconnect", () => { "dataconnect/connector/mutations.gql", ], expectCSQLProvisioning: false, + expectEnsureSchemaGQL: false, }, { desc: "exiting project should use existing directory", @@ -55,6 +60,7 @@ describe("init dataconnect", () => { expectedSource: "not-dataconnect", expectedFiles: ["not-dataconnect/dataconnect.yaml"], expectCSQLProvisioning: false, + expectEnsureSchemaGQL: false, }, { desc: "should write schema files", @@ -70,6 +76,7 @@ describe("init dataconnect", () => { expectedSource: "dataconnect", expectedFiles: ["dataconnect/dataconnect.yaml", "dataconnect/schema/schema.gql"], expectCSQLProvisioning: false, + expectEnsureSchemaGQL: false, }, { desc: "should write connector files", @@ -95,6 +102,7 @@ describe("init dataconnect", () => { "dataconnect/hello/queries.gql", ], expectCSQLProvisioning: false, + expectEnsureSchemaGQL: false, }, { desc: "should provision cloudSQL resources ", @@ -111,6 +119,22 @@ describe("init dataconnect", () => { "dataconnect/connector/mutations.gql", ], expectCSQLProvisioning: true, + expectEnsureSchemaGQL: false, + }, + { + desc: "should handle schema with no files", + requiredInfo: mockRequiredInfo({ + schemaGql: [], + }), + config: mockConfig({ + dataconnect: { + source: "dataconnect", + }, + }), + expectedSource: "dataconnect", + expectedFiles: ["dataconnect/dataconnect.yaml"], + expectCSQLProvisioning: false, + expectEnsureSchemaGQL: true, }, ]; @@ -129,6 +153,9 @@ describe("init dataconnect", () => { c.requiredInfo, ); expect(c.config.get("dataconnect.source")).to.equal(c.expectedSource); + if (c.expectEnsureSchemaGQL) { + expect(ensureSyncStub).to.have.been.calledWith("dataconnect/schema/schema.gql"); + } expect(askWriteProjectFileStub.args.map((a) => a[0])).to.deep.equal(c.expectedFiles); expect(provisionCSQLStub.called).to.equal(c.expectCSQLProvisioning); }); diff --git a/src/init/features/dataconnect/index.ts b/src/init/features/dataconnect/index.ts index 20546eb3851..0b8550de500 100644 --- a/src/init/features/dataconnect/index.ts +++ b/src/init/features/dataconnect/index.ts @@ -1,5 +1,6 @@ import { join, basename } from "path"; import * as clc from "colorette"; +import * as fs from "fs-extra"; import { confirm, promptOnce } from "../../../prompt"; import { Config } from "../../../config"; @@ -196,7 +197,11 @@ async function writeFiles(config: Config, info: RequiredInfo) { for (const f of info.schemaGql) { await config.askWriteProjectFile(join(dir, "schema", f.path), f.content); } + } else { + // Even if the schema is empty, lets give them an empty .gql file to get started. + fs.ensureFileSync(join(dir, "schema", "schema.gql")); } + for (const c of info.connectors) { await writeConnectorFiles(config, c); } diff --git a/src/management/projects.ts b/src/management/projects.ts index 93076da1699..9f25bc4552b 100644 --- a/src/management/projects.ts +++ b/src/management/projects.ts @@ -33,12 +33,30 @@ export const PROJECTS_CREATE_QUESTIONS: Question[] = [ message: "Please specify a unique project id " + `(${clc.yellow("warning")}: cannot be modified afterward) [6-30 characters]:\n`, + validate: (projectId: string) => { + if (projectId.length < 6) { + return "Project ID must be at least 6 characters long"; + } else if (projectId.length > 30) { + return "Project ID cannot be longer than 30 characters"; + } else { + return true; + } + }, }, { type: "input", name: "displayName", - default: "", + default: (answers: any) => answers.projectId, message: "What would you like to call your project? (defaults to your project ID)", + validate: (displayName: string) => { + if (displayName.length < 4) { + return "Project name must be at least 4 characters long"; + } else if (displayName.length > 30) { + return "Project name cannot be longer than 30 characters"; + } else { + return true; + } + }, }, ]; diff --git a/templates/init/functions/javascript/package.lint.json b/templates/init/functions/javascript/package.lint.json index 4ca87378275..b8816bdcd98 100644 --- a/templates/init/functions/javascript/package.lint.json +++ b/templates/init/functions/javascript/package.lint.json @@ -10,7 +10,7 @@ "logs": "firebase functions:log" }, "engines": { - "node": "18" + "node": "22" }, "main": "index.js", "dependencies": { diff --git a/templates/init/functions/javascript/package.nolint.json b/templates/init/functions/javascript/package.nolint.json index a2db8bcfc5f..0d14456d033 100644 --- a/templates/init/functions/javascript/package.nolint.json +++ b/templates/init/functions/javascript/package.nolint.json @@ -9,7 +9,7 @@ "logs": "firebase functions:log" }, "engines": { - "node": "18" + "node": "22" }, "main": "index.js", "dependencies": { diff --git a/templates/init/functions/typescript/package.lint.json b/templates/init/functions/typescript/package.lint.json index c72e8aac981..d7788081e69 100644 --- a/templates/init/functions/typescript/package.lint.json +++ b/templates/init/functions/typescript/package.lint.json @@ -11,7 +11,7 @@ "logs": "firebase functions:log" }, "engines": { - "node": "18" + "node": "22" }, "main": "lib/index.js", "dependencies": { diff --git a/templates/init/functions/typescript/package.nolint.json b/templates/init/functions/typescript/package.nolint.json index dd76062513e..d167a0ee473 100644 --- a/templates/init/functions/typescript/package.nolint.json +++ b/templates/init/functions/typescript/package.nolint.json @@ -10,7 +10,7 @@ "logs": "firebase functions:log" }, "engines": { - "node": "18" + "node": "22" }, "main": "lib/index.js", "dependencies": { diff --git a/templates/init/functions/typescript/tsconfig.json b/templates/init/functions/typescript/tsconfig.json index 7ce05d039d6..57b915f3cc9 100644 --- a/templates/init/functions/typescript/tsconfig.json +++ b/templates/init/functions/typescript/tsconfig.json @@ -1,6 +1,8 @@ { "compilerOptions": { - "module": "commonjs", + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "nodenext", "noImplicitReturns": true, "noUnusedLocals": true, "outDir": "lib",