Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions kits/firestore-vector-search/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Initial release of kit, see README for differences between the legacy extension and this kit
- No Eventarc events are published, matching the extension. The extension declares `onStart`, `onSuccess`, `onError` and `onCompletion` under `firebase.extensions.firestore-vector-search.v1.*` in its `extension.yaml` but never publishes any of them. Earlier `0.0.2-rc` builds of this kit published all four from `embedOnWrite` when `EVENTARC_CHANNEL` was set; they no longer do, and `EVENTARC_CHANNEL` is no longer read. If you subscribed to those events on an rc build, the subscription now receives nothing.
20 changes: 4 additions & 16 deletions kits/firestore-vector-search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,6 @@ the instances cannot collide. Set `INSTANCE_ID` in each config directory to the
same value as that directory's key in the `instances` map; it also namespaces
the internal Firestore metadata/query paths and task queue references.

## Events

When `EVENTARC_CHANNEL` is configured, the functions publish lifecycle events
such as `onStart`, `onError`, `onSuccess`, and `onCompletion` under
`firebase.extensions.firestore-vector-search.v1.*`.

## Differences from the Vector Search with Firestore extension

This kit is version 0.1.3 of the extension repackaged as an npm package, and it is
Expand Down Expand Up @@ -283,16 +277,6 @@ rather than the install-time location. Gemini embedding is not served in every
region; if you deploy somewhere it is unavailable, embedding fails and the error
is written to the document's status field.

### Events are actually published now

The extension declared four event types but never published any. The kit
publishes `onStart`, `onSuccess`, `onError` and `onCompletion` under
`firebase.extensions.firestore-vector-search.v1.*` from `embedOnWrite`, once you
set `EVENTARC_CHANNEL` in your `.env` to a channel you have created. Per-event
selection is not available, because the CLI rejects any `.env` key beginning with
`EXT_`, so `EXT_SELECTED_EVENTS` cannot be set and every event type is published.
With `EVENTARC_CHANNEL` unset, nothing is published.

### The triggers are 2nd gen

All seven functions are 2nd gen. Their service accounts need
Expand All @@ -302,6 +286,10 @@ for; the Firebase CLI grants these for you.

### Unchanged

- No Eventarc events are published. The extension declared `onStart`,
`onSuccess`, `onError` and `onCompletion` under
`firebase.extensions.firestore-vector-search.v1.*` but never published any of
them, and the kit publishes none either. `EVENTARC_CHANNEL` is not read.
- The indexed collection is still `COLLECTION_NAME` (default `products`), the
input, output and status fields still default to `input`, `embedding` and
`status`, and embeddings are still written as native Firestore vectors.
Expand Down
62 changes: 0 additions & 62 deletions kits/firestore-vector-search/src/events.ts

This file was deleted.

9 changes: 0 additions & 9 deletions kits/firestore-vector-search/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import type { CallableRequest } from "firebase-functions/v2/https";
import { HttpsError } from "firebase-functions/v2/https";
import type { Request } from "firebase-functions/v2/tasks";
import { createEmbedClient } from "./embeddings";
import * as events from "./events";
import type { ResolvedVectorSearchConfig } from "./export-config";
import * as logs from "./logs";
import {
Expand Down Expand Up @@ -74,7 +73,6 @@ export async function handleEmbedOnWrite(
ctx: HandlerContext
): Promise<void> {
if (!event.data?.after.exists) return;
await events.recordStartEvent({ params: event.params });
logs.start("embedOnWrite");

const data = event.data.after.data() ?? {};
Expand All @@ -94,10 +92,6 @@ export async function handleEmbedOnWrite(
},
{ merge: true }
);
await events.recordSuccessEvent({
subject: event.data.after.ref.path,
data: { outputFieldName: ctx.config.outputFieldName },
});
logs.complete("embedOnWrite");
} catch (err) {
await event.data.after.ref.set(
Expand All @@ -109,11 +103,8 @@ export async function handleEmbedOnWrite(
},
{ merge: true }
);
await events.recordErrorEvent(err as Error);
logs.error("embedOnWrite", err);
throw err;
} finally {
await events.recordCompletionEvent({ params: event.params });
}
}

