@firebase-function-kits/bigquery-firestore-export 0.0.2-rc.3 → 0.0.2-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firebase-function-kits/bigquery-firestore-export",
3
- "version": "0.0.2-rc.3",
3
+ "version": "0.0.2-rc.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/firebase/extensions.git",
@@ -35,15 +35,19 @@
35
35
  "@firebase/app": "^0.16.0",
36
36
  "@google-cloud/bigquery": "^8.3.1",
37
37
  "@google-cloud/bigquery-data-transfer": "^5.0.1",
38
- "@google-cloud/pubsub": "^4.11.0",
39
- "firebase-admin": "^14.1.0",
40
- "firebase-functions": "^7.3.3-rc.0"
38
+ "@google-cloud/pubsub": "^4.11.0"
41
39
  },
42
40
  "devDependencies": {
41
+ "firebase-admin": "^14.4.0",
42
+ "firebase-functions": "^7.3.3-rc.3",
43
43
  "typescript": "^5.9.3",
44
44
  "vitest": "^4.1.10"
45
45
  },
46
46
  "overrides": {
47
47
  "uuid@9.0.1": "11.1.1"
48
+ },
49
+ "peerDependencies": {
50
+ "firebase-admin": "^14.4.0",
51
+ "firebase-functions": "^7.3.3-rc.3"
48
52
  }
49
53
  }
