@firebase-function-kits/bigquery-firestore-export 0.0.1 → 0.0.2-rc.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +188 -0
  3. package/lib/config.d.ts +5 -0
  4. package/lib/config.d.ts.map +1 -0
  5. package/lib/config.js +75 -0
  6. package/lib/config.js.map +1 -0
  7. package/lib/dts.d.ts +14 -0
  8. package/lib/dts.d.ts.map +1 -0
  9. package/lib/dts.js +189 -0
  10. package/lib/dts.js.map +1 -0
  11. package/lib/export-config.d.ts +58 -0
  12. package/lib/export-config.d.ts.map +1 -0
  13. package/lib/export-config.js +56 -0
  14. package/lib/export-config.js.map +1 -0
  15. package/lib/handlers.d.ts +22 -0
  16. package/lib/handlers.d.ts.map +1 -0
  17. package/lib/handlers.js +125 -0
  18. package/lib/handlers.js.map +1 -0
  19. package/lib/helper.d.ts +37 -0
  20. package/lib/helper.d.ts.map +1 -0
  21. package/lib/helper.js +219 -0
  22. package/lib/helper.js.map +1 -0
  23. package/lib/index.d.ts +7 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +137 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/lib.d.ts +11 -0
  28. package/lib/lib.d.ts.map +1 -0
  29. package/lib/lib.js +47 -0
  30. package/lib/lib.js.map +1 -0
  31. package/lib/logs.d.ts +21 -0
  32. package/lib/logs.d.ts.map +1 -0
  33. package/lib/logs.js +122 -0
  34. package/lib/logs.js.map +1 -0
  35. package/lib/metadata.d.ts +6 -0
  36. package/lib/metadata.d.ts.map +1 -0
  37. package/lib/metadata.js +34 -0
  38. package/lib/metadata.js.map +1 -0
  39. package/lib/types.d.ts +44 -0
  40. package/lib/types.d.ts.map +1 -0
  41. package/lib/types.js +18 -0
  42. package/lib/types.js.map +1 -0
  43. package/package.json +40 -4
  44. package/src/config.ts +88 -0
  45. package/src/dts.ts +214 -0
  46. package/src/export-config.ts +123 -0
  47. package/src/handlers.ts +141 -0
  48. package/src/helper.ts +320 -0
  49. package/src/index.ts +126 -0
  50. package/src/lib.ts +69 -0
  51. package/src/logs.ts +145 -0
  52. package/src/metadata.ts +31 -0
  53. package/src/types.ts +100 -0
  54. package/tests/config.test.ts +61 -0
  55. package/tests/dts.test.ts +124 -0
  56. package/tests/export-config.test.ts +78 -0
  57. package/tests/handlers.test.ts +170 -0
  58. package/tests/helper.test.ts +74 -0
  59. package/tests/lib.test.ts +43 -0
  60. package/tsconfig.json +18 -0
  61. package/tsconfig.tsbuildinfo +1 -0
  62. package/index.js +0 -0
