firebase-dataconnect-bootstrap 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -1,31 +1,28 @@
1
1
  # firebase-dataconnect-bootstrap
2
2
 
3
- Firebase Data Connect の基本構成作成と、Firestore の `onDocumentWritten` Cloud Functions 追加を自動化する CLI です。
3
+ Firebase Data Connect Cloud Functions のセットアップを自動化する CLI です。
4
+ 初期作成だけでなく、再実行で設定変更(途中更新)もできます。
4
5
 
5
6
  ## できること
6
7
 
7
- - 対象レポジトリへ `.firebaserc` と `firebase.json` を生成/更新
8
- - `dataconnect/` 配下に初期ファイル群を作成
9
- - `functions/` が未初期化なら最小構成で初期化
10
- - Firestore `onDocumentWritten` 関数を追加して export
11
- - 必要に応じて `functions/` で `npm install` を実行
8
+ - `.firebaserc` と `firebase.json` の生成/更新
9
+ - `dataconnect/` 初期ファイル生成
10
+ - `functions/` 未初期化時の最小構成作成
11
+ - Firestore `onDocumentWritten` の生成/更新
12
+ - ベクトル検索用 `onRequest` 関数の生成/更新
13
+ - 埋め込み実装差し替え用 `vectorSearchEmbedding` テンプレート生成
14
+ - 前回設定を `.firebase-dataconnect-bootstrap.json` に保存し、次回実行時に再利用
12
15
 
13
16
  ## 使い方
14
17
 
15
- 初回セットアップ:
16
-
17
- ```bash
18
- npm install
19
- npm run build
20
- ```
21
-
22
- 対話モード:
23
-
24
18
  ```bash
25
19
  npx firebase-dataconnect-bootstrap
26
20
  ```
27
21
 
28
- 非対話モード:
22
+ 対話モードは日本語で質問が表示されます。
23
+ 同じリポジトリで再実行すると、保存済み設定を初期値として設定変更できます。
24
+
25
+ ## 非対話モード例
29
26
 
30
27
  ```bash
31
28
  npx firebase-dataconnect-bootstrap \
@@ -37,30 +34,50 @@ npx firebase-dataconnect-bootstrap \
37
34
  --location asia-northeast1 \
38
35
  --document 'meetingSummaries/{summaryId}' \
39
36
  --function onMeetingSummaryWritten \
37
+ --vector-search \
38
+ --vector-collection 'meetingSummaries' \
39
+ --source-text-field 'summary' \
40
+ --vector-field 'embedding' \
41
+ --search-fields 'title,summary,createdAt' \
42
+ --search-function 'searchByVector' \
43
+ --top-k 5 \
40
44
  --install
41
45
  ```
42
46
 
43
- ## オプション
47
+ ## 主なオプション
48
+
49
+ - `--config <name>`: 設定保存ファイル名(既定: `.firebase-dataconnect-bootstrap.json`)
50
+ - `--vector-search` / `--no-vector-search`: ベクトル検索 scaffold の有効/無効
51
+ - `--vector-collection <path>`: 検索対象コレクション
52
+ - `--source-text-field <name>`: 埋め込み更新対象のテキストフィールド
53
+ - `--vector-field <name>`: ベクトル保存フィールド
54
+ - `--search-fields <csv>`: 検索結果として返すフィールド
55
+ - `--search-function <name>`: 生成する `onRequest` 関数名
56
+ - `--top-k <number>`: 検索時のデフォルト上位件数
57
+
58
+ ## 生成される主な関数
44
59
 
45
- - `--target <path>`: 対象リポジトリ
46
- - `--project <id>`: Firebase project id
47
- - `--region <region>`: Functions region
48
- - `--service <serviceId>`: Data Connect service id
49
- - `--location <location>`: Data Connect location
50
- - `--document <path>`: Firestore document path
51
- - `--function <name>`: 関数の export 名
52
- - `--install`: `functions` で `npm install` を実行
53
- - `--no-install`: `npm install` をスキップ
54
- - `--yes`: 対話なしで実行(`--project` は必須)
60
+ - `firestoreOnDocumentWritten.*`
61
+ - `sourceTextField` の内容を `embedText` に渡し、`vectorField` へ保存
62
+ - `vectorSearchOnRequest.*`
63
+ - HTTP リクエストの `vector`(または `query`)でコサイン類似度検索
64
+ - `vectorSearchEmbedding.*`
65
+ - `embedText` を実装する差し替えポイント(デフォルトは `null` を返す)
55
66
 
56
- ## npm 公開
67
+ ## npm モジュールとして検索クライアントを使う
57
68
 
58
- 1. `npm login`
59
- 2. パッケージ名が未使用か確認
60
- 3. `npm run build`
61
- 4. `npm publish --access public`
69
+ ```ts
70
+ import { createVectorSearchClient } from "firebase-dataconnect-bootstrap/search-client";
62
71
 
63
- ## 注意点
72
+ const client = createVectorSearchClient({
73
+ endpoint: "https://<region>-<project>.cloudfunctions.net/searchByVector"
74
+ });
75
+
76
+ const result = await client.search({
77
+ query: "議事録の要点",
78
+ topK: 5
79
+ });
80
+ ```
64
81
 
65
- - Data Connect/Firebase CLI の認証やプロジェクト作成そのものは行いません。
66
- - 既存の `functions` 実装に追記するため、デプロイ前に差分確認を推奨します。
82
+ `query` を使う場合は、Functions `vectorSearchEmbedding.*` の `embedText` 実装が必要です。
83
+ `vector` を直接渡す場合は `embedText` が未実装でも検索できます。
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runCli(argv?: string[]): Promise<void>;
package/dist/cli.js CHANGED
@@ -14,8 +14,17 @@ Options:
14
14
  --location <location> Data Connect location (default: same as region)
15
15
  --document <path> Firestore document path (default: meetingSummaries/{summaryId})
16
16
  --function <name> Function export name (default: onMeetingSummaryWritten)
17
+ --config <name> Saved config filename (default: .firebase-dataconnect-bootstrap.json)
17
18
  --install Run npm install in functions directory
18
19
  --no-install Skip npm install in functions directory
20
+ --vector-search Enable vector search scaffolding
21
+ --no-vector-search Disable vector search scaffolding
22
+ --vector-collection <path> Collection path used for vector search
23
+ --source-text-field <name> Source text field for embedding updates
24
+ --vector-field <name> Vector field name
25
+ --search-fields <csv> Response fields for search results (comma-separated)
26
+ --search-function <name> onRequest function export name for vector search
27
+ --top-k <number> Default topK value for vector search
19
28
  --yes Use defaults and skip prompts where possible
20
29
  -h, --help Show help
