@firebase-function-kits/bigquery-firestore-export 0.0.2-rc.1 → 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.
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.1",
3
+ "version": "0.0.2-rc.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/firebase/extensions.git",
@@ -37,10 +37,7 @@
37
37
  "@google-cloud/bigquery-data-transfer": "^5.0.1",
38
38
  "@google-cloud/pubsub": "^4.11.0",
39
39
  "firebase-admin": "^14.1.0",
40
- "firebase-functions": "7.3.2"
41
- },
42
- "devDependencies": {
43
- "vitest": "^4.1.10"
40
+ "firebase-functions": "^7.3.2"
44
41
  },
45
42
  "overrides": {
46
43
  "uuid@9.0.1": "11.1.1"
package/src/config.ts CHANGED
@@ -76,6 +76,22 @@ const params = {
76
76
  }),
77
77
  }),
78
78
  transferConfigName: defineString("TRANSFER_CONFIG_NAME", { default: "" }),
79
+ pubSubTopic: defineString("PUB_SUB_TOPIC", {
80
+ label: "Pub/Sub Topic",
81
+ description:
82
+ "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
+
84
+ default: expr`kit-${instanceId}-processMessages`,
85
+ input: {
86
+ text: {
87
+ nonEmpty: true,
88
+ example: "ext-my-instance-processMessages",
89
+ validationRegex: /^(?!goog)[a-zA-Z][a-zA-Z0-9\-_.~+%]{2,254}$/,
90
+ validationErrorMessage:
91
+ "Must be a Pub/Sub topic ID, not a full projects/<project>/topics/<topic> resource name. IDs are 3 to 255 characters, start with a letter, may contain letters, numbers and - _ . ~ + %, and cannot start with goog.",
92
+ },
93
+ },
94
+ }),
79
95
  datasetId: defineString("DATASET_ID", {
80
96
  label: "Dataset ID",
81
97
  description:
@@ -168,7 +184,7 @@ const params = {
168
184
  };
169
185
 
170
186
  export const CONFIG_EXPRESSIONS: DeployTimeOptions = {
171
- pubSubTopic: expr`kit-${instanceId}-processMessages`,
187
+ pubSubTopic: params.pubSubTopic,
172
188
  };
173
189
 
174
190
  function optional(value: string): string | undefined {
@@ -198,7 +214,7 @@ export function configFromEnv(): BigqueryFirestoreExportConfig {
198
214
  displayName: params.displayName.value(),
199
215
  partitioningField: optional(params.partitioningField.value()),
200
216
  schedule: params.schedule.value(),
201
- pubSubTopic: `kit-${resolvedInstanceId}-processMessages`,
217
+ pubSubTopic: params.pubSubTopic.value(),
202
218
  firestoreCollection: params.firestoreCollection.value(),
203
219
  logLevel: normalizeLogLevel(params.logLevel.value()),
204
220
  };
package/src/dts.ts CHANGED
@@ -61,6 +61,21 @@ function transferConfigFields(config: TransferConfig) {
61
61
  return fields;
62
62
  }
63
63
 
64
+ /**
65
+ * Full resource name of the topic DTS publishes run notifications to.
66
+ *
67
+ * PUB_SUB_TOPIC is validated as a bare topic ID when prompted, but values from
68
+ * a dotenv file reach us unvalidated, and both the Pub/Sub client and the
69
+ * trigger accept a full resource name, so accept one here too.
70
+ */
71
+ export function notificationTopicName(
72
+ config: ResolvedBigqueryFirestoreExportConfig
73
+ ): string {
74
+ return config.pubSubTopic.startsWith("projects/")
75
+ ? config.pubSubTopic
76
+ : `projects/${config.projectId}/topics/${config.pubSubTopic}`;
77
+ }
78
+
64
79
  function stringField(value: string | undefined): { stringValue: string } {
65
80
  return { stringValue: value ?? "" };
66
81
  }
@@ -93,7 +108,7 @@ export function createTransferConfigRequest(
93
108
  },
94
109
  },
95
110
  schedule: config.schedule,
96
- notificationPubsubTopic: `projects/${config.projectId}/topics/${config.pubSubTopic}`,
111
+ notificationPubsubTopic: notificationTopicName(config),
97
112
  },
98
113
  };
99
114
  }
@@ -184,7 +199,7 @@ export async function constructUpdateTransferConfigRequest(
184
199
  updatedConfig.schedule = config.schedule;
185
200
  }
186
201
 
