Skip to content

Commit 4ac869b

Browse files
authored
Merge branch 'master' into test-hosting-and-project-number
2 parents 5c6a8ed + ee10ddc commit 4ac869b

File tree

14 files changed

+764
-453
lines changed

14 files changed

+764
-453
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
- Fixed a bug when deploying firestore indexes failed due to broken index comparison logic (#8859)
12
- Added prefix support for multi-instance Cloud Functions extension parameters. (#8911)
23
- Fixed a bug when `firebase deploy --only dataconnect` doesn't include GQL in nested folders (#8981)
34
- Make it possible to init a dataconnect project in non interactive mode (#8993)
45
- Added 2 new MCP tools for crashlytics `get_sample_crash_for_issue` and `get_issue_details` (#8995)
6+
- Use Gemini to generate schema and seed_data.gql in `firebase init dataconnect` (#8988)

src/dataconnect/client.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,24 +41,31 @@ export async function createService(
4141
projectId: string,
4242
locationId: string,
4343
serviceId: string,
44-
): Promise<types.Service> {
45-
const op = await dataconnectClient().post<types.Service, types.Service>(
46-
`/projects/${projectId}/locations/${locationId}/services`,
47-
{
48-
name: `projects/${projectId}/locations/${locationId}/services/${serviceId}`,
49-
},
50-
{
51-
queryParams: {
52-
service_id: serviceId,
44+
): Promise<types.Service | undefined> {
45+
try {
46+
const op = await dataconnectClient().post<types.Service, types.Service>(
47+
`/projects/${projectId}/locations/${locationId}/services`,
48+
{
49+
name: `projects/${projectId}/locations/${locationId}/services/${serviceId}`,
5350
},
54-
},
55-
);
56-
const pollRes = await operationPoller.pollOperation<types.Service>({
57-
apiOrigin: dataconnectOrigin(),
58-
apiVersion: DATACONNECT_API_VERSION,
59-
operationResourceName: op.body.name,
60-
});
61-
return pollRes;
51+
{
52+
queryParams: {
53+
service_id: serviceId,
54+
},
55+
},
56+
);
57+
const pollRes = await operationPoller.pollOperation<types.Service>({
58+
apiOrigin: dataconnectOrigin(),
59+
apiVersion: DATACONNECT_API_VERSION,
60+
operationResourceName: op.body.name,
61+
});
62+
return pollRes;
63+
} catch (err: any) {
64+
if (err.status !== 409) {
65+
throw err;
66+
}
67+
return undefined; // Service already exists
68+
}
6269
}
6370

6471
export async function deleteService(serviceName: string): Promise<types.Service> {

src/dataconnect/ensureApis.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
import * as api from "../api";
2-
import { check, ensure } from "../ensureApiEnabled";
2+
import { ensure } from "../ensureApiEnabled";
33

44
const prefix = "dataconnect";
55

6-
export async function isApiEnabled(projectId: string): Promise<boolean> {
7-
return await check(projectId, api.dataconnectOrigin(), prefix);
8-
}
9-
106
export async function ensureApis(projectId: string): Promise<void> {
11-
await ensure(projectId, api.dataconnectOrigin(), prefix);
12-
await ensure(projectId, api.cloudSQLAdminOrigin(), prefix);
13-
}
14-
15-
export async function ensureSparkApis(projectId: string): Promise<void> {
16-
// These are the APIs that can be enabled without a billing account.
17-
await ensure(projectId, api.cloudSQLAdminOrigin(), prefix);
18-
await ensure(projectId, api.dataconnectOrigin(), prefix);
7+
await Promise.all([
8+
ensure(projectId, api.dataconnectOrigin(), prefix),
9+
ensure(projectId, api.cloudSQLAdminOrigin(), prefix),
10+
]);
1911
}
2012

2113
export async function ensureGIFApis(projectId: string): Promise<void> {

src/dataconnect/freeTrial.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,32 @@ const FREE_TRIAL_METRIC = "sqladmin.googleapis.com/fdc_lifetime_free_trial_per_p
1212

1313
// Checks whether there is already a free trial instance on a project.
1414
export async function checkFreeTrialInstanceUsed(projectId: string): Promise<boolean> {
15-
utils.logLabeledBullet("dataconnect", "Checking Cloud SQL no cost trial eligibility...");
1615
const past7d = new Date();
1716
past7d.setDate(past7d.getDate() - 7);
1817
const query: CmQuery = {
1918
filter: `metric.type="serviceruntime.googleapis.com/quota/allocation/usage" AND metric.label.quota_metric = "${FREE_TRIAL_METRIC}"`,
2019
"interval.endTime": new Date().toJSON(),
2120
"interval.startTime": past7d.toJSON(),
2221
};
22+
let used = true;
2323
try {
2424
const ts = await queryTimeSeries(query, projectId);
25-
let used = true;
2625
if (ts.length) {
2726
used = ts[0].points.some((p) => p.value.int64Value);
2827
}
29-
if (used) {
30-
utils.logLabeledWarning(
31-
"dataconnect",
32-
"CloudSQL no cost trial has already been used on this project.",
33-
);
34-
}
35-
return used;
3628
} catch (err: any) {
3729
// If the metric doesn't exist, free trial is not used.
30+
used = false;
31+
}
32+
if (used) {
33+
utils.logLabeledWarning(
34+
"dataconnect",
35+
"CloudSQL no cost trial has already been used on this project.",
36+
);
37+
} else {
3838
utils.logLabeledSuccess("dataconnect", "CloudSQL no cost trial available!");
39-
return false;
4039
}
40+
return used;
4141
}
4242

4343
export async function getFreeTrialInstanceId(projectId: string): Promise<string | undefined> {
@@ -84,9 +84,11 @@ export function printFreeTrialUnavailable(
8484
}
8585

8686
export function upgradeInstructions(projectId: string): string {
87-
return `If you'd like to provision a CloudSQL Postgres instance on the Firebase Data Connect no-cost trial:
88-
1. Please upgrade to the pay-as-you-go (Blaze) billing plan. Visit the following page:
89-
https://console.firebase.google.com/project/${projectId}/usage/details
90-
2. Run ${clc.bold("firebase init dataconnect")} again to configure the Cloud SQL instance.
91-
3. Run ${clc.bold("firebase deploy --only dataconnect")} to deploy your Data Connect service.`;
87+
return `To provision a CloudSQL Postgres instance on the Firebase Data Connect no-cost trial:
88+
89+
1. Please upgrade to the pay-as-you-go (Blaze) billing plan. Visit the following page:
90+
91+
https://console.firebase.google.com/project/${projectId}/usage/details
92+
93+
2. Run ${clc.bold("firebase deploy --only dataconnect")} to deploy your Data Connect service.`;
9294
}

src/dataconnect/provisionCloudSql.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export async function provisionCloudSql(args: {
3232
} = args;
3333
try {
3434
const existingInstance = await cloudSqlAdminClient.getInstance(projectId, instanceId);
35-
silent || utils.logLabeledBullet("dataconnect", `Found existing instance ${instanceId}.`);
35+
silent ||
36+
utils.logLabeledBullet("dataconnect", `Found existing Cloud SQL instance ${instanceId}.`);
3637
connectionName = existingInstance?.connectionName || "";
3738
const why = getUpdateReason(existingInstance, enableGoogleMlIntegration);
3839
if (why) {
@@ -61,7 +62,6 @@ export async function provisionCloudSql(args: {
6162
if (err.status !== 404) {
6263
throw err;
6364
}
64-
cmekWarning();
6565
const cta = dryRun ? "It will be created on your next deploy" : "Creating it now.";
6666
const freeTrialUsed = await checkFreeTrialInstanceUsed(projectId);
6767
silent ||
@@ -95,7 +95,7 @@ export async function provisionCloudSql(args: {
9595
silent ||
9696
utils.logLabeledBullet(
9797
"dataconnect",
98-
"Cloud SQL instance creation started - it should be ready shortly. Database and users will be created on your next deploy.",
98+
"Cloud SQL instance creation started. While it is being set up, your data will be saved in a temporary database. When it is ready, your data will be migrated.",
9999
);
100100
return connectionName;
101101
}
@@ -111,17 +111,11 @@ export async function provisionCloudSql(args: {
111111
silent ||
112112
utils.logLabeledBullet(
113113
"dataconnect",
114-
`Database ${databaseId} not found. It will be created on your next deploy.`,
114+
`Postgres database ${databaseId} not found. It will be created on your next deploy.`,
115115
);
116116
} else {
117-
// Create the database if not found.
118-
silent ||
119-
utils.logLabeledBullet(
120-
"dataconnect",
121-
`Database ${databaseId} not found, creating it now...`,
122-
);
123117
await cloudSqlAdminClient.createDatabase(projectId, instanceId, databaseId);
124-
silent || utils.logLabeledBullet("dataconnect", `Database ${databaseId} created.`);
118+
silent || utils.logLabeledBullet("dataconnect", `Postgres database ${databaseId} created.`);
125119
}
126120
} else {
127121
// Skip it if the database is not accessible.
@@ -171,11 +165,3 @@ export function getUpdateReason(instance: Instance, requireGoogleMlIntegration:
171165

172166
return reason;
173167
}
174-
175-
function cmekWarning() {
176-
const message =
177-
"Cloud SQL instances created via the Firebase CLI do not support customer managed encryption keys.\n" +
178-
"If you'd like to use a CMEK to encrypt your data, first create a CMEK encrypted instance (https://cloud.google.com/sql/docs/postgres/configure-cmek#createcmekinstance).\n" +
179-
"Then, edit your `dataconnect.yaml` file to use the encrypted instance and redeploy.";
180-
utils.logLabeledWarning("dataconnect", message);
181-
}

src/dataconnect/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface PostgreSql {
3535
database?: string;
3636
cloudSql?: CloudSqlInstance;
3737
schemaValidation?: SchemaValidation | "NONE" | "SQL_SCHEMA_VALIDATION_UNSPECIFIED";
38+
schemaMigration?: "MIGRATE_COMPATIBLE";
3839
}
3940

4041
export interface CloudSqlInstance {

src/firestore/api.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as utils from "../utils";
55
import * as validator from "./validator";
66

77
import * as types from "./api-types";
8+
import { DatabaseEdition, Density } from "./api-types";
89
import * as Spec from "./api-spec";
910
import * as sort from "./api-sort";
1011
import * as util from "./util";
@@ -13,6 +14,7 @@ import { firestoreOrigin } from "../api";
1314
import { FirebaseError } from "../error";
1415
import { Client } from "../apiv2";
1516
import { PrettyPrint } from "./pretty-print";
17+
import { optionalValueMatches } from "../functional";
1618

1719
export class FirestoreApi {
1820
apiClient = new Client({ urlPrefix: firestoreOrigin(), apiVersion: "v1" });
@@ -74,8 +76,10 @@ export class FirestoreApi {
7476
databaseId,
7577
);
7678

79+
const database = await this.getDatabase(options.project, databaseId);
80+
const edition = database.databaseEdition ?? DatabaseEdition.STANDARD;
7781
const indexesToDelete = existingIndexes.filter((index) => {
78-
return !indexesToDeploy.some((spec) => this.indexMatchesSpec(index, spec));
82+
return !indexesToDeploy.some((spec) => this.indexMatchesSpec(index, spec, edition));
7983
});
8084

8185
// We only want to delete fields where there is nothing in the local file with the same
@@ -127,7 +131,7 @@ export class FirestoreApi {
127131
}
128132

129133
for (const index of indexesToDeploy) {
130-
const exists = existingIndexes.some((x) => this.indexMatchesSpec(x, index));
134+
const exists = existingIndexes.some((x) => this.indexMatchesSpec(x, index, edition));
131135
if (exists) {
132136
logger.debug(`Skipping existing index: ${JSON.stringify(index)}`);
133137
} else {
@@ -325,8 +329,11 @@ export class FirestoreApi {
325329
validator.assertType("multikey", index.multikey, "boolean");
326330
}
327331

328-
if (index.unique) {
332+
if (index.unique !== undefined) {
329333
validator.assertType("unique", index.unique, "boolean");
334+
// TODO(b/439901837): Remove this check and update indexMatchesSpec once
335+
// unique index configuration is supported.
336+
throw new FirebaseError("The `unique` index configuration is not supported yet.");
330337
}
331338

332339
validator.assertHas(index, "fields");
@@ -479,10 +486,51 @@ export class FirestoreApi {
479486
return this.apiClient.delete(`/${url}`);
480487
}
481488

489+
/**
490+
* Returns true if the given ApiScope values match.
491+
* If either one is undefined, the default value is used for comparison.
492+
* @param lhs the first ApiScope value.
493+
* @param rhs the second ApiScope value.
494+
*/
495+
optionalApiScopeMatches(
496+
lhs: types.ApiScope | undefined,
497+
rhs: types.ApiScope | undefined,
498+
): boolean {
499+
return optionalValueMatches<types.ApiScope>(lhs, rhs, types.ApiScope.ANY_API);
500+
}
501+
502+
/**
503+
* Returns true if the given Density values match.
504+
* If either one is undefined, the default value is used for comparison based on Database Edition.
505+
* @param lhs the first Density value.
506+
* @param rhs the second Density value.
507+
* @param edition the database edition used to determine the default value.
508+
*/
509+
optionalDensityMatches(
510+
lhs: Density | undefined,
511+
rhs: Density | undefined,
512+
edition: types.DatabaseEdition,
513+
): boolean {
514+
const defaultValue =
515+
edition === DatabaseEdition.STANDARD ? types.Density.SPARSE_ALL : types.Density.DENSE;
516+
return optionalValueMatches<types.Density>(lhs, rhs, defaultValue);
517+
}
518+
519+
/**
520+
* Returns true if the given Multikey values match.
521+
* If either one is undefined, the default value is used for comparison.
522+
* @param lhs the first Multikey value.
523+
* @param rhs the second Multikey value.
524+
*/
525+
optionalMultikeyMatches(lhs: boolean | undefined, rhs: boolean | undefined): boolean {
526+
const defaultValue = false;
527+
return optionalValueMatches<boolean>(lhs, rhs, defaultValue);
528+
}
529+
482530
/**
483531
* Determine if an API Index and a Spec Index are functionally equivalent.
484532
*/
485-
indexMatchesSpec(index: types.Index, spec: Spec.Index): boolean {
533+
indexMatchesSpec(index: types.Index, spec: Spec.Index, edition: types.DatabaseEdition): boolean {
486534
const collection = util.parseIndexName(index.name).collectionGroupId;
487535
if (collection !== spec.collectionGroup) {
488536
return false;
@@ -492,21 +540,24 @@ export class FirestoreApi {
492540
return false;
493541
}
494542

495-
if (index.apiScope !== spec.apiScope) {
543+
// apiScope is an optional value and may be missing in firestore.indexes.json,
544+
// and may also be missing from the server value (when default is picked).
545+
if (!this.optionalApiScopeMatches(index.apiScope, spec.apiScope)) {
496546
return false;
497547
}
498548

499-
if (index.density !== spec.density) {
549+
// density is an optional value and may be missing in firestore.indexes.json,
550+
// and may also be missing from the server value (when default is picked).
551+
if (!this.optionalDensityMatches(index.density, spec.density, edition)) {
500552
return false;
501553
}
502-
503-
if (index.multikey !== spec.multikey) {
554+
// multikey is an optional value and may be missing in firestore.indexes.json,
555+
// and may also be missing from the server value (when default is picked).
556+
if (!this.optionalMultikeyMatches(index.multikey, spec.multikey)) {
504557
return false;
505558
}
506559

507-
if (index.unique !== spec.unique) {
508-
return false;
509-
}
560+
// TODO(b/439901837): Compare `unique` index configuration when it's supported.
510561

511562
if (index.fields.length !== spec.fields.length) {
512563
return false;
@@ -529,7 +580,8 @@ export class FirestoreApi {
529580
return false;
530581
}
531582

532-
if (iField.vectorConfig !== sField.vectorConfig) {
583+
// Note: vectorConfig is an object, and using '!==' should not be used.
584+
if (!utils.deepEqual(iField.vectorConfig, sField.vectorConfig)) {
533585
return false;
534586
}
535587

0 commit comments

Comments
 (0)