21
30
  `;
@@ -29,6 +38,8 @@ function parseArgs(argv) {
29
38
  documentPath: undefined,
30
39
  functionName: undefined,
31
40
  installDependencies: undefined,
41
+ configFileName: undefined,
42
+ vectorSearch: {},
32
43
  yes: false,
33
44
  help: false
34
45
  };
@@ -50,6 +61,14 @@ function parseArgs(argv) {
50
61
  args.installDependencies = false;
51
62
  continue;
52
63
  }
64
+ if (token === "--vector-search") {
65
+ args.vectorSearch = { ...(args.vectorSearch ?? {}), enabled: true };
66
+ continue;
67
+ }
68
+ if (token === "--no-vector-search") {
69
+ args.vectorSearch = { ...(args.vectorSearch ?? {}), enabled: false };
70
+ continue;
71
+ }
53
72
  if (token.startsWith("--target=")) {
54
73
  args.targetDir = token.slice("--target=".length);
55
74
  continue;
@@ -78,12 +97,66 @@ function parseArgs(argv) {
78
97
  args.functionName = token.slice("--function=".length);
79
98
  continue;
80
99
  }
100
+ if (token.startsWith("--config=")) {
101
+ args.configFileName = token.slice("--config=".length);
102
+ continue;
103
+ }
104
+ if (token.startsWith("--vector-collection=")) {
105
+ args.vectorSearch = {
106
+ ...(args.vectorSearch ?? {}),
107
+ collectionPath: token.slice("--vector-collection=".length)
108
+ };
109
+ continue;
110
+ }
111
+ if (token.startsWith("--source-text-field=")) {
112
+ args.vectorSearch = {
113
+ ...(args.vectorSearch ?? {}),
114
+ sourceTextField: token.slice("--source-text-field=".length)
115
+ };
116
+ continue;
117
+ }
118
+ if (token.startsWith("--vector-field=")) {
119
+ args.vectorSearch = {
120
+ ...(args.vectorSearch ?? {}),
121
+ vectorField: token.slice("--vector-field=".length)
122
+ };
123
+ continue;
124
+ }
125
+ if (token.startsWith("--search-fields=")) {
126
+ args.vectorSearch = {
127
+ ...(args.vectorSearch ?? {}),
128
+ returnFields: token
129
+ .slice("--search-fields=".length)
130
+ .split(",")
131
+ .map((field) => field.trim())
132
+ .filter(Boolean)
133
+ };
134
+ continue;
135
+ }
136
+ if (token.startsWith("--search-function=")) {
137
+ args.vectorSearch = {
138
+ ...(args.vectorSearch ?? {}),
139
+ functionName: token.slice("--search-function=".length)
140
+ };
141
+ continue;
142
+ }
143
+ if (token.startsWith("--top-k=")) {
144
+ const parsedTopK = Number.parseInt(token.slice("--top-k=".length), 10);
145
+ if (Number.isNaN(parsedTopK) || parsedTopK <= 0) {
146
+ throw new Error("--top-k must be a positive integer");
147
+ }
148
+ args.vectorSearch = { ...(args.vectorSearch ?? {}), defaultTopK: parsedTopK };
149
+ continue;
150
+ }
81
151
  const nextValue = argv[i + 1];
82
- const useNext = (field) => {
152
+ const requireNextValue = () => {
83
153
  if (!nextValue || nextValue.startsWith("-")) {
84
154
  throw new Error(`Missing value for ${token}`);
85
155
  }
86
- args[field] = nextValue;
156
+ return nextValue;
157
+ };
158
+ const useNext = (field) => {
159
+ args[field] = requireNextValue();
87
160
  i += 1;
88
161
  };
89
162
  switch (token) {
@@ -108,6 +181,56 @@ function parseArgs(argv) {
108
181
  case "--function":
109
182
  useNext("functionName");
110
183
  break;
184
+ case "--config":
185
+ useNext("configFileName");
186
+ break;
187
+ case "--vector-collection":
188
+ args.vectorSearch = {
189
+ ...(args.vectorSearch ?? {}),
190
+ collectionPath: requireNextValue()
191
+ };
192
+ i += 1;
193
+ break;
194
+ case "--source-text-field":
195
+ args.vectorSearch = {
196
+ ...(args.vectorSearch ?? {}),
197
+ sourceTextField: requireNextValue()
198
+ };
199
+ i += 1;
200
+ break;
201
+ case "--vector-field":
202
+ args.vectorSearch = {
203
+ ...(args.vectorSearch ?? {}),
204
+ vectorField: requireNextValue()
205
+ };
206
+ i += 1;
207
+ break;
208
+ case "--search-fields":
209
+ args.vectorSearch = {
210
+ ...(args.vectorSearch ?? {}),
211
+ returnFields: requireNextValue()
212
+ .split(",")
213
+ .map((field) => field.trim())
214
+ .filter(Boolean)
215
+ };
216
+ i += 1;
217
+ break;
218
+ case "--search-function":
219
+ args.vectorSearch = {
220
+ ...(args.vectorSearch ?? {}),
221
+ functionName: requireNextValue()
222
+ };
223
+ i += 1;
224
+ break;
225
+ case "--top-k": {
226
+ const parsedTopK = Number.parseInt(requireNextValue(), 10);
227
+ if (Number.isNaN(parsedTopK) || parsedTopK <= 0) {
228
+ throw new Error("--top-k must be a positive integer");
229
+ }
230
+ args.vectorSearch = { ...(args.vectorSearch ?? {}), defaultTopK: parsedTopK };
231
+ i += 1;
232
+ break;
233
+ }
111
234
  default:
112
235
  throw new Error(`Unknown option: ${token}`);
113
236
  }
@@ -0,0 +1,6 @@
1
+ export { run } from "./main.js";
2
+ export { runCli } from "./cli.js";
3
+ export { collectConfig } from "./prompt.js";
4
+ export { createVectorSearchClient, searchByVector } from "./search-client.js";
5
+ export type { CliArgs, RunResult, SetupConfig, VectorSearchConfig } from "./types.js";
6
+ export type { SearchByVectorInput, VectorSearchClientOptions, VectorSearchHit, VectorSearchResponse } from "./search-client.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { run } from "./main.js";
2
+ export { runCli } from "./cli.js";
3
+ export { collectConfig } from "./prompt.js";
4
+ export { createVectorSearchClient, searchByVector } from "./search-client.js";
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { RunResult, SetupConfig } from "./types.js";
2
+ export declare function run(config: SetupConfig): Promise<RunResult>;
package/dist/main.js CHANGED
@@ -50,10 +50,7 @@ function appendIfMissing(content, appendLine) {
50
50
  }
51
51
  function detectModuleStyle(entryPath, packageJson) {
52
52
  const ext = path.extname(entryPath);
53
- if (ext === ".ts") {
54
- return "esm";
55
- }
56
- if (ext === ".mjs") {
53
+ if (ext === ".ts" || ext === ".mjs") {
57
54
  return "esm";
58
55
  }
59
56
  if (ext === ".cjs") {
@@ -64,6 +61,18 @@ function detectModuleStyle(entryPath, packageJson) {
64
61
  }
65
62
  return "cjs";
66
63
  }
64
+ function buildGeneratedImportPath(moduleStyle, extension, moduleBaseName) {
65
+ if (moduleStyle === "cjs") {
66
+ if (extension === ".cjs") {
67
+ return `./${moduleBaseName}.cjs`;
68
+ }
69
+ return `./${moduleBaseName}`;
70
+ }
71
+ if (extension === ".ts") {
72
+ return `./${moduleBaseName}.js`;
73
+ }
74
+ return `./${moduleBaseName}${extension}`;
75
+ }
67
76
  function buildDataconnectYaml(config) {
68
77
  return `specVersion: "v1alpha"