187
- const expectedTopic = `projects/${config.projectId}/topics/${config.pubSubTopic}`;
202
+ const expectedTopic = notificationTopicName(config);
188
203
  if (expectedTopic !== transferConfig.notificationPubsubTopic) {
189
204
  updateMask.push("notification_pubsub_topic");
190
205
  updatedConfig.notificationPubsubTopic = expectedTopic;
package/src/handlers.ts CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  createTransferConfig,
24
24
  type DataTransferClient,
25
25
  getTransferConfig,
26
+ notificationTopicName,
27
+ PARTITIONING_FIELD_REMOVAL_ERROR_PREFIX,
26
28
  updateTransferConfig,
27
29
  } from "./dts";
28
30
  import type { ResolvedBigqueryFirestoreExportConfig } from "./export-config";
@@ -64,6 +66,13 @@ async function ensureNotificationTopic(ctx: HandlerContext): Promise<void> {
64
66
  }
65
67
  }
66
68
 
69
+ function isPartitioningFieldRemovalError(err: unknown): err is Error {
70
+ return (
71
+ err instanceof Error &&
72
+ err.message.includes(PARTITIONING_FIELD_REMOVAL_ERROR_PREFIX)
73
+ );
74
+ }
75
+
67
76
  async function storeTransferConfig(
68
77
  ctx: HandlerContext,
69
78
  transferConfig: Awaited<ReturnType<typeof getTransferConfig>>
@@ -105,8 +114,19 @@ export async function handleUpsertTransferConfig(
105
114
  ctx.config.transferConfigName
106
115
  );
107
116
  if (!linked) {
108
- throw new Error(
109
- `Transfer config not found: ${ctx.config.transferConfigName}`
117
+ // Only a redeploy with a corrected TRANSFER_CONFIG_NAME can resolve this,
118
+ // so retrying the task cannot help.
119
+ logs.linkedTransferConfigMissing(ctx.config.transferConfigName);
120
+ return;
121
+ }
122
+ // A linked config is adopted as-is, so a topic mismatch is the user's to
123
+ // resolve: rewriting it would repoint a config this deployment did not create.
124
+ const expectedTopic = notificationTopicName(ctx.config);
125
+ if (linked.notificationPubsubTopic !== expectedTopic) {
126
+ logs.linkedTopicMismatch(
127
+ ctx.config.transferConfigName,
128
+ linked.notificationPubsubTopic,
129
+ expectedTopic
110
130
  );
111
131
  }
112
132
  await storeTransferConfig(ctx, linked);
@@ -128,14 +148,21 @@ export async function handleUpsertTransferConfig(
128
148
  const transferConfigName = existing.docs[0].data().name;
129
149
  if (typeof transferConfigName !== "string" || !transferConfigName) {
130
150
  throw new Error(
131
- `Existing transfer config document in ${ctx.config.firestoreCollection} is missing required 'name' field.`
151
+ `Existing transfer config document in ${ctx.config.firestoreCollection} is missing required 'name' field. Delete the document so a new scheduled query is created, then redeploy.`
132
152
  );
133
153
  }
134
154
 
135
- const updated = await updateTransferConfig(
136
- ctx.dataTransfer,
137
- transferConfigName,
138
- ctx.config
139
- );
140
- await storeTransferConfig(ctx, updated);
155
+ try {
156
+ const updated = await updateTransferConfig(
157
+ ctx.dataTransfer,
158
+ transferConfigName,
159
+ ctx.config
160
+ );
161
+ await storeTransferConfig(ctx, updated);
162
+ } catch (err) {
163
+ if (!isPartitioningFieldRemovalError(err)) throw err;
164
+ // The guard rejects the update while building the request, so nothing was
165
+ // sent and a retry hits the same guard.
166
+ logs.partitioningFieldRemovalAborted(err.message);
167
+ }
141
168
  }
package/src/helper.ts CHANGED
@@ -227,6 +227,7 @@ export async function writeRunResultsToFirestore(
227
227
  message.json.destinationDatasetId,
228
228
  tableName
229
229
  );
230
+ logs.writeRunResultsToFirestore(runId);
230
231
  const collection = db.collection(
231
232
  `${config.firestoreCollection}/${transferConfigId}/runs/${runId}/output`
232
233
  );
package/src/logs.ts CHANGED
@@ -70,6 +70,10 @@ export function bigqueryQueryFailed(
70
70
  });
71
71
  }
72
72
 
73
+ export function writeRunResultsToFirestore(runId: string): void {
74
+ logger.debug("Writing BigQuery results to Firestore", { runId });
75
+ }
76
+
73
77
  export function runResultsWrittenToFirestore(
74
78
  runId: string,
75
79
  successCount: number,
@@ -140,6 +144,30 @@ export function partitioningFieldRemovalAttempted(
140
144
  });