Expand Down
3 changes: 0 additions & 3 deletions kits/firestore-vector-search/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
geminiApiKey,
openAiApiKey,
} from "./config";
import * as events from "./events";
import {
type ResolvedVectorSearchConfig,
resolveVectorSearchConfig,
Expand Down Expand Up @@ -141,8 +140,6 @@ function getContext(): HandlerContext {

ensureDefaultApp();

events.setupEventChannel();

ctx = {
firestore: getFirestore(),
config: getConfig(),
Expand Down
158 changes: 158 additions & 0 deletions kits/firestore-vector-search/tests/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

// The extension declares four event types in its `extension.yaml` and publishes
// none of them, so the kit must publish none either. These tests fail if event
// publishing is reintroduced on the embed path.

const { getSingleEmbedding } = vi.hoisted(() => ({
getSingleEmbedding: vi.fn(),
}));

vi.mock("../src/embeddings", () => ({
createEmbedClient: vi.fn(() => ({
batchSize: 1,
getEmbeddings: vi.fn(),
getSingleEmbedding,
})),
}));

vi.mock("../src/queries/setup", () => ({ createIndex: vi.fn() }));

// Records whether `firebase-admin/eventarc` ever enters the module graph. Vitest
// only evaluates this factory if something actually imports the module, so the
// flag catches a reintroduced `events.ts` even when no channel is configured and
// the publish helpers would return early.
const eventarc = vi.hoisted(() => ({ imported: false, publish: vi.fn() }));

vi.mock("firebase-admin/eventarc", () => {
eventarc.imported = true;
return {
getEventarc: vi.fn(() => ({
channel: vi.fn(() => ({ publish: eventarc.publish })),
})),
};
});

import { resolveVectorSearchConfig } from "../src/export-config";
import {
type HandlerContext,
handleEmbedOnWrite,
type VectorWriteEvent,
} from "../src/handlers";

const config = resolveVectorSearchConfig({
projectId: "test-project",
instanceId: "test-instance",
});

const EMBEDDING = [0.1, 0.2, 0.3];

function makeCtx(): HandlerContext {
return { firestore: {}, config } as unknown as HandlerContext;
}

/** A write event whose `after` holds `after` and whose `before` holds `before`. */
function writeEvent(
after: Record<string, unknown> | null,
before: Record<string, unknown> | null = null
) {
const set = vi.fn().mockResolvedValue(undefined);
const snapshot = (data: Record<string, unknown> | null) => ({
exists: data !== null,
data: () => data ?? undefined,
get: (field: string) => (data ? data[field] : undefined),
ref: { path: `${config.collectionPath}/doc-1`, set },
});
const event = {
data: { after: snapshot(after), before: snapshot(before) },
params: { docId: "doc-1" },
} as unknown as VectorWriteEvent;
return { event, set };
}

describe("event publishing", () => {
beforeEach(() => {
// `eventarc.imported` is deliberately never reset: the import it records
// happens once, when the module graph loads, before any test body runs.
eventarc.publish.mockClear();
getSingleEmbedding.mockReset();
getSingleEmbedding.mockResolvedValue(EMBEDDING);
// Configured exactly as a user would to opt into events, so a reintroduced
// publish path would be live rather than short-circuited.
process.env.EVENTARC_CHANNEL = "locations/us-central1/channels/firebase";
process.env.EXT_SELECTED_EVENTS = [
"firebase.extensions.firestore-vector-search.v1.onStart",
"firebase.extensions.firestore-vector-search.v1.onSuccess",
"firebase.extensions.firestore-vector-search.v1.onError",
"firebase.extensions.firestore-vector-search.v1.onCompletion",
].join(",");
});

afterEach(() => {
delete process.env.EVENTARC_CHANNEL;
delete process.env.EXT_SELECTED_EVENTS;
});

test("does not pull Eventarc into the handler module graph", () => {
expect(eventarc.imported).toBe(false);
});

test("does not reach Eventarc when an embedding succeeds", async () => {
const { event, set } = writeEvent({ [config.inputFieldName]: "hello" });

await handleEmbedOnWrite(event, makeCtx());

expect(set).toHaveBeenCalledTimes(1);
expect(eventarc.imported).toBe(false);
expect(eventarc.publish).not.toHaveBeenCalled();
});

test("does not reach Eventarc when an embedding fails", async () => {
const { event, set } = writeEvent({ [config.inputFieldName]: "hello" });
getSingleEmbedding.mockRejectedValue(new Error("Error with embedding"));

await expect(handleEmbedOnWrite(event, makeCtx())).rejects.toThrow(
"Error with embedding"
);

expect(set).toHaveBeenCalledTimes(1);
expect(eventarc.imported).toBe(false);
expect(eventarc.publish).not.toHaveBeenCalled();
});

test("does not reach Eventarc when the write is skipped", async () => {
const unchanged = {
[config.inputFieldName]: "hello",
[config.outputFieldName]: [0.1],
};
const skipped = [
writeEvent(null),
writeEvent({ [config.inputFieldName]: 42 }),
writeEvent(unchanged, { [config.inputFieldName]: "hello" }),
];

for (const { event } of skipped) {
await handleEmbedOnWrite(event, makeCtx());
}

expect(getSingleEmbedding).not.toHaveBeenCalled();
expect(eventarc.imported).toBe(false);
expect(eventarc.publish).not.toHaveBeenCalled();
});
});
Loading