@@ -0,0 +1,123 @@
1
+ /*
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { Expression } from "firebase-functions/params";
18
+
19
+ /** Log levels supported by the original extension. */
20
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
21
+
22
+ /** Public configuration for the BigQuery-to-Firestore functions. */
23
+ export interface BigqueryFirestoreExportConfig {
24
+ /** Location of the BigQuery destination dataset, for example `US`. */
25
+ bigqueryDatasetLocation: string;
26
+ /** Google Cloud project id. */
27
+ projectId: string;
28
+ /** Stable id used to associate a DTS config with this deployment. */
29
+ instanceId: string;
30
+ /** Link this existing DTS config instead of creating one. */
31
+ transferConfigName?: string;
32
+ /** BigQuery destination dataset id. */
33
+ datasetId: string;
34
+ /** Prefix for per-run destination tables. */
35
+ tableName: string;
36
+ /** Scheduled query text. */
37
+ queryString: string;
38
+ /** Human-readable DTS scheduled-query name. */
39
+ displayName: string;
40
+ /** Optional destination-table partitioning field. */
41
+ partitioningField?: string;
42
+ /** BigQuery Data Transfer schedule, for example `every 15 minutes`. */
43
+ schedule: string;
44
+ /** Pub/Sub topic receiving DTS completion notifications. */
45
+ pubSubTopic?: string;
46
+ /** Root Firestore collection for configs and run output. */
47
+ firestoreCollection?: string;
48
+ /** Runtime identity used when creating a DTS config. */
49
+ serviceAccount?: string;
50
+ /** Log verbosity. Defaults to `info`. */
51
+ logLevel?: LogLevel;
52
+ }
53
+
54
+ /** Configuration after defaults and validation have been applied. */
55
+ export interface ResolvedBigqueryFirestoreExportConfig {
56
+ bigqueryDatasetLocation: string;
57
+ projectId: string;
58
+ instanceId: string;
59
+ transferConfigName?: string;
60
+ datasetId: string;
61
+ tableName: string;
62
+ queryString: string;
63
+ displayName: string;
64
+ partitioningField?: string;
65
+ schedule: string;
66
+ pubSubTopic: string;
67
+ firestoreCollection: string;
68
+ serviceAccount?: string;
69
+ logLevel: LogLevel;
70
+ }
71
+
72
+ /** Deploy-time values used to construct the v2 triggers. */
73
+ export interface DeployTimeOptions {
74
+ pubSubTopic: string | Expression<string>;
75
+ }
76
+
77
+ function required(value: string, field: string): string {
78
+ const normalized = value.trim();
79
+ if (!normalized) {
80
+ throw new Error(`${field} must be a non-empty string.`);
81
+ }
82
+ return normalized;
83
+ }
84
+
85
+ function optional(value: string | undefined): string | undefined {
86
+ if (value === undefined) return undefined;
87
+ const normalized = value.trim();
88
+ return normalized || undefined;
89
+ }
90
+
91
+ /** Applies package defaults and validates a user-supplied configuration. */
92
+ export function resolveConfig(
93
+ config: BigqueryFirestoreExportConfig
94
+ ): ResolvedBigqueryFirestoreExportConfig {
95
+ const instanceId = required(config.instanceId, "instanceId");
96
+ const logLevel = config.logLevel ?? "info";
97
+
98
+ if (!["debug", "info", "warn", "error", "silent"].includes(logLevel)) {
99
+ throw new Error(`Unsupported logLevel: ${logLevel}`);
100
+ }
101
+
102
+ return {
103
+ bigqueryDatasetLocation: required(
104
+ config.bigqueryDatasetLocation,
105
+ "bigqueryDatasetLocation"
106
+ ),
107
+ projectId: required(config.projectId, "projectId"),
108
+ instanceId,
109
+ transferConfigName: optional(config.transferConfigName),
110
+ datasetId: required(config.datasetId, "datasetId"),
111
+ tableName: required(config.tableName, "tableName"),
112
+ queryString: required(config.queryString, "queryString"),
113
+ displayName: required(config.displayName, "displayName"),
114
+ partitioningField: optional(config.partitioningField),
115
+ schedule: required(config.schedule, "schedule"),
116
+ pubSubTopic:
117
+ optional(config.pubSubTopic) ?? `kit-${instanceId}-processMessages`,
118
+ firestoreCollection:
119
+ optional(config.firestoreCollection) ?? "transferConfigs",
120
+ serviceAccount: optional(config.serviceAccount),
121
+ logLevel,
122
+ };
123
+ }
@@ -0,0 +1,141 @@
1
+ /*
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { BigQuery } from "@google-cloud/bigquery";
18
+ import type { PubSub } from "@google-cloud/pubsub";
19
+ import type { Firestore } from "firebase-admin/firestore";
20
+ import type { CloudEvent } from "firebase-functions/v2";
21
+ import type { MessagePublishedData } from "firebase-functions/v2/pubsub";
22
+ import {
23
+ createTransferConfig,
24
+ type DataTransferClient,
25
+ getTransferConfig,
26
+ updateTransferConfig,
27
+ } from "./dts";
28
+ import type { ResolvedBigqueryFirestoreExportConfig } from "./export-config";
29
+ import { handleTransferRunMessage, parseTransferConfigName } from "./helper";
30
+ import * as logs from "./logs";
31
+ import type { TransferRunPayload } from "./types";
32
+
33
+ export type TransferRunEvent = CloudEvent<
34
+ MessagePublishedData<TransferRunPayload>
35
+ >;
36
+
37
+ /** External services used by both handlers, injected for testability. */
38
+ export interface HandlerContext {
39
+ db: Firestore;
40
+ bigquery: BigQuery;
41
+ dataTransfer: DataTransferClient;
42
+ pubsub: PubSub;
43
+ config: ResolvedBigqueryFirestoreExportConfig;
44
+ }
45
+
46
+ async function ensureNotificationTopic(ctx: HandlerContext): Promise<void> {
47
+ const topic = ctx.pubsub.topic(ctx.config.pubSubTopic);
48
+ const [exists] = await topic.exists();
49
+ if (exists) return;
50
+
51
+ try {
52
+ await ctx.pubsub.createTopic(ctx.config.pubSubTopic);
53
+ logs.topicCreated(ctx.config.pubSubTopic);
54
+ } catch (err) {
55
+ // A concurrent init can create the same topic between exists() and create().
56
+ if (
57
+ typeof err !== "object" ||
58
+ err === null ||
59
+ !("code" in err) ||
60
+ err.code !== 6
61
+ ) {
62
+ throw err;
63
+ }
64
+ }
65
+ }
66
+
67
+ async function storeTransferConfig(
68
+ ctx: HandlerContext,
69
+ transferConfig: Awaited<ReturnType<typeof getTransferConfig>>
70
+ ): Promise<void> {
71
+ if (!transferConfig?.name) {
72
+ throw new Error("BigQuery transfer config is missing its resource name");
73
+ }
74
+ const { transferConfigId } = parseTransferConfigName(transferConfig.name);
75
+ await ctx.db
76
+ .collection(ctx.config.firestoreCollection)
77
+ .doc(transferConfigId)
78
+ .set({ extInstanceId: ctx.config.instanceId, ...transferConfig });
79
+ }
80
+
81
+ /** Handles a v2 Pub/Sub completion notification from BigQuery DTS. */
82
+ export async function handleMessagePublished(
83
+ event: TransferRunEvent,
84
+ ctx: HandlerContext
85
+ ): Promise<void> {
86
+ logs.start();
87
+ try {
88
+ await handleTransferRunMessage(ctx, { json: event.data.message.json });
89
+ logs.complete();
90
+ } catch (err) {
91
+ logs.error(err);
92
+ throw err;
93
+ }
94
+ }
95
+
96
+ /** Idempotently creates, links, or updates this deployment's DTS config. */
97
+ export async function handleUpsertTransferConfig(
98
+ ctx: HandlerContext
99
+ ): Promise<void> {
100
+ await ensureNotificationTopic(ctx);
101
+
102
+ if (ctx.config.transferConfigName) {
103
+ const linked = await getTransferConfig(
104
+ ctx.dataTransfer,
105
+ ctx.config.transferConfigName
106
+ );
107
+ if (!linked) {
108
+ throw new Error(
109
+ `Transfer config not found: ${ctx.config.transferConfigName}`
110
+ );
111
+ }
112
+ await storeTransferConfig(ctx, linked);
113
+ return;
114
+ }
115
+
116
+ const existing = await ctx.db
117
+ .collection(ctx.config.firestoreCollection)
118
+ .where("extInstanceId", "==", ctx.config.instanceId)
119
+ .limit(1)
120
+ .get();
121
+
122
+ if (existing.empty) {
123
+ const created = await createTransferConfig(ctx.dataTransfer, ctx.config);
124
+ await storeTransferConfig(ctx, created);
125
+ return;
126
+ }
127
+
128
+ const transferConfigName = existing.docs[0].data().name;
129
+ if (typeof transferConfigName !== "string" || !transferConfigName) {
130
+ throw new Error(
131
+ `Existing transfer config document in ${ctx.config.firestoreCollection} is missing required 'name' field.`
132
+ );
133
+ }
134
+
135
+ const updated = await updateTransferConfig(
136
+ ctx.dataTransfer,
137
+ transferConfigName,
138
+ ctx.config
139
+ );
140
+ await storeTransferConfig(ctx, updated);
141
+ }
package/src/helper.ts ADDED
@@ -0,0 +1,320 @@
1
+ /*
2
+ * Copyright 2025 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import {
18
+ type BigQuery,
19
+ BigQueryDate,
20
+ BigQueryDatetime,
21
+ BigQueryTime,
22
+ BigQueryTimestamp,
23
+ Geography,
24
+ } from "@google-cloud/bigquery";
25
+ import {
26
+ type DocumentData,
27
+ type DocumentReference,
28
+ type Firestore,
29
+ Timestamp,
30
+ } from "firebase-admin/firestore";
31
+ import type { ResolvedBigqueryFirestoreExportConfig } from "./export-config";
32
+ import * as logs from "./logs";
33
+ import type {
34
+ BigQueryRow,
35
+ BigQueryRowValue,
36
+ FirestoreRow,
37
+ FirestoreRowValue,
38
+ TransferRunMessage,
39
+ } from "./types";
40
+
41
+ export interface ParsedTransferRunName {
42
+ projectId: string;
43
+ location: string;
44
+ transferConfigId: string;
45
+ runId: string;
46
+ }
47
+
48
+ export interface ParsedTransferConfigName {
49
+ projectId: string;
50
+ location: string;
51
+ transferConfigId: string;
52
+ }
53
+
54
+ export interface ResultHandlerContext {
55
+ db: Firestore;
56
+ bigquery: BigQuery;
57
+ config: ResolvedBigqueryFirestoreExportConfig;
58
+ }
59
+
60
+ const TRANSFER_RUN_NAME_REGEX =
61
+ /^projects\/([^/]+)\/locations\/([^/]+)\/transferConfigs\/([^/]+)\/runs\/([^/]+)$/;
62
+ const TRANSFER_CONFIG_NAME_REGEX =
63
+ /^projects\/([^/]+)\/locations\/([^/]+)\/transferConfigs\/([^/]+)$/;
64
+ const FIRESTORE_WRITE_CHUNK_SIZE = 10_000;
65
+
66
+ export function parseTransferRunName(name: string): ParsedTransferRunName {
67
+ const match = name.match(TRANSFER_RUN_NAME_REGEX);
68
+ if (!match) {
69
+ throw new Error(
70
+ `Invalid transfer run name format: "${name}". Expected format: projects/{projectId}/locations/{location}/transferConfigs/{configId}/runs/{runId}`
71
+ );
72
+ }
73
+
74
+ return {
75
+ projectId: match[1],
76
+ location: match[2],
77
+ transferConfigId: match[3],
78
+ runId: match[4],
79
+ };
80
+ }
81
+
82
+ export function parseTransferConfigName(
83
+ name: string
84
+ ): ParsedTransferConfigName {
85
+ const match = name.match(TRANSFER_CONFIG_NAME_REGEX);
86
+ if (!match) {
87
+ throw new Error(
88
+ `Invalid transfer config name format: "${name}". Expected format: projects/{projectId}/locations/{location}/transferConfigs/{configId}`
89
+ );
90
+ }
91
+
92
+ return {
93
+ projectId: match[1],
94
+ location: match[2],
95
+ transferConfigId: match[3],
96
+ };
97
+ }
98
+
99
+ export async function updateLatestRunDocument(
100
+ db: Firestore,
101
+ config: ResolvedBigqueryFirestoreExportConfig,
102
+ transferConfigId: string,
103
+ runId: string,
104
+ message: TransferRunMessage,
105
+ rowCounts: { failedRowCount: number; totalRowCount: number }
106
+ ): Promise<void> {
107
+ const latestRef = db
108
+ .collection(`${config.firestoreCollection}/${transferConfigId}/runs`)
109
+ .doc("latest");
110
+ const runTime = new Date(message.json.runTime);
111
+ const docUpdate = {
112
+ runMetadata: message.json,
113
+ latestRunId: runId,
114
+ ...rowCounts,
115
+ };
116
+
117
+ await db.runTransaction(async (transaction) => {
118
+ const latest = await transaction.get(latestRef);
119
+ const latestData = latest.data();
120
+ const existingRunTime = latestData?.runMetadata?.runTime;
121
+ const existingRunId = latestData?.latestRunId;
122
+ const shouldUpdate =
123
+ !latestData ||
124
+ !existingRunTime ||
125
+ new Date(existingRunTime) < runTime ||
126
+ existingRunId === runId;
127
+
128
+ if (shouldUpdate) {
129
+ transaction.set(latestRef, docUpdate);
130
+ return;
131
+ }
132
+
133
+ logs.latestDocUpdateSkipped(
134
+ transferConfigId,
135
+ runId,
136
+ `existing run is newer (${existingRunTime} >= ${message.json.runTime})`
137
+ );
138
+ });
139
+ }
140
+
141
+ export async function getBigqueryResults(
142
+ bigquery: BigQuery,
143
+ config: ResolvedBigqueryFirestoreExportConfig,
144
+ transferConfigId: string,
145
+ runId: string,
146
+ datasetId: string,
147
+ tableName: string
148
+ ): Promise<BigQueryRow[]> {
149
+ const query = `SELECT * FROM \`${config.projectId}.${datasetId}.${tableName}\``;
150
+
151
+ try {
152
+ const [job] = await bigquery.createQueryJob({
153
+ query,
154
+ location: config.bigqueryDatasetLocation,
155
+ });
156
+ logs.bigqueryJobStarted(job.id);
157
+ const [rows] = await job.getQueryResults();
158
+ logs.bigqueryResultsRowCount(transferConfigId, runId, rows.length);
159
+ return rows as BigQueryRow[];
160
+ } catch (err) {
161
+ logs.bigqueryQueryFailed(transferConfigId, runId, tableName, err);
162
+ throw err;
163
+ }
164
+ }
165
+
166
+ export function convertUnsupportedDataTypes(row: null): null;
167
+ export function convertUnsupportedDataTypes(row: string): string;
168
+ export function convertUnsupportedDataTypes(row: number): number;
169
+ export function convertUnsupportedDataTypes(row: boolean): boolean;
170
+ export function convertUnsupportedDataTypes(row: BigQueryRow): FirestoreRow;
171
+ export function convertUnsupportedDataTypes(
172
+ row: BigQueryRowValue
173
+ ): FirestoreRowValue;
174
+ export function convertUnsupportedDataTypes(
175
+ row: BigQueryRowValue
176
+ ): FirestoreRowValue {
177
+ if (row === null || typeof row !== "object") {
178
+ return row as FirestoreRowValue;
179
+ }
180
+
181
+ if (
182
+ row instanceof BigQueryTimestamp ||
183
+ row instanceof BigQueryDate ||
184
+ row instanceof BigQueryTime ||
185
+ row instanceof BigQueryDatetime
186
+ ) {
187
+ return Timestamp.fromDate(new Date(row.value));
188
+ }
189
+ if (row instanceof Date) return Timestamp.fromDate(row);
190
+ if (row instanceof Buffer) return new Uint8Array(row);
191
+ if (row instanceof Geography) return row.value;
192
+ if (Array.isArray(row)) {
193
+ return row.map((value) => convertUnsupportedDataTypes(value));
194
+ }
195
+
196
+ return Object.fromEntries(
197
+ Object.entries(row).map(([key, value]) => [
198
+ key,
199
+ convertUnsupportedDataTypes(value),
200
+ ])
201
+ ) as FirestoreRow;
202
+ }
203
+
204
+ export async function writeRunResultsToFirestore(
205
+ ctx: ResultHandlerContext,
206
+ message: TransferRunMessage
207
+ ): Promise<void> {
208
+ const { db, bigquery, config } = ctx;
209
+ const { transferConfigId, runId } = parseTransferRunName(message.json.name);
210
+ const runTime = new Date(message.json.runTime);
211
+ const runTimeSuffix = [
212
+ runTime.getUTCHours(),
213
+ runTime.getUTCMinutes(),
214
+ runTime.getUTCSeconds(),
215
+ ]
216
+ .map((part) => String(part).padStart(2, "0"))
217
+ .join("");
218
+ const tableName = message.json.params.destination_table_name_template.replace(
219
+ '{run_time|"%H%M%S"}',
220
+ runTimeSuffix
221
+ );
222
+ const rows = await getBigqueryResults(
223
+ bigquery,
224
+ config,
225
+ transferConfigId,
226
+ runId,
227
+ message.json.destinationDatasetId,
228
+ tableName
229
+ );
230
+ const collection = db.collection(
231
+ `${config.firestoreCollection}/${transferConfigId}/runs/${runId}/output`
232
+ );
233
+ let succeededRowCount = 0;
234
+
235
+ for (let i = 0; i < rows.length; i += FIRESTORE_WRITE_CHUNK_SIZE) {
236
+ const writes: Array<Promise<DocumentReference<DocumentData>>> = [];
237
+ for (
238
+ let j = i;
239
+ j < i + FIRESTORE_WRITE_CHUNK_SIZE && j < rows.length;
240
+ j++
241
+ ) {
242
+ writes.push(collection.add(convertUnsupportedDataTypes(rows[j])));
243
+ }
244
+
245
+ const results = await Promise.allSettled(writes);
246
+ for (const result of results) {
247
+ if (result.status === "fulfilled") succeededRowCount++;
248
+ else logs.errorWritingToFirestore(result.reason);
249
+ }
250
+ }
251
+
252
+ const rowCounts = {
253
+ failedRowCount: rows.length - succeededRowCount,
254
+ totalRowCount: rows.length,
255
+ };
256
+ logs.runResultsWrittenToFirestore(runId, succeededRowCount, rows.length);
257
+
258
+ await db
259
+ .collection(`${config.firestoreCollection}/${transferConfigId}/runs`)
260
+ .doc(runId)
261
+ .set({ runMetadata: message.json, ...rowCounts });
262
+ await updateLatestRunDocument(
263
+ db,
264
+ config,
265
+ transferConfigId,
266
+ runId,
267
+ message,
268
+ rowCounts
269
+ );
270
+ }
271
+
272
+ export async function transferConfigAssociatedWithInstance(
273
+ db: Firestore,
274
+ config: ResolvedBigqueryFirestoreExportConfig,
275
+ transferConfigId: string
276
+ ): Promise<boolean> {
277
+ const results = await db
278
+ .collection(config.firestoreCollection)
279
+ .where("extInstanceId", "==", config.instanceId)
280
+ .get();
281
+ return results.docs.some((doc) => doc.id === transferConfigId);
282
+ }
283
+
284
+ export async function handleTransferRunMessage(
285
+ ctx: ResultHandlerContext,
286
+ message: TransferRunMessage
287
+ ): Promise<void> {
288
+ const { transferConfigId, runId } = parseTransferRunName(message.json.name);
289
+ const associated = await transferConfigAssociatedWithInstance(
290
+ ctx.db,
291
+ ctx.config,
292
+ transferConfigId
293
+ );
294
+
295
+ if (!associated) {
296
+ throw new Error(
297
+ `Skipping handling pubsub message because transferConfig '${transferConfigId}' is not associated with extension instance '${ctx.config.instanceId}'.`
298
+ );
299
+ }
300
+
301
+ if (message.json.state === "SUCCEEDED") {
302
+ await writeRunResultsToFirestore(ctx, message);
303
+ return;
304
+ }
305
+
306
+ logs.handlingNonSuccessRun(transferConfigId, runId, message.json.state);
307
+ const rowCounts = { failedRowCount: 0, totalRowCount: 0 };
308
+ await ctx.db
309
+ .collection(`${ctx.config.firestoreCollection}/${transferConfigId}/runs`)
310
+ .doc(runId)
311
+ .set({ runMetadata: message.json, ...rowCounts });
312
+ await updateLatestRunDocument(
313
+ ctx.db,
314
+ ctx.config,
315
+ transferConfigId,
316
+ runId,
317
+ message,
318
+ rowCounts
319
+ );
320
+ }
package/src/index.ts ADDED
@@ -0,0 +1,126 @@
1
+ /*
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Main entry point. Exports the wired Pub/Sub and lifecycle task functions,
19
+ * while resolving concrete config and clients lazily at runtime. Import from
20
+ * `./lib` for the side-effect-free handlers, helpers, and config types.
21
+ */
22
+
23
+ import { BigQuery } from "@google-cloud/bigquery";
24
+ import { v1 as bigqueryDataTransfer } from "@google-cloud/bigquery-data-transfer";
25
+ import { PubSub } from "@google-cloud/pubsub";
26
+ import { getApps, initializeApp } from "firebase-admin/app";
27
+ import { getFirestore } from "firebase-admin/firestore";
28
+ import { onMessagePublished } from "firebase-functions/v2/pubsub";
29
+ import { onTaskDispatched } from "firebase-functions/v2/tasks";
30
+ import type { Role } from "firebase-functions/v2";
31
+ import { requiresAPI, requiresRole } from "firebase-functions/v2";
32
+ import {
33
+ afterFirstDeploy,
34
+ afterRedeploy,
35
+ } from "firebase-functions/v2/lifecycle";
36
+ import { CONFIG_EXPRESSIONS, configFromEnv } from "./config";
37
+ import type { ResolvedBigqueryFirestoreExportConfig } from "./export-config";
38
+ import { resolveConfig } from "./export-config";
39
+ import {
40
+ type HandlerContext,
41
+ handleMessagePublished,
42
+ handleUpsertTransferConfig,
43
+ } from "./handlers";
44
+ import * as logs from "./logs";
45
+ import type { TransferRunPayload } from "./types";
46
+
47
+ export * from "./lib";
48
+
49
+ const UPSERT_FUNCTION = "upsertTransferConfig";
50
+ const REQUIRED_ROLES: ReadonlyArray<Role> = [
51
+ "roles/datastore.user",
52
+ "roles/bigquery.admin",
53
+ "roles/pubsub.admin",
54
+ "roles/eventarc.eventReceiver",
55
+ "roles/run.invoker",
56
+ ];
57
+ const REQUIRED_APIS = [
58
+ {
59
+ api: "bigquery.googleapis.com",
60
+ reason: "Runs scheduled queries and reads their destination tables.",
61
+ },
62
+ {
63
+ api: "bigquerydatatransfer.googleapis.com",
64
+ reason: "Creates and reconciles the scheduled-query transfer config.",
65
+ },
66
+ {
67
+ api: "pubsub.googleapis.com",
68
+ reason: "Delivers BigQuery transfer-completion notifications.",
69
+ },
70
+ ] as const;
71
+
72
+ for (const role of REQUIRED_ROLES) {
73
+ requiresRole(role);
74
+ }
75
+
76
+ for (const { api, reason } of REQUIRED_APIS) {
77
+ requiresAPI(api, reason);
78
+ }
79
+
80
+ afterFirstDeploy({
81
+ task: { function: UPSERT_FUNCTION, body: { data: {} } },
82
+ });
83
+ afterRedeploy({
84
+ task: { function: UPSERT_FUNCTION, body: { data: {} } },
85
+ });
86
+
87
+ let ctx: HandlerContext | undefined;
88
+
89
+ function getContext(): HandlerContext {
90
+ if (ctx) return ctx;
91
+
92
+ const config: ResolvedBigqueryFirestoreExportConfig = resolveConfig(
93
+ configFromEnv()
94
+ );
95
+ if (getApps().length === 0) initializeApp({ projectId: config.projectId });
96
+ logs.init(config);
97
+
98
+ ctx = {
99
+ db: getFirestore(),
100
+ bigquery: new BigQuery({ projectId: config.projectId }),
101
+ dataTransfer: new bigqueryDataTransfer.DataTransferServiceClient({
102
+ projectId: config.projectId,
103
+ }),
104
+ pubsub: new PubSub({ projectId: config.projectId }),
105
+ config,
106
+ };
107
+ return ctx;
108
+ }
109
+
110
+ /** Consumes BigQuery Data Transfer completion notifications. */
111
+ export const processMessages = onMessagePublished<TransferRunPayload>(
112
+ {
113
+ topic: CONFIG_EXPRESSIONS.pubSubTopic,
114
+ retry: true,
115
+ },
116
+ (event) => handleMessagePublished(event, getContext())
117
+ );
118
+
119
+ /** Creates, links, or reconciles this deployment's scheduled query. */
120
+ export const upsertTransferConfig = onTaskDispatched(
121
+ {
122
+ memory: "1GiB",
123
+ retryConfig: { maxAttempts: 5, minBackoffSeconds: 30 },
124
+ },
125
+ () => handleUpsertTransferConfig(getContext())
126
+ );