141
145
  }
142
146
 
147
+ export function linkedTransferConfigMissing(name: string): void {
148
+ logger.error(
149
+ "The scheduled query named by TRANSFER_CONFIG_NAME does not exist, so nothing was linked. Set it to a scheduled query that exists in this project and redeploy, or clear it to have this deployment create its own.",
150
+ { name }
151
+ );
152
+ }
153
+
154
+ // The reason carries the remediation, and an Error passed as structured data
155
+ // serialises to {}, so it goes in the message.
156
+ export function partitioningFieldRemovalAborted(reason: string): void {
157
+ logger.error(`Stopped without updating the scheduled query. ${reason}`);
158
+ }
159
+
143
160
  export function topicCreated(name: string): void {
144
161
  logger.info("Created Pub/Sub topic for transfer notifications", { name });
145
162
  }
163
+
164
+ export function linkedTopicMismatch(
165
+ name: string,
166
+ linkedTopic: string | null | undefined,
167
+ expectedTopic: string
168
+ ): void {
169
+ logger.warn(
170
+ "Linked transfer config notifies a different Pub/Sub topic, so its runs will not reach this kit. Set PUB_SUB_TOPIC to the linked topic, or point the transfer config at the configured one.",
171
+ { name, linkedTopic: linkedTopic ?? "", expectedTopic }
172
+ );
173
+ }
@@ -23,12 +23,41 @@ afterEach(() => {
23
23
  });
24
24
 
25
25
  describe("CONFIG_EXPRESSIONS", () => {
26
- test("namespaces the Pub/Sub topic with the required instance id", () => {
26
+ test("binds the trigger to the Pub/Sub topic parameter", () => {
27
27
  expect(CONFIG_EXPRESSIONS.pubSubTopic).toBeInstanceOf(Expression);
28
28
  expect((CONFIG_EXPRESSIONS.pubSubTopic as Expression<string>).toCEL()).toBe(
29
- "kit-{{ params.INSTANCE_ID }}-processMessages"
29
+ "{{ params.PUB_SUB_TOPIC }}"
30
30
  );
31
31
  });
32
+
33
+ test("defaults the topic parameter to the instance-namespaced kit topic", () => {
34
+ const spec = (
35
+ CONFIG_EXPRESSIONS.pubSubTopic as unknown as {
36
+ toSpec: () => { default?: string };
37
+ }
38
+ ).toSpec();
39
+ expect(spec.default).toBe("kit-{{ params.INSTANCE_ID }}-processMessages");
40
+ });
41
+
42
+ test("accepts a topic ID but rejects a full resource name", () => {
43
+ const spec = (
44
+ CONFIG_EXPRESSIONS.pubSubTopic as unknown as {
45
+ toSpec: () => { input?: { text?: { validationRegex?: string } } };
46
+ }
47
+ ).toSpec();
48
+ const pattern = spec.input?.text?.validationRegex;
49
+ expect(pattern).toBeTypeOf("string");
50
+ const validate = (value: string) =>
51
+ new RegExp(pattern as string).test(value);
52
+
53
+ expect(validate("ext-users-export-processMessages")).toBe(true);
54
+ expect(validate("kit-users-export-processMessages")).toBe(true);
55
+ expect(
56
+ validate("projects/test-project/topics/ext-users-export-processMessages")
57
+ ).toBe(false);
58
+ expect(validate("")).toBe(false);
59
+ expect(validate("goog-reserved-prefix")).toBe(false);
60
+ });
32
61
  });
33
62
 
34
63
  describe("configFromEnv", () => {
@@ -47,6 +76,8 @@ describe("configFromEnv", () => {
47
76
  vi.stubEnv("COLLECTION_PATH", "transferConfigs");
48
77
  vi.stubEnv("LOG_LEVEL", "info");
49
78
 
79
+ vi.stubEnv("PUB_SUB_TOPIC", "kit-users-export-processMessages");
80
+
50
81
  expect(configFromEnv()).toMatchObject({
51
82
  projectId: "test-project",
52
83
  instanceId: "users-export",
@@ -58,4 +89,25 @@ describe("configFromEnv", () => {
58
89
  logLevel: "info",
59
90
  });
60
91
  });
92
+
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");
107
+ vi.stubEnv("PUB_SUB_TOPIC", "ext-users-export-processMessages");
108
+
109
+ expect(configFromEnv().pubSubTopic).toBe(
110
+ "ext-users-export-processMessages"
111
+ );
112
+ });
61
113
  });