From cc2e5a0c29acd5458b66142d4f9b5d2aabc20fd5 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 1 Aug 2026 09:18:18 -0700 Subject: [PATCH 1/7] chore: wire the unit:integrations vitest project into the root test scripts vitest.config.ts has defined a unit:integrations project since it was added, but the root test/test:unit/test:coverage scripts never passed --project unit:integrations, so integrations/test never ran even though integrations/src is in the coverage include list. Wiring the project in surfaces integrations/test/version_test.ts, which had rotted to expect 1.3.0 while integrations/src/version.ts exports 1.5.0. The expectation is corrected, not skipped or deleted. Also re-export randomUUID from @google/adk so consumer workspaces can generate session IDs from the same source core uses, rather than a deep subpath import that core/package.json does not expose. --- core/src/common.ts | 1 + integrations/test/version_test.ts | 2 +- package.json | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/common.ts b/core/src/common.ts index 23f628165..ac0161b3d 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -280,6 +280,7 @@ export type { } from './tools/vertex_ai_search_tool.js'; export {VertexRagRetrievalTool} from './tools/vertex_rag_retrieval_tool.js'; export {getClientLabels, runWithClientLabel} from './utils/client_labels.js'; +export {randomUUID} from './utils/env_aware_utils.js'; export {LogLevel, getLogger, setLogLevel, setLogger} from './utils/logger.js'; export type {Logger} from './utils/logger.js'; export {isGemini2OrAbove, isGemini3xFlashLive} from './utils/model_name.js'; 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.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": [ From 09422e60e9152f2e41a037fec92787a231f90ac4 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 1 Aug 2026 09:55:33 -0700 Subject: [PATCH 2/7] deps(integrations): add @google-cloud/firestore Added with npm install --workspace=integrations so npm picks the caret range and regenerates the lockfile. It goes in integrations, not core, so the Firestore SDK stays off every adk-js user's install path. --- integrations/package.json | 1 + package-lock.json | 297 +++++++++++++++++++++++++++++++++++--- 2 files changed, 278 insertions(+), 20 deletions(-) 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/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", From ba2ba2f214ee30d0e82e9cb418d752b3b20615c6 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 1 Aug 2026 09:24:27 -0700 Subject: [PATCH 3/7] feat(integrations): port FirestoreSessionService from adk-python Adds a document-store session backend so users already running Firestore do not have to fall back to SQL. Sessions live under //users//sessions/, with app- and user-scoped state in the sibling app_states and user_states collections and merged back under the app:/user: prefixes on read. appendEvent runs in a Firestore transaction, serialized per session by a reference-counted in-process lock, and bumps a revision counter. deleteSession writes a DELETING marker first so a concurrent append fails rather than resurrecting a half-deleted session. Collection names, the document hierarchy and the DELETING marker match firestore_session_service.py byte for byte. Timestamps diverge deliberately: client-side Timestamp values in epoch milliseconds rather than SERVER_TIMESTAMP sentinels in seconds, matching adk-js's Event.timestamp and Session.lastUpdateTime units. --- .../firestore/firestore_session_service.ts | 569 ++++++++++++++++++ integrations/src/firestore/session_lock.ts | 65 ++ integrations/src/index.ts | 2 + 3 files changed, 636 insertions(+) create mode 100644 integrations/src/firestore/firestore_session_service.ts create mode 100644 integrations/src/firestore/session_lock.ts diff --git a/integrations/src/firestore/firestore_session_service.ts b/integrations/src/firestore/firestore_session_service.ts new file mode 100644 index 000000000..63abde0ad --- /dev/null +++ b/integrations/src/firestore/firestore_session_service.ts @@ -0,0 +1,569 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CollectionReference, + DocumentReference, + Firestore, + Query, + Timestamp, + Transaction, +} from '@google-cloud/firestore'; +import { + AppendEventRequest, + BaseSessionService, + createSession, + CreateSessionRequest, + DeleteSessionRequest, + Event, + getLogger, + GetSessionConfig, + GetSessionRequest, + ListSessionsRequest, + ListSessionsResponse, + mergeStates, + randomUUID, + Session, + State, + trimTempDeltaState, +} from '@google/adk'; + +import {SessionLockMap} from './session_lock.js'; + +/** 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'; + +/** Firestore's limit on the number of writes in a single batch. */ +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; +} + +/** + * Splits a state map into its app-scoped, user-scoped and session-scoped + * parts, stripping the `app:` and `user:` prefixes and dropping `temp:` keys. + */ +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}; +} + +/** 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)); +} + +/** Identifies one session for {@link SessionLockMap}. */ +function sessionLockKey( + appName: string, + userId: string, + sessionId: string, +): string { + return JSON.stringify([appName, userId, sessionId]); +} + +/** Reads a shared-state document inside a transaction. */ +async function readStateDoc( + tx: Transaction, + ref: DocumentReference, +): Promise> { + const snapshot = await tx.get(ref); + return snapshot.data() ?? {}; +} + +/** Reads a session's events, applying the window in `config`. */ +async function fetchEvents( + sessionRef: DocumentReference, + config: GetSessionConfig | undefined, +): Promise { + 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/ + * ``` + * + * Appends are atomic within a Firestore transaction and serialized per session + * within one process. Two processes appending to the same session can still + * lose an update. + */ +export class FirestoreSessionService extends BaseSessionService { + private readonly client: Firestore; + private readonly rootCollection: string; + private readonly sessionLocks = new SessionLockMap(); + + 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.`); + } + + const currentApp: Record = appSnapshot.data() ?? {}; + const currentUser: Record = userSnapshot.data() ?? {}; + + if (Object.keys(appDelta).length > 0) { + Object.assign(currentApp, appDelta); + tx.set(appRef, currentApp, {merge: true}); + } + if (Object.keys(userDelta).length > 0) { + Object.assign(currentUser, userDelta); + tx.set(userRef, currentUser, {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, userState, 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; + } + + // The Node SDK rejects `limitToLast(0)`, so an explicit zero has to skip + // the events query rather than flow through as a limit. + const [events, appSnapshot, userSnapshot] = await Promise.all([ + config?.numRecentEvents === 0 + ? Promise.resolve([]) + : 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, + ); + const appRef = this.appStateRef(session.appName); + const userRef = this.userStateRef(session.appName, session.userId); + + await this.sessionLocks.run( + sessionLockKey(session.appName, session.userId, session.id), + () => + 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.`, + ); + } + + const currentApp = hasAppDelta ? await readStateDoc(tx, appRef) : {}; + const currentUser = hasUserDelta + ? await readStateDoc(tx, userRef) + : {}; + + if (hasAppDelta) { + tx.set(appRef, {...currentApp, ...appDelta}, {merge: true}); + } + if (hasUserDelta) { + tx.set(userRef, {...currentUser, ...userDelta}, {merge: true}); + } + + tx.update(sessionRef, { + state: JSON.stringify({ + ...splitStateDelta(session.state).session, + ...sessionDelta, + }), + updateTime: Timestamp.fromMillis(trimmed.timestamp), + revision: (data.revision ?? 0) + 1, + }); + + 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/firestore/session_lock.ts b/integrations/src/firestore/session_lock.ts new file mode 100644 index 000000000..0eb785732 --- /dev/null +++ b/integrations/src/firestore/session_lock.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +interface LockEntry { + /** Resolves once the last caller queued on this key has finished. */ + tail: Promise; + /** Callers currently holding or waiting on this key. */ + refCount: number; +} + +/** + * A map of per-key mutexes that serializes async work sharing a key. + * + * Entries are reference counted and removed once the last caller releases, so + * a long-lived process does not accumulate one entry per key it has ever + * locked. + */ +export class SessionLockMap { + private readonly entries = new Map(); + + /** + * Runs `fn` with exclusive access to `key`. + * + * Callers sharing a key run one at a time, in the order they called; callers + * with different keys are unaffected. + */ + async run(key: string, fn: () => Promise): Promise { + // Python's equivalent needs a guard mutex because another task can + // interleave at an await. This lookup-and-insert has no await, so it is + // already atomic. + let entry = this.entries.get(key); + if (!entry) { + entry = {tail: Promise.resolve(), refCount: 0}; + this.entries.set(key, entry); + } + entry.refCount++; + + const predecessor = entry.tail; + let release!: () => void; + entry.tail = new Promise((resolve) => { + release = resolve; + }); + + await predecessor; + try { + return await fn(); + } finally { + release(); + entry.refCount--; + // Deleting unconditionally would let a queued waiter run against a lock + // no later caller can see. + if (entry.refCount === 0) { + this.entries.delete(key); + } + } + } + + /** Number of live lock entries. Zero once every caller has released. */ + get size(): number { + return this.entries.size; + } +} 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'; From f8382cf267b69609b393b02871529ce782898346 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 1 Aug 2026 09:42:07 -0700 Subject: [PATCH 4/7] test(integrations): cover FirestoreSessionService against an in-memory fake Adds an in-memory Firestore that needs no emulator, network or credentials, so the suite runs on every OS in the CI matrix. Its transaction buffers writes until the callback resolves, and its reads suspend on a shared per-tick barrier so concurrent transactions really do interleave -- a plain setTimeout would not, because Node drains the microtask queue between timer callbacks and each transaction would run to completion before the next resumed. The fake refuses query shapes it does not implement rather than returning a wrong answer, and has its own tests for merge semantics and transaction rollback. --- integrations/test/firestore/fake_firestore.ts | 387 ++++++ .../test/firestore/fake_firestore_test.ts | 141 +++ .../firestore_session_service_test.ts | 1053 +++++++++++++++++ .../test/firestore/session_lock_test.ts | 173 +++ 4 files changed, 1754 insertions(+) create mode 100644 integrations/test/firestore/fake_firestore.ts create mode 100644 integrations/test/firestore/fake_firestore_test.ts create mode 100644 integrations/test/firestore/firestore_session_service_test.ts create mode 100644 integrations/test/firestore/session_lock_test.ts diff --git a/integrations/test/firestore/fake_firestore.ts b/integrations/test/firestore/fake_firestore.ts new file mode 100644 index 000000000..2e7b591a8 --- /dev/null +++ b/integrations/test/firestore/fake_firestore.ts @@ -0,0 +1,387 @@ +/** + * @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'; + +/** + * 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(); + + /** 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; + + /** 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.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}`); + 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}`); + 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.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[]) {} +} + +/** Reads the orderable timestamp field off a stored document. */ +function timestampMillis(data: StoredDocument): number { + const value = data[TIMESTAMP_FIELD]; + if (!(value instanceof Timestamp)) { + 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); +} + +/** + * A transaction that buffers its writes and applies them only once the + * callback resolves, so a throwing callback leaves the store untouched. + */ +class FakeTransaction { + private readonly writes: Array<() => void> = []; + + constructor(private readonly store: FakeStore) {} + + async get(ref: FakeDocumentReference): Promise { + await this.store.yieldToPeers(); + return ref.get(); + } + + async getAll( + ...refs: FakeDocumentReference[] + ): Promise { + await this.store.yieldToPeers(); + 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 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 { + const transaction = new FakeTransaction(this.store); + const result = await updateFunction(transaction); + transaction.commit(); + return result; + } +} + +/** 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..85ad24c82 --- /dev/null +++ b/integrations/test/firestore/firestore_session_service_test.ts @@ -0,0 +1,1053 @@ +/** + * @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, + splitStateDelta, + 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; +} + +/** 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('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: {}, + }); + }); +}); + +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(document.createTime).toBeInstanceOf(Timestamp); + expect(document.updateTime).toBeInstanceOf(Timestamp); + }); + + 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('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); + for (const event of events) { + expect(store.documents.has(eventPath('s1', event.id))).toBe(true); + } + }); + + it('does not serialize appends to different sessions', async () => { + const other = await service.createSession({ + appName: APP_NAME, + userId: USER_ID, + sessionId: 's2', + }); + const from = store.operations.length; + + await Promise.all([ + service.appendEvent({session, event: newEvent({id: 'e1'})}), + service.appendEvent({session: other, event: newEvent({id: 'e2'})}), + ]); + + const operations = store.operations.slice(from); + const firstWrite = operations.findIndex((operation) => + operation.startsWith('write '), + ); + expect(operations.slice(0, firstWrite)).toEqual([ + `read ${sessionPath('s1')}`, + `read ${sessionPath('s2')}`, + ]); + }); +}); diff --git a/integrations/test/firestore/session_lock_test.ts b/integrations/test/firestore/session_lock_test.ts new file mode 100644 index 000000000..00f4c06ed --- /dev/null +++ b/integrations/test/firestore/session_lock_test.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; + +import {SessionLockMap} from '../../src/firestore/session_lock.js'; + +/** Resolves on the next macrotask, after every pending microtask has run. */ +function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** A promise plus the function that resolves it. */ +function gate(): {wait: Promise; open: () => void} { + let open!: () => void; + const wait = new Promise((resolve) => { + open = resolve; + }); + return {wait, open}; +} + +describe('SessionLockMap', () => { + it('runs callers sharing a key one at a time', async () => { + const locks = new SessionLockMap(); + const order: string[] = []; + const first = gate(); + + const a = locks.run('k', async () => { + order.push('a:enter'); + await first.wait; + order.push('a:exit'); + }); + const b = locks.run('k', async () => { + order.push('b:enter'); + order.push('b:exit'); + }); + + await tick(); + expect(order).toEqual(['a:enter']); + + first.open(); + await Promise.all([a, b]); + expect(order).toEqual(['a:enter', 'a:exit', 'b:enter', 'b:exit']); + }); + + it('lets callers with different keys overlap', async () => { + const locks = new SessionLockMap(); + const order: string[] = []; + const first = gate(); + + const a = locks.run('a', async () => { + order.push('a:enter'); + await first.wait; + order.push('a:exit'); + }); + const b = locks.run('b', async () => { + order.push('b:enter'); + order.push('b:exit'); + }); + + expect(locks.size).toBe(2); + + await tick(); + expect(order).toEqual(['a:enter', 'b:enter', 'b:exit']); + + first.open(); + await Promise.all([a, b]); + expect(order).toEqual(['a:enter', 'b:enter', 'b:exit', 'a:exit']); + }); + + it('serializes a caller that arrives while the key is already held', async () => { + const locks = new SessionLockMap(); + const order: string[] = []; + const firstGate = gate(); + const secondGate = gate(); + + const a = locks.run('k', async () => { + order.push('a:enter'); + await firstGate.wait; + order.push('a:exit'); + }); + const b = locks.run('k', async () => { + order.push('b:enter'); + await secondGate.wait; + order.push('b:exit'); + }); + + firstGate.open(); + await tick(); + expect(order).toEqual(['a:enter', 'a:exit', 'b:enter']); + + // c joins after the first caller released but while the second still + // holds the key, which is what the reference count exists to handle. + const c = locks.run('k', async () => { + order.push('c:enter'); + order.push('c:exit'); + }); + await tick(); + expect(order).toEqual(['a:enter', 'a:exit', 'b:enter']); + + secondGate.open(); + await Promise.all([a, b, c]); + expect(order).toEqual([ + 'a:enter', + 'a:exit', + 'b:enter', + 'b:exit', + 'c:enter', + 'c:exit', + ]); + expect(locks.size).toBe(0); + }); + + it('holds one entry per key while callers are queued', async () => { + const locks = new SessionLockMap(); + expect(locks.size).toBe(0); + const held = gate(); + + const running = [ + locks.run('k', () => held.wait), + locks.run('k', () => held.wait), + locks.run('k', () => held.wait), + ]; + expect(locks.size).toBe(1); + + held.open(); + await Promise.all(running); + expect(locks.size).toBe(0); + }); + + it('releases the entry when the callback rejects, without wedging the key', async () => { + const locks = new SessionLockMap(); + + await expect( + locks.run('k', () => Promise.reject(new Error('boom'))), + ).rejects.toThrow('boom'); + expect(locks.size).toBe(0); + + await expect(locks.run('k', () => Promise.resolve('ok'))).resolves.toBe( + 'ok', + ); + expect(locks.size).toBe(0); + }); + + it('runs a queued caller after the caller ahead of it rejects', async () => { + const locks = new SessionLockMap(); + const order: string[] = []; + const failing = gate(); + + const a = locks.run('k', async () => { + order.push('a:enter'); + await failing.wait; + throw new Error('boom'); + }); + const b = locks.run('k', async () => { + order.push('b:enter'); + }); + + failing.open(); + await expect(a).rejects.toThrow('boom'); + await b; + expect(order).toEqual(['a:enter', 'b:enter']); + expect(locks.size).toBe(0); + }); + + it('returns the callback result', async () => { + const locks = new SessionLockMap(); + await expect(locks.run('k', () => Promise.resolve(42))).resolves.toBe(42); + }); +}); From b047ea68394327af994ef5ba650d2bdb5664986d Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 1 Aug 2026 09:51:26 -0700 Subject: [PATCH 5/7] docs(integrations): correct two unverified comments on the Firestore port The numRecentEvents === 0 short-circuit was justified as working around the Node SDK rejecting limitToLast(0). It does not: validateInteger only rejects non-integers, so limitToLast(0) is accepted. The short-circuit is still load-bearing for a different reason -- 0 is falsy, so without it the limit is skipped and every event comes back -- and returning no events is what VertexAiSessionService and the adk-python database, sqlite, in-memory and Vertex AI backends do. The 500-write batch cap is a Firestore server-side limit, not something the client enforces, so the comment no longer implies otherwise. --- .../src/firestore/firestore_session_service.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/integrations/src/firestore/firestore_session_service.ts b/integrations/src/firestore/firestore_session_service.ts index 63abde0ad..44f731051 100644 --- a/integrations/src/firestore/firestore_session_service.ts +++ b/integrations/src/firestore/firestore_session_service.ts @@ -57,7 +57,10 @@ export const USERS_COLLECTION = 'users'; /** Status written to a session document while it is being deleted. */ const DELETING_STATUS = 'DELETING'; -/** Firestore's limit on the number of writes in a single batch. */ +/** + * 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. */ @@ -376,8 +379,10 @@ export class FirestoreSessionService extends BaseSessionService { return undefined; } - // The Node SDK rejects `limitToLast(0)`, so an explicit zero has to skip - // the events query rather than flow through as a limit. + // numRecentEvents of 0 asks for no events, but it is falsy, so letting it + // reach fetchEvents would drop the limit and return every event instead. + // Returning nothing matches VertexAiSessionService and the adk-python + // database, sqlite, in-memory and Vertex AI backends. const [events, appSnapshot, userSnapshot] = await Promise.all([ config?.numRecentEvents === 0 ? Promise.resolve([]) From caf4bf8170ee853e2c17688f3ab2e5796461db92 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 1 Aug 2026 10:33:36 -0700 Subject: [PATCH 6/7] fix(integrations): let Firestore serialize appends instead of an in-process lock appendEvent derived the persisted session state from the caller's in-memory session.state rather than from the snapshot the transaction read. That put the written value outside the transaction's read set, so Firestore could not detect a conflicting concurrent write, and it let a stale caller clobber keys another writer had committed. Deriving it from data.state fixes both. Every value the transaction writes now comes from the session document it read, so Firestore aborts and re-runs the callback on conflict -- across processes, which the in-process lock never covered. SessionLockMap and its plumbing are deleted; the lock did not even close the race it existed for, because super.appendEvent merges the delta into session.state after the lock is released. It also matches DatabaseSessionService and InMemorySessionService, which both persist storage state plus the delta. The shared app/user state documents are no longer read inside the transaction: set(..., {merge: true}) merges field by field on the server, so the reads were dead work, and keeping those documents out of the read set stops unrelated sessions of one app aborting each other. The fake now tracks document versions, refuses a commit whose read set moved, and retries like the real client -- without that, the concurrent-append test passed against a fake weaker than Firestore. splitStateDelta moves to core beside its inverse mergeStates, replacing the two hand-rolled copies in DatabaseSessionService, and randomUUID comes from node:crypto rather than widening the @google/adk public API for one Node-only call site. --- core/src/sessions/base_session_service.ts | 31 +++ core/src/sessions/database_session_service.ts | 40 ++-- .../sessions/base_session_service_test.ts | 48 +++++ .../firestore/firestore_session_service.ts | 177 +++++++----------- integrations/src/firestore/session_lock.ts | 65 ------- integrations/test/firestore/fake_firestore.ts | 82 +++++++- .../firestore_session_service_test.ts | 99 ++++++---- .../test/firestore/session_lock_test.ts | 173 ----------------- 8 files changed, 291 insertions(+), 424 deletions(-) create mode 100644 core/test/sessions/base_session_service_test.ts delete mode 100644 integrations/src/firestore/session_lock.ts delete mode 100644 integrations/test/firestore/session_lock_test.ts 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/src/firestore/firestore_session_service.ts b/integrations/src/firestore/firestore_session_service.ts index 44f731051..d5efd8dfd 100644 --- a/integrations/src/firestore/firestore_session_service.ts +++ b/integrations/src/firestore/firestore_session_service.ts @@ -4,13 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {randomUUID} from 'node:crypto'; + import { CollectionReference, DocumentReference, Firestore, Query, Timestamp, - Transaction, } from '@google-cloud/firestore'; import { AppendEventRequest, @@ -25,14 +26,11 @@ import { ListSessionsRequest, ListSessionsResponse, mergeStates, - randomUUID, Session, - State, + splitStateDelta, trimTempDeltaState, } from '@google/adk'; -import {SessionLockMap} from './session_lock.js'; - /** Root collection used when neither the option nor the env var is set. */ export const DEFAULT_ROOT_COLLECTION = 'adk-session'; @@ -96,32 +94,6 @@ export interface FirestoreSessionServiceOptions { rootCollection?: string; } -/** - * Splits a state map into its app-scoped, user-scoped and session-scoped - * parts, stripping the `app:` and `user:` prefixes and dropping `temp:` keys. - */ -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}; -} - /** Narrows a value to a Firestore `Timestamp` without using `instanceof`. */ function isTimestamp(value: unknown): value is Timestamp { return ( @@ -162,29 +134,19 @@ function toEventData(event: Event): Record { return JSON.parse(JSON.stringify(event)); } -/** Identifies one session for {@link SessionLockMap}. */ -function sessionLockKey( - appName: string, - userId: string, - sessionId: string, -): string { - return JSON.stringify([appName, userId, sessionId]); -} - -/** Reads a shared-state document inside a transaction. */ -async function readStateDoc( - tx: Transaction, - ref: DocumentReference, -): Promise> { - const snapshot = await tx.get(ref); - return snapshot.data() ?? {}; -} - /** 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'); @@ -281,14 +243,13 @@ function paginateSessions( * user_states//users/ * ``` * - * Appends are atomic within a Firestore transaction and serialized per session - * within one process. Two processes appending to the same session can still - * lose an update. + * `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; - private readonly sessionLocks = new SessionLockMap(); constructor(options: FirestoreSessionServiceOptions = {}) { super(); @@ -331,16 +292,16 @@ export class FirestoreSessionService extends BaseSessionService { 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) { - Object.assign(currentApp, appDelta); - tx.set(appRef, currentApp, {merge: true}); + tx.set(appRef, appDelta, {merge: true}); } if (Object.keys(userDelta).length > 0) { - Object.assign(currentUser, userDelta); - tx.set(userRef, currentUser, {merge: true}); + tx.set(userRef, userDelta, {merge: true}); } tx.set(sessionRef, { @@ -361,7 +322,11 @@ export class FirestoreSessionService extends BaseSessionService { id, appName, userId, - state: mergeStates(appState, userState, sessionState), + state: mergeStates( + {...appState, ...appDelta}, + {...userState, ...userDelta}, + sessionState, + ), events: [], lastUpdateTime: nowMillis, }); @@ -379,14 +344,8 @@ export class FirestoreSessionService extends BaseSessionService { return undefined; } - // numRecentEvents of 0 asks for no events, but it is falsy, so letting it - // reach fetchEvents would drop the limit and return every event instead. - // Returning nothing matches VertexAiSessionService and the adk-python - // database, sqlite, in-memory and Vertex AI backends. const [events, appSnapshot, userSnapshot] = await Promise.all([ - config?.numRecentEvents === 0 - ? Promise.resolve([]) - : fetchEvents(sessionRef, config), + fetchEvents(sessionRef, config), this.appStateRef(appName).get(), this.userStateRef(appName, userId).get(), ]); @@ -490,54 +449,48 @@ export class FirestoreSessionService extends BaseSessionService { session.userId, session.id, ); - const appRef = this.appStateRef(session.appName); - const userRef = this.userStateRef(session.appName, session.userId); - - await this.sessionLocks.run( - sessionLockKey(session.appName, session.userId, session.id), - () => - 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.`, - ); - } - - const currentApp = hasAppDelta ? await readStateDoc(tx, appRef) : {}; - const currentUser = hasUserDelta - ? await readStateDoc(tx, userRef) - : {}; - - if (hasAppDelta) { - tx.set(appRef, {...currentApp, ...appDelta}, {merge: true}); - } - if (hasUserDelta) { - tx.set(userRef, {...currentUser, ...userDelta}, {merge: true}); - } - - tx.update(sessionRef, { - state: JSON.stringify({ - ...splitStateDelta(session.state).session, - ...sessionDelta, - }), - updateTime: Timestamp.fromMillis(trimmed.timestamp), - revision: (data.revision ?? 0) + 1, - }); - - 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, - }); + + 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}); diff --git a/integrations/src/firestore/session_lock.ts b/integrations/src/firestore/session_lock.ts deleted file mode 100644 index 0eb785732..000000000 --- a/integrations/src/firestore/session_lock.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -interface LockEntry { - /** Resolves once the last caller queued on this key has finished. */ - tail: Promise; - /** Callers currently holding or waiting on this key. */ - refCount: number; -} - -/** - * A map of per-key mutexes that serializes async work sharing a key. - * - * Entries are reference counted and removed once the last caller releases, so - * a long-lived process does not accumulate one entry per key it has ever - * locked. - */ -export class SessionLockMap { - private readonly entries = new Map(); - - /** - * Runs `fn` with exclusive access to `key`. - * - * Callers sharing a key run one at a time, in the order they called; callers - * with different keys are unaffected. - */ - async run(key: string, fn: () => Promise): Promise { - // Python's equivalent needs a guard mutex because another task can - // interleave at an await. This lookup-and-insert has no await, so it is - // already atomic. - let entry = this.entries.get(key); - if (!entry) { - entry = {tail: Promise.resolve(), refCount: 0}; - this.entries.set(key, entry); - } - entry.refCount++; - - const predecessor = entry.tail; - let release!: () => void; - entry.tail = new Promise((resolve) => { - release = resolve; - }); - - await predecessor; - try { - return await fn(); - } finally { - release(); - entry.refCount--; - // Deleting unconditionally would let a queued waiter run against a lock - // no later caller can see. - if (entry.refCount === 0) { - this.entries.delete(key); - } - } - } - - /** Number of live lock entries. Zero once every caller has released. */ - get size(): number { - return this.entries.size; - } -} diff --git a/integrations/test/firestore/fake_firestore.ts b/integrations/test/firestore/fake_firestore.ts index 2e7b591a8..186e45c44 100644 --- a/integrations/test/firestore/fake_firestore.ts +++ b/integrations/test/firestore/fake_firestore.ts @@ -24,6 +24,9 @@ interface QuerySpec { /** 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 @@ -36,6 +39,13 @@ const TIMESTAMP_FIELD = 'timestamp'; 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[] = []; @@ -48,6 +58,18 @@ export class FakeStore { /** 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; @@ -55,12 +77,14 @@ export class FakeStore { /** 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}); } @@ -68,6 +92,7 @@ export class FakeStore { /** 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( @@ -79,6 +104,7 @@ export class FakeStore { /** Removes a document, if present. */ delete(path: string): void { + this.bump(path); this.docs.delete(path); } @@ -150,10 +176,20 @@ 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 (!(value instanceof Timestamp)) { + if (!isTimestamp(value)) { throw new Error( `fake Firestore: document is missing a Timestamp '${TIMESTAMP_FIELD}'`, ); @@ -283,17 +319,31 @@ 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(); } @@ -301,6 +351,9 @@ class FakeTransaction { ...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())); } @@ -319,6 +372,11 @@ class FakeTransaction { } 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(); } @@ -359,10 +417,24 @@ class FakeFirestoreClient { async runTransaction( updateFunction: (transaction: FakeTransaction) => Promise, ): Promise { - const transaction = new FakeTransaction(this.store); - const result = await updateFunction(transaction); - transaction.commit(); - return result; + // 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++; + } + } } } diff --git a/integrations/test/firestore/firestore_session_service_test.ts b/integrations/test/firestore/firestore_session_service_test.ts index 85ad24c82..eb134dbd4 100644 --- a/integrations/test/firestore/firestore_session_service_test.ts +++ b/integrations/test/firestore/firestore_session_service_test.ts @@ -21,7 +21,6 @@ import { EVENTS_COLLECTION, ROOT_COLLECTION_ENV_VAR, SESSIONS_COLLECTION, - splitStateDelta, USER_STATE_COLLECTION, USERS_COLLECTION, } from '../../src/firestore/firestore_session_service.js'; @@ -51,6 +50,14 @@ 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)) { @@ -148,31 +155,6 @@ describe('collection constants', () => { }); }); -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: {}, - }); - }); -}); - describe('FirestoreSessionService root collection', () => { it('defaults to adk-session', async () => { const session = await service.createSession({ @@ -265,8 +247,8 @@ describe('FirestoreSessionService.createSession', () => { expect(document.userId).toBe(USER_ID); expect(document.revision).toBe(0); expect(document.state).toBe(JSON.stringify({turn: 1})); - expect(document.createTime).toBeInstanceOf(Timestamp); - expect(document.updateTime).toBeInstanceOf(Timestamp); + expect(toMillis(document.createTime)).toBe(toMillis(document.updateTime)); + expect(toMillis(document.updateTime)).toBe(session.lastUpdateTime); }); it('honours an explicit session id', async () => { @@ -959,6 +941,36 @@ describe('FirestoreSessionService.appendEvent', () => { 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', @@ -1023,31 +1035,38 @@ describe('FirestoreSessionService.appendEvent', () => { ); 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('does not serialize appends to different sessions', async () => { + 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', }); - const from = store.operations.length; + store.transactionRetryCount = 0; await Promise.all([ - service.appendEvent({session, event: newEvent({id: 'e1'})}), - service.appendEvent({session: other, event: newEvent({id: 'e2'})}), + service.appendEvent({ + session, + event: eventWithDelta('e1', {'app:theme': 'dark'}), + }), + service.appendEvent({ + session: other, + event: eventWithDelta('e2', {'app:locale': 'en'}), + }), ]); - const operations = store.operations.slice(from); - const firstWrite = operations.findIndex((operation) => - operation.startsWith('write '), - ); - expect(operations.slice(0, firstWrite)).toEqual([ - `read ${sessionPath('s1')}`, - `read ${sessionPath('s2')}`, - ]); + // 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/firestore/session_lock_test.ts b/integrations/test/firestore/session_lock_test.ts deleted file mode 100644 index 00f4c06ed..000000000 --- a/integrations/test/firestore/session_lock_test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {describe, expect, it} from 'vitest'; - -import {SessionLockMap} from '../../src/firestore/session_lock.js'; - -/** Resolves on the next macrotask, after every pending microtask has run. */ -function tick(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -/** A promise plus the function that resolves it. */ -function gate(): {wait: Promise; open: () => void} { - let open!: () => void; - const wait = new Promise((resolve) => { - open = resolve; - }); - return {wait, open}; -} - -describe('SessionLockMap', () => { - it('runs callers sharing a key one at a time', async () => { - const locks = new SessionLockMap(); - const order: string[] = []; - const first = gate(); - - const a = locks.run('k', async () => { - order.push('a:enter'); - await first.wait; - order.push('a:exit'); - }); - const b = locks.run('k', async () => { - order.push('b:enter'); - order.push('b:exit'); - }); - - await tick(); - expect(order).toEqual(['a:enter']); - - first.open(); - await Promise.all([a, b]); - expect(order).toEqual(['a:enter', 'a:exit', 'b:enter', 'b:exit']); - }); - - it('lets callers with different keys overlap', async () => { - const locks = new SessionLockMap(); - const order: string[] = []; - const first = gate(); - - const a = locks.run('a', async () => { - order.push('a:enter'); - await first.wait; - order.push('a:exit'); - }); - const b = locks.run('b', async () => { - order.push('b:enter'); - order.push('b:exit'); - }); - - expect(locks.size).toBe(2); - - await tick(); - expect(order).toEqual(['a:enter', 'b:enter', 'b:exit']); - - first.open(); - await Promise.all([a, b]); - expect(order).toEqual(['a:enter', 'b:enter', 'b:exit', 'a:exit']); - }); - - it('serializes a caller that arrives while the key is already held', async () => { - const locks = new SessionLockMap(); - const order: string[] = []; - const firstGate = gate(); - const secondGate = gate(); - - const a = locks.run('k', async () => { - order.push('a:enter'); - await firstGate.wait; - order.push('a:exit'); - }); - const b = locks.run('k', async () => { - order.push('b:enter'); - await secondGate.wait; - order.push('b:exit'); - }); - - firstGate.open(); - await tick(); - expect(order).toEqual(['a:enter', 'a:exit', 'b:enter']); - - // c joins after the first caller released but while the second still - // holds the key, which is what the reference count exists to handle. - const c = locks.run('k', async () => { - order.push('c:enter'); - order.push('c:exit'); - }); - await tick(); - expect(order).toEqual(['a:enter', 'a:exit', 'b:enter']); - - secondGate.open(); - await Promise.all([a, b, c]); - expect(order).toEqual([ - 'a:enter', - 'a:exit', - 'b:enter', - 'b:exit', - 'c:enter', - 'c:exit', - ]); - expect(locks.size).toBe(0); - }); - - it('holds one entry per key while callers are queued', async () => { - const locks = new SessionLockMap(); - expect(locks.size).toBe(0); - const held = gate(); - - const running = [ - locks.run('k', () => held.wait), - locks.run('k', () => held.wait), - locks.run('k', () => held.wait), - ]; - expect(locks.size).toBe(1); - - held.open(); - await Promise.all(running); - expect(locks.size).toBe(0); - }); - - it('releases the entry when the callback rejects, without wedging the key', async () => { - const locks = new SessionLockMap(); - - await expect( - locks.run('k', () => Promise.reject(new Error('boom'))), - ).rejects.toThrow('boom'); - expect(locks.size).toBe(0); - - await expect(locks.run('k', () => Promise.resolve('ok'))).resolves.toBe( - 'ok', - ); - expect(locks.size).toBe(0); - }); - - it('runs a queued caller after the caller ahead of it rejects', async () => { - const locks = new SessionLockMap(); - const order: string[] = []; - const failing = gate(); - - const a = locks.run('k', async () => { - order.push('a:enter'); - await failing.wait; - throw new Error('boom'); - }); - const b = locks.run('k', async () => { - order.push('b:enter'); - }); - - failing.open(); - await expect(a).rejects.toThrow('boom'); - await b; - expect(order).toEqual(['a:enter', 'b:enter']); - expect(locks.size).toBe(0); - }); - - it('returns the callback result', async () => { - const locks = new SessionLockMap(); - await expect(locks.run('k', () => Promise.resolve(42))).resolves.toBe(42); - }); -}); From 2b8ed717b9bb5d34aabc608e525aa1ebed22781c Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 1 Aug 2026 10:38:22 -0700 Subject: [PATCH 7/7] chore(core): drop the randomUUID re-export The Firestore backend is the only consumer and it is Node-only, so it takes randomUUID from node:crypto rather than permanently widening the @google/adk public API for one call site. --- core/src/common.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/common.ts b/core/src/common.ts index ac0161b3d..23f628165 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -280,7 +280,6 @@ export type { } from './tools/vertex_ai_search_tool.js'; export {VertexRagRetrievalTool} from './tools/vertex_rag_retrieval_tool.js'; export {getClientLabels, runWithClientLabel} from './utils/client_labels.js'; -export {randomUUID} from './utils/env_aware_utils.js'; export {LogLevel, getLogger, setLogLevel, setLogger} from './utils/logger.js'; export type {Logger} from './utils/logger.js'; export {isGemini2OrAbove, isGemini3xFlashLive} from './utils/model_name.js';