69
78
  serviceId: "${config.serviceId}"
@@ -124,13 +133,36 @@ function buildMutationsGql() {
124
133
  }
125
134
  `;
126
135
  }
127
- function buildFunctionModule({ style, functionName, documentPath, region }) {
136
+ function buildEmbeddingModule(style) {
128
137
  if (style === "cjs") {
129
- return `const { onDocumentWritten } = require("firebase-functions/v2/firestore");
138
+ return `/**
139
+ * Replace this function with your embedding provider implementation.
140
+ * Return a numeric vector (number[]) for the given text.
141
+ */
142
+ async function embedText(_text) {
143
+ return null;
144
+ }
145
+
146
+ module.exports = { embedText };
147
+ `;
148
+ }
149
+ return `/**
150
+ * Replace this function with your embedding provider implementation.
151
+ * Return a numeric vector (number[]) for the given text.
152
+ */
153
+ export async function embedText(_text) {
154
+ return null;
155
+ }
156
+ `;
157
+ }
158
+ function buildFunctionModule({ style, config }) {
159
+ if (style === "cjs") {
160
+ if (!config.vectorSearch.enabled) {
161
+ return `const { onDocumentWritten } = require("firebase-functions/v2/firestore");
130
162
  const logger = require("firebase-functions/logger");
131
163
 
132
- const ${functionName} = onDocumentWritten(
133
- { document: "${documentPath}", region: "${region}" },
164
+ const ${config.functionName} = onDocumentWritten(
165
+ { document: "${config.documentPath}", region: "${config.region}" },
134
166
  async (event) => {
135
167
  logger.info("Firestore onDocumentWritten fired", {
136
168
  params: event.params,
@@ -142,14 +174,61 @@ const ${functionName} = onDocumentWritten(
142
174
  }
143
175
  );
144
176
 
145
- module.exports = { ${functionName} };
177
+ module.exports = { ${config.functionName} };
146
178
  `;
179
+ }
180
+ return `const { onDocumentWritten } = require("firebase-functions/v2/firestore");
181
+ const logger = require("firebase-functions/logger");
182
+ const { embedText } = require("./vectorSearchEmbedding");
183
+
184
+ const SOURCE_TEXT_FIELD = ${JSON.stringify(config.vectorSearch.sourceTextField)};
185
+ const VECTOR_FIELD = ${JSON.stringify(config.vectorSearch.vectorField)};
186
+
187
+ const ${config.functionName} = onDocumentWritten(
188
+ { document: "${config.documentPath}", region: "${config.region}" },
189
+ async (event) => {
190
+ if (!event.data?.after?.exists) {
191
+ return;
147
192
  }
148
- return `import { onDocumentWritten } from "firebase-functions/v2/firestore";
193
+
194
+ const afterData = event.data.after.data();
195
+ const sourceText = String(afterData?.[SOURCE_TEXT_FIELD] ?? "").trim();
196
+ if (!sourceText) {
197
+ logger.info("Skip embedding update because source text field is empty.", {
198
+ field: SOURCE_TEXT_FIELD,
199
+ document: event.data.after.ref.path
200
+ });
201
+ return;
202
+ }
203
+
204
+ const vector = await embedText(sourceText);
205
+ const validVector =
206
+ Array.isArray(vector) && vector.length > 0 && vector.every((value) => Number.isFinite(value));
207
+ if (!validVector) {
208
+ logger.warn("Embedding provider returned an invalid vector.", {
209
+ field: SOURCE_TEXT_FIELD,
210
+ document: event.data.after.ref.path
211
+ });
212
+ return;
213
+ }
214
+
215
+ await event.data.after.ref.set({ [VECTOR_FIELD]: vector }, { merge: true });
216
+ logger.info("Updated vector field from source text.", {
217
+ field: VECTOR_FIELD,
218
+ document: event.data.after.ref.path
219
+ });
220
+ }
221
+ );
222
+
223
+ module.exports = { ${config.functionName} };
224
+ `;
225
+ }
226
+ if (!config.vectorSearch.enabled) {
227
+ return `import { onDocumentWritten } from "firebase-functions/v2/firestore";
149
228
  import * as logger from "firebase-functions/logger";
150
229
 
