diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 9ecf3d737a8..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 @@ -184,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: diff --git a/CHANGELOG.md b/CHANGELOG.md index b91ad91680e..e69de29bb2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +0,0 @@ -- Changes default CF3 runtime to nodejs22 (#8037) -- Fixed an issue where `--import` would error for the Data Connect emulator if `dataDir` was also set. diff --git a/firebase-vscode/CHANGELOG.md b/firebase-vscode/CHANGELOG.md index a54189f1b4c..c26eb3b4706 100644 --- a/firebase-vscode/CHANGELOG.md +++ b/firebase-vscode/CHANGELOG.md @@ -2,6 +2,11 @@ - [Added] Added support for emulator import/export. +## 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 diff --git a/firebase-vscode/package-lock.json b/firebase-vscode/package-lock.json index a12947a3a2b..e1822db306a 100644 --- a/firebase-vscode/package-lock.json +++ b/firebase-vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "firebase-dataconnect-vscode", - "version": "0.11.1", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firebase-dataconnect-vscode", - "version": "0.11.1", + "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 88c6dd48faa..8b3d99666c2 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.11.1", + "version": "0.12.0", "engines": { "vscode": "^1.69.0" }, diff --git a/firebase-vscode/src/analytics.ts b/firebase-vscode/src/analytics.ts index d9500b2f978..298d62b3c6a 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. @@ -184,6 +185,7 @@ class GA4TelemetrySender implements TelemetrySender { } } data = { ...data }; + data = addFirebaseBinaryMetadata(data); if (!this.hasSentData) { trackVSCode( DATA_CONNECT_EVENT_NAME.EXTENSION_USED, @@ -199,3 +201,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/data-connect/deploy.ts b/firebase-vscode/src/data-connect/deploy.ts index ba43d285a9d..e15877503e7 100644 --- a/firebase-vscode/src/data-connect/deploy.ts +++ b/firebase-vscode/src/data-connect/deploy.ts @@ -42,16 +42,12 @@ export function registerFdcDeploy( ); const deployAllCmd = vscode.commands.registerCommand("fdc.deploy-all", () => { - analyticsLogger.logger.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 () => { - analyticsLogger.logger.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/sdk-generation.ts b/firebase-vscode/src/data-connect/sdk-generation.ts index 137671473d6..abf9b1a20cb 100644 --- a/firebase-vscode/src/data-connect/sdk-generation.ts +++ b/firebase-vscode/src/data-connect/sdk-generation.ts @@ -38,9 +38,7 @@ export function registerFdcSdkGeneration( const initSdkCmd = vscode.commands.registerCommand( "fdc.init-sdk", (args: { appFolder: string }) => { - analyticsLogger.logger.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`); diff --git a/firebase-vscode/src/data-connect/terminal.ts b/firebase-vscode/src/data-connect/terminal.ts index 584364baa70..5bc64d02348 100644 --- a/firebase-vscode/src/data-connect/terminal.ts +++ b/firebase-vscode/src/data-connect/terminal.ts @@ -74,9 +74,7 @@ export function registerTerminalTasks( const settings = getSettings(); const loginTaskBroker = broker.on("executeLogin", () => { - analyticsLogger.logger.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,19 +84,16 @@ export function registerTerminalTasks( }); const startEmulatorsTaskBroker = broker.on("runStartEmulators", () => { - analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.START_EMULATORS, { - firebase_binary_kind: settings.firebaseBinaryKind, - }); + analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.START_EMULATORS); + let cmd = `${settings.firebasePath} emulators:start --project ${currentProjectId.value}`; - console.log(settings); + if (settings.importPath) { cmd += ` --import ${settings.importPath}`; } if (settings.exportOnExit) { cmd += ` --export-on-exit ${settings.exportPath}`; } - console.log(cmd); - // TODO: optional debug mode runTerminalTask( "firebase emulators", cmd, 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/npm-shrinkwrap.json b/npm-shrinkwrap.json index 77d3aa60b92..81d46dac925 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "firebase-tools", - "version": "13.28.0", + "version": "13.29.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "firebase-tools", - "version": "13.28.0", + "version": "13.29.1", "license": "MIT", "dependencies": { "@electric-sql/pglite": "^0.2.0", diff --git a/package.json b/package.json index e87e0efa6f5..b25f40497f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebase-tools", - "version": "13.28.0", + "version": "13.29.1", "description": "Command-Line Interface for Firebase", "main": "./lib/index.js", "bin": { 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/emulator/controller.ts b/src/emulator/controller.ts index dc8637c49e6..46758aff71a 100755 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -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) { diff --git a/src/emulator/dataconnect/pgliteServer.ts b/src/emulator/dataconnect/pgliteServer.ts index 1aee1c2272c..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 @@ -18,25 +18,28 @@ import { 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); @@ -67,6 +70,7 @@ export class PostgresServer { server.emit("error", err); }); }); + this.server = server; const listeningPromise = new Promise((resolve) => { server.listen(port, host, () => { @@ -84,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, @@ -132,11 +134,20 @@ export class PostgresServer { } } - constructor(database: string, username: string, dataDirectory?: string, importPath?: string) { - this.username = username; - this.database = database; - this.dataDirectory = dataDirectory; - this.importPath = importPath; + 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 a1e83910bc8..9041d66342a 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 { @@ -116,7 +117,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`; @@ -166,6 +171,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 51431926b66..b9be3704f2b 100755 --- a/src/emulator/downloadableEmulators.ts +++ b/src/emulator/downloadableEmulators.ts @@ -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", }, }; 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/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",