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

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 (45) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/README.md +138 -4
  3. package/lib/config.d.ts.map +1 -1
  4. package/lib/config.js +128 -9
  5. package/lib/config.js.map +1 -1
  6. package/lib/dts.d.ts +17 -2
  7. package/lib/dts.d.ts.map +1 -1
  8. package/lib/dts.js +25 -8
  9. package/lib/dts.js.map +1 -1
  10. package/lib/export-config.d.ts +0 -3
  11. package/lib/export-config.d.ts.map +1 -1
  12. package/lib/export-config.js +0 -1
  13. package/lib/export-config.js.map +1 -1
  14. package/lib/handlers.d.ts.map +1 -1
  15. package/lib/handlers.js +26 -4
  16. package/lib/handlers.js.map +1 -1
  17. package/lib/helper.d.ts.map +1 -1
  18. package/lib/helper.js +1 -0
  19. package/lib/helper.js.map +1 -1
  20. package/lib/index.js +1 -1
  21. package/lib/index.js.map +1 -1
  22. package/lib/logs.d.ts +4 -0
  23. package/lib/logs.d.ts.map +1 -1
  24. package/lib/logs.js +18 -0
  25. package/lib/logs.js.map +1 -1
  26. package/lib/metadata.d.ts.map +1 -1
  27. package/npm-shrinkwrap.json +3841 -0
  28. package/package.json +2 -5
  29. package/src/config.ts +142 -9
  30. package/src/dts.ts +27 -9
  31. package/src/export-config.ts +0 -4
  32. package/src/handlers.ts +36 -9
  33. package/src/helper.ts +1 -0
  34. package/src/index.ts +1 -1
  35. package/src/logs.ts +28 -0
  36. package/tests/config.test.ts +54 -2
  37. package/tests/dts-transfer-config.test.ts +400 -0
  38. package/tests/dts.test.ts +18 -5
  39. package/tests/export-config.test.ts +0 -4
  40. package/tests/handlers.test.ts +113 -5
  41. package/tests/helper-values.test.ts +168 -0
  42. package/tests/helper.test.ts +65 -3
  43. package/tests/notification-topic.test.ts +151 -0
  44. package/tests/run-results.test.ts +373 -0
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,168 @@
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 {
18
+ BigQueryDate,
19
+ BigQueryDatetime,
20
+ BigQueryTime,
21
+ BigQueryTimestamp,
22
+ } from "@google-cloud/bigquery";
23
+ import { Timestamp } from "firebase-admin/firestore";
24
+ import { describe, expect, test } from "vitest";
25
+ import {
26
+ convertUnsupportedDataTypes,
27
+ parseTransferConfigName,
28
+ parseTransferRunName,
29
+ } from "../src/helper";
30
+ import type { FirestoreRow } from "../src/types";
31
+
32
+ describe("parseTransferRunName", () => {
33
+ test("parses ids containing hyphens", () => {
34
+ expect(
35
+ parseTransferRunName(
36
+ "projects/project-123/locations/us-central1/transferConfigs/642f3a36-0000-2fbb-ad1d-001a114e2fa6/runs/648762e0-0000-28ef-9109-001a11446b2a"
37
+ )
38
+ ).toEqual({
39
+ projectId: "project-123",
40
+ location: "us-central1",
41
+ transferConfigId: "642f3a36-0000-2fbb-ad1d-001a114e2fa6",
42
+ runId: "648762e0-0000-28ef-9109-001a11446b2a",
43
+ });
44
+ });
45
+
46
+ test("rejects a name without a runs segment", () => {
47
+ expect(() =>
48
+ parseTransferRunName("projects/p/locations/l/transferConfigs/c")
49
+ ).toThrow("Invalid transfer run name format");
50
+ });
51
+
52
+ test("rejects an empty name", () => {
53
+ expect(() => parseTransferRunName("")).toThrow(
54
+ "Invalid transfer run name format"
55
+ );
56
+ });
57
+ });
58
+
59
+ describe("parseTransferConfigName", () => {
60
+ test("parses ids containing hyphens", () => {
61
+ expect(
62
+ parseTransferConfigName(
63
+ "projects/project-123/locations/us-central1/transferConfigs/642f3a36-0000-2fbb-ad1d-001a114e2fa6"
64
+ )
65
+ ).toEqual({
66
+ projectId: "project-123",
67
+ location: "us-central1",
68
+ transferConfigId: "642f3a36-0000-2fbb-ad1d-001a114e2fa6",
69
+ });
70
+ });
71
+
72
+ test("rejects a transfer run name", () => {
73
+ expect(() =>
74
+ parseTransferConfigName("projects/p/locations/l/transferConfigs/c/runs/r")
75
+ ).toThrow("Invalid transfer config name format");
76
+ });
77
+
78
+ test("rejects an empty name", () => {
79
+ expect(() => parseTransferConfigName("")).toThrow(
80
+ "Invalid transfer config name format"
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("convertUnsupportedDataTypes", () => {
86
+ test("returns null and primitives unchanged", () => {
87
+ expect(convertUnsupportedDataTypes(null)).toBeNull();
88
+ expect(convertUnsupportedDataTypes("string")).toBe("string");
89
+ expect(convertUnsupportedDataTypes(123)).toBe(123);
90
+ expect(convertUnsupportedDataTypes(true)).toBe(true);
91
+ });
92
+
93
+ test("converts timestamp, date, and datetime values to Firestore Timestamps", () => {
94
+ const converted = convertUnsupportedDataTypes({
95
+ timestamp: new BigQueryTimestamp("2023-01-15T10:30:00Z"),
96
+ date: new BigQueryDate("2023-01-15"),
97
+ datetime: new BigQueryDatetime("2023-01-15T10:30:00"),
98
+ });
99
+
100
+ expect((converted.timestamp as Timestamp).toDate().toISOString()).toBe(
101
+ "2023-01-15T10:30:00.000Z"
102
+ );
103
+ expect((converted.date as Timestamp).toDate().toISOString()).toBe(
104
+ "2023-01-15T00:00:00.000Z"
105
+ );
106
+ // A DATETIME carries no offset, so the conversion reads it in the
107
+ // machine's zone rather than UTC.
108
+ expect(converted.datetime).toEqual(
109
+ Timestamp.fromDate(new Date("2023-01-15T10:30:00"))
110
+ );
111
+ });
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.');
117
+ });
118
+
119
+ test("converts a plain Date to a Firestore Timestamp", () => {
120
+ const converted = convertUnsupportedDataTypes({
121
+ date: new Date("2023-01-15T10:30:00Z"),
122
+ });
123
+
124
+ expect((converted.date as Timestamp).toDate().toISOString()).toBe(
125
+ "2023-01-15T10:30:00.000Z"
126
+ );
127
+ });
128
+
129
+ test("converts a Buffer to a Uint8Array of the same bytes", () => {
130
+ const converted = convertUnsupportedDataTypes({
131
+ data: Buffer.from([1, 2, 3, 4]),
132
+ });
133
+
134
+ expect(converted.data).toBeInstanceOf(Uint8Array);
135
+ // Buffer extends Uint8Array, so only this pins the conversion.
136
+ expect(converted.data).not.toBeInstanceOf(Buffer);
137
+ expect(Array.from(converted.data as Uint8Array)).toEqual([1, 2, 3, 4]);
138
+ });
139
+
140
+ test("descends into nested objects and arrays", () => {
141
+ const converted = convertUnsupportedDataTypes({
142
+ outer: {
143
+ inner: { timestamp: new BigQueryTimestamp("2023-01-15T10:30:00Z") },
144
+ },
145
+ items: [
146
+ { timestamp: new BigQueryTimestamp("2023-01-15T10:30:00Z") },
147
+ { value: "plain" },
148
+ ],
149
+ });
150
+
151
+ const inner = (converted.outer as FirestoreRow).inner as FirestoreRow;
152
+ const items = converted.items as FirestoreRow[];
153
+ expect(inner.timestamp).toBeInstanceOf(Timestamp);
154
+ expect(items[0].timestamp).toBeInstanceOf(Timestamp);
155
+ expect(items[1].value).toBe("plain");
156
+ });
157
+
158
+ test("preserves null values at every depth", () => {
159
+ const converted = convertUnsupportedDataTypes({
160
+ name: "test",
161
+ nullField: null,
162
+ nested: { alsoNull: null },
163
+ });
164
+
165
+ expect(converted.nullField).toBeNull();
166
+ expect((converted.nested as FirestoreRow).alsoNull).toBeNull();
167
+ });
168
+ });
@@ -14,14 +14,21 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { Geography } from "@google-cloud/bigquery";
18
- import { Timestamp } from "firebase-admin/firestore";
19
- import { describe, expect, test } from "vitest";
17
+ import { type BigQuery, Geography } from "@google-cloud/bigquery";
18
+ import { type Firestore, Timestamp } from "firebase-admin/firestore";
19
+ import { describe, expect, test, vi } from "vitest";
20
+ import { resolveConfig } from "../src/export-config";
20
21
  import {
21
22
  convertUnsupportedDataTypes,
22
23
  parseTransferConfigName,
23
24
  parseTransferRunName,
25
+ type ResultHandlerContext,
26
+ writeRunResultsToFirestore,
24
27
  } from "../src/helper";
28
+ import * as logs from "../src/logs";
29
+ import type { TransferRunMessage } from "../src/types";
30
+
31
+ vi.mock("../src/logs", { spy: true });
25
32
 
26
33
  describe("transfer resource parsing", () => {
27
34
  test("parses config and run resource names", () => {
@@ -72,3 +79,58 @@ describe("convertUnsupportedDataTypes", () => {
72
79
  expect(converted.nested).toEqual([{ value: true }]);
73
80
  });
74
81
  });
82
+
83
+ describe("writeRunResultsToFirestore", () => {
84
+ test("logs the run id after reading results and before writing rows", async () => {
85
+ const add = vi.fn().mockResolvedValue({});
86
+ const set = vi.fn().mockResolvedValue({});
87
+ const db = {
88
+ collection: vi.fn(() => ({ add, doc: vi.fn(() => ({ set })) })),
89
+ runTransaction: vi.fn(
90
+ async (fn: (transaction: unknown) => Promise<void>) =>
91
+ fn({
92
+ get: vi.fn().mockResolvedValue({ data: () => undefined }),
93
+ set: vi.fn(),
94
+ })
95
+ ),
96
+ } as unknown as Firestore;
97
+ const getQueryResults = vi.fn().mockResolvedValue([[{ value: 1 }]]);
98
+ const bigquery = {
99
+ createQueryJob: vi
100
+ .fn()
101
+ .mockResolvedValue([{ id: "job-1", getQueryResults }]),
102
+ } as unknown as BigQuery;
103
+ const config = resolveConfig({
104
+ bigqueryDatasetLocation: "US",
105
+ projectId: "test-project",
106
+ instanceId: "users-export",
107
+ datasetId: "analytics",
108
+ tableName: "out",
109
+ queryString: "SELECT * FROM source.users",
110
+ displayName: "Users export",
111
+ schedule: "every 24 hours",
112
+ });
113
+ const message = {
114
+ json: {
115
+ name: "projects/test-project/locations/us/transferConfigs/config-1/runs/run-1",
116
+ runTime: "2026-08-20T10:05:39Z",
117
+ state: "SUCCEEDED",
118
+ destinationDatasetId: "analytics",
119
+ params: { destination_table_name_template: 'out_{run_time|"%H%M%S"}' },
120
+ },
121
+ } as TransferRunMessage;
122
+ const ctx = { db, bigquery, config } as ResultHandlerContext;
123
+
124
+ await writeRunResultsToFirestore(ctx, message);
125
+
126
+ const logSpy = vi.mocked(logs.writeRunResultsToFirestore);
127
+ expect(logSpy).toHaveBeenCalledTimes(1);
128
+ expect(logSpy).toHaveBeenCalledWith("run-1");
129
+ expect(logSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
130
+ getQueryResults.mock.invocationCallOrder[0]
131
+ );
132
+ expect(logSpy.mock.invocationCallOrder[0]).toBeLessThan(
133
+ add.mock.invocationCallOrder[0]
134
+ );
135
+ });
136
+ });
@@ -0,0 +1,151 @@
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 { beforeEach, describe, expect, test, vi } from "vitest";
18
+ import { resolveConfig } from "../src/export-config";
19
+ import type { HandlerContext } from "../src/handlers";
20
+
21
+ const mocks = vi.hoisted(() => ({
22
+ createTransferConfig: vi.fn(),
23
+ getTransferConfig: vi.fn(),
24
+ updateTransferConfig: vi.fn(),
25
+ topicCreated: vi.fn(),
26
+ }));
27
+
28
+ vi.mock("../src/dts", () => ({
29
+ createTransferConfig: mocks.createTransferConfig,
30
+ getTransferConfig: mocks.getTransferConfig,
31
+ updateTransferConfig: mocks.updateTransferConfig,
32
+ }));
33
+
34
+ vi.mock("../src/logs", () => ({
35
+ complete: vi.fn(),
36
+ error: vi.fn(),
37
+ start: vi.fn(),
38
+ topicCreated: mocks.topicCreated,
39
+ }));
40
+
41
+ import { handleUpsertTransferConfig } from "../src/handlers";
42
+
43
+ const config = resolveConfig({
44
+ bigqueryDatasetLocation: "US",
45
+ projectId: "test-project",
46
+ instanceId: "users-export",
47
+ datasetId: "analytics",
48
+ tableName: "users",
49
+ queryString: "SELECT * FROM source.users",
50
+ displayName: "Users export",
51
+ schedule: "every 24 hours",
52
+ });
53
+
54
+ function makeContext(options: { topicExists: boolean }) {
55
+ const set = vi.fn();
56
+ const exists = vi.fn().mockResolvedValue([options.topicExists]);
57
+ const topic = vi.fn(() => ({ exists }));
58
+ const createTopic = vi.fn().mockResolvedValue(undefined);
59
+ const collection = vi.fn(() => ({
60
+ doc: vi.fn(() => ({ set })),
61
+ where: vi.fn(() => ({
62
+ limit: vi.fn(() => ({
63
+ get: vi.fn().mockResolvedValue({ empty: true, docs: [] }),
64
+ })),
65
+ })),
66
+ }));
67
+
68
+ return {
69
+ ctx: {
70
+ db: { collection },
71
+ bigquery: {},
72
+ dataTransfer: {},
73
+ pubsub: { topic, createTopic },
74
+ config,
75
+ } as unknown as HandlerContext,
76
+ topic,
77
+ createTopic,
78
+ };
79
+ }
80
+
81
+ /** gRPC status codes surfaced by the Pub/Sub admin client. */
82
+ const ALREADY_EXISTS = 6;
83
+ const PERMISSION_DENIED = 7;
84
+
85
+ function grpcError(code: number, message: string): Error {
86
+ return Object.assign(new Error(message), { code });
87
+ }
88
+
89
+ beforeEach(() => {
90
+ vi.clearAllMocks();
91
+ mocks.createTransferConfig.mockResolvedValue({
92
+ name: "projects/p/locations/us/transferConfigs/config-1",
93
+ });
94
+ });
95
+
96
+ describe("ensureNotificationTopic", () => {
97
+ test("does not create the topic when it already exists", async () => {
98
+ const { ctx, topic, createTopic } = makeContext({ topicExists: true });
99
+
100
+ await handleUpsertTransferConfig(ctx);
101
+
102
+ expect(topic).toHaveBeenCalledWith(config.pubSubTopic);
103
+ expect(createTopic).not.toHaveBeenCalled();
104
+ expect(mocks.topicCreated).not.toHaveBeenCalled();
105
+ });
106
+
107
+ test("creates the topic when it does not exist", async () => {
108
+ const { ctx, createTopic } = makeContext({ topicExists: false });
109
+
110
+ await handleUpsertTransferConfig(ctx);
111
+
112
+ expect(createTopic).toHaveBeenCalledWith(config.pubSubTopic);
113
+ expect(mocks.topicCreated).toHaveBeenCalledWith(config.pubSubTopic);
114
+ });
115
+
116
+ test("swallows ALREADY_EXISTS from a concurrent create", async () => {
117
+ const { ctx, createTopic } = makeContext({ topicExists: false });
118
+ createTopic.mockRejectedValue(
119
+ grpcError(ALREADY_EXISTS, "Topic already exists")
120
+ );
121
+
122
+ await expect(handleUpsertTransferConfig(ctx)).resolves.toBeUndefined();
123
+
124
+ expect(mocks.topicCreated).not.toHaveBeenCalled();
125
+ expect(mocks.createTransferConfig).toHaveBeenCalledOnce();
126
+ });
127
+
128
+ test("rethrows other gRPC failures and skips the transfer config", async () => {
129
+ const { ctx, createTopic } = makeContext({ topicExists: false });
130
+ createTopic.mockRejectedValue(
131
+ grpcError(PERMISSION_DENIED, "User not authorized")
132
+ );
133
+
134
+ await expect(handleUpsertTransferConfig(ctx)).rejects.toThrow(
135
+ "User not authorized"
136
+ );
137
+
138
+ expect(mocks.createTransferConfig).not.toHaveBeenCalled();
139
+ });
140
+
141
+ test("rethrows errors that carry no gRPC status code", async () => {
142
+ const { ctx, createTopic } = makeContext({ topicExists: false });
143
+ createTopic.mockRejectedValue(new Error("network unreachable"));
144
+
145
+ await expect(handleUpsertTransferConfig(ctx)).rejects.toThrow(
146
+ "network unreachable"
147
+ );
148
+
149
+ expect(mocks.createTransferConfig).not.toHaveBeenCalled();
150
+ });
151
+ });