diff --git a/core/src/sessions/base_session_service.ts b/core/src/sessions/base_session_service.ts index 29abae888..f8a6d55cc 100644 --- a/core/src/sessions/base_session_service.ts +++ b/core/src/sessions/base_session_service.ts @@ -236,6 +236,37 @@ export function trimTempState( return filteredState; } +/** + * Splits a state map into its app-scoped, user-scoped and session-scoped + * parts, stripping the `app:` and `user:` prefixes and dropping `temp:` keys. + * + * The inverse of {@link mergeStates}, which re-applies the prefixes. + * + * @param state The state to split. + * @return The app, user and session buckets. + */ +export function splitStateDelta(state: Record | undefined): { + app: Record; + user: Record; + session: Record; +} { + const app: Record = {}; + const user: Record = {}; + const session: Record = {}; + + for (const [key, value] of Object.entries(state ?? {})) { + if (key.startsWith(State.APP_PREFIX)) { + app[key.slice(State.APP_PREFIX.length)] = value; + } else if (key.startsWith(State.USER_PREFIX)) { + user[key.slice(State.USER_PREFIX.length)] = value; + } else if (!key.startsWith(State.TEMP_PREFIX)) { + session[key] = value; + } + } + + return {app, user, session}; +} + /** * Merges app state, user state, and session state. * diff --git a/core/src/sessions/database_session_service.ts b/core/src/sessions/database_session_service.ts index 033f05b9a..a1445c657 100644 --- a/core/src/sessions/database_session_service.ts +++ b/core/src/sessions/database_session_service.ts @@ -22,6 +22,7 @@ import { ListSessionsRequest, ListSessionsResponse, mergeStates, + splitStateDelta, trimTempDeltaState, } from './base_session_service.js'; import { @@ -37,7 +38,6 @@ import { StorageUserState, } from './db/schema.js'; import {createSession, Session} from './session.js'; -import {State} from './state.js'; /** * Checks if a URI is a database connection URI. @@ -140,21 +140,11 @@ export class DatabaseSessionService extends BaseSessionService { em.persist(userStateModel); } - const appStateDelta: Record = {}; - const userStateDelta: Record = {}; - const sessionState: Record = {}; - - if (state) { - for (const [key, value] of Object.entries(state)) { - if (key.startsWith(State.APP_PREFIX)) { - appStateDelta[key.replace(State.APP_PREFIX, '')] = value; - } else if (key.startsWith(State.USER_PREFIX)) { - userStateDelta[key.replace(State.USER_PREFIX, '')] = value; - } else if (!key.startsWith(State.TEMP_PREFIX)) { - sessionState[key] = value; - } - } - } + const { + app: appStateDelta, + user: userStateDelta, + session: sessionState, + } = splitStateDelta(state); if (Object.keys(appStateDelta).length > 0) { appStateModel.state = {...appStateModel.state, ...appStateDelta}; @@ -442,19 +432,11 @@ export class DatabaseSessionService extends BaseSessionService { } if (event.actions && event.actions.stateDelta) { - const appDelta: Record = {}; - const userDelta: Record = {}; - const sessionDelta: Record = {}; - - for (const [key, value] of Object.entries(event.actions.stateDelta)) { - if (key.startsWith(State.APP_PREFIX)) { - appDelta[key.replace(State.APP_PREFIX, '')] = value; - } else if (key.startsWith(State.USER_PREFIX)) { - userDelta[key.replace(State.USER_PREFIX, '')] = value; - } else if (!key.startsWith(State.TEMP_PREFIX)) { - sessionDelta[key] = value; - } - } + const { + app: appDelta, + user: userDelta, + session: sessionDelta, + } = splitStateDelta(event.actions.stateDelta); if (Object.keys(appDelta).length > 0) { appStateModel.state = {...appStateModel.state, ...appDelta}; diff --git a/core/test/sessions/base_session_service_test.ts b/core/test/sessions/base_session_service_test.ts new file mode 100644 index 000000000..c31cd8e92 --- /dev/null +++ b/core/test/sessions/base_session_service_test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {mergeStates, splitStateDelta} from '@google/adk'; +import {describe, expect, it} from 'vitest'; + +describe('splitStateDelta', () => { + it('routes each key by prefix and drops temporary keys', () => { + expect( + splitStateDelta({ + 'app:theme': 'dark', + 'user:locale': 'en', + 'temp:scratch': 1, + turn: 2, + }), + ).toEqual({ + app: {theme: 'dark'}, + user: {locale: 'en'}, + session: {turn: 2}, + }); + }); + + it('returns empty buckets for undefined state', () => { + expect(splitStateDelta(undefined)).toEqual({ + app: {}, + user: {}, + session: {}, + }); + }); + + it('strips only the leading prefix', () => { + expect(splitStateDelta({'app:app:nested': 1})).toEqual({ + app: {'app:nested': 1}, + user: {}, + session: {}, + }); + }); + + it('round-trips through mergeStates for non-temporary keys', () => { + const state = {'app:theme': 'dark', 'user:locale': 'en', turn: 2}; + const {app, user, session} = splitStateDelta(state); + + expect(mergeStates(app, user, session)).toEqual(state); + }); +}); diff --git a/integrations/package.json b/integrations/package.json index b7c467b4c..b2a11f6a2 100644 --- a/integrations/package.json +++ b/integrations/package.json @@ -40,6 +40,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@google-cloud/firestore": "^8.7.0", "@google/adk": "^1.5.0" } } diff --git a/integrations/src/firestore/firestore_session_service.ts b/integrations/src/firestore/firestore_session_service.ts new file mode 100644 index 000000000..d5efd8dfd --- /dev/null +++ b/integrations/src/firestore/firestore_session_service.ts @@ -0,0 +1,527 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {randomUUID} from 'node:crypto'; + +import { + CollectionReference, + DocumentReference, + Firestore, + Query, + Timestamp, +} from '@google-cloud/firestore'; +import { + AppendEventRequest, + BaseSessionService, + createSession, + CreateSessionRequest, + DeleteSessionRequest, + Event, + getLogger, + GetSessionConfig, + GetSessionRequest, + ListSessionsRequest, + ListSessionsResponse, + mergeStates, + Session, + splitStateDelta, + trimTempDeltaState, +} from '@google/adk'; + +/** Root collection used when neither the option nor the env var is set. */ +export const DEFAULT_ROOT_COLLECTION = 'adk-session'; + +/** Environment variable that overrides the default root collection. */ +export const ROOT_COLLECTION_ENV_VAR = 'ADK_FIRESTORE_ROOT_COLLECTION'; + +/** Collection holding one user's session documents. */ +export const SESSIONS_COLLECTION = 'sessions'; + +/** Collection holding one session's event documents. */ +export const EVENTS_COLLECTION = 'events'; + +/** Top-level collection holding state shared by a whole app. */ +export const APP_STATE_COLLECTION = 'app_states'; + +/** Top-level collection holding state shared by one user's sessions. */ +export const USER_STATE_COLLECTION = 'user_states'; + +/** Path segment separating an app document from its user documents. */ +export const USERS_COLLECTION = 'users'; + +/** Status written to a session document while it is being deleted. */ +const DELETING_STATUS = 'DELETING'; + +/** + * Deletes per write batch. Firestore caps the number of writes in one batched + * write; adk-python's port batches at this same size. + */ +const MAX_DELETES_PER_BATCH = 500; + +/** Field on an event document holding the serialized event. */ +const EVENT_DATA_FIELD = 'event_data'; + +/** + * A session document as read back. + * + * Every field is optional and loosely typed: a document may predate a field, + * and `state` is a JSON string on documents this service writes but may be a + * plain map on documents written by another client. + */ +interface StoredSession { + state?: string | Record; + updateTime?: unknown; + revision?: number; + status?: string; +} + +/** An event document as read back. */ +interface StoredEvent { + [EVENT_DATA_FIELD]?: Event; +} + +/** Options for {@link FirestoreSessionService}. */ +export interface FirestoreSessionServiceOptions { + /** An existing Firestore client. A default client is created when omitted. */ + client?: Firestore; + /** + * Root collection name. Falls back to the `ADK_FIRESTORE_ROOT_COLLECTION` + * environment variable, then to {@link DEFAULT_ROOT_COLLECTION}. + */ + rootCollection?: string; +} + +/** Narrows a value to a Firestore `Timestamp` without using `instanceof`. */ +function isTimestamp(value: unknown): value is Timestamp { + return ( + typeof value === 'object' && + value !== null && + 'toMillis' in value && + typeof value.toMillis === 'function' + ); +} + +/** + * Converts a stored `updateTime` to epoch milliseconds, yielding 0 when the + * field is absent or was not written as a `Timestamp`. + */ +function toLastUpdateTime(updateTime: unknown): number { + return isTimestamp(updateTime) ? updateTime.toMillis() : 0; +} + +/** Reads a session document's `state` field in either stored representation. */ +function parseSessionState( + raw: string | Record | undefined, +): Record { + if (typeof raw === 'string') { + return JSON.parse(raw); + } + return raw ?? {}; +} + +/** + * Serializes an event for storage, dropping `undefined` at every depth. + * + * This is the equivalent of Python's `model_dump(exclude_none=True)`. + * Firestore rejects `undefined` unless the client was constructed with + * `ignoreUndefinedProperties`, and the client may be caller-supplied, so the + * service cannot rely on that setting. + */ +function toEventData(event: Event): Record { + return JSON.parse(JSON.stringify(event)); +} + +/** Reads a session's events, applying the window in `config`. */ +async function fetchEvents( + sessionRef: DocumentReference, + config: GetSessionConfig | undefined, +): Promise { + // A numRecentEvents of 0 asks for no events, but it is falsy, so it would + // otherwise fall through the limit below and return every event. Returning + // nothing matches VertexAiSessionService and the adk-python database, + // sqlite, in-memory and Vertex AI backends. + if (config?.numRecentEvents === 0) { + return []; + } + + let query: Query = sessionRef + .collection(EVENTS_COLLECTION) + .orderBy('timestamp'); + if (config?.afterTimestamp) { + query = query.where( + 'timestamp', + '>=', + Timestamp.fromMillis(config.afterTimestamp), + ); + } + if (config?.numRecentEvents) { + query = query.limitToLast(config.numRecentEvents); + } + + const snapshot = await query.get(); + const events: Event[] = []; + for (const doc of snapshot.docs) { + const stored: StoredEvent = doc.data(); + if (stored[EVENT_DATA_FIELD]) { + events.push(stored[EVENT_DATA_FIELD]); + } + } + return events; +} + +/** + * Sorts and paginates sessions in memory, matching the arithmetic + * `InMemorySessionService` applies. + * + * Firestore could paginate server side, but `totalItems` would then need a + * separate count query, and the two other adk-js backends already paginate in + * memory. + */ +function paginateSessions( + sessions: Session[], + {limit, offset, page, order}: ListSessionsRequest, +): ListSessionsResponse { + if (order === 'asc') { + sessions.sort( + (a, b) => a.lastUpdateTime - b.lastUpdateTime || a.id.localeCompare(b.id), + ); + } else if (order === 'desc') { + sessions.sort( + (a, b) => b.lastUpdateTime - a.lastUpdateTime || a.id.localeCompare(b.id), + ); + } + + const totalItems = sessions.length; + + if (limit === undefined) { + return { + sessions: offset ? sessions.slice(offset) : sessions, + page: 1, + limit: totalItems, + totalItems, + totalPages: totalItems === 0 ? 0 : 1, + }; + } + + const effectiveOffset = + page !== undefined ? (page - 1) * limit : (offset ?? 0); + const effectivePage = + page !== undefined + ? page + : limit === 0 + ? 1 + : Math.floor(effectiveOffset / limit) + 1; + + return { + sessions: sessions.slice(effectiveOffset, effectiveOffset + limit), + page: effectivePage, + limit, + totalItems, + totalPages: limit === 0 ? 0 : Math.ceil(totalItems / limit), + }; +} + +/** + * A session service backed by Google Cloud Firestore. + * + * Sessions and their events live under the root collection: + * + * ``` + * //users//sessions/ + * //users//sessions//events/ + * ``` + * + * State shared by a whole app, or by one user's sessions, lives in two sibling + * top-level collections and is merged back under the `app:` and `user:` + * prefixes on read: + * + * ``` + * app_states/ + * user_states//users/ + * ``` + * + * `appendEvent` runs in a Firestore transaction whose writes all derive from + * the session document it read, so concurrent appends — from this process or + * another — are serialized by Firestore's own conflict detection and retry. + */ +export class FirestoreSessionService extends BaseSessionService { + private readonly client: Firestore; + private readonly rootCollection: string; + + constructor(options: FirestoreSessionServiceOptions = {}) { + super(); + this.client = options.client ?? new Firestore(); + this.rootCollection = + options.rootCollection || + process.env[ROOT_COLLECTION_ENV_VAR] || + DEFAULT_ROOT_COLLECTION; + } + + async createSession({ + appName, + userId, + state, + sessionId, + }: CreateSessionRequest): Promise { + const id = sessionId || randomUUID(); + const { + app: appDelta, + user: userDelta, + session: sessionState, + } = splitStateDelta(state); + + const sessionRef = this.sessionRef(appName, userId, id); + const appRef = this.appStateRef(appName); + const userRef = this.userStateRef(appName, userId); + const nowMillis = Date.now(); + const now = Timestamp.fromMillis(nowMillis); + + const [appState, userState] = await this.client.runTransaction( + async (tx) => { + // Firestore rejects a read issued after a write in the same + // transaction, so all three reads are batched up front. + const [sessionSnapshot, appSnapshot, userSnapshot] = await tx.getAll( + sessionRef, + appRef, + userRef, + ); + if (sessionSnapshot.exists) { + throw new Error(`Session with id ${id} already exists.`); + } + + // The snapshots are read for the merged state this returns; the + // writes only need the deltas, because `merge` merges server side. + const currentApp: Record = appSnapshot.data() ?? {}; + const currentUser: Record = userSnapshot.data() ?? {}; + + if (Object.keys(appDelta).length > 0) { + tx.set(appRef, appDelta, {merge: true}); + } + if (Object.keys(userDelta).length > 0) { + tx.set(userRef, userDelta, {merge: true}); + } + + tx.set(sessionRef, { + id, + appName, + userId, + state: JSON.stringify(sessionState), + createTime: now, + updateTime: now, + revision: 0, + }); + + return [currentApp, currentUser]; + }, + ); + + return createSession({ + id, + appName, + userId, + state: mergeStates( + {...appState, ...appDelta}, + {...userState, ...userDelta}, + sessionState, + ), + events: [], + lastUpdateTime: nowMillis, + }); + } + + async getSession({ + appName, + userId, + sessionId, + config, + }: GetSessionRequest): Promise { + const sessionRef = this.sessionRef(appName, userId, sessionId); + const data: StoredSession | undefined = (await sessionRef.get()).data(); + if (!data || Object.keys(data).length === 0) { + return undefined; + } + + const [events, appSnapshot, userSnapshot] = await Promise.all([ + fetchEvents(sessionRef, config), + this.appStateRef(appName).get(), + this.userStateRef(appName, userId).get(), + ]); + + return createSession({ + id: sessionId, + appName, + userId, + state: mergeStates( + appSnapshot.data(), + userSnapshot.data(), + parseSessionState(data.state), + ), + events, + lastUpdateTime: toLastUpdateTime(data.updateTime), + }); + } + + async listSessions( + request: ListSessionsRequest, + ): Promise { + const {appName, userId} = request; + const [snapshot, appSnapshot, userSnapshot] = await Promise.all([ + this.sessionsRef(appName, userId).get(), + this.appStateRef(appName).get(), + this.userStateRef(appName, userId).get(), + ]); + + const appState = appSnapshot.data(); + const userState = userSnapshot.data(); + const sessions = snapshot.docs.map((doc) => { + const data: StoredSession = doc.data(); + return createSession({ + id: doc.id, + appName, + userId, + state: mergeStates(appState, userState, parseSessionState(data.state)), + events: [], + lastUpdateTime: toLastUpdateTime(data.updateTime), + }); + }); + + return paginateSessions(sessions, request); + } + + async deleteSession({ + appName, + userId, + sessionId, + }: DeleteSessionRequest): Promise { + const sessionRef = this.sessionRef(appName, userId, sessionId); + + // The marker lets a concurrent append fail rather than resurrect a + // half-deleted session. It is best effort: failing to write it must not + // block the delete. + try { + await this.client.runTransaction(async (tx) => { + if ((await tx.get(sessionRef)).exists) { + tx.update(sessionRef, {status: DELETING_STATUS}); + } + }); + } catch (e: unknown) { + getLogger().debug( + `Failed to mark session ${sessionId} as deleting: ${String(e)}`, + ); + } + + const eventRefs = await sessionRef + .collection(EVENTS_COLLECTION) + .listDocuments(); + for (let i = 0; i < eventRefs.length; i += MAX_DELETES_PER_BATCH) { + const batch = this.client.batch(); + for (const ref of eventRefs.slice(i, i + MAX_DELETES_PER_BATCH)) { + batch.delete(ref); + } + await batch.commit(); + } + + await sessionRef.delete(); + } + + override async appendEvent({ + session, + event, + }: AppendEventRequest): Promise { + if (event.partial) { + return event; + } + + const trimmed = trimTempDeltaState(event); + const { + app: appDelta, + user: userDelta, + session: sessionDelta, + } = splitStateDelta(trimmed.actions.stateDelta); + const hasAppDelta = Object.keys(appDelta).length > 0; + const hasUserDelta = Object.keys(userDelta).length > 0; + + const sessionRef = this.sessionRef( + session.appName, + session.userId, + session.id, + ); + + await this.client.runTransaction(async (tx) => { + const data: StoredSession | undefined = (await tx.get(sessionRef)).data(); + if (!data) { + throw new Error(`Session ${session.id} not found for appendEvent`); + } + if (data.status === DELETING_STATUS) { + throw new Error(`Session ${session.id} is currently being deleted.`); + } + + // Both written values derive from the snapshot just read, so they sit in + // the transaction's read set and Firestore aborts and re-runs this + // callback if a concurrent writer commits first. Deriving the state from + // the caller's in-memory `session.state` instead would put it outside the + // read set, leaving the conflict undetectable and letting a stale caller + // clobber another writer's keys. + tx.update(sessionRef, { + state: JSON.stringify({ + ...parseSessionState(data.state), + ...sessionDelta, + }), + updateTime: Timestamp.fromMillis(trimmed.timestamp), + revision: (data.revision ?? 0) + 1, + }); + + // `merge` merges field by field on the server, so these need no read. + if (hasAppDelta) { + tx.set(this.appStateRef(session.appName), appDelta, {merge: true}); + } + if (hasUserDelta) { + tx.set(this.userStateRef(session.appName, session.userId), userDelta, { + merge: true, + }); + } + + tx.set(sessionRef.collection(EVENTS_COLLECTION).doc(trimmed.id), { + [EVENT_DATA_FIELD]: toEventData(trimmed), + timestamp: Timestamp.fromMillis(trimmed.timestamp), + appName: session.appName, + userId: session.userId, + }); + }); + + session.lastUpdateTime = trimmed.timestamp; + return super.appendEvent({session, event: trimmed}); + } + + private sessionsRef(appName: string, userId: string): CollectionReference { + return this.client + .collection(this.rootCollection) + .doc(appName) + .collection(USERS_COLLECTION) + .doc(userId) + .collection(SESSIONS_COLLECTION); + } + + private sessionRef( + appName: string, + userId: string, + sessionId: string, + ): DocumentReference { + return this.sessionsRef(appName, userId).doc(sessionId); + } + + private appStateRef(appName: string): DocumentReference { + return this.client.collection(APP_STATE_COLLECTION).doc(appName); + } + + private userStateRef(appName: string, userId: string): DocumentReference { + return this.client + .collection(USER_STATE_COLLECTION) + .doc(appName) + .collection(USERS_COLLECTION) + .doc(userId); + } +} diff --git a/integrations/src/index.ts b/integrations/src/index.ts index 7c02c02e5..a8f1a6d2f 100644 --- a/integrations/src/index.ts +++ b/integrations/src/index.ts @@ -4,4 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ +export {FirestoreSessionService} from './firestore/firestore_session_service.js'; +export type {FirestoreSessionServiceOptions} from './firestore/firestore_session_service.js'; export {version} from './version.js'; diff --git a/integrations/test/firestore/fake_firestore.ts b/integrations/test/firestore/fake_firestore.ts new file mode 100644 index 000000000..186e45c44 --- /dev/null +++ b/integrations/test/firestore/fake_firestore.ts @@ -0,0 +1,459 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Firestore, Timestamp} from '@google-cloud/firestore'; + +/** A stored document: a flat map of field name to value. */ +export type StoredDocument = Record; + +/** Options accepted by `set`. */ +interface SetOptions { + merge?: boolean; +} + +/** The narrowing applied to a query before it is run. */ +interface QuerySpec { + ordered?: boolean; + minTimestampMillis?: number; + limitToLast?: number; +} + +/** The only field this fake can order or filter on. */ +const TIMESTAMP_FIELD = 'timestamp'; + +/** Mirrors the real client's DEFAULT_MAX_TRANSACTION_ATTEMPTS. */ +const MAX_TRANSACTION_ATTEMPTS = 5; + +/** + * An in-memory Firestore, holding every document in one flat map keyed by its + * full slash-separated path. Children are derived by path prefix, so there is + * no document tree to keep consistent. + * + * It implements only the slice of the Firestore surface + * `FirestoreSessionService` uses, and throws on anything outside that slice + * rather than quietly returning a wrong answer. + */ +export class FakeStore { + private readonly docs = new Map(); + + /** + * Bumped on every write to a path. A transaction records the versions it + * read and refuses to commit if any of them moved, which is how real + * Firestore detects a conflicting concurrent write. + */ + private readonly versions = new Map(); + + /** Path of every query run, in order, for asserting a query did not run. */ + readonly queryPaths: string[] = []; + + /** + * Every document read and write, in order, as ` `, for asserting + * how two concurrent transactions interleaved. + */ + readonly operations: string[] = []; + + /** Number of write batches committed. */ + batchCommitCount = 0; + + /** Number of transaction attempts aborted by a read-set conflict. */ + transactionRetryCount = 0; + + /** Current version of a path; 0 before it is ever written. */ + versionOf(path: string): number { + return this.versions.get(path) ?? 0; + } + + private bump(path: string): void { + this.versions.set(path, this.versionOf(path) + 1); + } + + /** Every stored document, keyed by full path. */ + get documents(): ReadonlyMap { + return this.docs; + } + + /** Writes a document outright, replacing any existing one. */ + write(path: string, data: StoredDocument): void { + this.bump(path); + this.docs.set(path, {...data}); + } + + /** Writes a document, merging into the existing one when asked. */ + set(path: string, data: StoredDocument, merge: boolean): void { + this.operations.push(`write ${path}`); + this.bump(path); + const existing = merge ? this.docs.get(path) : undefined; + this.docs.set(path, {...existing, ...data}); + } + + /** Merges fields into an existing document, as Firestore's update does. */ + update(path: string, data: StoredDocument): void { + this.operations.push(`write ${path}`); + this.bump(path); + const existing = this.docs.get(path); + if (!existing) { + throw new Error( + `fake Firestore: cannot update missing document '${path}'`, + ); + } + this.docs.set(path, {...existing, ...data}); + } + + /** Removes a document, if present. */ + delete(path: string): void { + this.bump(path); + this.docs.delete(path); + } + + /** + * Returns a shallow copy of a document's fields, as Firestore does, so a + * caller mutating the result cannot reach into the store. + */ + read(path: string): StoredDocument | undefined { + const stored = this.docs.get(path); + return stored && {...stored}; + } + + /** Paths of the documents directly inside a collection. */ + childPaths(collectionPath: string): string[] { + const prefix = `${collectionPath}/`; + return [...this.docs.keys()].filter( + (path) => + path.startsWith(prefix) && !path.slice(prefix.length).includes('/'), + ); + } + + private barrier?: {wait: Promise; release: () => void}; + + /** + * Suspends the caller until the end of the current tick, releasing every + * caller that arrived in the same tick together. + * + * Transaction reads await this so concurrent transactions genuinely + * interleave. A plain `setTimeout` would not: Node drains the microtask + * queue between timer callbacks, so each transaction would run to + * completion before the next one resumed and an unserialized + * read-modify-write would never be caught losing an update. + */ + yieldToPeers(): Promise { + if (!this.barrier) { + let release!: () => void; + const wait = new Promise((resolve) => { + release = resolve; + }); + this.barrier = {wait, release}; + setTimeout(() => { + const pending = this.barrier; + this.barrier = undefined; + pending?.release(); + }, 0); + } + return this.barrier.wait; + } +} + +/** A document snapshot over the flat store. */ +class FakeDocumentSnapshot { + constructor( + readonly id: string, + private readonly stored: StoredDocument | undefined, + ) {} + + get exists(): boolean { + return this.stored !== undefined; + } + + data(): StoredDocument | undefined { + return this.stored; + } +} + +/** A query snapshot over the flat store. */ +class FakeQuerySnapshot { + constructor(readonly docs: FakeDocumentSnapshot[]) {} +} + +/** Narrows a value to a Timestamp structurally, as the service does. */ +function isTimestamp(value: unknown): value is Timestamp { + return ( + typeof value === 'object' && + value !== null && + 'toMillis' in value && + typeof value.toMillis === 'function' + ); +} + +/** Reads the orderable timestamp field off a stored document. */ +function timestampMillis(data: StoredDocument): number { + const value = data[TIMESTAMP_FIELD]; + if (!isTimestamp(value)) { + throw new Error( + `fake Firestore: document is missing a Timestamp '${TIMESTAMP_FIELD}'`, + ); + } + return value.toMillis(); +} + +/** A query over one collection. */ +class FakeQuery { + constructor( + protected readonly store: FakeStore, + protected readonly path: string, + private readonly spec: QuerySpec = {}, + ) {} + + orderBy(field: string): FakeQuery { + if (field !== TIMESTAMP_FIELD) { + throw new Error( + `fake Firestore: orderBy is only implemented for '${TIMESTAMP_FIELD}', got '${field}'`, + ); + } + return new FakeQuery(this.store, this.path, {...this.spec, ordered: true}); + } + + where(field: string, operator: string, value: Timestamp): FakeQuery { + if (field !== TIMESTAMP_FIELD || operator !== '>=') { + throw new Error( + `fake Firestore: where is only implemented for '${TIMESTAMP_FIELD} >=', got '${field} ${operator}'`, + ); + } + return new FakeQuery(this.store, this.path, { + ...this.spec, + minTimestampMillis: value.toMillis(), + }); + } + + limitToLast(limit: number): FakeQuery { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error( + 'fake Firestore: limitToLast() requires a positive integer', + ); + } + return new FakeQuery(this.store, this.path, { + ...this.spec, + limitToLast: limit, + }); + } + + get(): Promise { + this.store.queryPaths.push(this.path); + + let paths = this.store.childPaths(this.path); + const {minTimestampMillis, ordered, limitToLast} = this.spec; + if (minTimestampMillis !== undefined) { + paths = paths.filter((path) => this.millisAt(path) >= minTimestampMillis); + } + if (ordered) { + paths.sort((a, b) => this.millisAt(a) - this.millisAt(b)); + } + if (limitToLast !== undefined) { + paths = paths.slice(-limitToLast); + } + + return Promise.resolve( + new FakeQuerySnapshot( + paths.map( + (path) => + new FakeDocumentSnapshot(documentId(path), this.store.read(path)), + ), + ), + ); + } + + private millisAt(path: string): number { + const data = this.store.read(path); + if (!data) { + throw new Error(`fake Firestore: document '${path}' disappeared`); + } + return timestampMillis(data); + } +} + +/** A collection reference; a query that can also address its documents. */ +class FakeCollectionReference extends FakeQuery { + doc(id: string): FakeDocumentReference { + return new FakeDocumentReference(this.store, `${this.path}/${id}`); + } + + listDocuments(): Promise { + return Promise.resolve( + this.store + .childPaths(this.path) + .map((path) => new FakeDocumentReference(this.store, path)), + ); + } +} + +/** A document reference over the flat store. */ +class FakeDocumentReference { + constructor( + readonly store: FakeStore, + readonly path: string, + ) {} + + collection(name: string): FakeCollectionReference { + return new FakeCollectionReference(this.store, `${this.path}/${name}`); + } + + get(): Promise { + this.store.operations.push(`read ${this.path}`); + return Promise.resolve( + new FakeDocumentSnapshot( + documentId(this.path), + this.store.read(this.path), + ), + ); + } + + delete(): Promise { + this.store.delete(this.path); + return Promise.resolve(); + } +} + +/** Last path segment of a document path. */ +function documentId(path: string): string { + return path.slice(path.lastIndexOf('/') + 1); +} + +/** Raised when a transaction's read set changed before it committed. */ +export class FakeAbortedError extends Error { + constructor(path: string) { + super(`fake Firestore: transaction aborted, '${path}' changed`); + } +} + +/** + * A transaction that buffers its writes and applies them only once the + * callback resolves, so a throwing callback leaves the store untouched. + * + * Reads are recorded with the version they saw, and the commit is refused if + * any of them moved meanwhile. That is Firestore's own concurrency model, and + * it is what makes the concurrent-append test meaningful: a fake that + * committed unconditionally would let a lost update pass. + */ +class FakeTransaction { + private readonly writes: Array<() => void> = []; + private readonly readVersions = new Map(); + + constructor(private readonly store: FakeStore) {} + + async get(ref: FakeDocumentReference): Promise { + await this.store.yieldToPeers(); + this.readVersions.set(ref.path, this.store.versionOf(ref.path)); + return ref.get(); + } + + async getAll( + ...refs: FakeDocumentReference[] + ): Promise { + await this.store.yieldToPeers(); + for (const ref of refs) { + this.readVersions.set(ref.path, this.store.versionOf(ref.path)); + } + return Promise.all(refs.map((ref) => ref.get())); + } + + set( + ref: FakeDocumentReference, + data: StoredDocument, + options: SetOptions = {}, + ): void { + this.writes.push(() => + this.store.set(ref.path, data, options.merge ?? false), + ); + } + + update(ref: FakeDocumentReference, data: StoredDocument): void { + this.writes.push(() => this.store.update(ref.path, data)); + } + + commit(): void { + for (const [path, version] of this.readVersions) { + if (this.store.versionOf(path) !== version) { + throw new FakeAbortedError(path); + } + } + for (const write of this.writes) { + write(); + } + } +} + +/** A write batch supporting the deletes `deleteSession` issues. */ +class FakeWriteBatch { + private readonly deletions: string[] = []; + + constructor(private readonly store: FakeStore) {} + + delete(ref: FakeDocumentReference): void { + this.deletions.push(ref.path); + } + + commit(): Promise { + this.store.batchCommitCount++; + for (const path of this.deletions) { + this.store.delete(path); + } + return Promise.resolve(); + } +} + +/** The client handed to the service under test. */ +class FakeFirestoreClient { + constructor(private readonly store: FakeStore) {} + + collection(path: string): FakeCollectionReference { + return new FakeCollectionReference(this.store, path); + } + + batch(): FakeWriteBatch { + return new FakeWriteBatch(this.store); + } + + async runTransaction( + updateFunction: (transaction: FakeTransaction) => Promise, + ): Promise { + // Matches the real client, which re-runs the callback on an aborted + // commit up to DEFAULT_MAX_TRANSACTION_ATTEMPTS times. + for (let attempt = 1; ; attempt++) { + const transaction = new FakeTransaction(this.store); + const result = await updateFunction(transaction); + try { + transaction.commit(); + return result; + } catch (e: unknown) { + if ( + !(e instanceof FakeAbortedError) || + attempt === MAX_TRANSACTION_ATTEMPTS + ) { + throw e; + } + this.store.transactionRetryCount++; + } + } + } +} + +/** An in-memory Firestore plus the store backing it. */ +export interface FakeFirestore { + /** The client to pass to `FirestoreSessionService`. */ + client: Firestore; + /** The backing store, for seeding documents and asserting on them. */ + store: FakeStore; +} + +/** Creates an in-memory Firestore that needs no network or credentials. */ +export function createFakeFirestore(): FakeFirestore { + const store = new FakeStore(); + return { + // The single cast in the fake: `Firestore` declares dozens of members the + // session service never touches, so the fake implements only the slice it + // does touch. + client: new FakeFirestoreClient(store) as unknown as Firestore, + store, + }; +} diff --git a/integrations/test/firestore/fake_firestore_test.ts b/integrations/test/firestore/fake_firestore_test.ts new file mode 100644 index 000000000..e65023483 --- /dev/null +++ b/integrations/test/firestore/fake_firestore_test.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Timestamp} from '@google-cloud/firestore'; +import {describe, expect, it} from 'vitest'; + +import {createFakeFirestore, FakeStore} from './fake_firestore.js'; + +describe('FakeStore', () => { + it('overwrites a document by default and merges when asked', () => { + const store = new FakeStore(); + store.set('c/a', {keep: 1, drop: 2}, false); + + store.set('c/a', {keep: 3}, true); + expect(store.documents.get('c/a')).toEqual({keep: 3, drop: 2}); + + store.set('c/a', {keep: 4}, false); + expect(store.documents.get('c/a')).toEqual({keep: 4}); + }); + + it('refuses to update a document that does not exist', () => { + const store = new FakeStore(); + expect(() => store.update('c/missing', {a: 1})).toThrow( + "cannot update missing document 'c/missing'", + ); + }); + + it('hands out a copy so a reader cannot mutate the store', () => { + const store = new FakeStore(); + store.write('c/a', {n: 1}); + + const read = store.read('c/a'); + expect(read).toEqual({n: 1}); + if (!read) { + expect.fail('expected the seeded document to be readable'); + } + read.n = 2; + + expect(store.documents.get('c/a')).toEqual({n: 1}); + }); + + it('lists only the documents directly inside a collection', () => { + const store = new FakeStore(); + store.write('c/a', {}); + store.write('c/b', {}); + store.write('c/a/sub/x', {}); + store.write('other/a', {}); + + expect(store.childPaths('c')).toEqual(['c/a', 'c/b']); + expect(store.childPaths('c/a/sub')).toEqual(['c/a/sub/x']); + }); +}); + +describe('createFakeFirestore', () => { + it('leaves the store untouched when a transaction throws', async () => { + const {client, store} = createFakeFirestore(); + store.write('c/a', {n: 1}); + + await expect( + client.runTransaction(async (tx) => { + const ref = client.collection('c').doc('a'); + await tx.get(ref); + tx.update(ref, {n: 2}); + throw new Error('rolled back'); + }), + ).rejects.toThrow('rolled back'); + + expect(store.documents.get('c/a')).toEqual({n: 1}); + }); + + it('applies buffered writes once the transaction resolves', async () => { + const {client, store} = createFakeFirestore(); + + await client.runTransaction(async (tx) => { + const ref = client.collection('c').doc('a'); + await tx.get(ref); + tx.set(ref, {n: 1}); + }); + + expect(store.documents.get('c/a')).toEqual({n: 1}); + }); + + it('orders, filters and limits an events query', async () => { + const {client, store} = createFakeFirestore(); + for (const [id, millis] of [ + ['third', 300], + ['first', 100], + ['second', 200], + ] as const) { + store.write(`c/${id}`, {timestamp: Timestamp.fromMillis(millis)}); + } + const collection = client.collection('c'); + + const ordered = await collection.orderBy('timestamp').get(); + expect(ordered.docs.map((doc) => doc.id)).toEqual([ + 'first', + 'second', + 'third', + ]); + + const filtered = await collection + .orderBy('timestamp') + .where('timestamp', '>=', Timestamp.fromMillis(200)) + .get(); + expect(filtered.docs.map((doc) => doc.id)).toEqual(['second', 'third']); + + const limited = await collection.orderBy('timestamp').limitToLast(1).get(); + expect(limited.docs.map((doc) => doc.id)).toEqual(['third']); + }); + + it('rejects query shapes it does not implement', () => { + const {client} = createFakeFirestore(); + const collection = client.collection('c'); + + expect(() => collection.orderBy('other')).toThrow( + "orderBy is only implemented for 'timestamp'", + ); + expect(() => collection.where('timestamp', '<=', 1)).toThrow( + "where is only implemented for 'timestamp >='", + ); + expect(() => collection.limitToLast(0)).toThrow( + 'limitToLast() requires a positive integer', + ); + }); + + it('counts committed batches and deletes their documents', async () => { + const {client, store} = createFakeFirestore(); + store.write('c/a', {}); + store.write('c/b', {}); + + const batch = client.batch(); + batch.delete(client.collection('c').doc('a')); + await batch.commit(); + + expect(store.batchCommitCount).toBe(1); + expect([...store.documents.keys()]).toEqual(['c/b']); + }); +}); diff --git a/integrations/test/firestore/firestore_session_service_test.ts b/integrations/test/firestore/firestore_session_service_test.ts new file mode 100644 index 000000000..eb134dbd4 --- /dev/null +++ b/integrations/test/firestore/firestore_session_service_test.ts @@ -0,0 +1,1072 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Timestamp} from '@google-cloud/firestore'; +import { + createEvent, + createEventActions, + createSession, + Event, + Session, +} from '@google/adk'; +import {FirestoreSessionService} from '@google/adk-integrations'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + APP_STATE_COLLECTION, + DEFAULT_ROOT_COLLECTION, + EVENTS_COLLECTION, + ROOT_COLLECTION_ENV_VAR, + SESSIONS_COLLECTION, + USER_STATE_COLLECTION, + USERS_COLLECTION, +} from '../../src/firestore/firestore_session_service.js'; + +import { + createFakeFirestore, + FakeStore, + StoredDocument, +} from './fake_firestore.js'; + +const APP_NAME = 'test-app'; +const USER_ID = 'test-user'; +const APP_STATE_PATH = `${APP_STATE_COLLECTION}/${APP_NAME}`; +const USER_STATE_PATH = `${USER_STATE_COLLECTION}/${APP_NAME}/${USERS_COLLECTION}/${USER_ID}`; + +/** Full path of a session document under the default root collection. */ +function sessionPath(sessionId: string, userId = USER_ID): string { + return `${DEFAULT_ROOT_COLLECTION}/${APP_NAME}/${USERS_COLLECTION}/${userId}/${SESSIONS_COLLECTION}/${sessionId}`; +} + +/** Full path of an event document. */ +function eventPath(sessionId: string, eventId: string): string { + return `${sessionPath(sessionId)}/${EVENTS_COLLECTION}/${eventId}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Epoch milliseconds of a stored Timestamp field. */ +function toMillis(value: unknown): number { + if (!isRecord(value) || typeof value.toMillis !== 'function') { + expect.fail(`expected a Timestamp, got ${String(value)}`); + } + return Number(value.toMillis()); +} + +/** Narrows a stored field to a record, failing the test when it is not one. */ +function asRecord(value: unknown, what: string): Record { + if (!isRecord(value)) { + expect.fail(`expected ${what} to be an object, got ${String(value)}`); + } + return value; +} + +let store: FakeStore; +let service: FirestoreSessionService; + +const originalRootCollection = process.env[ROOT_COLLECTION_ENV_VAR]; + +beforeEach(() => { + delete process.env[ROOT_COLLECTION_ENV_VAR]; + const fake = createFakeFirestore(); + store = fake.store; + service = new FirestoreSessionService({client: fake.client}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalRootCollection === undefined) { + delete process.env[ROOT_COLLECTION_ENV_VAR]; + } else { + process.env[ROOT_COLLECTION_ENV_VAR] = originalRootCollection; + } +}); + +/** The raw session document, failing the test when it is missing. */ +function storedSession(sessionId: string): StoredDocument { + const data = store.documents.get(sessionPath(sessionId)); + if (!data) { + expect.fail(`no session document at ${sessionPath(sessionId)}`); + } + return data; +} + +/** The session-scoped state persisted on a session document. */ +function storedState(sessionId: string): Record { + const raw = storedSession(sessionId).state; + if (typeof raw !== 'string') { + expect.fail(`expected a JSON string state, got ${String(raw)}`); + } + return asRecord(JSON.parse(raw), 'the parsed session state'); +} + +/** The serialized event stored on an event document. */ +function storedEvent(sessionId: string, eventId: string): StoredDocument { + const doc = store.documents.get(eventPath(sessionId, eventId)); + if (!doc) { + expect.fail(`no event document at ${eventPath(sessionId, eventId)}`); + } + return asRecord(doc['event_data'], 'the stored event_data'); +} + +function newEvent(params: Partial = {}): Event { + return createEvent({author: 'user', invocationId: 'inv-1', ...params}); +} + +function eventWithDelta( + id: string, + stateDelta: Record, +): Event { + return newEvent({id, actions: createEventActions({stateDelta})}); +} + +/** Writes a session document directly, bypassing the service. */ +function seedSession( + sessionId: string, + fields: StoredDocument = {}, + userId = USER_ID, +): void { + store.write(sessionPath(sessionId, userId), { + id: sessionId, + appName: APP_NAME, + userId, + state: JSON.stringify({}), + createTime: Timestamp.fromMillis(0), + updateTime: Timestamp.fromMillis(0), + revision: 0, + ...fields, + }); +} + +describe('collection constants', () => { + it('match the names adk-python uses', () => { + expect(DEFAULT_ROOT_COLLECTION).toBe('adk-session'); + expect(SESSIONS_COLLECTION).toBe('sessions'); + expect(EVENTS_COLLECTION).toBe('events'); + expect(APP_STATE_COLLECTION).toBe('app_states'); + expect(USER_STATE_COLLECTION).toBe('user_states'); + expect(USERS_COLLECTION).toBe('users'); + expect(ROOT_COLLECTION_ENV_VAR).toBe('ADK_FIRESTORE_ROOT_COLLECTION'); + }); +}); + +describe('FirestoreSessionService root collection', () => { + it('defaults to adk-session', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect([...store.documents.keys()]).toContain( + `adk-session/${APP_NAME}/users/${USER_ID}/sessions/${session.id}`, + ); + }); + + it('builds a default client when none is supplied', () => { + expect(() => new FirestoreSessionService()).not.toThrow(); + }); + + it('reads ADK_FIRESTORE_ROOT_COLLECTION when no option is given', async () => { + process.env[ROOT_COLLECTION_ENV_VAR] = 'env-root'; + const fake = createFakeFirestore(); + const envService = new FirestoreSessionService({client: fake.client}); + + const session = await envService.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect([...fake.store.documents.keys()]).toContain( + `env-root/${APP_NAME}/users/${USER_ID}/sessions/${session.id}`, + ); + }); + + it('prefers the rootCollection option over the environment variable', async () => { + process.env[ROOT_COLLECTION_ENV_VAR] = 'env-root'; + const fake = createFakeFirestore(); + const optionService = new FirestoreSessionService({ + client: fake.client, + rootCollection: 'option-root', + }); + + const session = await optionService.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect([...fake.store.documents.keys()]).toContain( + `option-root/${APP_NAME}/users/${USER_ID}/sessions/${session.id}`, + ); + }); + + it('falls back to the default when the environment variable is empty', async () => { + process.env[ROOT_COLLECTION_ENV_VAR] = ''; + const fake = createFakeFirestore(); + const envService = new FirestoreSessionService({client: fake.client}); + + const session = await envService.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect([...fake.store.documents.keys()]).toContain( + `adk-session/${APP_NAME}/users/${USER_ID}/sessions/${session.id}`, + ); + }); +}); + +describe('FirestoreSessionService.createSession', () => { + it('returns a session with a generated id and no events', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect(session.appName).toBe(APP_NAME); + expect(session.userId).toBe(USER_ID); + expect(session.id).not.toBe(''); + expect(session.events).toEqual([]); + expect(session.state).toEqual({}); + }); + + it('writes the session document at the parity path with revision 0', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + state: {turn: 1}, + }); + + const document = storedSession(session.id); + expect(document.id).toBe(session.id); + expect(document.appName).toBe(APP_NAME); + expect(document.userId).toBe(USER_ID); + expect(document.revision).toBe(0); + expect(document.state).toBe(JSON.stringify({turn: 1})); + expect(toMillis(document.createTime)).toBe(toMillis(document.updateTime)); + expect(toMillis(document.updateTime)).toBe(session.lastUpdateTime); + }); + + it('honours an explicit session id', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'explicit-id', + }); + + expect(session.id).toBe('explicit-id'); + expect(store.documents.has(sessionPath('explicit-id'))).toBe(true); + }); + + it('splits prefixed initial state into the shared documents', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + state: {'app:theme': 'dark', 'user:locale': 'en', turn: 0}, + }); + + expect(store.documents.get(APP_STATE_PATH)).toEqual({theme: 'dark'}); + expect(store.documents.get(USER_STATE_PATH)).toEqual({locale: 'en'}); + expect(storedState(session.id)).toEqual({turn: 0}); + }); + + it('returns the shared state merged back under its prefixes', async () => { + store.write(APP_STATE_PATH, {existing: 'app'}); + store.write(USER_STATE_PATH, {existing: 'user'}); + + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + state: {'app:theme': 'dark', turn: 0}, + }); + + expect(session.state).toEqual({ + turn: 0, + 'app:existing': 'app', + 'app:theme': 'dark', + 'user:existing': 'user', + }); + }); + + it('rejects a duplicate session id and rolls the transaction back', async () => { + seedSession('taken', {revision: 7}); + const before = storedSession('taken'); + + await expect( + service.createSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'taken', + state: {'app:theme': 'dark'}, + }), + ).rejects.toThrow('Session with id taken already exists.'); + + expect(storedSession('taken')).toEqual(before); + expect(store.documents.has(APP_STATE_PATH)).toBe(false); + }); + + it('never persists temporary state', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + state: {'temp:scratch': 'x', turn: 0}, + }); + + expect(storedState(session.id)).toEqual({turn: 0}); + for (const document of store.documents.values()) { + expect(JSON.stringify(document)).not.toContain('temp:'); + } + }); +}); + +describe('FirestoreSessionService.getSession', () => { + it('returns undefined for an unknown session', async () => { + await expect( + service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'missing', + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined for a session document with no fields', async () => { + store.write(sessionPath('empty'), {}); + + await expect( + service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'empty', + }), + ).resolves.toBeUndefined(); + }); + + it('round-trips appended events in timestamp order', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + await service.appendEvent({ + session, + event: newEvent({id: 'second', timestamp: 2000}), + }); + await service.appendEvent({ + session, + event: newEvent({id: 'first', timestamp: 1000}), + }); + + const loaded = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: session.id, + }); + + expect(loaded?.events.map((event) => event.id)).toEqual([ + 'first', + 'second', + ]); + expect(loaded?.events[0].author).toBe('user'); + }); + + it('merges shared state written for the same app and user', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + state: {turn: 3}, + }); + store.write(APP_STATE_PATH, {theme: 'dark'}); + store.write(USER_STATE_PATH, {locale: 'en'}); + + const loaded = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: session.id, + }); + + expect(loaded?.state).toEqual({ + turn: 3, + 'app:theme': 'dark', + 'user:locale': 'en', + }); + }); + + it('returns only the most recent events for numRecentEvents', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + await service.appendEvent({ + session, + event: newEvent({id: 'old', timestamp: 1000}), + }); + await service.appendEvent({ + session, + event: newEvent({id: 'new', timestamp: 2000}), + }); + + const loaded = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: session.id, + config: {numRecentEvents: 1}, + }); + + expect(loaded?.events.map((event) => event.id)).toEqual(['new']); + }); + + it('skips the events query entirely when numRecentEvents is 0', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + await service.appendEvent({session, event: newEvent({id: 'e1'})}); + + const loaded = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: session.id, + config: {numRecentEvents: 0}, + }); + + expect(loaded?.events).toEqual([]); + expect( + store.queryPaths.filter((path) => path.endsWith(`/${EVENTS_COLLECTION}`)), + ).toEqual([]); + }); + + it('includes an event whose timestamp equals afterTimestamp', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + await service.appendEvent({ + session, + event: newEvent({id: 'before', timestamp: 1000}), + }); + await service.appendEvent({ + session, + event: newEvent({id: 'boundary', timestamp: 2000}), + }); + + const loaded = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: session.id, + config: {afterTimestamp: 2000}, + }); + + expect(loaded?.events.map((event) => event.id)).toEqual(['boundary']); + }); + + it('reads a session document whose state is a plain map', async () => { + seedSession('legacy', {state: {turn: 9}}); + + const loaded = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'legacy', + }); + + expect(loaded?.state).toEqual({turn: 9}); + }); + + it('reports lastUpdateTime 0 when the document has no updateTime', async () => { + store.write(sessionPath('no-update-time'), {id: 'no-update-time'}); + + const loaded = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'no-update-time', + }); + + expect(loaded?.lastUpdateTime).toBe(0); + expect(loaded?.state).toEqual({}); + }); +}); + +describe('FirestoreSessionService.listSessions', () => { + it('returns only the sessions of the requested app and user', async () => { + seedSession('mine'); + seedSession('theirs', {}, 'other-user'); + store.write( + `${DEFAULT_ROOT_COLLECTION}/other-app/${USERS_COLLECTION}/${USER_ID}/${SESSIONS_COLLECTION}/elsewhere`, + {id: 'elsewhere'}, + ); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect(response.sessions.map((session) => session.id)).toEqual(['mine']); + }); + + it('merges shared state and returns no events', async () => { + seedSession('s1', {state: JSON.stringify({turn: 1})}); + store.write(APP_STATE_PATH, {theme: 'dark'}); + store.write(USER_STATE_PATH, {locale: 'en'}); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect(response.sessions[0].state).toEqual({ + turn: 1, + 'app:theme': 'dark', + 'user:locale': 'en', + }); + expect(response.sessions[0].events).toEqual([]); + }); + + it('tolerates absent shared-state documents', async () => { + seedSession('s1', {state: JSON.stringify({turn: 1})}); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect(response.sessions[0].state).toEqual({turn: 1}); + }); + + it('preserves lastUpdateTime as epoch milliseconds', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + await service.appendEvent({ + session, + event: newEvent({id: 'e1', timestamp: 1_700_000_000_123}), + }); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect(response.sessions[0].lastUpdateTime).toBe(1_700_000_000_123); + }); + + it('reports whole-collection pagination metadata when no limit is given', async () => { + seedSession('a'); + seedSession('b'); + seedSession('c'); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect(response.sessions).toHaveLength(3); + expect(response).toMatchObject({ + page: 1, + limit: 3, + totalItems: 3, + totalPages: 1, + }); + }); + + it('paginates by limit and offset', async () => { + seedSession('a', {updateTime: Timestamp.fromMillis(1)}); + seedSession('b', {updateTime: Timestamp.fromMillis(2)}); + seedSession('c', {updateTime: Timestamp.fromMillis(3)}); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + order: 'asc', + limit: 2, + offset: 1, + }); + + expect(response.sessions.map((session) => session.id)).toEqual(['b', 'c']); + expect(response).toMatchObject({ + page: 1, + limit: 2, + totalItems: 3, + totalPages: 2, + }); + }); + + it('paginates by limit and page', async () => { + seedSession('a', {updateTime: Timestamp.fromMillis(1)}); + seedSession('b', {updateTime: Timestamp.fromMillis(2)}); + seedSession('c', {updateTime: Timestamp.fromMillis(3)}); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + order: 'asc', + limit: 2, + page: 2, + }); + + expect(response.sessions.map((session) => session.id)).toEqual(['c']); + expect(response).toMatchObject({ + page: 2, + limit: 2, + totalItems: 3, + totalPages: 2, + }); + }); + + it('applies an offset when no limit is given', async () => { + seedSession('a', {updateTime: Timestamp.fromMillis(1)}); + seedSession('b', {updateTime: Timestamp.fromMillis(2)}); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + order: 'asc', + offset: 1, + }); + + expect(response.sessions.map((session) => session.id)).toEqual(['b']); + expect(response).toMatchObject({page: 1, limit: 2, totalItems: 2}); + }); + + it('starts at the first page for a limit with no offset or page', async () => { + seedSession('a', {updateTime: Timestamp.fromMillis(1)}); + seedSession('b', {updateTime: Timestamp.fromMillis(2)}); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + order: 'asc', + limit: 1, + }); + + expect(response.sessions.map((session) => session.id)).toEqual(['a']); + expect(response).toMatchObject({ + page: 1, + limit: 1, + totalItems: 2, + totalPages: 2, + }); + }); + + it('returns nothing for a zero limit', async () => { + seedSession('a'); + + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + limit: 0, + }); + + expect(response).toEqual({ + sessions: [], + page: 1, + limit: 0, + totalItems: 1, + totalPages: 0, + }); + }); + + it('reports an empty page for a user with no sessions', async () => { + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + }); + + expect(response).toEqual({ + sessions: [], + page: 1, + limit: 0, + totalItems: 0, + totalPages: 0, + }); + }); + + it('reports zero total pages for a limited query with no results', async () => { + const response = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + limit: 2, + page: 3, + }); + + expect(response).toEqual({ + sessions: [], + page: 3, + limit: 2, + totalItems: 0, + totalPages: 0, + }); + }); + + it('sorts by lastUpdateTime, breaking ties on id', async () => { + seedSession('b', {updateTime: Timestamp.fromMillis(1)}); + seedSession('a', {updateTime: Timestamp.fromMillis(1)}); + seedSession('c', {updateTime: Timestamp.fromMillis(2)}); + + const ascending = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + order: 'asc', + }); + expect(ascending.sessions.map((session) => session.id)).toEqual([ + 'a', + 'b', + 'c', + ]); + + const descending = await service.listSessions({ + appName: APP_NAME, + userId: USER_ID, + order: 'desc', + }); + expect(descending.sessions.map((session) => session.id)).toEqual([ + 'c', + 'a', + 'b', + ]); + }); +}); + +describe('FirestoreSessionService.deleteSession', () => { + it('deletes the session document and every event under it', async () => { + const session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + }); + await service.appendEvent({session, event: newEvent({id: 'e1'})}); + await service.appendEvent({session, event: newEvent({id: 'e2'})}); + + await service.deleteSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: session.id, + }); + + const remaining = [...store.documents.keys()].filter((path) => + path.startsWith(sessionPath(session.id)), + ); + expect(remaining).toEqual([]); + }); + + it('marks the session as deleting before removing it', async () => { + seedSession('marked'); + const update = vi.spyOn(store, 'update'); + + await service.deleteSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'marked', + }); + + expect(update).toHaveBeenCalledWith(sessionPath('marked'), { + status: 'DELETING', + }); + expect(store.documents.has(sessionPath('marked'))).toBe(false); + }); + + it('deletes the session even when the deleting marker cannot be written', async () => { + seedSession('marked'); + store.write(eventPath('marked', 'e1'), { + timestamp: Timestamp.fromMillis(1), + }); + vi.spyOn(store, 'update').mockImplementation(() => { + throw new Error('marker write failed'); + }); + + await service.deleteSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'marked', + }); + + expect(store.documents.has(sessionPath('marked'))).toBe(false); + expect(store.documents.has(eventPath('marked', 'e1'))).toBe(false); + }); + + it('is a no-op for a session that does not exist', async () => { + const update = vi.spyOn(store, 'update'); + + await expect( + service.deleteSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'missing', + }), + ).resolves.toBeUndefined(); + + expect(update).not.toHaveBeenCalled(); + expect(store.batchCommitCount).toBe(0); + }); + + it('deletes more events than fit in one write batch', async () => { + const eventCount = 501; + seedSession('bulk'); + for (let i = 0; i < eventCount; i++) { + store.write(eventPath('bulk', `e${i}`), { + timestamp: Timestamp.fromMillis(i), + }); + } + + await service.deleteSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 'bulk', + }); + + expect(store.batchCommitCount).toBe(2); + expect([...store.documents.keys()]).toEqual([]); + }); +}); + +describe('FirestoreSessionService.appendEvent', () => { + let session: Session; + + beforeEach(async () => { + session = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 's1', + }); + }); + + it('writes the event document and bumps the revision on each append', async () => { + await service.appendEvent({session, event: newEvent({id: 'e1'})}); + expect(store.documents.has(eventPath('s1', 'e1'))).toBe(true); + expect(storedSession('s1').revision).toBe(1); + + await service.appendEvent({session, event: newEvent({id: 'e2'})}); + expect(storedSession('s1').revision).toBe(2); + }); + + it('stores the event timestamp and owner alongside the payload', async () => { + await service.appendEvent({ + session, + event: newEvent({id: 'e1', timestamp: 4321}), + }); + + const document = store.documents.get(eventPath('s1', 'e1')); + expect(document?.appName).toBe(APP_NAME); + expect(document?.userId).toBe(USER_ID); + expect(document?.timestamp).toEqual(Timestamp.fromMillis(4321)); + expect(storedEvent('s1', 'e1').id).toBe('e1'); + }); + + it('writes nothing for a partial event', async () => { + const before = [...store.documents.entries()]; + + const event = newEvent({id: 'partial', partial: true}); + await expect(service.appendEvent({session, event})).resolves.toBe(event); + + expect([...store.documents.entries()]).toEqual(before); + expect(session.events).toEqual([]); + }); + + it('rejects an append to an unknown session and writes nothing', async () => { + const unknown: Session = {...session, id: 'ghost'}; + const before = [...store.documents.entries()]; + + await expect( + service.appendEvent({session: unknown, event: newEvent({id: 'e1'})}), + ).rejects.toThrow('Session ghost not found for appendEvent'); + + expect([...store.documents.entries()]).toEqual(before); + }); + + it('splits a state delta across the three documents', async () => { + await service.appendEvent({ + session, + event: eventWithDelta('e1', { + 'app:theme': 'dark', + 'user:locale': 'en', + turn: 1, + }), + }); + + expect(store.documents.get(APP_STATE_PATH)).toEqual({theme: 'dark'}); + expect(store.documents.get(USER_STATE_PATH)).toEqual({locale: 'en'}); + expect(storedState('s1')).toEqual({turn: 1}); + }); + + it('merges into shared state written by an earlier append', async () => { + store.write(APP_STATE_PATH, {existing: 'kept'}); + + await service.appendEvent({ + session, + event: eventWithDelta('e1', {'app:theme': 'dark'}), + }); + + expect(store.documents.get(APP_STATE_PATH)).toEqual({ + existing: 'kept', + theme: 'dark', + }); + }); + + it('never persists a temporary state delta', async () => { + await service.appendEvent({ + session, + event: eventWithDelta('e1', {'temp:scratch': 'x', turn: 1}), + }); + + const actions = asRecord( + storedEvent('s1', 'e1').actions, + 'the stored event actions', + ); + expect(actions.stateDelta).toEqual({turn: 1}); + expect(storedState('s1')).toEqual({turn: 1}); + expect(session.state).toEqual({turn: 1}); + }); + + it('keeps prefixed and temporary keys off the session document', async () => { + store.write(APP_STATE_PATH, {theme: 'dark'}); + store.write(USER_STATE_PATH, {locale: 'en'}); + const loaded = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 's1', + }); + if (!loaded) { + expect.fail('expected the session to load'); + } + expect(loaded.state).toEqual({'app:theme': 'dark', 'user:locale': 'en'}); + loaded.state['temp:scratch'] = 'x'; + + await service.appendEvent({ + session: loaded, + event: eventWithDelta('e1', {turn: 1}), + }); + + expect(storedState('s1')).toEqual({turn: 1}); + }); + + it('persists the stored state, not a stale caller in-memory view', async () => { + // Two callers hold the same session. The first commits `a`; the second + // still holds a view from before that and appends `b`. Deriving the + // persisted state from the caller's session would drop `a`. + const first = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 's1', + }); + const second = await service.getSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 's1', + }); + if (!first || !second) { + expect.fail('expected both reads to load the session'); + } + + await service.appendEvent({ + session: first, + event: eventWithDelta('e1', {a: 1}), + }); + await service.appendEvent({ + session: second, + event: eventWithDelta('e2', {b: 2}), + }); + + expect(storedState('s1')).toEqual({a: 1, b: 2}); + }); + + it('treats a session document with no revision as revision 0', async () => { + store.write(sessionPath('legacy'), { + id: 'legacy', + appName: APP_NAME, + userId: USER_ID, + state: JSON.stringify({}), + updateTime: Timestamp.fromMillis(0), + }); + const legacy = createSession({ + id: 'legacy', + appName: APP_NAME, + userId: USER_ID, + }); + + await service.appendEvent({session: legacy, event: newEvent({id: 'e1'})}); + + expect(storedSession('legacy').revision).toBe(1); + }); + + it('updates the in-memory session', async () => { + const event = newEvent({id: 'e1', timestamp: 9999}); + + await expect(service.appendEvent({session, event})).resolves.toBe(event); + + expect(session.events).toEqual([event]); + expect(session.lastUpdateTime).toBe(9999); + }); + + it('replaces an event re-appended under the same id', async () => { + await service.appendEvent({session, event: newEvent({id: 'e1'})}); + await service.appendEvent({ + session, + event: newEvent({id: 'e1', timestamp: 5555}), + }); + + expect(session.events).toHaveLength(1); + expect(session.events[0].timestamp).toBe(5555); + expect( + [...store.documents.keys()].filter((path) => + path.includes(`/${EVENTS_COLLECTION}/`), + ), + ).toEqual([eventPath('s1', 'e1')]); + }); + + it('refuses to append to a session marked for deletion', async () => { + store.set(sessionPath('s1'), {status: 'DELETING'}, true); + + await expect( + service.appendEvent({session, event: newEvent({id: 'e1'})}), + ).rejects.toThrow('Session s1 is currently being deleted.'); + + expect(store.documents.has(eventPath('s1', 'e1'))).toBe(false); + }); + + it('serializes concurrent appends to the same session', async () => { + const events = Array.from({length: 5}, (_, index) => + newEvent({id: `e${index}`, timestamp: 1000 + index}), + ); + + await Promise.all( + events.map((event) => service.appendEvent({session, event})), + ); + + expect(storedSession('s1').revision).toBe(events.length); + // The appends genuinely raced: Firestore aborted and re-ran the losers. + expect(store.transactionRetryCount).toBeGreaterThan(0); + for (const event of events) { + expect(store.documents.has(eventPath('s1', event.id))).toBe(true); + } + }); + + it('lets appends to different sessions of one app proceed without contending', async () => { + const other = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 's2', + }); + store.transactionRetryCount = 0; + + await Promise.all([ + service.appendEvent({ + session, + event: eventWithDelta('e1', {'app:theme': 'dark'}), + }), + service.appendEvent({ + session: other, + event: eventWithDelta('e2', {'app:locale': 'en'}), + }), + ]); + + // Only the two session documents are in the read sets, so the shared + // app-state document cannot make unrelated sessions abort each other. + expect(store.transactionRetryCount).toBe(0); + expect(store.documents.get(APP_STATE_PATH)).toEqual({ + theme: 'dark', + locale: 'en', + }); + }); +}); diff --git a/integrations/test/version_test.ts b/integrations/test/version_test.ts index 9f5a6900a..a04fafe60 100644 --- a/integrations/test/version_test.ts +++ b/integrations/test/version_test.ts @@ -9,6 +9,6 @@ import {describe, expect, it} from 'vitest'; describe('version', () => { it('should return the correct version', () => { - expect(version).toBe('1.3.0'); + expect(version).toBe('1.5.0'); }); }); diff --git a/package-lock.json b/package-lock.json index e29a4a190..b2a2536ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -334,6 +334,7 @@ "version": "1.5.0", "license": "Apache-2.0", "dependencies": { + "@google-cloud/firestore": "^8.7.0", "@google/adk": "^1.5.0" } }, @@ -1345,6 +1346,21 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@google-cloud/firestore": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-8.7.0.tgz", + "integrity": "sha512-EvMpZQUXkTRdweSvOu6VL6EEQwHjHAgWz2UYZR+Mj6Ao52S+TWieHbSn15jiNnEw8F8RhbZj7IGXZ1PFB1eA+A==", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "fast-deep-equal": "^3.1.3", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^5.0.1", + "protobufjs": "^7.5.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@google-cloud/opentelemetry-cloud-monitoring-exporter": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-cloud-monitoring-exporter/-/opentelemetry-cloud-monitoring-exporter-0.21.0.tgz", @@ -1853,7 +1869,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -1871,7 +1886,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1884,7 +1898,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1897,14 +1910,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -1922,7 +1933,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -1938,7 +1948,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -3124,7 +3133,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -5587,7 +5595,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "devOptional": true, "license": "MIT" }, "node_modules/base64-js": { @@ -6647,7 +6654,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -7884,7 +7890,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -7901,7 +7906,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -8020,6 +8024,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==" + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -8371,6 +8380,243 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/google-gax": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.8.tgz", + "integrity": "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA==", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/google-gax/node_modules/gaxios": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", + "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax/node_modules/retry-request": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.4.tgz", + "integrity": "sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/teeny-request": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.4.tgz", + "integrity": "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg==", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/google-logging-utils": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", @@ -9613,7 +9859,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -11158,6 +11403,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -11359,7 +11612,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -11463,7 +11715,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -11480,7 +11731,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -11872,6 +12122,17 @@ "node": ">=10" } }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/protobufjs": { "version": "7.6.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", @@ -13256,7 +13517,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13271,7 +13531,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13303,7 +13562,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -15417,7 +15675,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", diff --git a/package.json b/package.json index 36cc0d747..cf793293e 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,12 @@ "docs:generate": "typedoc", "docs:serve": "http-server api-reference/typescript", "docs:check": "typedoc --emit none --treatWarningsAsErrors", - "test": "vitest --project unit:core --project unit:dev --project integration --project e2e", - "test:unit": "vitest --project unit:core --project unit:dev", + "test": "vitest --project unit:core --project unit:dev --project unit:integrations --project integration --project e2e", + "test:unit": "vitest --project unit:core --project unit:dev --project unit:integrations", "test:integration": "vitest --project integration", "test:e2e": "vitest --project e2e", "test:cross-language": "vitest --project cross-language", - "test:coverage": "vitest run --project unit:core --project unit:dev --project integration --project e2e --coverage", + "test:coverage": "vitest run --project unit:core --project unit:dev --project unit:integrations --project integration --project e2e --coverage", "prepare": "husky" }, "workspaces": [