151
- export const ${functionName} = onDocumentWritten(
152
- { document: "${documentPath}", region: "${region}" },
230
+ export const ${config.functionName} = onDocumentWritten(
231
+ { document: "${config.documentPath}", region: "${config.region}" },
153
232
  async (event) => {
154
233
  logger.info("Firestore onDocumentWritten fired", {
155
234
  params: event.params,
@@ -160,6 +239,267 @@ export const ${functionName} = onDocumentWritten(
160
239
  return;
161
240
  }
162
241
  );
242
+ `;
243
+ }
244
+ return `import { onDocumentWritten } from "firebase-functions/v2/firestore";
245
+ import * as logger from "firebase-functions/logger";
246
+ import { embedText } from "./vectorSearchEmbedding.js";
247
+
248
+ const SOURCE_TEXT_FIELD = ${JSON.stringify(config.vectorSearch.sourceTextField)};
249
+ const VECTOR_FIELD = ${JSON.stringify(config.vectorSearch.vectorField)};
250
+
251
+ export const ${config.functionName} = onDocumentWritten(
252
+ { document: "${config.documentPath}", region: "${config.region}" },
253
+ async (event) => {
254
+ if (!event.data?.after?.exists) {
255
+ return;
256
+ }
257
+
258
+ const afterData = event.data.after.data();
259
+ const sourceText = String(afterData?.[SOURCE_TEXT_FIELD] ?? "").trim();
260
+ if (!sourceText) {
261
+ logger.info("Skip embedding update because source text field is empty.", {
262
+ field: SOURCE_TEXT_FIELD,
263
+ document: event.data.after.ref.path
264
+ });
265
+ return;
266
+ }
267
+
268
+ const vector = await embedText(sourceText);
269
+ const validVector =
270
+ Array.isArray(vector) && vector.length > 0 && vector.every((value) => Number.isFinite(value));
271
+ if (!validVector) {
272
+ logger.warn("Embedding provider returned an invalid vector.", {
273
+ field: SOURCE_TEXT_FIELD,
274
+ document: event.data.after.ref.path
275
+ });
276
+ return;
277
+ }
278
+
279
+ await event.data.after.ref.set({ [VECTOR_FIELD]: vector }, { merge: true });
280
+ logger.info("Updated vector field from source text.", {
281
+ field: VECTOR_FIELD,
282
+ document: event.data.after.ref.path
283
+ });
284
+ }
285
+ );
286
+ `;
287
+ }
288
+ function buildVectorSearchOnRequestModule(style, config, extension) {
289
+ const returnFields = JSON.stringify(config.vectorSearch.returnFields);
290
+ const embeddingImportPath = buildGeneratedImportPath(style, extension, "vectorSearchEmbedding");
291
+ if (style === "cjs") {
292
+ return `const { onRequest } = require("firebase-functions/v2/https");
293
+ const logger = require("firebase-functions/logger");
294
+ const admin = require("firebase-admin");
295
+ const { embedText } = require("${embeddingImportPath}");
296
+
297
+ if (admin.apps.length === 0) {
298
+ admin.initializeApp();
299
+ }
300
+
301
+ const db = admin.firestore();
302
+ const COLLECTION_PATH = ${JSON.stringify(config.vectorSearch.collectionPath)};
303
+ const VECTOR_FIELD = ${JSON.stringify(config.vectorSearch.vectorField)};
304
+ const RETURN_FIELDS = ${returnFields};
305
+ const DEFAULT_TOP_K = ${config.vectorSearch.defaultTopK};
306
+
307
+ function normalizeVector(raw) {
308
+ if (!Array.isArray(raw)) {
309
+ return null;
310
+ }
311
+ if (raw.length === 0) {
312
+ return null;
313
+ }
314
+ const vector = raw.map((value) => Number(value));
315
+ if (vector.some((value) => !Number.isFinite(value))) {
316
+ return null;
317
+ }
318
+ return vector;
319
+ }
320
+
321
+ function cosineSimilarity(left, right) {
322
+ let dot = 0;
323
+ let leftNorm = 0;
324
+ let rightNorm = 0;
325
+ for (let i = 0; i < left.length; i += 1) {
326
+ dot += left[i] * right[i];
327
+ leftNorm += left[i] * left[i];
328
+ rightNorm += right[i] * right[i];
329
+ }
330
+ const denom = Math.sqrt(leftNorm) * Math.sqrt(rightNorm);
331
+ if (denom === 0) {
332
+ return Number.NaN;
333
+ }
334
+ return dot / denom;
335
+ }
336
+
337
+ function pickFields(data) {
338
+ const picked = {};
339
+ for (const field of RETURN_FIELDS) {
340
+ if (field in data) {
341
+ picked[field] = data[field];
342
+ }
343
+ }
344
+ return picked;
345
+ }
346
+
347
+ const ${config.vectorSearch.functionName} = onRequest(
348
+ { region: ${JSON.stringify(config.region)}, cors: true },
349
+ async (req, res) => {
350
+ try {
351
+ const payload = req.method === "GET" ? req.query : req.body ?? {};
352
+ let queryVector = normalizeVector(payload.vector);
353
+ const queryText = typeof payload.query === "string" ? payload.query.trim() : "";
354
+ const rawTopK = Number.parseInt(String(payload.topK ?? DEFAULT_TOP_K), 10);
355
+ const topK = Number.isFinite(rawTopK) && rawTopK > 0 ? rawTopK : DEFAULT_TOP_K;
356
+
357
+ if (!queryVector && queryText) {
358
+ queryVector = normalizeVector(await embedText(queryText));
359
+ }
360
+ if (!queryVector) {
361
+ res.status(400).json({
362
+ error: "Provide 'vector' (number[]) or implement embedText and send 'query'."
363
+ });
364
+ return;
365
+ }
366
+
367
+ const snapshot = await db.collection(COLLECTION_PATH).get();
368
+ const scored = [];
369
+ snapshot.forEach((doc) => {
370
+ const data = doc.data();
371
+ const candidate = normalizeVector(data[VECTOR_FIELD]);
372
+ if (!candidate || candidate.length !== queryVector.length) {
373
+ return;
374
+ }
375
+ const score = cosineSimilarity(queryVector, candidate);
376
+ if (!Number.isFinite(score)) {
377
+ return;
378
+ }
379
+ scored.push({
380
+ id: doc.id,
381
+ score,
382
+ data: pickFields(data)
383
+ });
384
+ });
385
+
386
+ scored.sort((a, b) => b.score - a.score);
387
+ res.status(200).json({
388
+ total: scored.length,
389
+ results: scored.slice(0, topK)
390
+ });
391
+ } catch (error) {
392
+ logger.error("Vector search request failed", error);
393
+ res.status(500).json({ error: "Vector search failed." });
394
+ }
395
+ }
396
+ );
397
+
398
+ module.exports = { ${config.vectorSearch.functionName} };
399
+ `;
400
+ }
401
+ return `import { onRequest } from "firebase-functions/v2/https";
402
+ import * as logger from "firebase-functions/logger";
403
+ import admin from "firebase-admin";
404
+ import { embedText } from "${embeddingImportPath}";
405
+
406
+ if (admin.apps.length === 0) {
407
+ admin.initializeApp();
408
+ }
409
+
410
+ const db = admin.firestore();
411
+ const COLLECTION_PATH = ${JSON.stringify(config.vectorSearch.collectionPath)};
412
+ const VECTOR_FIELD = ${JSON.stringify(config.vectorSearch.vectorField)};
413
+ const RETURN_FIELDS = ${returnFields};
414
+ const DEFAULT_TOP_K = ${config.vectorSearch.defaultTopK};
415
+
416
+ function normalizeVector(raw) {
417
+ if (!Array.isArray(raw) || raw.length === 0) {
418
+ return null;
419
+ }
420
+ const vector = raw.map((value) => Number(value));
421
+ if (vector.some((value) => !Number.isFinite(value))) {
422
+ return null;
423
+ }
424
+ return vector;
425
+ }
426
+
427
+ function cosineSimilarity(left, right) {
428
+ let dot = 0;
429
+ let leftNorm = 0;
430
+ let rightNorm = 0;
431
+ for (let i = 0; i < left.length; i += 1) {
432
+ dot += left[i] * right[i];
433
+ leftNorm += left[i] * left[i];
434
+ rightNorm += right[i] * right[i];
435
+ }
436
+ const denom = Math.sqrt(leftNorm) * Math.sqrt(rightNorm);
437
+ if (denom === 0) {
438
+ return Number.NaN;
439
+ }
440
+ return dot / denom;
441
+ }
442
+
443
+ function pickFields(data) {
444
+ const picked = {};
445
+ for (const field of RETURN_FIELDS) {
446
+ if (field in data) {
447
+ picked[field] = data[field];
448
+ }
449
+ }
450
+ return picked;
451
+ }
452
+
453
+ export const ${config.vectorSearch.functionName} = onRequest(
454
+ { region: ${JSON.stringify(config.region)}, cors: true },
455
+ async (req, res) => {
456
+ try {
457
+ const payload = req.method === "GET" ? req.query : req.body ?? {};
458
+ let queryVector = normalizeVector(payload.vector);
459
+ const queryText = typeof payload.query === "string" ? payload.query.trim() : "";
460
+ const rawTopK = Number.parseInt(String(payload.topK ?? DEFAULT_TOP_K), 10);
461
+ const topK = Number.isFinite(rawTopK) && rawTopK > 0 ? rawTopK : DEFAULT_TOP_K;
462
+
463
+ if (!queryVector && queryText) {
464
+ queryVector = normalizeVector(await embedText(queryText));
465
+ }
466
+ if (!queryVector) {
467
+ res.status(400).json({
468
+ error: "Provide 'vector' (number[]) or implement embedText and send 'query'."
469
+ });
470
+ return;
471
+ }
472
+
473
+ const snapshot = await db.collection(COLLECTION_PATH).get();
474
+ const scored = [];
475
+ snapshot.forEach((doc) => {
476
+ const data = doc.data();
477
+ const candidate = normalizeVector(data[VECTOR_FIELD]);
478
+ if (!candidate || candidate.length !== queryVector.length) {
479
+ return;
480
+ }
481
+ const score = cosineSimilarity(queryVector, candidate);
482
+ if (!Number.isFinite(score)) {
483
+ return;
484
+ }
485
+ scored.push({
486
+ id: doc.id,
487
+ score,
488
+ data: pickFields(data)
489
+ });
490
+ });
491
+
492
+ scored.sort((a, b) => b.score - a.score);
493
+ res.status(200).json({
494
+ total: scored.length,
495
+ results: scored.slice(0, topK)
496
+ });
497
+ } catch (error) {
498
+ logger.error("Vector search request failed", error);
499
+ res.status(500).json({ error: "Vector search failed." });
500
+ }
501
+ }
502
+ );
163
503
  `;
164
504
  }
165
505
  async function ensureFirebaseRc(targetDir, projectId) {
@@ -266,41 +606,88 @@ admin.initializeApp();
266
606
  `, "utf8");
267
607
  return { entryPath: defaultEntry, created: defaultEntry };
268
608
  }
269
- async function ensureFunctionExport(targetDir, config, entryRelativePath) {
609
+ async function resolveEntryContext(targetDir, entryRelativePath) {
270
610
  const functionsPackage = await readJsonIfExists(path.join(targetDir, "functions", "package.json"));
271
611
  const entryFullPath = path.join(targetDir, entryRelativePath);
272
612
  const moduleStyle = detectModuleStyle(entryFullPath, functionsPackage);
273
613
  const extension = path.extname(entryRelativePath) || ".js";
274
- const generatedFileName = `firestoreOnDocumentWritten${extension}`;
275
- const generatedRelativePath = path.join(path.dirname(entryRelativePath), generatedFileName);
276
- const generatedBaseName = path.basename(generatedFileName, extension);
277
- const generatedImportPath = moduleStyle === "esm"
278
- ? extension === ".ts"
279
- ? `./${generatedBaseName}.js`
280
- : `./${generatedFileName}`
281
- : `./${generatedBaseName}`;
614
+ return {
615
+ moduleStyle,
616
+ extension,
617
+ entryRelativePath,
618
+ entryFullPath
619
+ };
620
+ }
621
+ async function ensureFunctionExport(targetDir, config, context) {
622
+ const generatedFileName = `firestoreOnDocumentWritten${context.extension}`;
623
+ const generatedRelativePath = path.join(path.dirname(context.entryRelativePath), generatedFileName);
624
+ const generatedImportPath = buildGeneratedImportPath(context.moduleStyle, context.extension, "firestoreOnDocumentWritten");
282
625
  const generatedFullPath = path.join(targetDir, generatedRelativePath);
283
626
  const functionModuleText = buildFunctionModule({
284
- style: moduleStyle,
285
- functionName: config.functionName,
286
- documentPath: config.documentPath,
287
- region: config.region
627
+ style: context.moduleStyle,
628
+ config
288
629
  });
289
630
  await writeFile(generatedFullPath, functionModuleText, "utf8");
290
- const entryText = (await readFile(entryFullPath, "utf8")).trimEnd();
291
- const exportLine = moduleStyle === "cjs"
631
+ const entryText = (await readFile(context.entryFullPath, "utf8")).trimEnd();
632
+ const exportLine = context.moduleStyle === "cjs"
292
633
  ? `exports.${config.functionName} = require("${generatedImportPath}").${config.functionName};`
293
634
  : `export { ${config.functionName} } from "${generatedImportPath}";`;
294
635
  const updated = appendIfMissing(entryText, exportLine);
295
636
  if (updated.changed) {
296
- await writeFile(entryFullPath, `${updated.content}`, "utf8");
637
+ await writeFile(context.entryFullPath, updated.content, "utf8");
297
638
  }
298
639
  return {
299
640
  generatedRelativePath: toPosixPath(generatedRelativePath),
300
- entryRelativePath: toPosixPath(entryRelativePath),
641
+ entryRelativePath: toPosixPath(context.entryRelativePath),
301
642
  entryUpdated: updated.changed
302
643
  };
303
644
  }
645
+ async function ensureVectorSearchScaffold(targetDir, config, context) {
646
+ if (!config.vectorSearch.enabled) {
647
+ return null;
648
+ }
649
+ const folder = path.dirname(context.entryRelativePath);
650
+ const onRequestFileName = `vectorSearchOnRequest${context.extension}`;
651
+ const embeddingFileName = `vectorSearchEmbedding${context.extension}`;
652
+ const onRequestRelativePath = path.join(folder, onRequestFileName);
653
+ const embeddingRelativePath = path.join(folder, embeddingFileName);
654
+ const onRequestFullPath = path.join(targetDir, onRequestRelativePath);
655
+ const embeddingFullPath = path.join(targetDir, embeddingRelativePath);
656
+ const onRequestImportPath = buildGeneratedImportPath(context.moduleStyle, context.extension, "vectorSearchOnRequest");
657
+ await writeFile(onRequestFullPath, buildVectorSearchOnRequestModule(context.moduleStyle, config, context.extension), "utf8");
658
+ const embeddingCreated = await writeTextIfMissing(embeddingFullPath, buildEmbeddingModule(context.moduleStyle));
659
+ const entryText = (await readFile(context.entryFullPath, "utf8")).trimEnd();
660
+ const exportLine = context.moduleStyle === "cjs"
661
+ ? `exports.${config.vectorSearch.functionName} = require("${onRequestImportPath}").${config.vectorSearch.functionName};`
662
+ : `export { ${config.vectorSearch.functionName} } from "${onRequestImportPath}";`;
663
+ const updated = appendIfMissing(entryText, exportLine);
664
+ if (updated.changed) {
665
+ await writeFile(context.entryFullPath, updated.content, "utf8");
666
+ }
667
+ return {
668
+ generatedRelativePath: toPosixPath(onRequestRelativePath),
669
+ embeddingRelativePath: toPosixPath(embeddingRelativePath),
670
+ entryRelativePath: toPosixPath(context.entryRelativePath),
671
+ entryUpdated: updated.changed,
672
+ embeddingCreated
673
+ };
674
+ }
675
+ async function persistBootstrapConfig(targetDir, config) {
676
+ const filePath = path.join(targetDir, config.configFileName);
677
+ const persisted = {
678
+ projectId: config.projectId,
679
+ region: config.region,
680
+ serviceId: config.serviceId,
681
+ location: config.location,
682
+ documentPath: config.documentPath,
683
+ functionName: config.functionName,
684
+ installDependencies: config.installDependencies,
685
+ configFileName: config.configFileName,
686
+ vectorSearch: config.vectorSearch
687
+ };
688
+ await writeJson(filePath, persisted);
689
+ return toPosixPath(config.configFileName);
690
+ }
304
691
  async function runNpmInstall(functionsDir) {
305
692
  await new Promise((resolvePromise, rejectPromise) => {
306
693
  const child = spawn("npm", ["install"], {
@@ -350,11 +737,26 @@ export async function run(config) {
350
737
  else {
351
738
  summaryLines.push(`Using existing ${entryPath}`);
352
739
  }
353
- const exportResult = await ensureFunctionExport(targetDir, config, entryPath);
354
- summaryLines.push(`Updated ${exportResult.generatedRelativePath}`);
355
- summaryLines.push(exportResult.entryUpdated
356
- ? `Appended export in ${exportResult.entryRelativePath}`
357
- : `Export already present in ${exportResult.entryRelativePath}`);
740
+ const context = await resolveEntryContext(targetDir, entryPath);
741
+ const documentWriteResult = await ensureFunctionExport(targetDir, config, context);
742
+ summaryLines.push(`Updated ${documentWriteResult.generatedRelativePath}`);
743
+ summaryLines.push(documentWriteResult.entryUpdated
744
+ ? `Appended export in ${documentWriteResult.entryRelativePath}`
745
+ : `Export already present in ${documentWriteResult.entryRelativePath}`);
746
+ const vectorSearchResult = await ensureVectorSearchScaffold(targetDir, config, context);
747
+ if (vectorSearchResult) {
748
+ summaryLines.push(`Updated ${vectorSearchResult.generatedRelativePath}`);
749
+ summaryLines.push(vectorSearchResult.embeddingCreated
750
+ ? `Created ${vectorSearchResult.embeddingRelativePath}`
751
+ : `Using existing ${vectorSearchResult.embeddingRelativePath}`);
752
+ summaryLines.push(vectorSearchResult.entryUpdated
753
+ ? `Appended export in ${vectorSearchResult.entryRelativePath}`
754
+ : `Vector search export already present in ${vectorSearchResult.entryRelativePath}`);
755
+ }
756
+ else {
757
+ summaryLines.push("Skipped vector search scaffolding");
758
+ }
759
+ summaryLines.push(`Updated ${await persistBootstrapConfig(targetDir, config)}`);
358
760
  if (config.installDependencies) {
359
761
  const functionsDir = path.join(targetDir, "functions");
360
762
  await runNpmInstall(functionsDir);
@@ -0,0 +1,2 @@
1
+ import type { CliArgs, SetupConfig } from "./types.js";
2
+ export declare function collectConfig(parsed: CliArgs): Promise<SetupConfig>;
package/dist/prompt.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { resolve } from "node:path";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { createInterface } from "node:readline/promises";
3
4
  import { stdin as input, stdout as output } from "node:process";
5
+ const DEFAULT_CONFIG_FILE = ".firebase-dataconnect-bootstrap.json";
6
+ const DEFAULT_RETURN_FIELDS = ["title", "summary", "createdAt"];
4
7
  function normalizeFunctionName(name) {
5
8
  const cleaned = name.replace(/[^A-Za-z0-9_$]/g, "_");
6
9
  if (!cleaned) {
@@ -11,15 +14,64 @@ function normalizeFunctionName(name) {
11
14
  }
12
15
  return `fn_${cleaned}`;
13
16
  }
17
+ function toCsv(fields) {
18
+ return fields.join(",");
19
+ }
20
+ function parseCsvFields(value) {
21
+ const fields = value
22
+ .split(",")
23
+ .map((field) => field.trim())
24
+ .filter(Boolean);
25
+ return fields.length > 0 ? fields : [...DEFAULT_RETURN_FIELDS];
26
+ }
27
+ function deriveCollectionPath(documentPath) {
28
+ const segments = documentPath.split("/").filter(Boolean);
29
+ if (segments.length === 0) {
30
+ return "meetingSummaries";
31
+ }
32
+ while (segments.length > 0 && segments[segments.length - 1].startsWith("{")) {
33
+ segments.pop();
34
+ }
35
+ if (segments.length === 0) {
36
+ return "meetingSummaries";
37
+ }
38
+ return segments.join("/");
39
+ }
40
+ function normalizeVectorSearch(partialVectorSearch, documentPath) {
41
+ const source = partialVectorSearch ?? {};
42
+ const collectionPath = source.collectionPath?.trim() || deriveCollectionPath(documentPath);
43
+ const sourceTextField = source.sourceTextField?.trim() || "summary";
44
+ const vectorField = source.vectorField?.trim() || "embedding";
45
+ const returnFields = source.returnFields?.length ? source.returnFields : DEFAULT_RETURN_FIELDS;
46
+ const functionName = normalizeFunctionName(source.functionName?.trim() || "searchByVector");
47
+ const rawTopK = typeof source.defaultTopK === "number" && Number.isFinite(source.defaultTopK)
48
+ ? Math.floor(source.defaultTopK)
49
+ : 5;
50
+ const defaultTopK = rawTopK > 0 ? rawTopK : 5;
51
+ const enabled = typeof source.enabled === "boolean" ? source.enabled : false;
52
+ return {
53
+ enabled,
54
+ collectionPath,
55
+ sourceTextField,
56
+ vectorField,
57
+ returnFields,
58
+ functionName,
59
+ defaultTopK
60
+ };
61
+ }
14
62
  function normalizeConfig(partial) {
15
63
  const targetDir = resolve(partial.targetDir ?? process.cwd());
16
- const region = partial.region?.trim() || "us-central1";
17
64
  const projectId = partial.projectId?.trim() || "";
65
+ const region = partial.region?.trim() || "us-central1";
18
66
  const serviceId = partial.serviceId?.trim() || `${projectId || "my-project"}-service`;
19
67
  const location = partial.location?.trim() || region;
20
68
  const documentPath = partial.documentPath?.trim() || "meetingSummaries/{summaryId}";
21
69
  const functionName = normalizeFunctionName(partial.functionName?.trim() || "onMeetingSummaryWritten");
22
- const installDependencies = typeof partial.installDependencies === "boolean" ? partial.installDependencies : true;
70
+ const configFileName = partial.configFileName?.trim() || DEFAULT_CONFIG_FILE;
71
+ const installDependencies = typeof partial.installDependencies === "boolean"
72
+ ? partial.installDependencies
73
+ : true;
74
+ const vectorSearch = normalizeVectorSearch(partial.vectorSearch, documentPath);
23
75
  return {
24
76
  targetDir,
25
77
  projectId,
@@ -28,11 +80,50 @@ function normalizeConfig(partial) {
28
80
  location,
29
81
  documentPath,
30
82
  functionName,
31
- installDependencies
83
+ installDependencies,
84
+ configFileName,
85
+ vectorSearch
86
+ };
87
+ }
88
+ async function loadSavedConfig(targetDir, configFileName) {
89
+ try {
90
+ const filePath = resolve(targetDir, configFileName);
91
+ const raw = await readFile(filePath, "utf8");
92
+ const parsed = JSON.parse(raw);
93
+ if (typeof parsed !== "object" || parsed === null) {
94
+ return {};
95
+ }
96
+ return parsed;
97
+ }
98
+ catch {
99
+ return {};
100
+ }
101
+ }
102
+ function mergeConfig(parsed, saved, targetDir) {
103
+ return {
104
+ ...saved,
105
+ targetDir,
106
+ projectId: parsed.projectId ?? saved.projectId,
107
+ region: parsed.region ?? saved.region,
108
+ serviceId: parsed.serviceId ?? saved.serviceId,
109
+ location: parsed.location ?? saved.location,
110
+ documentPath: parsed.documentPath ?? saved.documentPath,
111
+ functionName: parsed.functionName ?? saved.functionName,
112
+ installDependencies: typeof parsed.installDependencies === "boolean"
113
+ ? parsed.installDependencies
114
+ : saved.installDependencies,
115
+ configFileName: parsed.configFileName ?? saved.configFileName ?? DEFAULT_CONFIG_FILE,
116
+ vectorSearch: {
117
+ ...(saved.vectorSearch ?? {}),
118
+ ...(parsed.vectorSearch ?? {})
119
+ }
32
120
  };
33
121
  }
34
122
  export async function collectConfig(parsed) {
35
- const defaults = normalizeConfig(parsed);
123
+ const targetDir = resolve(parsed.targetDir ?? process.cwd());
124
+ const configFileName = parsed.configFileName?.trim() || DEFAULT_CONFIG_FILE;
125
+ const saved = await loadSavedConfig(targetDir, configFileName);
126
+ const defaults = normalizeConfig(mergeConfig({ ...parsed, targetDir, configFileName }, { ...saved, targetDir, configFileName: saved.configFileName ?? configFileName }, targetDir));
36
127
  if (parsed.yes) {
37
128
  if (!defaults.projectId) {
38
129
  throw new Error("`--yes` を使う場合は `--project` が必須です。");
@@ -49,6 +140,17 @@ export async function collectConfig(parsed) {
49
140
  }
50
141
  return value;
51
142
  };
143
+ const askBoolean = async (label, fallback) => {
144
+ const base = fallback ? "y" : "n";
145
+ const value = (await ask(label, base, true)).toLowerCase();
146
+ if (["y", "yes", "true", "1", "はい"].includes(value)) {
147
+ return true;
148
+ }
149
+ if (["n", "no", "false", "0", "いいえ"].includes(value)) {
150
+ return false;
151
+ }
152
+ throw new Error(`${label} は y か n を入力してください。`);
153
+ };
52
154
  console.log("Firebase Data Connect / Cloud Functions セットアップ");
53
155
  console.log("");
54
156
  const targetDir = resolve(await ask("対象リポジトリのパス", defaults.targetDir, true));
@@ -61,6 +163,26 @@ export async function collectConfig(parsed) {
61
163
  const functionName = normalizeFunctionName(rawFunctionName);
62
164
  const installRaw = await ask("functions ディレクトリで npm install を実行しますか? (y/n)", defaults.installDependencies ? "y" : "n", true);
63
165
  const installDependencies = installRaw.toLowerCase().startsWith("y");
166
+ const vectorEnabled = await askBoolean("ベクトル検索と onRequest 検索関数を有効化しますか? (y/n)", defaults.vectorSearch.enabled);
167
+ let vectorCollectionPath = defaults.vectorSearch.collectionPath;
168
+ let sourceTextField = defaults.vectorSearch.sourceTextField;
169
+ let vectorField = defaults.vectorSearch.vectorField;
170
+ let returnFields = defaults.vectorSearch.returnFields;
171
+ let searchFunctionName = defaults.vectorSearch.functionName;
172
+ let defaultTopK = defaults.vectorSearch.defaultTopK;
173
+ if (vectorEnabled) {
174
+ vectorCollectionPath = await ask("ベクトル検索対象のコレクションパス", defaults.vectorSearch.collectionPath, true);
175
+ sourceTextField = await ask("埋め込み作成対象のテキストフィールド", defaults.vectorSearch.sourceTextField, true);
176
+ vectorField = await ask("ベクトル保存先フィールド名", defaults.vectorSearch.vectorField, true);
177
+ const returnFieldsCsv = await ask("検索結果に返すフィールド(カンマ区切り)", toCsv(defaults.vectorSearch.returnFields), true);
178
+ returnFields = parseCsvFields(returnFieldsCsv);
179
+ searchFunctionName = normalizeFunctionName(await ask("onRequest 検索関数名", defaults.vectorSearch.functionName, true));
180
+ const defaultTopKText = await ask("ベクトル検索のデフォルト topK", String(defaults.vectorSearch.defaultTopK), true);
181
+ defaultTopK = Number.parseInt(defaultTopKText, 10);
182
+ if (Number.isNaN(defaultTopK) || defaultTopK <= 0) {
183
+ throw new Error("topK は 1 以上の整数で入力してください。");
184
+ }
185
+ }
64
186
  return {
65
187
  targetDir,
66
188
  projectId,
@@ -69,7 +191,17 @@ export async function collectConfig(parsed) {
69
191
  location,
70
192
  documentPath,
71
193
  functionName,
72
- installDependencies
194
+ installDependencies,
195
+ configFileName: defaults.configFileName,
196
+ vectorSearch: {
197
+ enabled: vectorEnabled,
198
+ collectionPath: vectorCollectionPath,
199
+ sourceTextField,
200
+ vectorField,
201
+ returnFields,
202
+ functionName: searchFunctionName,
203
+ defaultTopK
204
+ }
73
205
  };
74
206
  }
75
207
  finally {
@@ -0,0 +1,40 @@
1
+ export interface VectorSearchHit {
2
+ id: string;
3
+ score: number;
4
+ data: Record<string, unknown>;
5
+ }
6
+ export interface VectorSearchResponse {
7
+ total: number;
8
+ results: VectorSearchHit[];
9
+ }
10
+ export interface SearchByVectorInput {
11
+ endpoint: string;
12
+ vector?: number[];
13
+ query?: string;
14
+ topK?: number;
15
+ headers?: Record<string, string>;
16
+ signal?: AbortSignal;
17
+ }
18
+ interface MinimalResponse {
19
+ ok: boolean;
20
+ status: number;
21
+ statusText: string;
22
+ json(): Promise<unknown>;
23
+ text(): Promise<string>;
24
+ }
25
+ type MinimalFetch = (url: string, init: {
26
+ method: string;
27
+ headers: Record<string, string>;
28
+ body: string;
29
+ signal?: AbortSignal;
30
+ }) => Promise<MinimalResponse>;
31
+ export declare function searchByVector(input: SearchByVectorInput, fetchImpl?: MinimalFetch): Promise<VectorSearchResponse>;
32
+ export interface VectorSearchClientOptions {
33
+ endpoint: string;
34
+ headers?: Record<string, string>;
35
+ fetchImpl?: MinimalFetch;
36
+ }
37
+ export declare function createVectorSearchClient(options: VectorSearchClientOptions): {
38
+ search: (input: Omit<SearchByVectorInput, "endpoint">) => Promise<VectorSearchResponse>;
39
+ };
40
+ export {};
@@ -0,0 +1,56 @@
1
+ const defaultFetch = (url, init) => fetch(url, init);
2
+ function validateVector(vector) {
3
+ if (!vector) {
4
+ return undefined;
5
+ }
6
+ if (vector.length === 0 || vector.some((value) => !Number.isFinite(value))) {
7
+ throw new Error("`vector` must be a non-empty number array.");
8
+ }
9
+ return vector;
10
+ }
11
+ export async function searchByVector(input, fetchImpl = defaultFetch) {
12
+ const endpoint = input.endpoint.trim();
13
+ if (!endpoint) {
14
+ throw new Error("`endpoint` is required.");
15
+ }
16
+ const payload = {
17
+ topK: input.topK
18
+ };
19
+ const vector = validateVector(input.vector);
20
+ if (vector) {
21
+ payload.vector = vector;
22
+ }
23
+ if (input.query) {
24
+ payload.query = input.query;
25
+ }
26
+ const response = await fetchImpl(endpoint, {
27
+ method: "POST",
28
+ headers: {
29
+ "content-type": "application/json",
30
+ ...(input.headers ?? {})
31
+ },
32
+ body: JSON.stringify(payload),
33
+ signal: input.signal
34
+ });
35
+ if (!response.ok) {
36
+ const message = await response.text();
37
+ throw new Error(`Vector search request failed (${response.status} ${response.statusText}): ${message}`);
38
+ }
39
+ const body = (await response.json());
40
+ return {
41
+ total: typeof body.total === "number" ? body.total : 0,
42
+ results: Array.isArray(body.results) ? body.results : []
43
+ };
44
+ }
45
+ export function createVectorSearchClient(options) {
46
+ return {
47
+ search: (input) => searchByVector({
48
+ ...input,
49
+ endpoint: options.endpoint,
50
+ headers: {
51
+ ...(options.headers ?? {}),
52
+ ...(input.headers ?? {})
53
+ }
54
+ }, options.fetchImpl)
55
+ };
56
+ }
@@ -0,0 +1,39 @@
1
+ export interface CliArgs {
2
+ targetDir?: string;
3
+ projectId?: string;
4
+ region?: string;
5
+ serviceId?: string;
6
+ location?: string;
7
+ documentPath?: string;
8
+ functionName?: string;
9
+ installDependencies?: boolean;
10
+ configFileName?: string;
11
+ vectorSearch?: Partial<VectorSearchConfig>;
12
+ yes: boolean;
13
+ help: boolean;
14
+ }
15
+ export interface VectorSearchConfig {
16
+ enabled: boolean;
17
+ collectionPath: string;
18
+ sourceTextField: string;
19
+ vectorField: string;
20
+ returnFields: string[];
21
+ functionName: string;
22
+ defaultTopK: number;
23
+ }
24
+ export interface SetupConfig {
25
+ targetDir: string;
26
+ projectId: string;
27
+ region: string;
28
+ serviceId: string;
29
+ location: string;
30
+ documentPath: string;
31
+ functionName: string;
32
+ installDependencies: boolean;
33
+ configFileName: string;
34
+ vectorSearch: VectorSearchConfig;
35
+ }
36
+ export interface RunResult {
37
+ targetDir: string;
38
+ summaryLines: string[];
39
+ }
package/package.json CHANGED
@@ -1,12 +1,24 @@
1
1
  {
2
2
  "name": "firebase-dataconnect-bootstrap",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Bootstrap Firebase Data Connect and Firestore onDocumentWritten function setup in any repository.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "firebase-dataconnect-bootstrap": "bin/firebase-dataconnect-bootstrap.js"
8
8
  },
9
- "main": "dist/main.js",
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./search-client": {
17
+ "types": "./dist/search-client.d.ts",
18
+ "import": "./dist/search-client.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
10
22
  "files": [
11
23
  "bin",
12
24
  "dist",
@@ -26,7 +38,8 @@
26
38
  "dataconnect",
27
39
  "firestore",
28
40
  "cloud-functions",
29
- "cli"
41
+ "cli",
42
+ "vector-search"
30
43
  ],
31
44
  "author": "",
32
45
  "license": "MIT",