@firebase-function-kits/bigquery-firestore-export 0.0.1 → 0.0.2-rc.1
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/CHANGELOG.md +1 -0
- package/README.md +308 -0
- package/lib/config.d.ts +5 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +181 -0
- package/lib/config.js.map +1 -0
- package/lib/dts.d.ts +21 -0
- package/lib/dts.d.ts.map +1 -0
- package/lib/dts.js +193 -0
- package/lib/dts.js.map +1 -0
- package/lib/export-config.d.ts +55 -0
- package/lib/export-config.d.ts.map +1 -0
- package/lib/export-config.js +55 -0
- package/lib/export-config.js.map +1 -0
- package/lib/handlers.d.ts +22 -0
- package/lib/handlers.d.ts.map +1 -0
- package/lib/handlers.js +125 -0
- package/lib/handlers.js.map +1 -0
- package/lib/helper.d.ts +37 -0
- package/lib/helper.d.ts.map +1 -0
- package/lib/helper.js +219 -0
- package/lib/helper.js.map +1 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +137 -0
- package/lib/index.js.map +1 -0
- package/lib/lib.d.ts +11 -0
- package/lib/lib.d.ts.map +1 -0
- package/lib/lib.js +47 -0
- package/lib/lib.js.map +1 -0
- package/lib/logs.d.ts +21 -0
- package/lib/logs.d.ts.map +1 -0
- package/lib/logs.js +122 -0
- package/lib/logs.js.map +1 -0
- package/lib/metadata.d.ts +6 -0
- package/lib/metadata.d.ts.map +1 -0
- package/lib/metadata.js +34 -0
- package/lib/metadata.js.map +1 -0
- package/lib/types.d.ts +44 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +18 -0
- package/lib/types.js.map +1 -0
- package/npm-shrinkwrap.json +5049 -0
- package/package.json +40 -4
- package/src/config.ts +205 -0
- package/src/dts.ts +217 -0
- package/src/export-config.ts +119 -0
- package/src/handlers.ts +141 -0
- package/src/helper.ts +320 -0
- package/src/index.ts +126 -0
- package/src/lib.ts +69 -0
- package/src/logs.ts +145 -0
- package/src/metadata.ts +31 -0
- package/src/types.ts +100 -0
- package/tests/config.test.ts +61 -0
- package/tests/dts.test.ts +120 -0
- package/tests/export-config.test.ts +74 -0
- package/tests/handlers.test.ts +170 -0
- package/tests/helper.test.ts +74 -0
- package/tests/lib.test.ts +43 -0
- package/tsconfig.json +18 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/index.js +0 -0
package/src/handlers.ts
ADDED
|
@@ -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: false,
|
|
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
|
+
);
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
* Side-effect-free library surface. Importing this module registers no
|
|
19
|
+
* functions, lifecycle hooks, roles, or APIs and reads no environment params.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
constructUpdateTransferConfigRequest,
|
|
24
|
+
createTransferConfig,
|
|
25
|
+
createTransferConfigRequest,
|
|
26
|
+
type DataTransferClient,
|
|
27
|
+
getTransferConfig,
|
|
28
|
+
PARTITIONING_FIELD_REMOVAL_ERROR,
|
|
29
|
+
PARTITIONING_FIELD_REMOVAL_ERROR_PREFIX,
|
|
30
|
+
type TransferConfig,
|
|
31
|
+
updateTransferConfig,
|
|
32
|
+
} from "./dts";
|
|
33
|
+
export {
|
|
34
|
+
type BigqueryFirestoreExportConfig,
|
|
35
|
+
type DeployTimeOptions,
|
|
36
|
+
type LogLevel,
|
|
37
|
+
type ResolvedBigqueryFirestoreExportConfig,
|
|
38
|
+
resolveConfig,
|
|
39
|
+
} from "./export-config";
|
|
40
|
+
export {
|
|
41
|
+
type HandlerContext,
|
|
42
|
+
handleMessagePublished,
|
|
43
|
+
handleUpsertTransferConfig,
|
|
44
|
+
type TransferRunEvent,
|
|
45
|
+
} from "./handlers";
|
|
46
|
+
export {
|
|
47
|
+
convertUnsupportedDataTypes,
|
|
48
|
+
getBigqueryResults,
|
|
49
|
+
handleTransferRunMessage,
|
|
50
|
+
type ParsedTransferConfigName,
|
|
51
|
+
type ParsedTransferRunName,
|
|
52
|
+
parseTransferConfigName,
|
|
53
|
+
parseTransferRunName,
|
|
54
|
+
type ResultHandlerContext,
|
|
55
|
+
transferConfigAssociatedWithInstance,
|
|
56
|
+
updateLatestRunDocument,
|
|
57
|
+
writeRunResultsToFirestore,
|
|
58
|
+
} from "./helper";
|
|
59
|
+
export { metadata } from "./metadata";
|
|
60
|
+
export type {
|
|
61
|
+
BigQueryRow,
|
|
62
|
+
BigQueryRowValue,
|
|
63
|
+
FirestoreRow,
|
|
64
|
+
FirestoreRowValue,
|
|
65
|
+
TransferRunMessage,
|
|
66
|
+
TransferRunParams,
|
|
67
|
+
TransferRunPayload,
|
|
68
|
+
TransferRunState,
|
|
69
|
+
} from "./types";
|