package/src/config.ts CHANGED
@@ -14,12 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import {
18
- defineString,
19
- expr,
20
- projectID,
21
- select,
22
- } from "firebase-functions/params";
17
+ import { defineString, projectID, select } from "firebase-functions/params";
23
18
  import type {
24
19
  BigqueryFirestoreExportConfig,
25
20
  DeployTimeOptions,
@@ -27,10 +22,29 @@ import type {
27
22
  } from "./export-config";
28
23
 
29
24
  const LOG_LEVEL_OPTIONS = ["debug", "info", "warn", "error", "silent"] as const;
30
- const instanceId = defineString("INSTANCE_ID");
25
+
26
+ // firebase-tools injects this for kit instances (set to the instance's key in
27
+ // firebase.json) during discovery, in the emulator, and on deployed functions.
28
+ // The FIREBASE_ prefix is reserved in .env files and the params machinery never
29
+ // sees injected values, so it must be a plain env read, not a defineString.
30
+ function instanceIdFromEnv(): string {
31
+ const instanceId = process.env.FIREBASE_KIT_INSTANCE_ID;
32
+ if (!instanceId) {
33
+ throw new Error(
34
+ "FIREBASE_KIT_INSTANCE_ID is not set. It is provided automatically to " +
35
+ "kit instances by firebase-tools >= 15.27.0; deploy or emulate this " +
36
+ "kit with a supported CLI version."
37
+ );
38
+ }
39
+ return instanceId;
40
+ }
41
+
42
+ // Resolved at import so the topic default is a concrete name at discovery. An
43
+ // unsupported CLI fails the discovery pass here rather than freezing
44
+ // "kit-undefined-processMessages" into the manifest.
45
+ const instanceId = instanceIdFromEnv();
31
46
 
32
47
  const params = {
33
- instanceId,
34
48
  bigqueryDatasetLocation: defineString("BIGQUERY_DATASET_LOCATION", {
35
49
  label: "BigQuery Dataset Location",
36
50
  description:
@@ -81,7 +95,7 @@ const params = {
81
95
  description:
82
96
  "Which Pub/Sub topic should receive BigQuery Data Transfer completion notifications? Leave the default unless you are migrating from the bigquery-firestore-export extension, whose topic is named ext-<instance id>-processMessages. Pointing this at the extension's topic keeps the existing scheduled query's notification settings untouched.",
83
97
 
84
- default: expr`kit-${instanceId}-processMessages`,
98
+ default: `kit-${instanceId}-processMessages`,
85
99
  input: {
86
100
  text: {
87
101
  nonEmpty: true,
@@ -201,12 +215,10 @@ function normalizeLogLevel(value: string): LogLevel {
201
215
 
202
216
  /** Reads runtime values from Firebase deploy-time parameters. */
203
217
  export function configFromEnv(): BigqueryFirestoreExportConfig {
204
- const resolvedInstanceId = params.instanceId.value();
205
-
206
218
  return {
207
219
  bigqueryDatasetLocation: params.bigqueryDatasetLocation.value(),
208
220
  projectId: projectID.value(),
209
- instanceId: resolvedInstanceId,
221
+ instanceId: instanceIdFromEnv(),
210
222
  transferConfigName: optional(params.transferConfigName.value()),
211
223
  datasetId: params.datasetId.value(),
212
224
  tableName: params.tableName.value(),
package/src/dts.ts CHANGED
@@ -194,6 +194,11 @@ export async function constructUpdateTransferConfigRequest(
194
194
  updatedFields.partitioning_field.stringValue = newPartitioningField;
195
195
  }
196
196
 
197
+ if (config.displayName !== transferConfig.displayName) {
198
+ updateMask.push("display_name");
199
+ updatedConfig.displayName = config.displayName;
200
+ }
201
+
197
202
  if (config.schedule !== transferConfig.schedule) {
198
203
  updateMask.push("schedule");
199
204
  updatedConfig.schedule = config.schedule;
package/src/helper.ts CHANGED
@@ -178,10 +178,12 @@ export function convertUnsupportedDataTypes(
178
178
  return row as FirestoreRowValue;
179
179
  }
180
180
 
181
+ // A TIME is a time of day with no date, so no Timestamp can hold it without
182
+ // inventing one. BigQuery's own "HH:MM:SS[.ffffff]" string is kept instead.
183
+ if (row instanceof BigQueryTime) return row.value;
181
184
  if (
182
185
  row instanceof BigQueryTimestamp ||
183
186
  row instanceof BigQueryDate ||
184
- row instanceof BigQueryTime ||
185
187
  row instanceof BigQueryDatetime
186
188
  ) {
187
189
  return Timestamp.fromDate(new Date(row.value));
package/src/index.ts CHANGED
@@ -55,6 +55,10 @@ const REQUIRED_ROLES: ReadonlyArray<Role> = [
55
55
  "roles/run.invoker",
56
56
  ];
57
57
  const REQUIRED_APIS = [
58
+ {
59
+ api: "firestore.googleapis.com",
60
+ reason: "Writes BigQuery export run state to Cloud Firestore.",
61
+ },
58
62
  {
59
63
  api: "bigquery.googleapis.com",
60
64
  reason: "Runs scheduled queries and reads their destination tables.",
@@ -14,32 +14,59 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { Expression } from "firebase-functions/params";
17
+ import { declaredParams, Expression } from "firebase-functions/params";
18
18
  import { afterEach, describe, expect, test, vi } from "vitest";
19
- import { CONFIG_EXPRESSIONS, configFromEnv } from "../src/config";
19
+
20
+ const INSTANCE_ID = "users-export";
21
+
22
+ // The instance id is read when the module loads, so the environment has to be
23
+ // in place before each import.
24
+ async function importConfig(instanceId: string | undefined) {
25
+ vi.resetModules();
26
+ vi.stubEnv("FIREBASE_KIT_INSTANCE_ID", instanceId);
27
+ return import("../src/config");
28
+ }
29
+
30
+ function stubRuntimeEnv() {
31
+ vi.stubEnv("FIREBASE_CONFIG", JSON.stringify({ projectId: "test-project" }));
32
+ vi.stubEnv("BIGQUERY_DATASET_LOCATION", "EU");
33
+ vi.stubEnv("DATASET_ID", "analytics");
34
+ vi.stubEnv("TABLE_NAME", "users");
35
+ vi.stubEnv("QUERY_STRING", "SELECT * FROM source.users");
36
+ vi.stubEnv("DISPLAY_NAME", "Users export");
37
+ vi.stubEnv("SCHEDULE", "every 24 hours");
38
+ vi.stubEnv("COLLECTION_PATH", "transferConfigs");
39
+ vi.stubEnv("LOG_LEVEL", "info");
40
+ }
20
41
 
21
42
  afterEach(() => {
22
43
  vi.unstubAllEnvs();
23
44
  });
24
45
 
25
46
  describe("CONFIG_EXPRESSIONS", () => {
26
- test("binds the trigger to the Pub/Sub topic parameter", () => {
47
+ test("binds the trigger to the Pub/Sub topic parameter", async () => {
48
+ const { CONFIG_EXPRESSIONS } = await importConfig(INSTANCE_ID);
49
+
27
50
  expect(CONFIG_EXPRESSIONS.pubSubTopic).toBeInstanceOf(Expression);
28
51
  expect((CONFIG_EXPRESSIONS.pubSubTopic as Expression<string>).toCEL()).toBe(
29
52
  "{{ params.PUB_SUB_TOPIC }}"
30
53
  );
31
54
  });
32
55
 
33
- test("defaults the topic parameter to the instance-namespaced kit topic", () => {
56
+ test("defaults the topic parameter to the instance-namespaced kit topic", async () => {
57
+ const { CONFIG_EXPRESSIONS } = await importConfig(INSTANCE_ID);
58
+
34
59
  const spec = (
35
60
  CONFIG_EXPRESSIONS.pubSubTopic as unknown as {
36
61
  toSpec: () => { default?: string };
37
62
  }
38
63
  ).toSpec();
39
- expect(spec.default).toBe("kit-{{ params.INSTANCE_ID }}-processMessages");
64
+ expect(spec.default).toBe("kit-users-export-processMessages");
40
65
  });
41
66
 
42
- test("accepts a topic ID but rejects a full resource name", () => {
67
+ test("accepts a topic ID but rejects a full resource name", async () => {
68
+ const { CONFIG_EXPRESSIONS } = await importConfig(INSTANCE_ID);
69
+
43
70
  const spec = (
44
71
  CONFIG_EXPRESSIONS.pubSubTopic as unknown as {
45
72
  toSpec: () => { input?: { text?: { validationRegex?: string } } };
@@ -60,22 +87,40 @@ describe("CONFIG_EXPRESSIONS", () => {
60
87
  });
61
88
  });
62
89
 
63
- describe("configFromEnv", () => {
64
- test("reads runtime parameters and derives the same topic", () => {
65
- vi.stubEnv(
66
- "FIREBASE_CONFIG",
67
- JSON.stringify({ projectId: "test-project" })
90
+ describe("instance id", () => {
91
+ // The CLI injects FIREBASE_KIT_INSTANCE_ID as a reserved env var; declaring
92
+ // it (or INSTANCE_ID) as a param makes the CLI prompt for a value it cannot
93
+ // accept and abort loading the kit.
94
+ test("is not declared as a param", async () => {
95
+ await importConfig(INSTANCE_ID);
96
+
97
+ const declared = declaredParams.map((param) => param.name);
98
+ expect(declared).toContain("PUB_SUB_TOPIC");
99
+ expect(declared).not.toContain("INSTANCE_ID");
100
+ expect(declared).not.toContain("FIREBASE_KIT_INSTANCE_ID");
101
+ });
102
+
103
+ test("fails discovery when FIREBASE_KIT_INSTANCE_ID is missing", async () => {
104
+ await expect(importConfig(undefined)).rejects.toThrow(
105
+ /FIREBASE_KIT_INSTANCE_ID is not set/
68
106
  );
69
- vi.stubEnv("INSTANCE_ID", "users-export");
70
- vi.stubEnv("BIGQUERY_DATASET_LOCATION", "EU");
71
- vi.stubEnv("DATASET_ID", "analytics");
72
- vi.stubEnv("TABLE_NAME", "users");
73
- vi.stubEnv("QUERY_STRING", "SELECT * FROM source.users");
74
- vi.stubEnv("DISPLAY_NAME", "Users export");
75
- vi.stubEnv("SCHEDULE", "every 24 hours");
76
- vi.stubEnv("COLLECTION_PATH", "transferConfigs");
77
- vi.stubEnv("LOG_LEVEL", "info");
107
+ });
108
+
109
+ test("throws at runtime when FIREBASE_KIT_INSTANCE_ID is missing", async () => {
110
+ const { configFromEnv } = await importConfig(INSTANCE_ID);
111
+ stubRuntimeEnv();
112
+ vi.stubEnv("FIREBASE_KIT_INSTANCE_ID", undefined);
78
113
 
114
+ expect(() => configFromEnv()).toThrow(
115
+ /FIREBASE_KIT_INSTANCE_ID is not set/
116
+ );
117
+ });
118
+ });
119
+
120
+ describe("configFromEnv", () => {
121
+ test("reads runtime parameters and derives the same topic", async () => {
122
+ const { configFromEnv } = await importConfig(INSTANCE_ID);
123
+ stubRuntimeEnv();
79
124
  vi.stubEnv("PUB_SUB_TOPIC", "kit-users-export-processMessages");
80
125
 
81
126
  expect(configFromEnv()).toMatchObject({
@@ -90,20 +135,9 @@ describe("configFromEnv", () => {
90
135
  });
91
136
  });
92
137
 
93
- test("passes through a topic pointing at the extension's own topic", () => {
94
- vi.stubEnv(
95
- "FIREBASE_CONFIG",
96
- JSON.stringify({ projectId: "test-project" })
97
- );
98
- vi.stubEnv("INSTANCE_ID", "users-export");
99
- vi.stubEnv("BIGQUERY_DATASET_LOCATION", "EU");
100
- vi.stubEnv("DATASET_ID", "analytics");
101
- vi.stubEnv("TABLE_NAME", "users");
102
- vi.stubEnv("QUERY_STRING", "SELECT * FROM source.users");
103
- vi.stubEnv("DISPLAY_NAME", "Users export");
104
- vi.stubEnv("SCHEDULE", "every 24 hours");
105
- vi.stubEnv("COLLECTION_PATH", "transferConfigs");
106
- vi.stubEnv("LOG_LEVEL", "info");
138
+ test("passes through a topic pointing at the extension's own topic", async () => {
139
+ const { configFromEnv } = await importConfig(INSTANCE_ID);
140
+ stubRuntimeEnv();
107
141
  vi.stubEnv("PUB_SUB_TOPIC", "ext-users-export-processMessages");
108
142
 
109
143
  expect(configFromEnv().pubSubTopic).toBe(
package/tests/dts.test.ts CHANGED
@@ -85,6 +85,7 @@ describe("constructUpdateTransferConfigRequest", () => {
85
85
  const client = clientWithTransferConfig({
86
86
  name: "projects/p/locations/us/transferConfigs/c",
87
87
  destinationDatasetId: "analytics",
88
+ displayName: "Users export",
88
89
  schedule: "every 24 hours",
89
90
  notificationPubsubTopic:
90
91
  "projects/test-project/topics/kit-users-export-processMessages",
@@ -108,6 +109,63 @@ describe("constructUpdateTransferConfigRequest", () => {
108
109
  expect(request.updateMask?.paths).toEqual(["params"]);
109
110
  });
110
111
 
112
+ test("updates the display name when it changed", async () => {
113
+ const client = clientWithTransferConfig({
114
+ name: "projects/p/locations/us/transferConfigs/c",
115
+ destinationDatasetId: "analytics",
116
+ displayName: "Old export name",
117
+ schedule: "every 24 hours",
118
+ notificationPubsubTopic:
119
+ "projects/test-project/topics/kit-users-export-processMessages",
120
+ params: {
121
+ fields: {
122
+ query: { stringValue: config.queryString },
123
+ destination_table_name_template: {
124
+ stringValue: 'users_{run_time|"%H%M%S"}',
125
+ },
126
+ partitioning_field: { stringValue: "created_at" },
127
+ },
128
+ },
129
+ });
130
+
131
+ const request = await constructUpdateTransferConfigRequest(
132
+ client,
133
+ "projects/p/locations/us/transferConfigs/c",
134
+ config
135
+ );
136
+
137
+ expect(request.updateMask?.paths).toEqual(["display_name"]);
138
+ expect(request.transferConfig?.displayName).toBe("Users export");
139
+ });
140
+
141
+ test("leaves the display name out of the mask when unchanged", async () => {
142
+ const client = clientWithTransferConfig({
143
+ name: "projects/p/locations/us/transferConfigs/c",
144
+ destinationDatasetId: "analytics",
145
+ displayName: "Users export",
146
+ schedule: "every 12 hours",
147
+ notificationPubsubTopic:
148
+ "projects/test-project/topics/kit-users-export-processMessages",
149
+ params: {
150
+ fields: {
151
+ query: { stringValue: config.queryString },
152
+ destination_table_name_template: {
153
+ stringValue: 'users_{run_time|"%H%M%S"}',
154
+ },
155
+ partitioning_field: { stringValue: "created_at" },
156
+ },
157
+ },
158
+ });
159
+
160
+ const request = await constructUpdateTransferConfigRequest(
161
+ client,
162
+ "projects/p/locations/us/transferConfigs/c",
163
+ config
164
+ );
165
+
166
+ expect(request.updateMask?.paths).toEqual(["schedule"]);
167
+ });
168
+
111
169
  test("rejects clearing an existing partitioning field", async () => {
112
170
  const client = clientWithTransferConfig({
113
171
  name: "projects/p/locations/us/transferConfigs/c",
@@ -110,10 +110,46 @@ describe("convertUnsupportedDataTypes", () => {
110
110
  );
111
111
  });
112
112
 
113
- test("throws on a TIME value, which no Date can represent", () => {
114
- expect(() =>
115
- convertUnsupportedDataTypes({ time: new BigQueryTime("10:30:00") })
116
- ).toThrow('Value for argument "seconds" is not a valid integer.');
113
+ // The strings here are the values a live query returns for
114
+ // TIME "10:30:00", TIME "10:30:00.123456" and TIME "00:00:00".
115
+ test("keeps a TIME value as the string BigQuery returned", () => {
116
+ expect(
117
+ convertUnsupportedDataTypes({
118
+ plain: new BigQueryTime("10:30:00"),
119
+ micros: new BigQueryTime("10:30:00.123456"),
120
+ midnight: new BigQueryTime("00:00:00"),
121
+ })
122
+ ).toEqual({
123
+ plain: "10:30:00",
124
+ micros: "10:30:00.123456",
125
+ midnight: "00:00:00",
126
+ });
127
+ });
128
+
129
+ test("keeps TIME values nested in arrays and structs", () => {
130
+ expect(
131
+ convertUnsupportedDataTypes({
132
+ times: [new BigQueryTime("01:02:03"), new BigQueryTime("04:05:06")],
133
+ outer: { inner: new BigQueryTime("07:08:09") },
134
+ })
135
+ ).toEqual({
136
+ times: ["01:02:03", "04:05:06"],
137
+ outer: { inner: "07:08:09" },
138
+ });
139
+ });
140
+
141
+ test("still converts the other temporal types alongside a TIME", () => {
142
+ const converted = convertUnsupportedDataTypes({
143
+ time: new BigQueryTime("10:30:00"),
144
+ timestamp: new BigQueryTimestamp("2023-01-15T10:30:00.000Z"),
145
+ date: new BigQueryDate("2023-01-15"),
146
+ datetime: new BigQueryDatetime("2023-01-15T10:30:00"),
147
+ });
148
+
149
+ expect(converted.time).toBe("10:30:00");
150
+ expect(converted.timestamp).toBeInstanceOf(Timestamp);
151
+ expect(converted.date).toBeInstanceOf(Timestamp);
152
+ expect(converted.datetime).toBeInstanceOf(Timestamp);
117
153
  });
118
154
 
119
155
  test("converts a plain Date to a Firestore Timestamp", () => {